diff --git a/web-dashboard/index.html b/web-dashboard/index.html
index 06b42bd..5936c76 100644
--- a/web-dashboard/index.html
+++ b/web-dashboard/index.html
@@ -707,6 +707,66 @@
+
+
+ Kündigungsfristen
+ Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.
+
+
+
+
+ Nebenkostenabrechnung
+
+
+
+
+
+ Handwerker-Kontakte
+
+
+
@@ -733,6 +793,8 @@
const STORAGE_LOCATION_LABEL = { FRIDGE: 'Kühlschrank', FREEZER: 'Gefrierfach', KITCHEN_CABINET_1: 'Küchenschrank 1', KITCHEN_CABINET_2: 'Küchenschrank 2', KITCHEN_CABINET_3: 'Küchenschrank 3', PANTRY: 'Vorratsschrank' };
const TRASH_TYPE_LABEL = { RESTMUELL: 'Restmüll', BIOMUELL: 'Biomüll', GELBER_SACK: 'Gelber Sack', PAPIER: 'Papier', GLAS: 'Glas' };
const DOCUMENT_CATEGORY_LABEL = { HAUSORDNUNG: 'Hausordnung', WLAN: 'WLAN', VERTRAG: 'Vertrag', TUTORIAL: 'Tutorial', SONSTIGES: 'Sonstiges' };
+ const CRAFTSMAN_TRADE_LABEL = { SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', HEIZUNG: 'Heizung', SCHREINEREI: 'Schreinerei', MALER: 'Maler', SCHLUESSELDIENST: 'Schlüsseldienst', SONSTIGES: 'Sonstiges' };
+ const SETTLEMENT_STATUS_LABEL = { OPEN: 'Offen', SETTLED: 'Beglichen' };
function formatEuro(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
@@ -910,6 +972,8 @@
document.getElementById('storageFormCard').style.display = isLandlord ? 'block' : 'none';
document.getElementById('trashFormCard').style.display = isLandlord ? 'block' : 'none';
document.getElementById('documentFormCard').style.display = isLandlord ? 'block' : 'none';
+ document.getElementById('utilityStatementFormCard').style.display = isLandlord ? 'block' : 'none';
+ document.getElementById('craftsmanFormCard').style.display = isLandlord ? 'block' : 'none';
initTicketForm();
initAbsenceForm();
@@ -919,6 +983,8 @@
initGuestCodeForm();
if (isLandlord) initStorageForms();
if (isLandlord) initDocumentForm();
+ if (isLandlord) initUtilityStatementForm();
+ if (isLandlord) initCraftsmanForm();
loadData();
loadCleaningTasks();
@@ -929,6 +995,9 @@
loadGuestCodes();
loadStorage();
loadDocuments();
+ loadNoticeDeadlines();
+ loadUtilityStatements();
+ loadCraftsmen();
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
@@ -939,6 +1008,9 @@
setInterval(loadGuestCodes, REFRESH_INTERVAL_MS);
setInterval(loadStorage, REFRESH_INTERVAL_MS);
setInterval(loadDocuments, REFRESH_INTERVAL_MS);
+ setInterval(loadNoticeDeadlines, REFRESH_INTERVAL_MS);
+ setInterval(loadUtilityStatements, REFRESH_INTERVAL_MS);
+ setInterval(loadCraftsmen, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
@@ -1142,6 +1214,9 @@
`${t.room ? t.room.roomNumber : 'Gemeinschaftsbereich'} · ${CATEGORY_LABEL[t.category] || t.category} · ` +
`${t.creator ? t.creator.fullName + ' · ' : ''}${new Date(t.createdAt).toLocaleDateString('de-DE')} · ` +
TICKET_STATUS_LABEL[t.status]));
+ if (t.craftsman) {
+ left.appendChild(el('div', 'meta-text', `Handwerker: ${t.craftsman.name} (${CRAFTSMAN_TRADE_LABEL[t.craftsman.trade] || t.craftsman.trade})${t.craftsman.phone ? ' · ' + t.craftsman.phone : ''}`));
+ }
row.appendChild(left);
const right = el('div');
@@ -1151,6 +1226,22 @@
right.appendChild(el('span', `priority-chip ${t.priority}`, PRIORITY_LABEL[t.priority] || t.priority));
if (isLandlord) {
+ const craftsmanSelect = document.createElement('select');
+ craftsmanSelect.style.maxWidth = '160px';
+ const noneOpt = document.createElement('option');
+ noneOpt.value = '';
+ noneOpt.textContent = 'Kein Handwerker';
+ craftsmanSelect.appendChild(noneOpt);
+ lastCraftsmen.forEach(c => {
+ const opt = document.createElement('option');
+ opt.value = c.id;
+ opt.textContent = c.name;
+ if (t.craftsman && t.craftsman.id === c.id) opt.selected = true;
+ craftsmanSelect.appendChild(opt);
+ });
+ craftsmanSelect.onchange = () => assignCraftsmanToTicket(t.id, craftsmanSelect.value);
+ right.appendChild(craftsmanSelect);
+
if (t.status === 'OPEN') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
@@ -2322,6 +2413,262 @@
}
}
+ // ---------------------------------------------------------------------
+ // KÜNDIGUNGSFRISTEN
+ // ---------------------------------------------------------------------
+
+ async function loadNoticeDeadlines() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/contracts/notice-deadlines');
+ renderNoticeDeadlines(data.deadlines);
+ } catch (err) {
+ console.error('[notice-deadlines] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderNoticeDeadlines(deadlines) {
+ const list = document.getElementById('noticeDeadlinesList');
+ list.innerHTML = '';
+ if (!deadlines.length) {
+ list.appendChild(el('div', 'empty-state', 'Keine aktiven Verträge gefunden.'));
+ return;
+ }
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ deadlines.forEach(d => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ const who = isLandlord ? `${d.user.fullName} · ${d.room ? d.room.roomNumber : ''} · ` : '';
+ left.appendChild(el('div', 'title', who + (d.endDate ? `Vertrag befristet bis ${new Date(d.endDate).toLocaleDateString('de-DE')}` : 'Unbefristeter Vertrag')));
+ const metaParts = [`Kündigungsfrist: ${d.noticePeriodMonths} Monate`];
+ if (d.noticeDeadline) {
+ metaParts.push(`spätester Kündigungstermin: ${new Date(d.noticeDeadline).toLocaleDateString('de-DE')}`);
+ if (d.isOverdue) metaParts.push('Frist bereits verstrichen');
+ else if (d.isUrgent) metaParts.push(`noch ${d.daysUntilDeadline} Tage`);
+ }
+ left.appendChild(el('div', 'meta-text', metaParts.join(' · ')));
+ row.appendChild(left);
+ if (d.isUrgent || d.isOverdue) {
+ const right = el('div');
+ right.appendChild(el('span', `priority-chip ${d.isOverdue ? 'EMERGENCY' : 'HIGH'}`, d.isOverdue ? 'Frist abgelaufen' : 'Bald fällig'));
+ row.appendChild(right);
+ }
+ list.appendChild(row);
+ });
+ }
+
+ // ---------------------------------------------------------------------
+ // NEBENKOSTENABRECHNUNG
+ // ---------------------------------------------------------------------
+
+ function initUtilityStatementForm() {
+ const form = document.getElementById('utilityStatementForm');
+ const errorBox = document.getElementById('utilityStatementFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('utilStatementSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const periodStart = document.getElementById('utilPeriodStart').value;
+ const periodEnd = document.getElementById('utilPeriodEnd').value;
+ const totalAmount = document.getElementById('utilTotalAmount').value;
+ const description = document.getElementById('utilDescription').value.trim();
+ await apiFetch('/utility-statements', {
+ method: 'POST',
+ body: JSON.stringify({ periodStart, periodEnd, totalAmount, description }),
+ });
+ form.reset();
+ loadUtilityStatements();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Abrechnung anlegen (Split nach Zimmergröße)';
+ }
+ };
+ }
+
+ async function loadUtilityStatements() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/utility-statements');
+ renderUtilityStatements(data.statements);
+ } catch (err) {
+ console.error('[utility-statements] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderUtilityStatements(statements) {
+ const list = document.getElementById('utilityStatementsList');
+ list.innerHTML = '';
+ if (!statements.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Nebenkostenabrechnung erstellt.'));
+ return;
+ }
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ statements.forEach(s => {
+ const wrap = el('div', 'ticket-row');
+ wrap.style.flexDirection = 'column';
+ wrap.style.alignItems = 'stretch';
+ const head = el('div');
+ head.appendChild(el('div', 'title',
+ `${new Date(s.periodStart).toLocaleDateString('de-DE')} – ${new Date(s.periodEnd).toLocaleDateString('de-DE')} · ${formatEuro(s.totalAmount)}`));
+ head.appendChild(el('div', 'meta-text', s.description || ''));
+ wrap.appendChild(head);
+
+ (s.shares || []).forEach(share => {
+ const shareRow = el('div', 'ticket-row');
+ shareRow.style.marginTop = '6px';
+ const left = el('div');
+ left.appendChild(el('div', 'title', isLandlord ? `${share.user.fullName} · ${share.room ? share.room.roomNumber : ''}` : 'Mein Anteil'));
+ left.appendChild(el('div', 'meta-text', `${formatEuro(share.shareAmount)} · ${SETTLEMENT_STATUS_LABEL[share.settlementStatus]}`));
+ shareRow.appendChild(left);
+ if (share.settlementStatus === 'OPEN' && (isLandlord || share.user.id === currentUser.id)) {
+ const btn = document.createElement('button');
+ btn.className = 'btn-secondary';
+ btn.textContent = 'Als beglichen markieren';
+ btn.onclick = () => settleUtilityShare(share.id);
+ shareRow.appendChild(btn);
+ }
+ wrap.appendChild(shareRow);
+ });
+
+ if (isLandlord) {
+ const delBtn = document.createElement('button');
+ delBtn.className = 'btn-secondary';
+ delBtn.style.marginTop = '8px';
+ delBtn.style.width = 'fit-content';
+ delBtn.textContent = 'Abrechnung löschen';
+ delBtn.onclick = () => deleteUtilityStatement(s.id);
+ wrap.appendChild(delBtn);
+ }
+
+ list.appendChild(wrap);
+ });
+ }
+
+ async function settleUtilityShare(id) {
+ try {
+ await apiFetch(`/utility-statement-shares/${id}`, { method: 'PATCH' });
+ loadUtilityStatements();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function deleteUtilityStatement(id) {
+ try {
+ await apiFetch(`/utility-statements/${id}`, { method: 'DELETE' });
+ loadUtilityStatements();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // HANDWERKER-KONTAKTE
+ // ---------------------------------------------------------------------
+
+ let lastCraftsmen = [];
+
+ function populateCraftsmanTradeSelect() {
+ const select = document.getElementById('craftsmanTrade');
+ select.innerHTML = '';
+ Object.keys(CRAFTSMAN_TRADE_LABEL).forEach(key => {
+ const opt = document.createElement('option');
+ opt.value = key;
+ opt.textContent = CRAFTSMAN_TRADE_LABEL[key];
+ select.appendChild(opt);
+ });
+ }
+
+ function initCraftsmanForm() {
+ populateCraftsmanTradeSelect();
+ const form = document.getElementById('craftsmanForm');
+ const errorBox = document.getElementById('craftsmanFormError');
+ form.onsubmit = async (e) => {
+ e.preventDefault();
+ errorBox.style.display = 'none';
+ const submitBtn = document.getElementById('craftsmanSubmitBtn');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Speichert…';
+ try {
+ const name = document.getElementById('craftsmanName').value.trim();
+ const trade = document.getElementById('craftsmanTrade').value;
+ const phone = document.getElementById('craftsmanPhone').value.trim();
+ const email = document.getElementById('craftsmanEmail').value.trim();
+ await apiFetch('/craftsmen', { method: 'POST', body: JSON.stringify({ name, trade, phone, email }) });
+ form.reset();
+ populateCraftsmanTradeSelect();
+ loadCraftsmen();
+ } catch (err) {
+ errorBox.textContent = err.message;
+ errorBox.style.display = 'block';
+ } finally {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Hinzufügen';
+ }
+ };
+ }
+
+ async function loadCraftsmen() {
+ if (!currentUser) return;
+ try {
+ const data = await apiFetch('/craftsmen');
+ lastCraftsmen = data.craftsmen;
+ renderCraftsmen(data.craftsmen);
+ } catch (err) {
+ console.error('[craftsmen] Laden fehlgeschlagen:', err.message);
+ }
+ }
+
+ function renderCraftsmen(craftsmen) {
+ const list = document.getElementById('craftsmenList');
+ if (!list) return;
+ list.innerHTML = '';
+ const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
+ if (!craftsmen.length) {
+ list.appendChild(el('div', 'empty-state', 'Noch keine Handwerker-Kontakte hinterlegt.'));
+ return;
+ }
+ craftsmen.forEach(c => {
+ const row = el('div', 'ticket-row');
+ const left = el('div');
+ left.appendChild(el('div', 'title', `${c.name} · ${CRAFTSMAN_TRADE_LABEL[c.trade] || c.trade}`));
+ left.appendChild(el('div', 'meta-text', [c.phone, c.email].filter(Boolean).join(' · ') || 'Keine Kontaktdaten hinterlegt'));
+ row.appendChild(left);
+ if (isLandlord) {
+ const delBtn = document.createElement('button');
+ delBtn.className = 'btn-secondary';
+ delBtn.textContent = 'Entfernen';
+ delBtn.onclick = () => deleteCraftsman(c.id);
+ row.appendChild(delBtn);
+ }
+ list.appendChild(row);
+ });
+ }
+
+ async function deleteCraftsman(id) {
+ try {
+ await apiFetch(`/craftsmen/${id}`, { method: 'DELETE' });
+ loadCraftsmen();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ async function assignCraftsmanToTicket(ticketId, craftsmanId) {
+ try {
+ await apiFetch(`/tickets/${ticketId}`, { method: 'PATCH', body: JSON.stringify({ craftsmanId: craftsmanId || null }) });
+ loadData();
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
bootstrap();