Mieterdaten: Vertrags-Eckdaten anzeigen und für Vermieter jederzeit editierbar machen
This commit is contained in:
parent
23d58e48d9
commit
ab8e9058e1
@ -16,9 +16,10 @@ export async function findOverlappingContract(
|
|||||||
roomId: string,
|
roomId: string,
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date | null,
|
endDate: Date | null,
|
||||||
|
excludeContractId?: string,
|
||||||
) {
|
) {
|
||||||
const activeContracts = await client.contract.findMany({
|
const activeContracts = await client.contract.findMany({
|
||||||
where: { roomId, isActive: true },
|
where: { roomId, isActive: true, id: excludeContractId ? { not: excludeContractId } : undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -166,6 +167,72 @@ contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedReque
|
|||||||
res.status(200).json({ contracts });
|
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,
|
// Vermieter/Admin: Dateien zu einem Vertrag hinzufügen (mehrere auf einmal,
|
||||||
// werden an bereits vorhandene angehängt statt sie zu ersetzen).
|
// werden an bereits vorhandene angehängt statt sie zu ersetzen).
|
||||||
contractsRouter.post(
|
contractsRouter.post(
|
||||||
|
|||||||
@ -3634,10 +3634,136 @@
|
|||||||
card.appendChild(top);
|
card.appendChild(top);
|
||||||
|
|
||||||
card.appendChild(buildProfileCompletenessBadge(c.user));
|
card.appendChild(buildProfileCompletenessBadge(c.user));
|
||||||
|
card.appendChild(buildContractEckdatenForm(c));
|
||||||
list.appendChild(card);
|
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) {
|
function renderContractDocuments(contracts) {
|
||||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||||
const list = document.getElementById('contractDocumentsList');
|
const list = document.getElementById('contractDocumentsList');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user