aether-framework/resources/[aether]/aether-police/html/js/module/anzeige.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

554 lines
24 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: Strafenkatalog & Anzeigen
Der Katalog liefert einen VORSCHLAG. Die Felder darunter gehören dem
Beamten — er kann sie jederzeit ändern. Weicht er ab, wird das
sichtbar gemacht und eine Begründung verlangt.
===================================================================== */
"use strict";
(() => {
const { sicher, zeit, euro, aufruf, meldung, fenster, bestaetigen, $ } = App;
const z = {
reiter: "erfassen", // erfassen | vorgaenge | katalog
katalog: [],
rechte: {},
auswahl: new Set(), // gewählte Delikt-Kennungen
vorschlag: null, // { delikte, haft, geld, hinweise }
beschuldigter: null, // { id, name }
anzeigen: [],
filterStatus: "",
};
/* ---- Bausteine --------------------------------------------------- */
function reiterLeiste() {
const reiter = [
{ id: "erfassen", name: "Anzeige erfassen", symbol: "✎" },
{ id: "vorgaenge", name: "Vorgänge", symbol: "📋" },
];
if (z.rechte.bearbeiten) reiter.push({ id: "katalog", name: "Katalog pflegen", symbol: "⚙" });
return `<div class="mdt-reiter">${reiter.map((r) =>
`<button data-reiter="${r.id}" class="${r.id === z.reiter ? "aktiv" : ""}">
${r.symbol} ${r.name}</button>`).join("")}</div>`;
}
/** Zeigt Minuten als „1 Std 30 Min" */
function haftText(minuten) {
const m = Math.max(0, Math.floor(minuten || 0));
if (m === 0) return "keine Haft";
if (m < 60) return m + " Min";
return Math.floor(m / 60) + " Std " + (m % 60 ? (m % 60) + " Min" : "");
}
/* =====================================================================
ANZEIGE ERFASSEN
===================================================================== */
function katalogAuswahl() {
if (!z.katalog.length) return `<div class="liste-leer">Der Katalog ist leer.</div>`;
return z.katalog.map((gruppe) => `
<div class="katalog-gruppe">
<div class="katalog-titel">${sicher(gruppe.kategorie)}</div>
${gruppe.delikte.map((d) => `
<label class="delikt ${z.auswahl.has(d.kennung) ? "gewaehlt" : ""}">
<input type="checkbox" data-delikt="${sicher(d.kennung)}"
${z.auswahl.has(d.kennung) ? "checked" : ""}>
<div class="delikt-text">
<div class="delikt-name">${sicher(d.bezeichnung)}</div>
<div class="delikt-unter">
${d.paragraph ? sicher(d.paragraph) + " · " : ""}
${haftText(d.haft)} · ${euro(d.geld)}
</div>
</div>
</label>`).join("")}
</div>`).join("");
}
function erfassenAnsicht(behaelter) {
const v = z.vorschlag;
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Anzeige erfassen</h2>
<p>Der Katalog schlägt vor — entschieden wird von dir.</p>
</div>
</div>
${reiterLeiste()}
<div class="anzeige-raster">
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Strafenkatalog</span>
<input class="katalog-suche" id="an-katalogsuche" type="search" placeholder="Delikt suchen …">
</div>
<div class="katalog-liste" id="an-katalog">${katalogAuswahl()}</div>
</div>
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Vorgang</span></div>
<div class="feld">
<label>Beschuldigte Person</label>
<div class="person-waehler">
<input id="an-person" placeholder="Name eingeben und suchen …"
value="${z.beschuldigter ? sicher(z.beschuldigter.name) : ""}">
<button class="knopf leise klein" id="an-personsuche">Suchen</button>
</div>
${z.beschuldigter
? `<div class="feld-hinweis">Ausgewählt: <b>${sicher(z.beschuldigter.name)}</b></div>`
: `<div class="feld-hinweis">Ohne Beschuldigten kann keine Anzeige erfasst werden.</div>`}
</div>
${v ? `
<div class="vorschlag-block">
<div class="vorschlag-kopf">
<span class="karte-titel">Vorschlag des Katalogs</span>
<span class="marker neutral">${v.delikte.length} Delikt${v.delikte.length === 1 ? "" : "e"}</span>
</div>
<div class="vorschlag-werte">
<div><span>Haft</span><b>${haftText(v.haft)}</b></div>
<div><span>Geldstrafe</span><b>${euro(v.geld)}</b></div>
</div>
${v.hinweise.length ? `
<div class="vorschlag-hinweise">
${v.hinweise.map((h) => `<div>ℹ ${sicher(h)}</div>`).join("")}
</div>` : ""}
</div>
<div class="entscheidung-block">
<div class="karte-titel" style="margin-bottom:10px">Deine Entscheidung</div>
<div class="raster k2" style="gap:10px">
<div class="feld" style="margin:0">
<label>Haftdauer (Minuten)</label>
<input id="an-haft" type="number" min="0" max="1440" value="${v.haft}">
</div>
<div class="feld" style="margin:0">
<label>Geldstrafe (€)</label>
<input id="an-geld" type="number" min="0" value="${v.geld}">
</div>
</div>
<div id="an-abweichung" class="abweichung" hidden>
<div class="abweichung-kopf">⚠ Du weichst vom Vorschlag ab</div>
<div class="feld" style="margin:10px 0 0">
<label>Begründung (erforderlich)</label>
<textarea id="an-grund" placeholder="Warum weichst du ab?"></textarea>
</div>
</div>
<div class="grundsatz">
<span>ℹ</span>
<div>Der Katalog ist ein <b>Nachschlagewerk</b>. Beide Werte lassen sich frei
anpassen — gespeichert werden Vorschlag und Entscheidung getrennt, damit
nachvollziehbar bleibt, wer was festgelegt hat.</div>
</div>
<button class="knopf gross" id="an-speichern">Als Entwurf erfassen</button>
<div class="feld-hinweis" style="text-align:center;margin-top:8px">
Es wird noch nichts eingezogen und niemand kommt in Haft.
</div>
</div>`
: `<div class="liste-leer">Wähle links mindestens ein Delikt aus.</div>`}
</div>
</div>`;
verdrahteErfassen(behaelter);
}
function verdrahteErfassen(behaelter) {
// Delikt an-/abwählen
behaelter.querySelectorAll("[data-delikt]").forEach((k) =>
k.addEventListener("change", async () => {
const kennung = k.dataset.delikt;
if (k.checked) z.auswahl.add(kennung); else z.auswahl.delete(kennung);
await berechneVorschlag();
erfassenAnsicht(behaelter);
}));
// Katalogsuche (nur Anzeige filtern)
const suche = $("an-katalogsuche");
if (suche) suche.addEventListener("input", () => {
const begriff = suche.value.trim().toLowerCase();
behaelter.querySelectorAll(".delikt").forEach((el) => {
const text = el.textContent.toLowerCase();
el.style.display = !begriff || text.includes(begriff) ? "" : "none";
});
behaelter.querySelectorAll(".katalog-gruppe").forEach((g) => {
const sichtbar = [...g.querySelectorAll(".delikt")].some((e) => e.style.display !== "none");
g.style.display = sichtbar ? "" : "none";
});
});
// Person suchen
const personSuche = () => {
const feld = $("an-person");
const begriff = feld.value.trim();
if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
personWaehlen(behaelter, begriff);
};
const personKnopf = $("an-personsuche");
if (personKnopf) personKnopf.addEventListener("click", personSuche);
const personFeld = $("an-person");
if (personFeld) personFeld.addEventListener("keydown", (e) => { if (e.key === "Enter") personSuche(); });
// Abweichung sichtbar machen
const pruefeAbweichung = () => {
if (!z.vorschlag) return;
const haft = +($("an-haft") || {}).value || 0;
const geld = +($("an-geld") || {}).value || 0;
const abweichung = haft !== z.vorschlag.haft || geld !== z.vorschlag.geld;
const block = $("an-abweichung");
if (block) block.hidden = !abweichung;
};
["an-haft", "an-geld"].forEach((id) => {
const el = $(id);
if (el) el.addEventListener("input", pruefeAbweichung);
});
// Erfassen
const speichern = $("an-speichern");
if (speichern) speichern.addEventListener("click", async () => {
if (!z.beschuldigter) { meldung("Bitte eine Person auswählen.", "fehler"); return; }
const haft = +($("an-haft") || {}).value || 0;
const geld = +($("an-geld") || {}).value || 0;
const abweichung = haft !== z.vorschlag.haft || geld !== z.vorschlag.geld;
const grund = ($("an-grund") || {}).value || "";
if (abweichung && !z.rechte.abweichen) {
meldung("Dir fehlt die Berechtigung, vom Vorschlag abzuweichen.", "fehler");
return;
}
if (abweichung && grund.trim().length < 5) {
meldung("Bitte begründe die Abweichung.", "fehler");
return;
}
try {
await aufruf("anzeige", "erstellen", {
beschuldigter: z.beschuldigter.id,
delikte: [...z.auswahl],
haft, geld, begruendung: grund,
});
meldung("Als Entwurf erfasst — Bestätigung steht noch aus.", "gut");
z.auswahl.clear(); z.vorschlag = null; z.beschuldigter = null;
z.reiter = "vorgaenge";
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
});
}
/** Holt den Vorschlag zur aktuellen Auswahl vom Server */
async function berechneVorschlag() {
if (!z.auswahl.size) { z.vorschlag = null; return; }
try {
z.vorschlag = await aufruf("anzeige", "vorschlag", { delikte: [...z.auswahl] });
} catch (f) { meldung(f.message, "fehler"); z.vorschlag = null; }
}
/** Personensuche über das MDT-Modul */
async function personWaehlen(behaelter, begriff) {
try {
const d = await aufruf("mdt", "personSuchen", { begriff });
const treffer = d.treffer || [];
fenster({
titel: "Person auswählen",
inhalt: treffer.length
? `<div class="liste">${treffer.map((p) =>
`<div class="liste-zeile treffer" data-wahl="${p.id}" data-name="${sicher(p.firstname + " " + p.lastname)}">
<div class="lz-haupt">
<div class="lz-titel">${sicher(p.firstname + " " + p.lastname)}</div>
<div class="lz-unter">geb. ${sicher(String(p.dob || "").slice(0, 10))}</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine Treffer.</div>`,
nachOeffnen: (el) => {
el.querySelectorAll("[data-wahl]").forEach((zeile) =>
zeile.addEventListener("click", () => {
z.beschuldigter = { id: +zeile.dataset.wahl, name: zeile.dataset.name };
el.remove();
erfassenAnsicht(behaelter);
}));
},
});
} catch (f) { meldung(f.message, "fehler"); }
}
/* =====================================================================
VORGÄNGE
===================================================================== */
function vorgaengeAnsicht(behaelter) {
const statusArt = { entwurf: "warn", bestaetigt: "info", verworfen: "neutral" };
const statusText = { entwurf: "Entwurf", bestaetigt: "Bestätigt", verworfen: "Verworfen" };
behaelter.innerHTML = `
<div class="seiten-kopf">
<div><h2>Vorgänge</h2><p>Erfasste Anzeigen und ihr Bearbeitungsstand</p></div>
<select id="an-filter" class="katalog-suche" style="max-width:190px">
<option value="">Alle</option>
<option value="entwurf" ${z.filterStatus === "entwurf" ? "selected" : ""}>Nur Entwürfe</option>
<option value="bestaetigt" ${z.filterStatus === "bestaetigt" ? "selected" : ""}>Nur bestätigte</option>
<option value="verworfen" ${z.filterStatus === "verworfen" ? "selected" : ""}>Nur verworfene</option>
</select>
</div>
${reiterLeiste()}
<div class="karte">
${z.anzeigen.length ? `<div class="liste">${z.anzeigen.map((a) => `
<div class="liste-zeile anzeige-zeile">
<div class="lz-haupt">
<div class="lz-titel">
${sicher((a.b_vorname || "") + " " + (a.b_nachname || ""))}
${a.abweichung ? '<span class="marker warn">abweichend</span>' : ""}
</div>
<div class="lz-unter">
${sicher((a.delikte || []).map((d) => d.name).join(", ") || "—")}
</div>
<div class="lz-unter" style="margin-top:3px">
${haftText(a.haft_final)} · ${euro(a.geld_final)}
${a.abweichung ? ` <span style="opacity:.7">(Vorschlag: ${haftText(a.haft_empfohlen)} · ${euro(a.geld_empfohlen)})</span>` : ""}
· erfasst von ${sicher((a.e_vorname || "") + " " + (a.e_nachname || ""))} · ${zeit(a.erstellt_am)}
</div>
${a.begruendung ? `<div class="lz-unter" style="margin-top:3px"><i>„${sicher(a.begruendung)}"</i></div>` : ""}
</div>
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
<span class="marker ${statusArt[a.status]}">${sicher(statusText[a.status] || a.status)}</span>
${a.status === "entwurf" && z.rechte.bestaetigen
? `<button class="knopf klein" data-bestaetigen="${a.id}">Bestätigen</button>` : ""}
${a.status === "entwurf"
? `<button class="knopf leise klein" data-verwerfen="${a.id}">Verwerfen</button>` : ""}
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine Vorgänge.</div>`}
</div>`;
$("an-filter").addEventListener("change", (e) => {
z.filterStatus = e.target.value;
rendern(behaelter);
});
behaelter.querySelectorAll("[data-bestaetigen]").forEach((b) =>
b.addEventListener("click", () => bestaetigenDialog(behaelter, +b.dataset.bestaetigen)));
behaelter.querySelectorAll("[data-verwerfen]").forEach((b) =>
b.addEventListener("click", () => {
bestaetigen({
titel: "Entwurf verwerfen",
gefahr: true,
inhalt: `<div class="feld"><label>Grund</label>
<input id="av-grund" placeholder="Warum wird der Entwurf verworfen?"></div>`,
bestaetigenText: "Verwerfen",
bestaetigt: async (el, zu) => {
try {
await aufruf("anzeige", "verwerfen", {
id: +b.dataset.verwerfen,
grund: el.querySelector("#av-grund").value.trim(),
});
zu(); meldung("Entwurf verworfen."); rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
},
});
}));
}
/** Bestätigungsdialog — benennt genau, was ausgeführt wird */
function bestaetigenDialog(behaelter, anzeigeId) {
const a = z.anzeigen.find((x) => x.id === anzeigeId);
if (!a) return;
bestaetigen({
titel: "Anzeige bestätigen",
inhalt: `
<p style="margin:0 0 14px;font-size:13px;line-height:1.6">
<b>${sicher((a.b_vorname || "") + " " + (a.b_nachname || ""))}</b><br>
${sicher((a.delikte || []).map((d) => d.name).join(", "))}
</p>
<div class="folgen">
<div class="folgen-titel">Mit deiner Bestätigung geschieht Folgendes:</div>
<div class="folge">
<span class="folge-symbol">💶</span>
<div>Die Geldstrafe von <b>${euro(a.geld_final)}</b> wird eingezogen und
der Staatskasse zugeführt.</div>
</div>
<div class="folge">
<span class="folge-symbol">🔒</span>
<div>${a.haft_final > 0
? `Die festgelegte Haftdauer von <b>${haftText(a.haft_final)}</b> wird
<b>vermerkt</b> — eine Einweisung in Gewahrsam erfolgt dadurch <b>nicht</b>.
Das ist ein eigener Schritt.`
: "Es ist keine Haft vorgesehen."}</div>
</div>
<div class="folge">
<span class="folge-symbol">📄</span>
<div>Der Vorgang wird mit deinem Namen als bestätigend festgehalten.</div>
</div>
</div>
${a.abweichung ? `
<div class="abweichung" style="margin-top:12px">
<div class="abweichung-kopf">⚠ Abweichung vom Katalogvorschlag</div>
<div style="font-size:12px;margin-top:6px;line-height:1.5">
Vorschlag war ${haftText(a.haft_empfohlen)} · ${euro(a.geld_empfohlen)}.
${a.begruendung ? `Begründung: „${sicher(a.begruendung)}"` : "Keine Begründung hinterlegt."}
</div>
</div>` : ""}`,
bestaetigenText: "Bestätigen",
bestaetigt: async (_, zu) => {
try {
const ergebnis = await aufruf("anzeige", "bestaetigen", { id: anzeigeId });
zu();
meldung(ergebnis.eingezogen > 0
? "Bestätigt · " + euro(ergebnis.eingezogen) + " eingezogen."
: "Bestätigt.", "gut");
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
},
});
}
/* =====================================================================
KATALOGPFLEGE
===================================================================== */
function katalogAnsicht(behaelter) {
behaelter.innerHTML = `
<div class="seiten-kopf">
<div><h2>Strafenkatalog pflegen</h2>
<p>Die Werte hier sind <b>Empfehlungen</b> — sie binden niemanden.</p></div>
<button class="knopf klein" id="kat-neu">+ Eintrag</button>
</div>
${reiterLeiste()}
<div class="karte">
${z.katalog.map((gruppe) => `
<div class="katalog-gruppe">
<div class="katalog-titel">${sicher(gruppe.kategorie)}</div>
<div class="liste">${gruppe.delikte.map((d) => `
<div class="liste-zeile">
<span class="marker neutral">${sicher(d.kennung)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(d.bezeichnung)}</div>
<div class="lz-unter">${d.paragraph ? sicher(d.paragraph) + " · " : ""}
${haftText(d.haft)} · ${euro(d.geld)}
${d.hinweis ? " · " + sicher(d.hinweis) : ""}</div>
</div>
<button class="knopf leise klein" data-kat-bearbeiten="${sicher(d.kennung)}">Ändern</button>
<button class="knopf leise klein" data-kat-weg="${sicher(d.kennung)}">Deaktivieren</button>
</div>`).join("")}</div>
</div>`).join("")}
</div>`;
$("kat-neu").addEventListener("click", () => katalogDialog(behaelter, null));
behaelter.querySelectorAll("[data-kat-bearbeiten]").forEach((b) =>
b.addEventListener("click", () => {
const kennung = b.dataset.katBearbeiten;
let eintrag = null;
z.katalog.forEach((g) => g.delikte.forEach((d) => { if (d.kennung === kennung) eintrag = d; }));
katalogDialog(behaelter, eintrag);
}));
behaelter.querySelectorAll("[data-kat-weg]").forEach((b) =>
b.addEventListener("click", () => {
bestaetigen({
titel: "Eintrag deaktivieren",
inhalt: `<p style="margin:0;font-size:13px;line-height:1.6">
Der Eintrag verschwindet aus der Auswahl, bleibt aber in bestehenden Anzeigen
nachvollziehbar. Gelöscht wird nichts.</p>`,
bestaetigenText: "Deaktivieren",
bestaetigt: async (_, zu) => {
try {
await aufruf("anzeige", "katalogDeaktivieren", { kennung: b.dataset.katWeg });
zu(); meldung("Eintrag deaktiviert."); rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
},
});
}));
}
function katalogDialog(behaelter, eintrag) {
const e = eintrag || {};
fenster({
titel: eintrag ? "Eintrag ändern" : "Neuer Katalogeintrag",
inhalt: `
<div class="raster k2" style="gap:10px">
<div class="feld" style="margin:0"><label>Kennung</label>
<input id="kd-kennung" value="${sicher(e.kennung || "")}"
${eintrag ? "readonly" : ""} placeholder="z. B. v6"></div>
<div class="feld" style="margin:0"><label>Kategorie</label>
<input id="kd-kategorie" value="${sicher(e.kategorie || "")}" placeholder="z. B. Verkehr"></div>
</div>
<div class="feld"><label>Bezeichnung</label>
<input id="kd-bezeichnung" value="${sicher(e.bezeichnung || "")}"></div>
<div class="feld"><label>Gesetzliche Grundlage</label>
<input id="kd-paragraph" value="${sicher(e.paragraph || "")}" placeholder="z. B. § 242 StGB"></div>
<div class="raster k2" style="gap:10px">
<div class="feld" style="margin:0"><label>Empfohlene Haft (Minuten)</label>
<input id="kd-haft" type="number" min="0" max="1440" value="${e.haft || 0}"></div>
<div class="feld" style="margin:0"><label>Empfohlene Geldstrafe (€)</label>
<input id="kd-geld" type="number" min="0" value="${e.geld || 0}"></div>
</div>
<div class="feld"><label>Hinweis für Beamte</label>
<textarea id="kd-hinweis" placeholder="Worauf ist zu achten?">${sicher(e.hinweis || "")}</textarea></div>`,
knoepfe: [
{ text: "Abbrechen", klick: (_, zu) => zu() },
{ text: "Speichern", klick: async (el, zu) => {
try {
await aufruf("anzeige", "katalogSpeichern", {
kennung: el.querySelector("#kd-kennung").value.trim(),
kategorie: el.querySelector("#kd-kategorie").value.trim(),
bezeichnung: el.querySelector("#kd-bezeichnung").value.trim(),
paragraph: el.querySelector("#kd-paragraph").value.trim(),
haft: +el.querySelector("#kd-haft").value,
geld: +el.querySelector("#kd-geld").value,
hinweis: el.querySelector("#kd-hinweis").value.trim(),
});
zu(); meldung("Gespeichert.", "gut"); rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
}},
],
});
}
/* ---- Laden -------------------------------------------------------- */
async function rendern(behaelter) {
try {
const k = await aufruf("anzeige", "katalog", {});
z.katalog = k.katalog;
z.rechte = k.rechte;
if (z.reiter === "vorgaenge") {
const v = await aufruf("anzeige", "liste", { status: z.filterStatus });
z.anzeigen = v.anzeigen;
z.rechte = Object.assign({}, z.rechte, v.rechte);
vorgaengeAnsicht(behaelter);
} else if (z.reiter === "katalog") {
katalogAnsicht(behaelter);
} else {
erfassenAnsicht(behaelter);
}
behaelter.querySelectorAll("[data-reiter]").forEach((b) =>
b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); }));
} catch (f) {
behaelter.innerHTML = `<div class="seiten-kopf"><div><h2>Anzeigen</h2>
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
}
}
App.registriereAnsicht("anzeige", { rendern });
})();