775 lines
26 KiB
HTML
775 lines
26 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>
|
||
<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 & 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 & 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>
|
||
<h2>Miet-Ampel je Zimmer</h2>
|
||
<div class="room-grid" id="roomGrid"></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' };
|
||
|
||
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';
|
||
if (isLandlord) initInvitePanel();
|
||
|
||
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 || [];
|
||
populateRoomSelect();
|
||
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');
|
||
|
||
// --- Stat-Kacheln ---
|
||
const statRow = document.getElementById('statRow');
|
||
statRow.innerHTML = '';
|
||
const 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: '' },
|
||
];
|
||
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);
|
||
});
|
||
|
||
// --- Zimmer-Ampeln ---
|
||
const roomGrid = document.getElementById('roomGrid');
|
||
roomGrid.innerHTML = '';
|
||
if (!data.trafficLights.length) {
|
||
roomGrid.appendChild(el('div', 'empty-state', 'Keine aktiven Verträge gefunden.'));
|
||
}
|
||
data.trafficLights.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);
|
||
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'} · ${new Date(t.createdAt).toLocaleDateString('de-DE')}`));
|
||
row.appendChild(left);
|
||
row.appendChild(el('span', `priority-chip ${t.priority}`, PRIORITY_LABEL[t.priority] || t.priority));
|
||
ticketList.appendChild(row);
|
||
});
|
||
}
|
||
|
||
bootstrap();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|