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:
parent
a6fb15cd41
commit
3e87ff3476
@ -325,3 +325,73 @@ contractsRouter.get('/contracts/archive', requireAuth, requireRole('LANDLORD', '
|
||||
});
|
||||
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 });
|
||||
},
|
||||
);
|
||||
|
||||
@ -2882,14 +2882,17 @@
|
||||
if (!isLandlord) { section.style.display = 'none'; return; }
|
||||
section.style.display = 'block';
|
||||
try {
|
||||
const data = await apiFetch('/contracts/archive');
|
||||
renderContractArchive(data.contracts);
|
||||
const [archiveData, roomsData] = await Promise.all([
|
||||
apiFetch('/contracts/archive'),
|
||||
apiFetch('/contracts/vacant-rooms'),
|
||||
]);
|
||||
renderContractArchive(archiveData.contracts, roomsData.rooms);
|
||||
} catch (err) {
|
||||
console.error('[contracts] Archiv laden fehlgeschlagen:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContractArchive(contracts) {
|
||||
function renderContractArchive(contracts, vacantRooms) {
|
||||
const list = document.getElementById('contractArchiveList');
|
||||
list.innerHTML = '';
|
||||
if (!contracts.length) {
|
||||
@ -2898,6 +2901,10 @@
|
||||
}
|
||||
contracts.forEach(c => {
|
||||
const row = el('div', 'ticket-row');
|
||||
row.style.flexDirection = 'column';
|
||||
row.style.alignItems = 'stretch';
|
||||
row.style.gap = '10px';
|
||||
|
||||
const left = el('div');
|
||||
left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
|
||||
left.appendChild(el('div', 'meta-text',
|
||||
@ -2918,10 +2925,119 @@
|
||||
});
|
||||
row.appendChild(docWrap);
|
||||
}
|
||||
|
||||
row.appendChild(buildReactivateSection(c, vacantRooms));
|
||||
|
||||
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) {
|
||||
const form = el('div', 'contract-template-form');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user