Compare commits

..

No commits in common. "3111f64ecd9348196812086a79c76d2c1a234b12" and "e6b16ea74e0d33b915c6c0a92a18a810f2189b68" have entirely different histories.

3 changed files with 86 additions and 243 deletions

View File

@ -3,7 +3,6 @@ import { PrismaClient, Prisma, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { signAuthToken, requireAuth, requireRole, AuthedRequest } from '../middleware/auth'; import { signAuthToken, requireAuth, requireRole, AuthedRequest } from '../middleware/auth';
import { findOverlappingContract } from './contracts';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -21,20 +20,13 @@ function inviteLinkFor(req: Request, token: string): string {
return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`; return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`;
} }
// POST /v1/invitations (nur Vermieter/Admin) // POST /v1/invitations (nur Vermieter/Admin) — { email, roomId?, role? } -> { invitation, inviteLink }
// { email, fullName?, roomId?, role?, contractStartDate?, contractEndDate? } -> { invitation, inviteLink }
//
// contractStartDate/contractEndDate sind nur relevant, wenn ein Zimmer
// zugeordnet wird: sie legen fest, ab wann (und ggf. bis wann, sonst
// unbefristet) der bei Einladungsannahme automatisch erzeugte Mietvertrag
// laufen soll — Basis für die Plausibilitätsprüfung gegen bereits vergebene
// Zimmer (siehe findOverlappingContract in contracts.ts).
invitationsRouter.post( invitationsRouter.post(
'/invitations', '/invitations',
requireAuth, requireAuth,
requireRole('LANDLORD', 'ADMIN'), requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => { async (req: AuthedRequest, res: Response) => {
const { email, fullName, roomId, role, contractStartDate, contractEndDate } = req.body || {}; const { email, roomId, role } = req.body || {};
if (!email || !String(email).trim()) { if (!email || !String(email).trim()) {
return res.status(400).json({ error: 'E-Mail-Adresse erforderlich' }); return res.status(400).json({ error: 'E-Mail-Adresse erforderlich' });
} }
@ -61,29 +53,9 @@ invitationsRouter.post(
}); });
} }
let parsedContractStart: Date | null = null;
let parsedContractEnd: Date | null = null;
if (roomId) { if (roomId) {
const room = await prisma.room.findUnique({ where: { id: roomId } }); const room = await prisma.room.findUnique({ where: { id: roomId } });
if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' }); if (!room) return res.status(400).json({ error: 'Zimmer nicht gefunden' });
if (contractEndDate !== undefined && contractEndDate !== null && Number.isNaN(Date.parse(contractEndDate))) {
return res.status(400).json({ error: 'Mietende ist ungültig' });
}
if (contractStartDate !== undefined && contractStartDate !== null && Number.isNaN(Date.parse(contractStartDate))) {
return res.status(400).json({ error: 'Mietbeginn ist ungültig' });
}
parsedContractStart = contractStartDate ? new Date(contractStartDate) : new Date();
parsedContractEnd = contractEndDate ? new Date(contractEndDate) : null;
const overlap = await findOverlappingContract(prisma, roomId, parsedContractStart, parsedContractEnd);
if (overlap) {
const overlapUntil = overlap.endDate ? overlap.endDate.toLocaleDateString('de-DE') : 'unbefristet';
return res.status(409).json({
error: `Das Zimmer ist im gewählten Zeitraum bereits vergeben (belegt bis ${overlapUntil}).`,
});
}
} }
const token = crypto.randomBytes(24).toString('hex'); const token = crypto.randomBytes(24).toString('hex');
@ -92,12 +64,9 @@ invitationsRouter.post(
const invitation = await prisma.invitation.create({ const invitation = await prisma.invitation.create({
data: { data: {
email: normalizedEmail, email: normalizedEmail,
fullName: typeof fullName === 'string' && fullName.trim() ? fullName.trim() : null,
token, token,
role: invitedRole, role: invitedRole,
roomId: roomId || null, roomId: roomId || null,
contractStartDate: parsedContractStart,
contractEndDate: parsedContractEnd,
invitedById: req.user!.id, invitedById: req.user!.id,
expiresAt, expiresAt,
}, },
@ -155,11 +124,8 @@ invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: R
} }
res.status(200).json({ res.status(200).json({
email: invitation.email, email: invitation.email,
fullName: invitation.fullName,
role: invitation.role, role: invitation.role,
room: invitation.room, room: invitation.room,
contractStartDate: invitation.contractStartDate,
contractEndDate: invitation.contractEndDate,
expiresAt: invitation.expiresAt, expiresAt: invitation.expiresAt,
}); });
}); });
@ -272,8 +238,7 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R
data: { data: {
userId: created.id, userId: created.id,
roomId: room.id, roomId: room.id,
startDate: invitation.contractStartDate || new Date(), startDate: new Date(),
endDate: invitation.contractEndDate,
totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal), totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal),
depositAmount: Number(room.baseRent) * 3, depositAmount: Number(room.baseRent) * 3,
isActive: true, isActive: true,

