diff --git a/backend/src/routes/contracts.ts b/backend/src/routes/contracts.ts index 9a5aa8f..c8ad07f 100644 --- a/backend/src/routes/contracts.ts +++ b/backend/src/routes/contracts.ts @@ -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 }); + }, +); diff --git a/web-dashboard/index.html b/web-dashboard/index.html index e709640..7fef847 100644 --- a/web-dashboard/index.html +++ b/web-dashboard/index.html @@ -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');