@ -656,6 +656,10 @@
< div class = "invite-card" >
< div id = "inviteFormError" style = "display:none" class = "form-error" > < / div >
< 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" >
< label for = "inviteEmail" > E-Mail-Adresse des Mieters< / label >
< input type = "email" id = "inviteEmail" required placeholder = "mieter@beispiel.de" / >
@ -666,6 +670,17 @@
< option value = "" > – kein Zimmer zuordnen – < / option >
< / select >
< / 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 >
< / form >
< div id = "inviteLinkResult" style = "display:none" class = "invite-link-box" >
@ -674,7 +689,7 @@
< / div >
< table class = "invite-table" id = "inviteTable" style = "display:none" >
< 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 >
< tbody id = "inviteTableBody" > < / tbody >
< / table >
@ -1305,6 +1320,7 @@
sub.textContent = `Einladung als ${ROLE_LABEL[preview.role] || preview.role}` +
(preview.room ? ` für ${preview.room.roomNumber}` : '') + '. Bitte Passwort festlegen.';
document.getElementById('acceptEmail').value = preview.email;
if (preview.fullName) document.getElementById('acceptFullName').value = preview.fullName;
form.style.display = 'flex';
form.style.flexDirection = 'column';
@ -1487,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 = '< 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 => {
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.roomNumber;
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();
@ -1511,16 +1576,26 @@
submitBtn.disabled = true;
submitBtn.textContent = 'Lädt ein…';
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, 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';
@ -1551,6 +1626,7 @@
table.style.display = 'table';
data.invitations.forEach(inv => {
const tr = document.createElement('tr');
tr.appendChild(el('td', '', inv.fullName || '– '));
tr.appendChild(el('td', '', inv.email));
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 > `));
@ -3574,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) {
@ -3612,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');
@ -3630,21 +3703,10 @@
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 = '15 0px';
roomField.style.minWidth = '190px';
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);
@ -3653,9 +3715,26 @@
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 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 rentField = el('div', 'field');
rentField.style.width = '130px';
rentField.appendChild(el('label', null, 'Warmmiete (€)'));
@ -3663,8 +3742,6 @@
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);
@ -3675,15 +3752,58 @@
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 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';
@ -3693,11 +3813,11 @@
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';
@ -3710,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 {