View File

@ -237,7 +237,6 @@ model Room {
model Invitation { model Invitation {
id String @id @default(uuid()) id String @id @default(uuid())
email String email String
fullName String? @map("full_name")
token String @unique token String @unique
role UserRole @default(TENANT) role UserRole @default(TENANT)
roomId String? @map("room_id") roomId String? @map("room_id")

View File

@ -656,10 +656,6 @@
<div class="invite-card"> <div class="invite-card">
<div id="inviteFormError" style="display:none" class="form-error"></div> <div id="inviteFormError" style="display:none" class="form-error"></div>
<form id="inviteForm" class="invite-form-row"> <form id="inviteForm" class="invite-form-row">
<div class="field">
<label for="inviteName">Name des Mieters (optional)</label>
<input type="text" id="inviteName" placeholder="Max Mustermann" />
</div>
<div class="field"> <div class="field">
<label for="inviteEmail">E-Mail-Adresse des Mieters</label> <label for="inviteEmail">E-Mail-Adresse des Mieters</label>
<input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" /> <input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" />
@ -670,17 +666,6 @@
<option value=""> kein Zimmer zuordnen </option> <option value=""> kein Zimmer zuordnen </option>
</select> </select>
</div> </div>
<div class="field" id="inviteStartField" style="display:none">
<label for="inviteStartDate">Mietbeginn</label>
<input type="date" id="inviteStartDate" />
</div>
<div class="field" id="inviteEndField" style="display:none">
<label for="inviteEndDate">Mietende</label>
<input type="date" id="inviteEndDate" />
<label style="font-weight:normal; margin-top:4px;">
<input type="checkbox" id="inviteUnbefristet" checked /> unbefristet
</label>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button> <button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button>
</form> </form>
<div id="inviteLinkResult" style="display:none" class="invite-link-box"> <div id="inviteLinkResult" style="display:none" class="invite-link-box">
@ -689,7 +674,7 @@
</div> </div>
<table class="invite-table" id="inviteTable" style="display:none"> <table class="invite-table" id="inviteTable" style="display:none">
<thead> <thead>
<tr><th>Name</th><th>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr> <tr><th>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr>
</thead> </thead>
<tbody id="inviteTableBody"></tbody> <tbody id="inviteTableBody"></tbody>
</table> </table>
@ -1320,7 +1305,6 @@
sub.textContent = `Einladung als ${ROLE_LABEL[preview.role] || preview.role}` + sub.textContent = `Einladung als ${ROLE_LABEL[preview.role] || preview.role}` +
(preview.room ? ` für ${preview.room.roomNumber}` : '') + '. Bitte Passwort festlegen.'; (preview.room ? ` für ${preview.room.roomNumber}` : '') + '. Bitte Passwort festlegen.';
document.getElementById('acceptEmail').value = preview.email; document.getElementById('acceptEmail').value = preview.email;
if (preview.fullName) document.getElementById('acceptFullName').value = preview.fullName;
form.style.display = 'flex'; form.style.display = 'flex';
form.style.flexDirection = 'column'; form.style.flexDirection = 'column';
@ -1503,71 +1487,22 @@
let lastRooms = []; let lastRooms = [];
let lastCockpitData = null; let lastCockpitData = null;
// Zimmerauswahl bei der Einladung ist datumsbewusst: statt nur nach
// Room.status zu filtern (das kennt nur "aktuell frei/belegt"), fragt sie
// /contracts/room-availability für den gewählten Mietbeginn/-ende ab. So
// lässt sich z.B. ein zum 1.9. gekündigtes Zimmer schon vorab ab 1.9. neu
// vergeben, während ein unbefristet/darüber hinaus vergebenes Zimmer für
// diesen Zeitraum ausgegraut bleibt (Plausibilitätsprüfung, siehe
// findOverlappingContract in contracts.ts).
async function refreshInviteRoomOptions() {
const select = document.getElementById('inviteRoom');
const previousValue = select.value;
const startInput = document.getElementById('inviteStartDate');
const endInput = document.getElementById('inviteEndDate');
const unbefristet = document.getElementById('inviteUnbefristet').checked;
const startDate = startInput.value || new Date().toISOString().slice(0, 10);
const endDate = unbefristet ? '' : endInput.value;
select.innerHTML = '<option value=""> kein Zimmer zuordnen </option>';
try {
const params = new URLSearchParams({ startDate });
if (endDate) params.set('endDate', endDate);
const data = await apiFetch(`/contracts/room-availability?${params.toString()}`);
(data.rooms || []).forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.available ? r.roomNumber : `${r.roomNumber} (im Zeitraum belegt)`;
opt.disabled = !r.available;
if (!r.available && r.conflictReason) opt.title = r.conflictReason;
select.appendChild(opt);
});
if (previousValue && [...select.options].some(o => o.value === previousValue && !o.disabled)) {
select.value = previousValue;
}
} catch (err) {
console.error('[invite] Zimmerverfügbarkeit laden fehlgeschlagen:', err.message);
}
}
function populateRoomSelect() { function populateRoomSelect() {
refreshInviteRoomOptions(); const select = document.getElementById('inviteRoom');
select.innerHTML = '<option value=""> kein Zimmer zuordnen </option>';
// Nur freie Zimmer zur Auswahl anbieten — ein bereits belegtes Zimmer
// kann nicht zusätzlich vergeben werden.
lastRooms.filter(r => r.status === 'VACANT').forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
select.appendChild(opt);
});
} }
function initInvitePanel() { function initInvitePanel() {
const form = document.getElementById('inviteForm'); const form = document.getElementById('inviteForm');
const errorBox = document.getElementById('inviteFormError'); const errorBox = document.getElementById('inviteFormError');
const roomSelect = document.getElementById('inviteRoom');
const startField = document.getElementById('inviteStartField');
const endField = document.getElementById('inviteEndField');
const startInput = document.getElementById('inviteStartDate');
const endInput = document.getElementById('inviteEndDate');
const unbefristetCheckbox = document.getElementById('inviteUnbefristet');
roomSelect.onchange = () => {
const hasRoom = !!roomSelect.value;
startField.style.display = hasRoom ? 'flex' : 'none';
endField.style.display = hasRoom ? 'flex' : 'none';
if (hasRoom && !startInput.value) startInput.value = new Date().toISOString().slice(0, 10);
};
unbefristetCheckbox.onchange = () => {
endInput.disabled = unbefristetCheckbox.checked;
if (unbefristetCheckbox.checked) endInput.value = '';
refreshInviteRoomOptions();
};
startInput.onchange = refreshInviteRoomOptions;
endInput.onchange = refreshInviteRoomOptions;
endInput.disabled = unbefristetCheckbox.checked;
form.onsubmit = async (e) => { form.onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@ -1576,26 +1511,16 @@
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Lädt ein…'; submitBtn.textContent = 'Lädt ein…';
try { try {
const fullName = document.getElementById('inviteName').value.trim() || undefined;
const email = document.getElementById('inviteEmail').value.trim(); const email = document.getElementById('inviteEmail').value.trim();
const roomId = roomSelect.value || undefined; const roomId = document.getElementById('inviteRoom').value || undefined;
const contractStartDate = roomId ? (startInput.value || undefined) : undefined;
const contractEndDate = roomId && !unbefristetCheckbox.checked ? (endInput.value || undefined) : undefined;
const data = await apiFetch('/invitations', { const data = await apiFetch('/invitations', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, fullName, roomId, contractStartDate, contractEndDate }), body: JSON.stringify({ email, roomId }),
}); });
document.getElementById('inviteLinkText').textContent = data.inviteLink; document.getElementById('inviteLinkText').textContent = data.inviteLink;
document.getElementById('inviteLinkResult').style.display = 'flex'; document.getElementById('inviteLinkResult').style.display = 'flex';
document.getElementById('inviteName').value = '';
document.getElementById('inviteEmail').value = ''; document.getElementById('inviteEmail').value = '';
startField.style.display = 'none';
endField.style.display = 'none';
startInput.value = '';
endInput.value = '';
unbefristetCheckbox.checked = true;
loadInvitations(); loadInvitations();
refreshInviteRoomOptions();
} catch (err) { } catch (err) {
errorBox.textContent = err.message; errorBox.textContent = err.message;
errorBox.style.display = 'block'; errorBox.style.display = 'block';
@ -1626,7 +1551,6 @@
table.style.display = 'table'; table.style.display = 'table';
data.invitations.forEach(inv => { data.invitations.forEach(inv => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.appendChild(el('td', '', inv.fullName || ''));
tr.appendChild(el('td', '', inv.email)); tr.appendChild(el('td', '', inv.email));
tr.appendChild(el('td', '', inv.room ? inv.room.roomNumber : '')); tr.appendChild(el('td', '', inv.room ? inv.room.roomNumber : ''));
tr.appendChild(el('td', '', `<span class="invite-status-chip ${inv.status}">${INVITE_STATUS_LABEL[inv.status] || inv.status}</span>`)); tr.appendChild(el('td', '', `<span class="invite-status-chip ${inv.status}">${INVITE_STATUS_LABEL[inv.status] || inv.status}</span>`));
@ -3650,14 +3574,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 archiveData = await apiFetch('/contracts/archive'); const [archiveData, roomsData] = await Promise.all([
renderContractArchive(archiveData.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) {
@ -3685,13 +3612,13 @@
row.appendChild(docWrap); row.appendChild(docWrap);
} }
row.appendChild(buildReactivateSection(c)); row.appendChild(buildReactivateSection(c, vacantRooms));
list.appendChild(row); list.appendChild(row);
}); });
} }
function buildReactivateSection(c) { function buildReactivateSection(c, vacantRooms) {
const wrap = el('div'); const wrap = el('div');
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
@ -3703,122 +3630,75 @@
formArea.style.display = 'none'; formArea.style.display = 'none';
formArea.style.marginTop = '10px'; formArea.style.marginTop = '10px';
const roomField = el('div', 'field'); if (!vacantRooms.length) {
roomField.style.minWidth = '190px'; formArea.appendChild(el('span', 'meta-text', 'Kein freies Zimmer verfügbar.'));
roomField.appendChild(el('label', null, 'Zimmer')); } else {
const roomSelect = document.createElement('select'); const roomField = el('div', 'field');
roomField.appendChild(roomSelect); roomField.style.minWidth = '150px';
formArea.appendChild(roomField); 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'); const startField = el('div', 'field');
startField.style.minWidth = '150px'; startField.style.minWidth = '150px';
startField.appendChild(el('label', null, 'Mietbeginn')); startField.appendChild(el('label', null, 'Mietbeginn'));
const startInput = document.createElement('input'); const startInput = document.createElement('input');
startInput.type = 'date'; startInput.type = 'date';
startInput.value = new Date().toISOString().slice(0, 10); startField.appendChild(startInput);
startField.appendChild(startInput); formArea.appendChild(startField);
formArea.appendChild(startField);
const endField = el('div', 'field'); const rentField = el('div', 'field');
endField.style.minWidth = '150px'; rentField.style.width = '130px';
endField.appendChild(el('label', null, 'Mietende')); rentField.appendChild(el('label', null, 'Warmmiete (€)'));
const endInput = document.createElement('input'); const rentInput = document.createElement('input');
endInput.type = 'date'; rentInput.type = 'number';
endInput.disabled = true; rentInput.min = '0';
endField.appendChild(endInput); rentInput.step = '0.01';
const unbefristetLabel = el('label', null, ' unbefristet'); const firstRoom = vacantRooms[0];
unbefristetLabel.style.cssText = 'font-weight:normal; margin-top:4px; display:block;'; rentInput.value = (Number(firstRoom.baseRent) + Number(firstRoom.utilityPauschal)).toFixed(2);
const unbefristetCheckbox = document.createElement('input'); rentField.appendChild(rentInput);
unbefristetCheckbox.type = 'checkbox'; formArea.appendChild(rentField);
unbefristetCheckbox.checked = true;
unbefristetLabel.prepend(unbefristetCheckbox);
endField.appendChild(unbefristetLabel);
formArea.appendChild(endField);
const rentField = el('div', 'field'); const depositField = el('div', 'field');
rentField.style.width = '130px'; depositField.style.width = '130px';
rentField.appendChild(el('label', null, 'Warmmiete (€)')); depositField.appendChild(el('label', null, 'Kaution (€)'));
const rentInput = document.createElement('input'); const depositInput = document.createElement('input');
rentInput.type = 'number'; depositInput.type = 'number';
rentInput.min = '0'; depositInput.min = '0';
rentInput.step = '0.01'; depositInput.step = '0.01';
rentField.appendChild(rentInput); depositInput.value = (Number(firstRoom.baseRent) * 3).toFixed(2);
formArea.appendChild(rentField); depositField.appendChild(depositInput);
formArea.appendChild(depositField);
const depositField = el('div', 'field'); roomSelect.onchange = () => {
depositField.style.width = '130px'; const opt = roomSelect.selectedOptions[0];
depositField.appendChild(el('label', null, 'Kaution (€)')); rentInput.value = (Number(opt.dataset.baseRent) + Number(opt.dataset.utilityPauschal)).toFixed(2);
const depositInput = document.createElement('input'); depositInput.value = (Number(opt.dataset.baseRent) * 3).toFixed(2);
depositInput.type = 'number'; };
depositInput.min = '0';
depositInput.step = '0.01';
depositField.appendChild(depositInput);
formArea.appendChild(depositField);
const roomInfoById = {}; const submitBtn = document.createElement('button');
submitBtn.type = 'button';
// Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende submitBtn.className = 'btn-primary';
// wird /contracts/room-availability neu abgefragt, damit z.B. ein zum submitBtn.style.width = 'auto';
// gewählten Datum bereits gekündigtes (aber noch aktives) Zimmer als submitBtn.textContent = 'Neuen Vertrag anlegen';
// verfügbar erscheint, ein unbefristet/darüber hinaus vergebenes aber submitBtn.onclick = () => reactivateTenant(c.user.id, {
// nicht (siehe findOverlappingContract in contracts.ts). roomId: roomSelect.value,
async function refreshRoomOptions() { startDate: startInput.value,
const startDate = startInput.value || new Date().toISOString().slice(0, 10); totalWarmRent: rentInput.value,
const endDate = unbefristetCheckbox.checked ? '' : endInput.value; depositAmount: depositInput.value,
const previousValue = roomSelect.value; }, submitBtn);
try { formArea.appendChild(submitBtn);
const params = new URLSearchParams({ startDate });
if (endDate) params.set('endDate', endDate);
const data = await apiFetch(`/contracts/room-availability?${params.toString()}`);
roomSelect.innerHTML = '';
(data.rooms || []).forEach(r => {
roomInfoById[r.id] = r;
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.available ? r.roomNumber : `${r.roomNumber} (im Zeitraum belegt)`;
opt.disabled = !r.available;
if (!r.available && r.conflictReason) opt.title = r.conflictReason;
roomSelect.appendChild(opt);
});
const stillAvailable = [...roomSelect.options].some(o => o.value === previousValue && !o.disabled);
roomSelect.value = stillAvailable ? previousValue : ([...roomSelect.options].find(o => !o.disabled)?.value || '');
applyRoomDefaults();
} catch (err) {
console.error('[contracts] Zimmerverfügbarkeit laden fehlgeschlagen:', err.message);
}
} }
function applyRoomDefaults() {
const info = roomInfoById[roomSelect.value];
if (!info) return;
rentInput.value = (Number(info.baseRent) + Number(info.utilityPauschal)).toFixed(2);
depositInput.value = (Number(info.baseRent) * 3).toFixed(2);
}
roomSelect.onchange = applyRoomDefaults;
startInput.onchange = refreshRoomOptions;
endInput.onchange = refreshRoomOptions;
unbefristetCheckbox.onchange = () => {
endInput.disabled = unbefristetCheckbox.checked;
if (unbefristetCheckbox.checked) endInput.value = '';
refreshRoomOptions();
};
refreshRoomOptions();
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,
endDate: unbefristetCheckbox.checked ? undefined : (endInput.value || undefined),
totalWarmRent: rentInput.value,
depositAmount: depositInput.value,
}, submitBtn);
formArea.appendChild(submitBtn);
toggleBtn.onclick = () => { toggleBtn.onclick = () => {
formArea.style.display = formArea.style.display === 'none' ? 'flex' : 'none'; formArea.style.display = formArea.style.display === 'none' ? 'flex' : 'none';
}; };
@ -3830,7 +3710,6 @@
async function reactivateTenant(userId, options, btn) { async function reactivateTenant(userId, options, btn) {
if (!options.startDate) { alert('Bitte Mietbeginn angeben.'); return; } if (!options.startDate) { alert('Bitte Mietbeginn angeben.'); return; }
if (!options.roomId) { alert('Bitte ein verfügbares Zimmer wählen.'); return; }
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Legt an…'; btn.textContent = 'Legt an…';
try { try {