- 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>
496 lines
19 KiB
JavaScript
496 lines
19 KiB
JavaScript
/* =====================================================================
|
||
aether-police / Ansicht: Administration
|
||
|
||
Module an- und abschalten, Strafenkatalog pflegen, Protokoll lesen.
|
||
Die Reiter richten sich nach den Rechten des Beamten — wer nur das
|
||
Protokoll lesen darf, sieht auch nur dieses.
|
||
===================================================================== */
|
||
"use strict";
|
||
|
||
(() => {
|
||
const { sicher, zeit, euro, aufruf, meldung, fenster, bestaetigen } = App;
|
||
|
||
const z = {
|
||
bereiche: null, // welche Reiter erlaubt sind
|
||
reiter: null,
|
||
daten: null,
|
||
katalogSuche: "",
|
||
katalogKategorie: "",
|
||
katalogStillgelegt: false,
|
||
protokollModul: "",
|
||
protokollNummer: "",
|
||
};
|
||
|
||
/* ---- Reiter ---------------------------------------------------------- */
|
||
|
||
function reiterleiste() {
|
||
const moeglich = [
|
||
{ id: "module", text: "Module", erlaubt: z.bereiche.module },
|
||
{ id: "katalog", text: "Strafenkatalog", erlaubt: z.bereiche.katalog },
|
||
{ id: "protokoll", text: "Protokoll", erlaubt: z.bereiche.protokoll },
|
||
].filter((e) => e.erlaubt);
|
||
|
||
return `<div class="mdt-reiter">
|
||
${moeglich.map((e) => `
|
||
<button class="${z.reiter === e.id ? "aktiv" : ""}" data-reiter="${e.id}">
|
||
${sicher(e.text)}</button>`).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
function verdrahteReiter(behaelter) {
|
||
behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
z.reiter = el.dataset.reiter;
|
||
rendern(behaelter);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ---- Module ----------------------------------------------------------- */
|
||
|
||
function moduleAnsicht(behaelter) {
|
||
const d = z.daten;
|
||
const aktiv = d.module.filter((m) => m.aktiv).length;
|
||
|
||
behaelter.innerHTML = `
|
||
<div class="seiten-kopf">
|
||
<div>
|
||
<h2>Module</h2>
|
||
<p>Jeder Baustein lässt sich einzeln abschalten. Abgeschaltete Module
|
||
verschwinden aus der Seitenleiste — ihre Daten bleiben unangetastet.</p>
|
||
</div>
|
||
</div>
|
||
|
||
${reiterleiste()}
|
||
|
||
<div class="raster raster-3">
|
||
<div class="kennzahl"><span class="kz-zahl">${aktiv}/${d.module.length}</span>
|
||
<span class="kz-text">Module aktiv</span></div>
|
||
<div class="kennzahl"><span class="kz-zahl">${d.beamteImDienst}</span>
|
||
<span class="kz-text">Beamte im Dienst</span></div>
|
||
<div class="kennzahl">
|
||
<span class="kz-zahl">${d.module.reduce((s, m) => s + m.aktionen, 0)}</span>
|
||
<span class="kz-text">registrierte Aktionen</span></div>
|
||
</div>
|
||
|
||
<div class="karte">
|
||
<div class="karte-kopf"><span class="karte-titel">Registrierte Module</span></div>
|
||
<div class="liste">
|
||
${d.module.map(modulZeile).join("")}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="karte">
|
||
<div class="karte-kopf">
|
||
<span class="karte-titel">Konfiguration</span>
|
||
<span class="feld-hinweis">Steht in config.lua — Änderung erfordert Neustart</span>
|
||
</div>
|
||
<div class="st-werte">
|
||
${d.konfiguration.map((k) =>
|
||
`<div><span>${sicher(k.name)}</span><b>${sicher(k.wert)}</b></div>`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
verdrahteModule(behaelter);
|
||
}
|
||
|
||
function modulZeile(m) {
|
||
return `<div class="liste-zeile adm-modul ${m.aktiv ? "" : "aus"}">
|
||
<span class="adm-icon">${sicher(m.icon)}</span>
|
||
<div class="lz-haupt">
|
||
<div class="lz-titel">${sicher(m.name)}</div>
|
||
<div class="lz-unter">
|
||
${sicher(m.beschreibung || "")}
|
||
${m.rechtName ? ` · Recht: ${sicher(m.rechtName)}`
|
||
: m.recht ? ` · Recht: ${sicher(String(m.recht))}` : " · für alle sichtbar"}
|
||
</div>
|
||
</div>
|
||
<span class="marker neutral">${m.aktionen} Aktionen</span>
|
||
${m.unverzichtbar
|
||
? `<span class="marker info" title="Ohne dieses Modul wäre das Tablet nicht mehr bedienbar">
|
||
unverzichtbar</span>`
|
||
: `<button class="knopf ${m.aktiv ? "leise" : ""} klein"
|
||
data-schalten="${sicher(m.id)}" data-wert="${m.aktiv ? "0" : "1"}">
|
||
${m.aktiv ? "Abschalten" : "Einschalten"}</button>`}
|
||
</div>`;
|
||
}
|
||
|
||
function verdrahteModule(behaelter) {
|
||
verdrahteReiter(behaelter);
|
||
|
||
behaelter.querySelectorAll("[data-schalten]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const id = el.dataset.schalten;
|
||
const einschalten = el.dataset.wert === "1";
|
||
const modul = z.daten.module.find((m) => m.id === id);
|
||
|
||
bestaetigen({
|
||
titel: einschalten ? "Modul einschalten" : "Modul abschalten",
|
||
gefahr: !einschalten,
|
||
inhalt: `<p><b>${sicher(modul.name)}</b> wird
|
||
${einschalten ? "wieder in der Seitenleiste erscheinen"
|
||
: "für alle Beamten aus der Seitenleiste verschwinden"}.</p>
|
||
${einschalten ? "" : `<p class="feld-hinweis">Vorhandene Daten bleiben erhalten
|
||
und sind nach dem Wiedereinschalten unverändert da. Beamte, die das Tablet
|
||
gerade offen haben, sehen die Änderung beim nächsten Öffnen.</p>`}
|
||
<div class="feld"><label>Grund (freiwillig)</label>
|
||
<input name="grund" placeholder="Warum wird das Modul geschaltet?"></div>`,
|
||
bestaetigenText: einschalten ? "Einschalten" : "Abschalten",
|
||
bestaetigt: async (dialog, zu) => {
|
||
try {
|
||
await aufruf("admin", "modulSchalten", {
|
||
id, aktiv: einschalten,
|
||
grund: dialog.querySelector("[name=grund]").value.trim(),
|
||
});
|
||
zu();
|
||
meldung(einschalten ? "Modul eingeschaltet." : "Modul abgeschaltet.", "gut");
|
||
rendern(behaelter);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
},
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ---- Strafenkatalog ---------------------------------------------------- */
|
||
|
||
function katalogAnsicht(behaelter) {
|
||
const d = z.daten;
|
||
|
||
// Delikte nach Kategorie gruppieren
|
||
const gruppen = {};
|
||
d.delikte.forEach((delikt) => {
|
||
(gruppen[delikt.kategorie] = gruppen[delikt.kategorie] || []).push(delikt);
|
||
});
|
||
|
||
behaelter.innerHTML = `
|
||
<div class="seiten-kopf">
|
||
<div>
|
||
<h2>Strafenkatalog</h2>
|
||
<p>Die Werte hier sind <b>Vorschläge</b>. Sie werden nie automatisch verhängt —
|
||
ein Beamter kann jederzeit begründet davon abweichen.</p>
|
||
</div>
|
||
<button class="knopf" data-neu="1">+ Delikt anlegen</button>
|
||
</div>
|
||
|
||
${reiterleiste()}
|
||
|
||
<div class="mdt-suchleiste">
|
||
<input class="feld" data-suche="1" placeholder="Kennung, Bezeichnung oder Paragraf…"
|
||
value="${sicher(z.katalogSuche)}">
|
||
<select class="feld" data-kategorie="1">
|
||
<option value="">Alle Kategorien</option>
|
||
${d.kategorien.map((k) =>
|
||
`<option value="${sicher(k.kategorie)}"
|
||
${k.kategorie === z.katalogKategorie ? "selected" : ""}>
|
||
${sicher(k.kategorie)}</option>`).join("")}
|
||
</select>
|
||
<button class="knopf ${z.katalogStillgelegt ? "" : "leise"}" data-stillgelegt="1">
|
||
${z.katalogStillgelegt ? "Auch stillgelegte" : "Nur aktive"}
|
||
</button>
|
||
</div>
|
||
|
||
${Object.keys(gruppen).length
|
||
? Object.keys(gruppen).map((kategorie) => `
|
||
<div class="karte">
|
||
<div class="karte-kopf">
|
||
<span class="karte-titel">${sicher(kategorie)}</span>
|
||
<span class="feld-hinweis">${gruppen[kategorie].length} Delikte</span>
|
||
</div>
|
||
<div class="liste">${gruppen[kategorie].map(deliktZeile).join("")}</div>
|
||
</div>`).join("")
|
||
: `<div class="liste-leer">Kein Delikt gefunden.</div>`}
|
||
`;
|
||
|
||
verdrahteKatalog(behaelter);
|
||
}
|
||
|
||
function deliktZeile(delikt) {
|
||
return `<div class="liste-zeile adm-delikt ${delikt.aktiv ? "" : "aus"}">
|
||
<span class="pv-nummer">${sicher(delikt.kennung)}</span>
|
||
<div class="lz-haupt">
|
||
<div class="lz-titel">${sicher(delikt.bezeichnung)}</div>
|
||
<div class="lz-unter">
|
||
${sicher(delikt.paragraph || "ohne Paragraf")}
|
||
${delikt.hinweis ? " · " + sicher(delikt.hinweis) : ""}
|
||
</div>
|
||
</div>
|
||
<span class="adm-vorschlag" title="Vorgeschlagene Haftzeit">
|
||
${delikt.haft || 0} Min</span>
|
||
<span class="adm-vorschlag" title="Vorgeschlagene Geldstrafe">
|
||
${euro(delikt.geld || 0)}</span>
|
||
${delikt.aktiv ? "" : `<span class="marker neutral">stillgelegt</span>`}
|
||
<button class="knopf leise klein" data-bearbeiten="${delikt.id}">Bearbeiten</button>
|
||
<button class="knopf ${delikt.aktiv ? "leise" : ""} klein"
|
||
data-stilllegen="${delikt.id}" data-wert="${delikt.aktiv ? "0" : "1"}">
|
||
${delikt.aktiv ? "Stilllegen" : "Freigeben"}</button>
|
||
</div>`;
|
||
}
|
||
|
||
function verdrahteKatalog(behaelter) {
|
||
verdrahteReiter(behaelter);
|
||
|
||
const suchfeld = behaelter.querySelector("[data-suche]");
|
||
let warten = null;
|
||
suchfeld.addEventListener("input", () => {
|
||
clearTimeout(warten);
|
||
warten = setTimeout(() => {
|
||
z.katalogSuche = suchfeld.value.trim();
|
||
rendern(behaelter);
|
||
}, 260);
|
||
});
|
||
|
||
behaelter.querySelector("[data-kategorie]").addEventListener("change", (e) => {
|
||
z.katalogKategorie = e.target.value;
|
||
rendern(behaelter);
|
||
});
|
||
|
||
behaelter.querySelector("[data-stillgelegt]").addEventListener("click", () => {
|
||
z.katalogStillgelegt = !z.katalogStillgelegt;
|
||
rendern(behaelter);
|
||
});
|
||
|
||
behaelter.querySelector("[data-neu]").addEventListener("click", () =>
|
||
deliktFenster(behaelter, null));
|
||
|
||
behaelter.querySelectorAll("[data-bearbeiten]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const delikt = z.daten.delikte.find((d) => d.id === +el.dataset.bearbeiten);
|
||
deliktFenster(behaelter, delikt);
|
||
});
|
||
});
|
||
|
||
behaelter.querySelectorAll("[data-stilllegen]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const delikt = z.daten.delikte.find((d) => d.id === +el.dataset.stilllegen);
|
||
const freigeben = el.dataset.wert === "1";
|
||
|
||
bestaetigen({
|
||
titel: freigeben ? "Delikt freigeben" : "Delikt stilllegen",
|
||
gefahr: !freigeben,
|
||
inhalt: `<p><b>${sicher(delikt.kennung)} — ${sicher(delikt.bezeichnung)}</b></p>
|
||
<p>${freigeben
|
||
? "Das Delikt steht wieder zur Auswahl."
|
||
: "Das Delikt lässt sich für neue Anzeigen nicht mehr auswählen."}</p>
|
||
<p class="feld-hinweis">Gelöscht wird nichts — bestehende Anzeigen behalten
|
||
ihren Eintrag und bleiben nachvollziehbar.</p>
|
||
<div class="feld"><label>Grund (freiwillig)</label>
|
||
<input name="grund" placeholder="Warum?"></div>`,
|
||
bestaetigenText: freigeben ? "Freigeben" : "Stilllegen",
|
||
bestaetigt: async (dialog, zu) => {
|
||
try {
|
||
await aufruf("admin", "katalogSchalten", {
|
||
id: delikt.id, aktiv: freigeben,
|
||
grund: dialog.querySelector("[name=grund]").value.trim(),
|
||
});
|
||
zu();
|
||
meldung(freigeben ? "Delikt freigegeben." : "Delikt stillgelegt.", "gut");
|
||
rendern(behaelter);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
},
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function deliktFenster(behaelter, delikt) {
|
||
const neu = !delikt;
|
||
delikt = delikt || { kennung: "", kategorie: "", bezeichnung: "", paragraph: "",
|
||
haft: 0, geld: 0, hinweis: "", sortierung: 0 };
|
||
|
||
fenster({
|
||
titel: neu ? "Delikt anlegen" : "Delikt bearbeiten",
|
||
inhalt: `
|
||
<div class="adm-formular">
|
||
<div class="feld"><label>Kennung</label>
|
||
<input name="kennung" maxlength="24" value="${sicher(delikt.kennung)}"
|
||
placeholder="z. B. STGB-242"></div>
|
||
<div class="feld"><label>Kategorie</label>
|
||
<input name="kategorie" maxlength="48" value="${sicher(delikt.kategorie)}"
|
||
list="adm-kategorien" placeholder="z. B. Eigentumsdelikte">
|
||
<datalist id="adm-kategorien">
|
||
${z.daten.kategorien.map((k) =>
|
||
`<option value="${sicher(k.kategorie)}">`).join("")}
|
||
</datalist></div>
|
||
</div>
|
||
<div class="feld"><label>Bezeichnung</label>
|
||
<input name="bezeichnung" maxlength="160" value="${sicher(delikt.bezeichnung)}"
|
||
placeholder="Klartext des Delikts"></div>
|
||
<div class="feld"><label>Paragraf</label>
|
||
<input name="paragraph" maxlength="48" value="${sicher(delikt.paragraph || "")}"
|
||
placeholder="z. B. § 242 StGB"></div>
|
||
|
||
<div class="adm-formular">
|
||
<div class="feld"><label>Vorgeschlagene Haft (Minuten)</label>
|
||
<input name="haft" type="number" min="0" value="${delikt.haft || 0}"></div>
|
||
<div class="feld"><label>Vorgeschlagene Geldstrafe</label>
|
||
<input name="geld" type="number" min="0" value="${delikt.geld || 0}"></div>
|
||
</div>
|
||
|
||
<div class="feld"><label>Hinweis für den Beamten</label>
|
||
<input name="hinweis" maxlength="255" value="${sicher(delikt.hinweis || "")}"
|
||
placeholder="z. B. bei Wiederholung höher ansetzen"></div>
|
||
<div class="feld"><label>Sortierung</label>
|
||
<input name="sortierung" type="number" value="${delikt.sortierung || 0}"></div>
|
||
|
||
<div class="grundsatz">
|
||
<span>ℹ</span>
|
||
<div>Diese Werte sind <b>Vorschläge</b>. Das System verhängt sie nie —
|
||
es rechnet sie nur zusammen und legt sie dem Beamten vor.</div>
|
||
</div>`,
|
||
knoepfe: [
|
||
{ text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
|
||
{ text: neu ? "Anlegen" : "Speichern", klick: async (el, zu) => {
|
||
const feld = (name) => el.querySelector(`[name=${name}]`).value.trim();
|
||
try {
|
||
await aufruf("admin", "katalogSpeichern", {
|
||
id: neu ? null : delikt.id,
|
||
kennung: feld("kennung"),
|
||
kategorie: feld("kategorie"),
|
||
bezeichnung: feld("bezeichnung"),
|
||
paragraph: feld("paragraph"),
|
||
haft: +feld("haft"),
|
||
geld: +feld("geld"),
|
||
hinweis: feld("hinweis"),
|
||
sortierung: +feld("sortierung"),
|
||
});
|
||
zu();
|
||
meldung(neu ? "Delikt angelegt." : "Delikt gespeichert.", "gut");
|
||
rendern(behaelter);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
} },
|
||
],
|
||
});
|
||
}
|
||
|
||
/* ---- Protokoll ---------------------------------------------------------- */
|
||
|
||
function protokollAnsicht(behaelter) {
|
||
const d = z.daten;
|
||
|
||
behaelter.innerHTML = `
|
||
<div class="seiten-kopf">
|
||
<div>
|
||
<h2>Protokoll</h2>
|
||
<p>Jede Entscheidung im System steht hier mit Beamtem, Zeit und Begründung.
|
||
Einträge lassen sich weder ändern noch entfernen.</p>
|
||
</div>
|
||
<span class="marker neutral">${d.gesamt.toLocaleString("de-DE")} Einträge gesamt</span>
|
||
</div>
|
||
|
||
${reiterleiste()}
|
||
|
||
<div class="mdt-suchleiste">
|
||
<select class="feld" data-modul="1">
|
||
<option value="">Alle Module</option>
|
||
${d.module.map((m) =>
|
||
`<option value="${sicher(m.modul)}" ${m.modul === z.protokollModul ? "selected" : ""}>
|
||
${sicher(m.modul)}</option>`).join("")}
|
||
</select>
|
||
<input class="feld" data-nummer="1" placeholder="Dienstnummer, z. B. LSPD-0001"
|
||
value="${sicher(z.protokollNummer)}">
|
||
<button class="knopf leise" data-zuruecksetzen="1">Filter zurücksetzen</button>
|
||
</div>
|
||
|
||
<div class="liste">
|
||
${d.eintraege.length
|
||
? d.eintraege.map(protokollZeile).join("")
|
||
: `<div class="liste-leer">${d.unbekannteNummer
|
||
? "Diese Dienstnummer gibt es nicht."
|
||
: "Kein Eintrag gefunden."}</div>`}
|
||
</div>
|
||
`;
|
||
|
||
verdrahteProtokoll(behaelter);
|
||
}
|
||
|
||
function protokollZeile(e) {
|
||
// Details als kurze Schlüssel-Wert-Kette
|
||
const details = e.details && typeof e.details === "object"
|
||
? Object.keys(e.details).map((k) =>
|
||
`${sicher(k)}: ${sicher(String(e.details[k]))}`).join(" · ")
|
||
: "";
|
||
|
||
return `<div class="liste-zeile adm-protokoll">
|
||
<span class="marker neutral">${sicher(e.modul)}</span>
|
||
<div class="lz-haupt">
|
||
<div class="lz-titel">${sicher(e.aktion)}</div>
|
||
<div class="lz-unter">
|
||
${sicher(e.name)}${e.dienstnummer ? " (" + sicher(e.dienstnummer) + ")" : ""}
|
||
${e.ziel_typ ? ` · ${sicher(e.ziel_typ)} ${sicher(e.ziel_id || "")}` : ""}
|
||
${details ? ` · ${details}` : ""}
|
||
</div>
|
||
${e.grund ? `<div class="adm-grund">„${sicher(e.grund)}"</div>` : ""}
|
||
</div>
|
||
<span class="komm-zeit">${zeit(e.am || e.erstellt_am)}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function verdrahteProtokoll(behaelter) {
|
||
verdrahteReiter(behaelter);
|
||
|
||
behaelter.querySelector("[data-modul]").addEventListener("change", (e) => {
|
||
z.protokollModul = e.target.value;
|
||
rendern(behaelter);
|
||
});
|
||
|
||
const nummer = behaelter.querySelector("[data-nummer]");
|
||
let warten = null;
|
||
nummer.addEventListener("input", () => {
|
||
clearTimeout(warten);
|
||
warten = setTimeout(() => {
|
||
z.protokollNummer = nummer.value.trim();
|
||
rendern(behaelter);
|
||
}, 320);
|
||
});
|
||
|
||
behaelter.querySelector("[data-zuruecksetzen]").addEventListener("click", () => {
|
||
z.protokollModul = "";
|
||
z.protokollNummer = "";
|
||
rendern(behaelter);
|
||
});
|
||
}
|
||
|
||
/* ---- Laden -------------------------------------------------------------- */
|
||
|
||
async function rendern(behaelter) {
|
||
try {
|
||
// Erlaubte Bereiche einmal holen und den Startreiter danach wählen
|
||
if (!z.bereiche) {
|
||
z.bereiche = await aufruf("admin", "bereiche", {});
|
||
z.reiter = z.bereiche.module ? "module"
|
||
: z.bereiche.katalog ? "katalog"
|
||
: z.bereiche.protokoll ? "protokoll" : null;
|
||
}
|
||
|
||
if (!z.reiter) {
|
||
behaelter.innerHTML = `<div class="seiten-kopf"><div><h2>Administration</h2>
|
||
<p>Für keinen Bereich der Administration hast du eine Berechtigung.</p></div></div>`;
|
||
return;
|
||
}
|
||
|
||
if (z.reiter === "katalog") {
|
||
z.daten = await aufruf("admin", "katalog", {
|
||
begriff: z.katalogSuche, kategorie: z.katalogKategorie,
|
||
stillgelegt: z.katalogStillgelegt,
|
||
});
|
||
katalogAnsicht(behaelter);
|
||
} else if (z.reiter === "protokoll") {
|
||
z.daten = await aufruf("admin", "protokoll", {
|
||
modul: z.protokollModul, dienstnummer: z.protokollNummer, limit: 200,
|
||
});
|
||
protokollAnsicht(behaelter);
|
||
} else {
|
||
z.daten = await aufruf("admin", "module", {});
|
||
moduleAnsicht(behaelter);
|
||
}
|
||
} catch (f) {
|
||
behaelter.innerHTML = `<div class="seiten-kopf"><div><h2>Administration</h2>
|
||
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
|
||
}
|
||
}
|
||
|
||
App.registriereAnsicht("admin", { rendern });
|
||
})();
|