Merge: Namensfeld bei Einladung + Vertragslaufzeit-Plausibilitätsprüfung
This commit is contained in:
commit
41c18d078f
@ -5,6 +5,31 @@ import { generateContractPdf } from '../services/contractDocumentGenerator';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Prueft, ob auf einem Zimmer bereits ein aktiver Vertrag existiert, dessen
|
||||
* Zeitraum sich mit [startDate, endDate] ueberschneidet. endDate=null steht
|
||||
* fuer unbefristet (offenes Ende). Grundlage fuer die Plausibilitaetspruefung
|
||||
* bei Neuvergabe/Einladung eines Zimmers.
|
||||
*/
|
||||
export async function findOverlappingContract(
|
||||
client: PrismaClient,
|
||||
roomId: string,
|
||||
startDate: Date,
|
||||
endDate: Date | null,
|
||||
) {
|
||||
const activeContracts = await client.contract.findMany({
|
||||
where: { roomId, isActive: true },
|
||||
});
|
||||
|
||||
return (
|
||||
activeContracts.find((c) => {
|
||||
const otherEnd = c.endDate;
|
||||
const overlaps = (!otherEnd || otherEnd >= startDate) && (!endDate || endDate >= c.startDate);
|
||||
return overlaps;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Router für Kündigungsfristen-Erinnerungen.
|
||||
*
|
||||
@ -342,12 +367,66 @@ contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', '
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Zimmer ohne aktiven Vertrag — für die Auswahl beim Wiederaufnehmen.
|
||||
contractsRouter.get('/contracts/vacant-rooms', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => {
|
||||
const rooms = await prisma.room.findMany({
|
||||
where: { contracts: { none: { isActive: true } } },
|
||||
// Optional datumsbewusst: mit startDate/endDate werden auch Zimmer mit
|
||||
// befristetem/gekuendigtem Vertrag beruecksichtigt, sofern deren Zeitraum
|
||||
// nicht mit dem angefragten Zeitraum ueberlappt. Ohne Parameter bleibt das
|
||||
// bisherige (rein isActive-basierte) Verhalten unveraendert.
|
||||
contractsRouter.get('/contracts/vacant-rooms', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => {
|
||||
const { startDate, endDate } = req.query as { startDate?: string; endDate?: string };
|
||||
|
||||
if (!startDate || Number.isNaN(Date.parse(startDate))) {
|
||||
const rooms = await prisma.room.findMany({
|
||||
where: { contracts: { none: { isActive: true } } },
|
||||
select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true },
|
||||
orderBy: { roomNumber: 'asc' },
|
||||
});
|
||||
return res.status(200).json({ rooms });
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = endDate && !Number.isNaN(Date.parse(endDate)) ? new Date(endDate) : null;
|
||||
|
||||
const allRooms = await prisma.room.findMany({
|
||||
select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true },
|
||||
orderBy: { roomNumber: 'asc' },
|
||||
});
|
||||
|
||||
const rooms = [];
|
||||
for (const room of allRooms) {
|
||||
const conflict = await findOverlappingContract(prisma, room.id, start, end);
|
||||
if (!conflict) rooms.push(room);
|
||||
}
|
||||
|
||||
res.status(200).json({ rooms });
|
||||
});
|
||||
|
||||
// Datumsbewusste Verfuegbarkeitspruefung ALLER Zimmer (auch belegte werden
|
||||
// zurueckgegeben, aber mit available:false + Grund) — Basis fuer die
|
||||
// Plausibilitaetspruefung im Frontend bei Einladung/Wiederaufnahme.
|
||||
contractsRouter.get('/contracts/room-availability', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (req: AuthedRequest, res: Response) => {
|
||||
const { startDate, endDate } = req.query as { startDate?: string; endDate?: string };
|
||||
if (!startDate || Number.isNaN(Date.parse(startDate))) {
|
||||
return res.status(400).json({ error: 'startDate ist erforderlich' });
|
||||
}
|
||||
const start = new Date(startDate);
|
||||
const end = endDate && !Number.isNaN(Date.parse(endDate)) ? new Date(endDate) : null;
|
||||
|
||||
const allRooms = await prisma.room.findMany({
|
||||
select: { id: true, roomNumber: true, baseRent: true, utilityPauschal: true },
|
||||
orderBy: { roomNumber: 'asc' },
|
||||
});
|
||||
|
||||
const rooms = [];
|
||||
for (const room of allRooms) {
|
||||
const conflict = await findOverlappingContract(prisma, room.id, start, end);
|
||||
const conflictUntil = conflict ? (conflict.endDate ? conflict.endDate.toLocaleDateString('de-DE') : 'unbefristet') : null;
|
||||
rooms.push({
|
||||
...room,
|
||||
available: !conflict,
|
||||
conflictReason: conflict ? `Zimmer ist im gewaehlten Zeitraum bereits vergeben (belegt bis ${conflictUntil}).` : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({ rooms });
|
||||
});
|
||||
|
||||
@ -360,11 +439,14 @@ contractsRouter.post(
|
||||
requireAuth,
|
||||
requireRole('LANDLORD', 'ADMIN'),
|
||||
async (req: AuthedRequest, res: Response) => {
|
||||
const { userId, roomId, startDate, totalWarmRent, depositAmount, paymentDueDay, noticePeriodMonths } = req.body || {};
|
||||
const { userId, roomId, startDate, endDate, totalWarmRent, 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' });
|
||||
if (!startDate || Number.isNaN(Date.parse(startDate))) return res.status(400).json({ error: 'startDate ist erforderlich' });
|
||||
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' });
|
||||
}
|
||||
@ -385,12 +467,23 @@ contractsRouter.post(
|
||||
if (activeForUser) return res.status(409).json({ error: 'Mieter hat bereits einen aktiven Vertrag' });
|
||||
if (activeForRoom) return res.status(409).json({ error: 'Zimmer ist bereits belegt' });
|
||||
|
||||
const parsedStart = new Date(startDate);
|
||||
const parsedEnd = endDate ? new Date(endDate) : null;
|
||||
const overlap = await findOverlappingContract(prisma, roomId, parsedStart, parsedEnd);
|
||||
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 [contract] = await prisma.$transaction([
|
||||
prisma.contract.create({
|
||||
data: {
|
||||
userId,
|
||||
roomId,
|
||||
startDate: new Date(startDate),
|
||||
startDate: parsedStart,
|
||||
endDate: parsedEnd,
|
||||
totalWarmRent: Number(totalWarmRent),
|
||||
depositAmount: Number(depositAmount),
|
||||
paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 3,
|
||||
|
||||
@ -247,6 +247,8 @@ model Invitation {
|
||||
status InvitationStatus @default(PENDING)
|
||||
expiresAt DateTime @map("expires_at")
|
||||
acceptedAt DateTime? @map("accepted_at")
|
||||
contractStartDate DateTime? @map("contract_start_date")
|
||||
contractEndDate DateTime? @map("contract_end_date")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
|
||||
@ -3056,6 +3056,13 @@
|
||||
const contractUploadFiles = {}; // contractId -> ausgewählte, noch nicht hochgeladene Files
|
||||
|
||||
async function loadContractDocuments() {
|
||||
// Während der Nutzer in einem der Vertragsformulare tippt, darf der
|
||||
// periodische Auto-Refresh die Eingaben nicht überschreiben (sonst
|
||||
// werden Formularfelder nach REFRESH_INTERVAL_MS scheinbar "automatisch
|
||||
// geleert"). Solange der Fokus innerhalb der Liste liegt, wird der
|
||||
// Refresh daher übersprungen.
|
||||
const list = document.getElementById('contractDocumentsList');
|
||||
if (list && list.contains(document.activeElement)) return;
|
||||
try {
|
||||
const data = await apiFetch('/contracts/documents');
|
||||
renderContractDocuments(data.contracts);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user