Compare commits

..

3 Commits

3 changed files with 242 additions and 85 deletions

View File

@ -3,6 +3,7 @@ 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();
@ -20,13 +21,20 @@ function inviteLinkFor(req: Request, token: string): string {
return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`; return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`;
} }
// POST /v1/invitations (nur Vermieter/Admin) — { email, roomId?, role? } -> { invitation, inviteLink } // POST /v1/invitations (nur Vermieter/Admin)
// { 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, roomId, role } = req.body || {}; const { email, fullName, roomId, role, contractStartDate, contractEndDate } = 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' });
} }
@ -53,9 +61,29 @@ 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');
@ -64,9 +92,12 @@ 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,
}, },
@ -124,8 +155,11 @@ 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,
}); });
}); });
@ -238,7 +272,8 @@ 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: new Date(), startDate: invitation.contractStartDate || 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,6 +237,7 @@ 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,6 +656,10 @@
<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" />
@ -666,6 +670,17 @@
<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">
@ -674,7 +689,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>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr> <tr><th>Name</th><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>
@ -1305,6 +1320,7 @@
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';
@ -1487,22 +1503,71 @@
let lastRooms = []; let lastRooms = [];
let lastCockpitData = null; let lastCockpitData = null;
function populateRoomSelect() { // 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 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>'; select.innerHTML = '<option value=""> kein Zimmer zuordnen </option>';
// Nur freie Zimmer zur Auswahl anbieten — ein bereits belegtes Zimmer try {
// kann nicht zusätzlich vergeben werden. const params = new URLSearchParams({ startDate });
lastRooms.filter(r => r.status === 'VACANT').forEach(r => { if (endDate) params.set('endDate', endDate);
const opt = document.createElement('option'); const data = await apiFetch(`/contracts/room-availability?${params.toString()}`);
opt.value = r.id; (data.rooms || []).forEach(r => {
opt.textContent = r.roomNumber; const opt = document.createElement('option');
select.appendChild(opt); 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() {
refreshInviteRoomOptions();
} }
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();
@ -1511,16 +1576,26 @@
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 = document.getElementById('inviteRoom').value || undefined; const roomId = roomSelect.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, roomId }), body: JSON.stringify({ email, fullName, roomId, contractStartDate, contractEndDate }),
}); });
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';
@ -1551,6 +1626,7 @@
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>`));
@ -3574,17 +3650,14 @@
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, roomsData] = await Promise.all([ const archiveData = await apiFetch('/contracts/archive');
apiFetch('/contracts/archive'), renderContractArchive(archiveData.contracts);
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, vacantRooms) { function renderContractArchive(contracts) {
const list = document.getElementById('contractArchiveList'); const list = document.getElementById('contractArchiveList');
list.innerHTML = ''; list.innerHTML = '';
if (!contracts.length) { if (!contracts.length) {
@ -3612,13 +3685,13 @@
row.appendChild(docWrap); row.appendChild(docWrap);
} }
row.appendChild(buildReactivateSection(c, vacantRooms)); row.appendChild(buildReactivateSection(c));
list.appendChild(row); list.appendChild(row);
}); });
} }
function buildReactivateSection(c, vacantRooms) { function buildReactivateSection(c) {
const wrap = el('div'); const wrap = el('div');
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
@ -3630,75 +3703,122 @@
formArea.style.display = 'none'; formArea.style.display = 'none';
formArea.style.marginTop = '10px'; formArea.style.marginTop = '10px';
if (!vacantRooms.length) { const roomField = el('div', 'field');
formArea.appendChild(el('span', 'meta-text', 'Kein freies Zimmer verfügbar.')); roomField.style.minWidth = '190px';
} else { roomField.appendChild(el('label', null, 'Zimmer'));
const roomField = el('div', 'field'); const roomSelect = document.createElement('select');
roomField.style.minWidth = '150px'; roomField.appendChild(roomSelect);
roomField.appendChild(el('label', null, 'Zimmer')); formArea.appendChild(roomField);
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';
startField.appendChild(startInput); startInput.value = new Date().toISOString().slice(0, 10);
formArea.appendChild(startField); startField.appendChild(startInput);
formArea.appendChild(startField);
const rentField = el('div', 'field'); const endField = el('div', 'field');
rentField.style.width = '130px'; endField.style.minWidth = '150px';
rentField.appendChild(el('label', null, 'Warmmiete (€)')); endField.appendChild(el('label', null, 'Mietende'));
const rentInput = document.createElement('input'); const endInput = document.createElement('input');
rentInput.type = 'number'; endInput.type = 'date';
rentInput.min = '0'; endInput.disabled = true;
rentInput.step = '0.01'; endField.appendChild(endInput);
const firstRoom = vacantRooms[0]; const unbefristetLabel = el('label', null, ' unbefristet');
rentInput.value = (Number(firstRoom.baseRent) + Number(firstRoom.utilityPauschal)).toFixed(2); unbefristetLabel.style.cssText = 'font-weight:normal; margin-top:4px; display:block;';
rentField.appendChild(rentInput); const unbefristetCheckbox = document.createElement('input');
formArea.appendChild(rentField); unbefristetCheckbox.type = 'checkbox';
unbefristetCheckbox.checked = true;
unbefristetLabel.prepend(unbefristetCheckbox);
endField.appendChild(unbefristetLabel);
formArea.appendChild(endField);
const depositField = el('div', 'field'); const rentField = el('div', 'field');
depositField.style.width = '130px'; rentField.style.width = '130px';
depositField.appendChild(el('label', null, 'Kaution (€)')); rentField.appendChild(el('label', null, 'Warmmiete (€)'));
const depositInput = document.createElement('input'); const rentInput = document.createElement('input');
depositInput.type = 'number'; rentInput.type = 'number';
depositInput.min = '0'; rentInput.min = '0';
depositInput.step = '0.01'; rentInput.step = '0.01';
depositInput.value = (Number(firstRoom.baseRent) * 3).toFixed(2); rentField.appendChild(rentInput);
depositField.appendChild(depositInput); formArea.appendChild(rentField);
formArea.appendChild(depositField);
roomSelect.onchange = () => { const depositField = el('div', 'field');
const opt = roomSelect.selectedOptions[0]; depositField.style.width = '130px';
rentInput.value = (Number(opt.dataset.baseRent) + Number(opt.dataset.utilityPauschal)).toFixed(2); depositField.appendChild(el('label', null, 'Kaution (€)'));
depositInput.value = (Number(opt.dataset.baseRent) * 3).toFixed(2); const depositInput = document.createElement('input');
}; depositInput.type = 'number';
depositInput.min = '0';
depositInput.step = '0.01';
depositField.appendChild(depositInput);
formArea.appendChild(depositField);
const submitBtn = document.createElement('button'); const roomInfoById = {};
submitBtn.type = 'button';
submitBtn.className = 'btn-primary'; // Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende
submitBtn.style.width = 'auto'; // wird /contracts/room-availability neu abgefragt, damit z.B. ein zum
submitBtn.textContent = 'Neuen Vertrag anlegen'; // gewählten Datum bereits gekündigtes (aber noch aktives) Zimmer als
submitBtn.onclick = () => reactivateTenant(c.user.id, { // verfügbar erscheint, ein unbefristet/darüber hinaus vergebenes aber
roomId: roomSelect.value, // nicht (siehe findOverlappingContract in contracts.ts).
startDate: startInput.value, async function refreshRoomOptions() {
totalWarmRent: rentInput.value, const startDate = startInput.value || new Date().toISOString().slice(0, 10);
depositAmount: depositInput.value, const endDate = unbefristetCheckbox.checked ? '' : endInput.value;
}, submitBtn); const previousValue = roomSelect.value;
formArea.appendChild(submitBtn); try {
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';
}; };
@ -3710,6 +3830,7 @@
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 {