feat(admin): Cockpit — Suche/Filter, Kennzahlen, klickbare Kunden-Detailansicht

- Kennzahlen-Kacheln mit Unterzeile (neu/30T, gesperrt, ARR) + Plan-Leiste
- Toolbar: Suche (E-Mail/Firma) + Plan-Filter, live
- Kundentabelle: Zeilen klickbar → Detailansicht
- Detail: Profil, Abo-Historie, Instanzen mit Öffnen/Sperren/Reaktivieren
- fix: Abmelden-Button hatte keinen Handler

E2E: Detail, Suspend→Counter, Reactivate verifiziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:04:32 +02:00
parent 9e24ce8dd6
commit db357b8103
39 changed files with 352 additions and 103 deletions
+127 -16
View File
@@ -345,35 +345,146 @@
return;
}
const stat = (label, val) =>
'<div class="admin-stat"><div class="admin-stat-num">' + esc(val) + "</div>" +
'<div class="admin-stat-label">' + label + "</div></div>";
adminCache = { stats, accounts: accounts.accounts };
paintAdmin();
}
const rows = accounts.accounts.map((a) =>
"<tr>" +
// Such-/Filter-State des Cockpits.
let adminCache = null;
let adminSearch = "";
let adminPlanFilter = "";
function statCard(label, val, sub) {
return '<div class="admin-stat"><div class="admin-stat-num">' + esc(val) + "</div>" +
'<div class="admin-stat-label">' + esc(label) + "</div>" +
(sub ? '<div class="admin-stat-sub">' + esc(sub) + "</div>" : "") + "</div>";
}
function paintAdmin() {
const { stats } = adminCache;
const planNames = Object.values(stats.byPlan || {});
const planChips = planNames.length
? '<div class="admin-planbar">' + planNames.map((p) =>
'<div class="admin-planchip"><b>' + esc(p.name) + "</b> · " + esc(p.count) +
' <span class="muted">(CHF ' + esc(p.revenue) + ")</span></div>"
).join("") + "</div>"
: "";
// Kunden filtern (Suche über Email/Firma + Plan-Filter).
const q = adminSearch.trim().toLowerCase();
const list = adminCache.accounts.filter((a) => {
const matchesQ = !q || (a.email + " " + (a.company || "")).toLowerCase().includes(q);
const matchesP = !adminPlanFilter || a.plan === adminPlanFilter;
return matchesQ && matchesP;
});
const rows = list.map((a) =>
'<tr class="admin-row" data-id="' + esc(a.id) + '">' +
"<td><b>" + esc(a.email) + "</b>" +
(a.company ? '<div class="muted" style="font-size:12px">' + esc(a.company) + "</div>" : "") + "</td>" +
"<td>" + (a.plan ? esc(a.plan) + '<div class="muted" style="font-size:12px">' + esc(a.sub_status || "") + "</div>" : '<span class="muted">—</span>') + "</td>" +
"<td style=\"text-align:center\">" + esc(a.instance_count) + "</td>" +
"<td class=\"muted\" style=\"font-size:12px\">" + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" +
"<td>" + (a.plan ? '<span class="admin-badge">' + esc(a.plan) + "</span>" +
(a.sub_status && a.sub_status !== "active" ? ' <span class="muted" style="font-size:11px">' + esc(a.sub_status) + "</span>" : "")
: '<span class="muted">—</span>') + "</td>" +
'<td style="text-align:center">' + esc(a.instance_count) + "</td>" +
'<td class="muted" style="font-size:12px">' + esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</td>" +
'<td style="text-align:right"><span class="admin-chevron"></span></td>' +
"</tr>"
).join("");
const planOptions = ['<option value="">Alle Pläne</option>']
.concat(planNames.map((p) => '<option value="' + esc(p.name.toLowerCase()) + '"' +
(adminPlanFilter === p.name.toLowerCase() ? " selected" : "") + ">" + esc(p.name) + "</option>"))
.join("");
const html =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">' +
'<div class="hosting-title" style="text-align:left;margin:0">Admin</div>' +
'<div class="hosting-title" style="text-align:left;margin:0">Cockpit</div>' +
'<button class="hosting-link" id="alogout">Abmelden</button></div>' +
'<div class="admin-stats">' +
stat("Kunden", stats.accounts) +
stat("Aktive Abos", stats.activeSubscriptions) +
stat("Instanzen", stats.activeInstances + "/" + stats.instances) +
stat("MRR (CHF)", stats.mrrChf) +
statCard("Kunden", stats.accounts, "+" + (stats.newAccounts30d || 0) + " (30 T.)") +
statCard("Aktive Abos", stats.activeSubscriptions) +
statCard("Instanzen", stats.activeInstances + "/" + stats.instances,
(stats.suspendedInstances ? stats.suspendedInstances + " gesperrt" : "")) +
statCard("MRR", "CHF " + stats.mrrChf, "ARR CHF " + (stats.arrChf || stats.mrrChf * 12)) +
"</div>" +
'<div style="margin:24px 0 10px;font-weight:600;font-size:14px">Kunden</div>' +
'<table class="admin-table"><thead><tr><th>Konto</th><th>Abo</th><th>Inst.</th><th>Seit</th></tr></thead>' +
"<tbody>" + (rows || '<tr><td colspan="4" class="muted">Noch keine Kunden.</td></tr>') + "</tbody></table>";
planChips +
'<div class="admin-toolbar">' +
'<input class="hosting-input admin-search" id="asearch" placeholder="Suche E-Mail / Firma…" value="' + esc(adminSearch) + '">' +
'<select class="hosting-input admin-filter" id="afilter">' + planOptions + "</select>" +
"</div>" +
'<table class="admin-table"><thead><tr><th>Konto</th><th>Abo</th><th>Inst.</th><th>Seit</th><th></th></tr></thead>' +
"<tbody>" + (rows || '<tr><td colspan="5" class="muted">Keine Treffer.</td></tr>') + "</tbody></table>";
root.innerHTML = card(html, true);
root.querySelector("#alogout").onclick = () => { adminTok.clear(); renderAdminLogin(""); };
const s = root.querySelector("#asearch");
s.oninput = () => { adminSearch = s.value; const pos = s.selectionStart; paintAdmin(); const n = root.querySelector("#asearch"); n.focus(); n.setSelectionRange(pos, pos); };
root.querySelector("#afilter").onchange = (e) => { adminPlanFilter = e.target.value; paintAdmin(); };
root.querySelectorAll(".admin-row").forEach((r) =>
(r.onclick = () => renderAdminDetail(r.dataset.id))
);
}
// — Kunden-Detailansicht (Profil, Abo-Historie, Instanzen + Aktionen) —
async function renderAdminDetail(id) {
root.innerHTML = card('<div class="hosting-sub">Lädt…</div>', true);
let d;
try { d = await adminApi("GET", "/admin/accounts/" + id); }
catch (err) {
if (err.status === 401 || err.status === 403) { adminTok.clear(); return renderAdminLogin("Bitte neu anmelden."); }
root.innerHTML = card('<div class="hosting-msg err">' + esc(err.message) + "</div>"); return;
}
const { account: a, subscriptions, instances } = d;
const profile = [
["Firma", a.company], ["Ansprechperson", a.contact_name],
["Adresse", [a.street, [a.zip, a.city].filter(Boolean).join(" "), a.country].filter(Boolean).join(", ")],
["Telefon", a.phone],
].filter(([, v]) => v).map(([k, v]) =>
'<div class="hosting-row"><span class="muted">' + esc(k) + "</span><span>" + esc(v) + "</span></div>"
).join("") || '<div class="muted" style="padding:8px 0">Keine Profildaten.</div>';
const subRows = subscriptions.length ? subscriptions.map((s) =>
'<div class="hosting-row"><span><span class="admin-badge">' + esc(s.plan) + "</span> " + esc(s.status) +
(s.priceChf != null ? ' <span class="muted">CHF ' + esc(s.priceChf) + "</span>" : "") + "</span>" +
'<span class="muted" style="font-size:12px">' + esc(new Date(s.created_at).toLocaleDateString("de-CH")) + "</span></div>"
).join("") : '<div class="muted" style="padding:8px 0">Kein Abo.</div>';
const instRows = instances.length ? instances.map((i) =>
'<div class="hosting-row"><div><b>' + esc(i.label || i.studio_slug) + "</b> " +
'<span class="admin-badge ' + (i.status === "active" ? "ok" : "warn") + '">' + esc(i.status) + "</span>" +
'<div class="muted" style="font-size:12px">' + esc(i.instance_url) + "</div></div>" +
'<div style="display:flex;gap:8px">' +
'<a class="hosting-btn admin-mini" href="' + esc(i.instance_url) + '" target="_blank" rel="noreferrer">Öffnen</a>' +
(i.status === "active"
? '<button class="hosting-btn admin-mini warn" data-act="suspend" data-iid="' + esc(i.id) + '">Sperren</button>'
: '<button class="hosting-btn admin-mini ok" data-act="reactivate" data-iid="' + esc(i.id) + '">Reaktivieren</button>') +
"</div></div>"
).join("") : '<div class="muted" style="padding:8px 0">Keine Instanzen.</div>';
const html =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">' +
'<button class="hosting-link" id="aback"> Zurück</button>' +
'<button class="hosting-link" id="alogout">Abmelden</button></div>' +
'<div class="hosting-title" style="text-align:left;margin:0 0 2px">' + esc(a.email) + "</div>" +
'<div class="hosting-sub" style="text-align:left;margin-bottom:22px">Kunde seit ' +
esc(new Date(a.created_at).toLocaleDateString("de-CH")) + "</div>" +
'<div class="admin-section">Profil</div>' + profile +
'<div class="admin-section">Abo-Historie</div>' + subRows +
'<div class="admin-section">Instanzen</div>' + instRows;
root.innerHTML = card(html, true);
root.querySelector("#aback").onclick = () => paintAdmin();
root.querySelector("#alogout").onclick = () => { adminTok.clear(); renderAdminLogin(""); };
root.querySelectorAll("[data-act]").forEach((b) =>
(b.onclick = async () => {
b.disabled = true;
try {
await adminApi("POST", "/admin/instances/" + b.dataset.iid + "/" + b.dataset.act);
renderAdminDetail(id); // neu laden
} catch (err) { alert(err.message); b.disabled = false; }
})
);
}
({ login: renderLogin, register: renderRegister, konto: renderKonto, preise: renderPreise, admin: renderAdmin }[page] || renderLogin)();