aether-framework/resources/[aether]/aether-police/html/js/module/statistik.js
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

374 lines
14 KiB
JavaScript
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.

/* =====================================================================
aether-police / Ansicht: Statistik
Zahlen über die Behörde, nicht über einzelne Beamte. Es gibt hier
bewusst keine Rangliste — jede Kennzahl beschreibt einen Vorgang,
keine Leistung.
===================================================================== */
"use strict";
(() => {
const { sicher, aufruf, meldung, euro } = App;
const z = {
reiter: "uebersicht", // uebersicht | abdeckung | katalog
tage: 30,
daten: null,
};
/* ---- Bausteine ------------------------------------------------------ */
/** Runde Zahl mit Tausenderpunkt */
function zahl(wert) {
return Math.round(wert || 0).toLocaleString("de-DE");
}
/** Kennzahlkachel */
function kachel(wert, text, unter) {
return `<div class="kennzahl">
<span class="kz-zahl">${wert}</span>
<span class="kz-text">${sicher(text)}</span>
${unter ? `<span class="st-unter">${sicher(unter)}</span>` : ""}
</div>`;
}
/** Waagerechtes Balkendiagramm aus einer Verteilung */
function verteilung(daten, leer) {
if (!daten || !daten.eintraege.length) {
return `<div class="liste-leer">${sicher(leer || "Keine Daten im Zeitraum.")}</div>`;
}
return `<div class="st-verteilung">
${daten.eintraege.map((e) => `
<div class="st-vt-zeile">
<span class="st-vt-name">${sicher(e.name)}</span>
<div class="st-vt-balken"><i style="width:${e.anteil}%"></i></div>
<span class="st-vt-zahl">${zahl(e.anzahl)}</span>
<span class="st-vt-anteil">${e.anteil} %</span>
</div>`).join("")}
</div>`;
}
/** Säulendiagramm des Tagesverlaufs */
function verlauf(zeilen) {
if (!zeilen || !zeilen.length) {
return `<div class="liste-leer">Im Zeitraum wurde nichts erfasst.</div>`;
}
// Höchstwert bestimmt die Skalierung beider Reihen
let hoechst = 1;
zeilen.forEach((t) => {
hoechst = Math.max(hoechst, +t.einsaetze || 0, +t.anzeigen || 0);
});
return `
<div class="st-legende">
<span><i class="st-punkt einsatz"></i> Einsätze</span>
<span><i class="st-punkt anzeige"></i> Anzeigen</span>
<span class="feld-hinweis">Höchstwert: ${hoechst}</span>
</div>
<div class="st-verlauf">
${zeilen.map((t) => {
const einsaetze = +t.einsaetze || 0;
const anzeigen = +t.anzeigen || 0;
const tag = String(t.tag).slice(0, 10);
return `<div class="st-tag" title="${sicher(tag)}: ${einsaetze} Einsätze, ${anzeigen} Anzeigen">
<div class="st-saeulen">
<i class="einsatz" style="height:${(einsaetze / hoechst) * 100}%"></i>
<i class="anzeige" style="height:${(anzeigen / hoechst) * 100}%"></i>
</div>
<span class="st-tag-text">${sicher(tag.slice(8))}</span>
</div>`;
}).join("")}
</div>`;
}
function reiterleiste() {
const eintraege = [
{ id: "uebersicht", text: "Überblick" },
{ id: "abdeckung", text: "Dienstabdeckung" },
{ id: "katalog", text: "Katalogauswertung" },
];
const zeitraeume = (z.daten && z.daten.zeitraeume) || [7, 30, 90];
return `
<div class="st-leiste">
<div class="mdt-reiter">
${eintraege.map((e) => `
<button class="${z.reiter === e.id ? "aktiv" : ""}" data-reiter="${e.id}">
${sicher(e.text)}</button>`).join("")}
</div>
<div class="st-zeitraum">
${zeitraeume.map((t) => `
<button class="pv-schalter ${z.tage === t ? "gewaehlt" : ""}" data-tage="${t}">
${t} Tage</button>`).join("")}
</div>
</div>`;
}
/* ---- Überblick -------------------------------------------------------- */
function uebersicht(behaelter) {
const d = z.daten;
const e = d.einsaetze, a = d.anzeigen, g = d.gewahrsam, v = d.vorgaenge, p = d.personal;
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Statistik</h2>
<p>Auswertung der letzten ${d.tage} Tage. Alle Zahlen beschreiben Vorgänge
der Behörde — es gibt hier keine Bewertung einzelner Beamter.</p>
</div>
</div>
${reiterleiste()}
<div class="raster raster-4">
${kachel(zahl(e.gesamt), "Einsätze", e.offen + " offen")}
${kachel(zahl(a.gesamt), "Anzeigen", a.bestaetigt + " bestätigt")}
${kachel(zahl(g.einweisungen), "Einweisungen", g.laufend + " laufend")}
${kachel(zahl(p.dienstStunden), "Dienststunden", p.bestand + " Beamte im Bestand")}
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Verlauf</span></div>
${verlauf(d.verlauf)}
</div>
<div class="st-spalten">
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Einsätze nach Kategorie</span></div>
${verteilung(e.nachKategorie)}
<div class="st-fuss">
Ein Einsatz bleibt im Schnitt
<b>${zahl(e.laufzeitMinuten)} Minuten</b> offen.
</div>
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Einsätze nach Priorität</span></div>
${verteilung(e.nachPrioritaet)}
</div>
</div>
<div class="st-spalten">
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Anzeigen</span></div>
<div class="st-werte">
<div><span>Bestätigt</span><b>${zahl(a.bestaetigt)}</b></div>
<div><span>Noch Entwurf</span><b>${zahl(a.entwurf)}</b></div>
<div><span>Ø festgelegte Haft</span><b>${zahl(a.haftDurchschnitt)} Min</b></div>
<div><span>Ø festgelegte Geldstrafe</span><b>${euro(a.geldDurchschnitt)}</b></div>
</div>
<div class="st-fuss">
In <b>${zahl(a.abweichungen)}</b> von ${zahl(a.bestaetigt)} bestätigten Anzeigen
wich die Entscheidung vom Katalogvorschlag ab. Das ist eine Aussage über den
Katalog — nicht über die Beamten.
</div>
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Gewahrsam</span></div>
<div class="st-werte">
<div><span>Einweisungen</span><b>${zahl(g.einweisungen)}</b></div>
<div><span>Laufend</span><b>${zahl(g.laufend)}</b></div>
<div><span>Ø festgelegte Dauer</span><b>${zahl(g.haftDurchschnitt)} Min</b></div>
<div><span>Vorzeitig entlassen</span><b>${zahl(g.vorzeitig)}</b></div>
</div>
<div class="st-fuss">
Zwischen Fristablauf und tatsächlicher Entlassung lagen im Schnitt
<b>${zahl(g.verzugMinuten)} Minuten</b>. Das System entlässt niemanden von
selbst — der Wert zeigt, wie schnell jemand erreichbar war.
</div>
</div>
</div>
<div class="st-spalten">
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Vorgänge</span></div>
<div class="st-werte">
<div><span>Akten angelegt</span><b>${zahl(v.akten)}</b></div>
<div><span>Akten offen</span><b>${zahl(v.aktenOffen)}</b></div>
<div><span>Berichte</span><b>${zahl(v.berichte)}</b></div>
<div><span>Berichte im Entwurf</span><b>${zahl(v.berichteEntwurf)}</b></div>
<div><span>Fahndungen</span><b>${zahl(v.fahndungen)}</b></div>
<div><span>Fahndungen aktiv</span><b>${zahl(v.fahndungenAktiv)}</b></div>
<div><span>Beweise erfasst</span><b>${zahl(v.beweise)}</b></div>
<div><span>Asservate eingelagert</span><b>${zahl(v.asservate)}</b></div>
</div>
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Akten nach Art</span></div>
${verteilung(v.aktenNachTyp)}
</div>
</div>
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Personal nach Abteilung</span>
<span class="feld-hinweis">${p.imDienst} gerade im Dienst</span>
</div>
${verteilung(p.nachAbteilung)}
</div>
`;
verdrahte(behaelter);
}
/* ---- Dienstabdeckung --------------------------------------------------- */
function abdeckung(behaelter) {
const d = z.daten;
const hoechst = Math.max(d.hoechstwert, 0.1);
// Wo ist die Besetzung am dünnsten?
const schwach = [...d.stunden].sort((a, b) => a.mittel - b.mittel).slice(0, 3);
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Dienstabdeckung</h2>
<p>Mittlere Besetzung je Tageszeit über ${d.tage} Tage — ein Planungswert.
Er sagt nichts darüber, <em>wer</em> Dienst hatte.</p>
</div>
</div>
${reiterleiste()}
<div class="raster raster-3">
${kachel(zahl(d.schichten), "Schichten im Zeitraum")}
${kachel(d.hoechstwert.toFixed(2), "stärkste Stunde", "gleichzeitig im Dienst")}
${kachel(schwach.map((s) => s.stunde + " Uhr").join(", "), "dünnste Stunden")}
</div>
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Besetzung nach Tageszeit</span>
<span class="feld-hinweis">Durchschnittlich gleichzeitig im Dienst</span>
</div>
<div class="st-stunden">
${d.stunden.map((s) => `
<div class="st-stunde" title="${s.stunde}:00 Uhr — Ø ${s.mittel} Beamte">
<div class="st-stunde-balken">
<i style="height:${(s.mittel / hoechst) * 100}%"></i>
</div>
<span class="st-stunde-text">${String(s.stunde).padStart(2, "0")}</span>
</div>`).join("")}
</div>
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Schichten nach Abteilung</span></div>
${verteilung(d.nachAbteilung)}
</div>
`;
verdrahte(behaelter);
}
/* ---- Katalogauswertung -------------------------------------------------- */
function katalog(behaelter) {
const d = z.daten;
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Katalogauswertung</h2>
<p>Welche Delikte kommen vor und wie oft weicht die Entscheidung vom
Vorschlag ab. Eine hohe Abweichungsquote heißt: der Katalogwert passt
nicht — nicht, dass jemand falsch entschieden hat.</p>
</div>
</div>
${reiterleiste()}
<div class="raster raster-3">
${kachel(zahl(d.anzeigen), "ausgewertete Anzeigen", "letzte " + d.tage + " Tage")}
${kachel(zahl(d.delikte.length), "verwendete Delikte")}
${kachel(zahl(d.ungenutzt.length), "nie verwendet", "von " + d.katalogGesamt + " im Katalog")}
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Häufigste Delikte</span></div>
${d.delikte.length ? `
<div class="liste">
${d.delikte.map((delikt) => `
<div class="liste-zeile">
<span class="pv-nummer">${sicher(delikt.kennung)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(delikt.name)}</div>
<div class="lz-unter">${sicher(delikt.paragraph || "")}</div>
</div>
<span class="st-vt-zahl">${zahl(delikt.anzahl)}×</span>
<span class="marker ${delikt.abweichungsAnteil >= 50 ? "warn"
: delikt.abweichungsAnteil > 0 ? "info" : "neutral"}"
title="Anteil der Anzeigen mit diesem Delikt, in denen vom Vorschlag abgewichen wurde">
${delikt.abweichungsAnteil} % abweichend</span>
</div>`).join("")}
</div>`
: `<div class="liste-leer">Im Zeitraum wurde keine Anzeige bestätigt.</div>`}
</div>
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Im Zeitraum nie verwendet</span>
<span class="feld-hinweis">Kandidaten zum Aufräumen des Katalogs</span>
</div>
${d.ungenutzt.length ? `
<div class="st-ungenutzt">
${d.ungenutzt.map((delikt) => `
<span class="marker neutral" title="${sicher(delikt.kategorie || "")}">
${sicher(delikt.kennung)} · ${sicher(delikt.bezeichnung)}</span>`).join("")}
</div>`
: `<div class="liste-leer">Jedes Delikt des Katalogs kam vor.</div>`}
</div>
`;
verdrahte(behaelter);
}
/* ---- Verdrahtung --------------------------------------------------------- */
function verdrahte(behaelter) {
behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
el.addEventListener("click", () => {
z.reiter = el.dataset.reiter;
rendern(behaelter);
});
});
behaelter.querySelectorAll("[data-tage]").forEach((el) => {
el.addEventListener("click", () => {
z.tage = +el.dataset.tage;
rendern(behaelter);
});
});
}
/* ---- Laden ---------------------------------------------------------------- */
async function rendern(behaelter) {
const aktionen = {
uebersicht: "uebersicht", abdeckung: "abdeckung", katalog: "katalog",
};
try {
z.daten = await aufruf("statistik", aktionen[z.reiter], { tage: z.tage });
// Der Server bestimmt den gültigen Zeitraum
z.tage = z.daten.tage;
if (z.reiter === "abdeckung") abdeckung(behaelter);
else if (z.reiter === "katalog") katalog(behaelter);
else uebersicht(behaelter);
} catch (f) {
behaelter.innerHTML = `<div class="seiten-kopf"><div><h2>Statistik</h2>
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
}
}
App.registriereAnsicht("statistik", { rendern });
})();