From ab8e9058e16c18961302dce1a29ee3badb4dc02c Mon Sep 17 00:00:00 2001 From: bernd Date: Thu, 13 Aug 2026 18:09:09 +0000 Subject: [PATCH] =?UTF-8?q?Mieterdaten:=20Vertrags-Eckdaten=20anzeigen=20u?= =?UTF-8?q?nd=20f=C3=BCr=20Vermieter=20jederzeit=20editierbar=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/contracts.ts | 69 ++++++++++++++++- web-dashboard/index.html | 126 ++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index 1c6e091..433a599 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -16,9 +16,10 @@ export async function findOverlappingContract( roomId: string, startDate: Date, endDate: Date | null, + excludeContractId?: string, ) { const activeContracts = await client.contract.findMany({ - where: { roomId, isActive: true }, + where: { roomId, isActive: true, id: excludeContractId ? { not: excludeContractId } : undefined }, }); return ( @@ -166,6 +167,72 @@ contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedReque res.status(200).json({ contracts }); }); +// Vermieter/Admin: Vertrags-Eckdaten (Mietbeginn/-ende, Warmmiete, Kaution, +// Fälligkeit, Kündigungsfrist) jederzeit nachträglich bearbeiten — dieselben +// Felder, die bei der Vertragserstellung (Einladung/Wiederaufnehmen) einmalig +// festgelegt werden. Änderungen an Zeitraum/Zimmer durchlaufen dieselbe +// Plausibilitätsprüfung wie bei der Neuanlage (siehe findOverlappingContract). +contractsRouter.patch( + '/contracts/:id', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { startDate, endDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {}; + + const existing = await prisma.contract.findUnique({ where: { id: req.params.id } }); + if (!existing) return res.status(404).json({ error: 'Vertrag nicht gefunden' }); + + if (startDate !== undefined && Number.isNaN(Date.parse(startDate))) { + return res.status(400).json({ error: 'startDate ist ungültig' }); + } + if (endDate !== undefined && endDate !== null && Number.isNaN(Date.parse(endDate))) { + return res.status(400).json({ error: 'endDate ist ungültig' }); + } + if (totalWarmRent !== undefined && (!Number.isFinite(Number(totalWarmRent)) || Number(totalWarmRent) <= 0)) { + return res.status(400).json({ error: 'totalWarmRent muss eine positive Zahl sein' }); + } + if (depositAmount !== undefined && (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0)) { + return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' }); + } + if (paymentDueDay !== undefined && paymentDueDay !== null && ![1, 15].includes(Number(paymentDueDay))) { + return res.status(400).json({ error: 'paymentDueDay muss 1 oder 15 sein' }); + } + if ( + noticePeriodMonths !== undefined && + (!Number.isFinite(Number(noticePeriodMonths)) || Number(noticePeriodMonths) < 0) + ) { + return res.status(400).json({ error: 'noticePeriodMonths muss eine Zahl sein' }); + } + + const parsedStart = startDate !== undefined ? new Date(startDate) : existing.startDate; + const parsedEnd = endDate !== undefined ? (endDate ? new Date(endDate) : null) : existing.endDate; + + if (startDate !== undefined || endDate !== undefined) { + const overlap = await findOverlappingContract(prisma, existing.roomId, parsedStart, parsedEnd, existing.id); + if (overlap) { + const overlapUntil = overlap.endDate ? overlap.endDate.toLocaleDateString('de-DE') : 'unbefristet'; + return res.status(409).json({ + error: `Das Zimmer ist im gewählten Zeitraum bereits vergeben (belegt bis ${overlapUntil}).`, + }); + } + } + + const updated = await prisma.contract.update({ + where: { id: existing.id }, + data: { + startDate: startDate !== undefined ? parsedStart : undefined, + endDate: endDate !== undefined ? parsedEnd : undefined, + totalWarmRent: totalWarmRent !== undefined ? Number(totalWarmRent) : undefined, + depositAmount: depositAmount !== undefined ? Number(depositAmount) : undefined, + paymentDueDay: paymentDueDay !== undefined ? Number(paymentDueDay) : undefined, + noticePeriodMonths: noticePeriodMonths !== undefined ? Number(noticePeriodMonths) : undefined, + }, + select: CONTRACT_DOCUMENTS_SELECT, + }); + res.status(200).json({ contract: updated }); + }, +); + // Vermieter/Admin: Dateien zu einem Vertrag hinzufügen (mehrere auf einmal, // werden an bereits vorhandene angehängt statt sie zu ersetzen). contractsRouter.post( diff --git a/web-dashboard/index.html b/web-dashboard/index.html index cdfd176..3a358e7 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -3634,10 +3634,136 @@ card.appendChild(top); card.appendChild(buildProfileCompletenessBadge(c.user)); + card.appendChild(buildContractEckdatenForm(c)); list.appendChild(card); }); } + // Editierbare Vertrags-Eckdaten (dieselben Felder wie bei der + // Vertragserstellung: Mietbeginn/-ende, Warmmiete, Kaution, Fälligkeit, + // Kündigungsfrist) — jederzeit vom Vermieter über PATCH /contracts/:id + // änderbar, inkl. serverseitiger Plausibilitätsprüfung bei Datumsänderung. + function buildContractEckdatenForm(c) { + const form = el('div', 'contract-template-form'); + + const startField = el('div', 'field'); + startField.style.minWidth = '150px'; + startField.appendChild(el('label', null, 'Mietbeginn')); + const startInput = document.createElement('input'); + startInput.type = 'date'; + startInput.value = c.startDate ? c.startDate.slice(0, 10) : ''; + startField.appendChild(startInput); + form.appendChild(startField); + + const endField = el('div', 'field'); + endField.style.minWidth = '150px'; + endField.appendChild(el('label', null, 'Mietende')); + const endInput = document.createElement('input'); + endInput.type = 'date'; + endInput.value = c.endDate ? c.endDate.slice(0, 10) : ''; + endInput.disabled = !c.endDate; + endField.appendChild(endInput); + const unbefristetLabel = el('label', null, ' unbefristet'); + unbefristetLabel.style.cssText = 'font-weight:normal; margin-top:4px; display:block;'; + const unbefristetCheckbox = document.createElement('input'); + unbefristetCheckbox.type = 'checkbox'; + unbefristetCheckbox.checked = !c.endDate; + unbefristetCheckbox.onchange = () => { + endInput.disabled = unbefristetCheckbox.checked; + if (unbefristetCheckbox.checked) endInput.value = ''; + }; + unbefristetLabel.prepend(unbefristetCheckbox); + endField.appendChild(unbefristetLabel); + form.appendChild(endField); + + const rentField = el('div', 'field'); + rentField.style.width = '120px'; + rentField.appendChild(el('label', null, 'Warmmiete (€)')); + const rentInput = document.createElement('input'); + rentInput.type = 'number'; + rentInput.min = '0'; + rentInput.step = '0.01'; + rentInput.value = c.totalWarmRent; + rentField.appendChild(rentInput); + form.appendChild(rentField); + + const depositField = el('div', 'field'); + depositField.style.width = '120px'; + depositField.appendChild(el('label', null, 'Kaution (€)')); + const depositInput = document.createElement('input'); + depositInput.type = 'number'; + depositInput.min = '0'; + depositInput.step = '0.01'; + depositInput.value = c.depositAmount; + depositField.appendChild(depositInput); + form.appendChild(depositField); + + const dueDayField = el('div', 'field'); + dueDayField.style.minWidth = '140px'; + dueDayField.appendChild(el('label', null, 'Fälligkeit der Miete')); + const dueDaySelect = document.createElement('select'); + [[1, 'Immer zum 1.'], [15, 'Immer zum 15.']].forEach(([value, label]) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + if (Number(c.paymentDueDay) === value) opt.selected = true; + dueDaySelect.appendChild(opt); + }); + dueDayField.appendChild(dueDaySelect); + form.appendChild(dueDayField); + + const noticeField = el('div', 'field'); + noticeField.style.width = '140px'; + noticeField.appendChild(el('label', null, 'Kündigungsfrist (Monate)')); + const noticeInput = document.createElement('input'); + noticeInput.type = 'number'; + noticeInput.min = '0'; + noticeInput.value = c.noticePeriodMonths; + noticeField.appendChild(noticeInput); + form.appendChild(noticeField); + + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'btn-primary'; + saveBtn.style.width = 'auto'; + saveBtn.textContent = 'Speichern'; + const errorBox = el('div', 'form-error'); + errorBox.style.display = 'none'; + + saveBtn.onclick = async () => { + errorBox.style.display = 'none'; + saveBtn.disabled = true; + saveBtn.textContent = 'Speichert…'; + try { + await apiFetch(`/contracts/${c.id}`, { + method: 'PATCH', + body: JSON.stringify({ + startDate: startInput.value, + endDate: unbefristetCheckbox.checked ? null : (endInput.value || null), + totalWarmRent: rentInput.value, + depositAmount: depositInput.value, + paymentDueDay: Number(dueDaySelect.value), + noticePeriodMonths: noticeInput.value, + }), + }); + loadTenantData(); + loadContractDocuments(); + } catch (err) { + errorBox.textContent = err.message; + errorBox.style.display = 'block'; + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Speichern'; + } + }; + form.appendChild(saveBtn); + + const wrap = el('div'); + wrap.appendChild(form); + wrap.appendChild(errorBox); + return wrap; + } + function renderContractDocuments(contracts) { const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN'; const list = document.getElementById('contractDocumentsList');