wg-verwaltung/web-dashboard/index.html
bernd 92101e3077 Let uploaded documents open as a preview, download separately
Every uploaded-document link (contract PDFs, ID front/back, Schufa
report — active and archived) previously forced a download on click
via the `download` attribute, with no way to just look at the file
first. New shared buildDocumentChip() renders the filename as a
target="_blank" link (browsers preview PDFs/images natively) plus a
small ⬇ icon next to it for an explicit download, reused across all
three places these chips are rendered.
2026-08-13 12:11:08 +00:00

3840 lines
147 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WG Nackenheim — Vermieter-Cockpit</title>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.15/locales/de.global.min.js"></script>
<style>
:root {
--bg: #F5F6F5;
--card-bg: #FFFFFF;
--text: #1F2933;
--text-muted: #6B7280;
--border: #E5E7EB;
--green: #2E7D6B;
--green-bg: #E6F4EF;
--yellow: #B8860B;
--yellow-bg: #FBF3D9;
--red: #C0392B;
--red-bg: #FBEAE8;
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
padding: 28px 32px 16px;
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: 12px;
}
header h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
header p { margin: 0; color: var(--text-muted); font-size: 14px; }
.meta { text-align: right; font-size: 12px; color: var(--text-muted); }
.refresh-btn {
border: 1px solid var(--border);
background: var(--card-bg);
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
color: var(--text);
box-shadow: var(--shadow);
}
.refresh-btn:hover { background: #FAFAFA; }
main { padding: 8px 32px 40px; max-width: 1100px; margin: 0 auto; }
.stat-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin: 16px 0 28px;
}
.stat-tile {
background: var(--card-bg);
border-radius: 12px;
padding: 16px 18px;
box-shadow: var(--shadow);
}
.stat-tile .value { font-size: 26px; font-weight: 700; }
.stat-tile .label { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
.stat-tile.green .value { color: var(--green); }
.stat-tile.yellow .value { color: var(--yellow); }
.stat-tile.red .value { color: var(--red); }
section h2 {
font-size: 15px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 28px 0 12px;
}
.room-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 14px;
}
.room-card {
background: var(--card-bg);
border-radius: 14px;
padding: 18px;
box-shadow: var(--shadow);
border-left: 5px solid var(--border);
}
.room-card.GREEN { border-left-color: var(--green); }
.room-card.YELLOW { border-left-color: var(--yellow); }
.room-card.RED { border-left-color: var(--red); }
.room-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.room-card .room-name { font-weight: 700; font-size: 16px; }
.badge {
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge.GREEN { background: var(--green-bg); color: var(--green); }
.badge.YELLOW { background: var(--yellow-bg); color: var(--yellow); }
.badge.RED { background: var(--red-bg); color: var(--red); }
.room-card .tenant { color: var(--text-muted); font-size: 13.5px; margin-bottom: 10px; }
.room-card .amount-row { display: flex; justify-content: space-between; font-size: 13.5px; }
.room-card .amount-row .label { color: var(--text-muted); }
.room-card .amount-row .value { font-weight: 600; }
.ticket-list { display: flex; flex-direction: column; gap: 10px; }
.ticket-row {
background: var(--card-bg);
border-radius: 10px;
padding: 12px 16px;
box-shadow: var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.ticket-row .title { font-weight: 600; font-size: 14px; }
.ticket-row .meta-text { color: var(--text-muted); font-size: 12.5px; }
.priority-chip {
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
background: #EEF0F2;
color: var(--text-muted);
}
.priority-chip.EMERGENCY, .priority-chip.HIGH { background: var(--red-bg); color: var(--red); }
.priority-chip.MEDIUM { background: var(--yellow-bg); color: var(--yellow); }
.empty-state {
background: var(--card-bg);
border-radius: 12px;
padding: 24px;
text-align: center;
color: var(--text-muted);
box-shadow: var(--shadow);
font-size: 14px;
}
.error-banner {
background: var(--red-bg);
color: var(--red);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 20px;
font-size: 14px;
}
.error-banner code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 4px; }
/* --- Auth / Invite views --- */
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
background: var(--card-bg);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 32px 28px;
width: 100%;
max-width: 380px;
}
#acceptInviteView .auth-card { max-width: 460px; }
.field-hint { font-size: 11.5px; color: var(--text-muted); margin: -10px 0 14px; }
.auth-card h1 { font-size: 19px; margin: 0 0 4px; }
.auth-card p.sub { color: var(--text-muted); font-size: 13.5px; margin: 0 0 22px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-muted); margin-bottom: 5px; }
.field input, .field select {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
font-size: 14px;
font-family: inherit;
background: #fff;
color: var(--text);
}
.field input:focus, .field select:focus { outline: 2px solid var(--green); outline-offset: -1px; }
.btn-primary {
width: 100%;
background: var(--green);
color: #fff;
border: none;
border-radius: 8px;
padding: 11px 14px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
}
.btn-primary:hover { opacity: 0.92; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
background: #fff;
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
}
.form-error {
background: var(--red-bg);
color: var(--red);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 14px;
}
.form-success {
background: var(--green-bg);
color: var(--green);
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
margin-bottom: 14px;
}
.view { display: none; }
.view.active { display: block; }
.topbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 12px 32px 0;
font-size: 13px;
color: var(--text-muted);
}
.invite-card {
background: var(--card-bg);
border-radius: 14px;
padding: 20px;
box-shadow: var(--shadow);
}
.invite-form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
}
.invite-form-row .field { flex: 1 1 200px; margin-bottom: 0; }
.invite-form-row button { flex: 0 0 auto; height: 40px; padding: 0 18px; }
.invite-link-box {
display: flex;
gap: 8px;
align-items: center;
background: var(--bg);
border: 1px dashed var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-top: 14px;
font-size: 13px;
word-break: break-all;
}
.invite-link-box code { flex: 1; }
.invite-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 13.5px; }
.invite-table th, .invite-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.invite-table th { color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
.invite-status-chip {
font-size: 11px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
text-transform: uppercase;
}
.invite-status-chip.PENDING { background: var(--yellow-bg); color: var(--yellow); }
.invite-status-chip.ACCEPTED { background: var(--green-bg); color: var(--green); }
.invite-status-chip.EXPIRED, .invite-status-chip.REVOKED { background: #EEF0F2; color: var(--text-muted); }
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.calendar-weekday {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
text-align: center;
padding-bottom: 4px;
}
.calendar-day {
background: var(--card-bg, #fff);
border: 1px solid var(--border);
border-radius: 8px;
min-height: 78px;
padding: 6px;
font-size: 11.5px;
display: flex;
flex-direction: column;
gap: 3px;
}
.calendar-day.calendar-day-outside { opacity: 0.35; }
.calendar-day.calendar-day-today { border-color: var(--green, #2f8f5b); border-width: 2px; }
.calendar-day-num { font-weight: 700; font-size: 12px; }
.calendar-badge {
border-radius: 5px;
padding: 1px 5px;
font-size: 10.5px;
line-height: 1.4;
white-space: normal;
}
.calendar-badge-task { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); }
.calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
.inventory-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
}
.inventory-thumb-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
/* --- Müll-Kalender (FullCalendar) --- */
.trash-calendar-wrap {
background: var(--card-bg);
border-radius: 14px;
padding: 16px 18px;
box-shadow: var(--shadow);
}
.trash-calendar-legend { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; font-size: 12.5px; color: var(--text-muted); }
.trash-calendar-legend span { display: inline-flex; align-items: center; gap: 6px; }
.trash-calendar-legend .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
/* --- Vertragsvorlage & Unterschrift --- */
.contract-template-form {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
align-items: flex-end;
background: var(--bg);
border: 1px dashed var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.contract-template-form .field { margin-bottom: 0; }
.contract-template-form .field-checkbox { display: flex; align-items: center; gap: 6px; font-size: 13px; padding-bottom: 10px; }
.contract-template-form .field-checkbox input { width: auto; }
.signature-pad-wrap { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.signature-pad-canvas { border: 1px solid var(--border); border-radius: 8px; background: #fff; touch-action: none; cursor: crosshair; }
#trashCalendarEl { font-size: 13px; }
#trashCalendarEl .fc { font-family: inherit; }
#trashCalendarEl .fc-toolbar-title { font-size: 16px; font-weight: 700; color: var(--text); }
#trashCalendarEl .fc-button {
background: var(--card-bg);
border: 1px solid var(--border);
color: var(--text);
box-shadow: none;
text-transform: capitalize;
padding: 6px 12px;
}
#trashCalendarEl .fc-button:hover { background: var(--bg); }
#trashCalendarEl .fc-button-primary:not(:disabled).fc-button-active { background: var(--green); border-color: var(--green); color: #fff; }
#trashCalendarEl .fc-daygrid-day.fc-day-today { background: var(--green-bg); }
#trashCalendarEl .fc-event { border: none; padding: 1px 5px; font-size: 11.5px; cursor: default; }
#trashCalendarEl .fc-daygrid-event-dot { display: none; }
#trashCalendarEl a { text-decoration: none; }
/* --- App shell / sidebar navigation --- */
.app-shell { display: flex; align-items: flex-start; min-height: 100vh; }
.sidebar {
flex: 0 0 232px;
width: 232px;
background: var(--card-bg);
border-right: 1px solid var(--border);
min-height: 100vh;
padding: 18px 12px;
position: sticky;
top: 0;
}
.sidebar-brand { padding: 6px 10px 18px; font-weight: 700; font-size: 14.5px; }
.sidebar-brand span { display: block; font-weight: 400; color: var(--text-muted); font-size: 11.5px; margin-top: 2px; }
.sidebar nav { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
border-radius: 8px;
font-family: inherit;
font-size: 13.5px;
color: var(--text);
text-align: left;
cursor: pointer;
}
.nav-item .nav-icon { font-size: 15px; width: 18px; text-align: center; }
.nav-item:hover { background: var(--bg); }
.nav-item.active { background: var(--green-bg); color: var(--green); font-weight: 600; }
.content-area { flex: 1; min-width: 0; }
.page { display: none; }
.page.active { display: block; }
.hamburger-btn {
display: none;
border: 1px solid var(--border);
background: var(--card-bg);
border-radius: 8px;
padding: 8px 11px;
font-size: 15px;
cursor: pointer;
box-shadow: var(--shadow);
}
.sidebar-overlay { display: none; }
@media (max-width: 880px) {
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 60;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 4px 0 14px rgba(0,0,0,0.12);
}
.sidebar.open { transform: translateX(0); }
.hamburger-btn { display: inline-flex; }
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.32);
z-index: 50;
}
.sidebar-overlay.open { display: block; }
}
</style>
</head>
<body>
<!-- ================= LOGIN VIEW ================= -->
<div id="loginView" class="view">
<div class="auth-wrap">
<div class="auth-card">
<h1>WG Nackenheim — Anmelden</h1>
<p class="sub">Vermieter-Cockpit &amp; Mieterbereich</p>
<div id="loginError" style="display:none" class="form-error"></div>
<form id="loginForm">
<div class="field">
<label for="loginEmail">E-Mail-Adresse</label>
<input type="email" id="loginEmail" required autocomplete="email" />
</div>
<div class="field">
<label for="loginPassword">Passwort</label>
<input type="password" id="loginPassword" required autocomplete="current-password" />
</div>
<button type="submit" class="btn-primary" id="loginSubmitBtn">Anmelden</button>
</form>
</div>
</div>
</div>
<!-- ================= ACCEPT INVITE VIEW ================= -->
<div id="acceptInviteView" class="view">
<div class="auth-wrap">
<div class="auth-card">
<h1>Einladung annehmen</h1>
<p class="sub" id="acceptInviteSub">Lade Einladung…</p>
<div id="acceptInviteError" style="display:none" class="form-error"></div>
<form id="acceptInviteForm" style="display:none">
<div class="field">
<label for="acceptEmail">E-Mail-Adresse</label>
<input type="email" id="acceptEmail" disabled />
</div>
<div class="field">
<label for="acceptFullName">Vollständiger Name</label>
<input type="text" id="acceptFullName" required autocomplete="name" />
</div>
<div class="field">
<label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label>
<input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" />
</div>
<div id="acceptTenantFields" style="display:none">
<div class="field">
<label for="acceptPhone">Telefonnummer</label>
<input type="tel" id="acceptPhone" autocomplete="tel" placeholder="z. B. 0151 23456789" />
</div>
<div class="field">
<label for="acceptFirstResidence">Aktueller Erstwohnsitz (laut Meldebescheinigung)</label>
<input type="text" id="acceptFirstResidence" placeholder="Straße Hausnr., PLZ Ort" />
</div>
<div class="field">
<label for="acceptIdFront">Ausweiskopie — Vorderseite</label>
<input type="file" id="acceptIdFront" accept="image/*,application/pdf" />
</div>
<div class="field">
<label for="acceptIdBack">Ausweiskopie — Rückseite</label>
<input type="file" id="acceptIdBack" accept="image/*,application/pdf" />
</div>
<div class="field">
<label for="acceptSchufa">Schufa-Auskunft (optional)</label>
<input type="file" id="acceptSchufa" accept="image/*,application/pdf" />
</div>
<div class="field" id="acceptSchufaDateField" style="display:none">
<label for="acceptSchufaDate">Ausstellungsdatum der Schufa-Auskunft</label>
<input type="date" id="acceptSchufaDate" />
</div>
<p class="field-hint">Telefonnummer, Erstwohnsitz und Ausweiskopie (beidseitig) sind für Mieter Pflichtangaben. Eine Schufa-Auskunft darf, falls hochgeladen, nicht älter als 3 Monate sein.</p>
</div>
<button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen &amp; anmelden</button>
</form>
</div>
</div>
</div>
<!-- ================= APP VIEW ================= -->
<div id="appView" class="view">
<div class="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>
<div class="app-shell">
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">WG Nackenheim<span>Vermieter-Cockpit</span></div>
<nav id="sidebarNav">
<button class="nav-item active" data-page="page-overview" onclick="switchPage('page-overview')"><span class="nav-icon">🏠</span>Übersicht</button>
<button class="nav-item" data-page="page-tenants" onclick="switchPage('page-tenants')"><span class="nav-icon">👥</span>Mieter</button>
<button class="nav-item" data-page="page-tickets" onclick="switchPage('page-tickets')"><span class="nav-icon">🛠️</span>Tickets &amp; Handwerker</button>
<button class="nav-item" data-page="page-cleaning" onclick="switchPage('page-cleaning')"><span class="nav-icon">🧹</span>Putzplan</button>
<button class="nav-item" data-page="page-finance" onclick="switchPage('page-finance')"><span class="nav-icon">💶</span>WG-Kasse</button>
<button class="nav-item" data-page="page-inventory" onclick="switchPage('page-inventory')"><span class="nav-icon">📦</span>Inventar &amp; Übergabe</button>
<button class="nav-item" data-page="page-access" onclick="switchPage('page-access')"><span class="nav-icon">🔑</span>Zugang</button>
<button class="nav-item" data-page="page-household" onclick="switchPage('page-household')"><span class="nav-icon">🍽️</span>Küche &amp; Müll</button>
<button class="nav-item" data-page="page-documents" onclick="switchPage('page-documents')"><span class="nav-icon">📄</span>Dokumente</button>
<button class="nav-item" data-page="page-contracts" onclick="switchPage('page-contracts')"><span class="nav-icon">📑</span>Verträge &amp; Abrechnung</button>
</nav>
</aside>
<div class="content-area">
<div class="topbar">
<button class="hamburger-btn" onclick="openSidebar()"></button>
<span id="whoAmI"></span>
<button class="btn-secondary" onclick="logout()">Abmelden</button>
</div>
<header>
<div>
<h1>WG Nackenheim — Vermieter-Cockpit</h1>
<p>170 qm · 4 Zimmer · Einzelzimmervermietung</p>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<div class="meta" id="lastUpdated"></div>
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button>
</div>
</header>
<main>
<div id="errorBanner" style="display:none" class="error-banner"></div>
<div class="page active" id="page-overview">
<div class="stat-row" id="statRow"></div>
<section id="rentSection">
<h2 id="rentSectionTitle">Miet-Ampel je Zimmer</h2>
<div class="room-grid" id="roomGrid"></div>
</section>
</div>
<div class="page" id="page-tenants">
<section id="inviteSection" style="display:none">
<h2>Mieter einladen</h2>
<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="inviteEmail">E-Mail-Adresse des Mieters</label>
<input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" />
</div>
<div class="field">
<label for="inviteRoom">Zimmer (optional)</label>
<select id="inviteRoom">
<option value=""> kein Zimmer zuordnen </option>
</select>
</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">
<code id="inviteLinkText"></code>
<button class="btn-secondary" onclick="copyInviteLink()">Kopieren</button>
</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>
</thead>
<tbody id="inviteTableBody"></tbody>
</table>
</div>
</section>
<section id="ratingsSection">
<h2>Mieter-Bewertung</h2>
<div class="invite-card" style="margin-bottom:16px;">
<div id="ratingFormError" style="display:none" class="form-error"></div>
<form id="ratingForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="ratingTargetUser">Mieter</label>
<select id="ratingTargetUser" required></select>
</div>
<div class="field" style="flex:1 1 140px;">
<label for="ratingScore">Bewertung</label>
<select id="ratingScore" required>
<option value="5">5 sehr gut</option>
<option value="4">4 gut</option>
<option value="3">3 okay</option>
<option value="2">2 schlecht</option>
<option value="1">1 sehr schlecht</option>
</select>
</div>
<div class="field" style="flex:2 1 240px;">
<label for="ratingComment">Kommentar (optional)</label>
<input type="text" id="ratingComment" placeholder="z. B. sehr zuverlässig, hält sich an den Putzplan" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="ratingSubmitBtn">Bewertung abgeben</button>
</form>
</div>
<div id="ratingsListWrap" style="display:none">
<p class="meta-text" style="margin-bottom:8px;">Nur für Vermieter/Admin sichtbar.</p>
<div class="ticket-list" id="ratingsList"></div>
</div>
</section>
</div>
<div class="page" id="page-tickets">
<section>
<h2>Schaden melden</h2>
<div class="invite-card">
<div id="ticketFormError" style="display:none" class="form-error"></div>
<form id="ticketForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 220px;">
<label for="ticketTitle">Titel</label>
<input type="text" id="ticketTitle" required placeholder="z. B. Spülmaschine defekt" />
</div>
<div class="field" style="flex:1 1 100%;">
<label for="ticketDescription">Beschreibung</label>
<input type="text" id="ticketDescription" required placeholder="Was ist passiert?" />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="ticketCategory">Kategorie</label>
<select id="ticketCategory"></select>
</div>
<div class="field" style="flex:1 1 140px;">
<label for="ticketPriority">Priorität</label>
<select id="ticketPriority"></select>
</div>
<div class="field" style="flex:1 1 200px;">
<label for="ticketRoom">Bereich</label>
<select id="ticketRoom"></select>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="ticketSubmitBtn">Melden</button>
</form>
</div>
</section>
<section>
<h2>Offene Schadensmeldungen</h2>
<div class="ticket-list" id="ticketList"></div>
</section>
<section id="craftsmenSection">
<h2>Handwerker-Kontakte</h2>
<div class="invite-card" id="craftsmanFormCard" style="display:none; margin-bottom:16px;">
<div id="craftsmanFormError" style="display:none" class="form-error"></div>
<form id="craftsmanForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 180px;">
<label for="craftsmanName">Firma / Name</label>
<input type="text" id="craftsmanName" required placeholder="z. B. Sanitär Müller" />
</div>
<div class="field" style="flex:1 1 150px;">
<label for="craftsmanTrade">Gewerk</label>
<select id="craftsmanTrade"></select>
</div>
<div class="field" style="flex:1 1 150px;">
<label for="craftsmanPhone">Telefon</label>
<input type="text" id="craftsmanPhone" placeholder="z. B. 06131 12345" />
</div>
<div class="field" style="flex:1 1 180px;">
<label for="craftsmanEmail">E-Mail</label>
<input type="email" id="craftsmanEmail" placeholder="kontakt@handwerker.de" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="craftsmanSubmitBtn">Hinzufügen</button>
</form>
</div>
<div class="ticket-list" id="craftsmenList"></div>
</section>
</div>
<div class="page" id="page-cleaning">
<section id="cleaningAssignSection" style="display:none">
<h2>Putzaufgabe zuweisen</h2>
<div class="invite-card">
<div id="cleaningFormError" style="display:none" class="form-error"></div>
<form id="cleaningForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="cleaningUser">Mieter</label>
<select id="cleaningUser"></select>
</div>
<div class="field" style="flex:1 1 160px;">
<label for="cleaningArea">Bereich</label>
<select id="cleaningArea"></select>
</div>
<div class="field" style="flex:1 1 160px;">
<label for="cleaningWeekOf">Woche ab (Mo.)</label>
<input type="date" id="cleaningWeekOf" required />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="cleaningDueDate">Fällig bis</label>
<input type="date" id="cleaningDueDate" required />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="cleaningSubmitBtn">Zuweisen</button>
</form>
</div>
</section>
<section id="cleaningSection">
<h2 id="cleaningSectionTitle">Putzplan</h2>
<div class="ticket-list" id="cleaningList"></div>
</section>
<section id="cleaningCalendarSection">
<h2>Putzplan-Kalender &amp; Abwesenheiten</h2>
<p style="color:var(--text-muted); font-size:13px; margin-top:-8px;">
Zeigt für alle sichtbar, wer wann für welchen Bereich zuständig ist, sowie eingetragene Abwesenheiten (Urlaub, "kann nicht").
</p>
<div class="invite-card" style="margin-bottom:16px;">
<div id="absenceFormError" style="display:none" class="form-error"></div>
<form id="absenceForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" id="absenceUserField" style="flex:1 1 200px; display:none;">
<label for="absenceUser">Mieter</label>
<select id="absenceUser"></select>
</div>
<div class="field" style="flex:1 1 150px;">
<label for="absenceStart">Von</label>
<input type="date" id="absenceStart" required />
</div>
<div class="field" style="flex:1 1 150px;">
<label for="absenceEnd">Bis</label>
<input type="date" id="absenceEnd" required />
</div>
<div class="field" style="flex:2 1 220px;">
<label for="absenceNote">Notiz (optional)</label>
<input type="text" id="absenceNote" maxlength="280" placeholder="z. B. Urlaub" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="absenceSubmitBtn">Eintragen</button>
</form>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<button type="button" class="btn-secondary" id="calPrevBtn"> Vorheriger Monat</button>
<strong id="calMonthLabel"></strong>
<button type="button" class="btn-secondary" id="calNextBtn">Nächster Monat </button>
</div>
<div class="calendar-grid" id="calendarWeekdays"></div>
<div class="calendar-grid" id="calendarGrid" style="margin-top:6px;"></div>
<h3 style="margin-top:20px; font-size:14px;">Abwesenheiten im angezeigten Monat</h3>
<div class="ticket-list" id="absenceList"></div>
</section>
</div>
<div class="page" id="page-finance">
<section id="expensesSection">
<h2>WG-Kasse (Ausgaben-Teiler)</h2>
<p style="color:var(--text-muted); font-size:13px; margin-top:-8px;">
Auslagen wie Spülmittel oder Klopapier eintragen — der Betrag wird automatisch unter allen Mitbewohnern geteilt.
</p>
<div class="invite-card" style="margin-bottom:16px;">
<div id="expenseFormError" style="display:none" class="form-error"></div>
<form id="expenseForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 140px;">
<label for="expenseAmount">Betrag (€)</label>
<input type="number" id="expenseAmount" required min="0.01" step="0.01" />
</div>
<div class="field" style="flex:2 1 220px;">
<label for="expenseDescription">Beschreibung</label>
<input type="text" id="expenseDescription" required placeholder="z. B. Spülmittel + Klopapier" />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="expenseCategory">Kategorie</label>
<select id="expenseCategory"></select>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="expenseSubmitBtn">Eintragen</button>
</form>
</div>
<div class="room-grid" id="expenseBalanceGrid" style="margin-bottom:16px;"></div>
<div class="ticket-list" id="expenseList"></div>
</section>
</div>
<div class="page" id="page-inventory">
<section id="inventorySection">
<h2>Inventarverwaltung</h2>
<div class="invite-card" id="inventoryFormCard" style="display:none; margin-bottom:16px;">
<div id="inventoryFormError" style="display:none" class="form-error"></div>
<form id="inventoryForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="inventoryItemName">Gegenstand</label>
<input type="text" id="inventoryItemName" required placeholder="z. B. Kühlschrank" />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="inventoryRoom">Zimmer</label>
<select id="inventoryRoom"></select>
</div>
<div class="field" style="flex:1 1 140px;">
<label for="inventoryCondition">Zustand</label>
<select id="inventoryCondition"></select>
</div>
<div class="field" style="flex:1 1 120px;">
<label for="inventoryPrice">Preis (€, optional)</label>
<input type="number" id="inventoryPrice" min="0" step="0.01" />
</div>
<div class="field" style="flex:1 1 220px;">
<label for="inventoryPhotos">Fotos (optional, mehrere möglich)</label>
<input type="file" id="inventoryPhotos" accept="image/*" multiple />
</div>
<div id="inventoryPhotoPreview" style="display:flex; gap:6px; flex-wrap:wrap; flex-basis:100%;"></div>
<button type="submit" class="btn-primary" style="width:auto" id="inventorySubmitBtn">Hinzufügen</button>
</form>
</div>
<div class="ticket-list" id="inventoryList"></div>
</section>
<section id="handoverSection">
<h2>Übergabeprotokolle</h2>
<div class="invite-card" id="handoverFormCard" style="display:none; margin-bottom:16px;">
<div id="handoverFormError" style="display:none" class="form-error"></div>
<form id="handoverForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 220px;">
<label for="handoverContract">Mietvertrag</label>
<select id="handoverContract"></select>
</div>
<div class="field" style="flex:1 1 140px;">
<label for="handoverType">Typ</label>
<select id="handoverType">
<option value="MOVE_IN">Einzug</option>
<option value="MOVE_OUT">Auszug</option>
</select>
</div>
<div class="field" style="flex:1 1 160px;">
<label for="handoverDate">Datum</label>
<input type="date" id="handoverDate" required />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="handoverSubmitBtn">Protokoll anlegen</button>
</form>
</div>
<div class="ticket-list" id="handoverList"></div>
</section>
</div>
<div class="page" id="page-access">
<section id="guestCodesSection">
<h2>Smart-Lock-Gastcodes</h2>
<div class="invite-card" style="margin-bottom:16px;">
<div id="guestCodeFormError" style="display:none" class="form-error"></div>
<form id="guestCodeForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="guestCodeFrom">Gültig ab</label>
<input type="datetime-local" id="guestCodeFrom" required />
</div>
<div class="field" style="flex:1 1 200px;">
<label for="guestCodeUntil">Gültig bis</label>
<input type="datetime-local" id="guestCodeUntil" required />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="guestCodeSubmitBtn">Code generieren</button>
</form>
</div>
<div class="ticket-list" id="guestCodeList"></div>
</section>
</div>
<div class="page" id="page-household">
<section id="storageSection">
<h2>Küchen-/Schrankplaner</h2>
<div class="invite-card" id="storageFormCard" style="display:none; margin-bottom:16px;">
<div id="storageFormError" style="display:none" class="form-error"></div>
<form id="storageForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 180px;">
<label for="storageLocation">Ort</label>
<select id="storageLocation"></select>
</div>
<div class="field" style="flex:1 1 180px;">
<label for="storageLabel">Bezeichnung</label>
<input type="text" id="storageLabel" required placeholder="z. B. Fach 1 oben" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="storageSubmitBtn">Fach anlegen</button>
</form>
</div>
<div class="ticket-list" id="storageList"></div>
<h2 style="margin-top:28px;">Müll-Kalender</h2>
<div class="invite-card" id="trashFormCard" style="display:none; margin-bottom:16px;">
<div id="trashFormError" style="display:none" class="form-error"></div>
<form id="trashForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 180px;">
<label for="trashType">Mülltyp</label>
<select id="trashType"></select>
</div>
<div class="field" style="flex:1 1 160px;">
<label for="trashDate">Termin</label>
<input type="date" id="trashDate" required />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="trashSubmitBtn">Termin eintragen</button>
</form>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin:-4px 0 12px;">
<p class="meta-text" style="margin:0;">Restmüll, Biomüll, Gelbe Tonne und Papiertonne werden automatisch täglich von der Kommunalen Abfallwirtschaft Mainz-Bingen übernommen.</p>
<button type="button" class="btn-secondary" id="trashSyncBtn" style="display:none; white-space:nowrap;">↻ Kalender aktualisieren</button>
</div>
<div id="trashSyncStatus" class="meta-text" style="display:none; margin:-6px 0 12px;"></div>
<div class="trash-calendar-wrap">
<div class="trash-calendar-legend" id="trashCalendarLegend"></div>
<div id="trashCalendarEl"></div>
</div>
</section>
</div>
<div class="page" id="page-documents">
<section id="documentsSection">
<h2>Dokumenten-Safe &amp; Tutorials</h2>
<div class="invite-card" id="documentFormCard" style="display:none; margin-bottom:16px;">
<div id="documentFormError" style="display:none" class="form-error"></div>
<form id="documentForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 200px;">
<label for="documentTitle">Titel</label>
<input type="text" id="documentTitle" required placeholder="z. B. Hausordnung" />
</div>
<div class="field" style="flex:1 1 160px;">
<label for="documentCategory">Kategorie</label>
<select id="documentCategory"></select>
</div>
<div class="field" style="flex:2 1 220px;">
<label for="documentUrl">Link (PDF/Video/Bild)</label>
<input type="url" id="documentUrl" required placeholder="https://..." />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="documentSubmitBtn">Hinzufügen</button>
</form>
</div>
<div class="ticket-list" id="documentList"></div>
</section>
</div>
<div class="page" id="page-contracts">
<section id="contractDocumentsSection">
<h2>Vertragsdokumente</h2>
<p class="hint-text">Unterschriebener Mietvertrag, Nachträge etc. — je Vertrag können mehrere Dateien hinterlegt werden.</p>
<div class="ticket-list" id="contractDocumentsList"></div>
</section>
<section id="contractArchiveSection" style="display:none">
<h2>Archiv — ausgezogene Mieter</h2>
<p class="hint-text">Verträge werden beim Auszug nicht gelöscht, sondern hier dauerhaft mit allen Dokumenten aufbewahrt.</p>
<div class="ticket-list" id="contractArchiveList"></div>
</section>
<section id="noticeDeadlinesSection">
<h2>Kündigungsfristen</h2>
<p class="hint-text">Zeigt für befristete Mietverträge, bis wann spätestens gekündigt werden muss.</p>
<div class="ticket-list" id="noticeDeadlinesList"></div>
</section>
<section id="utilityStatementsSection">
<h2>Nebenkostenabrechnung</h2>
<div class="invite-card" id="utilityStatementFormCard" style="display:none; margin-bottom:16px;">
<div id="utilityStatementFormError" style="display:none" class="form-error"></div>
<form id="utilityStatementForm" class="invite-form-row" style="flex-wrap:wrap;">
<div class="field" style="flex:1 1 150px;">
<label for="utilPeriodStart">Zeitraum von</label>
<input type="date" id="utilPeriodStart" required />
</div>
<div class="field" style="flex:1 1 150px;">
<label for="utilPeriodEnd">Zeitraum bis</label>
<input type="date" id="utilPeriodEnd" required />
</div>
<div class="field" style="flex:1 1 140px;">
<label for="utilTotalAmount">Gesamtbetrag (€)</label>
<input type="number" step="0.01" min="0.01" id="utilTotalAmount" required placeholder="z. B. 840.00" />
</div>
<div class="field" style="flex:2 1 200px;">
<label for="utilDescription">Beschreibung</label>
<input type="text" id="utilDescription" placeholder="z. B. Nebenkosten 2025" />
</div>
<button type="submit" class="btn-primary" style="width:auto" id="utilStatementSubmitBtn">Abrechnung anlegen (Split nach Zimmergröße)</button>
</form>
</div>
<div class="ticket-list" id="utilityStatementsList"></div>
</section>
</div>
</main>
</div>
</div>
</div>
<script>
const API_BASE_URL = window.WG_API_BASE_URL ||
(location.protocol === 'file:' ? 'http://localhost:3000/v1' : '/v1');
const REFRESH_INTERVAL_MS = 20000;
const STATUS_LABEL = { GREEN: 'Bezahlt', YELLOW: 'Fällig', RED: 'Überfällig' };
const PRIORITY_LABEL = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall' };
const ROLE_LABEL = { LANDLORD: 'Vermieter', TENANT: 'Mieter', CRAFTSMAN: 'Handwerker', ADMIN: 'Verwaltung' };
const INVITE_STATUS_LABEL = { PENDING: 'Offen', ACCEPTED: 'Angenommen', EXPIRED: 'Abgelaufen', REVOKED: 'Zurückgezogen' };
const CATEGORY_LABEL = {
SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', MOEBEL: 'Möbel', HEIZUNG: 'Heizung',
SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges',
};
const TICKET_STATUS_LABEL = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen' };
const CLEANING_AREA_LABEL = { BATHROOM_1: 'Bad 1', BATHROOM_2: 'Bad 2', KITCHEN: 'Wohnküche', HALLWAY_LAUNDRY: 'Waschraum/Flur' };
const CLEANING_STATUS_LABEL = { PENDING: 'Offen', COMPLETED: 'Erledigt', VERIFIED: 'Bestätigt', MISSED: 'Verpasst' };
const EXPENSE_CATEGORY_LABEL = { HAUSHALT: 'Haushalt', LEBENSMITTEL_GEMEINSAM: 'Lebensmittel (gemeinsam)', REPARATUR_VORAUSLAGE: 'Reparatur-Vorauslage', SONSTIGES: 'Sonstiges' };
const ITEM_CONDITION_LABEL = { NEW: 'Neu', GOOD: 'Gut', WEAR_AND_TEAR: 'Abgenutzt', DAMAGED: 'Beschädigt' };
const HANDOVER_TYPE_LABEL = { MOVE_IN: 'Einzug', MOVE_OUT: 'Auszug' };
const GUEST_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', EXPIRED: 'Abgelaufen', REVOKED: 'Widerrufen' };
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 TRASH_TYPE_COLOR = { RESTMUELL: '#4A4A4A', BIOMUELL: '#8B5E34', GELBER_SACK: '#E0B400', PAPIER: '#2F80ED', GLAS: '#27AE60' };
const UTILITY_MODEL_LABEL = { PAUSCHALE: 'Pauschale (in Warmmiete enthalten)', ABRECHNUNG: 'Jährliche Abrechnung' };
const RENT_ADJUSTMENT_LABEL = { NONE: 'Keine', INDEX_MIETE: 'Indexmiete', STAFFEL_MIETE: 'Staffelmiete' };
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);
}
function el(tag, className, html) {
const e = document.createElement(tag);
if (className) e.className = className;
if (html !== undefined) e.innerHTML = html;
return e;
}
// ---------------------------------------------------------------------
// AUTH — Login, Session-Wiederherstellung, Einladungen annehmen
// ---------------------------------------------------------------------
let currentUser = null;
function getToken() { return localStorage.getItem('wg_token'); }
function setSession(token, user) {
localStorage.setItem('wg_token', token);
localStorage.setItem('wg_user', JSON.stringify(user));
currentUser = user;
}
function clearSession() {
localStorage.removeItem('wg_token');
localStorage.removeItem('wg_user');
currentUser = null;
}
function authHeaders() {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
async function apiFetch(path, options = {}) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(options.headers || {}),
},
});
let body = null;
try { body = await res.json(); } catch { /* kein JSON-Body */ }
if (!res.ok) {
const message = (body && body.error) || `Server antwortete mit Status ${res.status}`;
throw new Error(message);
}
return body;
}
async function bootstrap() {
const inviteToken = new URLSearchParams(location.search).get('invite');
if (inviteToken) {
showView('acceptInviteView');
initAcceptInvite(inviteToken);
return;
}
const storedUser = localStorage.getItem('wg_user');
if (getToken() && storedUser) {
try {
const data = await apiFetch('/auth/me');
currentUser = data.user;
enterApp();
return;
} catch {
clearSession();
}
}
showView('loginView');
initLogin();
}
function initLogin() {
const form = document.getElementById('loginForm');
const errorBox = document.getElementById('loginError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('loginSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Anmelden…';
try {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const data = await apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setSession(data.token, data.user);
enterApp();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Anmelden';
}
};
}
async function initAcceptInvite(token) {
const sub = document.getElementById('acceptInviteSub');
const errorBox = document.getElementById('acceptInviteError');
const form = document.getElementById('acceptInviteForm');
try {
const preview = await apiFetch(`/invitations/${token}/preview`);
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;
form.style.display = 'flex';
form.style.flexDirection = 'column';
const isTenant = preview.role === 'TENANT';
document.getElementById('acceptTenantFields').style.display = isTenant ? 'block' : 'none';
const idFrontInput = document.getElementById('acceptIdFront');
const idBackInput = document.getElementById('acceptIdBack');
const schufaInput = document.getElementById('acceptSchufa');
const schufaDateField = document.getElementById('acceptSchufaDateField');
const schufaDateInput = document.getElementById('acceptSchufaDate');
if (isTenant) {
idFrontInput.required = true;
idBackInput.required = true;
document.getElementById('acceptPhone').required = true;
document.getElementById('acceptFirstResidence').required = true;
}
schufaInput.onchange = () => {
schufaDateField.style.display = schufaInput.files.length ? 'block' : 'none';
schufaDateInput.required = !!schufaInput.files.length;
};
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('acceptSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Wird erstellt…';
try {
const fullName = document.getElementById('acceptFullName').value.trim();
const password = document.getElementById('acceptPassword').value;
const body = { fullName, password };
if (isTenant) {
const MAX_RAW_UPLOAD_BYTES = 25 * 1024 * 1024; // Rohdatei vor Komprimierung — Fotos werden verkleinert, PDFs bleiben wie sie sind
const tooLarge = [idFrontInput.files[0], idBackInput.files[0], schufaInput.files[0]]
.filter(f => f && f.size > MAX_RAW_UPLOAD_BYTES);
if (tooLarge.length) {
throw new Error(`Datei zu groß (max. 25 MB): ${tooLarge.map(f => f.name).join(', ')}. Bitte kleineres Foto/Scan wählen.`);
}
body.phoneNumber = document.getElementById('acceptPhone').value.trim();
body.firstResidenceAddress = document.getElementById('acceptFirstResidence').value.trim();
body.idDocumentFrontUrl = await readFileAsCompressedDataUrl(idFrontInput.files[0], 1800, 0.82);
body.idDocumentBackUrl = await readFileAsCompressedDataUrl(idBackInput.files[0], 1800, 0.82);
if (schufaInput.files.length) {
body.schufaDocumentUrl = await readFileAsCompressedDataUrl(schufaInput.files[0], 1800, 0.82);
body.schufaDocumentDate = schufaDateInput.value;
}
}
const data = await apiFetch(`/invitations/${token}/accept`, {
method: 'POST',
body: JSON.stringify(body),
});
setSession(data.token, data.user);
history.replaceState(null, '', location.pathname);
enterApp();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Konto erstellen & anmelden';
}
};
} catch (err) {
sub.textContent = '';
errorBox.textContent = err.message;
errorBox.style.display = 'block';
}
}
function logout() {
clearSession();
history.replaceState(null, '', location.pathname);
location.reload();
}
// ---------------------------------------------------------------------
// Sidebar-Navigation
// ---------------------------------------------------------------------
function switchPage(pageId) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(pageId).classList.add('active');
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
const activeBtn = document.querySelector(`.nav-item[data-page="${pageId}"]`);
if (activeBtn) activeBtn.classList.add('active');
closeSidebar();
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
// FullCalendar berechnet seine Breite beim Erstellen; war die Seite beim
// ersten Laden der Daten noch nicht sichtbar (display:none), muss es nach
// dem Einblenden einmal neu vermessen werden.
if (pageId === 'page-household' && trashCalendar) trashCalendar.updateSize();
}
function openSidebar() {
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebarOverlay').classList.add('open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebarOverlay').classList.remove('open');
}
function enterApp() {
showView('appView');
document.getElementById('whoAmI').textContent =
`${currentUser.fullName} · ${ROLE_LABEL[currentUser.role] || currentUser.role}`;
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('inviteSection').style.display = isLandlord ? 'block' : 'none';
document.getElementById('rentSectionTitle').textContent = isLandlord ? 'Miet-Ampel je Zimmer' : 'Meine Miete';
document.getElementById('cleaningAssignSection').style.display = isLandlord ? 'block' : 'none';
document.getElementById('cleaningSectionTitle').textContent = isLandlord ? 'Putzplan (alle Mieter)' : 'Mein Putzplan';
if (isLandlord) initInvitePanel();
if (isLandlord) initCleaningAssignForm();
document.getElementById('absenceUserField').style.display = isLandlord ? 'flex' : 'none';
document.getElementById('inventoryFormCard').style.display = isLandlord ? 'block' : 'none';
document.getElementById('handoverFormCard').style.display = isLandlord ? 'block' : 'none';
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();
initExpenseForm();
if (isLandlord) initInventoryForm();
if (isLandlord) initHandoverForm();
initGuestCodeForm();
if (isLandlord) initStorageForms();
if (isLandlord) initDocumentForm();
if (isLandlord) initUtilityStatementForm();
if (isLandlord) initCraftsmanForm();
initRatingForm();
loadData();
loadCleaningTasks();
loadCalendar();
loadExpenses();
loadInventory();
loadHandoverProtocols();
loadGuestCodes();
loadStorage();
loadDocuments();
loadNoticeDeadlines();
loadUtilityStatements();
loadCraftsmen();
loadRatings();
loadContractDocuments();
loadContractArchive();
setInterval(loadData, REFRESH_INTERVAL_MS);
setInterval(loadCleaningTasks, REFRESH_INTERVAL_MS);
setInterval(loadCalendar, REFRESH_INTERVAL_MS);
setInterval(loadExpenses, REFRESH_INTERVAL_MS);
setInterval(loadInventory, REFRESH_INTERVAL_MS);
setInterval(loadHandoverProtocols, REFRESH_INTERVAL_MS);
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);
setInterval(loadRatings, REFRESH_INTERVAL_MS);
setInterval(loadContractDocuments, REFRESH_INTERVAL_MS);
setInterval(loadContractArchive, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
// MIETER EINLADEN (nur Vermieter/Admin)
// ---------------------------------------------------------------------
let lastRooms = [];
function populateRoomSelect() {
const select = document.getElementById('inviteRoom');
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 => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
select.appendChild(opt);
});
}
function initInvitePanel() {
const form = document.getElementById('inviteForm');
const errorBox = document.getElementById('inviteFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('inviteSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Lädt ein…';
try {
const email = document.getElementById('inviteEmail').value.trim();
const roomId = document.getElementById('inviteRoom').value || undefined;
const data = await apiFetch('/invitations', {
method: 'POST',
body: JSON.stringify({ email, roomId }),
});
document.getElementById('inviteLinkText').textContent = data.inviteLink;
document.getElementById('inviteLinkResult').style.display = 'flex';
document.getElementById('inviteEmail').value = '';
loadInvitations();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Einladen';
}
};
loadInvitations();
}
function copyInviteLink() {
const text = document.getElementById('inviteLinkText').textContent;
navigator.clipboard?.writeText(text);
}
async function loadInvitations() {
try {
const data = await apiFetch('/invitations');
const table = document.getElementById('inviteTable');
const body = document.getElementById('inviteTableBody');
body.innerHTML = '';
if (!data.invitations.length) {
table.style.display = 'none';
return;
}
table.style.display = 'table';
data.invitations.forEach(inv => {
const tr = document.createElement('tr');
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>`));
tr.appendChild(el('td', '', new Date(inv.createdAt).toLocaleDateString('de-DE')));
const actionsTd = el('td', '');
if (inv.status === 'PENDING') {
const revokeBtn = document.createElement('button');
revokeBtn.className = 'btn-secondary';
revokeBtn.textContent = 'Zurückziehen';
revokeBtn.onclick = () => revokeInvitation(inv.id);
actionsTd.appendChild(revokeBtn);
}
tr.appendChild(actionsTd);
body.appendChild(tr);
});
} catch (err) {
// Einladungsliste ist nicht kritisch für die Kernansicht — still fehlschlagen lassen.
console.error('[invitations] Laden fehlgeschlagen:', err.message);
}
}
async function revokeInvitation(id) {
try {
await apiFetch(`/invitations/${id}`, { method: 'DELETE' });
loadInvitations();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// COCKPIT-DATEN
// ---------------------------------------------------------------------
async function loadData() {
const errorBanner = document.getElementById('errorBanner');
errorBanner.style.display = 'none';
try {
const data = await apiFetch('/cockpit/summary');
lastRooms = data.rooms || [];
lastMyRoom = data.myRoom || null;
populateRoomSelect();
populateTicketRoomSelect();
populateInventoryRoomSelect();
render(data);
} catch (err) {
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
clearSession();
location.reload();
return;
}
errorBanner.style.display = 'block';
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. (${err.message})`;
}
}
function render(data) {
document.getElementById('lastUpdated').textContent =
'Stand: ' + new Date(data.generatedAt).toLocaleTimeString('de-DE');
const isLandlord = data.role === 'LANDLORD' || data.role === 'ADMIN';
// --- Stat-Kacheln ---
const statRow = document.getElementById('statRow');
statRow.innerHTML = '';
let stats;
if (isLandlord) {
stats = [
{ label: 'Bezahlt', value: data.summary.rentGreen, cls: 'green' },
{ label: 'Fällig', value: data.summary.rentYellow, cls: 'yellow' },
{ label: 'Überfällig', value: data.summary.rentRed, cls: 'red' },
{ label: 'Soll-Miete gesamt / Monat', value: formatEuro(data.summary.totalMonthlyRentTarget), cls: '' },
{ label: 'Offene Tickets', value: data.summary.openTicketsCount, cls: '' },
];
} else {
const myStatusCls = data.myRent ? { GREEN: 'green', YELLOW: 'yellow', RED: 'red' }[data.myRent.status] : '';
stats = [
{ label: 'Mein Mietstatus', value: data.myRent ? STATUS_LABEL[data.myRent.status] : '', cls: myStatusCls },
{ label: 'Fällig', value: formatEuro(data.myRent ? data.myRent.amountDue : 0), cls: '' },
{ label: 'Offene Meldungen', value: data.openTickets.length, cls: '' },
];
}
stats.forEach(s => {
const tile = el('div', `stat-tile ${s.cls}`);
tile.appendChild(el('div', 'value', s.value));
tile.appendChild(el('div', 'label', s.label));
statRow.appendChild(tile);
});
// --- Miet-Ampel(n) ---
const roomGrid = document.getElementById('roomGrid');
roomGrid.innerHTML = '';
const lights = isLandlord ? data.trafficLights : (data.myRent ? [data.myRent] : []);
if (!lights.length) {
roomGrid.appendChild(el('div', 'empty-state',
isLandlord ? 'Keine aktiven Verträge gefunden.' : 'Kein aktiver Mietvertrag gefunden.'));
}
lights.forEach(light => {
const card = el('div', `room-card ${light.status}`);
const top = el('div', 'room-card-top');
top.appendChild(el('div', 'room-name', light.roomNumber));
top.appendChild(el('div', `badge ${light.status}`, STATUS_LABEL[light.status]));
card.appendChild(top);
if (isLandlord) card.appendChild(el('div', 'tenant', light.tenantName));
const amountRow = el('div', 'amount-row');
amountRow.appendChild(el('span', 'label', 'Fällig'));
amountRow.appendChild(el('span', 'value', formatEuro(light.amountDue)));
card.appendChild(amountRow);
if (light.status === 'GREEN') {
const paidRow = el('div', 'amount-row');
paidRow.appendChild(el('span', 'label', 'Bezahlt'));
paidRow.appendChild(el('span', 'value', formatEuro(light.amountPaid)));
card.appendChild(paidRow);
}
roomGrid.appendChild(card);
});
// --- Offene Tickets ---
const ticketList = document.getElementById('ticketList');
ticketList.innerHTML = '';
if (!data.openTickets.length) {
ticketList.appendChild(el('div', 'empty-state', 'Aktuell keine offenen Schadensmeldungen. 🎉'));
}
data.openTickets.forEach(t => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', t.title));
left.appendChild(el('div', 'meta-text',
`${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');
right.style.display = 'flex';
right.style.gap = '8px';
right.style.alignItems = 'center';
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);
const pdfBtn = document.createElement('button');
pdfBtn.className = 'btn-secondary';
pdfBtn.textContent = 'PDF';
pdfBtn.title = 'Auftrag als PDF für Handwerker exportieren';
pdfBtn.onclick = () => downloadTicketPdf(t.id);
right.appendChild(pdfBtn);
if (t.status === 'OPEN') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'In Bearbeitung';
btn.onclick = () => updateTicketStatus(t.id, 'IN_PROGRESS');
right.appendChild(btn);
}
if (t.status === 'OPEN' || t.status === 'IN_PROGRESS') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Erledigt';
btn.onclick = () => updateTicketStatus(t.id, 'RESOLVED');
right.appendChild(btn);
}
} else if (t.creator && t.creator.id === currentUser.id && t.status === 'OPEN') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Zurückziehen';
btn.onclick = () => withdrawTicket(t.id);
right.appendChild(btn);
}
row.appendChild(right);
ticketList.appendChild(row);
});
}
// ---------------------------------------------------------------------
// SCHADENSMELDUNGEN / TICKETS
// ---------------------------------------------------------------------
let lastMyRoom = null;
function populateTicketRoomSelect() {
const select = document.getElementById('ticketRoom');
if (!select) return;
select.innerHTML = '';
const generalOpt = document.createElement('option');
generalOpt.value = '';
generalOpt.textContent = 'Gemeinschaftsbereich (Küche, Bad, Flur, ...)';
select.appendChild(generalOpt);
const isLandlord = currentUser && (currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN');
if (isLandlord) {
lastRooms.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
select.appendChild(opt);
});
} else if (lastMyRoom) {
const opt = document.createElement('option');
opt.value = lastMyRoom.id;
opt.textContent = `Mein Zimmer (${lastMyRoom.roomNumber})`;
select.appendChild(opt);
}
}
function populateTicketSelects() {
const catSelect = document.getElementById('ticketCategory');
catSelect.innerHTML = '';
Object.keys(CATEGORY_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = CATEGORY_LABEL[key];
catSelect.appendChild(opt);
});
const prioSelect = document.getElementById('ticketPriority');
prioSelect.innerHTML = '';
Object.keys(PRIORITY_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = PRIORITY_LABEL[key];
prioSelect.appendChild(opt);
});
prioSelect.value = 'MEDIUM';
}
function initTicketForm() {
populateTicketSelects();
const form = document.getElementById('ticketForm');
const errorBox = document.getElementById('ticketFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('ticketSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Meldet…';
try {
const title = document.getElementById('ticketTitle').value.trim();
const description = document.getElementById('ticketDescription').value.trim();
const category = document.getElementById('ticketCategory').value;
const priority = document.getElementById('ticketPriority').value;
const roomId = document.getElementById('ticketRoom').value || undefined;
await apiFetch('/tickets', {
method: 'POST',
body: JSON.stringify({ title, description, category, priority, roomId }),
});
form.reset();
populateTicketSelects();
loadData();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Melden';
}
};
}
async function updateTicketStatus(id, status) {
try {
await apiFetch(`/tickets/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) });
loadData();
} catch (err) {
alert(err.message);
}
}
async function withdrawTicket(id) {
try {
await apiFetch(`/tickets/${id}`, { method: 'DELETE' });
loadData();
} catch (err) {
alert(err.message);
}
}
async function downloadTicketPdf(id) {
try {
const res = await fetch(`${API_BASE_URL}/tickets/${id}/pdf`, { headers: authHeaders() });
if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ticket-${id}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// PUTZPLAN
// ---------------------------------------------------------------------
async function initCleaningAssignForm() {
const userSelect = document.getElementById('cleaningUser');
const areaSelect = document.getElementById('cleaningArea');
userSelect.innerHTML = '';
areaSelect.innerHTML = '';
Object.keys(CLEANING_AREA_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = CLEANING_AREA_LABEL[key];
areaSelect.appendChild(opt);
});
try {
const data = await apiFetch('/tenants');
data.tenants.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName;
userSelect.appendChild(opt);
});
} catch (err) {
console.error('[cleaning-tasks] Mieterliste laden fehlgeschlagen:', err.message);
}
const form = document.getElementById('cleaningForm');
const errorBox = document.getElementById('cleaningFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('cleaningSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Weist zu…';
try {
const assignedUserId = userSelect.value;
const area = areaSelect.value;
const weekOf = document.getElementById('cleaningWeekOf').value;
const dueDate = document.getElementById('cleaningDueDate').value;
await apiFetch('/cleaning-tasks', {
method: 'POST',
body: JSON.stringify({ assignedUserId, area, weekOf, dueDate }),
});
form.reset();
loadCleaningTasks();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Zuweisen';
}
};
}
async function loadCleaningTasks() {
if (!currentUser) return;
try {
const data = await apiFetch('/cleaning-tasks?status=PENDING');
renderCleaningTasks(data.tasks);
const dataCompleted = await apiFetch('/cleaning-tasks?status=COMPLETED');
renderCleaningTasks(data.tasks.concat(dataCompleted.tasks));
} catch (err) {
console.error('[cleaning-tasks] Laden fehlgeschlagen:', err.message);
}
}
function renderCleaningTasks(tasks) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const list = document.getElementById('cleaningList');
list.innerHTML = '';
if (!tasks.length) {
list.appendChild(el('div', 'empty-state', isLandlord ? 'Aktuell keine Putzaufgaben.' : 'Aktuell keine offenen Putzaufgaben für dich. 🎉'));
return;
}
tasks.forEach(t => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', CLEANING_AREA_LABEL[t.area] || t.area));
left.appendChild(el('div', 'meta-text',
`${isLandlord && t.assignedUser ? t.assignedUser.fullName + ' · ' : ''}` +
`Fällig ${new Date(t.dueDate).toLocaleDateString('de-DE')} · ` +
CLEANING_STATUS_LABEL[t.status]));
row.appendChild(left);
const right = el('div');
right.style.display = 'flex';
right.style.gap = '8px';
right.style.alignItems = 'center';
if (!isLandlord && t.status === 'PENDING') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Erledigt';
btn.onclick = () => completeCleaningTask(t.id);
right.appendChild(btn);
}
if (isLandlord && t.status === 'COMPLETED') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Bestätigen';
btn.onclick = () => verifyCleaningTask(t.id);
right.appendChild(btn);
}
row.appendChild(right);
list.appendChild(row);
});
}
async function completeCleaningTask(id) {
try {
await apiFetch(`/cleaning-tasks/${id}/complete`, { method: 'POST', body: JSON.stringify({}) });
loadCleaningTasks();
} catch (err) {
alert(err.message);
}
}
async function verifyCleaningTask(id) {
try {
await apiFetch(`/cleaning-tasks/${id}`, { method: 'PATCH', body: JSON.stringify({ status: 'VERIFIED' }) });
loadCleaningTasks();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// PUTZPLAN-KALENDER & ABWESENHEITEN
// ---------------------------------------------------------------------
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
let calendarMonthDate = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
let calendarData = { tasks: [], absences: [] };
function initAbsenceForm() {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const userSelect = document.getElementById('absenceUser');
if (isLandlord) {
userSelect.innerHTML = '';
apiFetch('/tenants').then(data => {
data.tenants.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName;
userSelect.appendChild(opt);
});
}).catch(err => console.error('[cleaning-absences] Mieterliste laden fehlgeschlagen:', err.message));
}
const form = document.getElementById('absenceForm');
const errorBox = document.getElementById('absenceFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('absenceSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Trägt ein…';
try {
const startDate = document.getElementById('absenceStart').value;
const endDate = document.getElementById('absenceEnd').value;
const note = document.getElementById('absenceNote').value;
const body = { startDate, endDate, note };
if (isLandlord) body.userId = userSelect.value;
await apiFetch('/cleaning-absences', { method: 'POST', body: JSON.stringify(body) });
form.reset();
loadCalendar();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Eintragen';
}
};
document.getElementById('calPrevBtn').onclick = () => {
calendarMonthDate = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth() - 1, 1);
loadCalendar();
};
document.getElementById('calNextBtn').onclick = () => {
calendarMonthDate = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth() + 1, 1);
loadCalendar();
};
}
async function loadCalendar() {
if (!currentUser) return;
const monthStart = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth(), 1);
const monthEnd = new Date(calendarMonthDate.getFullYear(), calendarMonthDate.getMonth() + 1, 0);
const from = monthStart.toISOString().slice(0, 10);
const to = monthEnd.toISOString().slice(0, 10);
try {
const data = await apiFetch(`/cleaning-calendar?from=${from}&to=${to}`);
calendarData = data;
renderCalendar();
} catch (err) {
console.error('[cleaning-calendar] Laden fehlgeschlagen:', err.message);
}
}
function renderCalendar() {
const weekdaysEl = document.getElementById('calendarWeekdays');
weekdaysEl.innerHTML = '';
WEEKDAY_LABELS.forEach(d => weekdaysEl.appendChild(el('div', 'calendar-weekday', d)));
document.getElementById('calMonthLabel').textContent =
calendarMonthDate.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
const grid = document.getElementById('calendarGrid');
grid.innerHTML = '';
const year = calendarMonthDate.getFullYear();
const month = calendarMonthDate.getMonth();
const firstOfMonth = new Date(year, month, 1);
// Montag = 0 ... Sonntag = 6
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const todayStr = new Date().toDateString();
const cellDate = new Date(year, month, 1 - leadingBlanks);
const totalCells = Math.ceil((leadingBlanks + daysInMonth) / 7) * 7;
for (let i = 0; i < totalCells; i++) {
const d = new Date(cellDate.getFullYear(), cellDate.getMonth(), cellDate.getDate() + i);
const isOutside = d.getMonth() !== month;
const cell = el('div', 'calendar-day' + (isOutside ? ' calendar-day-outside' : '') +
(d.toDateString() === todayStr ? ' calendar-day-today' : ''));
cell.appendChild(el('div', 'calendar-day-num', String(d.getDate())));
calendarData.tasks.filter(t => sameDay(new Date(t.dueDate), d)).forEach(t => {
const label = `${CLEANING_AREA_LABEL[t.area] || t.area}${t.assignedUser ? ' · ' + t.assignedUser.fullName.split(' ')[0] : ''}`;
cell.appendChild(el('div', 'calendar-badge calendar-badge-task', label));
});
calendarData.absences.filter(a => d >= stripTime(new Date(a.startDate)) && d <= stripTime(new Date(a.endDate))).forEach(a => {
cell.appendChild(el('div', 'calendar-badge calendar-badge-absence', `${a.user.fullName.split(' ')[0]} abwesend`));
});
grid.appendChild(cell);
}
const absenceList = document.getElementById('absenceList');
absenceList.innerHTML = '';
if (!calendarData.absences.length) {
absenceList.appendChild(el('div', 'empty-state', 'Keine Abwesenheiten in diesem Monat eingetragen.'));
} else {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
calendarData.absences.forEach(a => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', a.user.fullName));
left.appendChild(el('div', 'meta-text',
`${new Date(a.startDate).toLocaleDateString('de-DE')} ${new Date(a.endDate).toLocaleDateString('de-DE')}` +
(a.note ? ` · ${a.note}` : '')));
row.appendChild(left);
if (isLandlord || a.user.id === currentUser.id) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Löschen';
btn.onclick = () => deleteAbsence(a.id);
row.appendChild(btn);
}
absenceList.appendChild(row);
});
}
}
function stripTime(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
async function deleteAbsence(id) {
try {
await apiFetch(`/cleaning-absences/${id}`, { method: 'DELETE' });
loadCalendar();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// WG-KASSE (AUSGABEN-TEILER)
// ---------------------------------------------------------------------
function populateExpenseCategorySelect() {
const select = document.getElementById('expenseCategory');
select.innerHTML = '';
Object.keys(EXPENSE_CATEGORY_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = EXPENSE_CATEGORY_LABEL[key];
select.appendChild(opt);
});
}
function initExpenseForm() {
populateExpenseCategorySelect();
const form = document.getElementById('expenseForm');
const errorBox = document.getElementById('expenseFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('expenseSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const amount = document.getElementById('expenseAmount').value;
const description = document.getElementById('expenseDescription').value.trim();
const category = document.getElementById('expenseCategory').value;
await apiFetch('/expenses', { method: 'POST', body: JSON.stringify({ amount, description, category }) });
form.reset();
populateExpenseCategorySelect();
loadExpenses();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Eintragen';
}
};
}
async function loadExpenses() {
if (!currentUser) return;
try {
const [expensesData, balanceData] = await Promise.all([
apiFetch('/expenses'),
apiFetch('/expenses/balance'),
]);
renderExpenseBalance(balanceData.balance);
renderExpenses(expensesData.expenses);
} catch (err) {
console.error('[expenses] Laden fehlgeschlagen:', err.message);
}
}
function renderExpenseBalance(balance) {
const grid = document.getElementById('expenseBalanceGrid');
grid.innerHTML = '';
if (!balance.length) {
grid.appendChild(el('div', 'empty-state', 'Aktuell keine offenen Salden. 🎉'));
return;
}
balance.forEach(b => {
const net = b.isOwed - b.owes;
const card = el('div', `room-card ${net > 0 ? 'GREEN' : (net < 0 ? 'RED' : '')}`);
const top = el('div', 'room-card-top');
top.appendChild(el('div', 'room-name', b.user.fullName));
card.appendChild(top);
const owesRow = el('div', 'amount-row');
owesRow.appendChild(el('span', 'label', 'Schuldet'));
owesRow.appendChild(el('span', 'value', formatEuro(b.owes)));
card.appendChild(owesRow);
const owedRow = el('div', 'amount-row');
owedRow.appendChild(el('span', 'label', 'Bekommt'));
owedRow.appendChild(el('span', 'value', formatEuro(b.isOwed)));
card.appendChild(owedRow);
grid.appendChild(card);
});
}
function renderExpenses(expenses) {
const list = document.getElementById('expenseList');
list.innerHTML = '';
if (!expenses.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Ausgaben erfasst.'));
return;
}
expenses.forEach(x => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', `${x.description} · ${formatEuro(x.amount)}`));
left.appendChild(el('div', 'meta-text',
`${x.payer.fullName} · ${EXPENSE_CATEGORY_LABEL[x.category] || x.category} · ${new Date(x.createdAt).toLocaleDateString('de-DE')}`));
const openShares = x.shares.filter(s => s.settlementStatus === 'OPEN');
if (openShares.length) {
const shareText = openShares.map(s => `${s.user.fullName}: ${formatEuro(s.shareAmount)}`).join(', ');
left.appendChild(el('div', 'meta-text', 'Offen: ' + shareText));
}
row.appendChild(left);
const right = el('div');
right.style.display = 'flex';
right.style.gap = '8px';
right.style.flexWrap = 'wrap';
const myShare = x.shares.find(s => s.user.id === currentUser.id && s.settlementStatus === 'OPEN');
if (myShare) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Meinen Anteil begleichen';
btn.onclick = () => settleExpenseShare(myShare.id);
right.appendChild(btn);
}
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
if (isLandlord || x.payer.id === currentUser.id) {
openShares.forEach(s => {
if (s.user.id === currentUser.id) return; // schon oben abgedeckt
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = `${s.user.fullName.split(' ')[0]}: als bezahlt markieren`;
btn.onclick = () => settleExpenseShare(s.id);
right.appendChild(btn);
});
}
row.appendChild(right);
list.appendChild(row);
});
}
async function settleExpenseShare(id) {
try {
await apiFetch(`/expense-shares/${id}`, { method: 'PATCH' });
loadExpenses();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// INVENTARVERWALTUNG
// ---------------------------------------------------------------------
function populateInventoryRoomSelect() {
const roomSelect = document.getElementById('inventoryRoom');
if (!roomSelect) return;
roomSelect.innerHTML = '';
const noneOpt = document.createElement('option');
noneOpt.value = '';
noneOpt.textContent = 'Gemeinschaftsfläche';
roomSelect.appendChild(noneOpt);
lastRooms.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
roomSelect.appendChild(opt);
});
}
function populateInventoryConditionSelect() {
const condSelect = document.getElementById('inventoryCondition');
condSelect.innerHTML = '';
Object.keys(ITEM_CONDITION_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = ITEM_CONDITION_LABEL[key];
condSelect.appendChild(opt);
});
}
const MAX_INVENTORY_PHOTOS = 6;
let inventoryPhotoDataUrls = [];
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
// Handyfotos (Ausweis, Schufa-Scan) können 15-25MB pro Bild groß sein —
// als Data-URL im JSON-Body summiert sich das schnell über jedes sinnvolle
// Server-Limit. Bilder werden daher vor dem Versand auf eine für Dokumente
// mehr als ausreichende Auflösung herunterskaliert (PDFs bleiben unangetastet,
// die sind i. d. R. schon kompakt).
function readFileAsCompressedDataUrl(file, maxDimension, quality) {
if (file.type === 'application/pdf') return readFileAsDataUrl(file);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxDimension || height > maxDimension) {
const scale = maxDimension / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(img.src);
resolve(canvas.toDataURL('image/jpeg', quality || 0.82));
};
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'));
img.src = URL.createObjectURL(file);
});
}
function renderInventoryPhotoPreview() {
const preview = document.getElementById('inventoryPhotoPreview');
preview.innerHTML = '';
inventoryPhotoDataUrls.forEach((dataUrl, idx) => {
const wrap = el('div');
wrap.style.position = 'relative';
const img = document.createElement('img');
img.src = dataUrl;
img.className = 'inventory-thumb';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '×';
removeBtn.title = 'Entfernen';
removeBtn.style.cssText = 'position:absolute; top:-6px; right:-6px; width:18px; height:18px; border-radius:50%; border:1px solid var(--border); background:#fff; cursor:pointer; font-size:11px; line-height:1; padding:0;';
removeBtn.onclick = () => {
inventoryPhotoDataUrls.splice(idx, 1);
renderInventoryPhotoPreview();
};
wrap.appendChild(removeBtn);
preview.appendChild(wrap);
});
}
function initInventoryForm() {
populateInventoryRoomSelect();
populateInventoryConditionSelect();
inventoryPhotoDataUrls = [];
renderInventoryPhotoPreview();
const photoInput = document.getElementById('inventoryPhotos');
photoInput.onchange = async () => {
const files = Array.from(photoInput.files || []).slice(0, MAX_INVENTORY_PHOTOS - inventoryPhotoDataUrls.length);
for (const file of files) {
try {
const dataUrl = await readFileAsDataUrl(file);
inventoryPhotoDataUrls.push(dataUrl);
} catch (err) {
console.error('[inventory] Foto konnte nicht gelesen werden:', err);
}
}
photoInput.value = '';
renderInventoryPhotoPreview();
};
const form = document.getElementById('inventoryForm');
const errorBox = document.getElementById('inventoryFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('inventorySubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const itemName = document.getElementById('inventoryItemName').value.trim();
const roomId = document.getElementById('inventoryRoom').value || undefined;
const condition = document.getElementById('inventoryCondition').value;
const purchasePriceRaw = document.getElementById('inventoryPrice').value;
const purchasePrice = purchasePriceRaw ? Number(purchasePriceRaw) : undefined;
await apiFetch('/inventory', {
method: 'POST',
body: JSON.stringify({ itemName, roomId, condition, purchasePrice, photoUrls: inventoryPhotoDataUrls }),
});
form.reset();
populateInventoryConditionSelect();
inventoryPhotoDataUrls = [];
renderInventoryPhotoPreview();
loadInventory();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Hinzufügen';
}
};
}
async function loadInventory() {
if (!currentUser) return;
try {
const data = await apiFetch('/inventory');
renderInventory(data.items);
} catch (err) {
console.error('[inventory] Laden fehlgeschlagen:', err.message);
}
}
function renderInventory(items) {
const list = document.getElementById('inventoryList');
list.innerHTML = '';
if (!items.length) {
list.appendChild(el('div', 'empty-state', 'Noch kein Inventar erfasst.'));
return;
}
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
items.forEach(i => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', i.itemName));
left.appendChild(el('div', 'meta-text',
`${i.room ? i.room.roomNumber : 'Gemeinschaftsfläche'} · ${ITEM_CONDITION_LABEL[i.condition] || i.condition}` +
(i.purchasePrice ? ` · ${formatEuro(i.purchasePrice)}` : '')));
if (i.photoUrls && i.photoUrls.length) {
const thumbRow = el('div', 'inventory-thumb-row');
i.photoUrls.forEach(url => {
const img = document.createElement('img');
img.src = url;
img.className = 'inventory-thumb';
thumbRow.appendChild(img);
});
left.appendChild(thumbRow);
}
row.appendChild(left);
if (isLandlord) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Löschen';
btn.onclick = () => deleteInventoryItem(i.id);
row.appendChild(btn);
}
list.appendChild(row);
});
}
async function deleteInventoryItem(id) {
try {
await apiFetch(`/inventory/${id}`, { method: 'DELETE' });
loadInventory();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// ÜBERGABEPROTOKOLLE
// ---------------------------------------------------------------------
async function populateHandoverContractSelect() {
const select = document.getElementById('handoverContract');
select.innerHTML = '';
try {
const data = await apiFetch('/contracts');
data.contracts.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.user.fullName} · ${c.room.roomNumber}`;
select.appendChild(opt);
});
} catch (err) {
console.error('[handover] Verträge laden fehlgeschlagen:', err.message);
}
}
function initHandoverForm() {
populateHandoverContractSelect();
const form = document.getElementById('handoverForm');
const errorBox = document.getElementById('handoverFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('handoverSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const contractId = document.getElementById('handoverContract').value;
const type = document.getElementById('handoverType').value;
const protocolDate = document.getElementById('handoverDate').value;
await apiFetch('/handover-protocols', {
method: 'POST',
body: JSON.stringify({ contractId, type, protocolDate, items: [], meterReadings: [] }),
});
form.reset();
loadHandoverProtocols();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Protokoll anlegen';
}
};
}
async function loadHandoverProtocols() {
if (!currentUser) return;
try {
const data = await apiFetch('/handover-protocols');
renderHandoverProtocols(data.protocols);
} catch (err) {
console.error('[handover] Laden fehlgeschlagen:', err.message);
}
}
function renderHandoverProtocols(protocols) {
const list = document.getElementById('handoverList');
list.innerHTML = '';
if (!protocols.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Übergabeprotokolle vorhanden.'));
return;
}
protocols.forEach(p => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', `${HANDOVER_TYPE_LABEL[p.type] || p.type} · ${p.contract.room.roomNumber}`));
left.appendChild(el('div', 'meta-text',
`${p.user.fullName} · ${new Date(p.protocolDate).toLocaleDateString('de-DE')} · ${p.items.length} Positionen, ${p.meterReadings.length} Zählerstände`));
row.appendChild(left);
list.appendChild(row);
});
}
// ---------------------------------------------------------------------
// SMART-LOCK-GASTCODES
// ---------------------------------------------------------------------
function initGuestCodeForm() {
const form = document.getElementById('guestCodeForm');
const errorBox = document.getElementById('guestCodeFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('guestCodeSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Generiert…';
try {
const validFrom = document.getElementById('guestCodeFrom').value;
const validUntil = document.getElementById('guestCodeUntil').value;
await apiFetch('/guest-codes', { method: 'POST', body: JSON.stringify({ validFrom, validUntil }) });
form.reset();
loadGuestCodes();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Code generieren';
}
};
}
async function loadGuestCodes() {
if (!currentUser) return;
try {
const data = await apiFetch('/guest-codes');
renderGuestCodes(data.guestCodes);
} catch (err) {
console.error('[guest-codes] Laden fehlgeschlagen:', err.message);
}
}
function renderGuestCodes(codes) {
const list = document.getElementById('guestCodeList');
list.innerHTML = '';
if (!codes.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Gästecodes ausgestellt.'));
return;
}
codes.forEach(g => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', `Code: ${g.code}`));
left.appendChild(el('div', 'meta-text',
`${g.issuedBy.fullName} · ${new Date(g.validFrom).toLocaleString('de-DE')} ${new Date(g.validUntil).toLocaleString('de-DE')} · ${GUEST_CODE_STATUS_LABEL[g.status] || g.status}`));
row.appendChild(left);
if (g.status === 'ACTIVE') {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Widerrufen';
btn.onclick = () => revokeGuestCode(g.id);
row.appendChild(btn);
}
list.appendChild(row);
});
}
async function revokeGuestCode(id) {
try {
await apiFetch(`/guest-codes/${id}`, { method: 'DELETE' });
loadGuestCodes();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// KÜCHEN-/SCHRANKPLANER & MÜLL-KALENDER
// ---------------------------------------------------------------------
function populateStorageSelects() {
const locSelect = document.getElementById('storageLocation');
locSelect.innerHTML = '';
Object.keys(STORAGE_LOCATION_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = STORAGE_LOCATION_LABEL[key];
locSelect.appendChild(opt);
});
const trashSelect = document.getElementById('trashType');
trashSelect.innerHTML = '';
Object.keys(TRASH_TYPE_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = TRASH_TYPE_LABEL[key];
trashSelect.appendChild(opt);
});
}
function initStorageForms() {
populateStorageSelects();
const form = document.getElementById('storageForm');
const errorBox = document.getElementById('storageFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('storageSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const location = document.getElementById('storageLocation').value;
const label = document.getElementById('storageLabel').value.trim();
await apiFetch('/storage-slots', { method: 'POST', body: JSON.stringify({ location, label }) });
form.reset();
loadStorage();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Fach anlegen';
}
};
const trashForm = document.getElementById('trashForm');
const trashErrorBox = document.getElementById('trashFormError');
trashForm.onsubmit = async (e) => {
e.preventDefault();
trashErrorBox.style.display = 'none';
const trashSubmitBtn = document.getElementById('trashSubmitBtn');
trashSubmitBtn.disabled = true;
trashSubmitBtn.textContent = 'Speichert…';
try {
const type = document.getElementById('trashType').value;
const date = document.getElementById('trashDate').value;
await apiFetch('/trash-schedule', { method: 'POST', body: JSON.stringify({ type, date }) });
trashForm.reset();
loadStorage();
} catch (err) {
trashErrorBox.textContent = err.message;
trashErrorBox.style.display = 'block';
} finally {
trashSubmitBtn.disabled = false;
trashSubmitBtn.textContent = 'Termin eintragen';
}
};
document.getElementById('trashSyncBtn').onclick = syncTrashCalendar;
}
async function loadStorage() {
if (!currentUser) return;
try {
const [slotsData, trashData] = await Promise.all([
apiFetch('/storage-slots'),
apiFetch('/trash-schedule'),
]);
renderStorageSlots(slotsData.slots);
renderTrashSchedule(trashData.entries);
} catch (err) {
console.error('[storage] Laden fehlgeschlagen:', err.message);
}
}
function renderStorageSlots(slots) {
const list = document.getElementById('storageList');
list.innerHTML = '';
if (!slots.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Fächer angelegt.'));
return;
}
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
slots.forEach(s => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', `${STORAGE_LOCATION_LABEL[s.location] || s.location} · ${s.label}`));
left.appendChild(el('div', 'meta-text', s.user ? `Beansprucht von ${s.user.fullName}` : 'Frei'));
row.appendChild(left);
const right = el('div');
right.style.display = 'flex';
right.style.gap = '8px';
if (!s.user) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Beanspruchen';
btn.onclick = () => claimStorageSlot(s.id);
right.appendChild(btn);
} else if (isLandlord || s.user.id === currentUser.id) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Freigeben';
btn.onclick = () => releaseStorageSlot(s.id);
right.appendChild(btn);
}
if (isLandlord) {
const delBtn = document.createElement('button');
delBtn.className = 'btn-secondary';
delBtn.textContent = 'Löschen';
delBtn.onclick = () => deleteStorageSlot(s.id);
right.appendChild(delBtn);
}
row.appendChild(right);
list.appendChild(row);
});
}
async function claimStorageSlot(id) {
try {
await apiFetch(`/storage-slots/${id}`, { method: 'PATCH', body: JSON.stringify({ userId: currentUser.id }) });
loadStorage();
} catch (err) {
alert(err.message);
}
}
async function releaseStorageSlot(id) {
try {
await apiFetch(`/storage-slots/${id}`, { method: 'PATCH', body: JSON.stringify({ userId: null }) });
loadStorage();
} catch (err) {
alert(err.message);
}
}
async function deleteStorageSlot(id) {
try {
await apiFetch(`/storage-slots/${id}`, { method: 'DELETE' });
loadStorage();
} catch (err) {
alert(err.message);
}
}
let trashCalendar = null;
function renderTrashSchedule(entries) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
document.getElementById('trashSyncBtn').style.display = isLandlord ? 'inline-block' : 'none';
const legend = document.getElementById('trashCalendarLegend');
legend.innerHTML = '';
const typesPresent = new Set(entries.map(e => e.type));
Object.keys(TRASH_TYPE_LABEL).filter(t => typesPresent.has(t)).forEach(t => {
const item = el('span');
const dot = el('span', 'dot');
dot.style.background = TRASH_TYPE_COLOR[t] || '#888';
item.appendChild(dot);
item.appendChild(document.createTextNode(TRASH_TYPE_LABEL[t] || t));
legend.appendChild(item);
});
if (!trashCalendar) {
trashCalendar = new FullCalendar.Calendar(document.getElementById('trashCalendarEl'), {
locale: 'de',
firstDay: 1,
height: 'auto',
headerToolbar: { left: 'prev,next today', center: 'title', right: '' },
events: [],
});
trashCalendar.render();
}
trashCalendar.removeAllEvents();
entries.forEach(t => {
trashCalendar.addEvent({
id: t.id,
title: TRASH_TYPE_LABEL[t.type] || t.type,
start: (t.date || '').slice(0, 10),
allDay: true,
backgroundColor: TRASH_TYPE_COLOR[t.type] || '#888',
borderColor: TRASH_TYPE_COLOR[t.type] || '#888',
});
});
}
async function syncTrashCalendar() {
const btn = document.getElementById('trashSyncBtn');
const status = document.getElementById('trashSyncStatus');
btn.disabled = true;
btn.textContent = 'Lädt…';
status.style.display = 'none';
try {
const result = await apiFetch('/trash-schedule/sync', { method: 'POST' });
status.textContent = `${result.fetched} Termine von der Kommunalen Abfallwirtschaft geladen, ${result.created} neu übernommen.`;
status.style.display = 'block';
await loadStorage();
} catch (err) {
status.textContent = `Aktualisierung fehlgeschlagen: ${err.message}`;
status.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = '↻ Kalender aktualisieren';
}
}
// ---------------------------------------------------------------------
// DOKUMENTEN-SAFE & TUTORIALS
// ---------------------------------------------------------------------
function populateDocumentCategorySelect() {
const select = document.getElementById('documentCategory');
select.innerHTML = '';
Object.keys(DOCUMENT_CATEGORY_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = DOCUMENT_CATEGORY_LABEL[key];
select.appendChild(opt);
});
}
function initDocumentForm() {
populateDocumentCategorySelect();
const form = document.getElementById('documentForm');
const errorBox = document.getElementById('documentFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('documentSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const title = document.getElementById('documentTitle').value.trim();
const category = document.getElementById('documentCategory').value;
const url = document.getElementById('documentUrl').value.trim();
await apiFetch('/documents', { method: 'POST', body: JSON.stringify({ title, category, url }) });
form.reset();
populateDocumentCategorySelect();
loadDocuments();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Hinzufügen';
}
};
}
async function loadDocuments() {
if (!currentUser) return;
try {
const data = await apiFetch('/documents');
renderDocuments(data.documents);
} catch (err) {
console.error('[documents] Laden fehlgeschlagen:', err.message);
}
}
function renderDocuments(documents) {
const list = document.getElementById('documentList');
list.innerHTML = '';
if (!documents.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Dokumente hinterlegt.'));
return;
}
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
documents.forEach(d => {
const row = el('div', 'ticket-row');
const left = el('div');
const link = document.createElement('a');
link.href = d.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = d.title;
link.style.fontWeight = '600';
link.style.color = 'var(--text)';
left.appendChild(link);
left.appendChild(el('div', 'meta-text', `${DOCUMENT_CATEGORY_LABEL[d.category] || d.category} · ${d.uploadedBy.fullName}`));
row.appendChild(left);
if (isLandlord) {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = 'Löschen';
btn.onclick = () => deleteDocument(d.id);
row.appendChild(btn);
}
list.appendChild(row);
});
}
async function deleteDocument(id) {
try {
await apiFetch(`/documents/${id}`, { method: 'DELETE' });
loadDocuments();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// VERTRAGSDOKUMENTE
// ---------------------------------------------------------------------
const MAX_CONTRACT_DOCUMENTS = 10;
const contractUploadFiles = {}; // contractId -> ausgewählte, noch nicht hochgeladene Files
async function loadContractDocuments() {
try {
const data = await apiFetch('/contracts/documents');
renderContractDocuments(data.contracts);
} catch (err) {
console.error('[contracts] Vertragsdokumente laden fehlgeschlagen:', err.message);
}
}
function documentFileName(dataUrl, index) {
const match = /^data:([^;]+);/.exec(dataUrl);
const mime = match ? match[1] : '';
const ext = mime.includes('pdf') ? 'pdf' : (mime.split('/')[1] || 'datei');
return `Dokument ${index + 1}.${ext}`;
}
// Klick auf den Namen öffnet eine Vorschau im neuen Tab (Browser können
// PDFs/Bilder nativ anzeigen); der Pfeil daneben lädt bei Bedarf separat
// herunter. Für alle hochgeladenen Dokumente (Verträge, Ausweis, Schufa) einheitlich.
function buildDocumentChip(label, url) {
const chip = el('span', 'invite-status-chip');
chip.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none; display:inline-flex; align-items:center; gap:7px;';
const previewLink = document.createElement('a');
previewLink.href = url;
previewLink.target = '_blank';
previewLink.rel = 'noopener';
previewLink.textContent = label;
previewLink.style.color = 'var(--text)';
chip.appendChild(previewLink);
const ext = url.includes('application/pdf') ? 'pdf' : (/^data:image\/(\w+)/.exec(url)?.[1] || 'jpg');
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = `${label.replace(/[^\w.-]+/g, '_')}.${ext}`;
downloadLink.title = 'Herunterladen';
downloadLink.textContent = '⬇';
downloadLink.style.cssText = 'color:var(--text-muted); text-decoration:none; font-size:12px;';
chip.appendChild(downloadLink);
return chip;
}
function buildProfileCompletenessBadge(user) {
const missing = [];
if (!user.phoneNumber) missing.push('Telefonnummer');
if (!user.firstResidenceAddress) missing.push('Erstwohnsitz');
if (!user.idDocumentFrontUrl) missing.push('Ausweis Vorderseite');
if (!user.idDocumentBackUrl) missing.push('Ausweis Rückseite');
const wrap = el('div');
wrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:8px; align-items:center;';
const badge = el('span', 'invite-status-chip');
if (missing.length) {
badge.classList.add('PENDING');
badge.textContent = `Unvollständig: ${missing.join(', ')} fehlt`;
} else {
badge.classList.add('ACCEPTED');
badge.textContent = 'Profil vollständig';
}
wrap.appendChild(badge);
if (user.schufaDocumentUrl) {
const schufaBadge = el('span', 'invite-status-chip');
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const isOld = user.schufaDocumentDate && new Date(user.schufaDocumentDate) < threeMonthsAgo;
schufaBadge.classList.add(isOld ? 'EXPIRED' : 'ACCEPTED');
schufaBadge.textContent = isOld
? `Schufa veraltet (${new Date(user.schufaDocumentDate).toLocaleDateString('de-DE')})`
: `Schufa vorhanden (${user.schufaDocumentDate ? new Date(user.schufaDocumentDate).toLocaleDateString('de-DE') : ''})`;
wrap.appendChild(schufaBadge);
}
const linkWrap = el('div');
linkWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;';
[
['Ausweis Vorderseite', user.idDocumentFrontUrl],
['Ausweis Rückseite', user.idDocumentBackUrl],
['Schufa-Auskunft', user.schufaDocumentUrl],
].forEach(([label, url]) => {
if (!url) return;
linkWrap.appendChild(buildDocumentChip(label, url));
});
if (linkWrap.children.length) wrap.appendChild(linkWrap);
return wrap;
}
function renderContractDocuments(contracts) {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const list = document.getElementById('contractDocumentsList');
list.innerHTML = '';
if (!contracts.length) {
list.appendChild(el('div', 'empty-state', 'Kein aktiver Mietvertrag gefunden.'));
return;
}
contracts.forEach(c => {
const card = el('div', 'ticket-row');
card.style.flexDirection = 'column';
card.style.alignItems = 'stretch';
card.style.gap = '10px';
const top = el('div');
top.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`));
card.appendChild(top);
if (isLandlord) {
card.appendChild(buildProfileCompletenessBadge(c.user));
}
const isOwnContract = c.user.id === currentUser.id;
if (c.signedAt) {
const signedNote = el('div', 'meta-text', `✓ Digital unterschrieben am ${new Date(c.signedAt).toLocaleDateString('de-DE')}`);
signedNote.style.color = 'var(--green)';
signedNote.style.fontWeight = '600';
card.appendChild(signedNote);
} else if (!isLandlord && isOwnContract) {
card.appendChild(buildSignatureSection(c.id));
} else {
card.appendChild(el('div', 'meta-text', 'Noch nicht unterschrieben.'));
}
const docWrap = el('div');
docWrap.style.display = 'flex';
docWrap.style.flexWrap = 'wrap';
docWrap.style.gap = '8px';
if (!c.contractDocumentUrls.length) {
docWrap.appendChild(el('span', 'meta-text', 'Noch keine Dateien hochgeladen.'));
} else {
c.contractDocumentUrls.forEach((url, idx) => {
const chip = buildDocumentChip(documentFileName(url, idx), url);
if (isLandlord) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.textContent = '×';
removeBtn.title = 'Entfernen';
removeBtn.style.cssText = 'border:none; background:none; cursor:pointer; font-size:13px; color:var(--text-muted); padding:0;';
removeBtn.onclick = () => deleteContractDocument(c.id, url);
chip.appendChild(removeBtn);
}
docWrap.appendChild(chip);
});
}
card.appendChild(docWrap);
if (isLandlord) {
card.appendChild(buildContractTemplateForm(c));
}
if (isLandlord) {
const uploadRow = el('div');
uploadRow.style.display = 'flex';
uploadRow.style.gap = '8px';
uploadRow.style.alignItems = 'center';
uploadRow.style.flexWrap = 'wrap';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = 'application/pdf,image/*';
fileInput.onchange = () => {
contractUploadFiles[c.id] = Array.from(fileInput.files || []);
};
uploadRow.appendChild(fileInput);
const uploadBtn = document.createElement('button');
uploadBtn.type = 'button';
uploadBtn.className = 'btn-secondary';
uploadBtn.textContent = 'Hochladen';
uploadBtn.onclick = () => uploadContractDocuments(c.id, fileInput, uploadBtn);
uploadRow.appendChild(uploadBtn);
card.appendChild(uploadRow);
const moveOutBtn = document.createElement('button');
moveOutBtn.type = 'button';
moveOutBtn.className = 'btn-secondary';
moveOutBtn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)';
moveOutBtn.style.cssText = 'align-self:flex-start; color:var(--red); border-color:var(--red);';
moveOutBtn.onclick = () => moveOutTenant(c.id, c.user.fullName, c.room.roomNumber, moveOutBtn);
card.appendChild(moveOutBtn);
}
list.appendChild(card);
});
}
async function moveOutTenant(contractId, tenantName, roomNumber, btn) {
btn.disabled = true;
btn.textContent = 'Wird bearbeitet…';
try {
await apiFetch(`/contracts/${contractId}/move-out`, { method: 'POST' });
loadContractDocuments();
loadContractArchive();
} catch (err) {
alert(err.message);
btn.disabled = false;
btn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)';
}
}
async function loadContractArchive() {
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const section = document.getElementById('contractArchiveSection');
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);
} catch (err) {
console.error('[contracts] Archiv laden fehlgeschlagen:', err.message);
}
}
function renderContractArchive(contracts, vacantRooms) {
const list = document.getElementById('contractArchiveList');
list.innerHTML = '';
if (!contracts.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine ausgezogenen Mieter.'));
return;
}
contracts.forEach(c => {
const row = el('div', 'ticket-row');
row.style.flexDirection = 'column';
row.style.alignItems = 'stretch';
row.style.gap = '10px';
const left = el('div');
left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
left.appendChild(el('div', 'meta-text',
`${new Date(c.startDate).toLocaleDateString('de-DE')} ${c.endDate ? new Date(c.endDate).toLocaleDateString('de-DE') : ''} · ` +
`${c.contractDocumentUrls.length} Dokument(e)`));
row.appendChild(left);
if (c.contractDocumentUrls.length) {
const docWrap = el('div');
docWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;';
c.contractDocumentUrls.forEach((url, idx) => {
docWrap.appendChild(buildDocumentChip(documentFileName(url, idx), url));
});
row.appendChild(docWrap);
}
row.appendChild(buildReactivateSection(c, vacantRooms));
list.appendChild(row);
});
}
function buildReactivateSection(c, vacantRooms) {
const wrap = el('div');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-secondary';
toggleBtn.textContent = 'Wieder aufnehmen (neuer Vertrag)';
const formArea = el('div', 'contract-template-form');
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 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 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 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);
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 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);
}
toggleBtn.onclick = () => {
formArea.style.display = formArea.style.display === 'none' ? 'flex' : 'none';
};
wrap.appendChild(toggleBtn);
wrap.appendChild(formArea);
return wrap;
}
async function reactivateTenant(userId, options, btn) {
if (!options.startDate) { alert('Bitte Mietbeginn angeben.'); return; }
btn.disabled = true;
btn.textContent = 'Legt an…';
try {
await apiFetch('/contracts/reactivate', { method: 'POST', body: JSON.stringify({ userId, ...options }) });
loadContractDocuments();
loadContractArchive();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Neuen Vertrag anlegen';
}
}
function buildContractTemplateForm(c) {
const form = el('div', 'contract-template-form');
const landlordNameField = el('div', 'field');
landlordNameField.style.minWidth = '180px';
landlordNameField.appendChild(el('label', null, 'Vermieter (Name)'));
const landlordNameInput = document.createElement('input');
landlordNameInput.type = 'text';
landlordNameInput.value = c.landlordName || '';
landlordNameField.appendChild(landlordNameInput);
form.appendChild(landlordNameField);
const landlordAddressField = el('div', 'field');
landlordAddressField.style.minWidth = '220px';
landlordAddressField.appendChild(el('label', null, 'Anschrift des Vermieters'));
const landlordAddressInput = document.createElement('input');
landlordAddressInput.type = 'text';
landlordAddressInput.placeholder = 'Straße Hausnr., PLZ Ort';
landlordAddressInput.value = c.landlordAddress || '';
landlordAddressField.appendChild(landlordAddressInput);
form.appendChild(landlordAddressField);
const propertyAddressField = el('div', 'field');
propertyAddressField.style.minWidth = '220px';
propertyAddressField.appendChild(el('label', null, 'Anschrift der Wohnung'));
const propertyAddressInput = document.createElement('input');
propertyAddressInput.type = 'text';
propertyAddressInput.placeholder = 'Straße Hausnr., PLZ Nackenheim';
propertyAddressInput.value = c.propertyAddress || '';
propertyAddressField.appendChild(propertyAddressInput);
form.appendChild(propertyAddressField);
const wifiField = el('label', 'field-checkbox');
const wifiCheckbox = document.createElement('input');
wifiCheckbox.type = 'checkbox';
wifiCheckbox.checked = !!c.wifiIncluded;
wifiField.appendChild(wifiCheckbox);
wifiField.appendChild(document.createTextNode('WLAN inklusive'));
form.appendChild(wifiField);
const furnishedField = el('label', 'field-checkbox');
const furnishedCheckbox = document.createElement('input');
furnishedCheckbox.type = 'checkbox';
furnishedCheckbox.checked = !!c.furnished;
furnishedField.appendChild(furnishedCheckbox);
furnishedField.appendChild(document.createTextNode('Möbliert'));
form.appendChild(furnishedField);
const utilityField = el('div', 'field');
utilityField.style.minWidth = '190px';
utilityField.appendChild(el('label', null, 'Nebenkosten'));
const utilitySelect = document.createElement('select');
Object.keys(UTILITY_MODEL_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = UTILITY_MODEL_LABEL[key];
if (key === c.utilityBillingModel) opt.selected = true;
utilitySelect.appendChild(opt);
});
utilityField.appendChild(utilitySelect);
form.appendChild(utilityField);
const adjustmentField = el('div', 'field');
adjustmentField.style.minWidth = '150px';
adjustmentField.appendChild(el('label', null, 'Mietanpassung'));
const adjustmentSelect = document.createElement('select');
Object.keys(RENT_ADJUSTMENT_LABEL).forEach(key => {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = RENT_ADJUSTMENT_LABEL[key];
if (key === c.rentAdjustmentType) opt.selected = true;
adjustmentSelect.appendChild(opt);
});
adjustmentField.appendChild(adjustmentSelect);
form.appendChild(adjustmentField);
const noticeField = el('div', 'field');
noticeField.style.width = '110px';
noticeField.appendChild(el('label', null, 'Kündigungsfrist (Monate)'));
const noticeInput = document.createElement('input');
noticeInput.type = 'number';
noticeInput.min = '0';
noticeInput.value = c.noticePeriodMonths;
noticeField.appendChild(noticeInput);
form.appendChild(noticeField);
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 = c.depositAmount;
depositField.appendChild(depositInput);
form.appendChild(depositField);
const generateBtn = document.createElement('button');
generateBtn.type = 'button';
generateBtn.className = 'btn-primary';
generateBtn.style.width = 'auto';
generateBtn.textContent = 'Vertrag erstellen (PDF)';
generateBtn.onclick = () => generateContractDocument(c.id, {
wifiIncluded: wifiCheckbox.checked,
furnished: furnishedCheckbox.checked,
utilityBillingModel: utilitySelect.value,
rentAdjustmentType: adjustmentSelect.value,
noticePeriodMonths: noticeInput.value,
depositAmount: depositInput.value,
landlordName: landlordNameInput.value,
landlordAddress: landlordAddressInput.value,
propertyAddress: propertyAddressInput.value,
}, generateBtn);
form.appendChild(generateBtn);
return form;
}
async function generateContractDocument(contractId, options, btn) {
btn.disabled = true;
btn.textContent = 'Erstellt…';
try {
await apiFetch(`/contracts/${contractId}/generate-document`, { method: 'POST', body: JSON.stringify(options) });
loadContractDocuments();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Vertrag erstellen (PDF)';
}
}
function buildSignatureSection(contractId) {
const wrap = el('div', 'signature-pad-wrap');
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-secondary';
toggleBtn.textContent = 'Jetzt unterschreiben';
const padArea = el('div', 'signature-pad-wrap');
padArea.style.display = 'none';
const canvas = document.createElement('canvas');
canvas.className = 'signature-pad-canvas';
canvas.width = 320;
canvas.height = 120;
padArea.appendChild(canvas);
const actionRow = el('div');
actionRow.style.cssText = 'display:flex; gap:8px;';
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-secondary';
clearBtn.textContent = 'Löschen';
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'btn-primary';
saveBtn.style.width = 'auto';
saveBtn.textContent = 'Unterschrift speichern';
actionRow.appendChild(clearBtn);
actionRow.appendChild(saveBtn);
padArea.appendChild(actionRow);
const signaturePad = setupSignatureCanvas(canvas);
clearBtn.onclick = () => signaturePad.clear();
saveBtn.onclick = () => submitSignature(contractId, signaturePad, saveBtn);
toggleBtn.onclick = () => {
padArea.style.display = padArea.style.display === 'none' ? 'flex' : 'none';
padArea.style.flexDirection = 'column';
};
wrap.appendChild(toggleBtn);
wrap.appendChild(padArea);
return wrap;
}
function setupSignatureCanvas(canvas) {
const ctx = canvas.getContext('2d');
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = '#1F2933';
let drawing = false;
let hasStroke = false;
function pos(e) {
const rect = canvas.getBoundingClientRect();
const point = e.touches ? e.touches[0] : e;
return { x: point.clientX - rect.left, y: point.clientY - rect.top };
}
function start(e) {
e.preventDefault();
drawing = true;
const p = pos(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
function move(e) {
if (!drawing) return;
e.preventDefault();
const p = pos(e);
ctx.lineTo(p.x, p.y);
ctx.stroke();
hasStroke = true;
}
function end() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
window.addEventListener('mouseup', end);
canvas.addEventListener('touchstart', start);
canvas.addEventListener('touchmove', move);
canvas.addEventListener('touchend', end);
return {
clear() { ctx.clearRect(0, 0, canvas.width, canvas.height); hasStroke = false; },
isEmpty() { return !hasStroke; },
toDataUrl() { return canvas.toDataURL('image/png'); },
};
}
async function submitSignature(contractId, signaturePad, saveBtn) {
if (signaturePad.isEmpty()) {
alert('Bitte erst unterschreiben.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
try {
await apiFetch(`/contracts/${contractId}/sign`, {
method: 'POST',
body: JSON.stringify({ signatureDataUrl: signaturePad.toDataUrl() }),
});
loadContractDocuments();
} catch (err) {
alert(err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Unterschrift speichern';
}
}
async function uploadContractDocuments(contractId, fileInput, uploadBtn) {
const files = (contractUploadFiles[contractId] || []).slice(0, MAX_CONTRACT_DOCUMENTS);
if (!files.length) return;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Lädt hoch…';
try {
const urls = [];
for (const file of files) {
urls.push(await readFileAsDataUrl(file));
}
await apiFetch(`/contracts/${contractId}/documents`, { method: 'POST', body: JSON.stringify({ urls }) });
delete contractUploadFiles[contractId];
fileInput.value = '';
loadContractDocuments();
} catch (err) {
alert(err.message);
} finally {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Hochladen';
}
}
async function deleteContractDocument(contractId, url) {
try {
await apiFetch(`/contracts/${contractId}/documents`, { method: 'DELETE', body: JSON.stringify({ url }) });
loadContractDocuments();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// 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);
}
}
// ---------------------------------------------------------------------
// MIETER-BEWERTUNG (Peer-Bewertungssystem)
// ---------------------------------------------------------------------
async function initRatingForm() {
const select = document.getElementById('ratingTargetUser');
select.innerHTML = '';
try {
const data = await apiFetch('/ratings/tenants');
(data.tenants || [])
.filter(t => t.id !== currentUser.id)
.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.room ? `${t.fullName} (${t.room.roomNumber})` : t.fullName;
select.appendChild(opt);
});
if (!select.options.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Keine anderen Mieter vorhanden';
select.appendChild(opt);
}
} catch (err) {
console.error('[ratings] Mieterliste laden fehlgeschlagen:', err.message);
}
const form = document.getElementById('ratingForm');
const errorBox = document.getElementById('ratingFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const ratedUserId = select.value;
if (!ratedUserId) return;
const submitBtn = document.getElementById('ratingSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Speichert…';
try {
const score = Number(document.getElementById('ratingScore').value);
const comment = document.getElementById('ratingComment').value.trim();
await apiFetch('/ratings', {
method: 'POST',
body: JSON.stringify({ ratedUserId, score, comment: comment || undefined }),
});
document.getElementById('ratingComment').value = '';
loadRatings();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Bewertung abgeben';
}
};
}
async function loadRatings() {
if (!currentUser) return;
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
const wrap = document.getElementById('ratingsListWrap');
if (!isLandlord) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
try {
const data = await apiFetch('/ratings');
renderRatings(data.ratings || [], data.summary || []);
} catch (err) {
console.error('[ratings] Laden fehlgeschlagen:', err.message);
}
}
function renderRatings(ratings, summary) {
const list = document.getElementById('ratingsList');
if (!list) return;
list.innerHTML = '';
if (summary.length) {
const summaryBox = el('div', 'meta-text', '');
summaryBox.style.marginBottom = '10px';
summaryBox.innerHTML = summary
.map(s => `<strong>${s.fullName}</strong>: Ø ${s.avgScore.toFixed(1)} (${s.count} Bewertung${s.count === 1 ? '' : 'en'})`)
.join(' &nbsp;·&nbsp; ');
list.appendChild(summaryBox);
}
if (!ratings.length) {
list.appendChild(el('div', 'empty-state', 'Noch keine Bewertungen erfasst.'));
return;
}
ratings.forEach(r => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', `${r.ratedUser.fullName} · ${'★'.repeat(r.score)}${'☆'.repeat(5 - r.score)}`));
left.appendChild(el('div', 'meta-text',
`von ${r.rater.fullName} (${ROLE_LABEL[r.rater.role] || r.rater.role}) · ${new Date(r.createdAt).toLocaleDateString('de-DE')}` +
(r.comment ? ` · ${r.comment}` : '')));
row.appendChild(left);
list.appendChild(row);
});
}
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();
</script>
</body>
</html>