From a8dc999180586dd2c2568e966d3c0b22ca4e060c Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 09:52:56 +0000 Subject: [PATCH] =?UTF-8?q?Add=20multi-file=20contract=20document=20upload?= =?UTF-8?q?s=20(Vertr=C3=A4ge=20&=20Abrechnung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the unused, never-wired-up single contractPdfUrl field with contractDocumentUrls (String[]) so landlords can attach several files per contract (signed lease, addenda, ...) instead of just one. New endpoints: GET /contracts/documents (tenant sees own contract only, landlord sees all — same visibility rule as the rest of the app), POST/DELETE /contracts/:id/documents (landlord/admin only). Frontend reuses the existing FileReader-to-data-URL upload pattern already used for inventory photos, so files are stored inline like everywhere else in this app rather than introducing a new storage mechanism. --- backend/src/routes/contracts.ts | 83 ++++++++++++++++++- prisma/schema.prisma | 2 +- web-dashboard/index.html | 142 ++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index ddb1f68..86b89f6 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -1,6 +1,6 @@ import { Router, Response } from 'express'; import { PrismaClient } from '@prisma/client'; -import { AuthedRequest, requireAuth } from '../middleware/auth'; +import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; const prisma = new PrismaClient(); @@ -75,3 +75,84 @@ contractsRouter.get('/contracts/notice-deadlines', requireAuth, async (req: Auth res.status(200).json({ deadlines: items }); }); + +// ---------------------------------------------------------------------------- +// VERTRAGS-DOKUMENTE (unterschriebener Vertrag, Nachträge etc.) +// ---------------------------------------------------------------------------- + +const MAX_CONTRACT_DOCUMENTS = 10; + +const CONTRACT_DOCUMENTS_SELECT = { + id: true, + startDate: true, + endDate: true, + isActive: true, + contractDocumentUrls: true, + user: { select: { id: true, fullName: true } }, + room: { select: { id: true, roomNumber: true } }, +}; + +function sanitizeDocumentUrls(input: unknown): string[] { + if (!Array.isArray(input)) return []; + return input.filter((u) => typeof u === 'string' && u.trim()).map((u) => (u as string).trim()); +} + +// Mieter sehen nur den eigenen Vertrag, Vermieter/Admin alle — dieselbe +// Sichtbarkeitsregel wie bei allen anderen privaten Vertragsdaten. +contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => { + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + const where: Record = {}; + if (!isLandlord) where.userId = req.user!.id; + + const contracts = await prisma.contract.findMany({ + where, + select: CONTRACT_DOCUMENTS_SELECT, + orderBy: { startDate: 'desc' }, + }); + res.status(200).json({ contracts }); +}); + +// Vermieter/Admin: Dateien zu einem Vertrag hinzufügen (mehrere auf einmal, +// werden an bereits vorhandene angehängt statt sie zu ersetzen). +contractsRouter.post( + '/contracts/:id/documents', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const contract = await prisma.contract.findUnique({ where: { id: req.params.id } }); + if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); + + const newUrls = sanitizeDocumentUrls(req.body?.urls); + if (!newUrls.length) return res.status(400).json({ error: 'urls (Array) ist erforderlich' }); + + const merged = [...contract.contractDocumentUrls, ...newUrls].slice(0, MAX_CONTRACT_DOCUMENTS); + const updated = await prisma.contract.update({ + where: { id: contract.id }, + data: { contractDocumentUrls: merged }, + select: CONTRACT_DOCUMENTS_SELECT, + }); + res.status(200).json({ contract: updated }); + }, +); + +// Vermieter/Admin: einzelne Datei aus einem Vertrag entfernen. +contractsRouter.delete( + '/contracts/:id/documents', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const contract = await prisma.contract.findUnique({ where: { id: req.params.id } }); + if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); + + const { url } = req.body || {}; + if (!url || typeof url !== 'string') return res.status(400).json({ error: 'url ist erforderlich' }); + + const remaining = contract.contractDocumentUrls.filter((u) => u !== url); + const updated = await prisma.contract.update({ + where: { id: contract.id }, + data: { contractDocumentUrls: remaining }, + select: CONTRACT_DOCUMENTS_SELECT, + }); + res.status(200).json({ contract: updated }); + }, +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 846502f..91c9b66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -270,7 +270,7 @@ model Contract { isActive Boolean @default(true) @map("is_active") - contractPdfUrl String? @map("contract_pdf_url") + contractDocumentUrls String[] @default([]) @map("contract_document_urls") // unterschriebener Vertrag, Nachträge etc. (mehrere Dateien möglich) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/web-dashboard/index.html b/web-dashboard/index.html index b64d954..c819efd 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -933,6 +933,12 @@
+
+

Vertragsdokumente

+

Unterschriebener Mietvertrag, Nachträge etc. — je Vertrag können mehrere Dateien hinterlegt werden.

+
+
+

Kündigungsfristen

Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.

@@ -1231,6 +1237,7 @@ loadUtilityStatements(); loadCraftsmen(); loadRatings(); + loadContractDocuments(); setInterval(loadData, REFRESH_INTERVAL_MS); setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS); @@ -1245,6 +1252,7 @@ setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS); setInterval(loadCraftsmen, REFRESH_INTERVAL_MS); setInterval(loadRatings, REFRESH_INTERVAL_MS); + setInterval(loadContractDocuments, REFRESH_INTERVAL_MS); } // --------------------------------------------------------------------- @@ -2700,6 +2708,140 @@ } } + // --------------------------------------------------------------------- + // VERTRAGSDOKUMENTE + // --------------------------------------------------------------------- + + const MAX_CONTRACT_DOCUMENTS = 10; + const contractUploadFiles = {}; // contractId -> ausgewählte, noch nicht hochgeladene Files + + async function loadContractDocuments() { + try { + const data = await apiFetch('/contracts/documents'); + renderContractDocuments(data.contracts); + } catch (err) { + console.error('[contracts] Vertragsdokumente laden fehlgeschlagen:', err.message); + } + } + + function documentFileName(dataUrl, index) { + const match = /^data:([^;]+);/.exec(dataUrl); + const mime = match ? match[1] : ''; + const ext = mime.includes('pdf') ? 'pdf' : (mime.split('/')[1] || 'datei'); + return `Dokument ${index + 1}.${ext}`; + } + + function renderContractDocuments(contracts) { + const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; + const list = document.getElementById('contractDocumentsList'); + list.innerHTML = ''; + if (!contracts.length) { + list.appendChild(el('div', 'empty-state', 'Kein aktiver Mietvertrag gefunden.')); + return; + } + + contracts.forEach(c => { + const card = el('div', 'ticket-row'); + card.style.flexDirection = 'column'; + card.style.alignItems = 'stretch'; + card.style.gap = '10px'; + + const top = el('div'); + top.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`)); + top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`)); + card.appendChild(top); + + const docWrap = el('div'); + docWrap.style.display = 'flex'; + docWrap.style.flexWrap = 'wrap'; + docWrap.style.gap = '8px'; + if (!c.contractDocumentUrls.length) { + docWrap.appendChild(el('span', 'meta-text', 'Noch keine Dateien hochgeladen.')); + } else { + c.contractDocumentUrls.forEach((url, idx) => { + const chip = el('span', 'invite-status-chip'); + chip.style.cssText = 'background:var(--bg); color:var(--text); display:inline-flex; align-items:center; gap:6px; text-transform:none;'; + const link = document.createElement('a'); + link.href = url; + link.download = documentFileName(url, idx); + link.textContent = documentFileName(url, idx); + link.style.color = 'var(--text)'; + chip.appendChild(link); + if (isLandlord) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.textContent = '×'; + removeBtn.title = 'Entfernen'; + removeBtn.style.cssText = 'border:none; background:none; cursor:pointer; font-size:13px; color:var(--text-muted); padding:0;'; + removeBtn.onclick = () => deleteContractDocument(c.id, url); + chip.appendChild(removeBtn); + } + docWrap.appendChild(chip); + }); + } + card.appendChild(docWrap); + + if (isLandlord) { + const uploadRow = el('div'); + uploadRow.style.display = 'flex'; + uploadRow.style.gap = '8px'; + uploadRow.style.alignItems = 'center'; + uploadRow.style.flexWrap = 'wrap'; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.accept = 'application/pdf,image/*'; + fileInput.onchange = () => { + contractUploadFiles[c.id] = Array.from(fileInput.files || []); + }; + uploadRow.appendChild(fileInput); + + const uploadBtn = document.createElement('button'); + uploadBtn.type = 'button'; + uploadBtn.className = 'btn-secondary'; + uploadBtn.textContent = 'Hochladen'; + uploadBtn.onclick = () => uploadContractDocuments(c.id, fileInput, uploadBtn); + uploadRow.appendChild(uploadBtn); + + card.appendChild(uploadRow); + } + + list.appendChild(card); + }); + } + + async function uploadContractDocuments(contractId, fileInput, uploadBtn) { + const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS); + if (!files.length) return; + uploadBtn.disabled = true; + uploadBtn.textContent = 'Lädt hoch…'; + try { + const urls = []; + for (const file of files) { + urls.push(await readFileAsDataUrl(file)); + } + await apiFetch(`/contracts/${contractId}/documents`, { method: 'POST', body: JSON.stringify({ urls }) }); + delete contractUploadFiles[contractId]; + fileInput.value = ''; + loadContractDocuments(); + } catch (err) { + alert(err.message); + } finally { + uploadBtn.disabled = false; + uploadBtn.textContent = 'Hochladen'; + } + } + + async function deleteContractDocument(contractId, url) { + try { + await apiFetch(`/contracts/${contractId}/documents`, { method: 'DELETE', body: JSON.stringify({ url }) }); + loadContractDocuments(); + } catch (err) { + alert(err.message); + } + } + // --------------------------------------------------------------------- // KÜNDIGUNGSFRISTEN // ---------------------------------------------------------------------