- 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>
537 lines
18 KiB
JavaScript
537 lines
18 KiB
JavaScript
/* =====================================================================
|
||
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();
|