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 });
|
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; }
|
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');
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user