diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts
index 5ff5862..93fe942 100644
--- a/backend/src/routes/invitations.ts
+++ b/backend/src/routes/invitations.ts
@@ -3,6 +3,7 @@ import { PrismaClient, Prisma, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { signAuthToken, requireAuth, requireRole, AuthedRequest } from '../middleware/auth';
+import { findOverlappingContract } from './contracts';
const prisma = new PrismaClient();
@@ -20,13 +21,20 @@ function inviteLinkFor(req: Request, token: string): string {
return `${baseUrl.replace(/\/$/, '')}/?invite=${token}`;
}
-// POST /v1/invitations (nur Vermieter/Admin) — { email, fullName?, 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(
'/invitations',
requireAuth,
requireRole('LANDLORD', 'ADMIN'),
async (req: AuthedRequest, res: Response) => {
- const { email, fullName, roomId, role } = req.body || {};
+ const { email, fullName, roomId, role, contractStartDate, contractEndDate } = req.body || {};
if (!email || !String(email).trim()) {
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) {
const room = await prisma.room.findUnique({ where: { id: roomId } });
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');
@@ -68,6 +96,8 @@ invitationsRouter.post(
token,
role: invitedRole,
roomId: roomId || null,
+ contractStartDate: parsedContractStart,
+ contractEndDate: parsedContractEnd,
invitedById: req.user!.id,
expiresAt,
},
@@ -128,6 +158,8 @@ invitationsRouter.get('/invitations/:token/preview', async (req: Request, res: R
fullName: invitation.fullName,
role: invitation.role,
room: invitation.room,
+ contractStartDate: invitation.contractStartDate,
+ contractEndDate: invitation.contractEndDate,
expiresAt: invitation.expiresAt,
});
});
@@ -240,7 +272,8 @@ invitationsRouter.post('/invitations/:token/accept', async (req: Request, res: R
data: {
userId: created.id,
roomId: room.id,
- startDate: new Date(),
+ startDate: invitation.contractStartDate || new Date(),
+ endDate: invitation.contractEndDate,
totalWarmRent: Number(room.baseRent) + Number(room.utilityPauschal),
depositAmount: Number(room.baseRent) * 3,
isActive: true,
diff --git a/web-dashboard/index.html b/web-dashboard/index.html
index e052823..3305100 100644
--- a/web-dashboard/index.html
+++ b/web-dashboard/index.html
@@ -670,6 +670,17 @@
+
+
+
+
+
+
+
+
+
@@ -1492,22 +1503,71 @@
let lastRooms = [];
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 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 = '';
- // 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);
- });
+ 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() {
+ refreshInviteRoomOptions();
}
function initInvitePanel() {
const form = document.getElementById('inviteForm');
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) => {
e.preventDefault();
@@ -1518,16 +1578,24 @@
try {
const fullName = document.getElementById('inviteName').value.trim() || undefined;
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', {
method: 'POST',
- body: JSON.stringify({ email, fullName, roomId }),
+ body: JSON.stringify({ email, fullName, roomId, contractStartDate, contractEndDate }),
});
document.getElementById('inviteLinkText').textContent = data.inviteLink;
document.getElementById('inviteLinkResult').style.display = 'flex';
document.getElementById('inviteName').value = '';
document.getElementById('inviteEmail').value = '';
+ startField.style.display = 'none';
+ endField.style.display = 'none';
+ startInput.value = '';
+ endInput.value = '';
+ unbefristetCheckbox.checked = true;
loadInvitations();
+ refreshInviteRoomOptions();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
@@ -3582,17 +3650,14 @@
if (!isLandlord) { section.style.display = 'none'; return; }
section.style.display = 'block';
try {
- const [archiveData, roomsData] = await Promise.all([
- apiFetch('/contracts/archive'),
- apiFetch('/contracts/vacant-rooms'),
- ]);
- renderContractArchive(archiveData.contracts, roomsData.rooms);
+ const archiveData = await apiFetch('/contracts/archive');
+ renderContractArchive(archiveData.contracts);
} catch (err) {
console.error('[contracts] Archiv laden fehlgeschlagen:', err.message);
}
}
- function renderContractArchive(contracts, vacantRooms) {
+ function renderContractArchive(contracts) {
const list = document.getElementById('contractArchiveList');
list.innerHTML = '';
if (!contracts.length) {
@@ -3620,13 +3685,13 @@
row.appendChild(docWrap);
}
- row.appendChild(buildReactivateSection(c, vacantRooms));
+ row.appendChild(buildReactivateSection(c));
list.appendChild(row);
});
}
- function buildReactivateSection(c, vacantRooms) {
+ function buildReactivateSection(c) {
const wrap = el('div');
const toggleBtn = document.createElement('button');
@@ -3638,75 +3703,122 @@
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 roomField = el('div', 'field');
+ roomField.style.minWidth = '190px';
+ roomField.appendChild(el('label', null, 'Zimmer'));
+ const roomSelect = document.createElement('select');
+ 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 startField = el('div', 'field');
+ startField.style.minWidth = '150px';
+ startField.appendChild(el('label', null, 'Mietbeginn'));
+ const startInput = document.createElement('input');
+ startInput.type = 'date';
+ startInput.value = new Date().toISOString().slice(0, 10);
+ 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 endField = el('div', 'field');
+ endField.style.minWidth = '150px';
+ endField.appendChild(el('label', null, 'Mietende'));
+ const endInput = document.createElement('input');
+ endInput.type = 'date';
+ endInput.disabled = true;
+ endField.appendChild(endInput);
+ const unbefristetLabel = el('label', null, ' unbefristet');
+ unbefristetLabel.style.cssText = 'font-weight:normal; margin-top:4px; display:block;';
+ const unbefristetCheckbox = document.createElement('input');
+ unbefristetCheckbox.type = 'checkbox';
+ unbefristetCheckbox.checked = true;
+ unbefristetLabel.prepend(unbefristetCheckbox);
+ endField.appendChild(unbefristetLabel);
+ formArea.appendChild(endField);
- 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);
+ 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';
+ rentField.appendChild(rentInput);
+ formArea.appendChild(rentField);
- 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 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';
+ depositField.appendChild(depositInput);
+ formArea.appendChild(depositField);
- 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);
+ const roomInfoById = {};
+
+ // Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende
+ // wird /contracts/room-availability neu abgefragt, damit z.B. ein zum
+ // gewählten Datum bereits gekündigtes (aber noch aktives) Zimmer als
+ // verfügbar erscheint, ein unbefristet/darüber hinaus vergebenes aber
+ // nicht (siehe findOverlappingContract in contracts.ts).
+ async function refreshRoomOptions() {
+ const startDate = startInput.value || new Date().toISOString().slice(0, 10);
+ const endDate = unbefristetCheckbox.checked ? '' : endInput.value;
+ const previousValue = roomSelect.value;
+ 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 = () => {
formArea.style.display = formArea.style.display === 'none' ? 'flex' : 'none';
};
@@ -3718,6 +3830,7 @@
async function reactivateTenant(userId, options, btn) {
if (!options.startDate) { alert('Bitte Mietbeginn angeben.'); return; }
+ if (!options.roomId) { alert('Bitte ein verfügbares Zimmer wählen.'); return; }
btn.disabled = true;
btn.textContent = 'Legt an…';
try {