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.
|
// Sichtbarkeitsregel wie bei allen anderen privaten Vertragsdaten.
|
||||||
contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => {
|
contractsRouter.get('/contracts/documents', requireAuth, async (req: AuthedRequest, res: Response) => {
|
||||||
const isLandlord = req.user!.role === 'LANDLORD' || req.user!.role === 'ADMIN';
|
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;
|
if (!isLandlord) where.userId = req.user!.id;
|
||||||
|
|
||||||
const contracts = await prisma.contract.findMany({
|
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 });
|
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>
|
<div class="ticket-list" id="contractDocumentsList"></div>
|
||||||
</section>
|
</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">
|
<section id="noticeDeadlinesSection">
|
||||||
<h2>Kündigungsfristen</h2>
|
<h2>Kündigungsfristen</h2>
|
||||||
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
|
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
|
||||||
@ -1257,6 +1263,7 @@
|
|||||||
loadCraftsmen();
|
loadCraftsmen();
|
||||||
loadRatings();
|
loadRatings();
|
||||||
loadContractDocuments();
|
loadContractDocuments();
|
||||||
|
loadContractArchive();
|
||||||
|
|
||||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
setInterval(loadData, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
|
||||||
@ -1272,6 +1279,7 @@
|
|||||||
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
|
setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadRatings, REFRESH_INTERVAL_MS);
|
setInterval(loadRatings, REFRESH_INTERVAL_MS);
|
||||||
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
|
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
|
||||||
|
setInterval(loadContractArchive, REFRESH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@ -2840,12 +2848,80 @@
|
|||||||
uploadRow.appendChild(uploadBtn);
|
uploadRow.appendChild(uploadBtn);
|
||||||
|
|
||||||
card.appendChild(uploadRow);
|
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);
|
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) {
|
function buildContractTemplateForm(c) {
|
||||||
const form = el('div', 'contract-template-form');
|
const form = el('div', 'contract-template-form');
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user