4547 lines
179 KiB
HTML
4547 lines
179 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-pending { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
|
||
.calendar-badge-task-completed { background: #DCEAFB; color: #2062B4; }
|
||
.calendar-badge-task-verified { background: var(--green-bg, #E4F3EA); color: var(--green, #2f8f5b); }
|
||
.calendar-badge-task-missed { background: #FBE0DE; color: #C23B2E; }
|
||
.calendar-badge-absence { background: var(--yellow-bg, #FDF3DC); color: var(--yellow, #B4780A); }
|
||
.calendar-badge-announcement { background: #F4E3FB; color: #8A2EBE; }
|
||
|
||
.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; }
|
||
|
||
/* --- Dokument-Vorschau (Modal) --- */
|
||
.doc-preview-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 200;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
}
|
||
.doc-preview-box {
|
||
background: var(--card-bg);
|
||
border-radius: 14px;
|
||
box-shadow: var(--shadow);
|
||
width: 100%;
|
||
max-width: 720px;
|
||
max-height: 90vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.doc-preview-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid var(--border);
|
||
gap: 12px;
|
||
}
|
||
.doc-preview-header strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.doc-preview-body { flex: 1; overflow: auto; background: var(--bg); display: flex; align-items: center; justify-content: center; min-height: 300px; }
|
||
.doc-preview-body img { max-width: 100%; display: block; }
|
||
.doc-preview-body iframe { width: 100%; height: 75vh; border: none; }
|
||
#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="openChangePassword()">Passwort ändern</button>
|
||
<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="notificationsSection" style="display:none">
|
||
<h2>Für dich diese Woche</h2>
|
||
<div class="ticket-list" id="notificationsList"></div>
|
||
</section>
|
||
|
||
<section id="announcementSection">
|
||
<h2>Ankündigungen</h2>
|
||
<div id="announcementFormCard" class="invite-card" style="margin-bottom:16px; display:none">
|
||
<div id="announcementFormError" style="display:none" class="form-error"></div>
|
||
<form id="announcementForm" class="invite-form-row" style="flex-wrap:wrap;">
|
||
<div class="field" style="flex:1 1 200px;">
|
||
<label for="announcementTitle">Titel</label>
|
||
<input type="text" id="announcementTitle" placeholder="z. B. Schornsteinfeger" required maxlength="120" />
|
||
</div>
|
||
<div class="field" style="flex:1 1 160px;">
|
||
<label for="announcementDate">Termin (optional)</label>
|
||
<input type="date" id="announcementDate" />
|
||
</div>
|
||
<div class="field" style="flex:2 1 240px;">
|
||
<label for="announcementMessage">Nachricht (optional)</label>
|
||
<input type="text" id="announcementMessage" maxlength="500" placeholder="z. B. bitte Zugang zum Keller freihalten" />
|
||
</div>
|
||
<button type="submit" class="btn-primary" style="width:auto" id="announcementSubmitBtn">Ankündigen</button>
|
||
</form>
|
||
</div>
|
||
<div class="ticket-list" id="announcementList"></div>
|
||
</section>
|
||
|
||
<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="inviteName">Name des Mieters (optional)</label>
|
||
<input type="text" id="inviteName" placeholder="Max Mustermann" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="inviteEmail">E-Mail-Adresse des Mieters</label>
|
||
<input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="inviteRoom">Zimmer (optional)</label>
|
||
<select id="inviteRoom">
|
||
<option value="">– kein Zimmer zuordnen –</option>
|
||
</select>
|
||
</div>
|
||
<div class="field" id="inviteStartField" style="display:none">
|
||
<label for="inviteStartDate">Mietbeginn</label>
|
||
<input type="date" id="inviteStartDate" />
|
||
</div>
|
||
<div class="field" id="inviteEndField" style="display:none">
|
||
<label for="inviteEndDate">Mietende</label>
|
||
<input type="date" id="inviteEndDate" />
|
||
<label style="font-weight:normal; margin-top:4px;">
|
||
<input type="checkbox" id="inviteUnbefristet" checked /> unbefristet
|
||
</label>
|
||
</div>
|
||
<div class="field" id="inviteDueDayField" style="display:none">
|
||
<label for="inviteDueDay">Fälligkeit der Miete</label>
|
||
<select id="inviteDueDay">
|
||
<option value="1">Immer zum 1.</option>
|
||
<option value="15">Immer zum 15.</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>Name</th><th>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr>
|
||
</thead>
|
||
<tbody id="inviteTableBody"></tbody>
|
||
</table>
|
||
</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>
|
||
<form id="cleaningNewAreaForm" class="invite-form-row" style="flex-wrap:wrap; margin-top:10px;">
|
||
<div class="field" style="flex:1 1 200px;">
|
||
<label for="cleaningNewAreaName">Neuen Bereich anlegen</label>
|
||
<input type="text" id="cleaningNewAreaName" placeholder="z. B. Keller" maxlength="60" />
|
||
</div>
|
||
<button type="submit" class="btn-secondary" style="width:auto" id="cleaningNewAreaBtn">Bereich hinzufügen</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 und ob die Aufgabe bereits erledigt wurde, eingetragene Abwesenheiten (Urlaub, "kann nicht") sowie Ankündigungen mit Termin (z. B. Handwerker) inkl. Rückmeldung, ob die Person da war.
|
||
</p>
|
||
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-bottom:12px; font-size:13px;">
|
||
<span class="calendar-badge calendar-badge-task-pending" style="padding:3px 8px;">○ Offen</span>
|
||
<span class="calendar-badge calendar-badge-task-completed" style="padding:3px 8px;">✓ Erledigt</span>
|
||
<span class="calendar-badge calendar-badge-task-verified" style="padding:3px 8px;">✓✓ Bestätigt</span>
|
||
<span class="calendar-badge calendar-badge-task-missed" style="padding:3px 8px;">✕ Verpasst</span>
|
||
<span class="calendar-badge calendar-badge-absence" style="padding:3px 8px;">Abwesend</span>
|
||
<span class="calendar-badge calendar-badge-announcement" style="padding:3px 8px;">📢 Ankündigung</span>
|
||
</div>
|
||
<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="accessCodeSection">
|
||
<h2>Smart-Lock/Alarmanlagen-Code</h2>
|
||
<div id="accessCodeAssignCard" class="invite-card" style="margin-bottom:16px; display:none">
|
||
<div id="accessCodeFormError" style="display:none" class="form-error"></div>
|
||
<form id="accessCodeForm" class="invite-form-row" style="flex-wrap:wrap;">
|
||
<div class="field" style="flex:1 1 200px;">
|
||
<label for="accessCodeUser">Mieter</label>
|
||
<select id="accessCodeUser"></select>
|
||
</div>
|
||
<div class="field" style="flex:1 1 160px;">
|
||
<label for="accessCodeValue">Code</label>
|
||
<input type="text" id="accessCodeValue" placeholder="z. B. 4711" maxlength="20" required />
|
||
</div>
|
||
<button type="submit" class="btn-primary" style="width:auto" id="accessCodeSubmitBtn">Code vergeben</button>
|
||
</form>
|
||
</div>
|
||
<p id="accessCodeTenantHint" style="color:var(--text-muted); font-size:13px; display:none">
|
||
Dieser Code wird ausschließlich vom Vermieter vergeben.
|
||
</p>
|
||
<div class="ticket-list" id="accessCodeList"></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. "✓ Name" zeigt, wer die Tonne rausgestellt hat.</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>
|
||
|
||
<!-- ================= DOKUMENT-VORSCHAU (Modal) ================= -->
|
||
<div id="docPreviewOverlay" class="doc-preview-overlay" style="display:none" onclick="if (event.target === this) closeDocumentPreview()">
|
||
<div class="doc-preview-box">
|
||
<div class="doc-preview-header">
|
||
<strong id="docPreviewTitle"></strong>
|
||
<div style="display:flex; gap:10px; align-items:center;">
|
||
<a id="docPreviewDownload" class="btn-secondary" download>Herunterladen</a>
|
||
<button type="button" class="btn-secondary" onclick="closeDocumentPreview()">Schließen ✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="doc-preview-body" id="docPreviewBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= PASSWORT ÄNDERN (Modal) ================= -->
|
||
<div id="changePasswordOverlay" class="doc-preview-overlay" style="display:none" onclick="if (event.target === this) closeChangePassword()">
|
||
<div class="doc-preview-box" style="max-width:380px;">
|
||
<div class="doc-preview-header">
|
||
<strong>Passwort ändern</strong>
|
||
<button type="button" class="btn-secondary" onclick="closeChangePassword()">Schließen ✕</button>
|
||
</div>
|
||
<div style="padding:20px;">
|
||
<div id="changePasswordError" style="display:none" class="form-error"></div>
|
||
<div id="changePasswordSuccess" style="display:none" class="form-success"></div>
|
||
<form id="changePasswordForm">
|
||
<div class="field">
|
||
<label for="currentPassword">Aktuelles Passwort</label>
|
||
<input type="password" id="currentPassword" required autocomplete="current-password" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="newPassword">Neues Passwort (min. 8 Zeichen)</label>
|
||
<input type="password" id="newPassword" required minlength="8" autocomplete="new-password" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="newPasswordRepeat">Neues Passwort wiederholen</label>
|
||
<input type="password" id="newPasswordRepeat" required minlength="8" autocomplete="new-password" />
|
||
</div>
|
||
<button type="submit" class="btn-primary" id="changePasswordSubmitBtn">Passwort ändern</button>
|
||
</form>
|
||
</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: 'Vermieter' };
|
||
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_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 ACCESS_CODE_STATUS_LABEL = { ACTIVE: 'Aktiv', 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);
|
||
}
|
||
|
||
// Formatiert nur den Kalendertag eines ISO-Datums (YYYY-MM-DD-Präfix),
|
||
// ohne über `new Date(iso).toLocaleDateString()` zu gehen — das würde je
|
||
// nach Browser-Zeitzone auf den Vor-/Folgetag verschieben, wenn das
|
||
// Backend den Zeitpunkt nahe Mitternacht UTC serialisiert (z.B.
|
||
// Fälligkeitsdatum, das serverseitig auf 23:59:59 UTC gesetzt ist).
|
||
function formatIsoDateOnly(iso) {
|
||
if (!iso) return '';
|
||
const [year, month, day] = iso.slice(0, 10).split('-');
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
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;
|
||
if (preview.fullName) document.getElementById('acceptFullName').value = preview.fullName;
|
||
form.style.display = 'flex';
|
||
form.style.flexDirection = 'column';
|
||
|
||
const isTenant = preview.role === 'TENANT';
|
||
document.getElementById('acceptTenantFields').style.display = isTenant ? 'block' : 'none';
|
||
const idFrontInput = document.getElementById('acceptIdFront');
|
||
const idBackInput = document.getElementById('acceptIdBack');
|
||
const schufaInput = document.getElementById('acceptSchufa');
|
||
const schufaDateField = document.getElementById('acceptSchufaDateField');
|
||
const schufaDateInput = document.getElementById('acceptSchufaDate');
|
||
if (isTenant) {
|
||
idFrontInput.required = true;
|
||
idBackInput.required = true;
|
||
document.getElementById('acceptPhone').required = true;
|
||
document.getElementById('acceptFirstResidence').required = true;
|
||
}
|
||
schufaInput.onchange = () => {
|
||
schufaDateField.style.display = schufaInput.files.length ? 'block' : 'none';
|
||
schufaDateInput.required = !!schufaInput.files.length;
|
||
};
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('acceptSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Wird erstellt…';
|
||
try {
|
||
const fullName = document.getElementById('acceptFullName').value.trim();
|
||
const password = document.getElementById('acceptPassword').value;
|
||
const body = { fullName, password };
|
||
if (isTenant) {
|
||
const MAX_RAW_UPLOAD_BYTES = 25 * 1024 * 1024; // Rohdatei vor Komprimierung — Fotos werden verkleinert, PDFs bleiben wie sie sind
|
||
const tooLarge = [idFrontInput.files[0], idBackInput.files[0], schufaInput.files[0]]
|
||
.filter(f => f && f.size > MAX_RAW_UPLOAD_BYTES);
|
||
if (tooLarge.length) {
|
||
throw new Error(`Datei zu groß (max. 25 MB): ${tooLarge.map(f => f.name).join(', ')}. Bitte kleineres Foto/Scan wählen.`);
|
||
}
|
||
body.phoneNumber = document.getElementById('acceptPhone').value.trim();
|
||
body.firstResidenceAddress = document.getElementById('acceptFirstResidence').value.trim();
|
||
body.idDocumentFrontUrl = await readFileAsCompressedDataUrl(idFrontInput.files[0], 1800, 0.82);
|
||
body.idDocumentBackUrl = await readFileAsCompressedDataUrl(idBackInput.files[0], 1800, 0.82);
|
||
if (schufaInput.files.length) {
|
||
body.schufaDocumentUrl = await readFileAsCompressedDataUrl(schufaInput.files[0], 1800, 0.82);
|
||
body.schufaDocumentDate = schufaDateInput.value;
|
||
}
|
||
}
|
||
const data = await apiFetch(`/invitations/${token}/accept`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
setSession(data.token, data.user);
|
||
history.replaceState(null, '', location.pathname);
|
||
enterApp();
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Konto erstellen & anmelden';
|
||
}
|
||
};
|
||
} catch (err) {
|
||
sub.textContent = '';
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
clearSession();
|
||
history.replaceState(null, '', location.pathname);
|
||
location.reload();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Sidebar-Navigation
|
||
// ---------------------------------------------------------------------
|
||
|
||
function switchPage(pageId) {
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(pageId).classList.add('active');
|
||
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
|
||
const activeBtn = document.querySelector(`.nav-item[data-page="${pageId}"]`);
|
||
if (activeBtn) activeBtn.classList.add('active');
|
||
closeSidebar();
|
||
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
|
||
// FullCalendar berechnet seine Breite beim Erstellen; war die Seite beim
|
||
// ersten Laden der Daten noch nicht sichtbar (display:none), muss es nach
|
||
// dem Einblenden einmal neu vermessen werden.
|
||
if (pageId === 'page-household' && trashCalendar) trashCalendar.updateSize();
|
||
}
|
||
|
||
function openSidebar() {
|
||
document.getElementById('sidebar').classList.add('open');
|
||
document.getElementById('sidebarOverlay').classList.add('open');
|
||
}
|
||
|
||
function closeSidebar() {
|
||
document.getElementById('sidebar').classList.remove('open');
|
||
document.getElementById('sidebarOverlay').classList.remove('open');
|
||
}
|
||
|
||
function enterApp() {
|
||
showView('appView');
|
||
document.getElementById('whoAmI').textContent =
|
||
`${currentUser.fullName} · ${ROLE_LABEL[currentUser.role] || currentUser.role}`;
|
||
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
document.getElementById('inviteSection').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('rentSectionTitle').textContent = isLandlord ? 'Miet-Ampel je Zimmer' : 'Meine Miete';
|
||
document.getElementById('cleaningAssignSection').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('cleaningSectionTitle').textContent = isLandlord ? 'Putzplan (alle Mieter)' : 'Mein Putzplan';
|
||
if (isLandlord) initInvitePanel();
|
||
if (isLandlord) initCleaningAssignForm();
|
||
|
||
document.getElementById('absenceUserField').style.display = isLandlord ? 'flex' : 'none';
|
||
|
||
document.getElementById('inventoryFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('handoverFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('storageFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('trashFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('documentFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('utilityStatementFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('craftsmanFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
|
||
initTicketForm();
|
||
initAbsenceForm();
|
||
initExpenseForm();
|
||
if (isLandlord) initInventoryForm();
|
||
if (isLandlord) initHandoverForm();
|
||
initAccessCodeForm();
|
||
if (isLandlord) initStorageForms();
|
||
if (isLandlord) initDocumentForm();
|
||
if (isLandlord) initUtilityStatementForm();
|
||
if (isLandlord) initCraftsmanForm();
|
||
initRatingForm();
|
||
initChangePasswordForm();
|
||
initAnnouncementForm();
|
||
|
||
loadData();
|
||
loadCleaningTasks();
|
||
loadCalendar();
|
||
loadExpenses();
|
||
loadInventory();
|
||
loadHandoverProtocols();
|
||
loadAccessCodes();
|
||
loadStorage();
|
||
loadDocuments();
|
||
loadNoticeDeadlines();
|
||
loadUtilityStatements();
|
||
loadCraftsmen();
|
||
loadRatings();
|
||
loadContractDocuments();
|
||
loadContractArchive();
|
||
loadAnnouncements();
|
||
|
||
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(loadAccessCodes, 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);
|
||
setInterval(loadAnnouncements, REFRESH_INTERVAL_MS);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// MIETER EINLADEN (nur Vermieter/Admin)
|
||
// ---------------------------------------------------------------------
|
||
|
||
let lastRooms = [];
|
||
let lastCockpitData = null;
|
||
|
||
// Zimmerauswahl bei der Einladung ist datumsbewusst: statt nur nach
|
||
// Room.status zu filtern (das kennt nur "aktuell frei/belegt"), fragt sie
|
||
// /contracts/room-availability für den gewählten Mietbeginn/-ende ab. So
|
||
// lässt sich z.B. ein zum 1.9. gekündigtes Zimmer schon vorab ab 1.9. neu
|
||
// vergeben, während ein unbefristet/darüber hinaus vergebenes Zimmer für
|
||
// diesen Zeitraum ausgegraut bleibt (Plausibilitätsprüfung, siehe
|
||
// findOverlappingContract in contracts.ts).
|
||
async function refreshInviteRoomOptions() {
|
||
const select = document.getElementById('inviteRoom');
|
||
const previousValue = select.value;
|
||
const startInput = document.getElementById('inviteStartDate');
|
||
const endInput = document.getElementById('inviteEndDate');
|
||
const unbefristet = document.getElementById('inviteUnbefristet').checked;
|
||
const startDate = startInput.value || new Date().toISOString().slice(0, 10);
|
||
const endDate = unbefristet ? '' : endInput.value;
|
||
|
||
select.innerHTML = '<option value="">– kein Zimmer zuordnen –</option>';
|
||
try {
|
||
const params = new URLSearchParams({ startDate });
|
||
if (endDate) params.set('endDate', endDate);
|
||
const data = await apiFetch(`/contracts/room-availability?${params.toString()}`);
|
||
(data.rooms || []).forEach(r => {
|
||
const opt = document.createElement('option');
|
||
opt.value = r.id;
|
||
opt.textContent = r.available ? r.roomNumber : `${r.roomNumber} (im Zeitraum belegt)`;
|
||
opt.disabled = !r.available;
|
||
if (!r.available && r.conflictReason) opt.title = r.conflictReason;
|
||
select.appendChild(opt);
|
||
});
|
||
if (previousValue && [...select.options].some(o => o.value === previousValue && !o.disabled)) {
|
||
select.value = previousValue;
|
||
}
|
||
} catch (err) {
|
||
console.error('[invite] Zimmerverfügbarkeit laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function populateRoomSelect() {
|
||
refreshInviteRoomOptions();
|
||
}
|
||
|
||
function initInvitePanel() {
|
||
const form = document.getElementById('inviteForm');
|
||
const errorBox = document.getElementById('inviteFormError');
|
||
const roomSelect = document.getElementById('inviteRoom');
|
||
const startField = document.getElementById('inviteStartField');
|
||
const endField = document.getElementById('inviteEndField');
|
||
const startInput = document.getElementById('inviteStartDate');
|
||
const endInput = document.getElementById('inviteEndDate');
|
||
const unbefristetCheckbox = document.getElementById('inviteUnbefristet');
|
||
const dueDayField = document.getElementById('inviteDueDayField');
|
||
const dueDaySelect = document.getElementById('inviteDueDay');
|
||
|
||
roomSelect.onchange = () => {
|
||
const hasRoom = !!roomSelect.value;
|
||
startField.style.display = hasRoom ? 'flex' : 'none';
|
||
endField.style.display = hasRoom ? 'flex' : 'none';
|
||
dueDayField.style.display = hasRoom ? 'flex' : 'none';
|
||
if (hasRoom && !startInput.value) startInput.value = new Date().toISOString().slice(0, 10);
|
||
};
|
||
unbefristetCheckbox.onchange = () => {
|
||
endInput.disabled = unbefristetCheckbox.checked;
|
||
if (unbefristetCheckbox.checked) endInput.value = '';
|
||
refreshInviteRoomOptions();
|
||
};
|
||
startInput.onchange = refreshInviteRoomOptions;
|
||
endInput.onchange = refreshInviteRoomOptions;
|
||
endInput.disabled = unbefristetCheckbox.checked;
|
||
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('inviteSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Lädt ein…';
|
||
try {
|
||
const fullName = document.getElementById('inviteName').value.trim() || undefined;
|
||
const email = document.getElementById('inviteEmail').value.trim();
|
||
const roomId = roomSelect.value || undefined;
|
||
const contractStartDate = roomId ? (startInput.value || undefined) : undefined;
|
||
const contractEndDate = roomId && !unbefristetCheckbox.checked ? (endInput.value || undefined) : undefined;
|
||
const paymentDueDay = roomId ? Number(dueDaySelect.value) : undefined;
|
||
const data = await apiFetch('/invitations', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ email, fullName, roomId, contractStartDate, contractEndDate, paymentDueDay }),
|
||
});
|
||
document.getElementById('inviteLinkText').textContent = data.inviteLink;
|
||
document.getElementById('inviteLinkResult').style.display = 'flex';
|
||
document.getElementById('inviteName').value = '';
|
||
document.getElementById('inviteEmail').value = '';
|
||
startField.style.display = 'none';
|
||
endField.style.display = 'none';
|
||
dueDayField.style.display = 'none';
|
||
startInput.value = '';
|
||
endInput.value = '';
|
||
unbefristetCheckbox.checked = true;
|
||
loadInvitations();
|
||
refreshInviteRoomOptions();
|
||
} 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.fullName || '–'));
|
||
tr.appendChild(el('td', '', inv.email));
|
||
tr.appendChild(el('td', '', inv.room ? inv.room.roomNumber : '–'));
|
||
tr.appendChild(el('td', '', `<span class="invite-status-chip ${inv.status}">${INVITE_STATUS_LABEL[inv.status] || inv.status}</span>`));
|
||
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);
|
||
}
|
||
if (inv.status === 'REVOKED' && currentUser.role === 'ADMIN') {
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.className = 'btn-secondary';
|
||
deleteBtn.textContent = 'Endgültig löschen';
|
||
deleteBtn.onclick = () => deleteInvitationPermanently(inv.id);
|
||
actionsTd.appendChild(deleteBtn);
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
async function deleteInvitationPermanently(id) {
|
||
if (!confirm('Diese zurückgezogene Einladung endgültig löschen? Das kann nicht rückgängig gemacht werden.')) return;
|
||
try {
|
||
await apiFetch(`/invitations/${id}/permanent`, { 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;
|
||
lastCockpitData = data;
|
||
populateRoomSelect();
|
||
populateTicketRoomSelect();
|
||
populateInventoryRoomSelect();
|
||
render(data);
|
||
loadNotifications();
|
||
} 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.dueDate) {
|
||
const dueDateRow = el('div', 'amount-row');
|
||
dueDateRow.appendChild(el('span', 'label', 'Fälligkeitsdatum'));
|
||
dueDateRow.appendChild(el('span', 'value', formatIsoDateOnly(light.dueDate)));
|
||
card.appendChild(dueDateRow);
|
||
}
|
||
|
||
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 loadCleaningAreasIntoSelect(areaSelect) {
|
||
areaSelect.innerHTML = '';
|
||
try {
|
||
const data = await apiFetch('/cleaning-areas');
|
||
data.areas.forEach(a => {
|
||
const opt = document.createElement('option');
|
||
opt.value = a.name;
|
||
opt.textContent = a.name;
|
||
areaSelect.appendChild(opt);
|
||
});
|
||
} catch (err) {
|
||
console.error('[cleaning-tasks] Bereichsliste laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
async function initCleaningAssignForm() {
|
||
const userSelect = document.getElementById('cleaningUser');
|
||
const areaSelect = document.getElementById('cleaningArea');
|
||
userSelect.innerHTML = '';
|
||
await loadCleaningAreasIntoSelect(areaSelect);
|
||
|
||
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 newAreaForm = document.getElementById('cleaningNewAreaForm');
|
||
if (newAreaForm) {
|
||
newAreaForm.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const input = document.getElementById('cleaningNewAreaName');
|
||
const name = input.value.trim();
|
||
if (!name) return;
|
||
const btn = document.getElementById('cleaningNewAreaBtn');
|
||
btn.disabled = true;
|
||
try {
|
||
await apiFetch('/cleaning-areas', { method: 'POST', body: JSON.stringify({ name }) });
|
||
input.value = '';
|
||
await loadCleaningAreasIntoSelect(areaSelect);
|
||
areaSelect.value = name;
|
||
} catch (err) {
|
||
alert(err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
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', 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();
|
||
loadCalendar();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
async function verifyCleaningTask(id) {
|
||
try {
|
||
await apiFetch(`/cleaning-tasks/${id}`, { method: 'PATCH', body: JSON.stringify({ status: 'VERIFIED' }) });
|
||
loadCleaningTasks();
|
||
loadCalendar();
|
||
} 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: [] };
|
||
let calendarAnnouncements = [];
|
||
|
||
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;
|
||
} catch (err) {
|
||
console.error('[cleaning-calendar] Laden fehlgeschlagen:', err.message);
|
||
}
|
||
try {
|
||
const announcementData = await apiFetch('/announcements');
|
||
calendarAnnouncements = announcementData.announcements.filter(a => a.eventDate);
|
||
} catch (err) {
|
||
console.error('[cleaning-calendar] Ankündigungen laden fehlgeschlagen:', err.message);
|
||
}
|
||
renderCalendar();
|
||
}
|
||
|
||
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 statusIcon = { PENDING: '○', COMPLETED: '✓', VERIFIED: '✓✓', MISSED: '✕' }[t.status] || '';
|
||
const statusClass = {
|
||
PENDING: 'calendar-badge-task-pending',
|
||
COMPLETED: 'calendar-badge-task-completed',
|
||
VERIFIED: 'calendar-badge-task-verified',
|
||
MISSED: 'calendar-badge-task-missed',
|
||
}[t.status] || 'calendar-badge-task-pending';
|
||
const label = `${statusIcon} ${t.area}${t.assignedUser ? ' · ' + t.assignedUser.fullName.split(' ')[0] : ''}`;
|
||
const badge = el('div', `calendar-badge ${statusClass}`, label);
|
||
badge.title = `${CLEANING_STATUS_LABEL[t.status] || t.status}`;
|
||
cell.appendChild(badge);
|
||
});
|
||
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`));
|
||
});
|
||
calendarAnnouncements.filter(a => sameDay(new Date(a.eventDate), d)).forEach(a => {
|
||
const attendanceSuffix = a.attendanceStatus ? (a.attendanceStatus === 'PRESENT' ? ' ✓' : ' ✕') : '';
|
||
cell.appendChild(el('div', 'calendar-badge calendar-badge-announcement', `📢 ${a.title}${attendanceSuffix}`));
|
||
});
|
||
|
||
grid.appendChild(cell);
|
||
}
|
||
|
||
const absenceList = document.getElementById('absenceList');
|
||
absenceList.innerHTML = '';
|
||
if (!calendarData.absences.length) {
|
||
absenceList.appendChild(el('div', 'empty-state', 'Keine Abwesenheiten in diesem Monat eingetragen.'));
|
||
} else {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
calendarData.absences.forEach(a => {
|
||
const row = el('div', 'ticket-row');
|
||
const left = el('div');
|
||
left.appendChild(el('div', 'title', a.user.fullName));
|
||
left.appendChild(el('div', 'meta-text',
|
||
`${new Date(a.startDate).toLocaleDateString('de-DE')} – ${new Date(a.endDate).toLocaleDateString('de-DE')}` +
|
||
(a.note ? ` · ${a.note}` : '')));
|
||
row.appendChild(left);
|
||
if (isLandlord || a.user.id === currentUser.id) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = 'Löschen';
|
||
btn.onclick = () => deleteAbsence(a.id);
|
||
row.appendChild(btn);
|
||
}
|
||
absenceList.appendChild(row);
|
||
});
|
||
}
|
||
}
|
||
|
||
function stripTime(d) {
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
}
|
||
|
||
function sameDay(a, b) {
|
||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||
}
|
||
|
||
async function deleteAbsence(id) {
|
||
try {
|
||
await apiFetch(`/cleaning-absences/${id}`, { method: 'DELETE' });
|
||
loadCalendar();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// WG-KASSE (AUSGABEN-TEILER)
|
||
// ---------------------------------------------------------------------
|
||
|
||
function populateExpenseCategorySelect() {
|
||
const select = document.getElementById('expenseCategory');
|
||
select.innerHTML = '';
|
||
Object.keys(EXPENSE_CATEGORY_LABEL).forEach(key => {
|
||
const opt = document.createElement('option');
|
||
opt.value = key;
|
||
opt.textContent = EXPENSE_CATEGORY_LABEL[key];
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function initExpenseForm() {
|
||
populateExpenseCategorySelect();
|
||
const form = document.getElementById('expenseForm');
|
||
const errorBox = document.getElementById('expenseFormError');
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('expenseSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Speichert…';
|
||
try {
|
||
const amount = document.getElementById('expenseAmount').value;
|
||
const description = document.getElementById('expenseDescription').value.trim();
|
||
const category = document.getElementById('expenseCategory').value;
|
||
await apiFetch('/expenses', { method: 'POST', body: JSON.stringify({ amount, description, category }) });
|
||
form.reset();
|
||
populateExpenseCategorySelect();
|
||
loadExpenses();
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Eintragen';
|
||
}
|
||
};
|
||
}
|
||
|
||
async function loadExpenses() {
|
||
if (!currentUser) return;
|
||
try {
|
||
const [expensesData, balanceData] = await Promise.all([
|
||
apiFetch('/expenses'),
|
||
apiFetch('/expenses/balance'),
|
||
]);
|
||
renderExpenseBalance(balanceData.balance);
|
||
renderExpenses(expensesData.expenses);
|
||
} catch (err) {
|
||
console.error('[expenses] Laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function renderExpenseBalance(balance) {
|
||
const grid = document.getElementById('expenseBalanceGrid');
|
||
grid.innerHTML = '';
|
||
if (!balance.length) {
|
||
grid.appendChild(el('div', 'empty-state', 'Aktuell keine offenen Salden. 🎉'));
|
||
return;
|
||
}
|
||
balance.forEach(b => {
|
||
const net = b.isOwed - b.owes;
|
||
const card = el('div', `room-card ${net > 0 ? 'GREEN' : (net < 0 ? 'RED' : '')}`);
|
||
const top = el('div', 'room-card-top');
|
||
top.appendChild(el('div', 'room-name', b.user.fullName));
|
||
card.appendChild(top);
|
||
const owesRow = el('div', 'amount-row');
|
||
owesRow.appendChild(el('span', 'label', 'Schuldet'));
|
||
owesRow.appendChild(el('span', 'value', formatEuro(b.owes)));
|
||
card.appendChild(owesRow);
|
||
const owedRow = el('div', 'amount-row');
|
||
owedRow.appendChild(el('span', 'label', 'Bekommt'));
|
||
owedRow.appendChild(el('span', 'value', formatEuro(b.isOwed)));
|
||
card.appendChild(owedRow);
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderExpenses(expenses) {
|
||
const list = document.getElementById('expenseList');
|
||
list.innerHTML = '';
|
||
if (!expenses.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Noch keine Ausgaben erfasst.'));
|
||
return;
|
||
}
|
||
expenses.forEach(x => {
|
||
const row = el('div', 'ticket-row');
|
||
const left = el('div');
|
||
left.appendChild(el('div', 'title', `${x.description} · ${formatEuro(x.amount)}`));
|
||
left.appendChild(el('div', 'meta-text',
|
||
`${x.payer.fullName} · ${EXPENSE_CATEGORY_LABEL[x.category] || x.category} · ${new Date(x.createdAt).toLocaleDateString('de-DE')}`));
|
||
const openShares = x.shares.filter(s => s.settlementStatus === 'OPEN');
|
||
if (openShares.length) {
|
||
const shareText = openShares.map(s => `${s.user.fullName}: ${formatEuro(s.shareAmount)}`).join(', ');
|
||
left.appendChild(el('div', 'meta-text', 'Offen: ' + shareText));
|
||
}
|
||
row.appendChild(left);
|
||
|
||
const right = el('div');
|
||
right.style.display = 'flex';
|
||
right.style.gap = '8px';
|
||
right.style.flexWrap = 'wrap';
|
||
const myShare = x.shares.find(s => s.user.id === currentUser.id && s.settlementStatus === 'OPEN');
|
||
if (myShare) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = 'Meinen Anteil begleichen';
|
||
btn.onclick = () => settleExpenseShare(myShare.id);
|
||
right.appendChild(btn);
|
||
}
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
if (isLandlord || x.payer.id === currentUser.id) {
|
||
openShares.forEach(s => {
|
||
if (s.user.id === currentUser.id) return; // schon oben abgedeckt
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = `${s.user.fullName.split(' ')[0]}: als bezahlt markieren`;
|
||
btn.onclick = () => settleExpenseShare(s.id);
|
||
right.appendChild(btn);
|
||
});
|
||
}
|
||
row.appendChild(right);
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function settleExpenseShare(id) {
|
||
try {
|
||
await apiFetch(`/expense-shares/${id}`, { method: 'PATCH' });
|
||
loadExpenses();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// INVENTARVERWALTUNG
|
||
// ---------------------------------------------------------------------
|
||
|
||
function populateInventoryRoomSelect() {
|
||
const roomSelect = document.getElementById('inventoryRoom');
|
||
if (!roomSelect) return;
|
||
roomSelect.innerHTML = '';
|
||
const noneOpt = document.createElement('option');
|
||
noneOpt.value = '';
|
||
noneOpt.textContent = 'Gemeinschaftsfläche';
|
||
roomSelect.appendChild(noneOpt);
|
||
lastRooms.forEach(r => {
|
||
const opt = document.createElement('option');
|
||
opt.value = r.id;
|
||
opt.textContent = r.roomNumber;
|
||
roomSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function populateInventoryConditionSelect() {
|
||
const condSelect = document.getElementById('inventoryCondition');
|
||
condSelect.innerHTML = '';
|
||
Object.keys(ITEM_CONDITION_LABEL).forEach(key => {
|
||
const opt = document.createElement('option');
|
||
opt.value = key;
|
||
opt.textContent = ITEM_CONDITION_LABEL[key];
|
||
condSelect.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
const MAX_INVENTORY_PHOTOS = 6;
|
||
let inventoryPhotoDataUrls = [];
|
||
|
||
function readFileAsDataUrl(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
// Handyfotos (Ausweis, Schufa-Scan) können 15-25MB pro Bild groß sein —
|
||
// als Data-URL im JSON-Body summiert sich das schnell über jedes sinnvolle
|
||
// Server-Limit. Bilder werden daher vor dem Versand auf eine für Dokumente
|
||
// mehr als ausreichende Auflösung herunterskaliert (PDFs bleiben unangetastet,
|
||
// die sind i. d. R. schon kompakt).
|
||
function readFileAsCompressedDataUrl(file, maxDimension, quality) {
|
||
if (file.type === 'application/pdf') return readFileAsDataUrl(file);
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
let { width, height } = img;
|
||
if (width > maxDimension || height > maxDimension) {
|
||
const scale = maxDimension / Math.max(width, height);
|
||
width = Math.round(width * scale);
|
||
height = Math.round(height * scale);
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||
URL.revokeObjectURL(img.src);
|
||
resolve(canvas.toDataURL('image/jpeg', quality || 0.82));
|
||
};
|
||
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'));
|
||
img.src = URL.createObjectURL(file);
|
||
});
|
||
}
|
||
|
||
function renderInventoryPhotoPreview() {
|
||
const preview = document.getElementById('inventoryPhotoPreview');
|
||
preview.innerHTML = '';
|
||
inventoryPhotoDataUrls.forEach((dataUrl, idx) => {
|
||
const wrap = el('div');
|
||
wrap.style.position = 'relative';
|
||
const img = document.createElement('img');
|
||
img.src = dataUrl;
|
||
img.className = 'inventory-thumb';
|
||
wrap.appendChild(img);
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.title = 'Entfernen';
|
||
removeBtn.style.cssText = 'position:absolute; top:-6px; right:-6px; width:18px; height:18px; border-radius:50%; border:1px solid var(--border); background:#fff; cursor:pointer; font-size:11px; line-height:1; padding:0;';
|
||
removeBtn.onclick = () => {
|
||
inventoryPhotoDataUrls.splice(idx, 1);
|
||
renderInventoryPhotoPreview();
|
||
};
|
||
wrap.appendChild(removeBtn);
|
||
preview.appendChild(wrap);
|
||
});
|
||
}
|
||
|
||
function initInventoryForm() {
|
||
populateInventoryRoomSelect();
|
||
populateInventoryConditionSelect();
|
||
inventoryPhotoDataUrls = [];
|
||
renderInventoryPhotoPreview();
|
||
|
||
const photoInput = document.getElementById('inventoryPhotos');
|
||
photoInput.onchange = async () => {
|
||
const files = Array.from(photoInput.files || []).slice(0, MAX_INVENTORY_PHOTOS - inventoryPhotoDataUrls.length);
|
||
for (const file of files) {
|
||
try {
|
||
const dataUrl = await readFileAsDataUrl(file);
|
||
inventoryPhotoDataUrls.push(dataUrl);
|
||
} catch (err) {
|
||
console.error('[inventory] Foto konnte nicht gelesen werden:', err);
|
||
}
|
||
}
|
||
photoInput.value = '';
|
||
renderInventoryPhotoPreview();
|
||
};
|
||
|
||
const form = document.getElementById('inventoryForm');
|
||
const errorBox = document.getElementById('inventoryFormError');
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('inventorySubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Speichert…';
|
||
try {
|
||
const itemName = document.getElementById('inventoryItemName').value.trim();
|
||
const roomId = document.getElementById('inventoryRoom').value || undefined;
|
||
const condition = document.getElementById('inventoryCondition').value;
|
||
const purchasePriceRaw = document.getElementById('inventoryPrice').value;
|
||
const purchasePrice = purchasePriceRaw ? Number(purchasePriceRaw) : undefined;
|
||
await apiFetch('/inventory', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ itemName, roomId, condition, purchasePrice, photoUrls: inventoryPhotoDataUrls }),
|
||
});
|
||
form.reset();
|
||
populateInventoryConditionSelect();
|
||
inventoryPhotoDataUrls = [];
|
||
renderInventoryPhotoPreview();
|
||
loadInventory();
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Hinzufügen';
|
||
}
|
||
};
|
||
}
|
||
|
||
async function loadInventory() {
|
||
if (!currentUser) return;
|
||
try {
|
||
const data = await apiFetch('/inventory');
|
||
renderInventory(data.items);
|
||
} catch (err) {
|
||
console.error('[inventory] Laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function renderInventory(items) {
|
||
const list = document.getElementById('inventoryList');
|
||
list.innerHTML = '';
|
||
if (!items.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Noch kein Inventar erfasst.'));
|
||
return;
|
||
}
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
items.forEach(i => {
|
||
const row = el('div', 'ticket-row');
|
||
const left = el('div');
|
||
left.appendChild(el('div', 'title', i.itemName));
|
||
left.appendChild(el('div', 'meta-text',
|
||
`${i.room ? i.room.roomNumber : 'Gemeinschaftsfläche'} · ${ITEM_CONDITION_LABEL[i.condition] || i.condition}` +
|
||
(i.purchasePrice ? ` · ${formatEuro(i.purchasePrice)}` : '')));
|
||
if (i.photoUrls && i.photoUrls.length) {
|
||
const thumbRow = el('div', 'inventory-thumb-row');
|
||
i.photoUrls.forEach(url => {
|
||
const img = document.createElement('img');
|
||
img.src = url;
|
||
img.className = 'inventory-thumb';
|
||
thumbRow.appendChild(img);
|
||
});
|
||
left.appendChild(thumbRow);
|
||
}
|
||
row.appendChild(left);
|
||
if (isLandlord) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = 'Löschen';
|
||
btn.onclick = () => deleteInventoryItem(i.id);
|
||
row.appendChild(btn);
|
||
}
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function deleteInventoryItem(id) {
|
||
try {
|
||
await apiFetch(`/inventory/${id}`, { method: 'DELETE' });
|
||
loadInventory();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// ÜBERGABEPROTOKOLLE
|
||
// ---------------------------------------------------------------------
|
||
|
||
async function populateHandoverContractSelect() {
|
||
const select = document.getElementById('handoverContract');
|
||
select.innerHTML = '';
|
||
try {
|
||
const data = await apiFetch('/contracts');
|
||
data.contracts.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = `${c.user.fullName} · ${c.room.roomNumber}`;
|
||
select.appendChild(opt);
|
||
});
|
||
} catch (err) {
|
||
console.error('[handover] Verträge laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function initHandoverForm() {
|
||
populateHandoverContractSelect();
|
||
const form = document.getElementById('handoverForm');
|
||
const errorBox = document.getElementById('handoverFormError');
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('handoverSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Speichert…';
|
||
try {
|
||
const contractId = document.getElementById('handoverContract').value;
|
||
const type = document.getElementById('handoverType').value;
|
||
const protocolDate = document.getElementById('handoverDate').value;
|
||
await apiFetch('/handover-protocols', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ contractId, type, protocolDate, items: [], meterReadings: [] }),
|
||
});
|
||
form.reset();
|
||
loadHandoverProtocols();
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Protokoll anlegen';
|
||
}
|
||
};
|
||
}
|
||
|
||
async function loadHandoverProtocols() {
|
||
if (!currentUser) return;
|
||
try {
|
||
const data = await apiFetch('/handover-protocols');
|
||
renderHandoverProtocols(data.protocols);
|
||
} catch (err) {
|
||
console.error('[handover] Laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function renderHandoverProtocols(protocols) {
|
||
const list = document.getElementById('handoverList');
|
||
list.innerHTML = '';
|
||
if (!protocols.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Noch keine Übergabeprotokolle vorhanden.'));
|
||
return;
|
||
}
|
||
protocols.forEach(p => {
|
||
const row = el('div', 'ticket-row');
|
||
const left = el('div');
|
||
left.appendChild(el('div', 'title', `${HANDOVER_TYPE_LABEL[p.type] || p.type} · ${p.contract.room.roomNumber}`));
|
||
left.appendChild(el('div', 'meta-text',
|
||
`${p.user.fullName} · ${new Date(p.protocolDate).toLocaleDateString('de-DE')} · ${p.items.length} Positionen, ${p.meterReadings.length} Zählerstände`));
|
||
row.appendChild(left);
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// SMART-LOCK/ALARMANLAGEN-CODE
|
||
// ---------------------------------------------------------------------
|
||
|
||
async function initAccessCodeForm() {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
document.getElementById('accessCodeAssignCard').style.display = isLandlord ? 'block' : 'none';
|
||
document.getElementById('accessCodeTenantHint').style.display = isLandlord ? 'none' : 'block';
|
||
if (!isLandlord) return;
|
||
|
||
const userSelect = document.getElementById('accessCodeUser');
|
||
userSelect.innerHTML = '';
|
||
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('[access-codes] Mieterliste laden fehlgeschlagen:', err.message);
|
||
}
|
||
|
||
const form = document.getElementById('accessCodeForm');
|
||
const errorBox = document.getElementById('accessCodeFormError');
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('accessCodeSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Vergibt…';
|
||
try {
|
||
const userId = userSelect.value;
|
||
const code = document.getElementById('accessCodeValue').value;
|
||
await apiFetch('/access-codes', { method: 'POST', body: JSON.stringify({ userId, code }) });
|
||
form.reset();
|
||
loadAccessCodes();
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Code vergeben';
|
||
}
|
||
};
|
||
}
|
||
|
||
async function loadAccessCodes() {
|
||
if (!currentUser) return;
|
||
try {
|
||
const data = await apiFetch('/access-codes');
|
||
renderAccessCodes(data.accessCodes);
|
||
} catch (err) {
|
||
console.error('[access-codes] Laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function renderAccessCodes(codes) {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
const list = document.getElementById('accessCodeList');
|
||
list.innerHTML = '';
|
||
if (!codes.length) {
|
||
list.appendChild(el('div', 'empty-state', isLandlord ? 'Noch keine Codes vergeben.' : 'Dir wurde noch kein Code zugewiesen.'));
|
||
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',
|
||
`${isLandlord && g.user ? g.user.fullName + ' · ' : ''}vergeben von ${g.assignedBy.fullName} · ${ACCESS_CODE_STATUS_LABEL[g.status] || g.status}`));
|
||
row.appendChild(left);
|
||
if (isLandlord && g.status === 'ACTIVE') {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = 'Widerrufen';
|
||
btn.onclick = () => revokeAccessCode(g.id);
|
||
row.appendChild(btn);
|
||
}
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function revokeAccessCode(id) {
|
||
try {
|
||
await apiFetch(`/access-codes/${id}`, { method: 'DELETE' });
|
||
loadAccessCodes();
|
||
} 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 => {
|
||
const label = TRASH_TYPE_LABEL[t.type] || t.type;
|
||
trashCalendar.addEvent({
|
||
id: t.id,
|
||
title: t.completedBy ? `${label} ✓ ${t.completedBy.fullName.split(' ')[0]}` : label,
|
||
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() {
|
||
// Während der Nutzer in einem der Vertragsformulare tippt, darf der
|
||
// periodische Auto-Refresh die Eingaben nicht überschreiben (sonst
|
||
// werden Formularfelder nach REFRESH_INTERVAL_MS scheinbar "automatisch
|
||
// geleert"). Solange der Fokus innerhalb der Liste liegt, wird der
|
||
// Refresh daher übersprungen.
|
||
const list = document.getElementById('contractDocumentsList');
|
||
if (list && list.contains(document.activeElement)) return;
|
||
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 docFileExt(url) {
|
||
return url.includes('application/pdf') ? 'pdf' : (/^data:image\/(\w+)/.exec(url)?.[1] || 'jpg');
|
||
}
|
||
|
||
// Klick auf den Namen öffnet eine kleine Vorschau im selben Tab (Modal) —
|
||
// ein Link mit target="_blank" auf eine data:-URL würde Chrome als
|
||
// Top-Level-Navigation blockieren (Phishing-Schutz), als eingebettetes
|
||
// <img>/<iframe> im Modal ist das kein Problem. Der Pfeil daneben lädt bei
|
||
// Bedarf separat herunter. Für alle hochgeladenen Dokumente (Verträge,
|
||
// Ausweis, Schufa) einheitlich.
|
||
function buildDocumentChip(label, url) {
|
||
const chip = el('span', 'invite-status-chip');
|
||
chip.style.cssText = 'background:var(--bg); color:var(--text); text-transform:none; display:inline-flex; align-items:center; gap:7px;';
|
||
|
||
const previewBtn = document.createElement('button');
|
||
previewBtn.type = 'button';
|
||
previewBtn.textContent = label;
|
||
previewBtn.style.cssText = 'background:none; border:none; padding:0; margin:0; font:inherit; color:var(--text); text-decoration:underline; cursor:pointer;';
|
||
previewBtn.onclick = () => openDocumentPreview(label, url);
|
||
chip.appendChild(previewBtn);
|
||
|
||
const downloadLink = document.createElement('a');
|
||
downloadLink.href = url;
|
||
downloadLink.download = `${label.replace(/[^\w.-]+/g, '_')}.${docFileExt(url)}`;
|
||
downloadLink.title = 'Herunterladen';
|
||
downloadLink.textContent = '⬇';
|
||
downloadLink.style.cssText = 'color:var(--text-muted); text-decoration:none; font-size:12px;';
|
||
chip.appendChild(downloadLink);
|
||
|
||
return chip;
|
||
}
|
||
|
||
function openDocumentPreview(label, url) {
|
||
document.getElementById('docPreviewTitle').textContent = label;
|
||
const body = document.getElementById('docPreviewBody');
|
||
body.innerHTML = '';
|
||
if (url.startsWith('data:application/pdf')) {
|
||
const iframe = document.createElement('iframe');
|
||
iframe.src = url;
|
||
body.appendChild(iframe);
|
||
} else {
|
||
const img = document.createElement('img');
|
||
img.src = url;
|
||
img.alt = label;
|
||
body.appendChild(img);
|
||
}
|
||
const downloadLink = document.getElementById('docPreviewDownload');
|
||
downloadLink.href = url;
|
||
downloadLink.download = `${label.replace(/[^\w.-]+/g, '_')}.${docFileExt(url)}`;
|
||
document.getElementById('docPreviewOverlay').style.display = 'flex';
|
||
}
|
||
|
||
function closeDocumentPreview() {
|
||
document.getElementById('docPreviewOverlay').style.display = 'none';
|
||
document.getElementById('docPreviewBody').innerHTML = '';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// PASSWORT ÄNDERN (für alle Rollen)
|
||
// ---------------------------------------------------------------------
|
||
|
||
function openChangePassword() {
|
||
document.getElementById('changePasswordError').style.display = 'none';
|
||
document.getElementById('changePasswordSuccess').style.display = 'none';
|
||
document.getElementById('changePasswordForm').reset();
|
||
document.getElementById('changePasswordOverlay').style.display = 'flex';
|
||
}
|
||
|
||
function closeChangePassword() {
|
||
document.getElementById('changePasswordOverlay').style.display = 'none';
|
||
}
|
||
|
||
function initChangePasswordForm() {
|
||
const form = document.getElementById('changePasswordForm');
|
||
const errorBox = document.getElementById('changePasswordError');
|
||
const successBox = document.getElementById('changePasswordSuccess');
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
successBox.style.display = 'none';
|
||
const currentPassword = document.getElementById('currentPassword').value;
|
||
const newPassword = document.getElementById('newPassword').value;
|
||
const newPasswordRepeat = document.getElementById('newPasswordRepeat').value;
|
||
if (newPassword !== newPasswordRepeat) {
|
||
errorBox.textContent = 'Die neuen Passwörter stimmen nicht überein.';
|
||
errorBox.style.display = 'block';
|
||
return;
|
||
}
|
||
const submitBtn = document.getElementById('changePasswordSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Ändert…';
|
||
try {
|
||
await apiFetch('/auth/change-password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ currentPassword, newPassword }),
|
||
});
|
||
form.reset();
|
||
successBox.textContent = 'Passwort erfolgreich geändert.';
|
||
successBox.style.display = 'block';
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Passwort ändern';
|
||
}
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// ANKÜNDIGUNGEN
|
||
// ---------------------------------------------------------------------
|
||
|
||
function initAnnouncementForm() {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
document.getElementById('announcementFormCard').style.display = isLandlord ? 'block' : 'none';
|
||
if (!isLandlord) return;
|
||
|
||
const form = document.getElementById('announcementForm');
|
||
const errorBox = document.getElementById('announcementFormError');
|
||
form.onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
errorBox.style.display = 'none';
|
||
const submitBtn = document.getElementById('announcementSubmitBtn');
|
||
submitBtn.disabled = true;
|
||
submitBtn.textContent = 'Kündigt an…';
|
||
try {
|
||
const title = document.getElementById('announcementTitle').value;
|
||
const eventDate = document.getElementById('announcementDate').value;
|
||
const message = document.getElementById('announcementMessage').value;
|
||
await apiFetch('/announcements', { method: 'POST', body: JSON.stringify({ title, eventDate: eventDate || null, message }) });
|
||
form.reset();
|
||
loadAnnouncements();
|
||
loadNotifications();
|
||
} catch (err) {
|
||
errorBox.textContent = err.message;
|
||
errorBox.style.display = 'block';
|
||
} finally {
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Ankündigen';
|
||
}
|
||
};
|
||
}
|
||
|
||
async function loadAnnouncements() {
|
||
if (!currentUser) return;
|
||
try {
|
||
const data = await apiFetch('/announcements');
|
||
renderAnnouncements(data.announcements);
|
||
} catch (err) {
|
||
console.error('[announcements] Laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
// Rückmeldung "War da"/"War nicht da" für Ankündigungen mit Termin (z. B.
|
||
// Handwerker/Schornsteinfeger) — jeder Nutzer darf das markieren, nicht
|
||
// nur der Vermieter. Einmal markiert, wird nur noch der Status angezeigt.
|
||
function buildAttendanceControl(a, onDone) {
|
||
if (!a.eventDate) return null;
|
||
if (a.attendanceStatus) {
|
||
const label = a.attendanceStatus === 'PRESENT' ? '✓ War da' : '✕ War nicht da';
|
||
const span = el('span', 'invite-status-chip', `${label}${a.attendanceMarkedBy ? ' · ' + a.attendanceMarkedBy.fullName.split(' ')[0] : ''}`);
|
||
return span;
|
||
}
|
||
const wrap = el('div');
|
||
wrap.style.cssText = 'display:flex; gap:6px;';
|
||
const presentBtn = document.createElement('button');
|
||
presentBtn.className = 'btn-secondary';
|
||
presentBtn.textContent = 'War da';
|
||
presentBtn.onclick = () => markAnnouncementAttendance(a.id, 'PRESENT', onDone);
|
||
const absentBtn = document.createElement('button');
|
||
absentBtn.className = 'btn-secondary';
|
||
absentBtn.textContent = 'War nicht da';
|
||
absentBtn.onclick = () => markAnnouncementAttendance(a.id, 'ABSENT', onDone);
|
||
wrap.appendChild(presentBtn);
|
||
wrap.appendChild(absentBtn);
|
||
return wrap;
|
||
}
|
||
|
||
async function markAnnouncementAttendance(id, status, onDone) {
|
||
try {
|
||
await apiFetch(`/announcements/${id}/attendance`, { method: 'POST', body: JSON.stringify({ status }) });
|
||
if (onDone) onDone();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
function renderAnnouncements(announcements) {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
const list = document.getElementById('announcementList');
|
||
list.innerHTML = '';
|
||
if (!announcements.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Keine Ankündigungen.'));
|
||
return;
|
||
}
|
||
announcements.forEach(a => {
|
||
const row = el('div', 'ticket-row');
|
||
const left = el('div');
|
||
left.appendChild(el('div', 'title', a.title));
|
||
left.appendChild(el('div', 'meta-text',
|
||
`${a.eventDate ? new Date(a.eventDate).toLocaleDateString('de-DE') + ' · ' : ''}${a.message || ''}`));
|
||
row.appendChild(left);
|
||
const right = el('div');
|
||
right.style.cssText = 'display:flex; gap:8px; align-items:center;';
|
||
const attendance = buildAttendanceControl(a, () => { loadAnnouncements(); loadNotifications(); loadCalendar(); });
|
||
if (attendance) right.appendChild(attendance);
|
||
if (isLandlord) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = 'Löschen';
|
||
btn.onclick = () => deleteAnnouncement(a.id);
|
||
right.appendChild(btn);
|
||
}
|
||
row.appendChild(right);
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function deleteAnnouncement(id) {
|
||
try {
|
||
await apiFetch(`/announcements/${id}`, { method: 'DELETE' });
|
||
loadAnnouncements();
|
||
loadNotifications();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// NACHRICHTEN-ÜBERSICHT ("Für dich diese Woche")
|
||
//
|
||
// Bündelt für den Mieter alles Terminliche an einer Stelle: eigene fällige
|
||
// Putzaufgaben, fällige Miete, anstehende Mülltermine sowie allgemeine
|
||
// Ankündigungen des Vermieters — jeweils nur, wenn sie in den nächsten 7
|
||
// Tagen liegen (bzw. bei der Miete: aktuell fällig/überfällig).
|
||
// ---------------------------------------------------------------------
|
||
|
||
async function loadNotifications() {
|
||
if (!currentUser) return;
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
document.getElementById('notificationsSection').style.display = isLandlord ? 'none' : 'block';
|
||
if (isLandlord) return;
|
||
|
||
const items = [];
|
||
const today = stripTime(new Date());
|
||
const weekAhead = new Date(today);
|
||
weekAhead.setDate(weekAhead.getDate() + 7);
|
||
const trashAhead = new Date(today);
|
||
trashAhead.setDate(trashAhead.getDate() + 3);
|
||
|
||
if (lastCockpitData && lastCockpitData.myRent && lastCockpitData.myRent.status !== 'GREEN') {
|
||
items.push({
|
||
date: today,
|
||
icon: '💶',
|
||
text: `Miete ${STATUS_LABEL[lastCockpitData.myRent.status].toLowerCase()}: ${formatEuro(lastCockpitData.myRent.amountDue)}`,
|
||
});
|
||
}
|
||
|
||
try {
|
||
const data = await apiFetch('/cleaning-tasks?status=PENDING');
|
||
data.tasks
|
||
.filter(t => t.assignedUser && t.assignedUser.id === currentUser.id)
|
||
.filter(t => { const d = stripTime(new Date(t.dueDate)); return d >= today && d <= weekAhead; })
|
||
.forEach(t => {
|
||
items.push({ date: new Date(t.dueDate), icon: '🧹', text: `Putzplan: ${t.area} fällig am ${new Date(t.dueDate).toLocaleDateString('de-DE')}` });
|
||
});
|
||
} catch (err) {
|
||
console.error('[notifications] Putzplan laden fehlgeschlagen:', err.message);
|
||
}
|
||
|
||
try {
|
||
const from = today.toISOString().slice(0, 10);
|
||
const to = trashAhead.toISOString().slice(0, 10);
|
||
const data = await apiFetch(`/trash-schedule?from=${from}&to=${to}`);
|
||
data.entries.filter(e => !e.completedAt).forEach(e => {
|
||
items.push({
|
||
date: new Date(e.date),
|
||
icon: '🗑️',
|
||
text: `Müll rausstellen: ${TRASH_TYPE_LABEL[e.type] || e.type} am ${new Date(e.date).toLocaleDateString('de-DE')}`,
|
||
actionLabel: 'Erledigt',
|
||
onAction: () => completeTrashEntry(e.id),
|
||
});
|
||
});
|
||
} catch (err) {
|
||
console.error('[notifications] Müllkalender laden fehlgeschlagen:', err.message);
|
||
}
|
||
|
||
try {
|
||
const data = await apiFetch('/announcements');
|
||
data.announcements
|
||
.filter(a => !a.eventDate || (stripTime(new Date(a.eventDate)) >= today && stripTime(new Date(a.eventDate)) <= weekAhead))
|
||
.forEach(a => {
|
||
items.push({
|
||
date: a.eventDate ? new Date(a.eventDate) : new Date(a.createdAt),
|
||
icon: '📢',
|
||
text: `${a.title}${a.eventDate ? ' am ' + new Date(a.eventDate).toLocaleDateString('de-DE') : ''}${a.message ? ' · ' + a.message : ''}`,
|
||
extraNode: buildAttendanceControl(a, () => { loadNotifications(); loadAnnouncements(); loadCalendar(); }),
|
||
});
|
||
});
|
||
} catch (err) {
|
||
console.error('[notifications] Ankündigungen laden fehlgeschlagen:', err.message);
|
||
}
|
||
|
||
items.sort((a, b) => a.date - b.date);
|
||
|
||
const list = document.getElementById('notificationsList');
|
||
list.innerHTML = '';
|
||
if (!items.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Für diese Woche gibt es nichts Neues für dich.'));
|
||
return;
|
||
}
|
||
items.forEach(item => {
|
||
const row = el('div', 'ticket-row');
|
||
row.appendChild(el('div', 'title', `${item.icon} ${item.text}`));
|
||
if (item.onAction) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn-secondary';
|
||
btn.textContent = item.actionLabel;
|
||
btn.onclick = item.onAction;
|
||
row.appendChild(btn);
|
||
}
|
||
if (item.extraNode) row.appendChild(item.extraNode);
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function completeTrashEntry(id) {
|
||
try {
|
||
await apiFetch(`/trash-schedule/${id}/complete`, { method: 'POST' });
|
||
loadNotifications();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
function buildProfileCompletenessBadge(user) {
|
||
const missing = [];
|
||
if (!user.phoneNumber) missing.push('Telefonnummer');
|
||
if (!user.firstResidenceAddress) missing.push('Erstwohnsitz');
|
||
if (!user.idDocumentFrontUrl) missing.push('Ausweis Vorderseite');
|
||
if (!user.idDocumentBackUrl) missing.push('Ausweis Rückseite');
|
||
|
||
const wrap = el('div');
|
||
wrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:8px; align-items:center;';
|
||
|
||
const badge = el('span', 'invite-status-chip');
|
||
if (missing.length) {
|
||
badge.classList.add('PENDING');
|
||
badge.textContent = `Unvollständig: ${missing.join(', ')} fehlt`;
|
||
} else {
|
||
badge.classList.add('ACCEPTED');
|
||
badge.textContent = 'Profil vollständig';
|
||
}
|
||
wrap.appendChild(badge);
|
||
|
||
if (user.schufaDocumentUrl) {
|
||
const schufaBadge = el('span', 'invite-status-chip');
|
||
const threeMonthsAgo = new Date();
|
||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||
const isOld = user.schufaDocumentDate && new Date(user.schufaDocumentDate) < threeMonthsAgo;
|
||
schufaBadge.classList.add(isOld ? 'EXPIRED' : 'ACCEPTED');
|
||
schufaBadge.textContent = isOld
|
||
? `Schufa veraltet (${new Date(user.schufaDocumentDate).toLocaleDateString('de-DE')})`
|
||
: `Schufa vorhanden (${user.schufaDocumentDate ? new Date(user.schufaDocumentDate).toLocaleDateString('de-DE') : '–'})`;
|
||
wrap.appendChild(schufaBadge);
|
||
}
|
||
|
||
const linkWrap = el('div');
|
||
linkWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;';
|
||
[
|
||
['Ausweis Vorderseite', user.idDocumentFrontUrl],
|
||
['Ausweis Rückseite', user.idDocumentBackUrl],
|
||
['Schufa-Auskunft', user.schufaDocumentUrl],
|
||
].forEach(([label, url]) => {
|
||
if (!url) return;
|
||
linkWrap.appendChild(buildDocumentChip(label, url));
|
||
});
|
||
if (linkWrap.children.length) wrap.appendChild(linkWrap);
|
||
|
||
return wrap;
|
||
}
|
||
|
||
function renderContractDocuments(contracts) {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
const list = document.getElementById('contractDocumentsList');
|
||
list.innerHTML = '';
|
||
if (!contracts.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Kein aktiver Mietvertrag gefunden.'));
|
||
return;
|
||
}
|
||
|
||
contracts.forEach(c => {
|
||
const card = el('div', 'ticket-row');
|
||
card.style.flexDirection = 'column';
|
||
card.style.alignItems = 'stretch';
|
||
card.style.gap = '10px';
|
||
|
||
const top = el('div');
|
||
top.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
|
||
top.appendChild(el('div', 'meta-text', `Vertrag seit ${new Date(c.startDate).toLocaleDateString('de-DE')}`));
|
||
card.appendChild(top);
|
||
|
||
if (isLandlord) {
|
||
card.appendChild(buildProfileCompletenessBadge(c.user));
|
||
}
|
||
|
||
const isOwnContract = c.user.id === currentUser.id;
|
||
if (c.signedAt) {
|
||
const signedNote = el('div', 'meta-text', `✓ Digital unterschrieben am ${new Date(c.signedAt).toLocaleDateString('de-DE')}`);
|
||
signedNote.style.color = 'var(--green)';
|
||
signedNote.style.fontWeight = '600';
|
||
card.appendChild(signedNote);
|
||
} else if (!isLandlord && isOwnContract) {
|
||
card.appendChild(buildSignatureSection(c.id));
|
||
} else {
|
||
card.appendChild(el('div', 'meta-text', 'Noch nicht unterschrieben.'));
|
||
}
|
||
|
||
const docWrap = el('div');
|
||
docWrap.style.display = 'flex';
|
||
docWrap.style.flexWrap = 'wrap';
|
||
docWrap.style.gap = '8px';
|
||
if (!c.contractDocumentUrls.length) {
|
||
docWrap.appendChild(el('span', 'meta-text', 'Noch keine Dateien hochgeladen.'));
|
||
} else {
|
||
c.contractDocumentUrls.forEach((url, idx) => {
|
||
const chip = buildDocumentChip(documentFileName(url, idx), url);
|
||
if (isLandlord) {
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.textContent = '×';
|
||
removeBtn.title = 'Entfernen';
|
||
removeBtn.style.cssText = 'border:none; background:none; cursor:pointer; font-size:13px; color:var(--text-muted); padding:0;';
|
||
removeBtn.onclick = () => deleteContractDocument(c.id, url);
|
||
chip.appendChild(removeBtn);
|
||
}
|
||
docWrap.appendChild(chip);
|
||
});
|
||
}
|
||
card.appendChild(docWrap);
|
||
|
||
if (isLandlord) {
|
||
card.appendChild(buildContractTemplateForm(c));
|
||
}
|
||
|
||
if (isLandlord) {
|
||
const uploadRow = el('div');
|
||
uploadRow.style.display = 'flex';
|
||
uploadRow.style.gap = '8px';
|
||
uploadRow.style.alignItems = 'center';
|
||
uploadRow.style.flexWrap = 'wrap';
|
||
|
||
const fileInput = document.createElement('input');
|
||
fileInput.type = 'file';
|
||
fileInput.multiple = true;
|
||
fileInput.accept = 'application/pdf,image/*';
|
||
fileInput.onchange = () => {
|
||
contractUploadFiles[c.id] = Array.from(fileInput.files || []);
|
||
};
|
||
uploadRow.appendChild(fileInput);
|
||
|
||
const uploadBtn = document.createElement('button');
|
||
uploadBtn.type = 'button';
|
||
uploadBtn.className = 'btn-secondary';
|
||
uploadBtn.textContent = 'Hochladen';
|
||
uploadBtn.onclick = () => uploadContractDocuments(c.id, fileInput, uploadBtn);
|
||
uploadRow.appendChild(uploadBtn);
|
||
|
||
card.appendChild(uploadRow);
|
||
|
||
const moveOutBtn = document.createElement('button');
|
||
moveOutBtn.type = 'button';
|
||
moveOutBtn.className = 'btn-secondary';
|
||
moveOutBtn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)';
|
||
moveOutBtn.style.cssText = 'align-self:flex-start; color:var(--red); border-color:var(--red);';
|
||
moveOutBtn.onclick = () => moveOutTenant(c.id, c.user.fullName, c.room.roomNumber, moveOutBtn);
|
||
card.appendChild(moveOutBtn);
|
||
}
|
||
|
||
list.appendChild(card);
|
||
});
|
||
}
|
||
|
||
async function moveOutTenant(contractId, tenantName, roomNumber, btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wird bearbeitet…';
|
||
try {
|
||
await apiFetch(`/contracts/${contractId}/move-out`, { method: 'POST' });
|
||
loadContractDocuments();
|
||
loadContractArchive();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Mieter ausziehen lassen (Zimmer freigeben)';
|
||
}
|
||
}
|
||
|
||
async function loadContractArchive() {
|
||
const isLandlord = currentUser.role === 'LANDLORD' || currentUser.role === 'ADMIN';
|
||
const section = document.getElementById('contractArchiveSection');
|
||
if (!isLandlord) { section.style.display = 'none'; return; }
|
||
section.style.display = 'block';
|
||
try {
|
||
const archiveData = await apiFetch('/contracts/archive');
|
||
renderContractArchive(archiveData.contracts);
|
||
} catch (err) {
|
||
console.error('[contracts] Archiv laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function renderContractArchive(contracts) {
|
||
const list = document.getElementById('contractArchiveList');
|
||
list.innerHTML = '';
|
||
if (!contracts.length) {
|
||
list.appendChild(el('div', 'empty-state', 'Noch keine ausgezogenen Mieter.'));
|
||
return;
|
||
}
|
||
contracts.forEach(c => {
|
||
const row = el('div', 'ticket-row');
|
||
row.style.flexDirection = 'column';
|
||
row.style.alignItems = 'stretch';
|
||
row.style.gap = '10px';
|
||
|
||
const left = el('div');
|
||
left.appendChild(el('div', 'title', `${c.user.fullName} · ${c.room.roomNumber}`));
|
||
left.appendChild(el('div', 'meta-text',
|
||
`${new Date(c.startDate).toLocaleDateString('de-DE')} – ${c.endDate ? new Date(c.endDate).toLocaleDateString('de-DE') : '–'} · ` +
|
||
`${c.contractDocumentUrls.length} Dokument(e)`));
|
||
row.appendChild(left);
|
||
if (c.contractDocumentUrls.length) {
|
||
const docWrap = el('div');
|
||
docWrap.style.cssText = 'display:flex; flex-wrap:wrap; gap:6px;';
|
||
c.contractDocumentUrls.forEach((url, idx) => {
|
||
docWrap.appendChild(buildDocumentChip(documentFileName(url, idx), url));
|
||
});
|
||
row.appendChild(docWrap);
|
||
}
|
||
|
||
row.appendChild(buildReactivateSection(c));
|
||
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function buildReactivateSection(c) {
|
||
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';
|
||
|
||
const roomField = el('div', 'field');
|
||
roomField.style.minWidth = '190px';
|
||
roomField.appendChild(el('label', null, 'Zimmer'));
|
||
const roomSelect = document.createElement('select');
|
||
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';
|
||
startInput.value = new Date().toISOString().slice(0, 10);
|
||
startField.appendChild(startInput);
|
||
formArea.appendChild(startField);
|
||
|
||
const endField = el('div', 'field');
|
||
endField.style.minWidth = '150px';
|
||
endField.appendChild(el('label', null, 'Mietende'));
|
||
const endInput = document.createElement('input');
|
||
endInput.type = 'date';
|
||
endInput.disabled = true;
|
||
endField.appendChild(endInput);
|
||
const unbefristetLabel = el('label', null, ' unbefristet');
|
||
unbefristetLabel.style.cssText = 'font-weight:normal; margin-top:4px; display:block;';
|
||
const unbefristetCheckbox = document.createElement('input');
|
||
unbefristetCheckbox.type = 'checkbox';
|
||
unbefristetCheckbox.checked = true;
|
||
unbefristetLabel.prepend(unbefristetCheckbox);
|
||
endField.appendChild(unbefristetLabel);
|
||
formArea.appendChild(endField);
|
||
|
||
const rentField = el('div', 'field');
|
||
rentField.style.width = '130px';
|
||
rentField.appendChild(el('label', null, 'Warmmiete (€)'));
|
||
const rentInput = document.createElement('input');
|
||
rentInput.type = 'number';
|
||
rentInput.min = '0';
|
||
rentInput.step = '0.01';
|
||
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';
|
||
depositField.appendChild(depositInput);
|
||
formArea.appendChild(depositField);
|
||
|
||
const dueDayField = el('div', 'field');
|
||
dueDayField.style.minWidth = '150px';
|
||
dueDayField.appendChild(el('label', null, 'Fälligkeit der Miete'));
|
||
const dueDaySelect = document.createElement('select');
|
||
[[1, 'Immer zum 1.'], [15, 'Immer zum 15.']].forEach(([value, label]) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = value;
|
||
opt.textContent = label;
|
||
dueDaySelect.appendChild(opt);
|
||
});
|
||
dueDayField.appendChild(dueDaySelect);
|
||
formArea.appendChild(dueDayField);
|
||
|
||
const roomInfoById = {};
|
||
|
||
// Datumsbewusste Zimmerauswahl: bei jeder Änderung von Mietbeginn/-ende
|
||
// wird /contracts/room-availability neu abgefragt, damit z.B. ein zum
|
||
// gewählten Datum bereits gekündigtes (aber noch aktives) Zimmer als
|
||
// verfügbar erscheint, ein unbefristet/darüber hinaus vergebenes aber
|
||
// nicht (siehe findOverlappingContract in contracts.ts).
|
||
async function refreshRoomOptions() {
|
||
const startDate = startInput.value || new Date().toISOString().slice(0, 10);
|
||
const endDate = unbefristetCheckbox.checked ? '' : endInput.value;
|
||
const previousValue = roomSelect.value;
|
||
try {
|
||
const params = new URLSearchParams({ startDate });
|
||
if (endDate) params.set('endDate', endDate);
|
||
const data = await apiFetch(`/contracts/room-availability?${params.toString()}`);
|
||
roomSelect.innerHTML = '';
|
||
(data.rooms || []).forEach(r => {
|
||
roomInfoById[r.id] = r;
|
||
const opt = document.createElement('option');
|
||
opt.value = r.id;
|
||
opt.textContent = r.available ? r.roomNumber : `${r.roomNumber} (im Zeitraum belegt)`;
|
||
opt.disabled = !r.available;
|
||
if (!r.available && r.conflictReason) opt.title = r.conflictReason;
|
||
roomSelect.appendChild(opt);
|
||
});
|
||
const stillAvailable = [...roomSelect.options].some(o => o.value === previousValue && !o.disabled);
|
||
roomSelect.value = stillAvailable ? previousValue : ([...roomSelect.options].find(o => !o.disabled)?.value || '');
|
||
applyRoomDefaults();
|
||
} catch (err) {
|
||
console.error('[contracts] Zimmerverfügbarkeit laden fehlgeschlagen:', err.message);
|
||
}
|
||
}
|
||
|
||
function applyRoomDefaults() {
|
||
const info = roomInfoById[roomSelect.value];
|
||
if (!info) return;
|
||
rentInput.value = (Number(info.baseRent) + Number(info.utilityPauschal)).toFixed(2);
|
||
depositInput.value = (Number(info.baseRent) * 3).toFixed(2);
|
||
}
|
||
|
||
roomSelect.onchange = applyRoomDefaults;
|
||
startInput.onchange = refreshRoomOptions;
|
||
endInput.onchange = refreshRoomOptions;
|
||
unbefristetCheckbox.onchange = () => {
|
||
endInput.disabled = unbefristetCheckbox.checked;
|
||
if (unbefristetCheckbox.checked) endInput.value = '';
|
||
refreshRoomOptions();
|
||
};
|
||
refreshRoomOptions();
|
||
|
||
const submitBtn = document.createElement('button');
|
||
submitBtn.type = 'button';
|
||
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,
|
||
endDate: unbefristetCheckbox.checked ? undefined : (endInput.value || undefined),
|
||
totalWarmRent: rentInput.value,
|
||
depositAmount: depositInput.value,
|
||
paymentDueDay: Number(dueDaySelect.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; }
|
||
if (!options.roomId) { alert('Bitte ein verfügbares Zimmer wählen.'); 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>
|