wg-verwaltung/web-dashboard/index.html

963 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WG Nackenheim — Vermieter-Cockpit</title>
<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;
}
.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); }
</style>
</head>
<body>
<!-- ================= LOGIN VIEW ================= -->
<div id="loginView" class="view">
<div class="auth-wrap">
<div class="auth-card">
<h1>WG Nackenheim — Anmelden</h1>
<p class="sub">Vermieter-Cockpit &amp; Mieterbereich</p>
<div id="loginError" style="display:none" class="form-error"></div>
<form id="loginForm">
<div class="field">
<label for="loginEmail">E-Mail-Adresse</label>
<input type="email" id="loginEmail" required autocomplete="email" />
</div>
<div class="field">
<label for="loginPassword">Passwort</label>
<input type="password" id="loginPassword" required autocomplete="current-password" />
</div>
<button type="submit" class="btn-primary" id="loginSubmitBtn">Anmelden</button>
</form>
</div>
</div>
</div>
<!-- ================= ACCEPT INVITE VIEW ================= -->
<div id="acceptInviteView" class="view">
<div class="auth-wrap">
<div class="auth-card">
<h1>Einladung annehmen</h1>
<p class="sub" id="acceptInviteSub">Lade Einladung…</p>
<div id="acceptInviteError" style="display:none" class="form-error"></div>
<form id="acceptInviteForm" style="display:none">
<div class="field">
<label for="acceptEmail">E-Mail-Adresse</label>
<input type="email" id="acceptEmail" disabled />
</div>
<div class="field">
<label for="acceptFullName">Vollständiger Name</label>
<input type="text" id="acceptFullName" required autocomplete="name" />
</div>
<div class="field">
<label for="acceptPassword">Passwort wählen (min. 8 Zeichen)</label>
<input type="password" id="acceptPassword" required minlength="8" autocomplete="new-password" />
</div>
<button type="submit" class="btn-primary" id="acceptSubmitBtn">Konto erstellen &amp; anmelden</button>
</form>
</div>
</div>
</div>
<!-- ================= APP VIEW ================= -->
<div id="appView" class="view">
<div class="topbar">
<span id="whoAmI"></span>
<button class="btn-secondary" onclick="logout()">Abmelden</button>
</div>
<header>
<div>
<h1>WG Nackenheim — Vermieter-Cockpit</h1>
<p>170 qm · 4 Zimmer · Einzelzimmervermietung</p>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<div class="meta" id="lastUpdated"></div>
<button class="refresh-btn" onclick="loadData()">↻ Aktualisieren</button>
</div>
</header>
<main>
<div id="errorBanner" style="display:none" class="error-banner"></div>
<div class="stat-row" id="statRow"></div>
<section id="inviteSection" style="display:none">
<h2>Mieter einladen</h2>
<div class="invite-card">
<div id="inviteFormError" style="display:none" class="form-error"></div>
<form id="inviteForm" class="invite-form-row">
<div class="field">
<label for="inviteEmail">E-Mail-Adresse des Mieters</label>
<input type="email" id="inviteEmail" required placeholder="mieter@beispiel.de" />
</div>
<div class="field">
<label for="inviteRoom">Zimmer (optional)</label>
<select id="inviteRoom">
<option value=""> kein Zimmer zuordnen </option>
</select>
</div>
<button type="submit" class="btn-primary" style="width:auto" id="inviteSubmitBtn">Einladen</button>
</form>
<div id="inviteLinkResult" style="display:none" class="invite-link-box">
<code id="inviteLinkText"></code>
<button class="btn-secondary" onclick="copyInviteLink()">Kopieren</button>
</div>
<table class="invite-table" id="inviteTable" style="display:none">
<thead>
<tr><th>E-Mail</th><th>Zimmer</th><th>Status</th><th>Eingeladen am</th><th></th></tr>
</thead>
<tbody id="inviteTableBody"></tbody>
</table>
</div>
</section>
<section id="rentSection">
<h2 id="rentSectionTitle">Miet-Ampel je Zimmer</h2>
<div class="room-grid" id="roomGrid"></div>
</section>
<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>
</main>
</div>
<script>
const API_BASE_URL = window.WG_API_BASE_URL ||
(location.protocol === 'file:' ? 'http://localhost:3000/v1' : '/v1');
const REFRESH_INTERVAL_MS = 20000;
const STATUS_LABEL = { GREEN: 'Bezahlt', YELLOW: 'Fällig', RED: 'Überfällig' };
const PRIORITY_LABEL = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', EMERGENCY: 'Notfall' };
const ROLE_LABEL = { LANDLORD: 'Vermieter', TENANT: 'Mieter', CRAFTSMAN: 'Handwerker', ADMIN: 'Verwaltung' };
const INVITE_STATUS_LABEL = { PENDING: 'Offen', ACCEPTED: 'Angenommen', EXPIRED: 'Abgelaufen', REVOKED: 'Zurückgezogen' };
const CATEGORY_LABEL = {
SANITAER: 'Sanitär', ELEKTRIK: 'Elektrik', MOEBEL: 'Möbel', HEIZUNG: 'Heizung',
SCHIMMEL: 'Schimmel', SCHLUESSEL_SCHLOSS: 'Schlüssel/Schloss', SONSTIGES: 'Sonstiges',
};
const TICKET_STATUS_LABEL = { OPEN: 'Gemeldet', IN_PROGRESS: 'In Bearbeitung', RESOLVED: 'Erledigt', CLOSED: 'Geschlossen' };
function formatEuro(n) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0);
}
function el(tag, className, html) {
const e = document.createElement(tag);
if (className) e.className = className;
if (html !== undefined) e.innerHTML = html;
return e;
}
// ---------------------------------------------------------------------
// AUTH — Login, Session-Wiederherstellung, Einladungen annehmen
// ---------------------------------------------------------------------
let currentUser = null;
function getToken() { return localStorage.getItem('wg_token'); }
function setSession(token, user) {
localStorage.setItem('wg_token', token);
localStorage.setItem('wg_user', JSON.stringify(user));
currentUser = user;
}
function clearSession() {
localStorage.removeItem('wg_token');
localStorage.removeItem('wg_user');
currentUser = null;
}
function authHeaders() {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
async function apiFetch(path, options = {}) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...authHeaders(),
...(options.headers || {}),
},
});
let body = null;
try { body = await res.json(); } catch { /* kein JSON-Body */ }
if (!res.ok) {
const message = (body && body.error) || `Server antwortete mit Status ${res.status}`;
throw new Error(message);
}
return body;
}
async function bootstrap() {
const inviteToken = new URLSearchParams(location.search).get('invite');
if (inviteToken) {
showView('acceptInviteView');
initAcceptInvite(inviteToken);
return;
}
const storedUser = localStorage.getItem('wg_user');
if (getToken() && storedUser) {
try {
const data = await apiFetch('/auth/me');
currentUser = data.user;
enterApp();
return;
} catch {
clearSession();
}
}
showView('loginView');
initLogin();
}
function initLogin() {
const form = document.getElementById('loginForm');
const errorBox = document.getElementById('loginError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('loginSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Anmelden…';
try {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const data = await apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
setSession(data.token, data.user);
enterApp();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Anmelden';
}
};
}
async function initAcceptInvite(token) {
const sub = document.getElementById('acceptInviteSub');
const errorBox = document.getElementById('acceptInviteError');
const form = document.getElementById('acceptInviteForm');
try {
const preview = await apiFetch(`/invitations/${token}/preview`);
sub.textContent = `Einladung als ${ROLE_LABEL[preview.role] || preview.role}` +
(preview.room ? ` für ${preview.room.roomNumber}` : '') + '. Bitte Passwort festlegen.';
document.getElementById('acceptEmail').value = preview.email;
form.style.display = 'flex';
form.style.flexDirection = 'column';
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 data = await apiFetch(`/invitations/${token}/accept`, {
method: 'POST',
body: JSON.stringify({ fullName, password }),
});
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();
}
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';
if (isLandlord) initInvitePanel();
initTicketForm();
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
}
// ---------------------------------------------------------------------
// MIETER EINLADEN (nur Vermieter/Admin)
// ---------------------------------------------------------------------
let lastRooms = [];
function populateRoomSelect() {
const select = document.getElementById('inviteRoom');
select.innerHTML = '<option value=""> kein Zimmer zuordnen </option>';
lastRooms.forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = r.roomNumber;
select.appendChild(opt);
});
}
function initInvitePanel() {
const form = document.getElementById('inviteForm');
const errorBox = document.getElementById('inviteFormError');
form.onsubmit = async (e) => {
e.preventDefault();
errorBox.style.display = 'none';
const submitBtn = document.getElementById('inviteSubmitBtn');
submitBtn.disabled = true;
submitBtn.textContent = 'Lädt ein…';
try {
const email = document.getElementById('inviteEmail').value.trim();
const roomId = document.getElementById('inviteRoom').value || undefined;
const data = await apiFetch('/invitations', {
method: 'POST',
body: JSON.stringify({ email, roomId }),
});
document.getElementById('inviteLinkText').textContent = data.inviteLink;
document.getElementById('inviteLinkResult').style.display = 'flex';
document.getElementById('inviteEmail').value = '';
loadInvitations();
} catch (err) {
errorBox.textContent = err.message;
errorBox.style.display = 'block';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Einladen';
}
};
loadInvitations();
}
function copyInviteLink() {
const text = document.getElementById('inviteLinkText').textContent;
navigator.clipboard?.writeText(text);
}
async function loadInvitations() {
try {
const data = await apiFetch('/invitations');
const table = document.getElementById('inviteTable');
const body = document.getElementById('inviteTableBody');
body.innerHTML = '';
if (!data.invitations.length) {
table.style.display = 'none';
return;
}
table.style.display = 'table';
data.invitations.forEach(inv => {
const tr = document.createElement('tr');
tr.appendChild(el('td', '', inv.email));
tr.appendChild(el('td', '', inv.room ? inv.room.roomNumber : ''));
tr.appendChild(el('td', '', `<span class="invite-status-chip ${inv.status}">${INVITE_STATUS_LABEL[inv.status] || inv.status}</span>`));
tr.appendChild(el('td', '', new Date(inv.createdAt).toLocaleDateString('de-DE')));
const actionsTd = el('td', '');
if (inv.status === 'PENDING') {
const revokeBtn = document.createElement('button');
revokeBtn.className = 'btn-secondary';
revokeBtn.textContent = 'Zurückziehen';
revokeBtn.onclick = () => revokeInvitation(inv.id);
actionsTd.appendChild(revokeBtn);
}
tr.appendChild(actionsTd);
body.appendChild(tr);
});
} catch (err) {
// Einladungsliste ist nicht kritisch für die Kernansicht — still fehlschlagen lassen.
console.error('[invitations] Laden fehlgeschlagen:', err.message);
}
}
async function revokeInvitation(id) {
try {
await apiFetch(`/invitations/${id}`, { method: 'DELETE' });
loadInvitations();
} catch (err) {
alert(err.message);
}
}
// ---------------------------------------------------------------------
// COCKPIT-DATEN
// ---------------------------------------------------------------------
async function loadData() {
const errorBanner = document.getElementById('errorBanner');
errorBanner.style.display = 'none';
try {
const data = await apiFetch('/cockpit/summary');
lastRooms = data.rooms || [];
lastMyRoom = data.myRoom || null;
populateRoomSelect();
populateTicketRoomSelect();
render(data);
} catch (err) {
if (err.message === 'Nicht authentifiziert' || err.message === 'Token ungültig oder abgelaufen') {
clearSession();
location.reload();
return;
}
errorBanner.style.display = 'block';
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. (${err.message})`;
}
}
function render(data) {
document.getElementById('lastUpdated').textContent =
'Stand: ' + new Date(data.generatedAt).toLocaleTimeString('de-DE');
const isLandlord = data.role === 'LANDLORD' || data.role === 'ADMIN';
// --- Stat-Kacheln ---
const statRow = document.getElementById('statRow');
statRow.innerHTML = '';
let stats;
if (isLandlord) {
stats = [
{ label: 'Bezahlt', value: data.summary.rentGreen, cls: 'green' },
{ label: 'Fällig', value: data.summary.rentYellow, cls: 'yellow' },
{ label: 'Überfällig', value: data.summary.rentRed, cls: 'red' },
{ label: 'Soll-Miete gesamt / Monat', value: formatEuro(data.summary.totalMonthlyRentTarget), cls: '' },
{ label: 'Offene Tickets', value: data.summary.openTicketsCount, cls: '' },
];
} else {
const myStatusCls = data.myRent ? { GREEN: 'green', YELLOW: 'yellow', RED: 'red' }[data.myRent.status] : '';
stats = [
{ label: 'Mein Mietstatus', value: data.myRent ? STATUS_LABEL[data.myRent.status] : '', cls: myStatusCls },
{ label: 'Fällig', value: formatEuro(data.myRent ? data.myRent.amountDue : 0), cls: '' },
{ label: 'Offene Meldungen', value: data.openTickets.length, cls: '' },
];
}
stats.forEach(s => {
const tile = el('div', `stat-tile ${s.cls}`);
tile.appendChild(el('div', 'value', s.value));
tile.appendChild(el('div', 'label', s.label));
statRow.appendChild(tile);
});
// --- Miet-Ampel(n) ---
const roomGrid = document.getElementById('roomGrid');
roomGrid.innerHTML = '';
const lights = isLandlord ? data.trafficLights : (data.myRent ? [data.myRent] : []);
if (!lights.length) {
roomGrid.appendChild(el('div', 'empty-state',
isLandlord ? 'Keine aktiven Verträge gefunden.' : 'Kein aktiver Mietvertrag gefunden.'));
}
lights.forEach(light => {
const card = el('div', `room-card ${light.status}`);
const top = el('div', 'room-card-top');
top.appendChild(el('div', 'room-name', light.roomNumber));
top.appendChild(el('div', `badge ${light.status}`, STATUS_LABEL[light.status]));
card.appendChild(top);
if (isLandlord) card.appendChild(el('div', 'tenant', light.tenantName));
const amountRow = el('div', 'amount-row');
amountRow.appendChild(el('span', 'label', 'Fällig'));
amountRow.appendChild(el('span', 'value', formatEuro(light.amountDue)));
card.appendChild(amountRow);
if (light.status === 'GREEN') {
const paidRow = el('div', 'amount-row');
paidRow.appendChild(el('span', 'label', 'Bezahlt'));
paidRow.appendChild(el('span', 'value', formatEuro(light.amountPaid)));
card.appendChild(paidRow);
}
roomGrid.appendChild(card);
});
// --- Offene Tickets ---
const ticketList = document.getElementById('ticketList');
ticketList.innerHTML = '';
if (!data.openTickets.length) {
ticketList.appendChild(el('div', 'empty-state', 'Aktuell keine offenen Schadensmeldungen. 🎉'));
}
data.openTickets.forEach(t => {
const row = el('div', 'ticket-row');
const left = el('div');
left.appendChild(el('div', 'title', t.title));
left.appendChild(el('div', 'meta-text',
`${t.room ? t.room.roomNumber : 'Gemeinschaftsbereich'} · ${CATEGORY_LABEL[t.category] || t.category} · ` +
`${t.creator ? t.creator.fullName + ' · ' : ''}${new Date(t.createdAt).toLocaleDateString('de-DE')} · ` +
TICKET_STATUS_LABEL[t.status]));
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) {
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);
}
}
bootstrap();
</script>
</body>
</html>