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:
Giuseppe Lombardo 2026-08-13 10:59:16 +00:00
parent b4cbfa71aa
commit a6fb15cd41
2 changed files with 130 additions and 1 deletions

View File

@ -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 });
});

View File

@ -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');