wg-verwaltung/web-dashboard/index.html

297 lines
9.5 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; }
</style>
</head>
<body>
<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>
<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>
<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' };
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;
}
async function loadData() {
const errorBanner = document.getElementById('errorBanner');
errorBanner.style.display = 'none';
try {
const res = await fetch(`${API_BASE_URL}/cockpit/summary`);
if (!res.ok) throw new Error(`Server antwortete mit Status ${res.status}`);
const data = await res.json();
render(data);
} catch (err) {
errorBanner.style.display = 'block';
errorBanner.innerHTML = `Backend nicht erreichbar unter <code>${API_BASE_URL}</code>. ` +
`Läuft <code>setup-and-start.bat</code> noch? (${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);
});
}
loadData();
setInterval(loadData, REFRESH_INTERVAL_MS);
</script>
</body>
</html>