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();