- 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>
374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
/* =====================================================================
|
||
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 });
|
||
})();
|