Neuer Reiter zwischen Audit-Log und Verbrauchsrechner, Anlass war die Dashboard-Kachel API-Kosten gesamt, die nirgendwo hinführte (sie springt jetzt hierher). Kennzahlen-Set nach Betreiber-Best-Practices für nutzungsbasierte KI-Produkte (Kostenverlauf, Marge je Kunde, Betrieb). - GET /api/statistik?months=N liefert alles in einem JSON. Kosten und Läufe je Monat und Lagentyp (führende Quelle refresh_log mit lückenlosem incidents-Join), Quellen-Split aus token_usage_monthly, Erfolgsquote, normalisiertes Fehler-Ranking, Dauer je Typ, Wachstum, MAU/DAU aus user_activity_days und je Organisation Ist-Kosten plus Margen-Schätzung zu heutigen Sätzen (Recherche-Anlage zählt dreifach, weil der Multi-Pass nur eine Log-Zeile erzeugt). Aggregation als pure, unit-getestete Funktionen, Monatsachse lückenlos gefüllt, Mediane nur über Läufe mit Kosten (Erfassung begann erst 03/2026). - statistik.js injiziert sich selbst nach dem rechner.js-Muster, Platzierung doppelt gesichert (Ladereihenfolge mit async=false plus insertBefore). Charts als Inline-SVG ohne Bibliothek, eine Achse je Chart, Hover-Titel mit großen Trefferflächen, Legenden, Serienfarben je Theme mit dem dataviz-Palette-Validator geprüft (alle Checks PASS). Zeitraum 6/12/Alles, Datenstand-Hinweis, ehrliche Fußnote unter der Margen-Tabelle, Leerzustand für die junge Aktivitäts-Erfassung. - Migration user_activity_days für die Portal-Staging-DB (auf Live legt sie der Monitor an, dort läuft die Erfassung in get_current_user). - 10 neue Unit-Tests plus Auth-Smoke für den Endpoint, 96 Tests grün (die zwei bekannten shared-Import-Fehlschläge bestehen unverändert). Gegenstück im Monitor: Commit d367c60 (Aktivitäts-Erfassung). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
441 Zeilen
21 KiB
JavaScript
441 Zeilen
21 KiB
JavaScript
"use strict";
|
|
|
|
// Statistik-Tab für das Verwaltungsportal. Selbst-injizierend nach dem
|
|
// Muster von rechner.js, damit dashboard.html unverändert bleibt.
|
|
// Charts sind handgerendertes Inline-SVG ohne Bibliothek. Die Serienfarben
|
|
// sind mit dem dataviz-Palette-Validator gegen beide Themes geprüft
|
|
// (Light #96791A/#2a78d6/#199e70, Dark #a8863a/#3987e5/#199e70, alle Checks PASS).
|
|
|
|
(function () {
|
|
var geladen = false;
|
|
var aktMonate = 12;
|
|
|
|
// ---------- Format-Helfer ----------
|
|
|
|
function fmt(n) { return Number(n || 0).toLocaleString("de-DE"); }
|
|
function fmtUsd(n) {
|
|
return "$" + Number(n || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
function fmtMonat(ym) {
|
|
if (!ym) return "";
|
|
var namen = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
|
return namen[parseInt(ym.slice(5, 7), 10) - 1] + " " + ym.slice(2, 4);
|
|
}
|
|
function fmtDatum(s) {
|
|
if (!s) return "-";
|
|
var d = String(s).slice(0, 10).split("-");
|
|
return d.length === 3 ? d[2] + "." + d[1] + "." + d[0] : s;
|
|
}
|
|
function esc(s) {
|
|
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
});
|
|
}
|
|
|
|
// ---------- SVG-Renderer ----------
|
|
// Eine Achse pro Chart, rezessives Grid, 2px-Lücken zwischen Stapel-
|
|
// Segmenten, Hover über grosszügige unsichtbare Trefferflächen mit <title>.
|
|
|
|
var W = 720, H = 230, ML = 52, MR = 8, MT = 10, MB = 26;
|
|
|
|
function niceMax(v) {
|
|
if (v <= 0) return 1;
|
|
var mag = Math.pow(10, Math.floor(Math.log10(v)));
|
|
var norm = v / mag;
|
|
var step = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
|
return step * mag;
|
|
}
|
|
|
|
function achsen(maxY, einheit) {
|
|
var plotH = H - MT - MB;
|
|
var out = "";
|
|
for (var i = 0; i <= 3; i++) {
|
|
var y = MT + plotH - (plotH * i) / 3;
|
|
var wert = (maxY * i) / 3;
|
|
out += '<line x1="' + ML + '" y1="' + y + '" x2="' + (W - MR) + '" y2="' + y +
|
|
'" stroke="var(--border)" stroke-width="1"/>';
|
|
out += '<text x="' + (ML - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="10" fill="var(--text-muted)">' +
|
|
esc(kurz(wert, einheit)) + "</text>";
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function kurz(v, einheit) {
|
|
var s;
|
|
if (v >= 1000000) s = (v / 1000000).toLocaleString("de-DE", { maximumFractionDigits: 1 }) + " Mio";
|
|
else if (v >= 10000) s = Math.round(v / 1000).toLocaleString("de-DE") + "k";
|
|
else s = v.toLocaleString("de-DE", { maximumFractionDigits: v < 10 ? 1 : 0 });
|
|
return einheit === "usd" ? s + " $" : s;
|
|
}
|
|
|
|
function xLabels(labels) {
|
|
var n = labels.length;
|
|
if (!n) return "";
|
|
var plotW = W - ML - MR;
|
|
var schritt = n > 8 ? 2 : 1;
|
|
var out = "";
|
|
for (var i = 0; i < n; i++) {
|
|
if (i % schritt !== 0) continue;
|
|
var x = ML + (plotW * (i + 0.5)) / n;
|
|
out += '<text x="' + x + '" y="' + (H - 8) + '" text-anchor="middle" font-size="10" fill="var(--text-muted)">' +
|
|
esc(labels[i]) + "</text>";
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// serien: [{name, farbe, werte[]}], gestapelt. einheit: 'usd' | ''
|
|
function chartBalken(labels, serien, einheit) {
|
|
var n = labels.length;
|
|
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
|
var summen = labels.map(function (_, i) {
|
|
return serien.reduce(function (a, s) { return a + (s.werte[i] || 0); }, 0);
|
|
});
|
|
var maxY = niceMax(Math.max.apply(null, summen.concat([0])));
|
|
var plotW = W - ML - MR, plotH = H - MT - MB;
|
|
var breite = Math.min(40, (plotW / n) * 0.62);
|
|
|
|
var svg = achsen(maxY, einheit);
|
|
for (var i = 0; i < n; i++) {
|
|
var x = ML + (plotW * (i + 0.5)) / n - breite / 2;
|
|
var yCursor = MT + plotH;
|
|
var titel = [fmtMonat(labels[i]) || labels[i]];
|
|
for (var s = 0; s < serien.length; s++) {
|
|
var wert = serien[s].werte[i] || 0;
|
|
titel.push(serien[s].name + ": " + (einheit === "usd" ? fmtUsd(wert) : fmt(wert)));
|
|
if (wert <= 0) continue;
|
|
var hoehe = (wert / maxY) * plotH;
|
|
yCursor -= hoehe;
|
|
// 2px-Lücke zwischen Segmenten, oberstes Segment leicht gerundet
|
|
var segH = Math.max(0, hoehe - 2);
|
|
if (segH > 0) {
|
|
svg += '<rect x="' + x + '" y="' + yCursor + '" width="' + breite + '" height="' + segH +
|
|
'" rx="2" fill="' + serien[s].farbe + '"/>';
|
|
}
|
|
}
|
|
if (summen[i] <= 0) titel.push("Keine Daten");
|
|
svg += '<rect x="' + (ML + (plotW * i) / n) + '" y="' + MT + '" width="' + (plotW / n) +
|
|
'" height="' + plotH + '" fill="transparent"><title>' + esc(titel.join("\n")) + "</title></rect>";
|
|
}
|
|
svg += xLabels(labels.map(fmtMonat));
|
|
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
|
|
}
|
|
|
|
// serien: [{name, farbe, werte[]}] mit null-Lücken. xIstTag = Tageslabels
|
|
function chartLinie(labels, serien, einheit, xIstTag) {
|
|
var n = labels.length;
|
|
if (!n) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
|
var alle = [];
|
|
serien.forEach(function (s) { s.werte.forEach(function (v) { if (v != null) alle.push(v); }); });
|
|
if (!alle.length) return '<div class="st-leer">Keine Daten im Zeitraum</div>';
|
|
var maxY = niceMax(Math.max.apply(null, alle));
|
|
var plotW = W - ML - MR, plotH = H - MT - MB;
|
|
|
|
function px(i) { return ML + (plotW * (i + 0.5)) / n; }
|
|
function py(v) { return MT + plotH - (v / maxY) * plotH; }
|
|
|
|
var svg = achsen(maxY, einheit);
|
|
serien.forEach(function (s) {
|
|
var punkte = [];
|
|
for (var i = 0; i < n; i++) {
|
|
if (s.werte[i] == null) continue;
|
|
punkte.push(px(i).toFixed(1) + "," + py(s.werte[i]).toFixed(1));
|
|
}
|
|
if (punkte.length > 1) {
|
|
svg += '<polyline points="' + punkte.join(" ") + '" fill="none" stroke="' + s.farbe +
|
|
'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
|
}
|
|
for (var j = 0; j < n; j++) {
|
|
if (s.werte[j] == null) continue;
|
|
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="2.5" fill="' + s.farbe + '"/>';
|
|
svg += '<circle cx="' + px(j) + '" cy="' + py(s.werte[j]) + '" r="9" fill="transparent"><title>' +
|
|
esc((xIstTag ? fmtDatum(labels[j]) : fmtMonat(labels[j])) + "\n" + s.name + ": " +
|
|
(einheit === "usd" ? fmtUsd(s.werte[j]) : fmt(s.werte[j]))) + "</title></circle>";
|
|
}
|
|
});
|
|
svg += xLabels(xIstTag ? labels.map(function (t, i) { return i % Math.ceil(n / 8) === 0 ? String(t).slice(8, 10) + "." + String(t).slice(5, 7) + "." : ""; }) : labels.map(fmtMonat));
|
|
return '<svg viewBox="0 0 ' + W + " " + H + '" style="width:100%;height:auto;display:block;" role="img">' + svg + "</svg>";
|
|
}
|
|
|
|
function legende(eintraege) {
|
|
return '<div class="st-legende">' + eintraege.map(function (e) {
|
|
return '<span><span class="st-dot" style="background:' + e.farbe + '"></span>' + esc(e.name) + "</span>";
|
|
}).join("") + "</div>";
|
|
}
|
|
|
|
function kpiKachel(label, wert, sub) {
|
|
return '<div class="stat-card"><div class="stat-label">' + esc(label) + '</div>' +
|
|
'<div class="stat-value">' + wert + "</div>" +
|
|
(sub ? '<div class="stat-sub">' + sub + "</div>" : "") + "</div>";
|
|
}
|
|
|
|
function chartCard(titel, inhalt, legendeHtml) {
|
|
return '<div class="card"><div class="card-header"><h2>' + esc(titel) + "</h2>" +
|
|
(legendeHtml || "") + '</div><div class="card-body">' + inhalt + "</div></div>";
|
|
}
|
|
|
|
// ---------- Rendering ----------
|
|
|
|
function C1() { return "var(--st-c1)"; }
|
|
function C2() { return "var(--st-c2)"; }
|
|
function C3() { return "var(--st-c3)"; }
|
|
|
|
function render(d) {
|
|
var el = document.getElementById("st-inhalt");
|
|
if (!el) return;
|
|
var monate = d.window.monate || [];
|
|
var mt = {};
|
|
(d.monat_typ || []).forEach(function (r) { mt[r.monat + "|" + r.typ] = r; });
|
|
function reihe(typ, feld) {
|
|
return monate.map(function (m) { var r = mt[m + "|" + typ]; return r ? r[feld] : 0; });
|
|
}
|
|
function reiheNull(typ, feld) {
|
|
return monate.map(function (m) { var r = mt[m + "|" + typ]; return r && r[feld] != null ? r[feld] : null; });
|
|
}
|
|
|
|
// Übrige Quellen (globe/chat/enhance) je Monat zusammenfassen
|
|
var nebenKosten = {};
|
|
(d.monat_quelle || []).forEach(function (r) {
|
|
if (r.quelle === "monitor") return;
|
|
nebenKosten[r.monat] = (nebenKosten[r.monat] || 0) + (r.kosten || 0);
|
|
});
|
|
|
|
var kpi = d.kpi || {};
|
|
var html = "";
|
|
|
|
// --- Kosten & Marge ---
|
|
html += '<h3 class="st-gruppe">Kosten & Marge</h3>';
|
|
html += '<div class="stats-grid">' +
|
|
kpiKachel("API-Kosten im Zeitraum", fmtUsd(kpi.kosten_usd), fmt(kpi.laeufe_gesamt) + " Läufe gesamt") +
|
|
kpiKachel("Erfolgreiche Läufe", fmt(kpi.laeufe_ok), kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "% Erfolgsquote" : "") +
|
|
kpiKachel("Median je Lauf", kpi.median_kosten_lauf != null ? fmtUsd(kpi.median_kosten_lauf) : "-", "nur Läufe mit Kosten") +
|
|
kpiKachel("Artikel gefunden", fmt(kpi.artikel), fmt(kpi.tokens_in) + " Tokens ein / " + fmt(kpi.tokens_out) + " aus") +
|
|
"</div>";
|
|
|
|
html += chartCard("API-Kosten je Monat (USD)",
|
|
chartBalken(monate, [
|
|
{ name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "kosten") },
|
|
{ name: "Recherche", farbe: C2(), werte: reihe("research", "kosten") },
|
|
{ name: "Übrige Quellen", farbe: C3(), werte: monate.map(function (m) { return Math.round((nebenKosten[m] || 0) * 100) / 100; }) },
|
|
], "usd"),
|
|
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }, { name: "Übrige Quellen", farbe: C3() }]));
|
|
|
|
html += chartCard("Median-Kosten je Lauf (USD), der Frühwarner für Kosten-Anstieg",
|
|
chartLinie(monate, [
|
|
{ name: "Live-Monitoring", farbe: C1(), werte: reiheNull("adhoc", "kosten_median") },
|
|
{ name: "Recherche", farbe: C2(), werte: reiheNull("research", "kosten_median") },
|
|
], "usd"),
|
|
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }]));
|
|
|
|
// Orgs-Tabelle
|
|
var orgZeilen = (d.orgs || []).map(function (o) {
|
|
var margeCls = o.marge_usd >= 0 ? "st-pos" : "st-neg";
|
|
return "<tr><td>" + esc(o.name) + "</td><td>" + fmt(o.laeufe_ok) + " / " + fmt(o.laeufe_gesamt) + "</td>" +
|
|
"<td>" + fmtUsd(o.kosten_usd) + "</td><td>" + fmt(o.credits_geschaetzt) + "</td>" +
|
|
"<td>" + fmtUsd(o.gegenwert_usd) + '</td><td class="' + margeCls + '">' + fmtUsd(o.marge_usd) + "</td></tr>";
|
|
}).join("");
|
|
html += '<div class="card"><div class="card-header"><h2>Je Organisation (Zeitraum)</h2></div>' +
|
|
'<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Läufe ok/gesamt</th>' +
|
|
"<th>Ist-Kosten</th><th>Credits (geschätzt)</th><th>Gegenwert</th><th>Marge</th></tr></thead>" +
|
|
"<tbody>" + (orgZeilen || '<tr><td colspan="6" class="text-muted">Keine Daten</td></tr>') + "</tbody></table></div>" +
|
|
'<div class="card-body st-fussnote">Schätzung zu den heutigen Sätzen (billing_tariff), es gibt keine historisierten Buchungen. ' +
|
|
"Recherche-Anlage zählt als 3 Durchläufe (erster abgeschlossener Lauf je Recherche-Lage). " +
|
|
"Studio-Bausteine (analysis/factcheck) fehlen mangels Nutzungsdaten, der Gegenwert ist eher zu niedrig. " +
|
|
"Ist-Kosten enthalten auch Fehlläufe. 1 Credit = " + fmtUsd(d.tarif.usd_per_credit) + ".</div></div>";
|
|
|
|
// --- Nutzung ---
|
|
html += '<h3 class="st-gruppe">Nutzung</h3>';
|
|
var mb = {};
|
|
(d.monat_betrieb || []).forEach(function (r) { mb[r.monat] = r; });
|
|
function bReihe(feld) { return monate.map(function (m) { return mb[m] ? mb[m][feld] : 0; }); }
|
|
|
|
html += chartCard("Erfolgreiche Läufe je Monat",
|
|
chartBalken(monate, [
|
|
{ name: "Automatisch", farbe: C1(), werte: bReihe("auto") },
|
|
{ name: "Manuell", farbe: C2(), werte: bReihe("manual") },
|
|
], ""),
|
|
legende([{ name: "Automatisch", farbe: C1() }, { name: "Manuell", farbe: C2() }]));
|
|
|
|
html += chartCard("Gefundene Artikel je Monat",
|
|
chartBalken(monate, [
|
|
{ name: "Live-Monitoring", farbe: C1(), werte: reihe("adhoc", "artikel") },
|
|
{ name: "Recherche", farbe: C2(), werte: reihe("research", "artikel") },
|
|
], ""),
|
|
legende([{ name: "Live-Monitoring", farbe: C1() }, { name: "Recherche", farbe: C2() }]));
|
|
|
|
// --- Betrieb ---
|
|
html += '<h3 class="st-gruppe">Betrieb</h3>';
|
|
var dauerA = d.dauer_typ && d.dauer_typ.adhoc != null ? Math.round(d.dauer_typ.adhoc / 60) + " Min" : "-";
|
|
var dauerR = d.dauer_typ && d.dauer_typ.research != null ? Math.round(d.dauer_typ.research / 60) + " Min" : "-";
|
|
html += '<div class="stats-grid">' +
|
|
kpiKachel("Erfolgsquote", kpi.erfolgsquote != null ? kpi.erfolgsquote.toLocaleString("de-DE") + "%" : "-",
|
|
fmt(kpi.laeufe_ok) + " von " + fmt(kpi.laeufe_gesamt) + " Läufen") +
|
|
kpiKachel("Dauer Live-Lauf (ø)", dauerA, "") +
|
|
kpiKachel("Dauer Recherche-Durchlauf (ø)", dauerR, "") +
|
|
"</div>";
|
|
|
|
html += chartCard("Läufe nach Ausgang je Monat",
|
|
chartBalken(monate, [
|
|
{ name: "Erfolgreich", farbe: "var(--success)", werte: bReihe("completed") },
|
|
{ name: "Fehler", farbe: "var(--danger)", werte: bReihe("error") },
|
|
{ name: "Abgebrochen", farbe: "var(--text-muted)", werte: bReihe("cancelled") },
|
|
], ""),
|
|
legende([{ name: "Erfolgreich", farbe: "var(--success)" }, { name: "Fehler", farbe: "var(--danger)" }, { name: "Abgebrochen", farbe: "var(--text-muted)" }]));
|
|
|
|
var fehlerZeilen = (d.fehler_top || []).map(function (f) {
|
|
return "<tr><td>" + esc(f.grund) + "</td><td>" + fmt(f.n) + "</td></tr>";
|
|
}).join("");
|
|
html += '<div class="card"><div class="card-header"><h2>Häufigste Fehlerursachen</h2></div>' +
|
|
'<div class="table-wrap"><table><thead><tr><th>Ursache</th><th>Anzahl</th></tr></thead><tbody>' +
|
|
(fehlerZeilen || '<tr><td colspan="2" class="text-muted">Keine Fehler im Zeitraum</td></tr>') +
|
|
"</tbody></table></div></div>";
|
|
|
|
// --- Aktivität ---
|
|
html += '<h3 class="st-gruppe">Aktivität</h3>';
|
|
var akt = d.aktivitaet || {};
|
|
html += '<div class="stats-grid">' +
|
|
kpiKachel("Nutzer mit Login (30 Tage)", fmt(akt.nutzer_30d), "Momentaufnahme aus dem letzten Login") +
|
|
kpiKachel("Aktivitäts-Erfassung", akt.erfassung_seit ? "seit " + fmtDatum(akt.erfassung_seit) : "startet jetzt",
|
|
akt.erfassung_seit ? "je Nutzer und Tag ein Eintrag" : "MAU/DAU wachsen ab diesem Update") +
|
|
"</div>";
|
|
var mauWerte = (akt.mau || []).map(function (r) { return r.nutzer; });
|
|
var mauMax = Math.max.apply(null, mauWerte.concat([0]));
|
|
if (mauMax > 0) {
|
|
html += chartCard("Aktive Nutzer je Monat (MAU)",
|
|
chartBalken(monate, [{ name: "Aktive Nutzer", farbe: C1(), werte: mauWerte }], ""), "");
|
|
var dauLabels = (akt.dau || []).map(function (r) { return r.tag; });
|
|
var dauWerte = (akt.dau || []).map(function (r) { return r.nutzer; });
|
|
html += chartCard("Aktive Nutzer je Tag (DAU, letzte 30 Tage)",
|
|
dauLabels.length
|
|
? chartLinie(dauLabels, [{ name: "Aktive Nutzer", farbe: C1(), werte: dauWerte }], "", true)
|
|
: '<div class="st-leer">Noch keine Tageswerte</div>', "");
|
|
} else {
|
|
html += '<div class="card"><div class="card-body st-leer">Noch keine Aktivitätsdaten. ' +
|
|
"Die Erfassung (ein Eintrag je Nutzer und Tag) beginnt mit diesem Update, " +
|
|
"die MAU/DAU-Kurven erscheinen hier, sobald sich Nutzer im Monitor bewegen.</div></div>";
|
|
}
|
|
|
|
// --- Wachstum ---
|
|
html += '<h3 class="st-gruppe">Wachstum</h3>';
|
|
var w = {};
|
|
(d.wachstum || []).forEach(function (r) { w[r.monat] = r; });
|
|
function wReihe(feld) { return monate.map(function (m) { return w[m] ? w[m][feld] : 0; }); }
|
|
html += chartCard("Neuzugänge je Monat",
|
|
chartBalken(monate, [
|
|
{ name: "Organisationen", farbe: C1(), werte: wReihe("orgs") },
|
|
{ name: "Nutzer", farbe: C2(), werte: wReihe("users") },
|
|
{ name: "Lagen", farbe: C3(), werte: wReihe("lagen") },
|
|
], ""),
|
|
legende([{ name: "Organisationen", farbe: C1() }, { name: "Nutzer", farbe: C2() }, { name: "Lagen", farbe: C3() }]));
|
|
|
|
el.innerHTML = html;
|
|
|
|
// Datenstand
|
|
var ds = document.getElementById("st-datenstand");
|
|
if (ds) {
|
|
var teile = [];
|
|
if (d.datenstand.letzter_lauf) teile.push("Letzter Lauf " + fmtDatum(d.datenstand.letzter_lauf));
|
|
teile.push("Kostenerfassung ab 03/2026");
|
|
if (d.tarif && d.tarif.quelle === "defaults") teile.push("Sätze aus Rückfallwerten");
|
|
ds.textContent = "Datenstand: " + teile.join(" · ");
|
|
}
|
|
}
|
|
|
|
// ---------- Laden ----------
|
|
|
|
function lade() {
|
|
var el = document.getElementById("st-inhalt");
|
|
if (el) el.innerHTML = '<div class="st-leer">Lade Statistik …</div>';
|
|
API.get("/api/statistik?months=" + aktMonate).then(function (d) {
|
|
render(d);
|
|
}).catch(function (err) {
|
|
if (el) el.innerHTML = '<div class="st-leer">Statistik konnte nicht geladen werden: ' + esc(err.message) + "</div>";
|
|
});
|
|
}
|
|
|
|
function setzeZeitraum(monate, btn) {
|
|
aktMonate = monate;
|
|
document.querySelectorAll("#st-zeitraum button").forEach(function (b) { b.classList.remove("btn-primary"); b.classList.add("btn-secondary"); });
|
|
btn.classList.remove("btn-secondary");
|
|
btn.classList.add("btn-primary");
|
|
lade();
|
|
}
|
|
|
|
// ---------- Injektion (Muster rechner.js) ----------
|
|
|
|
var STYLES = "\n" +
|
|
"#sec-statistik { --st-c1:#a8863a; --st-c2:#3987e5; --st-c3:#199e70; }\n" +
|
|
'[data-theme="light"] #sec-statistik { --st-c1:#96791A; --st-c2:#2a78d6; --st-c3:#199e70; }\n' +
|
|
"#sec-statistik .st-gruppe { margin:26px 0 12px; font-size:16px; border-bottom:1px solid var(--border); padding-bottom:6px; }\n" +
|
|
"#sec-statistik .st-kopf { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }\n" +
|
|
"#sec-statistik .st-kopf h2 { font-size:20px; margin-right:auto; }\n" +
|
|
"#sec-statistik .st-datenstand { font-size:12px; color:var(--text-muted); margin-top:6px; }\n" +
|
|
"#sec-statistik .st-legende { display:flex; gap:14px; font-size:12px; color:var(--text-secondary); flex-wrap:wrap; }\n" +
|
|
"#sec-statistik .st-dot { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:5px; vertical-align:-1px; }\n" +
|
|
"#sec-statistik .st-leer { padding:22px; text-align:center; color:var(--text-muted); font-size:13px; }\n" +
|
|
"#sec-statistik .st-fussnote { font-size:12px; color:var(--text-muted); border-top:1px solid var(--border); }\n" +
|
|
"#sec-statistik .st-pos { color:var(--success); }\n" +
|
|
"#sec-statistik .st-neg { color:var(--danger); }\n" +
|
|
"#sec-statistik .card { margin-bottom:16px; }\n";
|
|
|
|
function init() {
|
|
var nav = document.querySelector("nav.nav-tabs");
|
|
var content = document.querySelector(".app-content");
|
|
if (!nav || !content || document.getElementById("sec-statistik")) return;
|
|
|
|
var style = document.createElement("style");
|
|
style.textContent = STYLES;
|
|
document.head.appendChild(style);
|
|
|
|
var sektion = document.createElement("div");
|
|
sektion.className = "section";
|
|
sektion.id = "sec-statistik";
|
|
sektion.innerHTML =
|
|
'<div class="st-kopf">' +
|
|
"<h2>Statistik</h2>" +
|
|
'<div id="st-zeitraum" style="display:flex; gap:6px;">' +
|
|
'<button type="button" class="btn btn-secondary btn-small" data-monate="6">6 Monate</button>' +
|
|
'<button type="button" class="btn btn-primary btn-small" data-monate="12">12 Monate</button>' +
|
|
'<button type="button" class="btn btn-secondary btn-small" data-monate="0">Alles</button>' +
|
|
"</div>" +
|
|
"</div>" +
|
|
'<div class="st-datenstand" id="st-datenstand"></div>' +
|
|
'<div id="st-inhalt" style="margin-top:14px;"></div>';
|
|
content.appendChild(sektion);
|
|
|
|
var btn = document.createElement("button");
|
|
btn.className = "nav-tab";
|
|
btn.setAttribute("data-section", "statistik");
|
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>Statistik';
|
|
// Zwischen Audit-Log und Verbrauchsrechner, egal in welcher Ladereihenfolge
|
|
var rechnerTab = nav.querySelector('.nav-tab[data-section="rechner"]');
|
|
nav.insertBefore(btn, rechnerTab || null);
|
|
|
|
btn.addEventListener("click", function () {
|
|
document.querySelectorAll("nav.nav-tabs .nav-tab").forEach(function (t) { t.classList.remove("active"); });
|
|
btn.classList.add("active");
|
|
document.querySelectorAll(".app-content > .section").forEach(function (s) { s.classList.remove("active"); });
|
|
var detail = document.getElementById("orgDetail");
|
|
if (detail) detail.classList.remove("active");
|
|
sektion.classList.add("active");
|
|
if (!geladen) { geladen = true; lade(); }
|
|
});
|
|
|
|
nav.addEventListener("click", function (e) {
|
|
var ziel = e.target.closest(".nav-tab");
|
|
if (!ziel || ziel === btn) return;
|
|
btn.classList.remove("active");
|
|
sektion.classList.remove("active");
|
|
});
|
|
|
|
sektion.querySelectorAll("#st-zeitraum button").forEach(function (b) {
|
|
b.addEventListener("click", function () { setzeZeitraum(parseInt(b.getAttribute("data-monate"), 10), b); });
|
|
});
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|