diff --git a/backend/src/routes/cockpit.ts b/backend/src/routes/cockpit.ts index fc6cedb..e58b0bc 100644 --- a/backend/src/routes/cockpit.ts +++ b/backend/src/routes/cockpit.ts @@ -18,6 +18,7 @@ const TICKET_SELECT = { createdAt: true, room: { select: { roomNumber: true } }, creator: { select: { id: true, fullName: true } }, + craftsman: { select: { id: true, name: true, trade: true, phone: true } }, } as const; // Erfordert Login: sensible Miet- und Kontaktdaten dürfen nicht öffentlich sein. diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts new file mode 100644 index 0000000..ddb1f68 --- /dev/null +++ b/backend/src/routes/contracts.ts @@ -0,0 +1,77 @@ +import { Router, Response } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { AuthedRequest, requireAuth } from '../middleware/auth'; + +const prisma = new PrismaClient(); + +/** + * Router für Kündigungsfristen-Erinnerungen. + * + * Nutzt die bereits vorhandenen Vertragsfelder (endDate, noticePeriodMonths) + * — kein Extra-Datenmodell nötig. Ein Mieter sieht ausschließlich seinen + * eigenen Vertrag; Vermieter/Admin sehen alle aktiven Verträge. + */ +export const contractsRouter = Router(); + +const CONTRACT_SELECT = { + id: true, + startDate: true, + endDate: true, + noticePeriodMonths: true, + isActive: true, + user: { select: { id: true, fullName: true } }, + room: { select: { id: true, roomNumber: true } }, +}; + +function addMonths(date: Date, months: number): Date { + const d = new Date(date); + d.setMonth(d.getMonth() - months); + return d; +} + +// GET /contracts/notice-deadlines — für befristete Verträge (endDate gesetzt) +// wird die späteste Kündigungsfrist (endDate - noticePeriodMonths) berechnet. +// Unbefristete Verträge (endDate = NULL) zeigen nur die geltende Kündigungs- +// frist an, ohne konkretes Datum. Rückgabe ist nach Dringlichkeit sortiert. +contractsRouter.get('/contracts/notice-deadlines', requireAuth, async (req: AuthedRequest, res: Response) => { + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + + const where: Record = { isActive: true }; + if (!isLandlord) { + where.userId = req.user!.id; + } + + const contracts = await prisma.contract.findMany({ + where, + select: CONTRACT_SELECT, + orderBy: { endDate: 'asc' }, + }); + + const now = new Date(); + const items = contracts.map((c) => { + const noticeDeadline = c.endDate ? addMonths(c.endDate, c.noticePeriodMonths) : null; + const daysUntilDeadline = noticeDeadline + ? Math.ceil((noticeDeadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) + : null; + return { + contractId: c.id, + user: c.user, + room: c.room, + startDate: c.startDate, + endDate: c.endDate, + noticePeriodMonths: c.noticePeriodMonths, + noticeDeadline, + daysUntilDeadline, + isUrgent: daysUntilDeadline !== null && daysUntilDeadline <= 30 && daysUntilDeadline >= 0, + isOverdue: daysUntilDeadline !== null && daysUntilDeadline < 0, + }; + }); + + items.sort((a, b) => { + if (a.daysUntilDeadline === null) return 1; + if (b.daysUntilDeadline === null) return -1; + return a.daysUntilDeadline - b.daysUntilDeadline; + }); + + res.status(200).json({ deadlines: items }); +}); diff --git a/backend/src/routes/craftsmen.ts b/backend/src/routes/craftsmen.ts new file mode 100644 index 0000000..5d0809f --- /dev/null +++ b/backend/src/routes/craftsmen.ts @@ -0,0 +1,78 @@ +import { Router, Response } from 'express'; +import { PrismaClient, CraftsmanTrade } from '@prisma/client'; +import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; + +const prisma = new PrismaClient(); + +/** + * Router für Handwerker-Kontakte (Anbindung an Schadensmeldungen). + * + * Handwerker sind reine Kontaktkarten ohne eigenen Login. Anlegen/Bearbeiten/ + * Löschen ist Vermieter/Admin vorbehalten; die Liste (zum Zuweisen an ein + * Ticket) dürfen auch Mieter lesen, da sie ohnehin auf zugewiesenen Tickets + * sichtbar ist (kein privates Datum). + */ +export const craftsmenRouter = Router(); + +const TRADES = Object.values(CraftsmanTrade); + +const CRAFTSMAN_SELECT = { + id: true, + name: true, + trade: true, + phone: true, + email: true, + notes: true, + createdAt: true, +}; + +craftsmenRouter.get('/craftsmen', requireAuth, async (_req: AuthedRequest, res: Response) => { + const craftsmen = await prisma.craftsman.findMany({ + orderBy: { name: 'asc' }, + select: CRAFTSMAN_SELECT, + }); + res.status(200).json({ craftsmen }); +}); + +craftsmenRouter.post( + '/craftsmen', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { name, trade, phone, email, notes } = req.body || {}; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ error: 'name ist erforderlich' }); + } + if (trade && !TRADES.includes(trade)) { + return res.status(400).json({ error: `Ungültiges Gewerk. Erlaubt: ${TRADES.join(', ')}` }); + } + + const craftsman = await prisma.craftsman.create({ + data: { + name: name.trim().slice(0, 120), + trade: trade || CraftsmanTrade.SONSTIGES, + phone: typeof phone === 'string' && phone.trim() ? phone.trim().slice(0, 40) : null, + email: typeof email === 'string' && email.trim() ? email.trim().slice(0, 120) : null, + notes: typeof notes === 'string' && notes.trim() ? notes.trim().slice(0, 300) : null, + }, + select: CRAFTSMAN_SELECT, + }); + + res.status(201).json({ craftsman }); + }, +); + +craftsmenRouter.delete( + '/craftsmen/:id', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + try { + await prisma.ticket.updateMany({ where: { craftsmanId: req.params.id }, data: { craftsmanId: null } }); + await prisma.craftsman.delete({ where: { id: req.params.id } }); + res.status(204).send(); + } catch { + res.status(404).json({ error: 'Handwerker nicht gefunden' }); + } + }, +); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 488bfbf..f7fe49f 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -36,6 +36,7 @@ const TICKET_SELECT = { room: { select: { id: true, roomNumber: true } }, creator: { select: { id: true, fullName: true } }, assignee: { select: { id: true, fullName: true } }, + craftsman: { select: { id: true, name: true, trade: true, phone: true, email: true } }, }; ticketsRouter.post('/tickets', requireAuth, async (req: AuthedRequest, res: Response) => { @@ -116,7 +117,7 @@ ticketsRouter.patch( requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => { - const { status, priority, assigneeId } = req.body || {}; + const { status, priority, assigneeId, craftsmanId } = req.body || {}; const data: Record = {}; if (status !== undefined) { @@ -135,6 +136,9 @@ ticketsRouter.patch( if (assigneeId !== undefined) { data.assigneeId = assigneeId || null; } + if (craftsmanId !== undefined) { + data.craftsmanId = craftsmanId || null; + } if (Object.keys(data).length === 0) { return res.status(400).json({ error: 'Keine Änderungen übergeben' }); diff --git a/backend/src/routes/utilityStatements.ts b/backend/src/routes/utilityStatements.ts new file mode 100644 index 0000000..86c6dd1 --- /dev/null +++ b/backend/src/routes/utilityStatements.ts @@ -0,0 +1,169 @@ +import { Router, Response } from 'express'; +import { PrismaClient, SettlementStatus } from '@prisma/client'; +import { AuthedRequest, requireAuth, requireRole } from '../middleware/auth'; + +const prisma = new PrismaClient(); + +/** + * Router für die Nebenkostenabrechnung. + * + * Der Vermieter trägt eine Abrechnung (Zeitraum + Gesamtbetrag) ein; die App + * verteilt den Betrag automatisch proportional zur Zimmergröße (sizeSqm) auf + * alle aktuell aktiven Mieter — dieselbe faire Grundlage wie bei der + * Nettokaltmiete. Zugriffsregel: ein Mieter sieht nur seinen EIGENEN Anteil, + * niemals die Anteile anderer Mieter (private Abrechnungsdaten). Vermieter/ + * Admin sehen die komplette Abrechnung inkl. aller Anteile. + */ +export const utilityStatementsRouter = Router(); + +const SHARE_SELECT = { + id: true, + shareAmount: true, + settlementStatus: true, + settledAt: true, + user: { select: { id: true, fullName: true } }, + room: { select: { id: true, roomNumber: true } }, +}; + +const STATEMENT_SELECT_LANDLORD = { + id: true, + periodStart: true, + periodEnd: true, + totalAmount: true, + description: true, + createdAt: true, + createdBy: { select: { id: true, fullName: true } }, + shares: { select: SHARE_SELECT }, +}; + +// POST /utility-statements — Abrechnung anlegen, automatischer Split nach +// Zimmergröße unter allen aktuell aktiven Mietern (Vermieter/Admin only). +utilityStatementsRouter.post( + '/utility-statements', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + const { periodStart, periodEnd, totalAmount, description } = req.body || {}; + + const parsedAmount = Number(totalAmount); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + return res.status(400).json({ error: 'totalAmount muss eine positive Zahl sein' }); + } + const start = new Date(periodStart); + const end = new Date(periodEnd); + if (isNaN(start.getTime()) || isNaN(end.getTime()) || start >= end) { + return res.status(400).json({ error: 'periodStart/periodEnd sind ungültig' }); + } + + // Aktive Verträge mit Zimmer laden, um proportional nach sizeSqm zu splitten. + const activeContracts = await prisma.contract.findMany({ + where: { isActive: true }, + select: { userId: true, roomId: true, room: { select: { sizeSqm: true } } }, + }); + + if (activeContracts.length === 0) { + return res.status(409).json({ error: 'Keine aktiven Mietverträge vorhanden, Split nicht möglich' }); + } + + const totalSqm = activeContracts.reduce((sum, c) => sum + Number(c.room.sizeSqm), 0); + + let allocated = 0; + const shares = activeContracts.map((c, idx) => { + const isLast = idx === activeContracts.length - 1; + let shareAmount: number; + if (isLast) { + // letzter Anteil bekommt den Rest, um Rundungsdifferenzen zu vermeiden + shareAmount = Math.round((parsedAmount - allocated) * 100) / 100; + } else { + shareAmount = Math.round(((parsedAmount * Number(c.room.sizeSqm)) / totalSqm) * 100) / 100; + allocated += shareAmount; + } + return { + userId: c.userId, + roomId: c.roomId, + shareAmount, + settlementStatus: SettlementStatus.OPEN, + }; + }); + + const statement = await prisma.utilityStatement.create({ + data: { + periodStart: start, + periodEnd: end, + totalAmount: parsedAmount, + description: typeof description === 'string' ? description.trim().slice(0, 300) : null, + createdById: req.user!.id, + shares: { create: shares }, + }, + select: STATEMENT_SELECT_LANDLORD, + }); + + res.status(201).json({ statement }); + }, +); + +// GET /utility-statements — Vermieter/Admin sehen alle Abrechnungen mit allen +// Anteilen; Mieter sehen alle Abrechnungen, aber NUR ihren eigenen Anteil +// (kein Einblick in die Anteile anderer Mieter). +utilityStatementsRouter.get('/utility-statements', requireAuth, async (req: AuthedRequest, res: Response) => { + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + + const statements = await prisma.utilityStatement.findMany({ + orderBy: { periodStart: 'desc' }, + select: { + id: true, + periodStart: true, + periodEnd: true, + totalAmount: true, + description: true, + createdAt: true, + createdBy: { select: { id: true, fullName: true } }, + shares: { + select: SHARE_SELECT, + where: isLandlord ? undefined : { userId: req.user!.id }, + }, + }, + }); + + res.status(200).json({ statements }); +}); + +// PATCH /utility-statement-shares/:id — eigenen Anteil als beglichen markieren +// (Mieter selbst) oder durch Vermieter/Admin. +utilityStatementsRouter.patch( + '/utility-statement-shares/:id', + requireAuth, + async (req: AuthedRequest, res: Response) => { + const share = await prisma.utilityStatementShare.findUnique({ where: { id: req.params.id } }); + if (!share) return res.status(404).json({ error: 'Anteil nicht gefunden' }); + + const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN'; + if (!isLandlord && share.userId !== req.user!.id) { + return res.status(403).json({ error: 'Keine Berechtigung, diesen Anteil zu ändern' }); + } + + const updated = await prisma.utilityStatementShare.update({ + where: { id: req.params.id }, + data: { settlementStatus: SettlementStatus.SETTLED, settledAt: new Date() }, + select: SHARE_SELECT, + }); + + res.status(200).json({ share: updated }); + }, +); + +// DELETE /utility-statements/:id — nur Vermieter/Admin. +utilityStatementsRouter.delete( + '/utility-statements/:id', + requireAuth, + requireRole('LANDLORD', 'ADMIN'), + async (req: AuthedRequest, res: Response) => { + try { + await prisma.utilityStatementShare.deleteMany({ where: { statementId: req.params.id } }); + await prisma.utilityStatement.delete({ where: { id: req.params.id } }); + res.status(204).send(); + } catch { + res.status(404).json({ error: 'Abrechnung nicht gefunden' }); + } + }, +);