Accepting an invitation as a TENANT now mandates phone number, current
first-residence address (Erstwohnsitz), and both sides of an ID
document — the accept endpoint rejects the request with a clear error
if any are missing. A Schufa credit report upload stays optional, but
if provided its issue date must be within the last 3 months or the
request is rejected.
New User fields: phoneNumber was already there; added
firstResidenceAddress, idDocumentFrontUrl, idDocumentBackUrl,
schufaDocumentUrl, schufaDocumentDate. Kept nullable at the DB level
(existing accounts have none of this and shouldn't be broken) —
enforcement lives in the accept-invitation route, not a DB constraint.
Landlords now see a completeness badge ("Profil vollständig" /
"Unvollständig: X fehlt") plus a Schufa freshness badge on each
contract card in Verträge & Abrechnung, with direct download links for
the uploaded ID/Schufa files.
3796 lines
145 KiB
HTML
3796 lines
145 KiB
HTML
<!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 & 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 & 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 & 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 & Ü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 & 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 & 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 & 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 & 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) {
|
||
body.phoneNumber = document.getElementById('acceptPhone').value.trim();
|
||
body.firstResidenceAddress = document.getElementById('acceptFirstResidence').value.trim();
|
||
body.idDocumentFrontUrl = await readFileAsDataUrl(idFrontInput.files[0]);
|
||
body.idDocumentBackUrl = await readFileAsDataUrl(idBackInput.files[0]);
|
||
if (schufaInput.files.length) {
|
||
body.schufaDocumentUrl = await readFileAsDataUrl(schufaInput.files[0]);
|
||
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>';
|
||
lastRooms.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);
|
||
});
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
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;
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = label + '.' + (url.includes('application/pdf') ? 'pdf' : 'jpg');
|
||
link.textContent = label;
|
||
link.className = 'invite-status-chip';
|
||
link.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none;';
|
||
linkWrap.appendChild(link);
|
||
});
|
||
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 = el('span', 'invite-status-chip');
|
||
chip.style.cssText = 'background:var(--bg); color:var(--text); display:inline-flex; align-items:center; gap:6px; text-transform:none;';
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = documentFileName(url, idx);
|
||
link.textContent = documentFileName(url, idx);
|
||
link.style.color = 'var(--text)';
|
||
chip.appendChild(link);
|
||
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) => {
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = documentFileName(url, idx);
|
||
link.textContent = documentFileName(url, idx);
|
||
link.className = 'invite-status-chip';
|
||
link.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none;';
|
||
docWrap.appendChild(link);
|
||
});
|
||
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(' · ');
|
||
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>
|