/* ===================================================================== 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 = `
Lade …
`; 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();