add web-dashboard/index.html
This commit is contained in:
parent
ba0429c26b
commit
1a2701576f
296
web-dashboard/index.html
Normal file
296
web-dashboard/index.html
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
<!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>
|
||||||
Loading…
Reference in New Issue
Block a user