Allow re-admitting an archived tenant via a brand-new contract

Adds "Wieder aufnehmen" to each archived contract card: landlord picks
a vacant room, move-in date, rent and deposit, then POST
/contracts/reactivate creates a fresh Contract row for that user (new
id, new terms, no carried-over documents/signature) and re-assigns the
room. The old contract is never touched — it stays exactly as-is in
the archive as a historical record, per the requirement that the
original tenancy documentation must be preserved even after rejoining.

New GET /contracts/vacant-rooms lists rooms with no active contract,
used to populate the room picker and prefill sensible rent/deposit
defaults from the room's base rent.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 11:10:20 +00:00
parent a6fb15cd41
commit 3e87ff3476
2 changed files with 189 additions and 3 deletions

View File

@ -325,3 +325,73 @@ contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', '
}); });
res.status(200).json({ contracts }); res.status(200).json({ contracts });
}); });
// ----------------------------------------------------------------------------
// ARCHIVIERTEN MIETER WIEDER AUFNEHMEN (neuer Vertrag, alter bleibt im Archiv)
// ----------------------------------------------------------------------------
// 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 } } },
select: { id: true, roomNumber: true, sizeSqm: true, baseRent: true, utilityPauschal: true },
orderBy: { roomNumber: 'asc' },
});
res.status(200).json({ rooms });
});
// Vermieter/Admin: ein Mieter aus dem Archiv (Contract.isActive=false, User
// ohne roomId) bekommt einen VÖLLIG NEUEN Vertrag — der alte Vertrag bleibt
// unverändert als Dokumentation im Archiv stehen, es wird kein alter Vertrag
// reaktiviert oder überschrieben.
contractsRouter.post(
'/contracts/reactivate',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
const { userId, roomId, startDate, 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 (!Number.isFinite(Number(totalWarmRent)) || Number(totalWarmRent) <= 0) {
return res.status(400).json({ error: 'totalWarmRent muss eine positive Zahl sein' });
}
if (!Number.isFinite(Number(depositAmount)) || Number(depositAmount) < 0) {
return res.status(400).json({ error: 'depositAmount muss eine Zahl sein' });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return res.status(404).json({ error: 'Mieter nicht gefunden' });
const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) return res.status(404).json({ error: 'Zimmer nicht gefunden' });
const [activeForUser, activeForRoom] = await Promise.all([
prisma.contract.findFirst({ where: { userId, isActive: true } }),
prisma.contract.findFirst({ where: { roomId, isActive: true } }),
]);
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 [contract] = await prisma.$transaction([
prisma.contract.create({
data: {
userId,
roomId,
startDate: new Date(startDate),
totalWarmRent: Number(totalWarmRent),
depositAmount: Number(depositAmount),
paymentDueDay: Number.isFinite(Number(paymentDueDay)) ? Number(paymentDueDay) : 3,
noticePeriodMonths: Number.isFinite(Number(noticePeriodMonths)) ? Number(noticePeriodMonths) : 3,
isActive: true,
},
select: CONTRACT_DOCUMENTS_SELECT,
}),
prisma.user.update({ where: { id: userId }, data: { roomId } }),
prisma.room.update({ where: { id: roomId }, data: { status: 'OCCUPIED' } }),
]);
res.status(201).json({ contract });
},
);

View File

