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

537 lines
18 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 — Anwendungskern der Oberfläche
Stellt bereit:
* NUI-Brücke mit Aufruf/Antwort-Zuordnung
* Modul-Registry (Ansichten registrieren sich selbst)
* Fenster-, Meldungs- und Mitteilungssystem
* Navigation und Grundzustand
Fachansichten liegen in js/module/*.js und registrieren sich über
`App.registriereAnsicht(...)`. Der Kern kennt keine Fachlogik.
===================================================================== */
"use strict";
const App = (() => {
/* ---- Zustand ---------------------------------------------------- */
const zustand = {
imDienst: false,
beamter: null,
module: [],
aktivesModul: null,
modulZiel: null,
dienststatus: [],
abteilungen: [],
beamteImDienst: [],
hinweise: [],
};
/* Registrierte Ansichten: [modulId] = { rendern, aktualisieren? } */
const ansichten = {};
/* Offene Aufrufe: [ruf] = { aufloesen, ablehnen } */
const offeneAufrufe = {};
let rufZaehler = 0;
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-police";
/* ---- Hilfsfunktionen -------------------------------------------- */
const $ = (id) => document.getElementById(id);
/** Schützt Text vor HTML-Interpretation */
function sicher(text) {
const d = document.createElement("div");
d.textContent = text == null ? "" : String(text);
return d.innerHTML;
}
/** Formatiert einen Zeitstempel der Datenbank als Datum + Uhrzeit */
function zeit(wert) {
if (!wert) return "—";
const text = String(wert).replace("T", " ");
return text.slice(8, 10) + "." + text.slice(5, 7) + ". " + text.slice(11, 16);
}
/** Formatiert eine Dauer in Sekunden als „2 Std 14 Min" */
function dauer(sekunden) {
const s = Math.max(0, Math.floor(sekunden || 0));
const std = Math.floor(s / 3600);
const min = Math.floor((s % 3600) / 60);
return (std > 0 ? std + " Std " : "") + min + " Min";
}
/** Formatiert einen Betrag als Euro */
function euro(betrag) {
return (Math.round(betrag || 0)).toLocaleString("de-DE") + " €";
}
/* ---- NUI-Brücke -------------------------------------------------- */
/** Schickt eine Nachricht an den Client */
function anClient(pfad, daten) {
if (!IST_FIVEM) return Promise.resolve();
return fetch(`https://${RESSOURCE}/${pfad}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/**
* Ruft eine Modul-Aktion auf dem Server auf.
* Liefert ein Promise, das mit dem Ergebnis aufgelöst wird.
*/
function aufruf(modul, aktion, daten) {
if (!IST_FIVEM) return Promise.reject(new Error("Keine Serververbindung"));
return new Promise((aufloesen, ablehnen) => {
const ruf = ++rufZaehler;
offeneAufrufe[ruf] = { aufloesen, ablehnen };
// Sicherheitsnetz, damit ein Aufruf nicht ewig hängen bleibt
setTimeout(() => {
if (offeneAufrufe[ruf]) {
delete offeneAufrufe[ruf];
ablehnen(new Error("Zeitüberschreitung"));
}
}, 15000);
anClient("aufruf", { modul, aktion, daten: daten || {}, ruf });
});
}
/* ---- Meldungen --------------------------------------------------- */
function meldung(text, art) {
const el = document.createElement("div");
el.className = "meldung" + (art ? " " + art : "");
el.textContent = text;
$("meldungen").appendChild(el);
setTimeout(() => el.remove(), 3200);
}
/* ---- Fenster (Dialoge) ------------------------------------------ */
/**
* Öffnet ein Dialogfenster.
* @param {object} o { titel, inhalt, knoepfe: [{text, art?, klick}] }
*/
function fenster(o) {
const ebene = $("fenster-ebene");
const hg = document.createElement("div");
hg.className = "fenster-hg";
const knoepfe = (o.knoepfe || []).map((k, i) =>
`<button class="knopf ${k.art || "leise"}" data-k="${i}">${sicher(k.text)}</button>`
).join("");
hg.innerHTML =
`<div class="fenster">
<div class="fenster-kopf">
<h3>${sicher(o.titel || "")}</h3>
<button class="knopf leise klein" data-zu="1">✕</button>
</div>
<div class="fenster-koerper">${o.inhalt || ""}</div>
${knoepfe ? `<div class="fenster-fuss">${knoepfe}</div>` : ""}
</div>`;
ebene.appendChild(hg);
const schliessen = () => hg.remove();
hg.querySelector("[data-zu]").addEventListener("click", schliessen);
hg.addEventListener("click", (e) => { if (e.target === hg) schliessen(); });
hg.querySelectorAll("[data-k]").forEach((b) => {
b.addEventListener("click", () => {
const eintrag = o.knoepfe[+b.dataset.k];
// Der Rückruf entscheidet selbst, ob das Fenster zugeht
if (eintrag.klick) eintrag.klick(hg, schliessen);
else schliessen();
});
});
if (o.nachOeffnen) o.nachOeffnen(hg);
return { element: hg, schliessen };
}
/**
* Fenster mit Bestätigungspflicht.
* Für jede Maßnahme, die ein Beamter selbst verantworten muss.
*/
function bestaetigen(o) {
return fenster({
titel: o.titel,
inhalt: `${o.inhalt || ""}
<div class="grundsatz">
<span>ℹ</span>
<div><b>Diese Entscheidung triffst du.</b> Das System hat den Vorgang nur
vorbereitet — ausgeführt wird er erst mit deiner Bestätigung.</div>
</div>`,
knoepfe: [
{ text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
{ text: o.bestaetigenText || "Bestätigen", art: o.gefahr ? "gefahr" : "",
klick: (el, zu) => { if (o.bestaetigt) o.bestaetigt(el, zu); } },
],
nachOeffnen: o.nachOeffnen,
});
}
/* ---- Navigation -------------------------------------------------- */
function zeichneNavigation() {
const nav = $("navigation");
nav.innerHTML = zustand.module.map((m) =>
`<button data-modul="${sicher(m.id)}" class="${m.id === zustand.aktivesModul ? "aktiv" : ""}">
<span class="nav-symbol">${sicher(m.icon)}</span>
<span>${sicher(m.name)}</span>
</button>`
).join("");
nav.querySelectorAll("[data-modul]").forEach((b) => {
b.addEventListener("click", () => oeffneModul(b.dataset.modul));
});
}
/**
* Öffnet ein Modul und lässt seine Ansicht zeichnen.
* @param {string} modulId Modul
* @param {object} ziel Optionaler Sprungbefehl, z. B. { person: 42 }.
* Die Ansicht liest ihn über App.zustand.modulZiel und räumt ihn auf.
*/
async function oeffneModul(modulId, ziel) {
zustand.aktivesModul = modulId;
zustand.modulZiel = ziel || null;
zeichneNavigation();
const inhalt = $("inhalt");
inhalt.innerHTML = `<div class="laden"><div class="spinner"></div><span>Lade …</span></div>`;
const ansicht = ansichten[modulId];
if (!ansicht) {
inhalt.innerHTML =
`<div class="seiten-kopf"><div><h2>Noch nicht verfügbar</h2>
<p>Für diesen Bereich ist noch keine Ansicht hinterlegt.</p></div></div>`;
return;
}
try {
await ansicht.rendern(inhalt);
} catch (fehler) {
inhalt.innerHTML =
`<div class="seiten-kopf"><div><h2>Fehler</h2>
<p>${sicher(fehler.message || "Der Bereich konnte nicht geladen werden.")}</p></div></div>`;
}
}
/* ---- Dienststatus ------------------------------------------------ */
function zeichneStatus() {
const aktueller = zustand.dienststatus.find(
(s) => s.id === (zustand.beamter && zustand.beamter.status)
) || zustand.dienststatus[0];
if (aktueller) {
$("status-text").textContent = aktueller.name;
$("status-punkt").style.background = aktueller.farbe;
}
$("status-liste").innerHTML = zustand.dienststatus.map((s) =>
`<button data-status="${sicher(s.id)}">
<span class="status-punkt" style="background:${sicher(s.farbe)}"></span>
${sicher(s.name)}
</button>`
).join("");
$("status-liste").querySelectorAll("[data-status]").forEach((b) => {
b.addEventListener("click", () => {
const neu = b.dataset.status;
if (zustand.beamter) zustand.beamter.status = neu;
anClient("status", { status: neu });
$("status-liste").hidden = true;
zeichneStatus();
});
});
}
/* ---- Mitteilungen ------------------------------------------------ */
function zeichneHinweise() {
const ungelesen = zustand.hinweise.filter((h) => !h.gelesen).length;
const zaehler = $("hinweise-zaehler");
zaehler.hidden = ungelesen === 0;
zaehler.textContent = ungelesen > 99 ? "99+" : ungelesen;
const symbole = {
einsatz: "🚨", fahndung: "🔍", gewahrsam: "🔒",
bericht: "📄", akte: "🗂", beweis: "🔬", system: "ℹ",
};
$("hinweise-inhalt").innerHTML = zustand.hinweise.length
? zustand.hinweise.map((h) =>
`<div class="hinweis-eintrag ${h.gelesen ? "" : "ungelesen"}" data-hinweis="${h.id || ""}">
<div class="he-kopf">
<span class="he-titel">${symbole[h.kategorie] || "•"} ${sicher(h.titel)}</span>
<span class="he-zeit">${zeit(h.erstellt_am)}</span>
</div>
${h.text ? `<div class="he-text">${sicher(h.text)}</div>` : ""}
</div>`
).join("")
: `<div class="liste-leer">Keine Mitteilungen.</div>`;
$("hinweise-inhalt").querySelectorAll("[data-hinweis]").forEach((el) => {
el.addEventListener("click", () => {
const id = +el.dataset.hinweis;
if (!id) return;
const treffer = zustand.hinweise.find((h) => h.id === id);
if (treffer) treffer.gelesen = 1;
aufruf("dashboard", "hinweisGelesen", { id }).catch(() => {});
zeichneHinweise();
if (treffer) springeZuHinweis(treffer);
});
});
}
/**
* Öffnet das Modul, zu dem ein Hinweis gehört.
* Nur Ziele, die eine Ansicht auch auswertet, werden mitgegeben —
* alles andere öffnet schlicht das passende Modul.
*/
function springeZuHinweis(hinweis) {
if (!hinweis.zielTyp) return;
// zielTyp → [Modul, Schlüssel des Sprungziels oder null]
const wege = {
akte: ["mdt", "akte"],
nachricht: ["kommunikation", "nachricht"],
aushang: ["kommunikation", "aushang"],
einsatz: ["einsatz", null],
fahndung: ["fahndung", null],
gewahrsam: ["gewahrsam", null],
bericht: ["bericht", null],
beweis: ["beweis", null],
};
const weg = wege[hinweis.zielTyp];
if (!weg) return;
// Nur öffnen, wenn das Modul für diesen Beamten überhaupt sichtbar ist
if (!zustand.module.some((m) => m.id === weg[0])) return;
const ziel = weg[1] && hinweis.zielId ? { [weg[1]]: +hinweis.zielId } : null;
oeffneModul(weg[0], ziel);
$("hinweise-schublade").hidden = true;
}
/** Nimmt einen neu eingetroffenen Hinweis auf */
function neuerHinweis(hinweis) {
zustand.hinweise.unshift(Object.assign({ erstellt_am: new Date().toISOString() }, hinweis));
if (zustand.hinweise.length > 50) zustand.hinweise.pop();
zeichneHinweise();
meldung(hinweis.titel, hinweis.prioritaet === "kritisch" ? "fehler" : "");
// Offene Ansicht auffrischen, falls sie das betrifft
const ansicht = ansichten[zustand.aktivesModul];
if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren();
}
/* ---- Aufbau bei Öffnen ------------------------------------------ */
function zeigeTablet(daten) {
zustand.imDienst = true;
zustand.beamter = daten.beamter;
zustand.module = daten.module || [];
zustand.dienststatus = daten.status || [];
zustand.abteilungen = daten.abteilungen || [];
$("anmeldung").hidden = true;
$("tablet").hidden = false;
$("tb-kuerzel").textContent = daten.kuerzel || "POL";
$("tb-behoerde").textContent = daten.behoerde || "Dienstsystem";
$("tb-name").textContent = daten.beamter.name;
$("tb-rolle").textContent = daten.beamter.rolleName +
(daten.beamter.leitstelle ? " · Leitstelle" : "");
$("tb-nummer").textContent = daten.beamter.dienstnummer;
zeichneNavigation();
zeichneStatus();
// Erstes Modul öffnen
if (zustand.module.length) oeffneModul(zustand.module[0].id);
}
function zeigeAnmeldung(daten) {
zustand.imDienst = false;
$("tablet").hidden = true;
$("anmeldung").hidden = false;
$("an-behoerde").textContent = daten.behoerde || "";
$("an-name").textContent = daten.name || "—";
$("an-nummer").textContent = daten.dienstnummer || "—";
$("an-rolle").textContent = daten.rolleName || "—";
$("an-abteilung").textContent = daten.abteilung || "—";
}
/* ---- Nachrichten vom Client ------------------------------------- */
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
switch (n.aktion) {
case "oeffnen":
document.body.style.display = "";
zeigeTablet(n);
break;
case "anmeldung":
document.body.style.display = "";
zeigeAnmeldung(n);
break;
case "antwort": {
const eintrag = offeneAufrufe[n.ruf];
if (!eintrag) break;
delete offeneAufrufe[n.ruf];
if (n.fehler) eintrag.ablehnen(new Error(n.fehler));
else eintrag.aufloesen(n.ergebnis);
break;
}
case "dienstliste":
zustand.beamteImDienst = n.beamte || [];
{
const ansicht = ansichten[zustand.aktivesModul];
if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren();
}
break;
case "hinweis":
neuerHinweis(n.hinweis || {});
break;
case "fehler":
meldung(n.text || "Aktion nicht möglich", "fehler");
break;
case "schliessen":
document.body.style.display = "none";
break;
}
});
/* ---- Bedienelemente der Shell ----------------------------------- */
function verdrahteShell() {
$("tablet-schliessen").addEventListener("click", () => anClient("schliessen", {}));
$("an-abbrechen").addEventListener("click", () => anClient("schliessen", {}));
$("an-antreten").addEventListener("click", () => anClient("dienstAntreten", {}));
$("dienst-beenden").addEventListener("click", () => {
bestaetigen({
titel: "Dienst beenden",
inhalt: `<p style="margin:0;font-size:13px;line-height:1.6">
Deine Dienstzeit wird protokolliert. Laufende Vorgänge bleiben bestehen und
können von anderen Beamten weitergeführt werden.</p>`,
bestaetigenText: "Dienst beenden",
bestaetigt: (_, zu) => { anClient("dienstBeenden", {}); zu(); },
});
});
$("status-knopf").addEventListener("click", () => {
$("status-liste").hidden = !$("status-liste").hidden;
});
$("hinweise-knopf").addEventListener("click", () => {
const schublade = $("hinweise-schublade");
schublade.hidden = !schublade.hidden;
if (!schublade.hidden) zeichneHinweise();
});
$("hinweise-zu").addEventListener("click", () => { $("hinweise-schublade").hidden = true; });
$("thema-wechsel").addEventListener("click", () => {
document.body.classList.toggle("thema-dunkel");
document.body.classList.toggle("thema-hell");
});
// Klick ausserhalb schliesst die Statusliste
document.addEventListener("click", (e) => {
if (!$("status-waehler").contains(e.target)) $("status-liste").hidden = true;
});
// ESC schliesst Fenster, sonst das Tablet
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const offeneFenster = $("fenster-ebene").lastElementChild;
if (offeneFenster) { offeneFenster.remove(); return; }
if (!$("hinweise-schublade").hidden) { $("hinweise-schublade").hidden = true; return; }
anClient("schliessen", {});
});
// Globale Suche (wird von den Fachmodulen bedient)
$("global-suche").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const begriff = e.target.value.trim();
if (begriff.length >= 2) globaleSuche(begriff);
}
});
}
/** Globale Suche über alle Module, die sich dafür anmelden */
const suchAnbieter = [];
function registriereSuche(funktion) { suchAnbieter.push(funktion); }
async function globaleSuche(begriff) {
if (!suchAnbieter.length) {
meldung("Es ist noch kein durchsuchbarer Bereich verfügbar.");
return;
}
const treffer = [];
for (const anbieter of suchAnbieter) {
try {
const ergebnis = await anbieter(begriff);
if (Array.isArray(ergebnis)) treffer.push(...ergebnis);
} catch (e) { /* einzelne Anbieter dürfen scheitern */ }
}
fenster({
titel: `Suche: „${begriff}"`,
inhalt: treffer.length
? `<div class="liste">${treffer.map((t) =>
`<div class="liste-zeile">
<span class="marker neutral">${sicher(t.bereich)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(t.titel)}</div>
<div class="lz-unter">${sicher(t.unter || "")}</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine Treffer.</div>`,
});
}
/* ---- Öffentliche Schnittstelle ---------------------------------- */
return {
/** Registriert eine Modulansicht */
registriereAnsicht(modulId, ansicht) { ansichten[modulId] = ansicht; },
/** Registriert einen Anbieter für die globale Suche */
registriereSuche,
zustand, aufruf, meldung, fenster, bestaetigen,
sicher, zeit, dauer, euro, $,
anClient, oeffneModul,
start() {
verdrahteShell();
if (IST_FIVEM) document.body.style.display = "none";
else {
// Eigenständige Browser-Ansicht: Anmeldebildschirm zeigen
zeigeAnmeldung({
behoerde: "Los Santos Police Department", name: "Vorschau",
dienstnummer: "LSPD-0000", rolleName: "Officer", abteilung: "streife",
});
}
},
};
})();
App.start();