Add tenant move-out with permanent archive instead of deletion
Landlords can now remove a tenant from their room via a "Mieter ausziehen lassen" button on the contract card. This never deletes anything: POST /contracts/:id/move-out sets Contract.isActive=false (with an endDate), frees the room (User.roomId=null, Room.status=VACANT) so it can be re-assigned via the existing invite flow, and leaves the contract row — along with every linked payment, ticket, rating, handover protocol, and contract document — untouched and permanently queryable. GET /contracts/documents now only lists active contracts; the new GET /contracts/archive (landlord/admin only) lists deactivated ones, rendered in a new "Archiv — ausgezogene Mieter" section on the Verträge-page with their historical documents still downloadable.
This commit is contained in:
parent
b4cbfa71aa
commit
a6fb15cd41
@ -118,7 +118,7 @@ function sanitizeDocumentUrls(input: unknown): string[] {
|
||||
// Sichtbarkeitsregel wie bei allen anderen privaten Vertragsdaten.
|
||||
contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
||||
const where: Record<string, unknown> = {};
|
||||
const where: Record<string, unknown> = { isActive: true };
|
||||
if (!isLandlord) where.userId = req.user!.id;
|
||||
|
||||
const contracts = await prisma.contract.findMany({
|
||||
@ -272,3 +272,56 @@ contractsRouter.post('/contracts/:id/sign', requireAuth, async (req: AuthedReque
|
||||
});
|
||||
res.status(200).json({ contract: updated });
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// MIETER AUSZIEHEN LASSEN (Archiv statt Löschen)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Vermieter/Admin: Mieter aus dem Zimmer entfernen. Der Vertrag wird NICHT
|
||||
// gelöscht, sondern nur deaktiviert (isActive=false, endDate gesetzt) und
|
||||
// bleibt damit samt aller verknüpften Daten (Zahlungen, Tickets, Bewertungen,
|
||||
// Übergabeprotokolle, Vertragsdokumente ...) dauerhaft einsehbar — siehe
|
||||
// GET /contracts/archive. Das Zimmer wird wieder als frei markiert und kann
|
||||
// über die Einladungsfunktion neu vergeben werden.
|
||||
contractsRouter.post(
|
||||
'/contracts/:id/move-out',
|
||||
requireAuth,
|
||||
requireRole('LANDLORD', 'ADMIN'),
|
||||
async (req: AuthedRequest, res: Response) => {
|
||||
const contract = await prisma.contract.findUnique({ where: { id: req.params.id } });
|
||||
if (!contract) return res.status(404).json({ error: 'Vertrag nicht gefunden' });
|
||||
if (!contract.isActive) return res.status(400).json({ error: 'Vertrag ist bereits inaktiv' });
|
||||
|
||||
const moveOutDate = new Date();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.contract.update({
|
||||
where: { id: contract.id },
|
||||
data: { isActive: false, endDate: contract.endDate ?? moveOutDate },
|
||||
}),
|
||||
prisma.user.update({
|
||||
where: { id: contract.userId },
|
||||
data: { roomId: null },
|
||||
}),
|
||||
prisma.room.update({
|
||||
where: { id: contract.roomId },
|
||||
data: { status: 'VACANT' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const updated = await prisma.contract.findUnique({ where: { id: contract.id }, select: CONTRACT_DOCUMENTS_SELECT });
|
||||
res.status(200).json({ contract: updated });
|
||||
},
|
||||
);
|
||||
|
||||
// Vermieter/Admin: Archiv ausgezogener Mieter — nichts wird gelöscht, diese
|
||||
// Route macht die bereits deaktivierten Verträge (samt Dokumenten) dauerhaft
|
||||
// einsehbar.
|
||||
contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', 'ADMIN'), async (_req: AuthedRequest, res: Response) => {
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where: { isActive: false },
|
||||
select: CONTRACT_DOCUMENTS_SELECT,
|
||||
orderBy: { endDate: 'desc' },
|
||||
});
|
||||
res.status(200).json({ contracts });
|
||||
});
|
||||
|
||||
@ -956,6 +956,12 @@
|
||||
<div class="ticket-list" id="contractDocumentsList"></div>
|
||||
</section>
|
||||
|
||||
<section id="contractArchiveSection" style="display:none">
|
||||
<h2>Archiv — ausgezogene Mieter</h2>
|
||||
<p class="hint-text">Verträge werden beim Auszug nicht gelöscht, sondern hier dauerhaft mit allen Dokumenten aufbewahrt.</p>
|
||||
<div class="ticket-list" id="contractArchiveList"></div>
|
||||
</section>
|
||||
|
||||
<section id="noticeDeadlinesSection">
|
||||
<h2>Kündigungsfristen</h2>
|
||||
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
|
||||
@ -1257,6 +1263,7 @@
|
||||
loadCraftsmen();
|
||||
loadRatings();
|
||||
loadContractDocuments();
|
||||
loadContractArchive();
|
||||
|
||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
||||
@ -1272,6 +1279,7 @@
|
||||
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadRatings, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
|
||||
setInterval(loadContractArchive, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@ -2840,12 +2848,80 @@
|
||||
uploadRow.appendChild(uploadBtn);
|
||||
|
||||
card.appendChild(uploadRow);
|
||||
|
||||
const moveOutBtn = document.createElement('button');
|
||||
moveOutBtn.type = 'button';
|
||||
moveOutBtn.className = 'btn-secondary';
|
||||
moveOutBtn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)';
|
||||
moveOutBtn.style.cssText = 'align-self:flex-start; color:var(--red); border-color:var(--red);';
|
||||
moveOutBtn.onclick = () => moveOutTenant(c.id, c.user.fullName, c.room.roomNumber, moveOutBtn);
|
||||
card.appendChild(moveOutBtn);
|
||||
}
|
||||
|
||||
list.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function moveOutTenant(contractId, tenantName, roomNumber, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird bearbeitet…';
|
||||
try {
|
||||
await apiFetch(`/contracts/${contractId}/move-out`, { method: 'POST' });
|
||||
loadContractDocuments();
|
||||
loadContractArchive();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContractArchive() {
|
||||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||||
const section = document.getElementById('contractArchiveSection');
|
||||
if (!isLandlord) { section.style.display = 'none'; return; }
|
||||
section.style.display = 'block';
|
||||
try {
|
||||
const data = await apiFetch('/contracts/archive');
|
||||
renderContractArchive(data.contracts);
|
||||
} catch (err) {
|
||||
console.error('[contracts] Archiv laden fehlgeschlagen:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContractArchive(contracts) {
|
||||
const list = document.getElementById('contractArchiveList');
|
||||
list.innerHTML = '';
|
||||
if (!contracts.length) {
|
||||
list.appendChild(el('div', 'empty-state', 'Noch keine ausgezogenen Mieter.'));
|
||||
return;
|
||||
}
|
||||
contracts.forEach(c => {
|
||||
const row = el('div', 'ticket-row');
|
||||
const left = el('div');
|
||||
left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
|
||||
left.appendChild(el('div', 'meta-text',
|
||||
`${new Date(c.startDate).toLocaleDateString('de-DE')} – ${c.endDate ? new Date(c.endDate).toLocaleDateString('de-DE') : '–'} · ` +
|
||||
`${c.contractDocumentUrls.length} Dokument(e)`));
|
||||
row.appendChild(left);
|
||||
if (c.contractDocumentUrls.length) {
|
||||
const docWrap = el('div');
|
||||
docWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;';
|
||||
c.contractDocumentUrls.forEach((url, idx) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = documentFileName(url, idx);
|
||||
link.textContent = documentFileName(url, idx);
|
||||
link.className = 'invite-status-chip';
|
||||
link.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none;';
|
||||
docWrap.appendChild(link);
|
||||
});
|
||||
row.appendChild(docWrap);
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function buildContractTemplateForm(c) {
|
||||
const form = el('div', 'contract-template-form');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user