@ -2882,14 +2882,17 @@
if (!isLandlord) { section.style.display = 'none'; return; } if (!isLandlord) { section.style.display = 'none'; return; }
section.style.display = 'block'; section.style.display = 'block';
try { try {
const data = await apiFetch('/contracts/archive'); const [archiveData, roomsData] = await Promise.all([
renderContractArchive(data.contracts); apiFetch('/contracts/archive'),
apiFetch('/contracts/vacant-rooms'),
]);
renderContractArchive(archiveData.contracts, roomsData.rooms);
} catch (err) { } catch (err) {
console.error('[contracts] Archiv laden fehlgeschlagen:', err.message); console.error('[contracts] Archiv laden fehlgeschlagen:', err.message);
} }
} }
function renderContractArchive(contracts) { function renderContractArchive(contracts, vacantRooms) {
const list = document.getElementById('contractArchiveList'); const list = document.getElementById('contractArchiveList');
list.innerHTML = ''; list.innerHTML = '';
if (!contracts.length) { if (!contracts.length) {
@ -2898,6 +2901,10 @@
} }
contracts.forEach(c => { contracts.forEach(c => {
const row = el('div', 'ticket-row'); const row = el('div', 'ticket-row');
row.style.flexDirection = 'column';
row.style.alignItems = 'stretch';
row.style.gap = '10px';
const left = el('div'); const left = el('div');
left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`)); left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
left.appendChild(el('div', 'meta-text', left.appendChild(el('div', 'meta-text',
@ -2918,10 +2925,119 @@
}); });
row.appendChild(docWrap); row.appendChild(docWrap);
} }
row.appendChild(buildReactivateSection(c, vacantRooms));
list.appendChild(row); list.appendChild(row);
}); });
} }
function buildReactivateSection(c, vacantRooms) {
const wrap = el('div');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-secondary';
toggleBtn.textContent = 'Wieder aufnehmen (neuer Vertrag)';
const formArea = el('div', 'contract-template-form');
formArea.style.display = 'none';
formArea.style.marginTop = '10px';
if (!vacantRooms.length) {
formArea.appendChild(el('span', 'meta-text', 'Kein freies Zimmer verfügbar.'));
} else {
const roomField = el('div', 'field');
roomField.style.minWidth = '150px';
roomField.appendChild(el('label', null, 'Zimmer'));
const roomSelect = document.createElement('select');
vacantRooms.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
opt.dataset.baseRent = r.baseRent;
opt.dataset.utilityPauschal = r.utilityPauschal;
roomSelect.appendChild(opt);
});
roomField.appendChild(roomSelect);
formArea.appendChild(roomField);
const startField = el('div', 'field');
startField.style.minWidth = '150px';
startField.appendChild(el('label', null, 'Mietbeginn'));
const startInput = document.createElement('input');
startInput.type = 'date';
startField.appendChild(startInput);
formArea.appendChild(startField);
const rentField = el('div', 'field');
rentField.style.width = '130px';
rentField.appendChild(el('label', null, 'Warmmiete (€)'));
const rentInput = document.createElement('input');
rentInput.type = 'number';
rentInput.min = '0';
rentInput.step = '0.01';
const firstRoom = vacantRooms[0];
rentInput.value = (Number(firstRoom.baseRent) + Number(firstRoom.utilityPauschal)).toFixed(2);
rentField.appendChild(rentInput);
formArea.appendChild(rentField);
const depositField = el('div', 'field');
depositField.style.width = '130px';
depositField.appendChild(el('label', null, 'Kaution (€)'));
const depositInput = document.createElement('input');
depositInput.type = 'number';
depositInput.min = '0';
depositInput.step = '0.01';
depositInput.value = (Number(firstRoom.baseRent) * 3).toFixed(2);
depositField.appendChild(depositInput);
formArea.appendChild(depositField);
roomSelect.onchange = () => {
const opt = roomSelect.selectedOptions[0];
rentInput.value = (Number(opt.dataset.baseRent) + Number(opt.dataset.utilityPauschal)).toFixed(2);
depositInput.value = (Number(opt.dataset.baseRent) * 3).toFixed(2);
};
const submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'btn-primary';
submitBtn.style.width = 'auto';
submitBtn.textContent = 'Neuen Vertrag anlegen';
submitBtn.onclick = () => reactivateTenant(c.user.id, {
roomId: roomSelect.value,
startDate: startInput.value,
totalWarmRent: rentInput.value,
depositAmount: depositInput.value,
}, submitBtn);
formArea.appendChild(submitBtn);
}
toggleBtn.onclick = () => {
formArea.style.display = formArea.style.display === 'none' ? 'flex' : 'none';
};
wrap.appendChild(toggleBtn);
wrap.appendChild(formArea);
return wrap;
}
async function reactivateTenant(userId, options, btn) {
if (!options.startDate) { alert('Bitte Mietbeginn angeben.'); return; }
btn.disabled = true;
btn.textContent = 'Legt an…';
try {
await apiFetch('/contracts/reactivate', { method: 'POST', body: JSON.stringify({ userId, ...options }) });
loadContractDocuments();
loadContractArchive();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Neuen Vertrag anlegen';
}
}
function buildContractTemplateForm(c) { function buildContractTemplateForm(c) {
const form = el('div', 'contract-template-form'); const form = el('div', 'contract-template-form');