diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index 2e44a54..9a5aa8f 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -118,7 +118,7 @@ function sanitizeDocumentUrls(input: unknown): string[] { // 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 = {}; + const where: Record = { isActive: true }; if (!isLandlord) where.userId = req.user!.id; const contracts = await prisma.contract.findMany({ @@ -272,3 +272,56 @@ contractsRouter.post('/contracts/:id/sign', requireAuth, async (req: AuthedReque }); res.status(200).json({ contract: updated }); }); + +// ---------------------------------------------------------------------------- +// MIETER AUSZIEHEN LASSEN (Archiv statt Löschen) +// ---------------------------------------------------------------------------- + +// Vermieter/Admin: Mieter aus dem Zimmer entfernen. Der Vertrag wird NICHT +// gelöscht, sondern nur deaktiviert (isActive=false, endDate gesetzt) und +// bleibt damit samt aller verknüpften Daten (Zahlungen, Tickets, Bewertungen, +// Übergabeprotokolle, Vertragsdokumente ...) dauerhaft einsehbar — siehe +// GET /contracts/archive. Das Zimmer wird wieder als frei markiert und kann +// über die Einladungsfunktion neu vergeben werden. +contractsRouter.post( + '/contracts/:id/move-out', + 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' }); + if (!contract.isActive) return res.status(400).json({ error: 'Vertrag ist bereits inaktiv' }); + + const moveOutDate = new Date(); + + await prisma.$transaction([ + prisma.contract.update({ + where: { id: contract.id }, + data: { isActive: false, endDate: contract.endDate ?? moveOutDate }, + }), + prisma.user.update({ + where: { id: contract.userId }, + data: { roomId: null }, + }), + prisma.room.update({ + where: { id: contract.roomId }, + data: { status: 'VACANT' }, + }), + ]); + + const updated = await prisma.contract.findUnique({ where: { id: contract.id }, select: CONTRACT_DOCUMENTS_SELECT }); + res.status(200).json({ contract: updated }); + }, +); + +// Vermieter/Admin: Archiv ausgezogener Mieter — nichts wird gelöscht, diese +// Route macht die bereits deaktivierten Verträge (samt Dokumenten) dauerhaft +// einsehbar. +contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => { + const contracts = await prisma.contract.findMany({ + where: { isActive: false }, + select: CONTRACT_DOCUMENTS_SELECT, + orderBy: { endDate: 'desc' }, + }); + res.status(200).json({ contracts }); +}); diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 00d700b..e709640 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -956,6 +956,12 @@
+ +

Kündigungsfristen

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

@@ -1257,6 +1263,7 @@ loadCraftsmen(); loadRatings(); loadContractDocuments(); + loadContractArchive(); setInterval(loadData, REFRESH_INTERVAL_MS); setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS); @@ -1272,6 +1279,7 @@ setInterval(loadCraftsmen, REFRESH_INTERVAL_MS); setInterval(loadRatings, REFRESH_INTERVAL_MS); setInterval(loadContractDocuments, REFRESH_INTERVAL_MS); + setInterval(loadContractArchive, REFRESH_INTERVAL_MS); } // --------------------------------------------------------------------- @@ -2840,12 +2848,80 @@ uploadRow.appendChild(uploadBtn); card.appendChild(uploadRow); + + const moveOutBtn = document.createElement('button'); + moveOutBtn.type = 'button'; + moveOutBtn.className = 'btn-secondary'; + moveOutBtn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)'; + moveOutBtn.style.cssText = 'align-self:flex-start; color:var(--red); border-color:var(--red);'; + moveOutBtn.onclick = () => moveOutTenant(c.id, c.user.fullName, c.room.roomNumber, moveOutBtn); + card.appendChild(moveOutBtn); } list.appendChild(card); }); } + async function moveOutTenant(contractId, tenantName, roomNumber, btn) { + btn.disabled = true; + btn.textContent = 'Wird bearbeitet…'; + try { + await apiFetch(`/contracts/${contractId}/move-out`, { method: 'POST' }); + loadContractDocuments(); + loadContractArchive(); + } catch (err) { + alert(err.message); + btn.disabled = false; + btn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)'; + } + } + + async function loadContractArchive() { + const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; + const section = document.getElementById('contractArchiveSection'); + if (!isLandlord) { section.style.display = 'none'; return; } + section.style.display = 'block'; + try { + const data = await apiFetch('/contracts/archive'); + renderContractArchive(data.contracts); + } catch (err) { + console.error('[contracts] Archiv laden fehlgeschlagen:', err.message); + } + } + + function renderContractArchive(contracts) { + const list = document.getElementById('contractArchiveList'); + list.innerHTML = ''; + if (!contracts.length) { + list.appendChild(el('div', 'empty-state', 'Noch keine ausgezogenen Mieter.')); + return; + } + contracts.forEach(c => { + const row = el('div', 'ticket-row'); + const left = el('div'); + left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`)); + left.appendChild(el('div', 'meta-text', + `${new Date(c.startDate).toLocaleDateString('de-DE')} – ${c.endDate ? new Date(c.endDate).toLocaleDateString('de-DE') : '–'} · ` + + `${c.contractDocumentUrls.length} Dokument(e)`)); + row.appendChild(left); + if (c.contractDocumentUrls.length) { + const docWrap = el('div'); + docWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;'; + c.contractDocumentUrls.forEach((url, idx) => { + const link = document.createElement('a'); + link.href = url; + link.download = documentFileName(url, idx); + link.textContent = documentFileName(url, idx); + link.className = 'invite-status-chip'; + link.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none;'; + docWrap.appendChild(link); + }); + row.appendChild(docWrap); + } + list.appendChild(row); + }); + } + function buildContractTemplateForm(c) { const form = el('div', 'contract-template-form');