/* =====================================================================
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) =>
``
).join("");
hg.innerHTML =
`
${sicher(o.titel || "")}
${o.inhalt || ""}
${knoepfe ? `
${knoepfe}
` : ""}
`;
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 || ""}
ℹ
Diese Entscheidung triffst du. Das System hat den Vorgang nur
vorbereitet — ausgeführt wird er erst mit deiner Bestätigung.
`,
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) =>
``
).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 = ``;
const ansicht = ansichten[modulId];
if (!ansicht) {
inhalt.innerHTML =
`Noch nicht verfügbar
Für diesen Bereich ist noch keine Ansicht hinterlegt.
`;
return;
}
try {
await ansicht.rendern(inhalt);
} catch (fehler) {
inhalt.innerHTML =
`Fehler
${sicher(fehler.message || "Der Bereich konnte nicht geladen werden.")}
`;
}
}
/* ---- 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) =>
``
).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) =>
`
${symbole[h.kategorie] || "•"} ${sicher(h.titel)}
${zeit(h.erstellt_am)}
${h.text ? `
${sicher(h.text)}
` : ""}
`
).join("")
: `Keine Mitteilungen.
`;
$("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: `
Deine Dienstzeit wird protokolliert. Laufende Vorgänge bleiben bestehen und
können von anderen Beamten weitergeführt werden.
`,
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
? `${treffer.map((t) =>
`
${sicher(t.bereich)}
${sicher(t.titel)}
${sicher(t.unter || "")}
`).join("")}
`
: `Keine Treffer.
`,
});
}
/* ---- Ö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();