diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 7bb4ae1..4fc47da 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -70,7 +70,8 @@ async function main() { userId: tenant.id, roomId: room.id, startDate: new Date('2025-10-01'), - totalWarmRent, + coldRent: def.baseRent, + utilityCosts: def.utilityPauschal, depositAmount: totalWarmRent * 3, isActive: true, }, diff --git a/backend/src/modules/banking/paymentService.ts b/backend/src/modules/banking/paymentService.ts index ea80752..bce490e 100644 --- a/backend/src/modules/banking/paymentService.ts +++ b/backend/src/modules/banking/paymentService.ts @@ -108,7 +108,7 @@ export class PaymentService { tenantName: c.user.fullName, status, dueDate: dueDate.toISOString(), - amountDue: Number(currentPayment?.amount ?? c.totalWarmRent), + amountDue: Number(currentPayment?.amount ?? Number(c.coldRent) + Number(c.utilityCosts) + Number(c.wifiCost)), amountPaid: paid ? Number(currentPayment.amount) : 0, }; }); diff --git a/backend/src/routes/cleaningTasks.ts b/backend/src/routes/cleaningTasks.ts index 3f0b5cc..fb047b7 100644 --- a/backend/src/routes/cleaningTasks.ts +++ b/backend/src/routes/cleaningTasks.ts @@ -7,9 +7,12 @@ const prisma = new PrismaClient(); /** * Router für den Putzplan (rotierende Reinigungsaufgaben). * - * Zugriffsregel: ein Mieter sieht (und kann abhaken) nur seine eigenen - * zugewiesenen Putzaufgaben. Vermieter/Admin sehen und verwalten den - * kompletten Putzplan aller Mieter. + * Zugriffsregel: der Putzplan wird von den Mietern selbst gepflegt — jeder + * eingeloggte Nutzer (Mieter wie Vermieter/Admin) kann Putzbereiche anlegen + * und Aufgaben eintragen/zuweisen. Ein Mieter sieht (und kann abhaken) nur + * seine eigenen zugewiesenen Aufgaben; Vermieter/Admin sehen den kompletten + * Plan. Die Bestätigung/Verifizierung erledigter Aufgaben bleibt Vermieter/ + * Admin vorbehalten (Qualitätskontrolle). */ export const cleaningTasksRouter = Router(); @@ -20,11 +23,11 @@ cleaningTasksRouter.get('/cleaning-areas', requireAuth, async (_req: AuthedReque res.status(200).json({ areas }); }); -// POST /cleaning-areas { name } — nur Vermieter/Admin legen neue Bereiche an. +// POST /cleaning-areas { name } — jeder eingeloggte Nutzer kann neue +// Bereiche anlegen (Putzplan wird von den Mietern selbst gepflegt). cleaningTasksRouter.post( '/cleaning-areas', requireAuth, - requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const { name } = req.body || {}; if (!name || !String(name).trim()) { @@ -52,11 +55,12 @@ const TASK_SELECT = { assignedUser: { select: { id: true, fullName: true } }, }; -// Vermieter/Admin: Liste der Mieter (für das Zuweisen-Formular im Web-Dashboard). +// Liste der Mieter (für das Putzplan-Formular) — für alle eingeloggten +// Nutzer sichtbar, da der Putzplan von den Mietern selbst gepflegt wird und +// sie Aufgaben auch anderen Mitbewohnern zuweisen können. cleaningTasksRouter.get( '/tenants', requireAuth, - requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => { const tenants = await prisma.user.findMany({ where: { role: 'TENANT' }, @@ -67,11 +71,12 @@ cleaningTasksRouter.get( }, ); -// Vermieter/Admin legen eine neue Putzaufgabe an (z. B. für die kommende Rotationswoche). +// Jeder eingeloggte Nutzer (Mieter wie Vermieter/Admin) legt eine neue +// Putzaufgabe an (z. B. für die kommende Rotationswoche) — der Putzplan wird +// von den Mietern selbst gepflegt/ausgefüllt. cleaningTasksRouter.post( '/cleaning-tasks', requireAuth, - requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { const { assignedUserId, area, weekOf, dueDate } = req.body || {}; diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index 433a599..1644964 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -115,7 +115,9 @@ const CONTRACT_DOCUMENTS_SELECT = { endDate: true, isActive: true, contractDocumentUrls: true, - totalWarmRent: true, + coldRent: true, + utilityCosts: true, + wifiCost: true, depositAmount: true, paymentDueDay: true, noticePeriodMonths: true, @@ -177,7 +179,8 @@ contractsRouter.patch( requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { - const { startDate, endDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {}; + const { startDate, endDate, coldRent, utilityCosts, wifiCost, 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' }); @@ -188,8 +191,14 @@ contractsRouter.patch( 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 (coldRent !== undefined && (!Number.isFinite(Number(coldRent)) || Number(coldRent) <= 0)) { + return res.status(400).json({ error: 'coldRent muss eine positive Zahl sein' }); + } + if (utilityCosts !== undefined && (!Number.isFinite(Number(utilityCosts)) || Number(utilityCosts) < 0)) { + return res.status(400).json({ error: 'utilityCosts muss eine Zahl sein' }); + } + if (wifiCost !== undefined && (!Number.isFinite(Number(wifiCost)) || Number(wifiCost) < 0)) { + return res.status(400).json({ error: 'wifiCost muss eine Zahl sein' }); } if (depositAmount !== undefined && (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0)) { return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' }); @@ -222,7 +231,9 @@ contractsRouter.patch( data: { startDate: startDate !== undefined ? parsedStart : undefined, endDate: endDate !== undefined ? parsedEnd : undefined, - totalWarmRent: totalWarmRent !== undefined ? Number(totalWarmRent) : undefined, + coldRent: coldRent !== undefined ? Number(coldRent) : undefined, + utilityCosts: utilityCosts !== undefined ? Number(utilityCosts) : undefined, + wifiCost: wifiCost !== undefined ? Number(wifiCost) : undefined, depositAmount: depositAmount !== undefined ? Number(depositAmount) : undefined, paymentDueDay: paymentDueDay !== undefined ? Number(paymentDueDay) : undefined, noticePeriodMonths: noticePeriodMonths !== undefined ? Number(noticePeriodMonths) : undefined, @@ -507,7 +518,8 @@ contractsRouter.post( requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { - const { userId, roomId, startDate, endDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {}; + const { userId, roomId, startDate, endDate, coldRent, utilityCosts, wifiCost, depositAmount, paymentDueDay, noticePeriodMonths } = + req.body || {}; if (!userId || typeof userId !== 'string') return res.status(400).json({ error: 'userId ist erforderlich' }); if (!roomId || typeof roomId !== 'string') return res.status(400).json({ error: 'roomId ist erforderlich' }); @@ -515,8 +527,14 @@ contractsRouter.post( if (endDate !== undefined && endDate !== null && Number.isNaN(Date.parse(endDate))) { return res.status(400).json({ error: 'endDate ist ungueltig' }); } - if (!Number.isFinite(Number(totalWarmRent)) || Number(totalWarmRent) <= 0) { - return res.status(400).json({ error: 'totalWarmRent muss eine positive Zahl sein' }); + if (!Number.isFinite(Number(coldRent)) || Number(coldRent) <= 0) { + return res.status(400).json({ error: 'coldRent muss eine positive Zahl sein' }); + } + if (utilityCosts !== undefined && (!Number.isFinite(Number(utilityCosts)) || Number(utilityCosts) < 0)) { + return res.status(400).json({ error: 'utilityCosts muss eine Zahl sein' }); + } + if (wifiCost !== undefined && (!Number.isFinite(Number(wifiCost)) || Number(wifiCost) < 0)) { + return res.status(400).json({ error: 'wifiCost muss eine Zahl sein' }); } if (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0) { return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' }); @@ -555,7 +573,9 @@ contractsRouter.post( roomId, startDate: parsedStart, endDate: parsedEnd, - totalWarmRent: Number(totalWarmRent), + coldRent: Number(coldRent), + utilityCosts: Number.isFinite(Number(utilityCosts)) ? Number(utilityCosts) : 0, + wifiCost: Number.isFinite(Number(wifiCost)) ? Number(wifiCost) : 0, depositAmount: Number(depositAmount), paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 1, noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : 3, diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index 53d8e22..6c24b68 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -297,7 +297,9 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R startDate: invitation.contractStartDate || new Date(), endDate: invitation.contractEndDate, paymentDueDay: invitation.paymentDueDay || 1, - totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal), + coldRent: Number(room.baseRent), + utilityCosts: Number(room.utilityPauschal), + wifiCost: 0, depositAmount: Number(room.baseRent) * 3, isActive: true, }, diff --git a/backend/src/services/contractDocumentGenerator.ts b/backend/src/services/contractDocumentGenerator.ts index c638fa5..447e21a 100644 --- a/backend/src/services/contractDocumentGenerator.ts +++ b/backend/src/services/contractDocumentGenerator.ts @@ -117,7 +117,13 @@ export function generateContractPdf(contract: ContractForPdf): Promise { // --- § 4 Miete und Nebenkosten --- section('§ 4 Miete und Nebenkosten', () => { - doc.text(`Die Warmmiete beträgt ${formatEuro(contract.totalWarmRent)} monatlich.`); + const totalRent = Number(contract.coldRent) + Number(contract.utilityCosts) + Number(contract.wifiCost); + doc.text(`Die Kaltmiete beträgt ${formatEuro(contract.coldRent)} monatlich.`); + doc.text(`Die Nebenkosten betragen ${formatEuro(contract.utilityCosts)} monatlich.`); + if (Number(contract.wifiCost) > 0) { + doc.text(`Die WLAN-Kosten betragen ${formatEuro(contract.wifiCost)} monatlich.`); + } + doc.text(`Die monatliche Gesamtmiete (Warmmiete) beträgt somit ${formatEuro(totalRent)}.`); doc.text( `Sie ist im Voraus, spätestens am ${contract.paymentDueDay}. eines jeden Monats, auf das ` + 'vom Vermieter benannte Konto zu entrichten.', diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e8b1417..858c87d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -272,7 +272,9 @@ model Contract { startDate DateTime @map("start_date") endDate DateTime? @map("end_date") // NULL = unbefristet - totalWarmRent Decimal @map("total_warm_rent") @db.Decimal(8, 2) + coldRent Decimal @map("cold_rent") @db.Decimal(8, 2) // Kaltmiete + utilityCosts Decimal @default(0) @map("utility_costs") @db.Decimal(8, 2) // Nebenkosten + wifiCost Decimal @default(0) @map("wifi_cost") @db.Decimal(8, 2) // WLAN-Kosten depositAmount Decimal @map("deposit_amount") @db.Decimal(8, 2) paymentDueDay Int @default(1) @map("payment_due_day") // Fälligkeit: Kalendertag im Monat (z.B. 1 oder 15) diff --git a/web-dashboard/index.html b/web-dashboard/index.html index 53459ec..c004b7b 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -827,8 +827,8 @@
-
@@ -1473,10 +1477,9 @@ document.getElementById('inviteSection').style.display = isLandlord ? 'block' : 'none'; document.getElementById('tenantDataSection').style.display = isLandlord ? 'block' : 'none'; document.getElementById('rentSectionTitle').textContent = isLandlord ? 'Miet-Ampel je Zimmer' : 'Meine Miete'; - document.getElementById('cleaningAssignSection').style.display = isLandlord ? 'block' : 'none'; document.getElementById('cleaningSectionTitle').textContent = isLandlord ? 'Putzplan (alle Mieter)' : 'Mein Putzplan'; if (isLandlord) initInvitePanel(); - if (isLandlord) initCleaningAssignForm(); + initCleaningAssignForm(); document.getElementById('absenceUserField').style.display = isLandlord ? 'flex' : 'none'; @@ -2386,7 +2389,9 @@ const amount = document.getElementById('expenseAmount').value; const description = document.getElementById('expenseDescription').value.trim(); const category = document.getElementById('expenseCategory').value; - await apiFetch('/expenses', { method: 'POST', body: JSON.stringify({ amount, description, category }) }); + const receiptFile = document.getElementById('expenseReceipt').files[0]; + const receiptUrl = receiptFile ? await readFileAsCompressedDataUrl(receiptFile, 1600, 0.82) : undefined; + await apiFetch('/expenses', { method: 'POST', body: JSON.stringify({ amount, description, category, receiptUrl }) }); form.reset(); populateExpenseCategorySelect(); loadExpenses(); @@ -2457,6 +2462,12 @@ const shareText = openShares.map(s => `${s.user.fullName}: ${formatEuro(s.shareAmount)}`).join(', '); left.appendChild(el('div', 'meta-text', 'Offen: ' + shareText)); } + if (x.receiptUrl) { + const chipWrap = el('div'); + chipWrap.style.marginTop = '4px'; + chipWrap.appendChild(buildDocumentChip('Beleg', x.receiptUrl)); + left.appendChild(chipWrap); + } row.appendChild(left); const right = el('div'); @@ -3640,7 +3651,7 @@ } // Editierbare Vertrags-Eckdaten (dieselben Felder wie bei der - // Vertragserstellung: Mietbeginn/-ende, Warmmiete, Kaution, Fälligkeit, + // Vertragserstellung: Mietbeginn/-ende, Kaltmiete/Nebenkosten/WLAN-Kosten, Kaution, Fälligkeit, // Kündigungsfrist) — jederzeit vom Vermieter über PATCH /contracts/:id // änderbar, inkl. serverseitiger Plausibilitätsprüfung bei Datumsänderung. function buildContractEckdatenForm(c) { @@ -3680,16 +3691,38 @@ if (unbefristetCheckbox.checked) endInput.value = ''; }; - const rentField = el('div', 'field'); - rentField.style.cssText = 'width:95px; flex:0 0 auto;'; - 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 coldRentField = el('div', 'field'); + coldRentField.style.cssText = 'width:95px; flex:0 0 auto;'; + coldRentField.appendChild(el('label', null, 'Kaltmiete (€)')); + const coldRentInput = document.createElement('input'); + coldRentInput.type = 'number'; + coldRentInput.min = '0'; + coldRentInput.step = '0.01'; + coldRentInput.value = c.coldRent; + coldRentField.appendChild(coldRentInput); + form.appendChild(coldRentField); + + const utilityCostsField = el('div', 'field'); + utilityCostsField.style.cssText = 'width:95px; flex:0 0 auto;'; + utilityCostsField.appendChild(el('label', null, 'Nebenkosten (€)')); + const utilityCostsInput = document.createElement('input'); + utilityCostsInput.type = 'number'; + utilityCostsInput.min = '0'; + utilityCostsInput.step = '0.01'; + utilityCostsInput.value = c.utilityCosts; + utilityCostsField.appendChild(utilityCostsInput); + form.appendChild(utilityCostsField); + + const wifiCostField = el('div', 'field'); + wifiCostField.style.cssText = 'width:95px; flex:0 0 auto;'; + wifiCostField.appendChild(el('label', null, 'WLAN-Kosten (€)')); + const wifiCostInput = document.createElement('input'); + wifiCostInput.type = 'number'; + wifiCostInput.min = '0'; + wifiCostInput.step = '0.01'; + wifiCostInput.value = c.wifiCost; + wifiCostField.appendChild(wifiCostInput); + form.appendChild(wifiCostField); const depositField = el('div', 'field'); depositField.style.cssText = 'width:95px; flex:0 0 auto;'; @@ -3726,7 +3759,10 @@ noticeField.appendChild(noticeInput); form.appendChild(noticeField); - const allInputs = [startInput, endInput, unbefristetCheckbox, rentInput, depositInput, dueDaySelect, noticeInput]; + const allInputs = [ + startInput, endInput, unbefristetCheckbox, coldRentInput, utilityCostsInput, wifiCostInput, + depositInput, dueDaySelect, noticeInput, + ]; const setEditable = (editable) => { allInputs.forEach(input => { input.disabled = !editable; }); if (editable) endInput.disabled = unbefristetCheckbox.checked; @@ -3766,7 +3802,9 @@ body: JSON.stringify({ startDate: startInput.value, endDate: unbefristetCheckbox.checked ? null : (endInput.value || null), - totalWarmRent: rentInput.value, + coldRent: coldRentInput.value, + utilityCosts: utilityCostsInput.value, + wifiCost: wifiCostInput.value, depositAmount: depositInput.value, paymentDueDay: Number(dueDaySelect.value), noticePeriodMonths: noticeInput.value, @@ -3996,15 +4034,36 @@ endField.appendChild(unbefristetLabel); formArea.appendChild(endField); - const rentField = el('div', 'field'); - rentField.style.width = '130px'; - rentField.appendChild(el('label', null, 'Warmmiete (€)')); - const rentInput = document.createElement('input'); - rentInput.type = 'number'; - rentInput.min = '0'; - rentInput.step = '0.01'; - rentField.appendChild(rentInput); - formArea.appendChild(rentField); + const coldRentField = el('div', 'field'); + coldRentField.style.width = '110px'; + coldRentField.appendChild(el('label', null, 'Kaltmiete (€)')); + const coldRentInput = document.createElement('input'); + coldRentInput.type = 'number'; + coldRentInput.min = '0'; + coldRentInput.step = '0.01'; + coldRentField.appendChild(coldRentInput); + formArea.appendChild(coldRentField); + + const utilityCostsField = el('div', 'field'); + utilityCostsField.style.width = '110px'; + utilityCostsField.appendChild(el('label', null, 'Nebenkosten (€)')); + const utilityCostsInput = document.createElement('input'); + utilityCostsInput.type = 'number'; + utilityCostsInput.min = '0'; + utilityCostsInput.step = '0.01'; + utilityCostsField.appendChild(utilityCostsInput); + formArea.appendChild(utilityCostsField); + + const wifiCostField = el('div', 'field'); + wifiCostField.style.width = '110px'; + wifiCostField.appendChild(el('label', null, 'WLAN-Kosten (€)')); + const wifiCostInput = document.createElement('input'); + wifiCostInput.type = 'number'; + wifiCostInput.min = '0'; + wifiCostInput.step = '0.01'; + wifiCostInput.value = '0'; + wifiCostField.appendChild(wifiCostInput); + formArea.appendChild(wifiCostField); const depositField = el('div', 'field'); depositField.style.width = '130px'; @@ -4065,7 +4124,8 @@ function applyRoomDefaults() { const info = roomInfoById[roomSelect.value]; if (!info) return; - rentInput.value = (Number(info.baseRent) + Number(info.utilityPauschal)).toFixed(2); + coldRentInput.value = Number(info.baseRent).toFixed(2); + utilityCostsInput.value = Number(info.utilityPauschal).toFixed(2); depositInput.value = (Number(info.baseRent) * 3).toFixed(2); } @@ -4088,7 +4148,9 @@ roomId: roomSelect.value, startDate: startInput.value, endDate: unbefristetCheckbox.checked ? undefined : (endInput.value || undefined), - totalWarmRent: rentInput.value, + coldRent: coldRentInput.value, + utilityCosts: utilityCostsInput.value, + wifiCost: wifiCostInput.value, depositAmount: depositInput.value, paymentDueDay: Number(dueDaySelect.value), }, submitBtn);