Dateien nach "backend/src/routes" hochladen
This commit is contained in:
parent
b25f5f0cdb
commit
ca6d851a70
@ -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.
|
||||
|
||||
77
backend/src/routes/contracts.ts
Normal file
77
backend/src/routes/contracts.ts
Normal file
@ -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<string, unknown> = { 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 });
|
||||
});
|
||||
78
backend/src/routes/craftsmen.ts
Normal file
78
backend/src/routes/craftsmen.ts
Normal file
@ -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' });
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -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<string, unknown> = {};
|
||||
|
||||
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' });
|
||||
|
||||
169
backend/src/routes/utilityStatements.ts
Normal file
169
backend/src/routes/utilityStatements.ts
Normal file
@ -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' });
|
||||
}
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user