Alle Spieler werden benachrichtigt.
+ ${sicher(p.job)}${p.fraktion ? " · " + sicher(p.fraktion) : ""} · ${sicher(p.region)} · ${d.spielzeit || 0} Min · Gruppe ${sicher(d.gruppe)}
+ Keine Projekte.
';
+ const act = async (a, id) => { try { await A("staat", a, { id }); toast("Erledigt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } };
+ main.querySelectorAll("[data-end]").forEach((b) => b.addEventListener("click", () => act("beenden", +b.dataset.end)));
+ main.querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => act("aktivieren", +b.dataset.act)));
+ main.querySelectorAll("[data-arch]").forEach((b) => b.addEventListener("click", () => act("archivieren", +b.dataset.arch)));
+ });
+
+ /* ===== RESSOURCEN ============================================ */
+ App.registriere("ressourcen", async (main) => {
+ const d = await A("ressourcen", "laden");
+ main.innerHTML = kopf("Ressourcen", `${d.ressourcen.filter((r) => r.status === "started").length}/${d.ressourcen.length} aktiv`,
+ darf("ressourcen.neustart") ? '}
+ */
+ function aufruf(modul, aktion, daten) {
+ return new Promise((aufloesen, ablehnen) => {
+ if (!IST_FIVEM) {
+ // Browser-Vorschau: es gibt keinen Server zum Fragen
+ ablehnen(new Error("Vorschaumodus — keine Verbindung zum Server."));
+ return;
+ }
+
+ const ruf = ++rufZaehler;
+ offeneRufe[ruf] = { aufloesen, ablehnen };
+
+ anClient("aufruf", { modul, aktion, daten: daten || {}, ruf });
+
+ // Nicht ewig warten, falls eine Antwort ausbleibt
+ setTimeout(() => {
+ if (offeneRufe[ruf]) {
+ delete offeneRufe[ruf];
+ ablehnen(new Error("Zeitüberschreitung — der Server hat nicht geantwortet."));
+ }
+ }, 15000);
+ });
+ }
+
+ /* ---- Kurzmeldungen ------------------------------------------------ */
+
+ /**
+ * Zeigt eine kurze Meldung.
+ * @param {string} text Inhalt
+ * @param {string} art "gut" | "fehler" | ""
+ */
+ function meldung(text, art) {
+ const el = document.createElement("div");
+ el.className = "meldung " + (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}], nachOeffnen? }
+ */
+ function fenster(o) {
+ const ebene = $("fenster-ebene");
+ const hg = document.createElement("div");
+ hg.className = "fenster-hg";
+
+ const knoepfe = (o.knoepfe || []).map((k, i) =>
+ `${sicher(k.text)} `
+ ).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 Mediziner 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 — dokumentiert 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) =>
+ `
+ ${sicher(m.icon)}
+ ${sicher(m.name)}
+ `).join("");
+
+ nav.querySelectorAll("[data-modul]").forEach((el) => {
+ el.addEventListener("click", () => oeffneModul(el.dataset.modul));
+ });
+ }
+
+ /**
+ * Öffnet ein Modul.
+ * @param {string} modulId Modul
+ * @param {object|null} ziel Sprungziel, z. B. { patient: 12 }.
+ * Die Ansicht liest ihn über App.zustand.modulZiel und räumt ihn auf.
+ */
+ async function oeffneModul(modulId, ziel) {
+ const ansicht = ansichten[modulId];
+ if (!ansicht) {
+ meldung("Dieses Modul ist nicht geladen.", "fehler");
+ return;
+ }
+
+ zustand.aktivesModul = modulId;
+ zustand.modulZiel = ziel || null;
+ zeichneNavigation();
+
+ const behaelter = $("inhalt");
+ behaelter.innerHTML = ``;
+
+ try {
+ await ansicht.rendern(behaelter);
+ } catch (fehler) {
+ behaelter.innerHTML =
+ `Fehler
+
${sicher(fehler.message)}
`;
+ }
+ }
+
+ /* ---- Dienststatus und Funktion -------------------------------------- */
+
+ function zeichneStatus() {
+ const aktuell = zustand.status.find((s) => s.id === (zustand.mitarbeiter || {}).status)
+ || zustand.status[0];
+
+ if (aktuell) {
+ $("status-text").textContent = aktuell.name;
+ $("status-punkt").style.background = aktuell.farbe;
+ }
+
+ $("status-liste").innerHTML = zustand.status.map((s) =>
+ `
+
+ ${sicher(s.name)}
+ `).join("");
+
+ $("status-liste").querySelectorAll("[data-status]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const gewaehlt = el.dataset.status;
+ zustand.mitarbeiter.status = gewaehlt;
+ anClient("status", { status: gewaehlt });
+ $("status-liste").hidden = true;
+ zeichneStatus();
+ });
+ });
+ }
+
+ function zeichneFunktion() {
+ const aktuell = zustand.funktionen.find(
+ (f) => f.id === (zustand.mitarbeiter || {}).funktion);
+
+ $("funktion-text").textContent = aktuell ? aktuell.name : "Stationsdienst";
+
+ $("funktion-liste").innerHTML = zustand.funktionen.map((f) =>
+ `${sicher(f.name)} `).join("");
+
+ $("funktion-liste").querySelectorAll("[data-funktion]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const gewaehlt = el.dataset.funktion;
+ zustand.mitarbeiter.funktion = gewaehlt;
+ anClient("funktion", { funktion: gewaehlt });
+ $("funktion-liste").hidden = true;
+ zeichneFunktion();
+ });
+ });
+ }
+
+ /* ---- 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: "🚑", patient: "🧍", labor: "🧪", medikament: "💊",
+ blut: "🩸", op: "🔪", doku: "📄", 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 = {
+ patient: ["mdt", "patient"],
+ akte: ["mdt", "akte"],
+ aufnahme: ["patienten", null],
+ behandlung: ["behandlung", "behandlung"],
+ bericht: ["bericht", "bericht"],
+ operation: ["operation", null],
+ labor: ["labor", null],
+ blut: ["blutbank", "blut"],
+ medikament: ["medikament", null],
+ zimmer: ["station", "zimmer"],
+ fahrzeug: ["fahrzeug", "fahrzeug"],
+ einsatz: ["einsatz", null],
+ intensiv: ["intensiv", "intensiv"],
+ nachricht: ["kommunikation", "nachricht"],
+ aushang: ["kommunikation", "aushang"],
+ };
+
+ const weg = wege[hinweis.zielTyp];
+ if (!weg) return;
+
+ // Nur öffnen, wenn das Modul für diesen Mitarbeiter 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 beim Öffnen ---------------------------------------------- */
+
+ function zeigeTablet(daten) {
+ zustand.imDienst = true;
+ zustand.mitarbeiter = daten.mitarbeiter || {};
+ zustand.module = daten.module || [];
+ zustand.status = daten.status || [];
+ zustand.funktionen = daten.funktionen || [];
+ zustand.stationen = daten.stationen || [];
+
+ $("anmeldung").hidden = true;
+ $("tablet").hidden = false;
+
+ $("tb-kuerzel").textContent = daten.kuerzel || "LSMC";
+ $("tb-klinik").textContent = daten.klinik || "Klinikinformationssystem";
+ $("tb-name").textContent = zustand.mitarbeiter.name || "—";
+ $("tb-rolle").textContent = zustand.mitarbeiter.rolleName || "—";
+ $("tb-nummer").textContent = zustand.mitarbeiter.personalnummer || "—";
+
+ zeichneNavigation();
+ zeichneStatus();
+ zeichneFunktion();
+ zeichneHinweise();
+
+ if (zustand.module.length) oeffneModul(zustand.module[0].id);
+ }
+
+ function zeigeAnmeldung(daten) {
+ zustand.imDienst = false;
+ $("tablet").hidden = true;
+ $("anmeldung").hidden = false;
+
+ $("an-klinik").textContent = daten.klinik || "Los Santos Medical Center";
+ $("an-name").textContent = daten.name || "—";
+ $("an-nummer").textContent = daten.personalnummer || "—";
+ $("an-rolle").textContent = daten.rolleName || "—";
+ $("an-station").textContent = daten.station || "—";
+
+ // Dienstfunktion auswählen — die Vorauswahl ist nur ein Vorschlag
+ const funktionen = daten.funktionen || [{ id: "station", name: "Stationsdienst" }];
+ let gewaehlt = funktionen[0].id;
+
+ $("an-funktionen").innerHTML = funktionen.map((f, i) =>
+ `${sicher(f.name)} `).join("");
+
+ $("an-funktionen").querySelectorAll("[data-funktion]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = el.dataset.funktion;
+ $("an-funktionen").querySelectorAll("[data-funktion]")
+ .forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+
+ $("an-antreten").onclick = () => anClient("dienstAntreten", { funktion: gewaehlt });
+ }
+
+ /* ---- Verdrahtung der Hülle -------------------------------------------- */
+
+ function verdrahteShell() {
+ $("an-abbrechen").addEventListener("click", () => anClient("schliessen", {}));
+ $("tablet-schliessen").addEventListener("click", () => anClient("schliessen", {}));
+
+ $("dienst-beenden").addEventListener("click", () => {
+ bestaetigen({
+ titel: "Dienst beenden",
+ inhalt: `Du meldest dich vom Dienst ab. Laufende Behandlungen und
+ Dokumentationen bleiben offen — sie werden nicht abgeschlossen.
`,
+ bestaetigenText: "Dienst beenden",
+ bestaetigt: (_, zu) => { zu(); anClient("dienstBeenden", {}); },
+ });
+ });
+
+ // Statuswähler
+ $("status-knopf").addEventListener("click", () => {
+ const liste = $("status-liste");
+ liste.hidden = !liste.hidden;
+ $("funktion-liste").hidden = true;
+ });
+
+ // Funktionswähler
+ $("funktion-knopf").addEventListener("click", () => {
+ const liste = $("funktion-liste");
+ liste.hidden = !liste.hidden;
+ $("status-liste").hidden = true;
+ });
+
+ // Mitteilungen
+ $("hinweise-knopf").addEventListener("click", () => {
+ const schublade = $("hinweise-schublade");
+ schublade.hidden = !schublade.hidden;
+ if (!schublade.hidden) zeichneHinweise();
+ });
+ $("hinweise-zu").addEventListener("click", () => { $("hinweise-schublade").hidden = true; });
+
+ // Design umschalten
+ $("thema-wechsel").addEventListener("click", () => {
+ document.body.classList.toggle("thema-dunkel");
+ document.body.classList.toggle("thema-hell");
+ });
+
+ // Globale Suche
+ $("global-suche").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ const begriff = e.target.value.trim();
+ if (begriff.length >= 2) globalSuchen(begriff);
+ }
+ });
+
+ // Klick ins Leere schließt die Auswahllisten
+ document.addEventListener("click", (e) => {
+ if (!e.target.closest("#status-waehler")) $("status-liste").hidden = true;
+ if (!e.target.closest("#funktion-waehler")) $("funktion-liste").hidden = true;
+ });
+
+ document.addEventListener("keydown", (e) => {
+ if (e.key !== "Escape") return;
+ if (document.querySelector(".fenster-hg")) {
+ document.querySelector(".fenster-hg").remove();
+ return;
+ }
+ if (!$("hinweise-schublade").hidden) { $("hinweise-schublade").hidden = true; return; }
+ anClient("schliessen", {});
+ });
+
+ // Nachrichten vom Client
+ window.addEventListener("message", (e) => {
+ const daten = e.data || {};
+
+ switch (daten.aktion) {
+ case "oeffnen":
+ zeigeTablet(daten);
+ document.body.style.display = "";
+ break;
+
+ case "anmeldung":
+ zeigeAnmeldung(daten);
+ document.body.style.display = "";
+ break;
+
+ case "schliessen":
+ document.body.style.display = "none";
+ $("hinweise-schublade").hidden = true;
+ $("fenster-ebene").innerHTML = "";
+ break;
+
+ case "antwort": {
+ const offen = offeneRufe[daten.ruf];
+ if (!offen) break;
+ delete offeneRufe[daten.ruf];
+ if (daten.fehler) offen.ablehnen(new Error(daten.fehler));
+ else offen.aufloesen(daten.ergebnis || {});
+ break;
+ }
+
+ case "hinweis":
+ neuerHinweis(daten.hinweis || {});
+ break;
+
+ case "dienstliste": {
+ zustand.dienstliste = daten.mitarbeiter || [];
+ const ansicht = ansichten[zustand.aktivesModul];
+ if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren();
+ break;
+ }
+
+ case "einsatzAenderung": {
+ const ansicht = ansichten[zustand.aktivesModul];
+ if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren();
+ break;
+ }
+
+ case "einsatzZuweisung":
+ meldung(`Zuweisung: ${daten.daten.nummer} — ${daten.daten.titel}`);
+ break;
+
+ case "fehler":
+ meldung(daten.text, "fehler");
+ break;
+ }
+ });
+ }
+
+ /* ---- Globale Suche ----------------------------------------------------- */
+
+ /** Registriert einen Anbieter für die globale Suche */
+ function registriereSuche(funktion) { suchAnbieter.push(funktion); }
+
+ async function globalSuchen(begriff) {
+ 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, minuten, euro, $,
+ anClient, oeffneModul,
+ start() {
+ verdrahteShell();
+ if (IST_FIVEM) document.body.style.display = "none";
+ else {
+ // Eigenständige Browser-Ansicht: Anmeldebildschirm zeigen
+ zeigeAnmeldung({
+ klinik: "Los Santos Medical Center", name: "Vorschau",
+ personalnummer: "LSMC-0000", rolleName: "Assistenzarzt", station: "notaufnahme",
+ funktionen: [
+ { id: "station", name: "Stationsdienst" },
+ { id: "rtw", name: "Rettungswagen" },
+ ],
+ });
+ }
+ },
+ };
+})();
+
+App.start();
diff --git a/resources/[aether]/aether-medical/html/js/bausteine.js b/resources/[aether]/aether-medical/html/js/bausteine.js
new file mode 100644
index 0000000..5ab258a
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/bausteine.js
@@ -0,0 +1,156 @@
+/* =====================================================================
+ aether-medical — Bausteine für die Bögen
+
+ Setzt vorformulierten Text in ein Formular ein. Mehr tut diese Datei
+ nicht, und mehr soll sie nicht tun:
+
+ * Sie schreibt nichts zum Server. Der Text landet im Textfeld,
+ gespeichert wird danach von Hand.
+ * Sie überschreibt nichts. Steht schon etwas im Feld, wird der
+ Baustein darunter gehängt.
+ * Sie wählt nichts aus. Welcher Baustein passt, entscheidet der,
+ der ihn einsetzt.
+===================================================================== */
+"use strict";
+
+const Bausteine = (() => {
+ const { sicher, aufruf, meldung, bestaetigen } = App;
+
+ /**
+ * Hängt Text an ein Textfeld an, ohne Vorhandenes zu verlieren.
+ * @param {HTMLTextAreaElement} feld Zielfeld
+ * @param {string} text Einzusetzender Text
+ */
+ function anhaengen(feld, text) {
+ const vorhanden = feld.value.trim();
+ feld.value = vorhanden ? vorhanden + "\n\n" + text : text;
+ }
+
+ /**
+ * Öffnet die Bausteinauswahl für einen Bogen.
+ * @param {object} o {
+ * modul Modulkennung, unter der die Aktion liegt
+ * behaelter Element, in dem die Textfelder stehen
+ * nachEinsetzen Rückruf nach dem Einsetzen (freiwillig)
+ * }
+ */
+ async function einsetzen(o) {
+ let d;
+ try {
+ d = await aufruf(o.modul, "bausteine", {});
+ } catch (fehler) { return meldung(fehler.message, "fehler"); }
+
+ if (!d.bausteine.length) {
+ return meldung("Für diesen Bogen ist kein Baustein hinterlegt.", "fehler");
+ }
+
+ let gewaehlt = null; // aktuell gewählter Baustein
+ const abgewaehlt = {}; // Felder, die der Nutzer ausgenommen hat
+
+ bestaetigen({
+ titel: "Baustein einsetzen",
+ inhalt: `
+
+
🧩
+
Der Baustein füllt nur das Formular. Gespeichert wird nichts —
+ das machst du danach selbst. Text, der schon im Feld steht, bleibt
+ erhalten; der Baustein kommt darunter.
+
+
+ Baustein für den ${sicher(d.bereichName)}
+
+ ${d.bausteine.map((b) => `
+
+
${sicher(b.kennung)}
+
+
${sicher(b.name)}
+
${d.felder
+ .filter((f) => b.inhalt[f.id])
+ .map((f) => sicher(f.name)).join(" · ") || "leer"}
+
+
`).join("")}
+
+
+
+
Wähle einen Baustein, um seinen Text zu sehen.
+
`,
+ bestaetigenText: "In den Bogen einsetzen",
+ bestaetigt: (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte einen Baustein wählen.", "fehler");
+
+ const gefuellt = [];
+ const uebersprungen = [];
+
+ d.felder.forEach((feld) => {
+ const text = gewaehlt.inhalt[feld.id];
+ if (!text || abgewaehlt[feld.id]) return;
+
+ // Gesperrte Felder gibt es im Bogen gar nicht — sie bleiben aussen vor
+ const ziel = o.behaelter.querySelector(`textarea[name=${feld.id}]`);
+ if (!ziel) {
+ uebersprungen.push(feld.name);
+ return;
+ }
+
+ anhaengen(ziel, text);
+ gefuellt.push(feld.name);
+ });
+
+ zu();
+
+ if (!gefuellt.length) {
+ return meldung("Kein Feld des Bogens ließ sich füllen.", "fehler");
+ }
+
+ meldung(uebersprungen.length
+ ? `Eingesetzt in ${gefuellt.length} Feld(er). Übersprungen: ${uebersprungen.join(", ")}.`
+ : `Eingesetzt in ${gefuellt.length} Feld(er). Noch nicht gespeichert.`, "gut");
+
+ if (o.nachEinsetzen) o.nachEinsetzen(gefuellt);
+ },
+ nachOeffnen: (hg) => {
+ const vorschau = hg.querySelector("[data-vorschau]");
+
+ /** Zeigt, was der Baustein in welches Feld schreiben würde */
+ function zeigeVorschau() {
+ vorschau.innerHTML = `
+
+ Wird eingesetzt
+ Häkchen entfernen, was nicht mit soll
+
+ ${d.felder.filter((f) => gewaehlt.inhalt[f.id]).map((f) => `
+
+
+
+ ${sicher(f.name)}
+
${sicher(gewaehlt.inhalt[f.id])
+ .replace(/\n/g, " ")}
+
`).join("")}`;
+
+ vorschau.querySelectorAll("[data-feld]").forEach((el) => {
+ el.addEventListener("change", () => {
+ abgewaehlt[el.dataset.feld] = !el.checked;
+ });
+ });
+ }
+
+ hg.querySelectorAll("[data-baustein]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = d.bausteine.find((b) => b.id === +el.dataset.baustein);
+ // Bei einem Wechsel gelten wieder alle Felder
+ Object.keys(abgewaehlt).forEach((k) => delete abgewaehlt[k]);
+
+ hg.querySelectorAll("[data-baustein]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+
+ zeigeVorschau();
+ });
+ });
+ },
+ });
+ }
+
+ return { einsetzen };
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/behandlung.js b/resources/[aether]/aether-medical/html/js/module/behandlung.js
new file mode 100644
index 0000000..a8414fb
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/behandlung.js
@@ -0,0 +1,730 @@
+/* =====================================================================
+ aether-medical / Ansicht: Behandlungsverwaltung
+
+ Ein Dokumentationsbogen, mehr nicht. Kein Feld wird vorausgefüllt,
+ keine Diagnose vorgeschlagen, kein Symptom ausgewertet. Was hier
+ steht, hat ein Mediziner geschrieben.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "laufend", // laufend | meine | abgeschlossen
+ suche: "",
+ daten: null,
+ offen: null,
+ offeneDaten: null,
+ };
+
+ /* ---- Übersicht --------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Behandlungen
+
Das System speichert ausschließlich die Dokumentation. Es behandelt
+ nicht, es diagnostiziert nicht und es schlägt nichts vor.
+
+ ${d.rechte.dokumentieren
+ ? `
+ Behandlung dokumentieren ` : ""}
+
+
+
+
+
Laufend
+
${d.zaehler.laufend}
+
noch nicht abgeschlossen
+
+
+
Davon von dir
+
${d.zaehler.meine}
+
deine offene Dokumentation
+
+
+
Heute abgeschlossen
+
${d.zaehler.heute}
+
in der ganzen Klinik
+
+
+
+
+
+ Laufend${d.zaehler.laufend}
+
+ Meine
+
+ Abgeschlossen
+
+
+
+
+
+
+
+
+ Dokumentationen
+ ${d.liste.length} Einträge
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
Keine Dokumentation gefunden.
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(b) {
+ const laufend = b.status === "laufend";
+
+ return `
+
${sicher(b.kennung)}
+
+
${sicher(b.patientName)}
+ geb. ${sicher(b.geburt)}
+
${sicher(b.diagnose || b.massnahmen
+ || "Noch nichts eingetragen")}
+
+
+ ${b.hinweis ? `
⚠ ` : ""}
+ ${b.allergien ? `
A ` : ""}
+ ${b.gaben > 0 ? `
💊 ${b.gaben} ` : ""}
+ ${b.einsatzNummer ? `
${sicher(b.einsatzNummer)} ` : ""}
+ ${b.eigene ? `
von dir ` : ""}
+
+
${sicher(b.status)}
+
${laufend
+ ? "offen seit " + minuten(b.offenSeit) : zeit(b.abgeschlossen_am)}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ const suchfeld = behaelter.querySelector("[data-suche]");
+ let warten = null;
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(() => {
+ z.suche = suchfeld.value.trim();
+ rendern(behaelter);
+ }, 280);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => anlegenFenster(behaelter));
+ }
+
+ /* ---- Dokumentationsbogen ------------------------------------------------- */
+
+ function einzelAnsicht(behaelter, d) {
+ const b = d.behandlung, s = d.stamm;
+ const laufend = b.status === "laufend";
+ // Die Diagnose darf nur mit eigenem Recht geschrieben werden
+ const diagnoseSperre = !d.rechte.diagnose;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(b.kennung)} · ${sicher(b.patientName)}
+
geb. ${sicher(b.geburt)}
+ ${b.einsatzNummer ? " · Einsatz " + sicher(b.einsatzNummer) : ""}
+ ${b.aufnahmeGrund ? " · Aufnahme: " + sicher(b.aufnahmeGrund) : ""}
+
+
← Übersicht
+
+
+ ${s.hinweis ? `
+
+
⚠
+
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""}
+
+ ${s.allergien ? `
+
+
💊
+
Allergien — ${sicher(s.allergien)}
+ Vor jeder Gabe selbst prüfen. Das System
+ gleicht Medikamente nicht gegen Allergien ab.
+
` : ""}
+
+
+
+
Status ${sicher(b.status)}
+
Angelegt ${zeit(b.erstelltAm)}
+
Von ${sicher(b.erstelltVon)}
+
Blutgruppe
+ ${sicher(s.blutgruppe || "nicht bestimmt")}
+
Vorerkrankungen
+ ${sicher(s.vorerkrankungen || "keine erfasst")}
+
Dauermedikation
+ ${sicher(s.dauermedikation || "keine erfasst")}
+ ${b.abgeschlossenAm ? `
+
Abgeschlossen ${zeit(b.abgeschlossenAm)}
+
Durch ${sicher(b.abgeschlossenVon)}
` : ""}
+
+
+ Patientenakte
+ ${laufend && d.rechte.dokumentieren
+ ? `🧩 Baustein
+ Dokumentation speichern ` : ""}
+ ${laufend && d.rechte.abschliessen
+ ? `Abschließen ` : ""}
+ ${!laufend && d.rechte.abschliessen
+ ? `Wieder öffnen ` : ""}
+
+
+
+
+ ${feld("symptome", "Symptome / Anamnese", b.symptome, laufend && d.rechte.dokumentieren,
+ "Was schildert der Patient?")}
+ ${feld("untersuchung", "Untersuchung", b.untersuchung, laufend && d.rechte.dokumentieren,
+ "Was hast du erhoben? Vitalwerte, Befunde am Patienten…")}
+ ${feld("diagnose", "Diagnose", b.diagnose,
+ laufend && d.rechte.dokumentieren && !diagnoseSperre,
+ diagnoseSperre ? "Für die Diagnose fehlt dir die Berechtigung."
+ : "Deine Einschätzung. Das System stellt keine Diagnose.")}
+ ${feld("massnahmen", "Maßnahmen / Behandlung", b.massnahmen,
+ laufend && d.rechte.dokumentieren,
+ "Verbände, Lagerung, Eingriffe, Reanimation…")}
+ ${feld("empfehlung", "Empfehlung", b.empfehlung, laufend && d.rechte.dokumentieren,
+ "Was empfiehlst du dem Patienten?")}
+ ${feld("nachsorge", "Nachsorge", b.nachsorge, laufend && d.rechte.dokumentieren,
+ "Kontrolltermin, Wundversorgung, Medikamentenplan…")}
+
+
+
+
+ Verabreichte Medikamente
+ ${laufend && d.rechte.medikament
+ ? `+ Gabe dokumentieren ` : ""}
+
+
+ ${d.gaben.length ? d.gaben.map((g) => `
+
+
+ ${g.btm ? "BTM" : "💊"}
+
+
${sicher(g.name)}
+ ${g.menge} ${sicher(g.einheit)}
+
+ ${g.dosierung ? sicher(g.dosierung) + " · " : ""}
+ ${sicher(g.autor)} · ${zeit(g.gegeben_am)}
+ ${g.hinweis ? " · " + sicher(g.hinweis) : ""}
+
+
+ ${laufend && (g.eigene || d.rechte.bestand)
+ ? `
✕ ` : ""}
+
`).join("")
+ : `
Keine Gabe dokumentiert.
`}
+
+
+
+
+
+
Laborergebnisse des Patienten
+
+ ${d.labor.length ? d.labor.map((l) => `
+
+
${sicher(l.kennung)}
+
+
${sicher(l.art)}
+
${sicher(l.befund || l.ergebnis
+ || "Noch kein Ergebnis")}
+
+
+ ${sicher(l.status)}
+
`).join("")
+ : `
Keine Laboraufträge.
`}
+
+
+
+
+
Operationen des Patienten
+
+ ${d.operationen.length ? d.operationen.map((o) => `
+
+
${sicher(o.kennung)}
+
+
${sicher(o.art)}
+
${o.beginn ? zeit(o.beginn) : "noch nicht begonnen"}
+
+
${sicher(o.status)}
+
`).join("")
+ : `
Keine Operationen.
`}
+
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ /**
+ * Ein Feld des Dokumentationsbogens.
+ * @param {string} name Feldname
+ * @param {string} titel Beschriftung
+ * @param {string} wert Bisheriger Inhalt
+ * @param {boolean} schreibbar Darf bearbeitet werden?
+ * @param {string} hinweis Text unter dem Feld
+ */
+ function feld(name, titel, wert, schreibbar, hinweis) {
+ if (!schreibbar) {
+ return `
+
${sicher(titel)}
+
${wert
+ ? sicher(wert).replace(/\n/g, " ")
+ : "Nicht dokumentiert."}
+ ${hinweis ? `
${sicher(hinweis)}
` : ""}
+
`;
+ }
+
+ return `
+
${sicher(titel)}
+
+ ${hinweis ? `
${sicher(hinweis)}
` : ""}
+
`;
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const b = d.behandlung;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelector("[data-akte]").addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "mdt")) {
+ return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("mdt", { patient: b.patient });
+ });
+
+ // Baustein einsetzen: füllt nur den Bogen, gespeichert wird danach von Hand
+ const baustein = behaelter.querySelector("[data-baustein]");
+ if (baustein) baustein.addEventListener("click", () => {
+ Bausteine.einsetzen({ modul: "behandlung", behaelter });
+ });
+
+ const speichern = behaelter.querySelector("[data-speichern]");
+ if (speichern) speichern.addEventListener("click", async () => {
+ const lies = (name) => {
+ const el = behaelter.querySelector(`[name=${name}]`);
+ return el ? el.value.trim() : undefined;
+ };
+ try {
+ await aufruf("behandlung", "speichern", {
+ id: b.id,
+ symptome: lies("symptome"),
+ untersuchung: lies("untersuchung"),
+ diagnose: lies("diagnose"),
+ massnahmen: lies("massnahmen"),
+ empfehlung: lies("empfehlung"),
+ nachsorge: lies("nachsorge"),
+ });
+ meldung("Dokumentation gespeichert.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const abschliessen = behaelter.querySelector("[data-abschliessen]");
+ if (abschliessen) abschliessen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Behandlung abschließen",
+ inhalt: `
+ ${sicher(b.kennung)} — ${sicher(b.patientName)}
+ Die Dokumentation wird damit schreibgeschützt. Sie lässt sich später
+ mit Begründung wieder öffnen.
+ ${!b.diagnose ? `
+
+
📄
+
Es ist keine Diagnose eingetragen. Abschließen ist trotzdem
+ möglich — das System trägt keine für dich ein.
+
` : ""}
+ Nicht gespeicherte Änderungen im Bogen gehen dabei
+ verloren. Erst speichern, dann abschließen.
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Abschließen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("behandlung", "abschliessen", {
+ id: b.id, vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung("Behandlung abgeschlossen.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const wiederOeffnen = behaelter.querySelector("[data-oeffnen]");
+ if (wiederOeffnen) wiederOeffnen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Dokumentation wieder öffnen",
+ inhalt: `Die Behandlung wird wieder bearbeitbar. Der Vorgang steht
+ dauerhaft im Protokoll.
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Wieder öffnen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("behandlung", "wiedereroeffnen", { id: b.id, grund });
+ zu();
+ meldung("Dokumentation wieder geöffnet.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const gabe = behaelter.querySelector("[data-gabe]");
+ if (gabe) gabe.addEventListener("click", () => gabeFenster(behaelter, d));
+
+ behaelter.querySelectorAll("[data-zuruecknehmen]").forEach((el) => {
+ el.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Eintrag zurücknehmen",
+ gefahr: true,
+ inhalt: `Der Eintrag wird aus der Dokumentation entfernt. Wurde damals
+ Bestand gebucht, wird er als Korrektur zurückgebucht.
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Zurücknehmen",
+ bestaetigt: async (dialog, zu) => {
+ const grund = dialog.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("behandlung", "medikamentZuruecknehmen", {
+ gabe: +el.dataset.zuruecknehmen, grund,
+ });
+ zu();
+ meldung("Eintrag zurückgenommen.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+ });
+ }
+
+ /* ---- Medikamentengabe ---------------------------------------------------- */
+
+ async function gabeFenster(behaelter, d) {
+ const b = d.behandlung;
+
+ let katalog;
+ try {
+ katalog = await aufruf("behandlung", "medikamente", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ let gewaehlt = null;
+ // Wird gesetzt, wenn der Bestand nicht reicht und trotzdem dokumentiert wird
+ let ohneBestand = false;
+
+ // Medikamente nach Kategorie gruppieren
+ const gruppen = {};
+ katalog.medikamente.forEach((m) => {
+ (gruppen[m.kategorie] = gruppen[m.kategorie] || []).push(m);
+ });
+ const kategorieName = {};
+ katalog.kategorien.forEach((k) => { kategorieName[k.id] = k.name; });
+
+ const dialog = bestaetigen({
+ titel: "Medikamentengabe dokumentieren",
+ inhalt: `
+ ${sicher(b.patientName)} · ${sicher(b.kennung)}
+ ${d.stamm.allergien ? `
+
+
⚠
+
Bekannte Allergien: ${sicher(d.stamm.allergien)}
+ Das System prüft nicht gegen diese Liste.
+ Der Abgleich ist deine Aufgabe.
+
` : ""}
+
+ Medikament
+
+
+ ${Object.keys(gruppen).map((kategorie) => `
+
+
${sicher(kategorieName[kategorie] || kategorie)}
+ ${gruppen[kategorie].map((m) => `
+
+ ${sicher(m.name)}
+ ${m.btm ? `BTM ` : ""}
+ ${m.abgelaufen ? `abgelaufen ` : ""}
+ ${sicher(m.wirkstoff || "")}
+ ${m.dosierung_standard ? " · " + sicher(m.dosierung_standard) : ""}
+
+ ${m.bestand} ${sicher(m.einheit)}
+ `).join("")}
+
`).join("")}
+
+
Die Standarddosierung ist ein Vorschlag aus dem
+ Katalog. Was du gibst, entscheidest du.
+
+
+
+ Hinweis (freiwillig)
+
+
`,
+ bestaetigenText: "Gabe dokumentieren",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte ein Medikament wählen.", "fehler");
+ try {
+ await aufruf("behandlung", "medikamentGeben", {
+ id: b.id,
+ medikament: gewaehlt,
+ menge: +el.querySelector("[name=menge]").value,
+ dosierung: el.querySelector("[name=dosierung]").value.trim(),
+ hinweis: el.querySelector("[name=hinweis]").value.trim(),
+ ohneBestand,
+ });
+ zu();
+ meldung("Gabe dokumentiert.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) {
+ meldung(f.message, "fehler");
+ // Bestand reicht nicht: Dokumentation trotzdem anbieten
+ if (f.message.indexOf("Bestand") !== -1) zeigeBestandsWeg(el);
+ }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const dosierung = hg.querySelector("[name=dosierung]");
+
+ hg.querySelectorAll("[data-med]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = +el.dataset.med;
+ ohneBestand = false;
+ hg.querySelector("[data-warnung]").style.display = "none";
+ hg.querySelectorAll("[data-med]").forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ // Standarddosierung als Vorschlag übernehmen, überschreibbar
+ if (!dosierung.value) dosierung.value = el.dataset.dosierung;
+ });
+ });
+
+ suchfeld.addEventListener("input", () => {
+ const begriff = suchfeld.value.trim().toLowerCase();
+ hg.querySelectorAll("[data-med]").forEach((el) => {
+ el.style.display = !begriff || el.dataset.name.indexOf(begriff) !== -1 ? "" : "none";
+ });
+ // Leere Gruppen ausblenden
+ hg.querySelectorAll("[data-gruppe]").forEach((gruppe) => {
+ const sichtbar = [...gruppe.querySelectorAll("[data-med]")]
+ .some((el) => el.style.display !== "none");
+ gruppe.style.display = sichtbar ? "" : "none";
+ });
+ });
+ },
+ });
+
+ /** Bietet an, die Gabe ohne Bestandsbuchung zu dokumentieren */
+ function zeigeBestandsWeg(el) {
+ const warnung = el.querySelector("[data-warnung]");
+ warnung.style.display = "";
+ warnung.innerHTML = `
+
+
📦
+
Der Lagerbestand reicht nicht. Eine Dokumentation darf daran
+ nicht scheitern — etwa wenn das Mittel aus dem RTW stammt.
+ Ohne Bestandsbuchung dokumentieren
+
`;
+
+ warnung.querySelector("[data-ohne]").addEventListener("click", () => {
+ ohneBestand = true;
+ meldung("Bestandsbuchung deaktiviert — jetzt bestätigen.", "gut");
+ warnung.querySelector("[data-ohne]").disabled = true;
+ });
+ }
+
+ return dialog;
+ }
+
+ /* ---- Anlegen ------------------------------------------------------------- */
+
+ function anlegenFenster(behaelter) {
+ let gewaehlt = null;
+ let einsaetze = [];
+
+ bestaetigen({
+ titel: "Behandlung dokumentieren",
+ inhalt: `
+ Patient suchen
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
+ Zu einem Einsatz zuordnen (freiwillig)
+
+
Symptome / Anamnese
+
+
Erste Untersuchung
+
+
Diagnose, Maßnahmen und Nachsorge trägst du
+ anschließend im Bogen nach.
+
`,
+ bestaetigenText: "Anlegen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler");
+ const einsatzFeld = el.querySelector("[name=einsatz]");
+ try {
+ const antwort = await aufruf("behandlung", "anlegen", {
+ patient: gewaehlt.id,
+ aufnahme: gewaehlt.aufnahme || null,
+ einsatz: einsatzFeld && einsatzFeld.value ? +einsatzFeld.value : null,
+ symptome: el.querySelector("[name=symptome]").value.trim(),
+ untersuchung: el.querySelector("[name=untersuchung]").value.trim(),
+ });
+ zu();
+ meldung("Behandlung " + antwort.kennung + " angelegt.", "gut");
+ oeffne(behaelter, antwort.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const liste = hg.querySelector("[data-treffer]");
+ const formular = hg.querySelector("[data-formular]");
+ const einsatzfeld = hg.querySelector("[data-einsatzfeld]");
+ let warten = null;
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) {
+ liste.innerHTML = `Mindestens zwei Zeichen eingeben.
`;
+ return;
+ }
+ try {
+ const antwort = await aufruf("behandlung", "patientSuche", { begriff });
+ const treffer = antwort.treffer || [];
+ einsaetze = antwort.einsaetze || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+ ${t.blutgruppe ? " · " + sicher(t.blutgruppe) : ""}
+
+ ${t.hinweis ? `
⚠ ` : ""}
+ ${t.allergien ? `
A ` : ""}
+ ${t.aufnahme ? `
aufgenommen ` : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = treffer.find((t) => t.id === +el.dataset.person);
+ liste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ formular.style.display = "";
+
+ // Laufende Einsätze anbieten, an denen man beteiligt ist
+ if (einsaetze.length) {
+ einsatzfeld.style.display = "";
+ hg.querySelector("[name=einsatz]").innerHTML =
+ `— keinem Einsatz zuordnen — ` +
+ einsaetze.map((e) =>
+ `${sicher(e.nummer)} · ${sicher(e.titel)} `
+ ).join("");
+ }
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+ }
+
+ /* ---- Laden ---------------------------------------------------------------- */
+
+ async function oeffne(behaelter, behandlungId) {
+ try {
+ const d = await aufruf("behandlung", "oeffnen", { id: behandlungId });
+ z.offen = behandlungId;
+ z.offeneDaten = d;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("behandlung", "uebersicht", {
+ ordner: z.ordner, begriff: z.suche,
+ });
+
+ // Sprungziel aus einem Hinweis, z. B. „Dokumentation fehlt"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.behandlung) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.behandlung);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Behandlungen
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("behandlung", { rendern });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("behandlung", "uebersicht", {
+ ordner: "laufend", begriff,
+ });
+ return (d.liste || []).slice(0, 5).map((b) => ({
+ bereich: "Behandlung",
+ titel: b.kennung + " · " + b.patientName,
+ unter: b.diagnose || b.massnahmen || "Noch nichts eingetragen",
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/bericht.js b/resources/[aether]/aether-medical/html/js/module/bericht.js
new file mode 100644
index 0000000..0059386
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/bericht.js
@@ -0,0 +1,848 @@
+/* =====================================================================
+ aether-medical / Ansicht: Berichte
+
+ Ein Bericht ist ein Dokument mit einer Unterschrift darunter. Kein
+ Satz darin entsteht von selbst: Text aus einer Behandlung wird nur
+ übernommen, wenn du es auslöst, und die Freigabe ist eine Unterschrift
+ von Hand. Ein freigegebener Bericht bleibt, wie er ist.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "meine", // meine | freigabe | freigegeben | alle
+ suche: "",
+ offen: null, // geöffneter Bericht
+ daten: null,
+ };
+
+ // Marker-Art je Status
+ const statusArt = { entwurf: "neutral", eingereicht: "warn", freigegeben: "gut" };
+
+ // Beschriftung der Unterschriftsarten
+ const unterschriftArt = {
+ verfasser: "Verfasser", freigabe: "Freigabe", gegenzeichnung: "Gegengezeichnet",
+ };
+
+ /* ---- Kleine Helfer ---------------------------------------------------- */
+
+ /** Anzeigename eines Status */
+ function statusName(kennung) {
+ const treffer = (z.daten.status || []).find((s) => s.id === kennung);
+ return treffer ? treffer.name : kennung;
+ }
+
+ /* ---- Gerüst ------------------------------------------------------------ */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "meine", text: "Meine Entwürfe", zahl: d.zaehler.meine },
+ { id: "freigabe", text: "Zur Freigabe", zahl: d.zaehler.freigabe },
+ { id: "freigegeben", text: "Freigegeben", zahl: d.zaehler.freigegeben },
+ { id: "alle", text: "Alle", zahl: 0 },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl > 0 ? `${e.zahl} ` : ""}
+ `).join("")}
+
`;
+ }
+
+ function kennzahlen(d) {
+ const zk = d.zaehler;
+
+ return `
+
+
Eigene Entwürfe
+
${zk.meine}
+
nur du siehst sie
+
+
+
Warten auf Freigabe
+
${zk.freigabe}
+
liegen zur Unterschrift
+
+
+
Davon von dir
+
${zk.meineOffen}
+
eingereicht, noch nicht gezeichnet
+
+
+
Heute freigegeben
+
${zk.freigegeben}
+
endgültig, unveränderlich
+
+
`;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Berichte
+
Das System schreibt keinen Satz. Der Weg vom Entwurf zur Freigabe wird
+ Schritt für Schritt gegangen — freigegeben wird durch Unterschrift,
+ nie durch Zeitablauf.
+
+ ${d.rechte.erstellen
+ ? `
+ Bericht anlegen ` : ""}
+
+
+ ${kennzahlen(d)}
+ ${reiterleiste(d)}
+
+
+
+ Fremde Entwürfe sind nicht einsehbar —
+ erst mit dem Einreichen wird ein Bericht sichtbar.
+
+
+
+
+ ${
+ z.ordner === "meine" ? "Deine offenen Entwürfe"
+ : z.ordner === "freigabe" ? "Eingereicht, wartet auf Unterschrift"
+ : z.ordner === "freigegeben" ? "Freigegebene Berichte" : "Alle Berichte"}
+ ${d.liste.length} Einträge
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
Keine Berichte in diesem Ordner.
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(b) {
+ return `
+
${sicher(b.nummer)}
+
+
+
${sicher(b.titel)}
+
+ ${b.patientName ? sicher(b.patientName) : "ohne Patientenbezug"}
+ · ${sicher(b.autor)}
+ ${b.behandlungKennung ? " · " + sicher(b.behandlungKennung) : ""}
+ ${b.einsatzNummer ? " · " + sicher(b.einsatzNummer) : ""}
+
+
+
+ ${b.vonMir ? `
von dir ` : ""}
+ ${b.hatDiagnose ? "" : `
ohne Diagnose `}
+ ${b.unterschriften > 0
+ ? `
${b.unterschriften} Unterschrift(en) ` : ""}
+
+
+ ${sicher(statusName(b.status))}
+
${zeit(b.erstellt_am)}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ const suche = behaelter.querySelector("[data-suche]");
+ let warten = null;
+ suche.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(() => {
+ z.suche = suche.value.trim();
+ rendern(behaelter);
+ }, 300);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => anlegenFenster(behaelter));
+ }
+
+ /* ---- Einzelansicht -------------------------------------------------------- */
+
+ /** Ein Feld des Berichtsbogens */
+ function bogenFeld(feld, wert, schreibbar) {
+ if (!schreibbar) {
+ return `
+
${sicher(feld.name)}
+
${wert
+ ? sicher(wert).replace(/\n/g, " ") : "Nicht ausgefüllt."}
+
`;
+ }
+ return `
+ ${sicher(feld.name)}
+
+
`;
+ }
+
+ function einzelAnsicht(behaelter, d) {
+ const b = d.bericht;
+ const schreibbar = d.rechte.aendern;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(b.titel)}
+
${sicher(b.nummer)} · ${sicher(statusName(b.status))}
+ ${b.patientName ? " · " + sicher(b.patientName)
+ + (b.patientGeburt ? ", geb. " + sicher(b.patientGeburt) : "") : ""}
+
+
← Übersicht
+
+
+ ${b.status === "freigegeben" ? `
+
+
🔒
+
Freigegeben. Der Wortlaut steht fest und wird nicht mehr geändert.
+ Eine weitere Unterschrift ist möglich, eine Änderung nicht.
+
` : ""}
+
+ ${b.status === "eingereicht" ? `
+
+
✍
+
Liegt zur Freigabe. Bis zur Unterschrift ist der Text gesperrt.
+ ${b.vonMir ? "Zum Weiterschreiben musst du ihn zurückziehen." : ""}
+
` : ""}
+
+ ${b.selbstFreigabe ? `
+
+
ℹ
+
Verfasser und Freigabe sind dieselbe Person. Das ist zulässig und
+ steht so im Dokument.
+
` : ""}
+
+
+
+
Nummer ${sicher(b.nummer)}
+
Verfasser ${sicher(b.autor)}
+
Angelegt ${zeit(b.erstelltAm)}
+
Patient
+ ${sicher(b.patientName || "kein Bezug")}
+
Behandlung
+ ${sicher(b.behandlungKennung || "keine")}
+
Einsatz
+ ${sicher(b.einsatzNummer || "keiner")}
+
+
+
+ ${b.patient
+ ? `Patientenakte ` : ""}
+ ${schreibbar ? `
+ 🧩 Baustein
+ Entwurf speichern
+ ${b.behandlung
+ ? `Aus Behandlung übernehmen ` : ""}
+ Anlage
+ Einreichen ` : ""}
+ ${b.status === "eingereicht" && b.vonMir && d.rechte.erstellen
+ ? `Zurückziehen ` : ""}
+ ${b.status === "eingereicht" && d.rechte.freigeben ? `
+ Freigeben
+ Zurückweisen ` : ""}
+ ${b.status !== "entwurf" && d.rechte.freigeben
+ ? `Gegenzeichnen ` : ""}
+
+
+
+ ${schreibbar ? `
+
+ Titel
+
+
` : ""}
+
+
+ ${d.felder.map((feld) => bogenFeld(feld, b[feld.id], schreibbar)).join("")}
+
+
+
+
+
+ Unterschriften
+ ${d.unterschriften.length} · nicht zurücknehmbar
+
+
+ ${d.unterschriften.length ? d.unterschriften.map((u) => `
+
+
+ ${sicher(unterschriftArt[u.art] || u.art)}
+
+
${sicher(u.name)}
+
${sicher(u.rolle || "")}
+ ${u.personalnummer ? " · " + sicher(u.personalnummer) : ""}
+
+
${zeit(u.am)}
+
`).join("")
+ : `
Noch nicht unterschrieben.
`}
+
+
+
+ Anlagen
+ ${d.anlagen.length}
+
+
+ ${d.anlagen.length ? d.anlagen.map((a, i) => `
+
+
+
${sicher(a.titel)}
+
${sicher(a.von)} · ${zeit(a.am)}
+
+ ${schreibbar
+ ? `
Entfernen `
+ : ""}
+
`).join("")
+ : `
Keine Anlagen.
`}
+
+
+
+
+
+ Weg des Berichts
+ aus dem Protokoll, unveränderlich
+
+
+ ${d.spur.length ? d.spur.map((s) => `
+
+
+
${sicher(s.aktion)}
+
${sicher(s.autor)}
+ ${s.grund ? " · " + sicher(s.grund) : ""}
+
+
${zeit(s.am)}
+
`).join("")
+ : `
Noch kein Eintrag.
`}
+
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const b = d.bericht;
+
+ /** Liest die Felder des Bogens aus */
+ const bogen = () => {
+ const werte = { id: b.id };
+ const titel = behaelter.querySelector("[name=titel]");
+ if (titel) werte.titel = titel.value.trim();
+
+ d.felder.forEach((feld) => {
+ const el = behaelter.querySelector(`[name=${feld.id}]`);
+ if (el) werte[feld.id] = el.value.trim();
+ });
+ return werte;
+ };
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ const akte = behaelter.querySelector("[data-akte]");
+ if (akte) {
+ akte.addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "mdt")) {
+ return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("mdt", { patient: b.patient });
+ });
+ }
+
+ // Baustein einsetzen: füllt nur den Bogen, gespeichert wird danach von Hand
+ const baustein = behaelter.querySelector("[data-baustein]");
+ if (baustein) {
+ baustein.addEventListener("click", () => {
+ Bausteine.einsetzen({ modul: "bericht", behaelter });
+ });
+ }
+
+ const speichern = behaelter.querySelector("[data-speichern]");
+ if (speichern) {
+ speichern.addEventListener("click", async () => {
+ try {
+ await aufruf("bericht", "speichern", bogen());
+ meldung("Entwurf gespeichert.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ }
+
+ const uebernehmen = behaelter.querySelector("[data-uebernehmen]");
+ if (uebernehmen) {
+ uebernehmen.addEventListener("click", () => uebernehmenFenster(behaelter, b, bogen));
+ }
+
+ const anlage = behaelter.querySelector("[data-anlage]");
+ if (anlage) anlage.addEventListener("click", () => anlageFenster(behaelter, b));
+
+ behaelter.querySelectorAll("[data-anlageweg]").forEach((el) => {
+ el.addEventListener("click", async () => {
+ try {
+ await aufruf("bericht", "anlage", { id: b.id, entfernen: +el.dataset.anlageweg });
+ meldung("Anlage entfernt.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ const einreichen = behaelter.querySelector("[data-einreichen]");
+ if (einreichen) {
+ einreichen.addEventListener("click", () => einreichenFenster(behaelter, b, d, bogen));
+ }
+
+ const zurueckziehen = behaelter.querySelector("[data-zurueckziehen]");
+ if (zurueckziehen) {
+ zurueckziehen.addEventListener("click", () => zurueckziehenFenster(behaelter, b));
+ }
+
+ const freigeben = behaelter.querySelector("[data-freigeben]");
+ if (freigeben) freigeben.addEventListener("click", () => freigebenFenster(behaelter, b, d));
+
+ const zurueckweisen = behaelter.querySelector("[data-zurueckweisen]");
+ if (zurueckweisen) {
+ zurueckweisen.addEventListener("click", () => zurueckweisenFenster(behaelter, b));
+ }
+
+ const gegenzeichnen = behaelter.querySelector("[data-gegenzeichnen]");
+ if (gegenzeichnen) {
+ gegenzeichnen.addEventListener("click", () => gegenzeichnenFenster(behaelter, b));
+ }
+ }
+
+ /* ---- Bericht anlegen ------------------------------------------------------- */
+
+ function anlegenFenster(behaelter) {
+ let patient = null; // gewählter Patient
+ let behandlung = null; // gewählte Behandlung
+ let einsatz = null; // gewählter Einsatz
+
+ bestaetigen({
+ titel: "Bericht anlegen",
+ inhalt: `
+ Titel
+
+
+ Patient (freiwillig)
+
+
+
Nicht jeder Bericht gehört zu einem Patienten.
+
+
+
+
Behandlung verknüpfen (freiwillig)
+
+
Einsatz verknüpfen (freiwillig)
+
+
+
+ Eine Verknüpfung erlaubt es später, den Wortlaut
+ der Behandlung ausdrücklich zu übernehmen. Von selbst wird nichts kopiert.
`,
+ bestaetigenText: "Anlegen",
+ bestaetigt: async (el, zu) => {
+ const titel = el.querySelector("[name=titel]").value.trim();
+ if (!titel) return meldung("Bitte einen Titel angeben.", "fehler");
+
+ try {
+ const antwort = await aufruf("bericht", "anlegen", {
+ titel, patient, behandlung, einsatz,
+ });
+ zu();
+ meldung(`Bericht ${antwort.nummer} angelegt.`, "gut");
+ z.ordner = "meine";
+ oeffne(behaelter, antwort.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const liste = hg.querySelector("[data-treffer]");
+ const vorgaenge = hg.querySelector("[data-vorgaenge]");
+ let warten = null;
+
+ /** Lädt Behandlungen und Einsätze zum gewählten Patienten */
+ async function ladeVorgaenge() {
+ let antwort;
+ try {
+ antwort = await aufruf("bericht", "quellen", { patient });
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ vorgaenge.style.display = "";
+
+ const behListe = hg.querySelector("[data-behandlungen]");
+ behListe.innerHTML = antwort.behandlungen.length
+ ? antwort.behandlungen.map((b) => `
+
+
${sicher(b.kennung)}
+
+
${sicher(b.diagnose || "ohne Diagnose")}
+
${zeit(b.erstellt_am)}
+
+
+ ${sicher(b.status)}
+
`).join("")
+ : `Keine Behandlung erfasst.
`;
+
+ const einListe = hg.querySelector("[data-einsaetze]");
+ einListe.innerHTML = antwort.einsaetze.length
+ ? antwort.einsaetze.map((e) => `
+
+
${sicher(e.nummer)}
+
+
${sicher(e.titel)}
+
${zeit(e.erstellt_am)}
+
+
`).join("")
+ : `Kein Einsatz verknüpft.
`;
+
+ // Ein zweiter Klick nimmt die Verknüpfung wieder zurück
+ behListe.querySelectorAll("[data-behandlung]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const schon = behandlung === +el.dataset.behandlung;
+ behListe.querySelectorAll("[data-behandlung]")
+ .forEach((k) => k.classList.remove("gewaehlt"));
+ behandlung = schon ? null : +el.dataset.behandlung;
+ if (!schon) el.classList.add("gewaehlt");
+ });
+ });
+
+ einListe.querySelectorAll("[data-einsatz]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const schon = einsatz === +el.dataset.einsatz;
+ einListe.querySelectorAll("[data-einsatz]")
+ .forEach((k) => k.classList.remove("gewaehlt"));
+ einsatz = schon ? null : +el.dataset.einsatz;
+ if (!schon) el.classList.add("gewaehlt");
+ });
+ });
+ }
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) return;
+
+ try {
+ const antwort = await aufruf("bericht", "quellen", { begriff });
+ const treffer = antwort.treffer || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.name)}
+
geb. ${sicher(t.dob)}
+
+ ${t.blutgruppe
+ ? `
${sicher(t.blutgruppe)} ` : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ patient = +el.dataset.person;
+ behandlung = null;
+ einsatz = null;
+ liste.querySelectorAll("[data-person]")
+ .forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ ladeVorgaenge();
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 280);
+ });
+ },
+ });
+ }
+
+ /* ---- Wortlaut übernehmen ----------------------------------------------------- */
+
+ function uebernehmenFenster(behaelter, b, bogen) {
+ bestaetigen({
+ titel: "Wortlaut aus der Behandlung übernehmen",
+ inhalt: `
+ Aus ${sicher(b.behandlungKennung)} wird der dort dokumentierte Text
+ in diesen Bericht kopiert.
+
+
+
ℹ
+
Das System formuliert nichts um und fasst nichts zusammen —
+ es kopiert den Wortlaut. Bereits ausgefüllte Felder bleiben unberührt;
+ nur leere werden gefüllt. Was danach im Bericht steht, verantwortest du.
+
+
+ Ungespeicherte Änderungen im Bogen gehen dabei
+ verloren — der Entwurf wird zuerst gespeichert.
`,
+ bestaetigenText: "Übernehmen",
+ bestaetigt: async (_, zu) => {
+ try {
+ // Erst sichern, was der Verfasser bereits geschrieben hat
+ await aufruf("bericht", "speichern", bogen());
+ const antwort = await aufruf("bericht", "uebernehmen", {
+ id: b.id, behandlung: b.behandlung,
+ });
+ zu();
+ meldung(`Übernommen: ${antwort.uebernommen.join(", ")}.`, "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Anlage ------------------------------------------------------------------- */
+
+ function anlageFenster(behaelter, b) {
+ bestaetigen({
+ titel: "Anlage hinzufügen",
+ inhalt: `
+ Bezeichnung
+
+ Adresse
+
+ Die Anlage wird verlinkt, nicht hochgeladen.
+ Entfernen lässt sie sich nur, solange der Bericht ein Entwurf ist.
`,
+ bestaetigenText: "Hinzufügen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("bericht", "anlage", {
+ id: b.id,
+ titel: el.querySelector("[name=titel]").value.trim(),
+ adresse: el.querySelector("[name=adresse]").value.trim(),
+ });
+ zu();
+ meldung("Anlage hinzugefügt.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Einreichen und zurückziehen ------------------------------------------------ */
+
+ function einreichenFenster(behaelter, b, d, bogen) {
+ // Welche Felder sind im Bogen gerade leer?
+ const werte = bogen();
+ const leer = d.felder.filter((f) => !werte[f.id]).map((f) => f.name);
+
+ bestaetigen({
+ titel: "Bericht einreichen",
+ inhalt: `
+ ${sicher(b.nummer)} geht zur Freigabe. Du unterschreibst ihn damit
+ als Verfasser.
+
+ ${leer.length ? `
+
+
ℹ
+
Noch nicht ausgefüllt: ${sicher(leer.join(", "))}.
+ Das hält die Einreichung nicht auf — es steht nur so im Protokoll.
+
` : ""}
+
+
+
✍
+
Nach dem Einreichen ist der Text gesperrt. Zum Weiterschreiben musst du
+ den Bericht zurückziehen — das wird festgehalten.
+
`,
+ bestaetigenText: "Unterschreiben und einreichen",
+ bestaetigt: async (_, zu) => {
+ try {
+ // Der zuletzt getippte Stand ist der, der eingereicht wird
+ await aufruf("bericht", "speichern", werte);
+ await aufruf("bericht", "einreichen", { id: b.id });
+ zu();
+ meldung("Bericht eingereicht.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function zurueckziehenFenster(behaelter, b) {
+ bestaetigen({
+ titel: "Bericht zurückziehen",
+ inhalt: `
+ ${sicher(b.nummer)} geht zurück in den Entwurf und lässt sich
+ wieder bearbeiten.
+
+
ℹ
+
Deine Unterschrift als Verfasser bleibt stehen — sie wurde geleistet.
+ Dass du danach weitergeschrieben hast, steht im Protokoll.
+
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Zurückziehen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (!grund) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ await aufruf("bericht", "zurueckziehen", { id: b.id, grund });
+ zu();
+ meldung("Bericht zurückgezogen.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Freigabe ------------------------------------------------------------------- */
+
+ function freigebenFenster(behaelter, b, d) {
+ const leer = d.felder.filter((f) => !b[f.id]).map((f) => f.name);
+
+ bestaetigen({
+ titel: "Bericht freigeben",
+ inhalt: `
+ Du unterschreibst ${sicher(b.nummer)} — „${sicher(b.titel)}",
+ verfasst von ${sicher(b.autor)}.
+
+
+
🔒
+
Die Freigabe ist endgültig. Danach wird am Wortlaut nichts mehr
+ geändert — von niemandem.
+
+
+ ${b.vonMir ? `
+
+
ℹ
+
Du hast diesen Bericht selbst geschrieben. Verfasser und Freigabe in
+ einer Person ist zulässig und wird so im Dokument vermerkt.
+
` : ""}
+
+ ${leer.length ? `
+
+
📄
+
Nicht ausgefüllt: ${sicher(leer.join(", "))}.
+
` : ""}
+
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Freigeben",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("bericht", "freigeben", {
+ id: b.id, vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung("Bericht freigegeben.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function zurueckweisenFenster(behaelter, b) {
+ bestaetigen({
+ titel: "Bericht zurückweisen",
+ gefahr: true,
+ inhalt: `
+ ${sicher(b.nummer)} geht als Entwurf an ${sicher(b.autor)} zurück.
+ Begründung (Pflicht)
+
+
Der Verfasser bekommt diese Begründung angezeigt.
`,
+ bestaetigenText: "Zurückweisen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("bericht", "zurueckweisen", { id: b.id, grund });
+ zu();
+ meldung("Bericht zurückgewiesen.", "gut");
+ z.offen = null;
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function gegenzeichnenFenster(behaelter, b) {
+ bestaetigen({
+ titel: "Bericht gegenzeichnen",
+ inhalt: `
+ Du setzt deine Unterschrift unter ${sicher(b.nummer)} .
+
+
✍
+
Eine Gegenzeichnung ändert nichts am Wortlaut und lässt sich nicht
+ zurücknehmen. Sie sagt nur: du hast diesen Bericht gelesen und trägst ihn mit.
+
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Gegenzeichnen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("bericht", "gegenzeichnen", {
+ id: b.id, vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung("Gegengezeichnet.", "gut");
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, berichtId) {
+ try {
+ const d = await aufruf("bericht", "oeffnen", { id: berichtId });
+ z.offen = berichtId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) {
+ meldung(f.message, "fehler");
+ z.offen = null;
+ }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("bericht", "uebersicht", {
+ ordner: z.ordner, suche: z.suche,
+ });
+
+ // Sprungziel aus einem Hinweis, z. B. „Bericht zurückgewiesen"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.bericht) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.bericht);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Berichte
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("bericht", {
+ rendern,
+ aktualisieren() {
+ // Einen offenen Bogen nie überschreiben — dort steht ungespeicherter Text
+ if (App.zustand.aktivesModul === "bericht" && !z.offen
+ && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/blutbank.js b/resources/[aether]/aether-medical/html/js/module/blutbank.js
new file mode 100644
index 0000000..2c3b6fa
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/blutbank.js
@@ -0,0 +1,755 @@
+/* =====================================================================
+ aether-medical / Ansicht: Blutbank
+
+ Ein Lagerbuch. Die Verträglichkeitstabelle ist eine Nachschlagehilfe
+ und keine Schranke: Weicht eine Kombination ab, wird das deutlich
+ angezeigt — ausgegeben werden kann sie trotzdem. Auch abgelaufene
+ Konserven verschwinden nicht von selbst.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "vorraetig", // vorraetig | reserviert | archiv
+ gruppe: "", // Filter auf eine Blutgruppe
+ daten: null,
+ };
+
+ // Marker-Art je Status
+ const statusArt = {
+ vorraetig: "gut", reserviert: "warn",
+ ausgegeben: "info", verworfen: "neutral",
+ };
+
+ /* ---- Kleine Helfer ---------------------------------------------------- */
+
+ /** Anzeigename eines Status */
+ function statusName(status) {
+ const treffer = (z.daten.status || []).find((s) => s.id === status);
+ return treffer ? treffer.name : status;
+ }
+
+ /**
+ * Schlägt in der Verträglichkeitstabelle nach.
+ * @returns {boolean|null} null, wenn sich nichts sagen lässt
+ */
+ function vertraeglich(empfaenger, konserve) {
+ if (!empfaenger) return null;
+ const erlaubt = (z.daten.vertraeglichkeit || {})[empfaenger];
+ if (!erlaubt) return null;
+ return erlaubt.includes(konserve);
+ }
+
+ /** Kurzer Hinweistext zum Verfallsdatum */
+ function verfallText(k) {
+ if (k.tage === null || k.tage === undefined) return "ohne Verfallsdatum";
+ if (k.tage <= 0) return `seit ${Math.abs(k.tage)} Tagen über dem Datum`;
+ return `noch ${k.tage} Tage`;
+ }
+
+ /* ---- Gerüst ------------------------------------------------------------ */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "vorraetig", text: "Vorrätig", zahl: d.zaehler.vorraetig },
+ { id: "reserviert", text: "Reserviert", zahl: d.zaehler.reserviert },
+ { id: "archiv", text: "Archiv", zahl: d.zaehler.ausgegebenHeute },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl}
+ `).join("")}
+
`;
+ }
+
+ function kennzahlen(d) {
+ const zk = d.zaehler;
+
+ return `
+
+
Vorrätig
+
${zk.vorraetig}
+
frei verfügbare Konserven
+
+
+
Reserviert
+
${zk.reserviert}
+
für Patienten zurückgelegt
+
+
+
Läuft bald ab
+
${zk.baldAb}
+
innerhalb von ${d.vorlaufTage} Tagen
+
+
+
Über dem Datum
+
${zk.abgelaufen}
+
liegt weiter im Lager
+
+
`;
+ }
+
+ /** Bestandskacheln je Blutgruppe — dienen zugleich als Filter */
+ function bestandsKacheln(d) {
+ return `
+ ${d.bestand.map((b) => `
+
+
Blutgruppe ${sicher(b.gruppe)}
+
${b.vorraetig}
+
+ vorrätig · ${b.reserviert} reserviert
+ ${b.baldAb > 0 ? ` · ${b.baldAb} läuft ab` : ""}
+
+
`).join("")}
+
`;
+ }
+
+ /* ---- Übersicht ----------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Blutbank
+
Das System verwirft keine Konserve und wählt keine aus. Ein abgelaufenes
+ Datum ist eine Erinnerung — ob eine Konserve noch brauchbar ist, beurteilt
+ ein Mensch.
+
+
+ Verträglichkeit nachschlagen
+ ${d.rechte.verwalten
+ ? `+ Konserven einlagern ` : ""}
+
+
+
+ ${kennzahlen(d)}
+ ${bestandsKacheln(d)}
+ ${reiterleiste(d)}
+
+
+
+ Alle Blutgruppen
+ ${d.gruppen.map((g) =>
+ `
+ ${sicher(g)} `).join("")}
+
+ ${z.ordner === "vorraetig"
+ ? "Die älteste Konserve steht oben — welche genommen wird, entscheidest du."
+ : "Klick auf eine Zeile öffnet die Konserve."}
+
+
+
+
+ ${z.ordner === "vorraetig" ? "Bestand"
+ : z.ordner === "reserviert" ? "Zurückgelegte Konserven"
+ : "Ausgegeben und verworfen"}
+ ${d.liste.length} Einträge
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
Keine Konserve in diesem Ordner.
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(k) {
+ return `
+
${sicher(k.konserve)}
+
${sicher(k.blutgruppe)}
+
+
+
${k.menge_ml} ml
+ ${k.lagerort ? `${sicher(k.lagerort)} ` : ""}
+
+ ${k.empfaenger ? "für " + sicher(k.empfaenger)
+ : "eingelagert " + zeit(k.eingelagert_am)}
+ · ${sicher(verfallText(k))}
+
+
+
+ ${k.vertraeglich === false
+ ? `
Tabelle ✕ ` : ""}
+ ${k.abgelaufen ? `
abgelaufen `
+ : k.laeuftBaldAb ? `
läuft ab ` : ""}
+
+
+ ${sicher(statusName(k.status))}
+
+
${k.status === "ausgegeben" ? zeit(k.ausgegeben_am)
+ : k.verfaellt_am ? sicher(String(k.verfaellt_am).slice(0, 10)) : "—"}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-gruppe]").forEach((el) => {
+ el.addEventListener("click", () => {
+ // Ein zweiter Klick hebt den Filter wieder auf
+ z.gruppe = z.gruppe === el.dataset.gruppe ? "" : el.dataset.gruppe;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelector("[data-gruppenfilter]").addEventListener("change", (e) => {
+ z.gruppe = e.target.value;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => konserveFenster(behaelter, +el.dataset.oeffne));
+ });
+
+ behaelter.querySelector("[data-tabelle]")
+ .addEventListener("click", () => tabellenFenster());
+
+ const einlagern = behaelter.querySelector("[data-einlagern]");
+ if (einlagern) einlagern.addEventListener("click", () => einlagernFenster(behaelter));
+ }
+
+ /* ---- Einzelne Konserve ------------------------------------------------------ */
+
+ async function konserveFenster(behaelter, konserveId) {
+ let d;
+ try {
+ d = await aufruf("blutbank", "konserve", { id: konserveId });
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const k = d.konserve;
+ const imLager = k.status === "vorraetig" || k.status === "reserviert";
+
+ const dialog = fenster({
+ titel: `Konserve ${k.kennung} · ${k.blutgruppe}`,
+ inhalt: `
+ ${k.vertraeglich === false ? `
+
+
⚠
+
Weicht von der Verträglichkeitstabelle ab.
+ Konserve ${sicher(k.blutgruppe)} für einen Empfänger mit
+ ${sicher(k.empfaengerGruppe)}. Das System sperrt nichts —
+ die Entscheidung liegt bei dir.
+
` : ""}
+
+ ${k.abgelaufen ? `
+
+
📅
+
Über dem Verfallsdatum — ${sicher(verfallText(k))}.
+ Die Konserve bleibt im Bestand, bis jemand sie verwirft.
+
` : ""}
+
+
+
Status ${sicher(statusName(k.status))}
+
Blutgruppe ${sicher(k.blutgruppe)}
+
Menge ${k.menge} ml
+
Lagerort ${sicher(k.lagerort || "—")}
+
Eingelagert ${zeit(k.eingelagertAm)}
+
Eingelagert von ${sicher(k.eingelagertVon)}
+
Verfällt am
+ ${k.verfaelltAm ? sicher(String(k.verfaelltAm).slice(0, 10))
+ : "kein Datum"} · ${sicher(verfallText(k))}
+ ${k.empfaengerName ? `
+
Zurückgelegt für
+ ${sicher(k.empfaengerName)}
+ ${k.empfaengerGruppe ? ` (${sicher(k.empfaengerGruppe)})`
+ : " (Blutgruppe nicht bestimmt)"}
` : ""}
+ ${k.reserviertVon
+ ? `
Reserviert von ${sicher(k.reserviertVon)}
` : ""}
+ ${k.ausgegebenAm ? `
+
Ausgegeben ${zeit(k.ausgegebenAm)}
+
Ausgegeben von ${sicher(k.ausgegebenVon)}
` : ""}
+
+
+ ${d.rechte.verwalten && imLager ? `
+
+ ${k.status === "vorraetig"
+ ? `Für Patienten zurücklegen `
+ : `Reservierung aufheben `}
+ Ausgeben
+ Umlagern
+ Verwerfen
+
` : ""}
+
+
+ Weg der Konserve
+ aus dem Protokoll, unveränderlich
+
+
+ ${d.spur.length ? d.spur.map((s) => `
+
+
+
${sicher(s.aktion)}
+
${sicher(s.autor)}
+ ${s.grund ? " · " + sicher(s.grund) : ""}
+
+
${zeit(s.am)}
+
`).join("")
+ : `
Noch kein Eintrag.
`}
+
+ `,
+ nachOeffnen: (hg) => {
+ const knopf = (name, funktion) => {
+ const el = hg.querySelector(`[data-${name}]`);
+ if (el) el.addEventListener("click", () => { dialog.schliessen(); funktion(); });
+ };
+
+ knopf("reservieren", () => reservierenFenster(behaelter, k));
+ knopf("loesen", () => loesenFenster(behaelter, k));
+ knopf("ausgeben", () => ausgebenFenster(behaelter, k));
+ knopf("umlagern", () => umlagernFenster(behaelter, k));
+ knopf("verwerfen", () => verwerfenFenster(behaelter, k));
+ },
+ });
+ }
+
+ /* ---- Einlagern --------------------------------------------------------------- */
+
+ function einlagernFenster(behaelter) {
+ const d = z.daten;
+
+ bestaetigen({
+ titel: "Konserven einlagern",
+ inhalt: `
+
+
Blutgruppe
+
+ ${d.gruppen.map((g) =>
+ `${sicher(g)} `).join("")}
+
+
Anzahl
+
+
Menge je Konserve (ml)
+
+
+
+ Lagerort
+
+ ${(d.lagerorte || []).map((o) =>
+ `${sicher(o)} `).join("")}
+
+
+ Verfällt am (freiwillig)
+
+
Ohne Datum erinnert später nichts an einen Ablauf.
+ Mit Datum erinnert das System — verwerfen muss trotzdem jemand von Hand.
+
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Einlagern",
+ bestaetigt: async (el, zu) => {
+ try {
+ const antwort = await aufruf("blutbank", "einlagern", {
+ blutgruppe: el.querySelector("[name=blutgruppe]").value,
+ anzahl: +el.querySelector("[name=anzahl]").value,
+ menge: +el.querySelector("[name=menge]").value,
+ lagerort: el.querySelector("[name=lagerort]").value,
+ verfaellt: el.querySelector("[name=verfaellt]").value,
+ vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung(`${antwort.anzahl} Konserve(n) eingelagert.`, "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Reservieren ---------------------------------------------------------------- */
+
+ function reservierenFenster(behaelter, k) {
+ let gewaehlt = null;
+
+ bestaetigen({
+ titel: `${k.kennung} zurücklegen`,
+ inhalt: `
+ Konserve ${sicher(k.blutgruppe)} , ${k.menge} ml.
+
+ Patient suchen
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
+
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Zurücklegen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler");
+ try {
+ const antwort = await aufruf("blutbank", "reservieren", {
+ id: k.id,
+ patient: gewaehlt.id,
+ vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung(antwort.vertraeglich === false
+ ? "Zurückgelegt — die Abweichung wurde gemeldet."
+ : "Konserve zurückgelegt.", antwort.vertraeglich === false ? "" : "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const liste = hg.querySelector("[data-treffer]");
+ const pruefung = hg.querySelector("[data-pruefung]");
+ let warten = null;
+
+ /** Zeigt, was die Nachschlagetabelle zu dieser Kombination sagt */
+ function zeigePruefung(person) {
+ const passt = vertraeglich(person.blutgruppe, k.blutgruppe);
+
+ pruefung.innerHTML = passt === null
+ ? `?
+
Blutgruppe nicht bestimmt. Zur Verträglichkeit lässt sich
+ nichts nachschlagen — das ist kein Hindernis, aber es steht so da.
`
+ : passt
+ ? `✔
+
Laut Tabelle verträglich: Konserve ${sicher(k.blutgruppe)} für
+ Empfänger ${sicher(person.blutgruppe)}.
`
+ : `⚠
+
Laut Tabelle nicht verträglich: Konserve
+ ${sicher(k.blutgruppe)} für Empfänger ${sicher(person.blutgruppe)}.
+ Das System hält dich nicht auf — es meldet die Abweichung.
`;
+ }
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) {
+ liste.innerHTML = `Mindestens zwei Zeichen eingeben.
`;
+ return;
+ }
+ try {
+ const antwort = await aufruf("blutbank", "patientSuche", { begriff });
+ const treffer = antwort.treffer || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+
+ ${t.hinweis ? `
⚠ ` : ""}
+
+ ${sicher(t.blutgruppe || "nicht bestimmt")}
+ ${t.laufend ? `
${sicher(t.laufend)} ` : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = treffer.find((t) => t.id === +el.dataset.person);
+ liste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ zeigePruefung(gewaehlt);
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+ }
+
+ function loesenFenster(behaelter, k) {
+ bestaetigen({
+ titel: "Reservierung aufheben",
+ inhalt: `
+ Konserve ${sicher(k.kennung)} war für
+ ${sicher(k.empfaengerName || "einen Patienten")} zurückgelegt und geht
+ zurück in den freien Bestand.
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Reservierung aufheben",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (!grund) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ await aufruf("blutbank", "reservierungLoesen", { id: k.id, grund });
+ zu();
+ meldung("Reservierung aufgehoben.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Ausgeben --------------------------------------------------------------------- */
+
+ function ausgebenFenster(behaelter, k) {
+ // Bei einer reservierten Konserve steht der Empfänger schon fest
+ let gewaehlt = k.empfaenger ? { id: k.empfaenger, blutgruppe: k.empfaengerGruppe } : null;
+
+ const passtSchon = k.empfaenger ? vertraeglich(k.empfaengerGruppe, k.blutgruppe) : null;
+
+ bestaetigen({
+ titel: `${k.kennung} ausgeben`,
+ gefahr: true,
+ inhalt: `
+ Konserve ${sicher(k.blutgruppe)} , ${k.menge} ml verlässt das Lager.
+
+
+
ℹ
+
Das System transfundiert nicht. Es hält fest, dass die Konserve
+ herausgegeben wurde. Alles Weitere verantwortet der Arzt am Patienten.
+
+
+ ${k.abgelaufen ? `
+
+
📅
+
Diese Konserve ist über dem Verfallsdatum —
+ ${sicher(verfallText(k))}. Die Ausgabe ist möglich und wird so vermerkt.
+
` : ""}
+
+ ${k.empfaenger ? `
+ Empfänger
+
${sicher(k.empfaengerName)} ·
+ ${sicher(k.empfaengerGruppe || "Blutgruppe nicht bestimmt")}
+ ${passtSchon === false ? `
+
+
⚠
+
Laut Tabelle nicht verträglich. Die Ausgabe wird trotzdem
+ dokumentiert und gemeldet.
+
` : ""}`
+ : `
+ Empfänger suchen (Pflicht)
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
`}
+
+ Vermerk zur Ausgabe (Pflicht)
+
`,
+ bestaetigenText: "Ausgabe dokumentieren",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte einen Empfänger wählen.", "fehler");
+
+ const vermerk = el.querySelector("[name=vermerk]").value.trim();
+ if (!vermerk) return meldung("Bitte einen Vermerk hinterlassen.", "fehler");
+
+ try {
+ const antwort = await aufruf("blutbank", "ausgeben", {
+ id: k.id, patient: gewaehlt.id, vermerk,
+ });
+ zu();
+ meldung(antwort.vertraeglich === false
+ ? "Ausgegeben — die Abweichung wurde gemeldet."
+ : "Ausgabe dokumentiert.", antwort.vertraeglich === false ? "" : "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ if (!suchfeld) return;
+
+ const liste = hg.querySelector("[data-treffer]");
+ const pruefung = hg.querySelector("[data-pruefung]");
+ let warten = null;
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) return;
+ try {
+ const antwort = await aufruf("blutbank", "patientSuche", { begriff });
+ const treffer = antwort.treffer || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+
+
+ ${sicher(t.blutgruppe || "nicht bestimmt")}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = treffer.find((t) => t.id === +el.dataset.person);
+ liste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+
+ const passt = vertraeglich(gewaehlt.blutgruppe, k.blutgruppe);
+ pruefung.innerHTML = passt === false
+ ? `⚠
+
Laut Tabelle nicht verträglich. Die Ausgabe bleibt
+ möglich und wird gemeldet.
`
+ : passt === null
+ ? `?
+
Blutgruppe nicht bestimmt — nichts nachzuschlagen.
`
+ : "";
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+ }
+
+ /* ---- Verwerfen ---------------------------------------------------------------------- */
+
+ function verwerfenFenster(behaelter, k) {
+ bestaetigen({
+ titel: `${k.kennung} verwerfen`,
+ gefahr: true,
+ inhalt: `
+ Konserve ${sicher(k.blutgruppe)} , ${k.menge} ml wird als verworfen
+ festgehalten und verschwindet aus dem Bestand.
+
+ ${k.empfaengerName ? `
+
+
🧍
+
Diese Konserve war für ${sicher(k.empfaengerName)} zurückgelegt.
+ Das wird allen im Dienst gemeldet.
+
` : ""}
+
+ Begründung (Pflicht)
+
+ Das Verfallsdatum allein verwirft nichts. Ob eine
+ Konserve noch brauchbar ist, beurteilst du.
`,
+ bestaetigenText: "Verwerfen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("blutbank", "verwerfen", { id: k.id, grund });
+ zu();
+ meldung("Konserve verworfen.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Umlagern ------------------------------------------------------------------------ */
+
+ function umlagernFenster(behaelter, k) {
+ bestaetigen({
+ titel: `${k.kennung} umlagern`,
+ inhalt: `
+ Aktueller Lagerort: ${sicher(k.lagerort || "—")}
+ Neuer Lagerort
+
+ ${(z.daten.lagerorte || []).map((o) =>
+ `
+ ${sicher(o)} `).join("")}
+
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Umlagern",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("blutbank", "umlagern", {
+ id: k.id,
+ lagerort: el.querySelector("[name=lagerort]").value,
+ vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung("Lagerort geändert.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Verträglichkeitstabelle ------------------------------------------------------------ */
+
+ function tabellenFenster() {
+ const d = z.daten;
+
+ fenster({
+ titel: "Verträglichkeit nachschlagen",
+ inhalt: `
+
+
ℹ
+
Eine Nachschlagehilfe, keine Schranke. Das System sperrt keine
+ Konserve und wählt keine aus. Weicht eine Kombination ab, wird das
+ angezeigt und protokolliert — entschieden wird am Patienten.
+
+
+
+ ${d.gruppen.map((empfaenger) => {
+ const erlaubt = (d.vertraeglichkeit || {})[empfaenger] || [];
+ return `
+
${sicher(empfaenger)}
+
+
verträgt laut Tabelle
+
+ ${d.gruppen.map((spender) => `
+
+ ${sicher(spender)} `).join("")}
+
+
+
${erlaubt.length} von ${d.gruppen.length}
+
`;
+ }).join("")}
+
`,
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("blutbank", "uebersicht", {
+ ordner: z.ordner, gruppe: z.gruppe,
+ });
+
+ uebersicht(behaelter);
+
+ // Sprungziel aus einem Hinweis, z. B. „Blutkonserve läuft ab"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.blut) {
+ App.zustand.modulZiel = null;
+ return konserveFenster(behaelter, ziel.blut);
+ }
+ } catch (f) {
+ behaelter.innerHTML = `Blutbank
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("blutbank", {
+ rendern,
+ aktualisieren() {
+ // Kein offenes Fenster überschreiben
+ if (App.zustand.aktivesModul === "blutbank" && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/dashboard.js b/resources/[aether]/aether-medical/html/js/module/dashboard.js
new file mode 100644
index 0000000..296741b
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/dashboard.js
@@ -0,0 +1,259 @@
+/* =====================================================================
+ aether-medical / Ansicht: Übersicht
+
+ Der Lagebericht der Klinik. Jede Zahl kommt frisch vom Server; keine
+ ist fest verdrahtet. Die Warnungen betreffen ausschliesslich Bestände,
+ Fristen und Besetzung — nie den Zustand eines Patienten, den das
+ System selbst bewertet hätte.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung } = App;
+
+ const z = { daten: null, behaelter: null };
+
+ /* ---- Bausteine ------------------------------------------------------ */
+
+ /**
+ * Kennzahlkachel
+ * @param {string|number} wert Angezeigter Wert
+ * @param {string} titel Beschriftung
+ * @param {string} zusatz Zusatzzeile
+ * @param {string} art "", "gut", "warn", "hoch"
+ * @param {string} ziel Modul, in das ein Klick springt
+ */
+ function kachel(wert, titel, zusatz, art, ziel) {
+ return `
+
${sicher(titel)}
+
${sicher(wert)}
+ ${zusatz ? `
${sicher(zusatz)}
` : ""}
+
`;
+ }
+
+ /** Waagerechter Belegungsbalken */
+ function belegungsBalken(name, belegt, gesamt) {
+ const anteil = gesamt > 0 ? Math.round((belegt / gesamt) * 100) : 0;
+ // Je voller, desto dringlicher die Farbe
+ const farbe = anteil >= 90 ? "var(--kritisch)"
+ : anteil >= 70 ? "var(--warn)" : "var(--gut)";
+
+ return `
+
${sicher(name)}
+
+
${belegt}/${gesamt}
+
`;
+ }
+
+ /** Name einer Station anhand ihrer Kennung */
+ function stationsName(id) {
+ const treffer = (App.zustand.stationen || []).find((s) => s.id === id);
+ return treffer ? treffer.name : (id || "—");
+ }
+
+ /* ---- Zeichnen -------------------------------------------------------- */
+
+ function zeichne(behaelter) {
+ const d = z.daten;
+ const dienst = d.dienst, ein = d.einsaetze, pat = d.patienten;
+ const bel = d.belegung, offen = d.offen;
+
+ behaelter.innerHTML = `
+
+
+
Übersicht
+
Stand der Klinik. Alle Zahlen sind Momentaufnahmen —
+ sie lösen nichts aus und ersetzen keine Einschätzung am Patienten.
+
+
${zeit(new Date().toISOString())}
+
+
+ ${d.warnungen.length ? `
+ ${d.warnungen.map((w) => `
+
+
${w.art === "kritisch" ? "⚠" : "❗"}
+
${sicher(w.titel)} — ${sicher(w.text)}
+
`).join("")}
+
` : ""}
+
+
+ ${kachel(dienst.imDienst, "Im Dienst",
+ dienst.leitstelle ? "Leitstelle: " + dienst.leitstelle : "Leitstelle unbesetzt",
+ dienst.imDienst === 0 ? "hoch" : "gut")}
+ ${kachel(dienst.notaerzte, "Notärzte",
+ dienst.aufRtw + " auf RTW · " + dienst.imOp + " im OP")}
+ ${kachel(ein.offen, "Offene Notrufe",
+ ein.laufend + " laufend · " + ein.dringend + " dringend",
+ ein.offen > 0 ? "warn" : "", "leitstelle")}
+ ${kachel(pat.aufgenommen, "Patienten im Haus",
+ pat.wartend + " warten auf Aufnahme", "", "patienten")}
+
+
+
+ ${kachel(pat.intensiv, "Intensivpatienten",
+ pat.kritisch + " als kritisch eingetragen",
+ pat.kritisch > 0 ? "hoch" : "", "intensiv")}
+ ${kachel(bel.opLaufend + "/" + bel.opSaeleGesamt, "OP-Säle belegt",
+ bel.opGeplant + " geplant", "", "operation")}
+ ${kachel(bel.bettenFrei, "Freie Betten",
+ "von " + bel.bettenGesamt + " insgesamt",
+ bel.bettenFrei === 0 ? "hoch" : bel.bettenFrei <= 2 ? "warn" : "gut", "klinik")}
+ ${kachel(bel.behandlungFrei, "Freie Behandlungsräume",
+ "von " + bel.behandlungGesamt + " insgesamt",
+ bel.behandlungFrei === 0 ? "warn" : "", "klinik")}
+
+
+
+ ${kachel(offen.dokumentationen, "Offene Dokumentationen",
+ offen.eigeneDoku + " davon von dir",
+ offen.eigeneDoku > 0 ? "warn" : "", "behandlung")}
+ ${kachel(offen.laborOffen, "Laboraufträge offen",
+ offen.laborUnbefundet + " warten auf Bewertung", "", "labor")}
+ ${kachel(offen.berichteEntwurf, "Eigene Berichtsentwürfe",
+ "noch nicht eingereicht",
+ offen.berichteEntwurf > 0 ? "warn" : "", "bericht")}
+ ${kachel(pat.laengsteWartezeit > 0 ? minuten(pat.laengsteWartezeit) : "—",
+ "Längste Wartezeit",
+ pat.wartend + " Patienten warten",
+ pat.laengsteWartezeit >= 30 ? "hoch"
+ : pat.laengsteWartezeit >= 10 ? "warn" : "", "patienten")}
+
+
+
+
+
+ Wartende Patienten
+ Aufnahme entscheidet ein Mediziner
+
+
+ ${pat.warteliste.length ? pat.warteliste.map((p) => `
+
+
+
+
${sicher(p.firstname)} ${sicher(p.lastname)}
+
${sicher(String(p.grund).slice(0, 70))}
+
+
wartet ${minuten(p.wartet)}
+
`).join("")
+ : `
Niemand wartet auf Aufnahme.
`}
+
+
+
+
+
+ Offene und laufende Einsätze
+ Zuweisung durch die Leitstelle
+
+
+ ${ein.liste.length ? ein.liste.map((e) => `
+
+
+
${sicher(e.nummer)}
+
+
${sicher(e.titel)}
+
${sicher(e.ort || "Ort offen")}
+
+
+ ${e.status === "offen" ? "offen" : "laufend"}
+
`).join("")
+ : `
Keine offenen Einsätze.
`}
+
+
+
+
+
+
+
Belegung
+
+ ${belegungsBalken("Betten", bel.bettenBelegt, bel.bettenGesamt)}
+ ${belegungsBalken("Behandlungsräume",
+ bel.behandlungGesamt - bel.behandlungFrei, bel.behandlungGesamt)}
+ ${belegungsBalken("OP-Säle", bel.opLaufend, bel.opSaeleGesamt)}
+
+
+
+
+
+ Kollegen im Dienst
+ ${dienst.imDienst} anwesend
+
+
+ ${dienst.liste.length ? dienst.liste.slice(0, 8).map((m) => `
+
+
+
${sicher(m.personalnummer)}
+
+
${sicher(m.name)}
+
${sicher(m.rolleName)}
+ · ${sicher(stationsName(m.station))}
+
+ ${m.leitstelle ? `
Leitstelle ` : ""}
+
${sicher(funktionsName(m.funktion))}
+
`).join("")
+ : `
Niemand im Dienst.
`}
+
+
+
+
+
+ Das System unterstützt medizinische Entscheidungen — es trifft niemals selbst
+ welche. Es nimmt niemanden auf, entlässt niemanden, stellt keine Diagnose,
+ gibt kein Medikament aus und stellt keinen Tod fest. Wartezeiten und Fristen
+ laufen ab, ohne dass etwas passiert, bis ein Mediziner entscheidet.
+
+ `;
+
+ verdrahte(behaelter);
+ }
+
+ /** Farbe zum Dienststatus aus der Konfiguration */
+ function farbeZuStatus(status) {
+ const treffer = (App.zustand.status || []).find((s) => s.id === status);
+ return treffer ? treffer.farbe : "var(--flaeche-3)";
+ }
+
+ /** Klartextname einer Dienstfunktion */
+ function funktionsName(funktion) {
+ const treffer = (App.zustand.funktionen || []).find((f) => f.id === funktion);
+ return treffer ? treffer.name : (funktion || "—");
+ }
+
+ function verdrahte(behaelter) {
+ // Kacheln und Warnungen springen in das zuständige Modul
+ behaelter.querySelectorAll("[data-springe]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const ziel = el.dataset.springe;
+ if (!App.zustand.module.some((m) => m.id === ziel)) {
+ meldung("Dieses Modul steht dir nicht zur Verfügung.", "fehler");
+ return;
+ }
+ App.oeffneModul(ziel);
+ });
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------------ */
+
+ async function rendern(behaelter) {
+ z.behaelter = behaelter;
+ try {
+ z.daten = await aufruf("dashboard", "laden", {});
+ // Hinweise aus dem ersten Laden in den Kern übernehmen
+ App.zustand.hinweise = z.daten.hinweise || [];
+ zeichne(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Übersicht
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("dashboard", {
+ rendern,
+ // Wird gerufen, wenn sich Dienstliste oder Einsätze ändern
+ aktualisieren() {
+ if (z.behaelter && App.zustand.aktivesModul === "dashboard") rendern(z.behaelter);
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/einsatz.js b/resources/[aether]/aether-medical/html/js/module/einsatz.js
new file mode 100644
index 0000000..744d766
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/einsatz.js
@@ -0,0 +1,641 @@
+/* =====================================================================
+ aether-medical / Ansicht: Leitstelle und Einsatzverwaltung
+
+ Links die Einsätze, rechts die Kräfte. Eine Kraft lässt sich auf
+ einen Einsatz ziehen — das schickt eine ANFRAGE, keine Zuweisung.
+ Ob sie den Einsatz übernimmt, entscheidet sie selbst.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen, anClient } = App;
+
+ const z = {
+ ordner: "aktuell", // aktuell | archiv
+ daten: null,
+ offen: null,
+ };
+
+ const prioText = { 1: "Priorität 1", 2: "Priorität 2", 3: "Priorität 3", 4: "Priorität 4" };
+
+ /* ---- Leitstelle --------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Leitstelle
+
Notrufe erzeugen offene Einsätze. Wer hinfährt, entscheidet die
+ Leitstelle — und die angefragte Kraft selbst.
+
+
+ ${d.rechte.leitstelle && !d.istLeitstelle
+ ? `Leitstelle übernehmen ` : ""}
+ ${d.rechte.erstellen
+ ? `+ Einsatz anlegen ` : ""}
+
+
+
+
+
+
Offen
+
${d.zaehler.offen}
+
noch niemand gebunden
+
+
+
Laufend
+
${d.zaehler.laufend}
+
Kräfte im Einsatz
+
+
+
Kräfte im Dienst
+
${d.kraefte.length}
+
${d.kraefte.filter((k) => !k.gebundenAn).length} ungebunden
+
+
+
Leitstelle
+
+ ${sicher(d.leitstelleName || "unbesetzt")}
+
${d.zaehler.heute} Einsätze heute beendet
+
+
+
+
+
+ Aktuelle Einsätze
+
+ Archiv
+
+
+
+
+
+ ${z.ordner === "archiv"
+ ? "Abgeschlossene Einsätze" : "Einsätze"}
+ ${d.rechte.zuweisen && z.ordner === "aktuell"
+ ? "Kraft von rechts auf einen Einsatz ziehen"
+ : d.einsaetze.length + " Einträge"}
+
+
+ ${d.einsaetze.length ? d.einsaetze.map(einsatzKarte).join("")
+ : `
${z.ordner === "archiv"
+ ? "Das Archiv ist leer." : "Keine offenen Einsätze."}
`}
+
+
+
+
+
+ Kräfte im Dienst
+ ${d.rechte.zuweisen ? "ziehbar" : "Übersicht"}
+
+
+ ${d.kraefte.length ? d.kraefte.map((k) => kraftZeile(k, d)).join("")
+ : `
Niemand im Dienst.
`}
+
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function einsatzKarte(e) {
+ const abgeschlossen = e.status === "abgeschlossen";
+
+ return `
+
+ ${sicher(e.nummer)}
+ ${sicher(e.status)}
+ ${e.ichDabei ? `du bist dabei ` : ""}
+ ${abgeschlossen
+ ? zeit(e.geschlossen_am) : "offen seit " + minuten(e.offenSeit)}
+
+
+
${sicher(e.titel)}
+
+
+ ${sicher(e.kategorieName)}
+ · ${sicher(prioText[e.prioritaet] || "")}
+ ${e.ort ? " · " + sicher(e.ort) : ""}
+ ${e.quelle === "notruf" ? ` · Notruf ` : ""}
+
+
+
+
${e.kraefte} Kraft/Kräfte gebunden
+ · angelegt von ${sicher(e.erstelltVon)}
+
+ ${e.pos_x ? `◎ ` : ""}
+ ${!abgeschlossen ? (e.ichDabei
+ ? `Abrücken `
+ : `Übernehmen `) : ""}
+ Details
+
+
+
`;
+ }
+
+ function kraftZeile(k, d) {
+ const farbe = (d.status.find((s) => s.id === k.status) || {}).farbe || "var(--flaeche-3)";
+ const funktion = (d.funktionen.find((f) => f.id === k.funktion) || {}).name || k.funktion;
+
+ return `
+
+
${sicher(k.personalnummer)}
+
+
${sicher(k.name)}
+
${sicher(k.rolleName)} · ${sicher(funktion)}
+
+ ${k.leitstelle ? `
Leitstelle ` : ""}
+ ${k.gebundenAn
+ ? `
+ ${sicher(k.gebundenAn)} `
+ : `
frei `}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => erstellenFenster(behaelter));
+
+ const uebernehmen = behaelter.querySelector("[data-uebernehmen]");
+ if (uebernehmen) uebernehmen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Leitstelle übernehmen",
+ inhalt: `Du übernimmst die Disposition. Damit fragst du Kräfte für
+ Einsätze an — verpflichten kannst du niemanden.
`,
+ bestaetigenText: "Übernehmen",
+ bestaetigt: (_, zu) => {
+ zu();
+ anClient("leitstelle", {});
+ setTimeout(() => rendern(behaelter), 400);
+ },
+ });
+ });
+
+ behaelter.querySelectorAll("[data-oeffnen]").forEach((el) => {
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ oeffne(behaelter, +el.dataset.oeffnen);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-beitreten]").forEach((el) => {
+ el.addEventListener("click", async (e) => {
+ e.stopPropagation();
+ try {
+ await aufruf("einsatz", "beitreten", { id: +el.dataset.beitreten });
+ meldung("Einsatz übernommen.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ behaelter.querySelectorAll("[data-verlassen]").forEach((el) => {
+ el.addEventListener("click", async (e) => {
+ e.stopPropagation();
+ try {
+ await aufruf("einsatz", "verlassen", { id: +el.dataset.verlassen });
+ meldung("Vom Einsatz abgerückt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ behaelter.querySelectorAll("[data-wegpunkt]").forEach((el) => {
+ el.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const einsatz = d.einsaetze.find((x) => x.id === +el.dataset.wegpunkt);
+ if (einsatz && einsatz.pos_x) {
+ anClient("wegpunkt", { x: einsatz.pos_x, y: einsatz.pos_y });
+ meldung("Wegpunkt gesetzt.");
+ }
+ });
+ });
+
+ if (d.rechte.zuweisen && z.ordner === "aktuell") verdrahteDisposition(behaelter);
+ }
+
+ /* ---- Disposition per Ziehen ---------------------------------------------- */
+
+ function verdrahteDisposition(behaelter) {
+ let gezogeneQuelle = null;
+
+ behaelter.querySelectorAll("[data-kraft]").forEach((el) => {
+ el.addEventListener("dragstart", (e) => {
+ gezogeneQuelle = +el.dataset.kraft;
+ el.classList.add("zieht");
+ // Ohne Nutzdaten lässt Firefox das Ziehen nicht zu
+ e.dataTransfer.setData("text/plain", String(gezogeneQuelle));
+ e.dataTransfer.effectAllowed = "link";
+ });
+
+ el.addEventListener("dragend", () => {
+ gezogeneQuelle = null;
+ el.classList.remove("zieht");
+ behaelter.querySelectorAll("[data-ziel]")
+ .forEach((ziel) => ziel.classList.remove("uebergabe"));
+ });
+ });
+
+ behaelter.querySelectorAll("[data-ziel]").forEach((el) => {
+ el.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "link";
+ el.classList.add("uebergabe");
+ });
+
+ el.addEventListener("dragleave", () => el.classList.remove("uebergabe"));
+
+ el.addEventListener("drop", (e) => {
+ e.preventDefault();
+ el.classList.remove("uebergabe");
+ const quelle = gezogeneQuelle || +e.dataTransfer.getData("text/plain");
+ if (quelle) anfrageFenster(behaelter, +el.dataset.einsatz, quelle);
+ });
+ });
+ }
+
+ function anfrageFenster(behaelter, einsatzId, zielQuelle) {
+ const einsatz = z.daten.einsaetze.find((e) => e.id === einsatzId);
+ const kraft = z.daten.kraefte.find((k) => k.quelle === zielQuelle);
+ if (!einsatz || !kraft) return;
+
+ bestaetigen({
+ titel: "Kraft anfragen",
+ inhalt: `
+ ${sicher(kraft.name)} (${sicher(kraft.personalnummer)})
+ soll ${sicher(einsatz.nummer)} übernehmen:
+ ${sicher(einsatz.titel)}${einsatz.ort ? " — " + sicher(einsatz.ort) : ""}
+ ${kraft.gebundenAn ? `
+
+
⚠
+
Diese Kraft ist bereits an ${sicher(kraft.gebundenAn)} gebunden.
+
` : ""}
+
+
ℹ
+
Das ist eine Anfrage, keine Zuweisung. Die Kraft bekommt den
+ Einsatz mit Wegpunkt angezeigt und entscheidet selbst, ob sie übernimmt.
+
`,
+ bestaetigenText: "Anfragen",
+ bestaetigt: async (_, zu) => {
+ try {
+ const antwort = await aufruf("einsatz", "anfragen", {
+ id: einsatzId, quelle: zielQuelle,
+ });
+ zu();
+ meldung("Anfrage an " + antwort.name + " gesendet.", "gut");
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Einzelansicht --------------------------------------------------------- */
+
+ function einzelAnsicht(behaelter, d) {
+ const e = d.einsatz;
+ const aktiveKraefte = d.kraefte.filter((k) => k.aktiv);
+
+ behaelter.innerHTML = `
+
+
+
${sicher(e.nummer)} · ${sicher(e.titel)}
+
${sicher(e.kategorieName)} · ${sicher(prioText[e.prioritaet] || "")}
+ ${e.ort ? " · " + sicher(e.ort) : ""}
+
+
← Leitstelle
+
+
+
+
+
+ Einsatz
+ ${sicher(e.status)}
+
+
+
Angelegt ${zeit(e.erstelltAm)}
+
Von ${sicher(e.erstelltVon)}
+
Quelle ${sicher(e.quelle)}
+ ${e.geschlossenAm ? `
+
Abgeschlossen ${zeit(e.geschlossenAm)}
+
Durch ${sicher(e.geschlossenVon)}
` : ""}
+
+ ${e.beschreibung
+ ? `
${sicher(e.beschreibung).replace(/\n/g, " ")}
` : ""}
+
+
+ ${e.position ? `
+ ◎ Wegpunkt ` : ""}
+ ${e.status !== "abgeschlossen" ? (d.ichDabei
+ ? `Abrücken `
+ : `Übernehmen `) : ""}
+ ${e.status !== "abgeschlossen" && d.rechte.zuweisen
+ ? `Priorität ` : ""}
+ ${e.status !== "abgeschlossen" && d.rechte.schliessen
+ ? `Abschließen ` : ""}
+
+
+
+
+
+ Beteiligte Kräfte
+ ${aktiveKraefte.length} gebunden
+
+
+ ${d.kraefte.length ? d.kraefte.map((k) => `
+
+
${sicher(k.personalnummer || "—")}
+
+
${sicher(k.name)}
+
${sicher(k.rolleName || "")} · seit ${zeit(k.seit)}
+
+
+ ${k.aktiv ? "im Einsatz" : "abgerückt"}
+
`).join("")
+ : `
Noch niemand übernommen.
`}
+
+
+
+
+
+
+ Einsatzverlauf
+ + Notiz
+
+
+ ${d.notizen.length ? d.notizen.map((n) => `
+
+
+
${sicher(n.text)}
+
${sicher(n.autor)} · ${zeit(n.am)}
+
+
`).join("")
+ : `
Noch keine Einträge im Verlauf.
`}
+
+
+
+
+
+ Dokumentation zu diesem Einsatz
+ Das System legt keine an
+
+
+ ${d.behandlungen.length ? d.behandlungen.map((b) => `
+
+
${sicher(b.kennung)}
+
+
${sicher(b.patientName)}
+
${sicher(b.diagnose || "Noch keine Diagnose")}
+
+
+ ${sicher(b.status)}
+
`).join("")
+ : `
Noch keine Behandlung dokumentiert.
`}
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const e = d.einsatz;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ const wegpunkt = behaelter.querySelector("[data-wegpunkt]");
+ if (wegpunkt) wegpunkt.addEventListener("click", () => {
+ anClient("wegpunkt", { x: e.position.x, y: e.position.y });
+ meldung("Wegpunkt gesetzt.");
+ });
+
+ const beitreten = behaelter.querySelector("[data-beitreten]");
+ if (beitreten) beitreten.addEventListener("click", async () => {
+ try {
+ await aufruf("einsatz", "beitreten", { id: e.id });
+ meldung("Einsatz übernommen.", "gut");
+ oeffne(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const verlassen = behaelter.querySelector("[data-verlassen]");
+ if (verlassen) verlassen.addEventListener("click", async () => {
+ try {
+ await aufruf("einsatz", "verlassen", { id: e.id });
+ meldung("Abgerückt.", "gut");
+ oeffne(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ behaelter.querySelector("[data-notiz]").addEventListener("click", () => {
+ fenster({
+ titel: "Notiz zum Einsatzverlauf",
+ inhalt: `Was ist passiert?
+
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("einsatz", "notiz", {
+ id: e.id, text: el.querySelector("[name=text]").value.trim(),
+ });
+ zu();
+ oeffne(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ });
+
+ const prio = behaelter.querySelector("[data-prio]");
+ if (prio) prio.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Priorität ändern",
+ inhalt: `
+ Aktuell: ${sicher(prioText[e.prioritaet])}
+ Neue Priorität
+
+ 1 — Sofort
+ 2 — Dringend
+ 3 — Regulär
+ 4 — Nachrangig
+
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Setzen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("einsatz", "prioritaet", {
+ id: e.id,
+ prioritaet: +el.querySelector("[name=prioritaet]").value,
+ grund,
+ });
+ zu();
+ meldung("Priorität geändert.", "gut");
+ oeffne(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const schliessen = behaelter.querySelector("[data-schliessen]");
+ if (schliessen) schliessen.addEventListener("click", () => {
+ const dokumentiert = d.behandlungen.length;
+
+ bestaetigen({
+ titel: "Einsatz abschließen",
+ gefahr: true,
+ inhalt: `
+ ${sicher(e.nummer)} — ${sicher(e.titel)}
+ Alle noch gebundenen Kräfte werden abgemeldet.
+ ${dokumentiert === 0 ? `
+
+
📄
+
Zu diesem Einsatz ist noch keine Behandlung dokumentiert.
+ Das System legt keine an — du bekommst nur eine Erinnerung.
+ Abschließen ist trotzdem möglich.
+
` : ""}
+ Abschlussvermerk (Pflicht)
+
`,
+ bestaetigenText: "Abschließen",
+ bestaetigt: async (el, zu) => {
+ const vermerk = el.querySelector("[name=vermerk]").value.trim();
+ if (vermerk.length < 5) return meldung("Bitte einen Vermerk angeben.", "fehler");
+ try {
+ await aufruf("einsatz", "schliessen", { id: e.id, vermerk });
+ zu();
+ meldung("Einsatz abgeschlossen.", "gut");
+ z.offen = null;
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+ }
+
+ /* ---- Einsatz anlegen --------------------------------------------------------- */
+
+ function erstellenFenster(behaelter) {
+ const d = z.daten;
+
+ fenster({
+ titel: "Einsatz anlegen",
+ inhalt: `
+ Titel
+
+
+
Kategorie
+
+ ${d.kategorien.map((k) =>
+ `${sicher(k.name)} `).join("")}
+
+
Priorität
+
+ 1 — Sofort
+ 2 — Dringend
+ 3 — Regulär
+ 4 — Nachrangig
+
+
+ Ort
+
+ Beschreibung
+
+
Der Einsatz erscheint für alle als offen .
+ Alarmiert wird niemand gezielt.
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Anlegen", klick: async (el, zu) => {
+ try {
+ await aufruf("einsatz", "erstellen", {
+ titel: el.querySelector("[name=titel]").value.trim(),
+ kategorie: el.querySelector("[name=kategorie]").value,
+ prioritaet: +el.querySelector("[name=prioritaet]").value,
+ ort: el.querySelector("[name=ort]").value.trim(),
+ beschreibung: el.querySelector("[name=beschreibung]").value.trim(),
+ });
+ zu();
+ meldung("Einsatz angelegt.", "gut");
+ z.ordner = "aktuell";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ }
+
+ /* ---- Laden --------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, einsatzId) {
+ try {
+ const d = await aufruf("einsatz", "oeffnen", { id: einsatzId });
+ z.offen = einsatzId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("einsatz", "uebersicht", { ordner: z.ordner });
+
+ // Sprungziel aus einem Hinweis, z. B. „Neuer Einsatz"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.einsatz) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.einsatz);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Leitstelle
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("einsatz", {
+ rendern,
+ aktualisieren() {
+ if (App.zustand.aktivesModul === "einsatz") rendern(App.$("inhalt"));
+ },
+ });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("einsatz", "uebersicht", { ordner: "aktuell" });
+ const klein = begriff.toLowerCase();
+ return (d.einsaetze || [])
+ .filter((e) => (e.nummer + " " + e.titel + " " + (e.ort || ""))
+ .toLowerCase().includes(klein))
+ .slice(0, 5)
+ .map((e) => ({
+ bereich: "Einsatz", titel: e.nummer + " · " + e.titel,
+ unter: e.kategorieName + (e.ort ? " · " + e.ort : ""),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/fahrzeug.js b/resources/[aether]/aether-medical/html/js/module/fahrzeug.js
new file mode 100644
index 0000000..2396848
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/fahrzeug.js
@@ -0,0 +1,621 @@
+/* =====================================================================
+ aether-medical / Ansicht: Fuhrpark
+
+ Zwei Sorten von Angaben, streng getrennt dargestellt:
+ GEMELDET (Tank, Zustand, Position) kommt aus dem Spiel und trägt
+ immer sein Alter mit sich. EINGETRAGEN (Status, Wartung, Zuweisung)
+ stammt von Menschen. Kein gemeldeter Wert ändert einen eingetragenen.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen, anClient } = App;
+
+ const z = {
+ ordner: "dienst", // dienst | werkstatt | ausser
+ art: "", // Filter auf eine Fahrzeugart
+ daten: null,
+ };
+
+ // Marker-Art je Einsatzstatus
+ const statusArt = {
+ verfuegbar: "gut", im_einsatz: "warn",
+ werkstatt: "info", ausser_dienst: "neutral",
+ };
+
+ /* ---- Kleine Helfer ---------------------------------------------------- */
+
+ /** Anzeigename aus einer Konfigurationsliste */
+ function nameAus(liste, kennung) {
+ const treffer = (liste || []).find((e) => e.id === kennung);
+ return treffer ? treffer.name : kennung;
+ }
+
+ /** Farbe eines Balkens nach seinem Wert */
+ function balkenFarbe(wert, grenze) {
+ if (wert === null || wert === undefined) return "var(--text-leise)";
+ if (wert <= grenze / 2) return "var(--kritisch)";
+ if (wert <= grenze) return "var(--warn)";
+ return "var(--gut)";
+ }
+
+ /**
+ * Ein Balken für einen gemeldeten Wert.
+ * Ist die Meldung alt, wird der Balken blass — der Wert sagt dann
+ * nichts über den jetzigen Zustand.
+ */
+ function balken(titel, wert, grenze, veraltet) {
+ const hat = wert !== null && wert !== undefined;
+
+ return `
+
+ ${sicher(titel)}
+ ${hat ? wert + " %" : "nie gemeldet"}
+
+
+
+
+
`;
+ }
+
+ /* ---- Gerüst ------------------------------------------------------------ */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "dienst", text: "Im Dienst", zahl: d.zaehler.verfuegbar + d.zaehler.imEinsatz },
+ { id: "werkstatt", text: "Wartung", zahl: d.zaehler.werkstatt },
+ { id: "ausser", text: "Außer Dienst", zahl: d.zaehler.ausserDienst },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl}
+ `).join("")}
+
`;
+ }
+
+ function kennzahlen(d) {
+ const zk = d.zaehler;
+
+ return `
+
+
Verfügbar
+
${zk.verfuegbar}
+
so eingetragen
+
+
+
Im Einsatz
+
${zk.imEinsatz}
+
${zk.zugewiesen} Fahrzeuge zugewiesen
+
+
+
Wartung offen
+
${zk.werkstatt}
+
Werkstatt oder fällig
+
+
+
Außer Dienst
+
${zk.ausserDienst}
+
fahren nicht
+
+
`;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Fuhrpark
+
Tank, Zustand und Position werden gemeldet, während jemand fährt.
+ Status, Wartung und Zuweisung trägt ein Mensch ein — das eine ändert
+ das andere nie.
+
+ ${d.rechte.pflegen
+ ? `
+ Fahrzeug aufnehmen ` : ""}
+
+
+ ${kennzahlen(d)}
+ ${reiterleiste(d)}
+
+
+
+ Alle Fahrzeugarten
+ ${d.arten.map((a) =>
+ `
+ ${sicher(a.name)} `).join("")}
+
+ Eine Meldung älter als ${d.grenzen.meldung} Minuten
+ wird blass dargestellt.
+
+
+ ${d.liste.length
+ ? `${d.liste.map((f) => karte(f, d)).join("")}
`
+ : `Kein Fahrzeug in diesem Ordner.
`}
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function karte(f, d) {
+ return `
+
+
+
${sicher(f.bezeichnung)}
+
${sicher(f.kennzeichen)}
+
+
+ ${sicher(f.statusName)}
+
+
+
+ ${sicher(f.artName)}
+ ${f.wartung !== "ok"
+ ? `${sicher(f.wartungName)} ` : ""}
+ ${f.zugewiesenAn
+ ? `${sicher(f.zugewiesenAn)} ` : ""}
+
+
+
+ ${balken("Tank", f.tank, d.grenzen.tank, f.meldungVeraltet)}
+ ${balken("Zustand", f.zustand, d.grenzen.zustand, f.meldungVeraltet)}
+
+
+
+ ${f.gemeldet_am
+ ? `gemeldet vor ${sicher(minuten(f.meldungAlter))}${
+ f.letzterFahrer ? " · " + sicher(f.letzterFahrer) : ""}`
+ : "noch nie gemeldet"}
+ ${f.notiz ? ` · ${sicher(f.notiz)}` : ""}
+
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelector("[data-artfilter]").addEventListener("change", (e) => {
+ z.art = e.target.value;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => fahrzeugFenster(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => bearbeitenFenster(behaelter, null));
+ }
+
+ /* ---- Einzelnes Fahrzeug ---------------------------------------------------- */
+
+ async function fahrzeugFenster(behaelter, fahrzeugId) {
+ let d;
+ try {
+ d = await aufruf("fahrzeug", "oeffnen", { id: fahrzeugId });
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const f = d.fahrzeug;
+ const ausstattung = String(f.ausstattung || "")
+ .split(",").map((t) => t.trim()).filter(Boolean);
+
+ const dialog = fenster({
+ titel: `${f.bezeichnung} · ${f.kennzeichen}`,
+ inhalt: `
+ ${f.wartung !== "ok" ? `
+
+
🔧
+
${sicher(f.wartungName)} ${f.notiz ? " — " + sicher(f.notiz) : ""}.
+ Eingetragen von Hand; ob das Fahrzeug fährt, steht im Einsatzstatus.
+
` : ""}
+
+ ${f.meldungVeraltet ? `
+
+
🕓
+
Die letzte Meldung ist ${sicher(minuten(f.meldungAlter))} alt.
+ Tank und Zustand sagen nichts über den jetzigen Stand.
+
` : ""}
+
+
+
Einsatzstatus ${sicher(f.statusName)}
+
Wartung ${sicher(f.wartungName)}
+
Art ${sicher(f.artName)}
+
Modell ${sicher(f.modell)}
+
Station ${sicher(f.station || "—")}
+
Zugewiesen an
+ ${sicher(f.zugewiesenAn || "niemand")}
+
+
+
+ Gemeldet
+ ${f.gemeldetAm
+ ? "vor " + sicher(minuten(f.meldungAlter)) + " von "
+ + sicher(f.letzterFahrer || "unbekannt")
+ : "noch nie"}
+
+
+ ${balken("Tank", f.tank, 20, f.meldungVeraltet)}
+ ${balken("Zustand", f.zustand, 60, f.meldungVeraltet)}
+
+ ${f.position
+ ? `Wegpunkt zur letzten Position `
+ : `Keine Position gemeldet.
`}
+
+
+ Ausstattung
+ notiert, nicht geprüft
+
+ ${ausstattung.length
+ ? `${ausstattung.map((a) =>
+ `${sicher(a)} `).join("")}
`
+ : `Nichts eingetragen.
`}
+
+
+ ${d.rechte.zuweisen
+ ? `Status setzen
+ Zuweisen ` : ""}
+ ${d.rechte.wartung
+ ? `Wartung ` : ""}
+ ${d.rechte.pflegen
+ ? `Bearbeiten ` : ""}
+ ${d.rechte.pflegen && f.status === "ausser_dienst"
+ ? `Aus dem Fuhrpark nehmen ` : ""}
+
+
+
+ Eingetragene Vorgänge
+ aus dem Protokoll, unveränderlich
+
+
+ ${d.spur.length ? d.spur.map((s) => `
+
+
+
${sicher(s.aktion)}
+
${sicher(s.autor)}
+ ${s.grund ? " · " + sicher(s.grund) : ""}
+
+
${zeit(s.am)}
+
`).join("")
+ : `
Noch kein Eintrag.
`}
+
+ `,
+ nachOeffnen: (hg) => {
+ const wegpunkt = hg.querySelector("[data-wegpunkt]");
+ if (wegpunkt) {
+ wegpunkt.addEventListener("click", () => {
+ anClient("wegpunkt", { x: f.position.x, y: f.position.y });
+ meldung("Wegpunkt zur letzten gemeldeten Position gesetzt.");
+ });
+ }
+
+ const knopf = (name, funktion) => {
+ const el = hg.querySelector(`[data-${name}]`);
+ if (el) el.addEventListener("click", () => { dialog.schliessen(); funktion(); });
+ };
+
+ knopf("status", () => statusFenster(behaelter, f, d));
+ knopf("zuweisen", () => zuweisenFenster(behaelter, f));
+ knopf("wartung", () => wartungFenster(behaelter, f, d));
+ knopf("bearbeiten", () => bearbeitenFenster(behaelter, f));
+ knopf("entfernen", () => entfernenFenster(behaelter, f));
+ },
+ });
+ }
+
+ /* ---- Einsatzstatus ----------------------------------------------------------- */
+
+ function statusFenster(behaelter, f, d) {
+ bestaetigen({
+ titel: "Einsatzstatus setzen",
+ inhalt: `
+ Aktuell eingetragen: ${sicher(f.statusName)}
+
+ Neuer Status
+
+ ${d.status.map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
Werkstatt und Außer Dienst darf nur eintragen,
+ wer die Wartung führt — und beides braucht einen Grund.
+
+ Grund
+
+
+ ${f.zugewiesenAn ? `
+
+
🧍
+
Das Fahrzeug ist ${sicher(f.zugewiesenAn)} zugewiesen.
+ Bei Werkstatt oder Außer Dienst wird das gemeldet — die Zuweisung
+ selbst bleibt bestehen.
+
` : ""}`,
+ bestaetigenText: "Status setzen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("fahrzeug", "status", {
+ id: f.id,
+ status: el.querySelector("[name=status]").value,
+ grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung("Status gesetzt.", "gut");
+ rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Zuweisung ------------------------------------------------------------------ */
+
+ async function zuweisenFenster(behaelter, f) {
+ let k;
+ try {
+ k = await aufruf("fahrzeug", "kollegen", {});
+ } catch (fehler) { return meldung(fehler.message, "fehler"); }
+
+ let gewaehlt = f.zugewiesen || null;
+ let aufheben = false;
+
+ bestaetigen({
+ titel: `${f.bezeichnung} zuweisen`,
+ inhalt: `
+ Zugewiesen ist zurzeit: ${sicher(f.zugewiesenAn || "niemand")}
+
+
+
ℹ
+
Eine Zuweisung ist ein Eintrag im Fahrzeugbuch. Sie spawnt kein
+ Fahrzeug, schließt keines auf und gibt keinen Schlüssel aus.
+
+
+ Im Dienst
+
+ ${k.liste.length ? k.liste.map((p) => `
+
+
+
${sicher(p.name)}
+ ${p.selbst ? `du ` : ""}
+
${sicher(p.rolleName)} ·
+ ${sicher(p.personalnummer || "")}
+
+
${sicher(p.funktion || "")}
+
`).join("")
+ : `
Niemand im Dienst.
`}
+
+
+
+
+ Zuweisung aufheben (niemand ist danach eingetragen)
+
+ Vermerk / Grund
+
`,
+ bestaetigenText: "Eintragen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+
+ if (aufheben && !grund) {
+ return meldung("Bitte angeben, warum die Zuweisung entfällt.", "fehler");
+ }
+ if (!aufheben && !gewaehlt) {
+ return meldung("Bitte eine Person wählen.", "fehler");
+ }
+
+ try {
+ await aufruf("fahrzeug", "zuweisen", {
+ id: f.id,
+ charakter: aufheben ? null : gewaehlt,
+ grund,
+ });
+ zu();
+ meldung(aufheben ? "Zuweisung aufgehoben." : "Fahrzeug zugewiesen.", "gut");
+ rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = +el.dataset.person;
+ hg.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+
+ const haken = hg.querySelector("[name=aufheben]");
+ haken.addEventListener("change", () => {
+ aufheben = haken.checked;
+ hg.querySelector("[data-kollegen]").style.opacity = aufheben ? ".45" : "";
+ });
+ },
+ });
+ }
+
+ /* ---- Wartung ---------------------------------------------------------------------- */
+
+ function wartungFenster(behaelter, f, d) {
+ bestaetigen({
+ titel: "Wartungsstatus",
+ inhalt: `
+ Aktuell eingetragen: ${sicher(f.wartungName)}
+
+
+
ℹ
+
Ein gemeldeter Schaden setzt diesen Status nicht. Was das Spiel
+ meldet, ist eine Beobachtung — ob gewartet wird, beurteilst du.
+
+
+ Wartungsstatus
+
+ ${d.wartungsstatus.map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
+ Notiz
+
+ Ein leeres Feld löscht die Notiz.
`,
+ bestaetigenText: "Eintragen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("fahrzeug", "wartung", {
+ id: f.id,
+ wartung: el.querySelector("[name=wartung]").value,
+ notiz: el.querySelector("[name=notiz]").value.trim(),
+ });
+ zu();
+ meldung("Wartungsstatus eingetragen.", "gut");
+ rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Fahrzeug anlegen und bearbeiten ------------------------------------------------ */
+
+ function bearbeitenFenster(behaelter, f) {
+ const d = z.daten;
+ const neu = !f;
+ const vorhanden = String((f && f.ausstattung) || "")
+ .split(",").map((t) => t.trim()).filter(Boolean);
+ const eigene = vorhanden.filter((a) => !d.ausstattung.includes(a));
+
+ bestaetigen({
+ titel: neu ? "Fahrzeug aufnehmen" : `${f.bezeichnung} bearbeiten`,
+ inhalt: `
+
+ Über das Kennzeichen findet die Telemetrie das
+ Fahrzeug wieder — es muss genau dem im Spiel entsprechen.
+
+
+
Modell (Spawnname)
+
+
Art
+
+ ${d.arten.map((a) =>
+ `
+ ${sicher(a.name)} `).join("")}
+
+
+
+ Station
+
+ keine Zuordnung
+ ${d.stationen.map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
+ Ausstattung
+
+ ${d.ausstattung.map((a) => `
+ ${sicher(a)} `).join("")}
+
+
+ Weitere Ausstattung (mit Komma trennen)
+
`,
+ bestaetigenText: neu ? "Aufnehmen" : "Speichern",
+ bestaetigt: async (el, zu) => {
+ const gewaehlt = Array.from(el.querySelectorAll(".fz-teil-knopf.gewaehlt"))
+ .map((k) => k.dataset.teil);
+ const weitere = el.querySelector("[name=eigene]").value
+ .split(",").map((t) => t.trim()).filter(Boolean);
+
+ try {
+ await aufruf("fahrzeug", "speichern", {
+ id: neu ? null : f.id,
+ kennzeichen: el.querySelector("[name=kennzeichen]").value.trim(),
+ bezeichnung: el.querySelector("[name=bezeichnung]").value.trim(),
+ modell: el.querySelector("[name=modell]").value.trim(),
+ art: el.querySelector("[name=art]").value,
+ station: el.querySelector("[name=station]").value,
+ ausstattung: gewaehlt.concat(weitere).join(", "),
+ });
+ zu();
+ meldung(neu ? "Fahrzeug aufgenommen." : "Fahrzeug gespeichert.", "gut");
+ rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-teil]").forEach((el) => {
+ el.addEventListener("click", () => el.classList.toggle("gewaehlt"));
+ });
+ },
+ });
+ }
+
+ function entfernenFenster(behaelter, f) {
+ bestaetigen({
+ titel: `${f.bezeichnung} aus dem Fuhrpark nehmen`,
+ gefahr: true,
+ inhalt: `
+ Das Fahrzeug verschwindet aus dem Fuhrpark. Die Protokolleinträge
+ dazu bleiben erhalten.
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Entfernen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (!grund) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ await aufruf("fahrzeug", "entfernen", { id: f.id, grund });
+ zu();
+ meldung("Fahrzeug entfernt.", "gut");
+ rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden ---------------------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("fahrzeug", "uebersicht", { ordner: z.ordner, art: z.art });
+
+ uebersicht(behaelter);
+
+ // Sprungziel aus einem Hinweis, z. B. „Tankstand gemeldet"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.fahrzeug) {
+ App.zustand.modulZiel = null;
+ return fahrzeugFenster(behaelter, ziel.fahrzeug);
+ }
+ } catch (f) {
+ behaelter.innerHTML = `Fuhrpark
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("fahrzeug", {
+ rendern,
+ aktualisieren() {
+ // Kein offenes Fenster überschreiben
+ if (App.zustand.aktivesModul === "fahrzeug" && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/intensiv.js b/resources/[aether]/aether-medical/html/js/module/intensiv.js
new file mode 100644
index 0000000..d43d40f
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/intensiv.js
@@ -0,0 +1,747 @@
+/* =====================================================================
+ aether-medical / Ansicht: Intensivstation
+
+ Diese Ansicht ist ein Monitor für Dokumentation, nicht für Patienten.
+ Kein Wert hier wird gemessen: der Zustand steht da, weil ein Arzt ihn
+ eingetragen hat, und die Geräteliste ist eine Liste von Namen.
+ Ein Aufenthalt endet erst, wenn jemand ihn beendet.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "laufend", // laufend | beendet
+ offen: null, // geöffneter Aufenthalt
+ daten: null,
+ };
+
+ // Marker-Art je Zustand — nur Farbe, keine Wertung
+ const zustandArt = { kritisch: "kritisch", stabil: "info", besserung: "gut" };
+
+ /* ---- Kleine Helfer ---------------------------------------------------- */
+
+ /** Farbe eines Zustands aus der Konfiguration */
+ function zustandFarbe(kennung) {
+ const liste = (z.daten && z.daten.zustaende) || [];
+ const treffer = liste.find((s) => s.id === kennung);
+ return treffer ? treffer.farbe : "#8697a4";
+ }
+
+ /** Zerlegt die Geräteliste in einzelne Namen */
+ function geraeteTeile(text) {
+ return String(text || "").split(",").map((t) => t.trim()).filter(Boolean);
+ }
+
+ /* ---- Gerüst ------------------------------------------------------------ */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "laufend", text: "In Betreuung", zahl: d.zaehler.laufend },
+ { id: "beendet", text: "Beendet", zahl: d.zaehler.beendetHeute },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl}
+ `).join("")}
+
`;
+ }
+
+ function kennzahlen(d) {
+ const zk = d.zaehler;
+
+ return `
+
+
In Betreuung
+
${zk.laufend}
+
laufende Aufenthalte
+
+
+
Als kritisch eingetragen
+
${zk.kritisch}
+
von Hand eingestuft
+
+
+
Ohne zuständigen Arzt
+
${zk.ohneArzt}
+
niemand eingetragen
+
+
+
Ohne frischen Eintrag
+
${zk.langeStill}
+
seit über ${d.stilleNach} Minuten
+
+
`;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Intensivstation
+
Das System misst nichts und schlägt keinen Alarm. Was hier steht, hat
+ jemand eingetragen — der Zustand ändert sich nie von selbst.
+
+ ${d.rechte.betreuen
+ ? `
+ Intensivaufnahme ` : ""}
+
+
+ ${kennzahlen(d)}
+ ${reiterleiste(d)}
+
+
+
+ ${z.ordner === "laufend"
+ ? "Patienten in Intensivbetreuung" : "Beendete Aufenthalte"}
+ ${z.ordner === "laufend"
+ ? "Oben steht, was als am schwersten eingetragen wurde"
+ : d.liste.length + " Einträge"}
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
${z.ordner === "laufend"
+ ? "Zurzeit wird niemand intensiv betreut."
+ : "Noch keine beendeten Aufenthalte."}
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(a) {
+ const laufend = !a.beendet_am;
+
+ return `
+
+
+
+
${sicher(a.name)}
+ geb. ${sicher(a.geburt)}
+
+ ${a.zimmerKennung ? "Zimmer " + sicher(a.zimmerKennung) : "ohne festen Platz"}
+ ${a.geraete ? " · " + sicher(String(a.geraete).slice(0, 60)) : ""}
+ ${laufend ? " · zuletzt dokumentiert vor " + sicher(minuten(a.stillSeit)) : ""}
+
+
+
+ ${a.warnhinweis ? `
⚠ ` : ""}
+ ${a.allergien ? `
A ` : ""}
+ ${a.blutgruppe ? `
${sicher(a.blutgruppe)} ` : ""}
+
+ ${a.langeStill ? `
Verlauf offen ` : ""}
+ ${laufend && !a.verantwortlichName
+ ? `
kein Arzt `
+ : a.verantwortlichName
+ ? `
${sicher(a.verantwortlichName)} ` : ""}
+
+
+ ${sicher(a.zustandName)}
+
+
${laufend
+ ? "seit " + sicher(minuten(a.liegtSeit))
+ : zeit(a.beendet_am)}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const aufnehmen = behaelter.querySelector("[data-aufnehmen]");
+ if (aufnehmen) aufnehmen.addEventListener("click", () => aufnahmeFenster(behaelter));
+ }
+
+ /* ---- Einzelansicht -------------------------------------------------------- */
+
+ function einzelAnsicht(behaelter, d) {
+ const a = d.aufenthalt, s = d.stamm;
+ const geraete = geraeteTeile(a.geraete);
+
+ behaelter.innerHTML = `
+
+
+
${sicher(a.name)}
+
geb. ${sicher(a.geburt)} · ${sicher(a.zustandName)}
+ ${a.zimmerKennung ? " · Zimmer " + sicher(a.zimmerKennung) : ""}
+ ${a.stationName ? " · " + sicher(a.stationName) : ""}
+
+
← Übersicht
+
+
+ ${a.laufend ? "" : `
+
+
✔
+
Intensivbetreuung beendet — ${zeit(a.beendetAm)} von
+ ${sicher(a.beendetVon || "unbekannt")}. Der Aufenthalt ist nur noch
+ zum Nachlesen da.
+
`}
+
+ ${s.hinweis ? `
+
+
⚠
+
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""}
+
+ ${s.allergien ? `
+
+
💊
+
Allergien — ${sicher(s.allergien)}
+
` : ""}
+
+ ${a.laufend && !a.verantwortlichName ? `
+
+
🧑⚕️
+
Kein zuständiger Arzt eingetragen. Das System wählt keinen aus —
+ jemand muss die Verantwortung übernehmen.
+
` : ""}
+
+
+
${s.lichtbild
+ ? `
`
+ : `
Kein Lichtbild `}
+
+
+
Zustand
+
+ ${sicher(a.zustandName)}
+
In Betreuung seit
+ ${sicher(minuten(a.liegtSeit))}
+
Zuständig
+ ${sicher(a.verantwortlichName || "niemand eingetragen")}
+
Platz
+ ${sicher(a.zimmerKennung || "kein fester Platz")}
+
Blutgruppe
+ ${sicher(s.blutgruppe || "nicht bestimmt")}
+
Dauermedikation
+ ${sicher(s.dauermedikation || "keine erfasst")}
+
+
+
+ Patientenakte öffnen
+ ${a.aufnahme
+ ? `Aufnahme öffnen ` : ""}
+ ${a.laufend && d.rechte.betreuen ? `
+ Zustand eintragen
+ Geräte
+ Zuständigkeit
+ Betreuung beenden ` : ""}
+
+
+
+
+
+
Aufenthalt
+
+
Aufgenommen ${zeit(a.aufgenommenAm)}
+
Aufgenommen von ${sicher(a.aufgenommenVon)}
+ ${a.aufnahmeGrund
+ ? `
Grund der Aufnahme ${sicher(a.aufnahmeGrund)}
` : ""}
+ ${a.beendetAm ? `
+
Beendet ${zeit(a.beendetAm)}
+
Beendet von ${sicher(a.beendetVon)}
` : ""}
+
+
+
+ Angeschlossene Geräte
+ nur notiert, nicht gesteuert
+
+ ${geraete.length
+ ? `
${geraete.map((g) =>
+ `${sicher(g)} `).join("")}
`
+ : `
Nichts eingetragen.
`}
+
+
+ Grund der Intensivbetreuung
+
+
+ ${sicher(a.notiz || "Nichts eingetragen.")}
+
+
+
+
+ Verlauf
+ ${d.verlauf.length} Einträge
+
+
+ ${a.laufend && d.rechte.betreuen ? `
+
+
+
+ Eintrag schreiben
+ Der Wortlaut bleibt genau so stehen.
+
+
` : ""}
+
+ ${d.verlauf.length ? `
+
+ ${d.verlauf.map((v) => `
+
+
✎
+
+
+ ${sicher(v.autor)}
+ ${zeit(v.am)}
+
+
${sicher(v.text)}
+
+
`).join("")}
+
`
+ : `
Noch nichts dokumentiert.
`}
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const a = d.aufenthalt;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelector("[data-akte]").addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "mdt")) {
+ return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("mdt", { patient: a.patient });
+ });
+
+ const vorgang = behaelter.querySelector("[data-vorgang]");
+ if (vorgang) {
+ vorgang.addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "patienten")) {
+ return meldung("Die Patientenverwaltung steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("patienten", { aufnahme: a.aufnahme });
+ });
+ }
+
+ const zustand = behaelter.querySelector("[data-zustand]");
+ if (zustand) zustand.addEventListener("click", () => zustandFenster(behaelter, d));
+
+ const geraete = behaelter.querySelector("[data-geraete]");
+ if (geraete) geraete.addEventListener("click", () => geraeteFenster(behaelter, d));
+
+ const zustaendig = behaelter.querySelector("[data-zustaendig]");
+ if (zustaendig) zustaendig.addEventListener("click", () => zustaendigFenster(behaelter, d));
+
+ const beenden = behaelter.querySelector("[data-beenden]");
+ if (beenden) beenden.addEventListener("click", () => beendenFenster(behaelter, d));
+
+ const speichern = behaelter.querySelector("[data-verlaufspeichern]");
+ if (speichern) {
+ speichern.addEventListener("click", async () => {
+ const feld = behaelter.querySelector("[data-neuerverlauf]");
+ const text = feld.value.trim();
+ if (text.length < 3) return meldung("Bitte einen Text eingeben.", "fehler");
+ try {
+ await aufruf("intensiv", "verlauf", { id: a.id, text });
+ meldung("Eintrag geschrieben.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ }
+ }
+
+ /* ---- Intensivaufnahme ------------------------------------------------------ */
+
+ async function aufnahmeFenster(behaelter) {
+ let k;
+ try {
+ k = await aufruf("intensiv", "kandidaten", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ let gewaehlt = null; // Patient/Aufnahme
+ let gewaehltesZimmer = null; // Zimmer der Betreuung
+
+ bestaetigen({
+ titel: "Patient auf die Intensivstation aufnehmen",
+ inhalt: `
+ Patient (aufgenommen, ohne laufende Intensivbetreuung)
+
+ ${k.patienten.length ? k.patienten.map((p) => `
+
+
+
+
${sicher(p.name)}
+ geb. ${sicher(p.geburt)}
+
${sicher(String(p.grund || "").slice(0, 70))}
+
+ ${p.warnhinweis ? `
⚠ ` : ""}
+ ${p.allergien ? `
A ` : ""}
+ ${p.zimmerKennung ? `
${sicher(p.zimmerKennung)} ` : ""}
+
`).join("")
+ : `
Kein aufgenommener Patient verfügbar.
`}
+
+
Auf die Intensivstation kommt nur, wer bereits
+ aufgenommen ist. Die Reihenfolge ist die eingetragene Dringlichkeit.
+
+
+ Zustand bei Übernahme
+
+ ${(z.daten.zustaende || []).map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
Deine Einschätzung. Das System leitet sie aus nichts ab.
+
+
+ Platz auf der Intensivstation (freiwillig)
+
+ ${k.zimmer.length ? k.zimmer.map((zi) => `
+
+ ${sicher(zi.kennung)}
+ ${zi.belegt}/${zi.betten} belegt
+ ${zi.status !== "frei"
+ ? `${sicher(zi.status)} ` : ""}
+ `).join("")
+ : `
Kein Intensivplatz angelegt.
`}
+
+
Hier wird nur festgehalten, wo betreut wird.
+ Das Bett selbst belegt die Stationsverwaltung.
+
+
+ Angeschlossene Geräte (freiwillig)
+
+
+ Grund der Intensivbetreuung (Pflicht)
+
+
+ `,
+ bestaetigenText: "Aufnehmen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler");
+
+ const notiz = el.querySelector("[name=notiz]").value.trim();
+ if (notiz.length < 5) return meldung("Bitte den Grund angeben.", "fehler");
+
+ try {
+ const antwort = await aufruf("intensiv", "aufnehmen", {
+ aufnahme: gewaehlt,
+ zimmer: gewaehltesZimmer,
+ zustand: el.querySelector("[name=zustand]").value,
+ geraete: el.querySelector("[name=geraete]").value.trim(),
+ notiz,
+ selbst: el.querySelector("[name=selbst]").checked,
+ });
+ zu();
+ meldung("Intensivaufenthalt eröffnet.", "gut");
+ z.ordner = "laufend";
+ oeffne(behaelter, antwort.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-patient]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = +el.dataset.patient;
+ hg.querySelectorAll("[data-patient]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+
+ hg.querySelectorAll("[data-zimmer]").forEach((el) => {
+ el.addEventListener("click", () => {
+ // Ein zweiter Klick nimmt die Wahl wieder zurück
+ const schonGewaehlt = gewaehltesZimmer === +el.dataset.zimmer;
+ hg.querySelectorAll("[data-zimmer]").forEach((k) => k.classList.remove("gewaehlt"));
+ gewaehltesZimmer = schonGewaehlt ? null : +el.dataset.zimmer;
+ if (!schonGewaehlt) el.classList.add("gewaehlt");
+ });
+ });
+ },
+ });
+ }
+
+ /* ---- Zustand eintragen ------------------------------------------------------- */
+
+ function zustandFenster(behaelter, d) {
+ const a = d.aufenthalt;
+
+ bestaetigen({
+ titel: "Zustand eintragen",
+ inhalt: `
+ Aktuell eingetragen: ${sicher(a.zustandName)}
+ — von ${sicher(a.verantwortlichName || "niemandem")} verantwortet.
+
+ Neue Einschätzung
+
+ ${(d.zustaende || []).map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
+ Begründung (Pflicht)
+
+
Dieser Text wandert unverändert in den Verlauf.
`,
+ bestaetigenText: "Eintragen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("intensiv", "zustand", {
+ id: a.id,
+ zustand: el.querySelector("[name=zustand]").value,
+ grund,
+ });
+ zu();
+ meldung("Zustand eingetragen.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Geräte ------------------------------------------------------------------- */
+
+ function geraeteFenster(behaelter, d) {
+ const a = d.aufenthalt;
+ const vorschlaege = d.geraeteliste || [];
+ const vorhanden = geraeteTeile(a.geraete);
+
+ // Alles, was nicht in der Vorschlagsliste steht, bleibt als Freitext erhalten
+ const eigene = vorhanden.filter((g) => !vorschlaege.includes(g));
+
+ bestaetigen({
+ titel: "Angeschlossene Geräte",
+ inhalt: `
+
+
ℹ
+
Das System steuert kein Gerät, liest keinen Wert aus und schlägt keinen
+ Alarm. Es hält fest, was jemand angeschlossen hat.
+
+
+ Aus der Liste
+
+ ${vorschlaege.map((g) => `
+ ${sicher(g)} `).join("")}
+
+
+ Weitere (mit Komma trennen)
+
`,
+ bestaetigenText: "Speichern",
+ bestaetigt: async (el, zu) => {
+ const gewaehlt = Array.from(el.querySelectorAll(".its-geraet-knopf.gewaehlt"))
+ .map((k) => k.dataset.geraet);
+ const eigeneTexte = geraeteTeile(el.querySelector("[name=eigene]").value);
+
+ try {
+ await aufruf("intensiv", "geraete", {
+ id: a.id,
+ geraete: gewaehlt.concat(eigeneTexte).join(", "),
+ });
+ zu();
+ meldung("Geräte eingetragen.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-geraet]").forEach((el) => {
+ el.addEventListener("click", () => el.classList.toggle("gewaehlt"));
+ });
+ },
+ });
+ }
+
+ /* ---- Zuständigkeit -------------------------------------------------------------- */
+
+ async function zustaendigFenster(behaelter, d) {
+ const a = d.aufenthalt;
+
+ let b;
+ try {
+ b = await aufruf("intensiv", "betreuer", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ let gewaehlt = a.verantwortlich || null;
+ let niederlegen = false;
+
+ bestaetigen({
+ titel: "Zuständigkeit",
+ inhalt: `
+ Zuständig ist zurzeit:
+ ${sicher(a.verantwortlichName || "niemand")}
+
+ Im Dienst und berechtigt
+
+ ${b.liste.length ? b.liste.map((p) => `
+
+
+
${sicher(p.name)}
+ ${p.selbst ? `du ` : ""}
+
${sicher(p.rolleName)} ·
+ ${sicher(p.personalnummer || "")}
+
+
${sicher(p.funktion || "")}
+
`).join("")
+ : `
Niemand mit diesem Recht ist im Dienst.
`}
+
+
Das System verteilt keine Zuständigkeit —
+ sie wird übernommen oder übertragen.
+
+
+
+ Zuständigkeit niederlegen (niemand ist danach eingetragen)
+
+ Vermerk${a.verantwortlich ? " / Grund" : " (freiwillig)"}
+
`,
+ bestaetigenText: "Übernehmen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+
+ if (niederlegen && !grund) {
+ return meldung("Bitte angeben, warum niemand mehr zuständig ist.", "fehler");
+ }
+ if (!niederlegen && !gewaehlt) {
+ return meldung("Bitte eine Person wählen.", "fehler");
+ }
+
+ try {
+ await aufruf("intensiv", "verantwortlich", {
+ id: a.id,
+ charakter: niederlegen ? null : gewaehlt,
+ grund,
+ });
+ zu();
+ meldung(niederlegen ? "Zuständigkeit niedergelegt." : "Zuständigkeit eingetragen.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = +el.dataset.person;
+ hg.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+
+ const haken = hg.querySelector("[name=niederlegen]");
+ haken.addEventListener("change", () => {
+ niederlegen = haken.checked;
+ // Die Personenliste wird unbrauchbar, sobald niedergelegt wird
+ hg.querySelector("[data-betreuer]").style.opacity = niederlegen ? ".45" : "";
+ });
+ },
+ });
+ }
+
+ /* ---- Betreuung beenden ------------------------------------------------------------ */
+
+ function beendenFenster(behaelter, d) {
+ const a = d.aufenthalt;
+
+ bestaetigen({
+ titel: "Intensivbetreuung beenden",
+ gefahr: true,
+ inhalt: `
+ ${sicher(a.name)} wird ab sofort nicht mehr intensiv betreut.
+
+
+
ℹ
+
Das ist keine Entlassung. Der Patient bleibt aufgenommen, sein Bett
+ bleibt belegt und keine Behandlung wird dadurch abgeschlossen.
+
+
+ ${a.zustand === "kritisch" ? `
+
+
⚠
+
Der Zustand steht weiterhin auf kritisch . Das Beenden ist
+ trotzdem möglich — es wird so festgehalten.
+
` : ""}
+
+ Begründung (Pflicht)
+
+
Dieser Text schließt den Verlauf ab.
`,
+ bestaetigenText: "Betreuung beenden",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("intensiv", "beenden", { id: a.id, grund });
+ zu();
+ meldung("Intensivbetreuung beendet.", "gut");
+ z.offen = null;
+ z.ordner = "beendet";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden ----------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, intensivId) {
+ try {
+ const d = await aufruf("intensiv", "oeffnen", { id: intensivId });
+ z.offen = intensivId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("intensiv", "uebersicht", { ordner: z.ordner });
+
+ // Sprungziel aus einem Hinweis, z. B. „als kritisch eingetragen"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.intensiv) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.intensiv);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Intensivstation
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("intensiv", {
+ rendern,
+ aktualisieren() {
+ // Eine offene Einzelansicht oder ein offenes Fenster nicht stören
+ if (App.zustand.aktivesModul === "intensiv" && !z.offen
+ && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/kommunikation.js b/resources/[aether]/aether-medical/html/js/module/kommunikation.js
new file mode 100644
index 0000000..beaa52d
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/kommunikation.js
@@ -0,0 +1,741 @@
+/* =====================================================================
+ aether-medical / Ansicht: Nachrichten und Aushänge
+
+ Postfach und Schwarzes Brett. Abgeschickt ist abgeschickt — es gibt
+ hier bewusst kein Bearbeiten und kein Löschen von Nachrichten.
+ Ein Aushang wird archiviert, nie entfernt. Und eine Kenntnisnahme
+ setzt jeder nur für sich selbst.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "posteingang", // posteingang | gesendet | aushang | archiv
+ daten: null,
+ };
+
+ // Marker-Art je Empfängerart
+ const typArt = { rundmeldung: "kritisch", station: "warn", person: "info" };
+ const typName = { rundmeldung: "Rundmeldung", station: "Station", person: "Persönlich" };
+
+ /* ---- Kleine Helfer ---------------------------------------------------- */
+
+ /** Anzeigename einer Aushangkategorie */
+ function kategorieName(kennung) {
+ const treffer = (z.daten.kategorien || []).find((k) => k.id === kennung);
+ return treffer ? treffer.name : kennung;
+ }
+
+ /** Farbe einer Aushangkategorie */
+ function kategorieFarbe(kennung) {
+ const treffer = (z.daten.kategorien || []).find((k) => k.id === kennung);
+ return treffer ? treffer.farbe : "#8697a4";
+ }
+
+ /** Beschreibt eine Frist in Worten */
+ function fristText(a) {
+ if (a.restMinuten === null || a.restMinuten === undefined) return null;
+ if (a.restMinuten <= 0) return `Frist seit ${minuten(-a.restMinuten)} abgelaufen`;
+ return `noch ${minuten(a.restMinuten)}`;
+ }
+
+ /* ---- Gerüst ------------------------------------------------------------ */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "posteingang", text: "Posteingang", zahl: d.zaehler.ungelesen },
+ { id: "gesendet", text: "Gesendet", zahl: d.zaehler.gesendet },
+ { id: "aushang", text: "Schwarzes Brett", zahl: d.zaehler.aushaenge },
+ { id: "archiv", text: "Archiv", zahl: 0 },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl > 0 ? `${e.zahl} ` : ""}
+ `).join("")}
+
`;
+ }
+
+ function kennzahlen(d) {
+ const zk = d.zaehler;
+
+ return `
+
+
Ungelesen
+
${zk.ungelesen}
+
im Posteingang
+
+
+
Kenntnisnahme offen
+
${zk.offeneKenntnis}
+
von dir noch nicht bestätigt
+
+
+
Aushänge
+
${zk.aushaenge}
+
hängen gerade
+
+
+
Von dir gesendet
+
${zk.gesendet}
+
bleiben unverändert stehen
+
+
`;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+ const amBrett = z.ordner === "aushang" || z.ordner === "archiv";
+
+ behaelter.innerHTML = `
+
+
+
Nachrichten und Aushänge
+
Was hier steht, hat jemand geschrieben und unterschrieben. Eine gesendete
+ Nachricht lässt sich nicht mehr ändern, ein Aushang wird archiviert statt
+ gelöscht.
+
+
+ ${d.rechte.senden
+ ? `✉ Nachricht schreiben ` : ""}
+ ${d.rechte.schreiben
+ ? `+ Aushang veröffentlichen ` : ""}
+
+
+
+ ${kennzahlen(d)}
+ ${reiterleiste(d)}
+
+ ${amBrett ? brettAnsicht(d) : postAnsicht(d)}
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ /* ---- Postfach ------------------------------------------------------------- */
+
+ function postAnsicht(d) {
+ const gesendet = z.ordner === "gesendet";
+
+ return `
+
+ ${gesendet ? "Von dir gesendet" : "Posteingang"}
+ ${gesendet
+ ? "Gelesen heißt: jemand hat sie geöffnet"
+ : "Als gelesen gilt, was du geöffnet hast"}
+
+
+ ${d.liste.length ? d.liste.map(gesendet ? gesendetZeile : postZeile).join("")
+ : `
${gesendet
+ ? "Du hast noch nichts geschrieben." : "Keine Nachrichten."}
`}
+
+
`;
+ }
+
+ function postZeile(n) {
+ return `
+
+
+
+
${sicher(n.betreff || "(ohne Betreff)")}
+
${sicher(n.absenderName)} · ${sicher(n.vorschau)}
+
+
+
+ ${sicher(typName[n.empf_typ] || n.empf_typ)}
+
${zeit(n.gesendet)}
+
`;
+ }
+
+ function gesendetZeile(n) {
+ return `
+
+
${sicher(n.betreff || "(ohne Betreff)")}
+
an ${sicher(n.empfaengerName)} · ${sicher(n.vorschau)}
+
+
+
+ ${sicher(typName[n.empf_typ] || n.empf_typ)}
+
${n.gelesenVon} gelesen
+
${zeit(n.gesendet)}
+
`;
+ }
+
+ /* ---- Schwarzes Brett ------------------------------------------------------- */
+
+ function brettAnsicht(d) {
+ const archiv = z.ordner === "archiv";
+
+ if (!d.liste.length) {
+ return `${archiv
+ ? "Nichts archiviert." : "Am Brett hängt nichts."}
`;
+ }
+
+ return `${d.liste.map((a) => `
+
+
+
+ ${a.angeheftet ? `📌 ` : ""}
+ ${sicher(a.titel)}
+
${sicher(kategorieName(a.kategorie))}
+
+
+
${sicher(a.vorschau)}
+
+
+ ${sicher(a.autor)} · ${zeit(a.erstellt_am)}
+
+ ${a.station ? `${sicher(a.stationName)} ` : ""}
+ ${a.kenntnispflicht
+ ? (a.bestaetigt
+ ? `gelesen `
+ : `Kenntnis offen `)
+ : ""}
+ ${a.kenntnispflicht
+ ? `${a.kenntnisse} bestätigt ` : ""}
+ ${fristText(a)
+ ? `
+ ${sicher(fristText(a))} ` : ""}
+
+
+
+ ${archiv && a.archiviertVon
+ ? `
Abgenommen von ${sicher(a.archiviertVon)} ·
+ ${zeit(a.archiviert_am)}
` : ""}
+
`).join("")}
`;
+ }
+
+ /* ---- Verdrahtung ------------------------------------------------------------ */
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-nachricht]").forEach((el) => {
+ el.addEventListener("click", () => nachrichtFenster(behaelter, +el.dataset.nachricht));
+ });
+
+ behaelter.querySelectorAll("[data-aushang]").forEach((el) => {
+ el.addEventListener("click", () => aushangFenster(behaelter, +el.dataset.aushang));
+ });
+
+ const schreiben = behaelter.querySelector("[data-schreiben]");
+ if (schreiben) schreiben.addEventListener("click", () => schreibenFenster(behaelter, null));
+
+ const neuerAushang = behaelter.querySelector("[data-neueraushang]");
+ if (neuerAushang) {
+ neuerAushang.addEventListener("click", () => aushangSchreibenFenster(behaelter, null));
+ }
+ }
+
+ /* ---- Nachricht lesen ---------------------------------------------------------- */
+
+ async function nachrichtFenster(behaelter, nachrichtId) {
+ let d;
+ try {
+ d = await aufruf("kommunikation", "nachricht", { id: nachrichtId });
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const n = d.nachricht;
+
+ const dialog = fenster({
+ titel: n.betreff || "(ohne Betreff)",
+ inhalt: `
+
+
Von ${sicher(n.absenderName)}
+
An ${sicher(n.empfaengerName)}
+
Gesendet ${zeit(n.gesendet)}
+
+
+ ${sicher(n.text)}
+
+ ${n.vonMir ? `
+
+ Gelesen von
+ ${d.leser.length} Person(en)
+
+
+ ${d.leser.length ? d.leser.map((l) => `
+
+
+
${zeit(l.gelesen_am)}
+
`).join("")
+ : `
Noch niemand hat sie geöffnet.
`}
+
` : ""}
+
+ ${!n.vonMir && d.rechte.senden ? `
+
+ Antworten
+
` : ""}
+ `,
+ nachOeffnen: (hg) => {
+ const antworten = hg.querySelector("[data-antworten]");
+ if (antworten) {
+ antworten.addEventListener("click", () => {
+ dialog.schliessen();
+ schreibenFenster(behaelter, {
+ typ: "person",
+ ziel: n.absender,
+ zielName: n.absenderName,
+ betreff: n.betreff && !/^Re:/i.test(n.betreff)
+ ? "Re: " + n.betreff : (n.betreff || ""),
+ });
+ });
+ }
+ },
+ });
+
+ // Der Posteingang zeigt sie danach als gelesen
+ rendernStill(behaelter);
+ }
+
+ /* ---- Nachricht schreiben -------------------------------------------------------- */
+
+ async function schreibenFenster(behaelter, vorgabe) {
+ let e;
+ try {
+ e = await aufruf("kommunikation", "empfaenger", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ let typ = (vorgabe && vorgabe.typ) || "person";
+ let ziel = (vorgabe && vorgabe.ziel) || null;
+
+ bestaetigen({
+ titel: "Nachricht schreiben",
+ inhalt: `
+
+
ℹ
+
Eine abgeschickte Nachricht lässt sich weder ändern noch löschen.
+ Sie bleibt mit deinem Namen stehen.
+
+
+ Empfänger
+
+ Person
+ Station
+ ${e.rechte.rundmeldung
+ ? `Alle im Haus ` : ""}
+
+
+
+
+
+
+ ${e.personal.map((p) => `
+
+
+
${sicher(p.name)}
+ ${p.selbst ? `du ` : ""}
+
${sicher(p.rolleName)} ·
+ ${sicher(p.personalnummer || "")} · ${sicher(p.stationName)}
+
+ ${p.imDienst ? `
im Dienst `
+ : `
außer Dienst `}
+
`).join("")}
+
+
Wer nicht im Dienst ist, bekommt die Nachricht
+ beim nächsten Öffnen des Systems.
+
+
+
+ Station
+
+ ${e.stationen.map((s) =>
+ `${sicher(s.name)} `).join("")}
+
+
+
+
+
📣
+
Diese Nachricht geht an jeden im Haus und wird allen im Dienst
+ sofort gemeldet.
+
+
+ Betreff
+
+
+ Text
+
`,
+ bestaetigenText: "Abschicken",
+ bestaetigt: async (el, zu) => {
+ const text = el.querySelector("[name=text]").value.trim();
+ if (text.length < 2) return meldung("Bitte einen Text schreiben.", "fehler");
+
+ if (typ === "person" && !ziel) {
+ return meldung("Bitte einen Empfänger wählen.", "fehler");
+ }
+
+ const zielWert = typ === "station"
+ ? el.querySelector("[name=station]").value
+ : (typ === "person" ? ziel : null);
+
+ try {
+ const antwort = await aufruf("kommunikation", "senden", {
+ typ, ziel: zielWert,
+ betreff: el.querySelector("[name=betreff]").value.trim(),
+ text,
+ });
+ zu();
+ meldung(`Nachricht an ${antwort.empfaenger} abgeschickt.`, "gut");
+ z.ordner = "gesendet";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ // Empfängerart umschalten
+ hg.querySelectorAll("[data-typ]").forEach((el) => {
+ el.addEventListener("click", () => {
+ typ = el.dataset.typ;
+ hg.querySelectorAll("[data-typ]").forEach((k) => k.classList.remove("aktiv"));
+ el.classList.add("aktiv");
+ hg.querySelectorAll("[data-wahl]").forEach((block) => {
+ block.style.display = block.dataset.wahl === typ ? "" : "none";
+ });
+ });
+ });
+
+ // Person wählen
+ hg.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ ziel = +el.dataset.person;
+ hg.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+
+ // Personenliste filtern
+ const suche = hg.querySelector("[name=suche]");
+ suche.addEventListener("input", () => {
+ const begriff = suche.value.trim().toLowerCase();
+ hg.querySelectorAll("[data-person]").forEach((el) => {
+ el.style.display = !begriff || el.dataset.suchtext.includes(begriff) ? "" : "none";
+ });
+ });
+ },
+ });
+ }
+
+ /* ---- Aushang lesen -------------------------------------------------------------- */
+
+ async function aushangFenster(behaelter, aushangId) {
+ let d;
+ try {
+ d = await aufruf("kommunikation", "aushang", { id: aushangId });
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const a = d.aushang;
+
+ const dialog = fenster({
+ titel: a.titel,
+ inhalt: `
+ ${a.aktiv ? "" : `
+
+
📁
+
Archiviert — abgenommen von ${sicher(a.archiviertVon || "unbekannt")}
+ am ${zeit(a.archiviertAm)}. Der Wortlaut bleibt nachlesbar.
+
`}
+
+ ${a.kenntnispflicht && !a.bestaetigt && a.aktiv ? `
+
+
✋
+
Kenntnisnahme steht aus. Bestätigen kannst nur du selbst —
+ niemand kann das für dich tun.
+
` : ""}
+
+ ${fristText(a) ? `
+
+
🕓
+
${sicher(fristText(a))}. Eine abgelaufene Frist erinnert —
+ sie hat keine weitere Folge.
+
` : ""}
+
+
+
Kategorie ${sicher(kategorieName(a.kategorie))}
+
Gilt für ${sicher(a.stationName)}
+
Veröffentlicht ${zeit(a.erstelltAm)}
+
Von ${sicher(a.autor)}
+
+
+ ${sicher(a.text)}
+
+
+ ${a.aktiv && a.kenntnispflicht && !a.bestaetigt
+ ? `Zur Kenntnis genommen ` : ""}
+ ${a.aktiv && d.rechte.schreiben
+ ? `Bearbeiten
+
+ ${a.angeheftet ? "Lösen" : "Anheften"} ` : ""}
+ ${a.aktiv && d.rechte.archivieren
+ ? `Abnehmen ` : ""}
+
+
+ ${a.textGesperrt && a.aktiv ? `
+
+ ${d.kenntnis.length} Person(en) haben diesen Wortlaut gelesen — Titel und Text
+ lassen sich deshalb nicht mehr ändern. Für eine Änderung: abnehmen und neu
+ veröffentlichen.
` : ""}
+
+ ${a.kenntnispflicht ? `
+
+
+
+ Zur Kenntnis genommen
+ ${d.kenntnis.length}
+
+
+ ${d.kenntnis.length ? d.kenntnis.map((k) => `
+
+
${sicher(k.personalnummer || "—")}
+
+
${zeit(k.bestaetigt_am)}
+
`).join("")
+ : `
Noch niemand.
`}
+
+
+
+
+
+ Steht noch aus
+ ${d.offen.length}
+
+
+ ${d.offen.length ? d.offen.map((o) => `
+
+
${sicher(o.personalnummer || "—")}
+
+
`).join("")
+ : `
Alle haben bestätigt.
`}
+
+
+
` : ""}
+ `,
+ nachOeffnen: (hg) => {
+ const kenntnis = hg.querySelector("[data-kenntnis]");
+ if (kenntnis) {
+ kenntnis.addEventListener("click", async () => {
+ try {
+ await aufruf("kommunikation", "kenntnis", { id: a.id });
+ dialog.schliessen();
+ meldung("Kenntnisnahme bestätigt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ }
+
+ const anheften = hg.querySelector("[data-anheften]");
+ if (anheften) {
+ anheften.addEventListener("click", async () => {
+ try {
+ await aufruf("kommunikation", "anheften", {
+ id: a.id, angeheftet: !a.angeheftet,
+ });
+ dialog.schliessen();
+ meldung(a.angeheftet ? "Aushang gelöst." : "Aushang angeheftet.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ }
+
+ const bearbeiten = hg.querySelector("[data-bearbeiten]");
+ if (bearbeiten) {
+ bearbeiten.addEventListener("click", () => {
+ dialog.schliessen();
+ aushangSchreibenFenster(behaelter, a);
+ });
+ }
+
+ const archivieren = hg.querySelector("[data-archivieren]");
+ if (archivieren) {
+ archivieren.addEventListener("click", () => {
+ dialog.schliessen();
+ archivierenFenster(behaelter, a);
+ });
+ }
+ },
+ });
+ }
+
+ /* ---- Aushang veröffentlichen ------------------------------------------------------ */
+
+ function aushangSchreibenFenster(behaelter, a) {
+ const d = z.daten;
+ const neu = !a;
+
+ bestaetigen({
+ titel: neu ? "Aushang veröffentlichen" : `„${a.titel}" bearbeiten`,
+ inhalt: `
+ ${!neu && a.textGesperrt ? `
+
+
✋
+
Titel und Text sind gesperrt. Es haben bereits Personen diesen
+ Wortlaut zur Kenntnis genommen. Für eine inhaltliche Änderung den Aushang
+ abnehmen und neu veröffentlichen.
+
` : ""}
+
+ Titel
+
+
+
+
Kategorie
+
+ ${d.kategorien.map((k) =>
+ `
+ ${sicher(k.name)} `).join("")}
+
+
Gilt für
+
+ gesamte Klinik
+ ${d.stationen.map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
+
+ Text
+
+
+
+
+ Oben anheften
+
+
+
+ Frist (freiwillig)
+
+
Läuft die Frist ab, geschieht nichts weiter —
+ es wird nur deutlicher erinnert.
`,
+ bestaetigenText: neu ? "Veröffentlichen" : "Speichern",
+ bestaetigt: async (el, zu) => {
+ const gesperrt = !neu && a.textGesperrt;
+
+ const titel = gesperrt ? a.titel : el.querySelector("[name=titel]").value.trim();
+ const text = gesperrt ? a.text : el.querySelector("[name=text]").value.trim();
+
+ if (!titel) return meldung("Bitte einen Titel angeben.", "fehler");
+ if (text.length < 5) return meldung("Bitte einen Text schreiben.", "fehler");
+
+ try {
+ await aufruf("kommunikation", "aushangSchreiben", {
+ id: neu ? null : a.id,
+ titel, text,
+ kategorie: el.querySelector("[name=kategorie]").value,
+ station: el.querySelector("[name=station]").value,
+ angeheftet: el.querySelector("[name=angeheftet]").checked,
+ kenntnispflicht: el.querySelector("[name=kenntnispflicht]").checked,
+ frist: el.querySelector("[name=frist]").value,
+ });
+ zu();
+ meldung(neu ? "Aushang hängt." : "Aushang gespeichert.", "gut");
+ z.ordner = "aushang";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function archivierenFenster(behaelter, a) {
+ bestaetigen({
+ titel: "Aushang abnehmen",
+ gefahr: true,
+ inhalt: `
+ „${sicher(a.titel)} " wird vom Brett genommen und wandert ins Archiv.
+ Gelöscht wird nichts — der Wortlaut und alle Kenntnisnahmen bleiben.
+ ${a.kenntnispflicht ? `
+
+
ℹ
+
Offene Kenntnisnahmen halten das Abnehmen nicht auf. Wie viele noch
+ ausstanden, wird im Protokoll festgehalten.
+
` : ""}
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Abnehmen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (!grund) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ const antwort = await aufruf("kommunikation", "aushangArchivieren",
+ { id: a.id, grund });
+ zu();
+ meldung(antwort.offen > 0
+ ? `Abgenommen — ${antwort.offen} Kenntnisnahme(n) standen noch aus.`
+ : "Aushang abgenommen.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------------------------ */
+
+ /** Frischt die Liste im Hintergrund auf, ohne ein offenes Fenster zu stören */
+ async function rendernStill(behaelter) {
+ try {
+ z.daten = await aufruf("kommunikation", "uebersicht", { ordner: z.ordner });
+ if (!document.querySelector(".fenster-hg")) uebersicht(behaelter);
+ } catch (f) { /* eine stille Auffrischung darf folgenlos scheitern */ }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("kommunikation", "uebersicht", { ordner: z.ordner });
+
+ uebersicht(behaelter);
+
+ // Sprungziel aus einem Hinweis
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.nachricht) {
+ App.zustand.modulZiel = null;
+ return nachrichtFenster(behaelter, ziel.nachricht);
+ }
+ if (ziel && ziel.aushang) {
+ App.zustand.modulZiel = null;
+ return aushangFenster(behaelter, ziel.aushang);
+ }
+ } catch (f) {
+ behaelter.innerHTML = `Nachrichten und Aushänge
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("kommunikation", {
+ rendern,
+ aktualisieren() {
+ if (App.zustand.aktivesModul === "kommunikation"
+ && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/labor.js b/resources/[aether]/aether-medical/html/js/module/labor.js
new file mode 100644
index 0000000..badb9ad
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/labor.js
@@ -0,0 +1,603 @@
+/* =====================================================================
+ aether-medical / Ansicht: Labor
+
+ Zwei Blöcke, streng getrennt: das ERGEBNIS trägt das Labor ein, den
+ BEFUND ein Mediziner. Dazwischen liegt die Bewertung — und die nimmt
+ das System niemandem ab. Kein Wert wird markiert, verglichen oder
+ als auffällig dargestellt.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "offen", // offen | befund | meine | archiv
+ art: "",
+ suche: "",
+ daten: null,
+ offen: null,
+ };
+
+ const statusArt = {
+ offen: "warn", in_arbeit: "info", fertig: "gut", verworfen: "neutral",
+ };
+ const statusName = {
+ offen: "offen", in_arbeit: "in Arbeit", fertig: "Ergebnis liegt vor",
+ verworfen: "verworfen",
+ };
+
+ /* ---- Übersicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Labor
+
Das Labor speichert Ergebnisse. Ob ein Wert etwas bedeutet,
+ entscheidet ein Mediziner — nicht das System.
+
+ ${d.rechte.beauftragen
+ ? `
+ Laborauftrag stellen ` : ""}
+
+
+
+
+
Offen
+
${d.zaehler.offen}
+
wartet auf das Labor
+
+
+
Davon dringend
+
${d.zaehler.dringend}
+
vorrangig bearbeiten
+
+
+
Ohne Bewertung
+
${d.zaehler.befund}
+
Ergebnis da, Befund fehlt
+
+
+
Eigene Aufträge
+
${d.zaehler.meine}
+
von dir gestellt
+
+
+
+
+
+ Im Labor${d.zaehler.offen}
+
+ Bewertung offen${d.zaehler.befund}
+
+ Meine Aufträge
+
+ Archiv
+
+
+
+
+
+ Alle Probenarten
+ ${d.arten.map((a) =>
+ `
+ ${sicher(a.name)} `).join("")}
+
+
+
+
+
+ Aufträge
+ ${d.liste.length} Einträge
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
Kein Auftrag gefunden.
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(l) {
+ return `
+ ${l.dringend ? `
`
+ : `
`}
+
${sicher(l.kennung)}
+
+
${sicher(l.patientName)}
+ ${sicher(l.artName)}
+
${sicher(String(l.fragestellung || "").slice(0, 90))}
+
+
+ ${l.dringend ? `
dringend ` : ""}
+ ${l.hatErgebnis && !l.hatBefund
+ ? `
Befund fehlt ` : ""}
+ ${l.hatBefund ? `
befundet ` : ""}
+ ${l.eigene ? `
dein Auftrag ` : ""}
+
+
+ ${sicher(statusName[l.status] || l.status)}
+
${l.status === "offen" || l.status === "in_arbeit"
+ ? "seit " + minuten(l.offenSeit) : zeit(l.auftrag_am)}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelector("[data-art]").addEventListener("change", (e) => {
+ z.art = e.target.value;
+ rendern(behaelter);
+ });
+
+ const suchfeld = behaelter.querySelector("[data-suche]");
+ let warten = null;
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(() => {
+ z.suche = suchfeld.value.trim();
+ rendern(behaelter);
+ }, 280);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => auftragFenster(behaelter));
+ }
+
+ /* ---- Einzelansicht -------------------------------------------------------- */
+
+ function einzelAnsicht(behaelter, d) {
+ const a = d.auftrag, s = d.stamm;
+ const offen = a.status === "offen";
+ const inArbeit = a.status === "in_arbeit";
+ const verworfen = a.status === "verworfen";
+
+ behaelter.innerHTML = `
+
+
+
${sicher(a.kennung)} · ${sicher(a.artName)}
+
${sicher(a.patientName)} · geb. ${sicher(a.geburt)}
+ ${a.dringend ? " · dringend " : ""}
+
+
← Übersicht
+
+
+ ${s.hinweis ? `
+
+
⚠
+
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""}
+
+
+
+
Status ${sicher(statusName[a.status] || a.status)}
+
Beauftragt ${zeit(a.auftragAm)}
+
Von ${sicher(a.auftragVon)}
+ ${a.bearbeitetVon ? `
+
Im Labor ${sicher(a.bearbeitetVon)}
+
Angenommen ${zeit(a.bearbeitetAm)}
` : ""}
+
Blutgruppe (Stammdaten)
+ ${sicher(s.blutgruppe || "nicht bestimmt")}
+
+
+ Patientenakte
+ ${offen && d.rechte.bearbeiten
+ ? `In Arbeit nehmen ` : ""}
+ ${(offen || inArbeit) && d.rechte.bearbeiten
+ ? `Probe verwerfen ` : ""}
+
+
+
+
+
Fragestellung des Auftraggebers
+
${sicher(a.fragestellung || "—")}
+
+
+
+
+
+
+ Ergebnis — gemessene Werte
+ ${d.rechte.bearbeiten && !verworfen
+ ? `
+ ${a.ergebnis ? "Ergebnis ändern" : "Ergebnis eintragen"} ` : ""}
+
+
${a.ergebnis
+ ? sicher(a.ergebnis).replace(/\n/g, " ")
+ : "Das Labor hat noch nichts eingetragen."}
+
+ Reine Messwerte. Das System vergleicht sie mit nichts und markiert
+ nichts als auffällig.
+
+
+
+
+
+
+ Befund — ärztliche Bewertung
+ ${d.rechte.befunden && a.ergebnis && !verworfen
+ ? `
+ ${a.befund ? "Befund ändern" : "Befund eintragen"} ` : ""}
+
+
${a.befund
+ ? sicher(a.befund).replace(/\n/g, " ")
+ : a.ergebnis
+ ? "Das Ergebnis ist noch nicht bewertet."
+ : "Erst wenn ein Ergebnis vorliegt, lässt sich befunden."}
+
+ ${a.befundVon
+ ? `Bewertet von ${sicher(a.befundVon)} am ${zeit(a.befundAm)}.`
+ : "Die Bewertung stammt immer von einem Mediziner."}
+
+
+
+
+ ${a.ergebnis && !verworfen && d.rechte.stammdaten ? `
+
+
+ Blutgruppe in die Stammdaten übernehmen
+
+
+ Falls in diesem Auftrag eine Blutgruppe bestimmt wurde: sie wandert
+ nicht von selbst in die Patientenakte. Du trägst sie ausdrücklich
+ ein und stehst dafür gerade.
+
+
+ Blutgruppe eintragen${s.blutgruppe ? " (aktuell: " + sicher(s.blutgruppe) + ")" : ""}
+
+
` : ""}
+
+
+
+ Frühere Aufträge dieses Patienten
+ zum Vergleich — nicht verrechnet
+
+
+ ${d.frueher.length ? d.frueher.map((f) => `
+
+
${sicher(f.kennung)}
+
+
${sicher(f.art)}
+
${zeit(f.auftrag_am)}
+
+
+ ${sicher(statusName[f.status] || f.status)}
+
`).join("")
+ : `
Keine früheren Aufträge.
`}
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const a = d.auftrag;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelector("[data-akte]").addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "mdt")) {
+ return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("mdt", { patient: a.patient });
+ });
+
+ behaelter.querySelectorAll("[data-frueher]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.frueher));
+ });
+
+ const annehmen = behaelter.querySelector("[data-annehmen]");
+ if (annehmen) annehmen.addEventListener("click", async () => {
+ try {
+ await aufruf("labor", "annehmen", { id: a.id });
+ meldung("Auftrag in Arbeit genommen.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const ergebnis = behaelter.querySelector("[data-ergebnis]");
+ if (ergebnis) ergebnis.addEventListener("click", () => ergebnisFenster(behaelter, d));
+
+ const befund = behaelter.querySelector("[data-befund]");
+ if (befund) befund.addEventListener("click", () => befundFenster(behaelter, d));
+
+ const verwerfen = behaelter.querySelector("[data-verwerfen]");
+ if (verwerfen) verwerfen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Probe verwerfen",
+ gefahr: true,
+ inhalt: `${sicher(a.kennung)} wird als verworfen abgelegt.
+ Der Auftraggeber wird informiert; ein neuer Auftrag muss von Hand
+ gestellt werden.
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Verwerfen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("labor", "verwerfen", { id: a.id, grund });
+ zu();
+ meldung("Auftrag verworfen.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const blutgruppe = behaelter.querySelector("[data-blutgruppe]");
+ if (blutgruppe) blutgruppe.addEventListener("click", () => blutgruppeFenster(behaelter, d));
+ }
+
+ /* ---- Ergebnis und Befund --------------------------------------------------- */
+
+ function ergebnisFenster(behaelter, d) {
+ const a = d.auftrag;
+
+ fenster({
+ titel: "Ergebnis eintragen · " + a.kennung,
+ inhalt: `
+
+ ${sicher(a.patientName)} · ${sicher(a.artName)}
+ Fragestellung: ${sicher(a.fragestellung || "—")}
+
+ Gemessene Werte
+
+
Nur Messwerte, keine Bewertung. Die Einordnung
+ nimmt anschließend ein Mediziner im Befund vor.
+ ${d.vorlage && !a.ergebnis
+ ? `Vorlage einfügen ` : ""}`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Ergebnis speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("labor", "ergebnis", {
+ id: a.id, ergebnis: el.querySelector("[name=ergebnis]").value.trim(),
+ });
+ zu();
+ meldung("Ergebnis gespeichert. Der Auftraggeber wurde informiert.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ nachOeffnen: (hg) => {
+ const vorlage = hg.querySelector("[data-vorlage]");
+ if (vorlage) vorlage.addEventListener("click", () => {
+ const feld = hg.querySelector("[name=ergebnis]");
+ feld.value = d.vorlage;
+ feld.focus();
+ });
+ },
+ });
+ }
+
+ function befundFenster(behaelter, d) {
+ const a = d.auftrag;
+
+ bestaetigen({
+ titel: "Befund eintragen · " + a.kennung,
+ inhalt: `
+ Ergebnis des Labors
+
${sicher(a.ergebnis).replace(/\n/g, " ")}
+ Deine Bewertung
+
+
Der Befund erscheint in der Patientenakte und im
+ Behandlungsbogen. Er trägt deinen Namen.
`,
+ bestaetigenText: "Befund speichern",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("labor", "befund", {
+ id: a.id, befund: el.querySelector("[name=befund]").value.trim(),
+ });
+ zu();
+ meldung("Befund gespeichert.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Blutgruppe übernehmen -------------------------------------------------- */
+
+ function blutgruppeFenster(behaelter, d) {
+ const a = d.auftrag;
+
+ bestaetigen({
+ titel: "Blutgruppe in die Stammdaten übernehmen",
+ inhalt: `
+ Ergebnis des Labors
+
${sicher(a.ergebnis).replace(/\n/g, " ")}
+ Blutgruppe für ${sicher(a.patientName)}
+
+ — bitte wählen —
+ ${d.blutgruppen.map((b) =>
+ `
+ ${sicher(b)} `).join("")}
+
+
Das System liest die Blutgruppe nicht aus dem
+ Ergebnistext aus. Du wählst sie, und damit steht dein Name darunter.
+ ${d.stamm.blutgruppe ? `
+
+
⚠
+
In den Stammdaten steht bereits ${sicher(d.stamm.blutgruppe)} .
+ Ein Überschreiben wird protokolliert.
+
` : ""}`,
+ bestaetigenText: "Übernehmen",
+ bestaetigt: async (el, zu) => {
+ const gewaehlt = el.querySelector("[name=blutgruppe]").value;
+ if (!gewaehlt) return meldung("Bitte eine Blutgruppe wählen.", "fehler");
+ try {
+ const antwort = await aufruf("labor", "blutgruppeUebernehmen", {
+ id: a.id, blutgruppe: gewaehlt,
+ });
+ zu();
+ meldung("Blutgruppe " + antwort.blutgruppe + " eingetragen.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Auftrag stellen --------------------------------------------------------- */
+
+ function auftragFenster(behaelter) {
+ const d = z.daten;
+ let gewaehlt = null;
+
+ bestaetigen({
+ titel: "Laborauftrag stellen",
+ inhalt: `
+ Patient suchen
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
Probenart
+
+ ${d.arten.map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+
Fragestellung
+
+
Je genauer die Frage, desto brauchbarer das
+ Ergebnis. Das Labor misst — bewerten musst du selbst.
+
+
Dringend
+
Dringende Aufträge stehen im Labor oben und
+ erzeugen einen Hinweis mit hoher Priorität. Bearbeitet werden sie
+ trotzdem von einem Menschen.
+
+
`,
+ bestaetigenText: "Auftrag stellen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler");
+ try {
+ const antwort = await aufruf("labor", "beauftragen", {
+ patient: gewaehlt.id,
+ art: el.querySelector("[name=art]").value,
+ fragestellung: el.querySelector("[name=fragestellung]").value.trim(),
+ dringend: el.querySelector("[name=dringend]").checked,
+ });
+ zu();
+ meldung("Auftrag " + antwort.kennung + " gestellt.", "gut");
+ z.ordner = "meine";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const liste = hg.querySelector("[data-treffer]");
+ const formular = hg.querySelector("[data-formular]");
+ let warten = null;
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) {
+ liste.innerHTML = `Mindestens zwei Zeichen eingeben.
`;
+ return;
+ }
+ try {
+ const antwort = await aufruf("labor", "patientSuche", { begriff });
+ const treffer = antwort.treffer || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+ ${t.blutgruppe ? " · " + sicher(t.blutgruppe)
+ : " · Blutgruppe nicht bestimmt"}
+
+ ${t.hinweis ? `
⚠ ` : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = treffer.find((t) => t.id === +el.dataset.person);
+ liste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ formular.style.display = "";
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, laborId) {
+ try {
+ const d = await aufruf("labor", "oeffnen", { id: laborId });
+ z.offen = laborId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("labor", "uebersicht", {
+ ordner: z.ordner, art: z.art, begriff: z.suche,
+ });
+
+ // Sprungziel aus einem Hinweis, z. B. „Laborergebnis verfügbar"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.labor) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.labor);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Labor
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("labor", {
+ rendern,
+ aktualisieren() {
+ if (!z.offen && App.zustand.aktivesModul === "labor") rendern(App.$("inhalt"));
+ },
+ });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("labor", "uebersicht", { ordner: "archiv", begriff });
+ return (d.liste || []).slice(0, 5).map((l) => ({
+ bereich: "Labor",
+ titel: l.kennung + " · " + l.patientName,
+ unter: l.artName + " · " + (statusName[l.status] || l.status),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/mdt.js b/resources/[aether]/aether-medical/html/js/module/mdt.js
new file mode 100644
index 0000000..5274e47
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/mdt.js
@@ -0,0 +1,766 @@
+/* =====================================================================
+ aether-medical / Ansicht: Medizinisches MDT
+
+ Patientensuche und Patientenakte. Alles, was hier rot oder auffällig
+ dargestellt wird, hat ein Mediziner eingetragen — das System leitet
+ aus keinem Feld eine Warnung, eine Diagnose oder eine Empfehlung ab.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ suche: "",
+ treffer: [],
+ patient: null, // geöffnete Patientenakte (Serverantwort)
+ reiter: "stamm", // stamm | verlauf | akten | notizen
+ unterdaten: null, // Daten des aktiven Reiters
+ offeneAkte: null,
+ };
+
+ // Symbol und Farbe je Verlaufsart
+ const verlaufsArt = {
+ aufnahme: { symbol: "🏥", art: "info" },
+ entlassung: { symbol: "🚪", art: "neutral" },
+ behandlung: { symbol: "🩺", art: "gut" },
+ operation: { symbol: "🔪", art: "warn" },
+ labor: { symbol: "🧪", art: "info" },
+ medikament: { symbol: "💊", art: "neutral" },
+ notiz: { symbol: "📝", art: "neutral" },
+ };
+
+ const statusArt = {
+ entwurf: "neutral", offen: "warn", freigegeben: "gut", geschlossen: "info",
+ };
+
+ /* ---- Suche ------------------------------------------------------------ */
+
+ function sucheAnsicht(behaelter) {
+ behaelter.innerHTML = `
+
+
+
Medizinisches MDT
+
Patientensuche über Name, Geburtsdatum oder Telefonnummer.
+ Was in einer Akte steht, hat ein Mediziner eingetragen.
+
+
+
+
+
+
+
+
+
+ Treffer
+ ${z.treffer.length} Personen
+
+
+ ${z.treffer.length ? z.treffer.map(trefferZeile).join("")
+ : `
${z.suche.length >= 2
+ ? "Keine Person gefunden."
+ : "Mindestens zwei Zeichen eingeben."}
`}
+
+
+ `;
+
+ const feld = behaelter.querySelector("[data-suche]");
+ let warten = null;
+ feld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ z.suche = feld.value.trim();
+ if (z.suche.length < 2) { z.treffer = []; return sucheAnsicht(behaelter); }
+ try {
+ const d = await aufruf("mdt", "suche", { begriff: z.suche });
+ z.treffer = d.treffer || [];
+ sucheAnsicht(behaelter);
+ // Den Fokus zurück ins Suchfeld, damit man weitertippen kann
+ const neu = behaelter.querySelector("[data-suche]");
+ neu.focus();
+ neu.setSelectionRange(neu.value.length, neu.value.length);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 280);
+ });
+
+ behaelter.querySelectorAll("[data-patient]").forEach((el) => {
+ el.addEventListener("click", () => oeffnePatient(behaelter, +el.dataset.patient));
+ });
+ }
+
+ function trefferZeile(p) {
+ return `
+
${p.lichtbild
+ ? `
`
+ : `
— `}
+
+
${sicher(p.firstname)} ${sicher(p.lastname)}
+
+ geb. ${sicher(p.dob)}
+ ${p.telefon ? " · ☎ " + sicher(p.telefon) : ""}
+ ${p.blutgruppe ? " · " + sicher(p.blutgruppe) : " · Blutgruppe unbekannt"}
+
+
+ ${p.hinweis ? `
Warnhinweis ` : ""}
+ ${p.allergien ? `
Allergien ` : ""}
+ ${p.imHaus ? `
im Haus ` : ""}
+ ${p.wartet ? `
wartet ` : ""}
+
`;
+ }
+
+ /* ---- Patientenakte ------------------------------------------------------ */
+
+ function akteAnsicht(behaelter) {
+ const d = z.patient;
+ const p = d.patient, s = d.stamm;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(p.name)}
+
geb. ${sicher(p.geburt)} · ${sicher(p.geschlecht === "w" ? "weiblich" : "männlich")}
+ ${p.telefon ? " · ☎ " + sicher(p.telefon) : ""}
+
+
← Suche
+
+
+ ${s.hinweis ? `
+
+
⚠
+
Warnhinweis — ${sicher(s.hinweis)}
+ ${s.gepflegtVon ? `Eingetragen von
+ ${sicher(s.gepflegtVon)}${s.gepflegtAm ? " am " + zeit(s.gepflegtAm) : ""}.
+ Das System hat diesen Hinweis nicht erzeugt. ` : ""}
+
+
` : ""}
+
+ ${d.aufnahme ? `
+
+
🏥
+
${d.aufnahme.status === "wartend"
+ ? "Wartet auf Aufnahme" : "Aufgenommen"}
+ — ${sicher(d.aufnahme.grund)}
+ ${d.aufnahme.zimmer ? " · Zimmer " + sicher(d.aufnahme.zimmer) : ""}
+ Seit ${zeit(d.aufnahme.aufgenommen_am)}
+ durch ${sicher(d.aufnahme.aufgenommenVon)}.
+
+
` : ""}
+
+ ${d.intensiv ? `
+
+
🫀
+
Auf Intensivstation — Zustand als
+ ${sicher(d.intensiv.zustand)} eingetragen
+ ${d.intensiv.verantwortlichName
+ ? " · zuständig: " + sicher(d.intensiv.verantwortlichName) : ""}
+
+
` : ""}
+
+
+
${s.lichtbild
+ ? `
`
+ : `
Kein Lichtbild `}
+
+
+ ${wertZeile("Blutgruppe", s.blutgruppe, !s.blutgruppe && "nicht bestimmt")}
+ ${wertZeile("Allergien", s.allergien, "keine erfasst")}
+ ${wertZeile("Vorerkrankungen", s.vorerkrankungen, "keine erfasst")}
+ ${wertZeile("Dauermedikation", s.dauermedikation, "keine erfasst")}
+ ${wertZeile("Implantate", s.implantate, "keine erfasst")}
+ ${wertZeile("Organspende", s.organspenderName)}
+ ${wertZeile("Notfallkontakt", s.notfallkontakt, "keiner hinterlegt")}
+
+
+
+ ${d.rechte.stammdaten
+ ? `Stammdaten pflegen ` : ""}
+ ${d.rechte.hinweis
+ ? `
+ ${s.hinweis ? "Warnhinweis ändern" : "Warnhinweis setzen"} ` : ""}
+
+
+
+
+
+ Übersicht
+
+ Verlauf
+
+ Akten ${d.zaehler.akten}
+
+ Vermerke ${d.zaehler.notizen}
+
+
+
+ `;
+
+ verdrahteAkte(behaelter);
+ zeichneReiter(behaelter);
+ }
+
+ /** Eine Zeile im Stammdatenblock */
+ function wertZeile(name, wert, ersatz) {
+ const fehlt = !wert;
+ return `
+ ${sicher(name)}
+ ${fehlt ? sicher(ersatz || "nicht erfasst") : sicher(wert)}
+
`;
+ }
+
+ function verdrahteAkte(behaelter) {
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.patient = null;
+ z.offeneAkte = null;
+ sucheAnsicht(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.reiter = el.dataset.reiter;
+ z.offeneAkte = null;
+ akteAnsicht(behaelter);
+ });
+ });
+
+ const stamm = behaelter.querySelector("[data-stamm]");
+ if (stamm) stamm.addEventListener("click", () => stammdatenFenster(behaelter));
+
+ const hinweis = behaelter.querySelector("[data-hinweis]");
+ if (hinweis) hinweis.addEventListener("click", () => hinweisFenster(behaelter));
+ }
+
+ /* ---- Reiterinhalte ------------------------------------------------------- */
+
+ async function zeichneReiter(behaelter) {
+ const ziel = behaelter.querySelector("#mdt-unterinhalt");
+ const patientId = z.patient.patient.id;
+
+ try {
+ if (z.reiter === "verlauf") {
+ z.unterdaten = await aufruf("mdt", "verlauf", { patient: patientId });
+ zeichneVerlauf(ziel);
+ } else if (z.reiter === "akten") {
+ z.unterdaten = await aufruf("mdt", "akten", { patient: patientId });
+ zeichneAkten(ziel, behaelter);
+ } else if (z.reiter === "notizen") {
+ z.unterdaten = await aufruf("mdt", "notizen", { patient: patientId });
+ zeichneNotizen(ziel, behaelter);
+ } else {
+ zeichneUebersicht(ziel);
+ }
+ } catch (f) {
+ ziel.innerHTML = `${sicher(f.message)}
`;
+ }
+ }
+
+ function zeichneUebersicht(ziel) {
+ const d = z.patient;
+ const zaehler = d.zaehler;
+
+ ziel.innerHTML = `
+
+
+
Behandlungen
+
${zaehler.behandlungen}
+
dokumentiert
+
+
+
Operationen
+
${zaehler.operationen}
+
dokumentiert
+
+
+
Laboraufträge
+
${zaehler.labor}
+
gestellt
+
+
+
Aufnahmen
+
${zaehler.aufnahmen}
+
insgesamt
+
+
+
Akten
+
${zaehler.akten}
+
angelegt
+
+
+
Vermerke
+
${zaehler.notizen}
+
geschrieben
+
+
+
+
+ Fehlende Angaben bedeuten „nicht erhoben", nicht „unauffällig".
+ Eine leere Allergieliste heißt, dass niemand danach gefragt oder es
+ eingetragen hat. Das System füllt keines dieser Felder von selbst und
+ schließt aus keinem etwas.
+
+ `;
+ }
+
+ function zeichneVerlauf(ziel) {
+ const eintraege = z.unterdaten.eintraege || [];
+
+ ziel.innerHTML = eintraege.length ? `
+
+
+ Zeitleiste
+ ${z.unterdaten.gesamt} Einträge insgesamt
+
+
+ ${eintraege.map((e) => {
+ const stil = verlaufsArt[e.art] || { symbol: "•", art: "neutral" };
+ return `
+
${stil.symbol}
+
+
+ ${sicher(e.titel)}
+ ${zeit(e.am)}
+
+ ${e.text ? `
${sicher(e.text)}
` : ""}
+ ${e.marker ? `
${sicher(e.marker)}
` : ""}
+
+
`;
+ }).join("")}
+
+
`
+ : `Für diesen Patienten ist noch nichts dokumentiert.
`;
+ }
+
+ function zeichneAkten(ziel, behaelter) {
+ const d = z.unterdaten;
+
+ // Eine einzelne Akte ist geöffnet
+ if (z.offeneAkte) return zeichneEinzelakte(ziel, behaelter);
+
+ ziel.innerHTML = `
+
+
+ Akten dieses Patienten
+ ${z.patient.rechte.akteErstellen
+ ? `+ Akte anlegen ` : ""}
+
+
+ ${d.akten.length ? d.akten.map((a) => `
+
+
${sicher(a.aktenzeichen)}
+
+
${sicher(a.titel)}
+
${sicher(a.typName)} · ${sicher(a.autor)}
+ · ${zeit(a.erstellt_am)}
+
+
v${a.version}
+
${sicher(a.status)}
+
`).join("")
+ : `
Noch keine Akte angelegt.
`}
+
+
`;
+
+ ziel.querySelectorAll("[data-akte]").forEach((el) => {
+ el.addEventListener("click", async () => {
+ try {
+ z.offeneAkte = await aufruf("mdt", "akteOeffnen", { id: +el.dataset.akte });
+ zeichneEinzelakte(ziel, behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ const neu = ziel.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => akteAnlegenFenster(behaelter));
+ }
+
+ function zeichneEinzelakte(ziel, behaelter) {
+ const d = z.offeneAkte;
+ const a = d.akte;
+
+ ziel.innerHTML = `
+
+
+
${sicher(a.aktenzeichen)} · Version ${a.version}
+
+ ${d.rechte.bearbeiten && a.status !== "geschlossen"
+ ? `Bearbeiten ` : ""}
+ ${d.rechte.freigeben && a.status !== "freigegeben" && a.status !== "geschlossen"
+ ? `Freigeben ` : ""}
+ ${d.rechte.schliessen && a.status !== "geschlossen"
+ ? `Schließen ` : ""}
+ ← Akten
+
+
+
+
${sicher(a.titel)}
+
+ ${sicher(a.status)}
+ Angelegt von ${sicher(a.autor)} am ${zeit(a.erstelltAm)}
+ ${a.freigabeVon ? ` · freigegeben von ${sicher(a.freigabeVon)}
+ am ${zeit(a.freigabeAm)}` : ""}
+
+
+
${a.inhalt
+ ? sicher(a.inhalt).replace(/\n/g, " ")
+ : `Diese Akte hat noch keinen Inhalt. `}
+
+
+
+
+ Versionsverlauf
+ Nichts wird überschrieben
+
+
+ ${d.versionen.map((v) => `
+
+
+ v${v.version}
+
+
${sicher(v.kommentar || "ohne Kommentar")}
+
${sicher(v.autor)} · ${zeit(v.geaendert_am)}
+
+ ${v.version === a.version
+ ? `
aktuell `
+ : `
ansehen `}
+
`).join("")}
+
+
+ `;
+
+ ziel.querySelector("[data-zurueckliste]").addEventListener("click", () => {
+ z.offeneAkte = null;
+ zeichneReiter(behaelter);
+ });
+
+ const bearbeiten = ziel.querySelector("[data-bearbeiten]");
+ if (bearbeiten) bearbeiten.addEventListener("click", () => akteBearbeitenFenster(behaelter));
+
+ ziel.querySelectorAll("[data-status]").forEach((el) => {
+ el.addEventListener("click", () => akteStatusFenster(behaelter, el.dataset.status));
+ });
+
+ ziel.querySelectorAll("[data-version]").forEach((el) => {
+ el.addEventListener("click", () => versionAnsehen(+el.dataset.version));
+ });
+ }
+
+ function zeichneNotizen(ziel, behaelter) {
+ const notizen = z.unterdaten.notizen || [];
+
+ ziel.innerHTML = `
+
+
+ Vermerke
+ + Vermerk
+
+
+ ${notizen.length ? notizen.map((n) => `
+
+
+
${sicher(n.text)}
+
${sicher(n.autor)}
+ ${n.personalnummer ? " (" + sicher(n.personalnummer) + ")" : ""}
+ · ${zeit(n.am)}
+
+ ${n.eigene ? `
von dir ` : ""}
+
`).join("")
+ : `
Keine Vermerke.
`}
+
+
`;
+
+ ziel.querySelector("[data-neu-notiz]").addEventListener("click", () => {
+ fenster({
+ titel: "Vermerk anlegen",
+ inhalt: `Vermerk
+
+
Ein Vermerk ist keine Diagnose und ersetzt
+ keine Behandlungsdokumentation.
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("mdt", "notizAnlegen", {
+ patient: z.patient.patient.id,
+ text: el.querySelector("[name=text]").value.trim(),
+ });
+ zu();
+ meldung("Vermerk gespeichert.", "gut");
+ oeffnePatient(behaelter, z.patient.patient.id, "notizen");
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ });
+ }
+
+ /* ---- Dialoge -------------------------------------------------------------- */
+
+ function stammdatenFenster(behaelter) {
+ const d = z.patient;
+ const s = d.stamm;
+
+ fenster({
+ titel: "Stammdaten pflegen",
+ inhalt: `
+
+
Blutgruppe
+
+ — nicht bestimmt —
+ ${d.blutgruppen.map((b) =>
+ `
+ ${sicher(b)} `).join("")}
+
+
Wird nicht aus dem Labor übernommen — die
+ Feststellung verantwortet ein Mediziner.
+
Organspende
+
+ ${Object.keys(d.organstatus).map((k) =>
+ `
+ ${sicher(d.organstatus[k])} `).join("")}
+
+
+
+ Allergien
+
+ Vorerkrankungen
+
+ Dauermedikation
+
+ Implantate
+
+ Notfallkontakt
+
+ Lichtbild (Bild-Adresse)
+
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ const wert = (name) => el.querySelector(`[name=${name}]`).value.trim();
+ try {
+ await aufruf("mdt", "stammdaten", {
+ patient: d.patient.id,
+ blutgruppe: wert("blutgruppe"),
+ organspender: wert("organspender"),
+ allergien: wert("allergien"),
+ vorerkrankungen: wert("vorerkrankungen"),
+ dauermedikation: wert("dauermedikation"),
+ implantate: wert("implantate"),
+ notfallkontakt: wert("notfallkontakt"),
+ lichtbild: wert("lichtbild"),
+ });
+ zu();
+ meldung("Stammdaten gespeichert.", "gut");
+ oeffnePatient(behaelter, d.patient.id, z.reiter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ }
+
+ function hinweisFenster(behaelter) {
+ const d = z.patient;
+
+ bestaetigen({
+ titel: d.stamm.hinweis ? "Warnhinweis ändern" : "Warnhinweis setzen",
+ inhalt: `
+ Ein Warnhinweis erscheint bei jeder Suche und ganz oben in der Akte.
+ Er ist für Dinge gedacht, die vor jeder Maßnahme bekannt sein müssen.
+ Warnhinweis (leer lassen zum Entfernen)
+
+ Begründung
+
`,
+ bestaetigenText: "Übernehmen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("mdt", "hinweisSetzen", {
+ patient: d.patient.id,
+ hinweis: el.querySelector("[name=hinweis]").value.trim(),
+ grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung("Warnhinweis übernommen.", "gut");
+ oeffnePatient(behaelter, d.patient.id, z.reiter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function akteAnlegenFenster(behaelter) {
+ const arten = z.unterdaten.aktenarten || [];
+
+ fenster({
+ titel: "Akte anlegen",
+ inhalt: `
+ Titel
+
+ Aktenart
+
+ ${arten.map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+ Inhalt
+
+
Die Akte startet als Entwurf. Jede spätere Änderung
+ erzeugt eine neue Version — überschrieben wird nichts.
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Anlegen", klick: async (el, zu) => {
+ try {
+ const antwort = await aufruf("mdt", "akteAnlegen", {
+ patient: z.patient.patient.id,
+ titel: el.querySelector("[name=titel]").value.trim(),
+ typ: el.querySelector("[name=typ]").value,
+ inhalt: el.querySelector("[name=inhalt]").value,
+ });
+ zu();
+ meldung("Akte " + antwort.aktenzeichen + " angelegt.", "gut");
+ oeffnePatient(behaelter, z.patient.patient.id, "akten");
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ }
+
+ function akteBearbeitenFenster(behaelter) {
+ const a = z.offeneAkte.akte;
+
+ fenster({
+ titel: "Akte bearbeiten · " + a.aktenzeichen,
+ inhalt: `
+ Titel
+
+ Inhalt
+
+ Was wurde geändert? (Pflicht)
+
+
Der Kommentar steht dauerhaft im Versionsverlauf.
+ Version ${a.version} bleibt unverändert erhalten.
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Neue Version speichern", klick: async (el, zu) => {
+ const kommentar = el.querySelector("[name=kommentar]").value.trim();
+ if (kommentar.length < 3) {
+ return meldung("Bitte angeben, was geändert wurde.", "fehler");
+ }
+ try {
+ await aufruf("mdt", "akteSpeichern", {
+ id: a.id,
+ titel: el.querySelector("[name=titel]").value.trim(),
+ inhalt: el.querySelector("[name=inhalt]").value,
+ kommentar,
+ });
+ zu();
+ meldung("Neue Version gespeichert.", "gut");
+ z.offeneAkte = await aufruf("mdt", "akteOeffnen", { id: a.id });
+ zeichneEinzelakte(behaelter.querySelector("#mdt-unterinhalt"), behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ }
+
+ function akteStatusFenster(behaelter, status) {
+ const a = z.offeneAkte.akte;
+ const beschreibung = {
+ freigegeben: "Die Akte gilt damit als geprüft und freigegeben. Sie lässt sich weiterhin bearbeiten.",
+ geschlossen: "Eine geschlossene Akte lässt sich nicht mehr ändern . Der Inhalt bleibt lesbar.",
+ };
+
+ bestaetigen({
+ titel: status === "freigegeben" ? "Akte freigeben" : "Akte schließen",
+ gefahr: status === "geschlossen",
+ inhalt: `${sicher(a.aktenzeichen)} — ${sicher(a.titel)}
+ ${beschreibung[status] || ""}
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: status === "freigegeben" ? "Freigeben" : "Schließen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("mdt", "akteStatus", {
+ id: a.id, status,
+ grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung("Status geändert.", "gut");
+ z.offeneAkte = await aufruf("mdt", "akteOeffnen", { id: a.id });
+ zeichneEinzelakte(behaelter.querySelector("#mdt-unterinhalt"), behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ async function versionAnsehen(version) {
+ try {
+ const v = await aufruf("mdt", "akteVersion", { id: z.offeneAkte.akte.id, version });
+
+ fenster({
+ titel: `Version ${v.version} · ${z.offeneAkte.akte.aktenzeichen}`,
+ inhalt: `
+ ${sicher(v.autor)} · ${zeit(v.geaendert_am)}
+ ${v.kommentar ? " · " + sicher(v.kommentar) : ""}
+ ${v.inhalt
+ ? sicher(v.inhalt).replace(/\n/g, " ")
+ : `Diese Version hatte keinen Inhalt. `}
`,
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* ---- Laden ----------------------------------------------------------------- */
+
+ async function oeffnePatient(behaelter, patientId, reiter) {
+ try {
+ z.patient = await aufruf("mdt", "oeffnen", { patient: patientId });
+ z.reiter = reiter || "stamm";
+ z.offeneAkte = null;
+ akteAnsicht(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ // Sprungziel aus einem anderen Modul oder aus einem Hinweis
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.patient) {
+ App.zustand.modulZiel = null;
+ return oeffnePatient(behaelter, ziel.patient);
+ }
+ if (ziel && ziel.akte) {
+ App.zustand.modulZiel = null;
+ try {
+ const akte = await aufruf("mdt", "akteOeffnen", { id: ziel.akte });
+ if (akte.akte.patient) {
+ await oeffnePatient(behaelter, akte.akte.patient, "akten");
+ z.offeneAkte = akte;
+ zeichneEinzelakte(behaelter.querySelector("#mdt-unterinhalt"), behaelter);
+ return;
+ }
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ if (z.patient) akteAnsicht(behaelter);
+ else sucheAnsicht(behaelter);
+ }
+
+ App.registriereAnsicht("mdt", { rendern });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("mdt", "suche", { begriff });
+ return (d.treffer || []).slice(0, 5).map((p) => ({
+ bereich: "Patient",
+ titel: p.firstname + " " + p.lastname,
+ unter: "geb. " + p.dob + (p.blutgruppe ? " · " + p.blutgruppe : ""),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/medikament.js b/resources/[aether]/aether-medical/html/js/module/medikament.js
new file mode 100644
index 0000000..0eff4da
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/medikament.js
@@ -0,0 +1,679 @@
+/* =====================================================================
+ aether-medical / Ansicht: Medikamentenverwaltung
+
+ Bestand, Buchungen und BTM-Buch. Das System zählt und erinnert —
+ nachbestellt, entnommen und entsorgt wird von Hand. Abgelaufene
+ Präparate werden auffällig markiert, aber nicht gesperrt.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ reiter: "bestand", // bestand | btm
+ filter: "",
+ kategorie: "",
+ suche: "",
+ stillgelegt: false,
+ daten: null,
+ offen: null,
+ btmMedikament: null,
+ // Aus der jeweils letzten Antwort uebernommen, damit Dialoge sie sicher haben
+ rechte: { bestand: false, btm: false, katalog: false },
+ kategorien: [],
+ };
+
+ /* ---- Übersicht ----------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ // Nach Kategorie gruppieren
+ const gruppen = {};
+ d.liste.forEach((m) => { (gruppen[m.kategorie] = gruppen[m.kategorie] || []).push(m); });
+
+ behaelter.innerHTML = `
+
+
+
Medikamente
+
Bestandsführung. Das System gibt nichts aus, verordnet nichts und
+ bestellt nichts nach — es zählt, was gebucht wurde.
+
+ ${d.rechte.katalog
+ ? `
+ Medikament anlegen ` : ""}
+
+
+
+ ${kachel("gesamt", "Im Katalog", d.zaehler.gesamt, "aktive Präparate", "")}
+ ${kachel("knapp", "Unter Mindestbestand", d.zaehler.knapp,
+ "nachbestellen", d.zaehler.knapp > 0 ? "warn" : "")}
+ ${kachel("leer", "Nicht vorrätig", d.zaehler.leer,
+ "Bestand null", d.zaehler.leer > 0 ? "hoch" : "")}
+ ${kachel("verfall", "Läuft ab", d.zaehler.verfall,
+ "Verfallsdatum nahe", d.zaehler.verfall > 0 ? "warn" : "")}
+ ${kachel("btm", "Betäubungsmittel", d.zaehler.btm, "gesondert geführt", "")}
+
+
+
+
+ Bestand
+ ${d.rechte.btm
+ ? `
+ BTM-Buch ` : ""}
+
+
+
+
+
+ Alle Kategorien
+ ${d.kategorien.map((k) =>
+ `
+ ${sicher(k.name)} `).join("")}
+
+ ${z.filter
+ ? `Filter „${sicher(z.filter)}" aufheben `
+ : ""}
+ ${d.rechte.katalog
+ ? `
+ ${z.stillgelegt ? "Stillgelegte" : "Aktive"} ` : ""}
+
+
+ ${Object.keys(gruppen).length
+ ? Object.keys(gruppen).map((kategorie) => `
+
+
+ ${sicher(gruppen[kategorie][0].kategorieName)}
+ ${gruppen[kategorie].length} Präparate
+
+
${gruppen[kategorie].map(zeile).join("")}
+
`).join("")
+ : `Kein Medikament gefunden.
`}
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function kachel(filter, titel, wert, zusatz, art) {
+ const aktiv = z.filter === filter;
+ return `
+
${sicher(titel)}
+
${wert}
+
${sicher(zusatz)}
+
`;
+ }
+
+ function zeile(m) {
+ // Der Bestandsbalken zeigt das Verhältnis zum Mindestbestand
+ const anteil = m.mindestbestand > 0
+ ? Math.min(100, Math.round((m.bestand / (m.mindestbestand * 3)) * 100))
+ : (m.bestand > 0 ? 100 : 0);
+ const farbe = m.leer ? "var(--kritisch)" : m.knapp ? "var(--warn)" : "var(--gut)";
+
+ return `
+
${sicher(m.kennung)}
+
+
${sicher(m.name)}
+ ${m.btm ? `BTM ` : ""}
+
+ ${sicher(m.wirkstoff || "—")}
+ ${m.dosierung_standard ? " · " + sicher(m.dosierung_standard) : ""}
+ ${m.lagerort ? " · " + sicher(m.lagerort) : ""}
+
+
+
+ ${m.abgelaufen
+ ? `
abgelaufen `
+ : m.laeuftAb
+ ? `
+ in ${m.tageBisVerfall} Tagen ` : ""}
+ ${!m.aktiv ? `
stillgelegt ` : ""}
+
+
+
+
+ ${m.bestand} ${sicher(m.einheit)}
+
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.reiter = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-filter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const gewaehlt = el.dataset.filter;
+ // „gesamt" ist kein Filter, sondern hebt ihn auf
+ z.filter = (gewaehlt === "gesamt" || z.filter === gewaehlt) ? "" : gewaehlt;
+ rendern(behaelter);
+ });
+ });
+
+ const filterweg = behaelter.querySelector("[data-filterweg]");
+ if (filterweg) filterweg.addEventListener("click", () => {
+ z.filter = "";
+ rendern(behaelter);
+ });
+
+ behaelter.querySelector("[data-kategorie]").addEventListener("change", (e) => {
+ z.kategorie = e.target.value;
+ rendern(behaelter);
+ });
+
+ const stillgelegt = behaelter.querySelector("[data-stillgelegt]");
+ if (stillgelegt) stillgelegt.addEventListener("click", () => {
+ z.stillgelegt = !z.stillgelegt;
+ rendern(behaelter);
+ });
+
+ const suchfeld = behaelter.querySelector("[data-suche]");
+ let warten = null;
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(() => {
+ z.suche = suchfeld.value.trim();
+ rendern(behaelter);
+ }, 280);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => katalogFenster(behaelter, null));
+ }
+
+ /* ---- Einzelansicht --------------------------------------------------------- */
+
+ function einzelAnsicht(behaelter, d) {
+ const m = d.medikament;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(m.kennung)} · ${sicher(m.name)}
+
${sicher(m.wirkstoff || "ohne Wirkstoffangabe")}
+ ${m.btm ? " · Betäubungsmittel " : ""}
+ ${m.aktiv ? "" : " · stillgelegt"}
+
+
← Bestand
+
+
+ ${m.abgelaufen ? `
+
+
⏳
+
Verfallsdatum überschritten — am ${zeit(m.verfaelltAm)}.
+ Das Präparat bleibt buchbar und
+ dokumentierbar. Ob es noch verwendbar ist, beurteilt die Apotheke —
+ das System sperrt nichts anhand eines Datums.
+
` : ""}
+
+ ${m.bestand <= m.mindestbestand ? `
+
+
📦
+
${m.bestand <= 0 ? "Nicht vorrätig" : "Unter Mindestbestand"}
+ — ${m.bestand} von mindestens ${m.mindestbestand} ${sicher(m.einheit)}.
+ Nachbestellen muss jemand von Hand.
+
` : ""}
+
+
+
+
Bestand ${m.bestand} ${sicher(m.einheit)}
+
Mindestbestand ${m.mindestbestand} ${sicher(m.einheit)}
+
Standarddosierung
+ ${sicher(m.dosierung || "keine hinterlegt")}
+
Lagerort
+ ${sicher(m.lagerort || "nicht angegeben")}
+
Verfällt am
+ ${m.verfaelltAm ? String(m.verfaelltAm).slice(0, 10) : "kein Datum"}
+
Hinweis
+ ${sicher(m.hinweis || "keiner")}
+
+
+ ${d.rechte.bestand && m.aktiv
+ ? `Bestand buchen ` : ""}
+ ${d.rechte.katalog
+ ? `Stammdaten ` : ""}
+ ${d.rechte.katalog
+ ? `
+ ${m.aktiv ? "Stilllegen" : "Freigeben"} ` : ""}
+
+
+
+
+
+
+ Buchungen
+ Bestand nach jeder Buchung
+
+
+ ${d.buchungen.length ? d.buchungen.map((b) => `
+
+
+ ${sicher(b.artName)}
+
+
${b.art === "korrektur" ? "auf" : b.art === "ausgabe"
+ || b.art === "verfall" ? "−" : "+"} ${b.menge} ${sicher(m.einheit)}
+ Stand danach: ${b.bestand_danach}
+
${sicher(b.grund || "")}
+ ${b.patientName ? " · " + sicher(b.patientName) : ""}
+ · ${sicher(b.autor)} · ${zeit(b.gebucht_am)}
+
+
`).join("")
+ : `
Noch keine Buchung.
`}
+
+
+
+
+
+ Dokumentierte Gaben
+ aus den Behandlungsbögen
+
+
+ ${d.gaben.length ? d.gaben.map((g) => `
+
+
${sicher(g.behandlung)}
+
+
${sicher(g.patientName)}
+ ${g.menge} ${sicher(m.einheit)}
+
${g.dosierung ? sicher(g.dosierung) + " · " : ""}
+ ${sicher(g.autor)} · ${zeit(g.gegeben_am)}
+
+ ${g.ohneBuchung
+ ? `
+ ohne Buchung ` : ""}
+
`).join("")
+ : `
Noch keine Gabe dokumentiert.
`}
+
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const m = d.medikament;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ const buchen = behaelter.querySelector("[data-buchen]");
+ if (buchen) buchen.addEventListener("click", () => buchungsFenster(behaelter, d));
+
+ const bearbeiten = behaelter.querySelector("[data-bearbeiten]");
+ if (bearbeiten) bearbeiten.addEventListener("click", () => katalogFenster(behaelter, m));
+
+ const schalten = behaelter.querySelector("[data-schalten]");
+ if (schalten) schalten.addEventListener("click", () => {
+ const freigeben = !m.aktiv;
+
+ bestaetigen({
+ titel: freigeben ? "Medikament freigeben" : "Medikament stilllegen",
+ gefahr: !freigeben,
+ inhalt: `${sicher(m.kennung)} — ${sicher(m.name)}
+ ${freigeben
+ ? "Das Präparat steht wieder zur Auswahl."
+ : "Das Präparat lässt sich für neue Gaben nicht mehr auswählen."}
+ ${!freigeben && m.bestand > 0 ? `
+
+
📦
+
Es liegen noch ${m.bestand} ${sicher(m.einheit)} im Schrank.
+ Die Entnahme muss gebucht werden — das System bucht sie nicht weg.
+
` : ""}
+ Gelöscht wird nichts. Bestehende Gaben und Buchungen
+ bleiben vollständig nachvollziehbar.
+ Grund (freiwillig)
+
`,
+ bestaetigenText: freigeben ? "Freigeben" : "Stilllegen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("medikament", "schalten", {
+ id: m.id, aktiv: freigeben,
+ grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung(freigeben ? "Freigegeben." : "Stillgelegt.", "gut");
+ oeffne(behaelter, m.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+ }
+
+ /* ---- Buchen ----------------------------------------------------------------- */
+
+ function buchungsFenster(behaelter, d) {
+ const m = d.medikament;
+ // Die Ausgabe entsteht nur aus einer Gabe und fehlt hier bewusst
+ const arten = ["eingang", "ruecknahme", "verfall", "korrektur"];
+
+ bestaetigen({
+ titel: "Bestand buchen · " + m.name,
+ inhalt: `
+ Aktueller Bestand: ${m.bestand} ${sicher(m.einheit)}
+ Art der Buchung
+
+ ${arten.map((a) =>
+ `${sicher(d.buchungsarten[a].name)} `).join("")}
+
+
+ Wareneingang und Rücknahme erhöhen den Bestand,
+ Verfall entnommen verringert ihn.
+ Korrektur setzt ihn auf den gezählten Wert.
+ Eine Ausgabe entsteht ausschließlich aus einer dokumentierten Gabe.
+
+ Menge
+
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Buchen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ const antwort = await aufruf("medikament", "buchen", {
+ id: m.id,
+ art: el.querySelector("[name=art]").value,
+ menge: +el.querySelector("[name=menge]").value,
+ grund,
+ });
+ zu();
+ meldung("Gebucht. Neuer Bestand: " + antwort.bestand + " " + m.einheit + ".", "gut");
+ oeffne(behaelter, m.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ // Bei einer Korrektur ist die Menge der neue Stand, nicht die Differenz
+ const art = hg.querySelector("[name=art]");
+ const label = hg.querySelector("[data-mengenlabel]");
+ art.addEventListener("change", () => {
+ label.textContent = art.value === "korrektur"
+ ? "Gezählter Bestand" : "Menge";
+ });
+ },
+ });
+ }
+
+ /* ---- Katalog ---------------------------------------------------------------- */
+
+ function katalogFenster(behaelter, m) {
+ const neu = !m;
+ m = m || {
+ kennung: "", name: "", wirkstoff: "", kategorie: "sonstiges",
+ dosierung: "", einheit: "Stück", mindestbestand: 0, lagerort: "",
+ verfaelltAm: null, hinweis: "", btm: false, sortierung: 0,
+ };
+
+ const darfBtm = z.rechte.btm;
+
+ fenster({
+ titel: neu ? "Medikament anlegen" : "Stammdaten · " + m.name,
+ inhalt: `
+
+
Kennung
+
+
Kategorie
+
+ ${z.kategorien.map((k) =>
+ `
+ ${sicher(k.name)} `).join("")}
+
+
+ Name
+
+ Wirkstoff
+
+
+
+
Standarddosierung
+
+
Ein Vorschlag im Gabe-Dialog, keine Verordnung.
+
Einheit
+
+
+
+
+
Mindestbestand
+
+
Ab hier erinnert das System.
+
Verfällt am
+
+
+
+
+
+ Hinweis
+
+
+
+
+ Betäubungsmittel
+
${darfBtm
+ ? "Betäubungsmittel erscheinen zusätzlich im BTM-Buch und erfordern für jede Gabe und Buchung das BTM-Recht."
+ : "Zum Kennzeichnen als Betäubungsmittel fehlt dir das BTM-Recht."}
+
+
+ ${neu ? `
+
ℹ
+
Ein neues Präparat startet mit Bestand 0 . Den Zugang buchst du
+ anschließend als Wareneingang — damit steht jede Menge im Schrank
+ nachvollziehbar in der Historie.
+
` : ""}`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: neu ? "Anlegen" : "Speichern", klick: async (el, zu) => {
+ const wert = (name) => el.querySelector(`[name=${name}]`).value.trim();
+ try {
+ const antwort = await aufruf("medikament", "speichern", {
+ id: neu ? null : m.id,
+ kennung: wert("kennung"),
+ name: wert("name"),
+ wirkstoff: wert("wirkstoff"),
+ kategorie: el.querySelector("[name=kategorie]").value,
+ dosierung: wert("dosierung"),
+ einheit: wert("einheit"),
+ mindestbestand: +wert("mindestbestand"),
+ verfaelltAm: wert("verfaellt"),
+ lagerort: wert("lagerort"),
+ sortierung: +wert("sortierung"),
+ hinweis: wert("hinweis"),
+ btm: el.querySelector("[name=btm]").checked,
+ });
+ zu();
+ meldung(neu ? "Angelegt: " + antwort.kennung : "Gespeichert.", "gut");
+ if (neu) rendern(behaelter);
+ else oeffne(behaelter, m.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ }
+
+ /* ---- BTM-Buch ---------------------------------------------------------------- */
+
+ function btmAnsicht(behaelter, d) {
+ behaelter.innerHTML = `
+
+
+
BTM-Buch
+
Lückenlose Bewegungsliste aller Betäubungsmittel. Reine Auskunft —
+ hier lässt sich nichts ändern.
+
+
← Bestand
+
+
+
+
Aktueller Stand
+
+ ${d.bestaende.length ? d.bestaende.map((b) => `
+
+
${sicher(b.kennung)}
+
+
${sicher(b.name)}
+
${sicher(b.lagerort || "kein Lagerort")}
+ ${b.verfaellt_am ? " · verfällt "
+ + String(b.verfaellt_am).slice(0, 10) : ""}
+
+ ${b.abgelaufen ? `
abgelaufen ` : ""}
+
+ ${b.bestand} ${sicher(b.einheit)}
+
`).join("")
+ : `
Keine Betäubungsmittel im Katalog.
`}
+
+
+
+ ${d.ohneBuchung.length ? `
+
+
+ Gaben ohne Bestandsbuchung
+ ${d.ohneBuchung.length} Einträge
+
+
+ Diese Gaben wurden bewusst ohne Bestandsbuchung dokumentiert — etwa weil
+ das Präparat aus einem Fahrzeug stammte. Sie stehen hier, damit die
+ Zahlen im Buch erklärbar bleiben, nicht als Beanstandung.
+
+
+ ${d.ohneBuchung.map((g) => `
+
+
${sicher(g.behandlung)}
+
+
${sicher(g.name)}
+ ${g.menge} ${sicher(g.einheit)}
+
${sicher(g.patientName)}
+ · ${sicher(g.autor)} · ${zeit(g.gegeben_am)}
+
+
ohne Buchung
+
`).join("")}
+
+
` : ""}
+
+
+
+ Bewegungen
+ ${d.bewegungen.length} Einträge
+ ${d.gewaehlt ? "· gefiltert" : ""}
+
+ ${d.gewaehlt
+ ? `
Alle Präparate zeigen ` : ""}
+
+ ${d.bewegungen.length ? d.bewegungen.map((b) => `
+
+
+ ${sicher(b.artName)}
+
${sicher(b.kennung)}
+
+
${sicher(b.name)}
+ ${b.art === "korrektur" ? "auf"
+ : b.art === "ausgabe" || b.art === "verfall" ? "−" : "+"}
+ ${b.menge} ${sicher(b.einheit)} · Stand danach: ${b.bestand_danach}
+
${sicher(b.grund || "")}
+ ${b.patientName ? " · " + sicher(b.patientName) : ""}
+ · ${sicher(b.autor)}
+
+
${zeit(b.gebucht_am)}
+
`).join("")
+ : `
Keine Bewegungen erfasst.
`}
+
+
+ `;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.reiter = "bestand";
+ z.btmMedikament = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-med]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.btmMedikament = +el.dataset.med;
+ rendern(behaelter);
+ });
+ });
+
+ const alle = behaelter.querySelector("[data-alle]");
+ if (alle) alle.addEventListener("click", () => {
+ z.btmMedikament = null;
+ rendern(behaelter);
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, medikamentId) {
+ try {
+ const d = await aufruf("medikament", "oeffnen", { id: medikamentId });
+ z.offen = medikamentId;
+ z.rechte = d.rechte;
+ z.kategorien = d.kategorien;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ if (z.reiter === "btm") {
+ const d = await aufruf("medikament", "btmBuch", { medikament: z.btmMedikament });
+ return btmAnsicht(behaelter, d);
+ }
+
+ z.daten = await aufruf("medikament", "uebersicht", {
+ filter: z.filter, kategorie: z.kategorie,
+ begriff: z.suche, stillgelegt: z.stillgelegt,
+ });
+ z.rechte = z.daten.rechte;
+ z.kategorien = z.daten.kategorien;
+
+ // Sprungziel aus einem Hinweis, z. B. „Medikament fast leer"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.medikament) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.medikament);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Medikamente
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("medikament", { rendern });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("medikament", "uebersicht", { begriff });
+ return (d.liste || []).slice(0, 5).map((m) => ({
+ bereich: "Medikament",
+ titel: m.kennung + " · " + m.name,
+ unter: m.bestand + " " + m.einheit + (m.knapp ? " · unter Mindestbestand" : ""),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/operation.js b/resources/[aether]/aether-medical/html/js/module/operation.js
new file mode 100644
index 0000000..a0bac07
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/operation.js
@@ -0,0 +1,735 @@
+/* =====================================================================
+ aether-medical / Ansicht: Operationsverwaltung
+
+ OP-Plan, OP-Säle und Operationsbericht. Der geplante Zeitpunkt ist
+ eine Erinnerung — beginnen und beenden trägt das Team von Hand ein.
+ Überschneidungen im Saal werden angezeigt, aber nicht verhindert.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "plan", // plan | laufend | archiv
+ daten: null,
+ offen: null,
+ };
+
+ const statusArt = {
+ geplant: "info", laufend: "warn", abgeschlossen: "gut", abgesagt: "neutral",
+ };
+
+ /* ---- Übersicht ----------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Operationen
+
Der OP-Plan erinnert an Termine. Begonnen und beendet wird eine
+ Operation ausschließlich durch Eintrag des OP-Teams.
+
+ ${d.rechte.planen
+ ? `
+ Operation planen ` : ""}
+
+
+
+
+
Geplant
+
${d.zaehler.geplant}
+
im OP-Plan
+
+
+
Termin erreicht
+
${d.zaehler.faellig}
+
wartet auf Beginn
+
+
+
Laufend
+
${d.zaehler.laufend}
+
Eingriffe im Gange
+
+
+
Heute beendet
+
${d.zaehler.heute}
+
abgeschlossen
+
+
+
+
+
+ OP-Säle
+ Belegung ergibt sich aus laufenden Eingriffen
+
+
+ ${d.saele.length ? d.saele.map(saalKachel).join("")
+ : `
Keine OP-Säle angelegt.
`}
+
+
+
+
+
+ OP-Plan${d.zaehler.geplant + d.zaehler.laufend}
+
+ Laufend${d.zaehler.laufend}
+
+ Archiv
+
+
+
+
+ ${z.ordner === "archiv"
+ ? "Abgeschlossene und abgesagte Operationen" : "Eingriffe"}
+ ${d.liste.length} Einträge
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
Nichts geplant.
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function saalKachel(s) {
+ const belegt = !!s.laufend;
+ const gesperrt = s.status === "gesperrt";
+
+ return `
+
+ ${sicher(s.kennung)}
+
+ ${belegt ? "belegt" : gesperrt ? "gesperrt" : "frei"}
+
+ ${belegt ? `
+
+
${sicher(s.laufend.kennung)} · ${sicher(s.laufend.art)}
+
${sicher(s.laufend.patientName)}
+ · läuft seit ${minuten(s.laufend.dauer)}
+
`
+ : `
+
${s.geplant} geplante Eingriffe
+ ${s.hinweis ? " · " + sicher(s.hinweis) : ""}
+
`}
+
`;
+ }
+
+ function zeile(o) {
+ const laufend = o.status === "laufend";
+ const faellig = o.status === "geplant" && o.restMinuten !== null && o.restMinuten <= 0;
+
+ return `
+
${sicher(o.kennung)}
+
+
${sicher(o.art)}
+ ${sicher(o.patientName)}
+
+ ${o.saalName ? "Saal " + sicher(o.saalName) : "kein Saal"}
+ · ${o.teamGroesse} im Team
+ · geplant von ${sicher(o.erstelltVon)}
+
+
+
+ ${o.hinweis ? `
⚠ ` : ""}
+ ${o.allergien ? `
A ` : ""}
+ ${o.blutgruppe ? `
${sicher(o.blutgruppe)} ` : ""}
+ ${o.hatKomplikationen ? `
Komplikationen ` : ""}
+ ${o.ichDabei ? `
du bist im Team ` : ""}
+ ${faellig ? `
Termin erreicht ` : ""}
+
+
${sicher(o.status)}
+
${laufend
+ ? "läuft seit " + minuten(o.dauerMinuten)
+ : o.status === "geplant"
+ ? (o.geplant_fuer
+ ? (o.restMinuten > 0 ? "in " + minuten(o.restMinuten) : "seit " + minuten(-o.restMinuten) + " fällig")
+ : "ohne Termin")
+ : zeit(o.ende)}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => planungsFenster(behaelter));
+ }
+
+ /* ---- Einzelansicht -------------------------------------------------------- */
+
+ function einzelAnsicht(behaelter, d) {
+ const o = d.operation, s = d.stamm;
+ const geplant = o.status === "geplant";
+ const laufend = o.status === "laufend";
+ const beendet = o.status === "abgeschlossen" || o.status === "abgesagt";
+ const schreibbar = !beendet && d.rechte.dokumentieren;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(o.kennung)} · ${sicher(o.art)}
+
${sicher(o.patientName)} · geb. ${sicher(o.geburt)}
+ ${o.saalName ? " · Saal " + sicher(o.saalName) : ""}
+
+
← OP-Plan
+
+
+ ${s.hinweis ? `
+
+
⚠
+
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""}
+
+ ${s.allergien ? `
+
+
💊
+
Allergien — ${sicher(s.allergien)}
+
` : ""}
+
+ ${s.implantate ? `
+
+
🔩
+
Implantate — ${sicher(s.implantate)}
+
` : ""}
+
+ ${d.ueberschneidungen.length ? `
+
+
🕐
+
Zeitgleich im selben Saal:
+ ${d.ueberschneidungen.map((u) =>
+ `${sicher(u.kennung)} (${sicher(u.patientName)})`).join(", ")}
+ Das System verhindert nichts — vielleicht
+ ist der andere Eingriff früher fertig. Die Entscheidung liegt bei dir.
+
+
` : ""}
+
+
+
+
Status ${sicher(o.status)}
+
Geplant für ${o.geplantFuer
+ ? zeit(o.geplantFuer) : "ohne Termin"}
+
Beginn ${o.beginn ? zeit(o.beginn) : "noch nicht begonnen"}
+
Ende ${o.ende ? zeit(o.ende) : "—"}
+
Geplant von ${sicher(o.erstelltVon)}
+
Blutgruppe
+ ${sicher(s.blutgruppe || "nicht bestimmt")}
+
+
+ Patientenakte
+ ${geplant && d.rechte.dokumentieren
+ ? `Operation beginnen ` : ""}
+ ${laufend && d.rechte.dokumentieren
+ ? `Operation beenden ` : ""}
+ ${geplant && d.rechte.planen
+ ? `Umplanen ` : ""}
+ ${!beendet && d.rechte.dokumentieren
+ ? `
+ ${d.ichDabei ? "Team verlassen" : "Ins Team eintreten"} ` : ""}
+ ${!beendet && d.rechte.planen
+ ? `Absagen ` : ""}
+
+
+
+
+
+
+ OP-Team
+ ${!beendet && d.rechte.planen
+ ? `+ Mitglied ` : ""}
+
+
+ ${d.team.length ? d.team.map((t) => `
+
+
+ ${sicher(t.funktionName)}
+
+
${sicher(t.name)}
+
${sicher(t.personalnummer || "—")}
+ · ${sicher(t.rolleName || "")}
+
+ ${!beendet && d.rechte.planen
+ ? `
✕ ` : ""}
+
`).join("")
+ : `
Noch niemand eingeteilt.
`}
+
+
+
+
+
Relevante Vorgeschichte
+
+
Vorerkrankungen
+ ${sicher(s.vorerkrankungen || "keine erfasst")}
+
Dauermedikation
+ ${sicher(s.dauermedikation || "keine erfasst")}
+
Implantate
+ ${sicher(s.implantate || "keine erfasst")}
+ ${o.aufnahmeGrund
+ ? `
Aufnahmegrund ${sicher(o.aufnahmeGrund)}
` : ""}
+
+
+ Fehlende Angaben heißen „nicht erhoben". Das System schließt daraus nichts.
+
+
+
+
+
+ ${bogenFeld("verlauf", "Operationsverlauf", o.verlauf, schreibbar,
+ "Zugang, Vorgehen, Befund, Versorgung…")}
+ ${bogenFeld("komplikationen", "Komplikationen", o.komplikationen, schreibbar,
+ "Nur eintragen, wenn welche auftraten.")}
+ ${bogenFeld("nachsorge", "Nachsorge", o.nachsorge, schreibbar,
+ "Drainagen, Verbandwechsel, Kontrolle, Belastung…")}
+
+
+ ${schreibbar ? `
+
+ OP-Bericht speichern
+
` : ""}
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ /** Ein Feld des Operationsberichts */
+ function bogenFeld(name, titel, wert, schreibbar, hinweis) {
+ if (!schreibbar) {
+ return `
+
${sicher(titel)}
+
${wert
+ ? sicher(wert).replace(/\n/g, " ") : "Nicht dokumentiert."}
+
`;
+ }
+ return `
+
${sicher(titel)}
+
+
${sicher(hinweis || "")}
+
`;
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const o = d.operation;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelector("[data-akte]").addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "mdt")) {
+ return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("mdt", { patient: o.patient });
+ });
+
+ /** Liest die Felder des Berichts aus */
+ const bericht = () => ({
+ verlauf: (behaelter.querySelector("[name=verlauf]") || {}).value,
+ komplikationen: (behaelter.querySelector("[name=komplikationen]") || {}).value,
+ nachsorge: (behaelter.querySelector("[name=nachsorge]") || {}).value,
+ });
+
+ const speichern = behaelter.querySelector("[data-speichern]");
+ if (speichern) speichern.addEventListener("click", async () => {
+ try {
+ await aufruf("operation", "dokumentieren",
+ Object.assign({ id: o.id }, bericht()));
+ meldung("OP-Bericht gespeichert.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const beginnen = behaelter.querySelector("[data-beginnen]");
+ if (beginnen) beginnen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Operation beginnen",
+ inhalt: `${sicher(o.kennung)} — ${sicher(o.art)}
+ bei ${sicher(o.patientName)}.
+ Der Beginn wird mit der aktuellen Uhrzeit vermerkt und der Saal gilt
+ als belegt. ${o.geplantFuer
+ ? "Der geplante Termin war " + zeit(o.geplantFuer) + "." : ""}
+ ${d.team.length === 0 ? `
+
+
👥
+
Es ist noch niemand im OP-Team. Du wirst mit dem Beginn
+ als Beteiligter eingetragen.
+
` : ""}`,
+ bestaetigenText: "Beginn eintragen",
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("operation", "beginnen", { id: o.id });
+ zu();
+ meldung("Beginn eingetragen.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const beenden = behaelter.querySelector("[data-beenden]");
+ if (beenden) beenden.addEventListener("click", () => {
+ const aktuell = bericht();
+
+ bestaetigen({
+ titel: "Operation beenden",
+ inhalt: `${sicher(o.kennung)} wird abgeschlossen. Der Bericht
+ ist danach schreibgeschützt.
+ ${!aktuell.verlauf && !o.verlauf ? `
+
+
📄
+
Der Operationsverlauf ist leer. Ohne Verlauf lässt sich
+ nicht abschließen — bitte zuerst dokumentieren.
+
` : ""}
+ Was gerade im Bogen steht, wird mit übernommen.
`,
+ bestaetigenText: "Beenden",
+ gefahr: true,
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("operation", "beenden",
+ Object.assign({ id: o.id }, aktuell));
+ zu();
+ meldung("Operation abgeschlossen.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const umplanen = behaelter.querySelector("[data-umplanen]");
+ if (umplanen) umplanen.addEventListener("click", () => umplanungsFenster(behaelter, d));
+
+ const absagen = behaelter.querySelector("[data-absagen]");
+ if (absagen) absagen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Operation absagen",
+ gefahr: true,
+ inhalt: `${sicher(o.kennung)} — ${sicher(o.art)}
+ Die Operation wird als abgesagt abgelegt. Die Begründung wandert
+ in den Verlauf und bleibt dauerhaft lesbar.
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Absagen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("operation", "absagen", { id: o.id, grund });
+ zu();
+ meldung("Operation abgesagt.", "gut");
+ z.offen = null;
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const team = behaelter.querySelector("[data-team]");
+ if (team) team.addEventListener("click", async () => {
+ try {
+ const antwort = await aufruf("operation", "teamWechsel", {
+ id: o.id, beitreten: !d.ichDabei, funktion: "assistenz",
+ });
+ meldung(antwort.dabei ? "Du bist im OP-Team." : "Team verlassen.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const teamZu = behaelter.querySelector("[data-teamzu]");
+ if (teamZu) teamZu.addEventListener("click", () => teamFenster(behaelter, d));
+
+ behaelter.querySelectorAll("[data-teamweg]").forEach((el) => {
+ el.addEventListener("click", async () => {
+ try {
+ await aufruf("operation", "teamEntfernen", {
+ id: o.id, charakterId: +el.dataset.teamweg,
+ });
+ meldung("Aus dem Team genommen.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+ }
+
+ /* ---- Team ergänzen --------------------------------------------------------- */
+
+ async function teamFenster(behaelter, d) {
+ const o = d.operation;
+
+ let auswahl;
+ try {
+ auswahl = await aufruf("operation", "kollegen", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const imTeam = new Set(d.team.map((t) => t.character));
+ const frei = (auswahl.kollegen || []).filter((k) => !imTeam.has(k.charakterId));
+
+ let gewaehlt = null;
+
+ fenster({
+ titel: "OP-Team ergänzen",
+ inhalt: `
+ Kollege im Dienst
+
+ ${frei.length ? frei.map((k) => `
+
+
${sicher(k.personalnummer)}
+
+
${sicher(k.name)}
+
${sicher(k.rolleName)}
+
+
`).join("")
+ : `
Niemand weiter im Dienst.
`}
+
+ Funktion im Team
+
+ ${d.funktionen.map((f) =>
+ `${sicher(f.name)} `).join("")}
+
+
Die Einteilung ist eine Zuordnung im Bericht —
+ teilnehmen muss der Kollege trotzdem selbst.
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Einteilen", klick: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte einen Kollegen wählen.", "fehler");
+ try {
+ await aufruf("operation", "teamSetzen", {
+ id: o.id, charakterId: gewaehlt,
+ funktion: el.querySelector("[name=funktion]").value,
+ });
+ zu();
+ meldung("Ins Team eingeteilt.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-kollege]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = +el.dataset.kollege;
+ hg.querySelectorAll("[data-kollege]")
+ .forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+ },
+ });
+ }
+
+ /* ---- Planen und Umplanen ---------------------------------------------------- */
+
+ function planungsFenster(behaelter) {
+ let gewaehlt = null;
+ let saele = [];
+
+ bestaetigen({
+ titel: "Operation planen",
+ inhalt: `
+ Patient suchen
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
Art des Eingriffs
+
+
+
OP-Saal
+ — später festlegen —
+
Beginn in … Minuten
+
+
0 = ohne Termin
+
+
+
+ Ich bin der Operateur
+
+
+
ℹ
+
Der Termin ist eine Erinnerung . Zum geplanten Zeitpunkt passiert
+ nichts von selbst — das System meldet sich, beginnen musst du.
+
+
`,
+ bestaetigenText: "Planen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler");
+ const saalFeld = el.querySelector("[name=saal]");
+ try {
+ const antwort = await aufruf("operation", "planen", {
+ patient: gewaehlt.id,
+ aufnahme: gewaehlt.aufnahme || null,
+ art: el.querySelector("[name=art]").value.trim(),
+ saal: saalFeld.value ? +saalFeld.value : null,
+ vorlaufMinuten: +el.querySelector("[name=vorlauf]").value,
+ selbstOperateur: el.querySelector("[name=operateur]").checked,
+ });
+ zu();
+ meldung("Operation " + antwort.kennung + " geplant.", "gut");
+ oeffne(behaelter, antwort.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const liste = hg.querySelector("[data-treffer]");
+ const formular = hg.querySelector("[data-formular]");
+ let warten = null;
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) {
+ liste.innerHTML = `Mindestens zwei Zeichen eingeben.
`;
+ return;
+ }
+ try {
+ const antwort = await aufruf("operation", "patientSuche", { begriff });
+ const treffer = antwort.treffer || [];
+ saele = antwort.saele || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+ ${t.blutgruppe ? " · " + sicher(t.blutgruppe) : ""}
+
+ ${t.hinweis ? `
⚠ ` : ""}
+ ${t.allergien ? `
A ` : ""}
+ ${t.aufnahme ? `
im Haus ` : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehlt = treffer.find((t) => t.id === +el.dataset.person);
+ liste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ formular.style.display = "";
+
+ hg.querySelector("[name=saal]").innerHTML =
+ `— später festlegen — ` +
+ saele.map((s) =>
+ `
+ ${sicher(s.kennung)}${s.belegt > 0 ? " (gerade belegt)" : ""}
+ ${s.status === "gesperrt" ? " — gesperrt" : ""} `).join("");
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+ }
+
+ function umplanungsFenster(behaelter, d) {
+ const o = d.operation;
+
+ bestaetigen({
+ titel: "Operation umplanen",
+ inhalt: `
+ ${sicher(o.kennung)} — ${sicher(o.art)}
+
+
OP-Saal
+
+ — kein Saal —
+ ${d.saele.map((s) =>
+ `
+ ${sicher(s.kennung)}${s.status === "gesperrt" ? " — gesperrt" : ""}
+ `).join("")}
+
+
Neuer Beginn in … Minuten
+
+
0 = Termin entfernen
+
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Umplanen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ const saalFeld = el.querySelector("[name=saal]");
+ try {
+ await aufruf("operation", "umplanen", {
+ id: o.id,
+ saal: saalFeld.value ? +saalFeld.value : null,
+ vorlaufMinuten: +el.querySelector("[name=vorlauf]").value,
+ grund,
+ });
+ zu();
+ meldung("Umgeplant.", "gut");
+ oeffne(behaelter, o.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, operationId) {
+ try {
+ const d = await aufruf("operation", "oeffnen", { id: operationId });
+ z.offen = operationId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("operation", "uebersicht", { ordner: z.ordner });
+
+ // Sprungziel aus einem Hinweis, z. B. „Operation beginnt in 10 Minuten"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.operation) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.operation);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Operationen
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("operation", {
+ rendern,
+ aktualisieren() {
+ if (!z.offen && App.zustand.aktivesModul === "operation") rendern(App.$("inhalt"));
+ },
+ });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("operation", "uebersicht", { ordner: "plan" });
+ const klein = begriff.toLowerCase();
+ return (d.liste || [])
+ .filter((o) => (o.kennung + " " + o.art + " " + o.patientName)
+ .toLowerCase().includes(klein))
+ .slice(0, 5)
+ .map((o) => ({
+ bereich: "Operation", titel: o.kennung + " · " + o.art,
+ unter: o.patientName + " · " + o.status,
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/patienten.js b/resources/[aether]/aether-medical/html/js/module/patienten.js
new file mode 100644
index 0000000..0bac9ab
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/patienten.js
@@ -0,0 +1,667 @@
+/* =====================================================================
+ aether-medical / Ansicht: Patientenverwaltung
+
+ Warteliste, Belegung und Entlassung. Die Liste ist nach der vom
+ Mediziner gesetzten Dringlichkeit sortiert — nicht nach Wartezeit.
+ Wer als Nächstes drankommt, entscheidet niemand ausser dir.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ ordner: "warteliste", // warteliste | station | entlassen
+ station: "",
+ daten: null,
+ offen: null, // geöffnete Aufnahme
+ };
+
+ // Farbe der Dringlichkeitsstufen
+ const prioArt = { 1: "kritisch", 2: "warn", 3: "info", 4: "neutral" };
+
+ /* ---- Gerüst ---------------------------------------------------------- */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "warteliste", text: "Warteliste", zahl: d.zaehler.wartend },
+ { id: "station", text: "Im Haus", zahl: d.zaehler.aufgenommen },
+ { id: "entlassen", text: "Entlassen", zahl: d.zaehler.entlassenHeute },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl}
+ `).join("")}
+
`;
+ }
+
+ /* ---- Übersicht -------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ // Belegung der Stationen als Kachelreihe
+ const stationsKacheln = d.stationen.map((s) => `
+
+
${sicher(s.name)}
+
${s.frei}
+
frei von ${s.betten} · ${s.zimmer} Zimmer
+
`).join("");
+
+ behaelter.innerHTML = `
+
+
+
Patientenverwaltung
+
Angemeldet wird von Hand, aufgenommen wird von Hand, entlassen wird von Hand.
+ Das System führt nur die Liste.
+
+ ${d.rechte.aufnehmen
+ ? `
+ Patient anmelden ` : ""}
+
+
+ ${stationsKacheln}
+
+ ${reiterleiste(d)}
+
+
+
+ Alle Stationen
+ ${d.stationen.map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+ ${d.rechte.belegen
+ ? `Zimmerübersicht ` : ""}
+
+
+
+
+ ${z.ordner === "warteliste" ? "Wartende Patienten"
+ : z.ordner === "station" ? "Patienten im Haus" : "Zuletzt entlassen"}
+ ${z.ordner === "warteliste"
+ ? "Sortiert nach eingetragener Dringlichkeit, nicht nach Wartezeit"
+ : d.liste.length + " Einträge"}
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
${z.ordner === "warteliste"
+ ? "Niemand wartet." : "Keine Einträge."}
`}
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(a) {
+ const entlassen = a.status === "entlassen";
+
+ return `
+
+
+
${sicher(a.name)}
+ geb. ${sicher(a.geburt)}
+
${sicher(String(a.grund).slice(0, 90))}
+
+
+ ${a.hinweis ? `
⚠ ` : ""}
+ ${a.allergien ? `
A ` : ""}
+ ${a.blutgruppe ? `
${sicher(a.blutgruppe)} ` : ""}
+ ${a.aufIntensiv ? `
Intensiv ` : ""}
+
+ ${a.zimmer ? `
${sicher(a.zimmer)} `
+ : a.stationName ? `
${sicher(a.stationName)} ` : ""}
+
+
+ ${sicher(a.dringlichkeit)}
+
+
${entlassen
+ ? zeit(a.entlassen_am)
+ : (z.ordner === "warteliste" ? "wartet " : "seit ") + minuten(a.wartet)}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.ordner = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelector("[data-station]").addEventListener("change", (e) => {
+ z.station = e.target.value;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const anmelden = behaelter.querySelector("[data-anmelden]");
+ if (anmelden) anmelden.addEventListener("click", () => anmeldeFenster(behaelter));
+
+ const zimmer = behaelter.querySelector("[data-zimmer]");
+ if (zimmer) zimmer.addEventListener("click", () => zimmerUebersicht(behaelter));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------------ */
+
+ function einzelAnsicht(behaelter, d) {
+ const a = d.aufnahme, s = d.stamm;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(a.name)}
+
geb. ${sicher(a.geburt)} · ${sicher(a.dringlichkeit)}
+ ${a.stationName ? " · " + sicher(a.stationName) : ""}
+ ${a.zimmer ? " · Zimmer " + sicher(a.zimmer)
+ + (a.bett ? " / Bett " + a.bett : "") : ""}
+
+
← Übersicht
+
+
+ ${s.hinweis ? `
+
+
⚠
+
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""}
+
+ ${s.allergien ? `
+
+
💊
+
Allergien — ${sicher(s.allergien)}
+
` : ""}
+
+
+
${s.lichtbild
+ ? `
`
+ : `
Kein Lichtbild `}
+
+
+
Status
+ ${sicher(statusName(a.status))}
+
Dringlichkeit
+ ${sicher(a.dringlichkeit)}
+
Blutgruppe
+ ${sicher(s.blutgruppe || "nicht bestimmt")}
+
Vorerkrankungen
+ ${sicher(s.vorerkrankungen || "keine erfasst")}
+
Dauermedikation
+ ${sicher(s.dauermedikation || "keine erfasst")}
+
Notfallkontakt
+ ${sicher(s.notfallkontakt || "keiner hinterlegt")}
+
+
+
+ Patientenakte öffnen
+ ${a.status === "wartend" && d.rechte.aufnehmen
+ ? `Aufnehmen ` : ""}
+ ${a.status === "aufgenommen" && d.rechte.verlegen
+ ? `Verlegen ` : ""}
+ ${a.status !== "entlassen" && d.rechte.aufnehmen
+ ? `Dringlichkeit ` : ""}
+ ${a.status !== "entlassen" && d.rechte.entlassen
+ ? `Entlassen ` : ""}
+
+
+
+
+
+
Vorgang
+
+
Grund der Vorstellung ${sicher(a.grund)}
+
Angemeldet ${zeit(a.aufgenommenAm)}
+
Angemeldet von ${sicher(a.aufgenommenVon)}
+ ${a.entlassenAm ? `
+
Entlassen ${zeit(a.entlassenAm)}
+
Entlassen von ${sicher(a.entlassenVon)}
` : ""}
+
+ ${a.entlassGrund ? `
+
Entlassgrund: ${sicher(a.entlassGrund)}
` : ""}
+
+
+
+
+ Behandlungen zu dieser Aufnahme
+
+
+ ${d.behandlungen.length ? d.behandlungen.map((b) => `
+
+
${sicher(b.kennung)}
+
+
${sicher(b.diagnose || "Noch keine Diagnose eingetragen")}
+
${sicher(b.autor)} · ${zeit(b.erstellt_am)}
+
+
+ ${sicher(b.status)}
+
`).join("")
+ : `
Noch nichts dokumentiert.
`}
+
+
+
+ `;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function statusName(status) {
+ return { wartend: "Wartet auf Aufnahme", aufgenommen: "Aufgenommen",
+ entlassen: "Entlassen" }[status] || status;
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const a = d.aufnahme;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelector("[data-akte]").addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "mdt")) {
+ return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler");
+ }
+ App.oeffneModul("mdt", { patient: a.patient });
+ });
+
+ const aufnehmen = behaelter.querySelector("[data-aufnehmen]");
+ if (aufnehmen) aufnehmen.addEventListener("click", () => zuweisungsFenster(behaelter, d, false));
+
+ const verlegen = behaelter.querySelector("[data-verlegen]");
+ if (verlegen) verlegen.addEventListener("click", () => zuweisungsFenster(behaelter, d, true));
+
+ const prio = behaelter.querySelector("[data-prio]");
+ if (prio) prio.addEventListener("click", () => prioFenster(behaelter, d));
+
+ const entlassen = behaelter.querySelector("[data-entlassen]");
+ if (entlassen) entlassen.addEventListener("click", () => entlassungsFenster(behaelter, d));
+ }
+
+ /* ---- Anmeldung ----------------------------------------------------------- */
+
+ function anmeldeFenster(behaelter) {
+ let gewaehlt = null;
+
+ bestaetigen({
+ titel: "Patient anmelden",
+ inhalt: `
+ Person suchen
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
Grund der Vorstellung
+
+
Dringlichkeit
+
+ 1 — Sofort (lebensbedrohlich)
+ 2 — Dringend (zeitkritisch)
+ 3 — Regulär
+ 4 — Nachrangig
+
+
Diese Einstufung nimmst du vor. Das System
+ ändert sie nie — auch nicht, wenn jemand lange wartet.
+
`,
+ bestaetigenText: "Anmelden",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst eine Person wählen.", "fehler");
+ try {
+ await aufruf("patienten", "anmelden", {
+ patient: gewaehlt.id,
+ grund: el.querySelector("[name=grund]").value.trim(),
+ prioritaet: +el.querySelector("[name=prioritaet]").value,
+ });
+ zu();
+ meldung("Patient auf der Warteliste.", "gut");
+ z.ordner = "warteliste";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const suchfeld = hg.querySelector("[name=suche]");
+ const liste = hg.querySelector("[data-treffer]");
+ const formular = hg.querySelector("[data-formular]");
+ let warten = null;
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) {
+ liste.innerHTML = `Mindestens zwei Zeichen eingeben.
`;
+ return;
+ }
+ try {
+ const antwort = await aufruf("patienten", "patientSuche", { begriff });
+ const treffer = antwort.treffer || [];
+
+ liste.innerHTML = treffer.length ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+ ${t.blutgruppe ? " · " + sicher(t.blutgruppe) : ""}
+
+ ${t.hinweis ? `
⚠ ` : ""}
+ ${t.laufend ? `
${sicher(t.laufend)} ` : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ liste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const person = treffer.find((t) => t.id === +el.dataset.person);
+ if (person && person.laufend) {
+ return meldung("Für diesen Patienten läuft bereits eine Aufnahme.", "fehler");
+ }
+ gewaehlt = person;
+ liste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ formular.style.display = "";
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+ }
+
+ /* ---- Aufnahme und Verlegung ----------------------------------------------- */
+
+ async function zuweisungsFenster(behaelter, d, istVerlegung) {
+ const a = d.aufnahme;
+
+ let zimmerDaten;
+ try {
+ zimmerDaten = await aufruf("patienten", "freieZimmer", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ // Zimmer nach Station gruppieren
+ const nachStation = {};
+ zimmerDaten.zimmer.forEach((zi) => {
+ (nachStation[zi.station] = nachStation[zi.station] || {
+ name: zi.stationName, zimmer: [],
+ }).zimmer.push(zi);
+ });
+
+ let gewaehltesZimmer = null;
+ let gewaehlteStation = null;
+
+ bestaetigen({
+ titel: istVerlegung ? "Patient verlegen" : "Patient aufnehmen",
+ inhalt: `
+ ${sicher(a.name)} — ${sicher(a.grund)}
+ ${istVerlegung
+ ? `Grund der Verlegung
+
`
+ : `Vermerk (freiwillig)
+
`}
+
+ Station und Zimmer
+
+ ${Object.keys(nachStation).map((kennung) => {
+ const st = nachStation[kennung];
+ return `
+
+ ${sicher(st.name)}
+
+ ohne Zimmer
+
+
+ ${st.zimmer.map((zi) => `
+
+ ${sicher(zi.kennung)}
+ ${zi.frei}/${zi.betten} frei
+ ${zi.status !== "frei"
+ ? `${sicher(zi.status)} ` : ""}
+ `).join("")}
+
+
`;
+ }).join("")}
+
+
Das System zeigt nur, was frei ist. Welches Zimmer
+ zu diesem Patienten passt, entscheidest du.
+
`,
+ bestaetigenText: istVerlegung ? "Verlegen" : "Aufnehmen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlteStation) {
+ return meldung("Bitte eine Station wählen.", "fehler");
+ }
+ if (istVerlegung) {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte den Grund angeben.", "fehler");
+ }
+ try {
+ const antwort = await aufruf("patienten", istVerlegung ? "verlegen" : "aufnehmen", {
+ id: a.id,
+ station: gewaehlteStation,
+ zimmer: gewaehltesZimmer,
+ grund: istVerlegung ? el.querySelector("[name=grund]").value.trim() : null,
+ vermerk: istVerlegung ? null : el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung(istVerlegung ? "Patient verlegt."
+ : "Patient aufgenommen." + (antwort.bett ? " Bett " + antwort.bett + "." : ""), "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const alleKnoepfe = () => hg.querySelectorAll("[data-zimmer], [data-nurstation]");
+
+ hg.querySelectorAll("[data-zimmer]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehltesZimmer = +el.dataset.zimmer;
+ gewaehlteStation = el.dataset.station;
+ alleKnoepfe().forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+
+ hg.querySelectorAll("[data-nurstation]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehltesZimmer = null;
+ gewaehlteStation = el.dataset.nurstation;
+ alleKnoepfe().forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+ },
+ });
+ }
+
+ /* ---- Dringlichkeit --------------------------------------------------------- */
+
+ function prioFenster(behaelter, d) {
+ const a = d.aufnahme;
+
+ bestaetigen({
+ titel: "Dringlichkeit ändern",
+ inhalt: `
+ Aktuell: ${sicher(a.dringlichkeit)}
+ Neue Einstufung
+
+ ${[1, 2, 3, 4].map((stufe) => `
+
+ ${stufe} — ${sicher(d.dringlichkeit[stufe].name)}
+ (${sicher(d.dringlichkeit[stufe].hinweis)}) `).join("")}
+
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Einstufung setzen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("patienten", "prioritaet", {
+ id: a.id,
+ prioritaet: +el.querySelector("[name=prioritaet]").value,
+ grund,
+ });
+ zu();
+ meldung("Einstufung geändert.", "gut");
+ oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Entlassung ------------------------------------------------------------ */
+
+ function entlassungsFenster(behaelter, d) {
+ const a = d.aufnahme;
+ const offeneDoku = d.behandlungen.filter((b) => b.status === "laufend").length;
+
+ bestaetigen({
+ titel: "Patient entlassen",
+ gefahr: true,
+ inhalt: `
+ ${sicher(a.name)} wird aus der Klinik entlassen.
+ Ein belegtes Bett wird dabei frei.
+ ${offeneDoku > 0 ? `
+
+
📄
+
${offeneDoku} Behandlung(en) noch nicht abgeschlossen.
+ Sie bleiben offen — das System schließt nichts für dich ab.
+ Die Entlassung ist trotzdem möglich.
+
` : ""}
+ Begründung / Entlassbrief (Pflicht)
+
`,
+ bestaetigenText: "Entlassen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("patienten", "entlassen", { id: a.id, grund });
+ zu();
+ meldung("Patient entlassen.", "gut");
+ z.offen = null;
+ z.ordner = "entlassen";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Zimmerübersicht -------------------------------------------------------- */
+
+ async function zimmerUebersicht(behaelter) {
+ let d;
+ try {
+ d = await aufruf("patienten", "freieZimmer", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const nachStation = {};
+ d.zimmer.forEach((zi) => {
+ (nachStation[zi.station] = nachStation[zi.station] || {
+ name: zi.stationName, zimmer: [],
+ }).zimmer.push(zi);
+ });
+
+ const dialog = fenster({
+ titel: "Zimmerübersicht",
+ inhalt: `
+ Ein leer gewordenes Zimmer meldet sich als frei, setzt
+ seinen Status aber nicht selbst zurück — Reinigung und Freigabe bleiben
+ eine Entscheidung.
+ ${Object.keys(nachStation).map((kennung) => {
+ const st = nachStation[kennung];
+ return `
+
${sicher(st.name)}
+
+ ${st.zimmer.map((zi) => `
+
+
${sicher(zi.kennung)}
+
+
${zi.frei} von ${zi.betten} Betten frei
+
${sicher(zi.art)}
+ ${zi.hinweis ? " · " + sicher(zi.hinweis) : ""}
+
+
0 ? "gut" : "warn")
+ : zi.status === "reinigung" ? "warn" : "kritisch"}">
+ ${sicher(zi.status)}
+
+ Status ändern…
+ frei
+ Reinigung
+ gesperrt
+
+
`).join("")}
+
+
`;
+ }).join("")}`,
+ nachOeffnen: (hg) => {
+ hg.querySelectorAll("[data-zimmer]").forEach((el) => {
+ el.addEventListener("change", async () => {
+ if (!el.value) return;
+ try {
+ await aufruf("patienten", "zimmerStatus", {
+ id: +el.dataset.zimmer, status: el.value,
+ });
+ meldung("Zimmerstatus gesetzt.", "gut");
+ dialog.schliessen();
+ rendern(behaelter);
+ } catch (f) {
+ meldung(f.message, "fehler");
+ el.value = "";
+ }
+ });
+ });
+ },
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------------------- */
+
+ async function oeffne(behaelter, aufnahmeId) {
+ try {
+ const d = await aufruf("patienten", "oeffnen", { id: aufnahmeId });
+ z.offen = aufnahmeId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("patienten", "uebersicht", {
+ ordner: z.ordner, station: z.station,
+ });
+
+ // Sprungziel aus einem Hinweis, z. B. „Patient wartet"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.aufnahme) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.aufnahme);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Patientenverwaltung
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("patienten", {
+ rendern,
+ aktualisieren() {
+ // Nur die Übersicht auffrischen, nicht eine offene Einzelansicht stören
+ if (!z.offen && App.zustand.aktivesModul === "patienten") {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/station.js b/resources/[aether]/aether-medical/html/js/module/station.js
new file mode 100644
index 0000000..83a245f
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/station.js
@@ -0,0 +1,849 @@
+/* =====================================================================
+ aether-medical / Ansicht: Stationen und Zimmerbelegung
+
+ Der Bettenspiegel ist eine Auskunft, keine Vorgabe. Er zeigt, was
+ frei ist und wer wo liegt — welches Bett zu welchem Patienten passt,
+ entscheidest du. Kein Zimmer wechselt hier seinen Status von selbst.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ reiter: "spiegel", // spiegel | ohnebett | verwaltung
+ station: "", // Filter auf eine Station (Kennung)
+ zeigeInaktive: false, // Abgeschaltete Stationen mitzeigen (nur Verwaltung)
+ daten: null,
+ };
+
+ // Farbe der Dringlichkeitsstufen — dieselbe Lesart wie in der Patientenverwaltung
+ const prioArt = { 1: "kritisch", 2: "warn", 3: "info", 4: "neutral" };
+
+ /* ---- Kleine Helfer --------------------------------------------------- */
+
+ /** Marker-Art zum Zimmerstatus */
+ function statusArt(status, frei) {
+ if (status === "gesperrt") return "kritisch";
+ if (status === "reinigung") return "warn";
+ return frei > 0 ? "gut" : "neutral";
+ }
+
+ /** Anzeigename eines Zimmerstatus */
+ function statusName(status) {
+ const treffer = (z.daten.zimmerstatus || []).find((s) => s.id === status);
+ return treffer ? treffer.name : status;
+ }
+
+ /** Anzeigename einer Zimmerart */
+ function artName(art) {
+ const treffer = (z.daten.zimmerarten || []).find((a) => a.id === art);
+ return treffer ? treffer.name : art;
+ }
+
+ /** Alle Stationen, die der aktuelle Filter durchlässt */
+ function sichtbareStationen() {
+ if (!z.station) return z.daten.stationen;
+ return z.daten.stationen.filter((s) => s.kennung === z.station);
+ }
+
+ /* ---- Gerüst ----------------------------------------------------------- */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "spiegel", text: "Bettenspiegel", zahl: d.zaehler.frei },
+ { id: "ohnebett", text: "Ohne Bett", zahl: d.ohneBett.length },
+ ];
+
+ if (d.rechte.verwalten) {
+ eintraege.push({ id: "verwaltung", text: "Verwaltung", zahl: d.zaehler.zimmer });
+ }
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}${e.zahl}
+ `).join("")}
+
`;
+ }
+
+ function kennzahlen(d) {
+ const zk = d.zaehler;
+ const auslastung = zk.betten > 0 ? Math.round((zk.belegt / zk.betten) * 100) : 0;
+
+ return `
+
+
Freie Betten
+
${zk.frei}
+
von ${zk.betten} Betten insgesamt
+
+
+
Belegt
+
${zk.belegt}
+
${auslastung} % ausgelastet
+
+
+
Nicht belegbar
+
${zk.gesperrt + zk.reinigung}
+
${zk.gesperrt} gesperrt · ${zk.reinigung} in Reinigung
+
+
+
Ohne Bett
+
${d.ohneBett.length}
+
aufgenommen, aber in keinem Zimmer
+
+
`;
+ }
+
+ /* ---- Bettenspiegel ------------------------------------------------------ */
+
+ /** Ein einzelnes Bett als Zelle */
+ function bettZelle(bett) {
+ const b = bett.belegung;
+
+ if (!b) {
+ return `
+ ${bett.nummer}
+ frei
+
`;
+ }
+
+ return `
+ ${bett.nummer}
+ ${sicher(b.name)}
+
+ ${b.warnhinweis ? `⚠ ` : ""}
+ ${b.allergien ? `A ` : ""}
+ ${b.aufIntensiv ? `ITS ` : ""}
+ ${b.prioritaet ? `
+ ${b.prioritaet} ` : ""}
+
+
`;
+ }
+
+ /** Eine Zimmerkachel */
+ function zimmerKachel(zi) {
+ return `
+
+ ${sicher(zi.kennung)}
+
+ ${zi.status === "frei" ? zi.frei + " / " + zi.betten + " frei"
+ : sicher(statusName(zi.status))}
+
+
${zi.bettenListe.map(bettZelle).join("")}
+ ${zi.hinweis
+ ? `
${sicher(zi.hinweis)}
` : ""}
+
`;
+ }
+
+ /** Eine Station mit allen ihren Zimmern */
+ function stationsKarte(s) {
+ return `
+
+
+ ${sicher(s.name)}
+ ${s.etage ? ` · ${sicher(s.etage)}` : ""}
+ ${s.aktiv ? "" : ` abgeschaltet `}
+
+
+ ${s.belegt} von ${s.betten} Betten belegt · ${s.frei} frei
+
+
+ ${s.zimmer.length
+ ? `
${s.zimmer.map(zimmerKachel).join("")}
`
+ : `
Für diese Station ist kein Zimmer angelegt.
`}
+
`;
+ }
+
+ function spiegelAnsicht(d) {
+ const stationen = sichtbareStationen();
+
+ return stationen.length
+ ? `${stationen.map(stationsKarte).join("")}
`
+ : ``;
+ }
+
+ /* ---- Patienten ohne Bett -------------------------------------------------- */
+
+ function ohneBettAnsicht(d) {
+ return `
+
+ Aufgenommen, aber ohne Bett
+ Das System legt niemanden von selbst hin
+
+
+ ${d.ohneBett.length ? d.ohneBett.map((a) => `
+
+
+
+
${sicher(a.name)}
+
${sicher(String(a.grund || "").slice(0, 90))}
+
+ ${a.warnhinweis ? `
⚠ ` : ""}
+ ${a.allergien ? `
A ` : ""}
+ ${a.blutgruppe ? `
${sicher(a.blutgruppe)} ` : ""}
+ ${a.stationName ? `
${sicher(a.stationName)} ` : ""}
+
seit ${sicher(minuten(a.wartet))}
+ ${d.rechte.belegen
+ ? `
Bett zuweisen ` : ""}
+
`).join("")
+ : `
Jeder aufgenommene Patient liegt in einem Bett.
`}
+
+
`;
+ }
+
+ /* ---- Verwaltung ------------------------------------------------------------ */
+
+ function verwaltungsAnsicht(d) {
+ return `
+
+
ℹ
+
Stationen und Zimmer werden abgeschaltet oder gesperrt, nicht gelöscht.
+ Nur ein Zimmer, in dem nie jemand gelegen hat, lässt sich noch entfernen —
+ sonst verlöre der Belegungsverlauf seinen Ort.
+
+
+
+
+
+ Abgeschaltete Stationen mitzeigen
+
+ + Station anlegen
+
+
+ ${d.stationen.map((s) => `
+
+
+
+ ${sicher(s.kennung)}
+ ${sicher(s.name)}
+ ${s.aktiv ? "" : ` abgeschaltet `}
+
+
+ Bearbeiten
+ + Zimmer
+
+ ${s.aktiv ? "Abschalten" : "Einschalten"}
+
+
+
+
+ ${s.zimmer.length ? s.zimmer.map((zi) => `
+
+
${sicher(zi.kennung)}
+
+
${zi.betten} Bett(en) · ${sicher(artName(zi.art))}
+
${zi.belegt} belegt${
+ zi.hinweis ? " · " + sicher(zi.hinweis) : ""}
+
+
+ ${sicher(statusName(zi.status))}
+
Bearbeiten
+ ${zi.belegt === 0
+ ? `
Entfernen `
+ : ""}
+
`).join("")
+ : `
Noch kein Zimmer angelegt.
`}
+
+
`).join("")}
+ `;
+ }
+
+ /* ---- Gesamtansicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Stationen und Belegung
+
Belegt und frei gegeben wird von Hand. Ein leer gewordenes Zimmer meldet
+ sich, setzt seinen Status aber nicht selbst zurück — Reinigung und Freigabe
+ bleiben eine Entscheidung.
+
+
+
+ ${kennzahlen(d)}
+ ${reiterleiste(d)}
+
+ ${z.reiter === "spiegel" ? `
+
+
+ Alle Stationen
+ ${d.stationen.map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+ Klick auf ein Zimmer öffnet die Belegung.
+
` : ""}
+
+ ${z.reiter === "spiegel" ? spiegelAnsicht(d)
+ : z.reiter === "ohnebett" ? ohneBettAnsicht(d)
+ : verwaltungsAnsicht(d)}
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.reiter = el.dataset.reiter;
+ // Abgeschaltete Stationen gehören nur in die Verwaltung
+ if (z.reiter !== "verwaltung") z.zeigeInaktive = false;
+ rendern(behaelter);
+ });
+ });
+
+ const filter = behaelter.querySelector("[data-stationsfilter]");
+ if (filter) {
+ filter.addEventListener("change", (e) => {
+ z.station = e.target.value;
+ uebersicht(behaelter);
+ });
+ }
+
+ behaelter.querySelectorAll("[data-zimmer]").forEach((el) => {
+ el.addEventListener("click", () => zimmerFenster(behaelter, +el.dataset.zimmer));
+ });
+
+ behaelter.querySelectorAll("[data-zuweisen]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const aufnahme = z.daten.ohneBett.find((a) => a.id === +el.dataset.zuweisen);
+ if (aufnahme) zuweisungsFenster(behaelter, aufnahme);
+ });
+ });
+
+ /* --- Verwaltung --- */
+
+ const inaktive = behaelter.querySelector("[data-inaktive]");
+ if (inaktive) {
+ inaktive.addEventListener("change", () => {
+ z.zeigeInaktive = inaktive.checked;
+ rendern(behaelter);
+ });
+ }
+
+ const neueStation = behaelter.querySelector("[data-neuestation]");
+ if (neueStation) {
+ neueStation.addEventListener("click", () => stationsFenster(behaelter, null));
+ }
+
+ behaelter.querySelectorAll("[data-station-bearbeiten]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const station = z.daten.stationen.find((s) => s.id === +el.dataset.stationBearbeiten);
+ if (station) stationsFenster(behaelter, station);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-station-aktiv]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const station = z.daten.stationen.find((s) => s.id === +el.dataset.stationAktiv);
+ if (station) aktivFenster(behaelter, station, el.dataset.ziel === "1");
+ });
+ });
+
+ behaelter.querySelectorAll("[data-neueszimmer]").forEach((el) => {
+ el.addEventListener("click", () =>
+ zimmerBearbeitenFenster(behaelter, null, +el.dataset.neueszimmer));
+ });
+
+ behaelter.querySelectorAll("[data-zimmer-bearbeiten]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const zimmer = findeZimmer(+el.dataset.zimmerBearbeiten);
+ if (zimmer) zimmerBearbeitenFenster(behaelter, zimmer, zimmer.station);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-zimmer-entfernen]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const zimmer = findeZimmer(+el.dataset.zimmerEntfernen);
+ if (zimmer) entfernenFenster(behaelter, zimmer);
+ });
+ });
+ }
+
+ /** Sucht ein Zimmer über alle Stationen hinweg */
+ function findeZimmer(zimmerId) {
+ for (const station of z.daten.stationen) {
+ const treffer = station.zimmer.find((zi) => zi.id === zimmerId);
+ if (treffer) return treffer;
+ }
+ return null;
+ }
+
+ /* ---- Zimmerfenster ---------------------------------------------------------- */
+
+ async function zimmerFenster(behaelter, zimmerId) {
+ let d;
+ try {
+ d = await aufruf("station", "zimmer", { id: zimmerId });
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ const zi = d.zimmer;
+
+ const dialog = fenster({
+ titel: `Zimmer ${zi.kennung} · ${zi.stationName}`,
+ inhalt: `
+
+
Art ${sicher(artName(zi.art))}
+
Status ${sicher(statusName(zi.status))}
+
Betten ${zi.betten}
+ ${zi.etage ? `
Etage ${sicher(zi.etage)}
` : ""}
+
+ ${zi.hinweis
+ ? `ℹ
+
${sicher(zi.hinweis)}
` : ""}
+
+
+ Belegung
+
+
+ ${d.betten.map((bett) => {
+ const b = bett.belegung;
+ if (!b) {
+ return `
+
Bett ${bett.nummer}
+
+
`;
+ }
+ return `
+
Bett ${bett.nummer}
+
+
${sicher(b.name)}
+ geb. ${sicher(b.geburt)}
+
liegt seit ${sicher(minuten(b.liegtSeit))} ·
+ belegt von ${sicher(b.belegtVon)}
+
+ ${b.warnhinweis ? `
⚠ ` : ""}
+ ${b.allergien ? `
A ` : ""}
+ ${b.blutgruppe ? `
${sicher(b.blutgruppe)} ` : ""}
+ ${b.aufnahme
+ ? `
Vorgang ` : ""}
+ ${d.rechte.belegen
+ ? `
+ Bett freigeben ` : ""}
+
`;
+ }).join("")}
+
+
+ ${d.rechte.belegen ? `
+
+
Zimmerstatus setzen
+
+
+ ${(d.zimmerstatus || []).map((s) =>
+ `
+ ${sicher(s.name)} `).join("")}
+
+
+ Setzen
+
+
Ein leeres Zimmer bleibt so lange in Reinigung oder
+ gesperrt, bis jemand es wieder freigibt. Das System tut das nie.
+
` : ""}
+
+ ${d.verlauf.length ? `
+
+ Frühere Belegungen
+ bleibt dauerhaft erhalten
+
+
+ ${d.verlauf.map((v) => `
+
+
Bett ${v.bett}
+
+
${sicher(v.name)}
+
${zeit(v.belegt_am)} → ${zeit(v.frei_am)} ·
+ freigegeben von ${sicher(v.freiVon)}
+
+
`).join("")}
+
` : ""}
+ `,
+ nachOeffnen: (hg) => {
+ // Sprung in die Patientenverwaltung
+ hg.querySelectorAll("[data-akte]").forEach((el) => {
+ el.addEventListener("click", () => {
+ if (!App.zustand.module.some((m) => m.id === "patienten")) {
+ return meldung("Die Patientenverwaltung steht dir nicht zur Verfügung.", "fehler");
+ }
+ dialog.schliessen();
+ App.oeffneModul("patienten", { aufnahme: +el.dataset.akte });
+ });
+ });
+
+ hg.querySelectorAll("[data-freigeben]").forEach((el) => {
+ el.addEventListener("click", () => {
+ dialog.schliessen();
+ freigabeFenster(behaelter, {
+ id: +el.dataset.freigeben,
+ name: el.dataset.name,
+ bett: el.dataset.bett,
+ zimmer: zi.kennung,
+ });
+ });
+ });
+
+ const setzen = hg.querySelector("[data-statussetzen]");
+ if (setzen) {
+ setzen.addEventListener("click", async () => {
+ const status = hg.querySelector("[data-neuerstatus]").value;
+ const hinweis = hg.querySelector("[data-statushinweis]").value.trim();
+ try {
+ await aufruf("station", "zimmerStatus", { id: zi.id, status, hinweis });
+ dialog.schliessen();
+ meldung("Zimmerstatus gesetzt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ }
+ },
+ });
+ }
+
+ /* ---- Bett freigeben ---------------------------------------------------------- */
+
+ function freigabeFenster(behaelter, belegung) {
+ bestaetigen({
+ titel: "Bett freigeben",
+ inhalt: `
+ ${sicher(belegung.name)} liegt in Zimmer
+ ${sicher(belegung.zimmer)}, Bett ${sicher(belegung.bett)}.
+
+
🧍
+
Das ist keine Entlassung. Der Patient bleibt aufgenommen und
+ steht danach ohne Bett auf der Station. Entlassen wird ausschließlich
+ über die Patientenverwaltung.
+
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Bett freigeben",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 3) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ await aufruf("station", "bettFreigeben", { id: belegung.id, grund });
+ zu();
+ meldung("Bett freigegeben.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Bett zuweisen ------------------------------------------------------------ */
+
+ function zuweisungsFenster(behaelter, aufnahme) {
+ const d = z.daten;
+
+ let gewaehltesZimmer = null; // Zimmerobjekt aus dem Bettenspiegel
+ let gewaehltesBett = null; // Bettnummer oder null für „erstes freies"
+
+ // Nur Stationen mit belegbaren Zimmern anbieten
+ const stationen = d.stationen.filter(
+ (s) => s.aktiv && s.zimmer.some((zi) => zi.verfuegbar));
+
+ bestaetigen({
+ titel: "Bett zuweisen",
+ inhalt: `
+ ${sicher(aufnahme.name)} — ${sicher(aufnahme.grund || "")}
+ ${aufnahme.warnhinweis
+ ? `⚠
+
Warnhinweis — ${sicher(aufnahme.warnhinweis)}
` : ""}
+
+ Zimmer wählen
+
+ ${stationen.length ? stationen.map((s) => `
+
+
+ ${sicher(s.name)}
+ ${s.kennung !== aufnahme.station
+ ? `andere Station ` : ""}
+
+
+ ${s.zimmer.filter((zi) => zi.verfuegbar).map((zi) => `
+
+ ${sicher(zi.kennung)}
+ ${zi.frei}/${zi.betten} frei
+ `).join("")}
+
+
`).join("")
+ : `
Zurzeit ist kein Zimmer belegbar.
`}
+
+
Angezeigt wird nur, was frei und nicht gesperrt ist.
+ Welches Zimmer zu diesem Patienten passt, entscheidest du.
+
+
+
+
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Zuweisen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehltesZimmer) return meldung("Bitte ein Zimmer wählen.", "fehler");
+ try {
+ const antwort = await aufruf("station", "bettZuweisen", {
+ aufnahme: aufnahme.id,
+ zimmer: gewaehltesZimmer.id,
+ bett: gewaehltesBett,
+ vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung(`Zimmer ${antwort.zimmer}, Bett ${antwort.bett} belegt.`, "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ const bettwahl = hg.querySelector("[data-bettwahl]");
+ const bettreihe = hg.querySelector("[data-bettreihe]");
+
+ hg.querySelectorAll("[data-zimmer]").forEach((el) => {
+ el.addEventListener("click", () => {
+ gewaehltesZimmer = findeZimmer(+el.dataset.zimmer);
+ gewaehltesBett = null;
+
+ hg.querySelectorAll("[data-zimmer]").forEach((k) => k.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+
+ // Freie Betten des gewählten Zimmers zur Auswahl stellen
+ const frei = gewaehltesZimmer.bettenListe.filter((b) => !b.belegung);
+ bettwahl.style.display = frei.length > 1 ? "" : "none";
+ bettreihe.innerHTML = frei.map((b) => `
+
+ Bett ${b.nummer}
+ `).join("");
+
+ bettreihe.querySelectorAll("[data-bett]").forEach((knopf) => {
+ knopf.addEventListener("click", () => {
+ gewaehltesBett = +knopf.dataset.bett;
+ bettreihe.querySelectorAll("[data-bett]")
+ .forEach((k) => k.classList.remove("gewaehlt"));
+ knopf.classList.add("gewaehlt");
+ });
+ });
+ });
+ });
+ },
+ });
+ }
+
+ /* ---- Station anlegen und bearbeiten -------------------------------------------- */
+
+ function stationsFenster(behaelter, station) {
+ const d = z.daten;
+ const neu = !station;
+
+ bestaetigen({
+ titel: neu ? "Station anlegen" : `Station ${station.kennung} bearbeiten`,
+ inhalt: `
+
+ Name
+
+
+
Art
+
+ ${(d.stationsarten || []).map((a) =>
+ `
+ ${sicher(a.name)} `).join("")}
+
+
Etage
+
+
+ ${neu ? "" : `
+ Wird die Kennung geändert, ziehen alle Aufnahmen mit,
+ die auf diese Station verweisen.
`}`,
+ bestaetigenText: neu ? "Anlegen" : "Speichern",
+ bestaetigt: async (el, zu) => {
+ const kennung = el.querySelector("[name=kennung]").value.trim();
+ const name = el.querySelector("[name=name]").value.trim();
+ if (!kennung) return meldung("Bitte eine Kennung angeben.", "fehler");
+ if (!name) return meldung("Bitte einen Namen angeben.", "fehler");
+
+ try {
+ await aufruf("station", "stationSpeichern", {
+ id: neu ? null : station.id,
+ kennung,
+ name,
+ art: el.querySelector("[name=art]").value,
+ etage: el.querySelector("[name=etage]").value.trim(),
+ sortierung: +el.querySelector("[name=sortierung]").value,
+ });
+ zu();
+ meldung(neu ? "Station angelegt." : "Station gespeichert.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function aktivFenster(behaelter, station, einschalten) {
+ bestaetigen({
+ titel: einschalten ? "Station einschalten" : "Station abschalten",
+ gefahr: !einschalten,
+ inhalt: einschalten
+ ? `Station ${sicher(station.name)} steht danach wieder für
+ Aufnahmen und Belegungen zur Verfügung.
+ Vermerk (freiwillig)
+
`
+ : `Station ${sicher(station.name)} wird abgeschaltet. Zimmer und
+ Belegungsverlauf bleiben erhalten — es lässt sich dort nur niemand
+ mehr aufnehmen.
+ ℹ
+
Solange dort Patienten liegen, ist das Abschalten nicht möglich.
+ Grund (freiwillig)
+
`,
+ bestaetigenText: einschalten ? "Einschalten" : "Abschalten",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("station", "stationAktiv", {
+ id: station.id,
+ aktiv: einschalten,
+ grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung(einschalten ? "Station eingeschaltet." : "Station abgeschaltet.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Zimmer anlegen und bearbeiten ---------------------------------------------- */
+
+ function zimmerBearbeitenFenster(behaelter, zimmer, stationId) {
+ const d = z.daten;
+ const neu = !zimmer;
+ const station = d.stationen.find((s) => s.id === stationId);
+
+ bestaetigen({
+ titel: neu ? `Zimmer anlegen · ${station ? station.name : ""}`
+ : `Zimmer ${zimmer.kennung} bearbeiten`,
+ inhalt: `
+
+ Art
+
+ ${(d.zimmerarten || []).map((a) =>
+ `
+ ${sicher(a.name)} `).join("")}
+
+ Hinweis (freiwillig)
+
+ ${neu
+ ? "Ein neues Zimmer startet gesperrt. Erst wenn es jemand freigibt, lässt es sich belegen."
+ : "Belegte Betten lassen sich nicht wegkürzen."}
`,
+ bestaetigenText: neu ? "Anlegen" : "Speichern",
+ bestaetigt: async (el, zu) => {
+ const kennung = el.querySelector("[name=kennung]").value.trim();
+ if (!kennung) return meldung("Bitte eine Kennung angeben.", "fehler");
+
+ try {
+ await aufruf("station", "zimmerSpeichern", {
+ id: neu ? null : zimmer.id,
+ station: stationId,
+ kennung,
+ betten: +el.querySelector("[name=betten]").value,
+ art: el.querySelector("[name=art]").value,
+ hinweis: el.querySelector("[name=hinweis]").value.trim(),
+ });
+ zu();
+ meldung(neu ? "Zimmer angelegt." : "Zimmer gespeichert.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function entfernenFenster(behaelter, zimmer) {
+ bestaetigen({
+ titel: `Zimmer ${zimmer.kennung} entfernen`,
+ gefahr: true,
+ inhalt: `
+ Das Zimmer verschwindet aus dem Bettenspiegel.
+
+
ℹ
+
Möglich ist das nur, solange nie jemand darin gelegen hat. Sobald es
+ eine Belegung gab, bleibt das Zimmer bestehen und lässt sich nur sperren.
+
+ Grund (freiwillig)
+
`,
+ bestaetigenText: "Entfernen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("station", "zimmerEntfernen", {
+ id: zimmer.id,
+ grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung("Zimmer entfernt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("station", "uebersicht", { alle: z.zeigeInaktive });
+
+ // Sprungziel aus einem Hinweis, z. B. „Zimmer wurde frei"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.zimmer) {
+ App.zustand.modulZiel = null;
+ uebersicht(behaelter);
+ return zimmerFenster(behaelter, ziel.zimmer);
+ }
+
+ uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Stationen und Belegung
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("station", {
+ rendern,
+ aktualisieren() {
+ // Nur auffrischen, wenn kein Fenster offen ist — sonst geht eine Eingabe verloren
+ if (App.zustand.aktivesModul === "station" && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/html/js/module/vorlage.js b/resources/[aether]/aether-medical/html/js/module/vorlage.js
new file mode 100644
index 0000000..d40f9f6
--- /dev/null
+++ b/resources/[aether]/aether-medical/html/js/module/vorlage.js
@@ -0,0 +1,272 @@
+/* =====================================================================
+ aether-medical / Ansicht: Bausteine
+
+ Pflege der Textvorlagen für Behandlungs- und Berichtsbogen. Ein
+ Baustein ist vorbereiteter Text — er wird nie von selbst eingesetzt
+ und schreibt nichts in eine Dokumentation.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ bereich: "behandlung", // gewählter Bogen
+ daten: null,
+ };
+
+ /* ---- Gerüst ------------------------------------------------------------ */
+
+ function reiterleiste(d) {
+ return `
+ ${d.bereiche.map((b) => `
+
+ ${sicher(b.name)}${b.anzahl}
+ `).join("")}
+
`;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Bausteine
+
Vorformulierter Text für die Bögen. Ein Baustein füllt beim Einsetzen nur
+ das Formular — gespeichert wird danach von Hand, und vorhandener Text
+ bleibt stehen.
+
+
+ Baustein anlegen
+
+
+ ${reiterleiste(d)}
+
+
+
+ Bausteine für diesen Bogen
+ Die Reihenfolge bestimmt nur die Anzeige
+
+
+ ${d.liste.length ? d.liste.map(zeile).join("")
+ : `
Für diesen Bogen ist noch nichts hinterlegt.
`}
+
+
+
+
+
+ Felder dieses Bogens
+
+
+ ${d.felder.map((f) => `${sicher(f.name)} `).join("")}
+
+
+ Ein Baustein kann nur füllen, was es im Bogen auch gibt. Felder, für die
+ jemandem das Recht fehlt, bleiben beim Einsetzen unberührt.
+
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ function zeile(b) {
+ return `
+
${sicher(b.kennung)}
+
+
+
${sicher(b.name)}
+
${b.felder.length
+ ? sicher(b.felder.join(" · ")) : "füllt kein Feld"}
+
+
+
Reihenfolge ${b.sortierung}
+
+ ${b.aktiv ? "einsetzbar" : "abgeschaltet"}
+
+
Ansehen
+
Bearbeiten
+
+ ${b.aktiv ? "Abschalten" : "Einschalten"}
+
Entfernen
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.bereich = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => bearbeitenFenster(behaelter, null));
+
+ behaelter.querySelectorAll("[data-ansehen]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const b = z.daten.liste.find((e) => e.id === +el.dataset.ansehen);
+ if (b) ansehenFenster(b);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-bearbeiten]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const b = z.daten.liste.find((e) => e.id === +el.dataset.bearbeiten);
+ if (b) bearbeitenFenster(behaelter, b);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-aktiv]").forEach((el) => {
+ el.addEventListener("click", async () => {
+ try {
+ await aufruf("vorlage", "aktiv", {
+ id: +el.dataset.aktiv, aktiv: el.dataset.ziel === "1",
+ });
+ meldung(el.dataset.ziel === "1" ? "Baustein einsetzbar." : "Baustein abgeschaltet.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ behaelter.querySelectorAll("[data-entfernen]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const b = z.daten.liste.find((e) => e.id === +el.dataset.entfernen);
+ if (b) entfernenFenster(behaelter, b);
+ });
+ });
+ }
+
+ /* ---- Ansehen ------------------------------------------------------------- */
+
+ function ansehenFenster(b) {
+ const d = z.daten;
+
+ fenster({
+ titel: `${b.kennung} · ${b.name}`,
+ inhalt: `
+ So sieht der Text aus, der beim Einsetzen in die
+ Felder wandert. Vorhandener Text im Bogen bleibt erhalten.
+ ${d.felder.filter((f) => b.inhalt[f.id]).map((f) => `
+
+
${sicher(f.name)}
+
${sicher(b.inhalt[f.id]).replace(/\n/g, " ")}
+
`).join("") || `Dieser Baustein ist leer.
`}`,
+ });
+ }
+
+ /* ---- Anlegen und bearbeiten ------------------------------------------------ */
+
+ function bearbeitenFenster(behaelter, b) {
+ const d = z.daten;
+ const neu = !b;
+
+ bestaetigen({
+ titel: neu ? `Baustein für den ${d.bereiche.find((e) => e.id === z.bereich).name}`
+ : `„${b.name}" bearbeiten`,
+ inhalt: `
+
+
+ Leer gelassene Felder bleiben beim Einsetzen
+ unberührt. Ein Baustein muss mindestens ein Feld füllen.
+
+ ${d.felder.map((f) => `
+ ${sicher(f.name)}
+
`).join("")}
+
+ ${neu ? "" : `Der Bogen eines Bausteins lässt sich
+ nicht wechseln — die Felder des einen passen nicht auf den anderen.
`}`,
+ bestaetigenText: neu ? "Anlegen" : "Speichern",
+ bestaetigt: async (el, zu) => {
+ const name = el.querySelector("[name=name]").value.trim();
+ if (!name) return meldung("Bitte einen Namen angeben.", "fehler");
+
+ // Nur gefüllte Felder wandern in den Baustein
+ const inhalt = {};
+ d.felder.forEach((f) => {
+ const wert = el.querySelector(`[name=${f.id}]`).value.trim();
+ if (wert) inhalt[f.id] = wert;
+ });
+
+ if (!Object.keys(inhalt).length) {
+ return meldung("Bitte mindestens ein Feld ausfüllen.", "fehler");
+ }
+
+ try {
+ await aufruf("vorlage", "speichern", {
+ id: neu ? null : b.id,
+ bereich: z.bereich,
+ name,
+ sortierung: +el.querySelector("[name=sortierung]").value,
+ inhalt,
+ });
+ zu();
+ meldung(neu ? "Baustein angelegt." : "Baustein gespeichert.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function entfernenFenster(behaelter, b) {
+ bestaetigen({
+ titel: `„${b.name}" entfernen`,
+ gefahr: true,
+ inhalt: `
+ Der Baustein verschwindet aus der Auswahl.
+
+
ℹ
+
Was damit bereits geschrieben wurde, steht in den Bögen und bleibt
+ unberührt. Soll der Baustein nur vorübergehend verschwinden, ist
+ Abschalten der sanftere Weg.
+
+ Grund (Pflicht)
+
`,
+ bestaetigenText: "Entfernen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (!grund) return meldung("Bitte einen Grund angeben.", "fehler");
+ try {
+ await aufruf("vorlage", "entfernen", { id: b.id, grund });
+ zu();
+ meldung("Baustein entfernt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("vorlage", "uebersicht", { bereich: z.bereich });
+ uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Bausteine
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("vorlage", {
+ rendern,
+ aktualisieren() {
+ if (App.zustand.aktivesModul === "vorlage" && !document.querySelector(".fenster-hg")) {
+ rendern(App.$("inhalt"));
+ }
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-medical/server/dienst.lua b/resources/[aether]/aether-medical/server/dienst.lua
new file mode 100644
index 0000000..ffb7f24
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/dienst.lua
@@ -0,0 +1,296 @@
+-- =====================================================================
+-- aether-medical — Dienstsystem
+--
+-- Speichert ausschliesslich Statusinformationen. Es gibt keine
+-- automatische Dienstkleidung und keine automatische Fahrzeugausgabe.
+-- Auch die Funktion (RTW, Notarzt, OP, Leitstelle) wählt jeder selbst.
+-- =====================================================================
+
+Dienst = {}
+
+-- Mitarbeiter im Dienst: [quelle] = mitarbeiter
+local imDienst = {}
+-- Wer hält die Leitstelle? (Server-ID oder nil)
+local leitstelle = nil
+
+local Core = exports['aether-core']
+
+-- ---------------------------------------------------------------------
+-- Zugriff
+-- ---------------------------------------------------------------------
+
+--- Liefert die Daten eines Mitarbeiters im Dienst
+--- @param quelle number Server-ID
+--- @return table|nil
+function Dienst.Hole(quelle)
+ return imDienst[quelle]
+end
+
+--- Liefert alle Mitarbeiter im Dienst
+--- @return table [quelle] = mitarbeiter
+function Dienst.AlleImDienst()
+ return imDienst
+end
+
+--- Sucht die Server-ID zu einer Charakter-ID
+--- @param charakterId number Charakter
+--- @return number|nil
+function Dienst.QuelleVonCharakter(charakterId)
+ for quelle, mitarbeiter in pairs(imDienst) do
+ if mitarbeiter.charakterId == charakterId then return quelle end
+ end
+ return nil
+end
+
+--- Wer hält die Leitstelle?
+--- @return number|nil Server-ID
+function Dienst.Leitstelle()
+ return leitstelle
+end
+
+-- ---------------------------------------------------------------------
+-- Personalakte laden
+-- ---------------------------------------------------------------------
+
+--- Lädt den Personaldatensatz und baut daraus den Mitarbeiter
+--- @param quelle number Server-ID
+--- @param charakterId number Charakter
+--- @return table|nil Mitarbeiter, string|nil Fehler
+function Dienst.LadeMitarbeiter(quelle, charakterId)
+ local zeilen = MySQL.query.await(
+ 'SELECT * FROM medical_personal WHERE `character` = ? AND aktiv = 1', { charakterId })
+
+ if not zeilen or not zeilen[1] then
+ return nil, 'Du gehörst nicht zum Personal der Klinik.'
+ end
+
+ local personal = zeilen[1]
+ local person = Core:HoleSpielerInfo(quelle)
+ local rolle = Medizin.HoleRolle(personal.rolle)
+
+ return {
+ quelle = quelle,
+ charakterId = charakterId,
+ name = ((person and person.vorname or '') .. ' ' ..
+ (person and person.nachname or '')),
+ personalnummer = personal.personalnummer,
+ rolle = personal.rolle,
+ rolleName = rolle and rolle.name or personal.rolle,
+ stufe = Medizin.Stufe(personal.rolle),
+ station = personal.station,
+ rechte = Rechte.Berechne(personal),
+ status = 'verfuegbar',
+ funktion = 'station',
+ seit = os.time(),
+ dienstId = nil,
+ }
+end
+
+-- ---------------------------------------------------------------------
+-- Dienst antreten und beenden
+-- ---------------------------------------------------------------------
+
+--- Meldet einen Mitarbeiter zum Dienst an
+--- @param quelle number Server-ID
+--- @param funktion string|nil Gewählte Dienstfunktion
+--- @return table|nil Mitarbeiter, string|nil Fehler
+function Dienst.Antreten(quelle, funktion)
+ if imDienst[quelle] then return imDienst[quelle] end
+
+ local charakterId = Core:HoleCharakterId(quelle)
+ if not charakterId then return nil, 'Kein Charakter geladen.' end
+
+ local mitarbeiter, fehler = Dienst.LadeMitarbeiter(quelle, charakterId)
+ if not mitarbeiter then return nil, fehler end
+
+ if not Rechte.Hat(mitarbeiter, 'dienst.antreten') then
+ return nil, 'Dir fehlt die Berechtigung, den Dienst anzutreten.'
+ end
+
+ -- Funktion prüfen; manche erfordern ein eigenes Recht
+ local gewaehlt = Dienst.PruefeFunktion(mitarbeiter, funktion)
+ mitarbeiter.funktion = gewaehlt
+
+ -- Dienstzeit protokollieren
+ mitarbeiter.dienstId = MySQL.insert.await(
+ 'INSERT INTO medical_dienst (`character`, station, funktion) VALUES (?, ?, ?)',
+ { charakterId, mitarbeiter.station, gewaehlt })
+
+ imDienst[quelle] = mitarbeiter
+
+ Protokoll.Schreibe(mitarbeiter, 'dienst', 'Dienst angetreten',
+ nil, { funktion = gewaehlt, station = mitarbeiter.station })
+ Kern.Loese('dienst.angetreten', quelle, mitarbeiter)
+ Dienst.Verteile()
+
+ return mitarbeiter
+end
+
+--- Prüft eine gewünschte Dienstfunktion und liefert eine zulässige zurück
+--- @param mitarbeiter table Mitarbeiter
+--- @param funktion string|nil Wunsch
+--- @return string Zulässige Funktion
+function Dienst.PruefeFunktion(mitarbeiter, funktion)
+ for _, eintrag in ipairs(Medizin.Config.Dienstfunktionen) do
+ if eintrag.id == funktion then
+ if not eintrag.recht or Rechte.Hat(mitarbeiter, eintrag.recht) then
+ return eintrag.id
+ end
+ break
+ end
+ end
+ -- Im Zweifel der einfache Stationsdienst
+ return 'station'
+end
+
+--- Meldet einen Mitarbeiter vom Dienst ab
+--- @param quelle number Server-ID
+function Dienst.Beenden(quelle)
+ local mitarbeiter = imDienst[quelle]
+ if not mitarbeiter then return end
+
+ if mitarbeiter.dienstId then
+ MySQL.update('UPDATE medical_dienst SET ende = NOW(), sekunden = ? WHERE id = ?',
+ { os.time() - mitarbeiter.seit, mitarbeiter.dienstId })
+ end
+
+ imDienst[quelle] = nil
+
+ -- Leitstelle freigeben, wenn der Inhaber geht
+ if leitstelle == quelle then
+ leitstelle = nil
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'dienst', 'Dienst beendet')
+ Kern.Loese('dienst.beendet', quelle, mitarbeiter)
+ Dienst.Verteile()
+end
+
+--- Setzt den Status (verfügbar, am Patienten, Pause ...)
+--- @param quelle number Server-ID
+--- @param status string Statuskennung
+--- @return boolean
+function Dienst.SetzeStatus(quelle, status)
+ local mitarbeiter = imDienst[quelle]
+ if not mitarbeiter then return false end
+
+ local gueltig = false
+ for _, eintrag in ipairs(Medizin.Config.Dienststatus) do
+ if eintrag.id == status then gueltig = true break end
+ end
+ if not gueltig then return false end
+
+ mitarbeiter.status = status
+ Kern.Loese('dienst.status', quelle, mitarbeiter, status)
+ Dienst.Verteile()
+ return true
+end
+
+--- Wechselt die Dienstfunktion im laufenden Dienst
+--- @param quelle number Server-ID
+--- @param funktion string Gewünschte Funktion
+--- @return boolean, string|nil
+function Dienst.SetzeFunktion(quelle, funktion)
+ local mitarbeiter = imDienst[quelle]
+ if not mitarbeiter then return false end
+
+ local gewaehlt = Dienst.PruefeFunktion(mitarbeiter, funktion)
+ if gewaehlt ~= funktion then
+ return false, 'Dir fehlt die Berechtigung für diese Funktion.'
+ end
+
+ mitarbeiter.funktion = gewaehlt
+
+ if mitarbeiter.dienstId then
+ MySQL.update('UPDATE medical_dienst SET funktion = ? WHERE id = ?',
+ { gewaehlt, mitarbeiter.dienstId })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'dienst', 'Dienstfunktion gewechselt',
+ nil, { funktion = gewaehlt })
+ Kern.Loese('dienst.funktion', quelle, mitarbeiter, gewaehlt)
+ Dienst.Verteile()
+ return true
+end
+
+--- Übernimmt oder überträgt die Leitstelle
+--- @param quelle number Handelnder
+--- @param zielQuelle number|nil Ziel (nil = selbst übernehmen)
+--- @return boolean, string|nil
+function Dienst.SetzeLeitstelle(quelle, zielQuelle)
+ local mitarbeiter = imDienst[quelle]
+ if not mitarbeiter then return false end
+
+ if not Rechte.Hat(mitarbeiter, 'dienst.leitstelle') then
+ return false, 'Dir fehlt die Berechtigung für die Leitstelle.'
+ end
+
+ if zielQuelle and zielQuelle ~= quelle then
+ -- Zuweisen an andere setzt voraus, dass man über ihnen steht
+ local ziel = imDienst[zielQuelle]
+ if not ziel then return false, 'Diese Person ist nicht im Dienst.' end
+ if not Rechte.StehtUeber(mitarbeiter, ziel.rolle) then
+ return false, 'Du kannst die Leitstelle nur an rangniedrigere Kollegen übertragen.'
+ end
+ leitstelle = zielQuelle
+ Protokoll.Schreibe(mitarbeiter, 'dienst', 'Leitstelle übertragen',
+ { typ = 'person', id = ziel.charakterId })
+ else
+ leitstelle = quelle
+ Protokoll.Schreibe(mitarbeiter, 'dienst', 'Leitstelle übernommen')
+ end
+
+ Kern.Loese('dienst.leitstelle', leitstelle)
+ Dienst.Verteile()
+ return true
+end
+
+-- ---------------------------------------------------------------------
+-- Verteilung an die Clients
+-- ---------------------------------------------------------------------
+
+--- Baut die Liste der Mitarbeiter im Dienst
+--- @return table
+function Dienst.BaueListe()
+ local liste = {}
+ for quelle, mitarbeiter in pairs(imDienst) do
+ local position = GetEntityCoords(GetPlayerPed(quelle))
+ liste[#liste + 1] = {
+ quelle = quelle,
+ charakterId = mitarbeiter.charakterId,
+ name = mitarbeiter.name,
+ personalnummer = mitarbeiter.personalnummer,
+ rolle = mitarbeiter.rolle,
+ rolleName = mitarbeiter.rolleName,
+ station = mitarbeiter.station,
+ funktion = mitarbeiter.funktion,
+ status = mitarbeiter.status,
+ leitstelle = leitstelle == quelle,
+ seit = mitarbeiter.seit,
+ position = { x = position.x, y = position.y, z = position.z },
+ }
+ end
+ table.sort(liste, function(a, b) return a.personalnummer < b.personalnummer end)
+ return liste
+end
+
+--- Schickt die Dienstliste an alle im Dienst
+function Dienst.Verteile()
+ local liste = Dienst.BaueListe()
+ for quelle in pairs(imDienst) do
+ TriggerClientEvent('aether:medical:dienstliste', quelle, liste)
+ end
+end
+
+-- Positionen regelmäßig auffrischen (für die Einsatzkarte)
+CreateThread(function()
+ while true do
+ Wait(5000)
+ if next(imDienst) then Dienst.Verteile() end
+ end
+end)
+
+-- Beim Verlassen sauber abmelden
+AddEventHandler('playerDropped', function()
+ Dienst.Beenden(source)
+end)
diff --git a/resources/[aether]/aether-medical/server/hinweise.lua b/resources/[aether]/aether-medical/server/hinweise.lua
new file mode 100644
index 0000000..fcdb6ca
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/hinweise.lua
@@ -0,0 +1,484 @@
+-- =====================================================================
+-- aether-medical — Hinweise und Fristenüberwachung
+--
+-- Dies ist der einzige Teil des Systems, der von selbst tätig wird —
+-- und er tut ausschliesslich eines: ERINNERN.
+--
+-- Kein Hinweis nimmt einen Patienten auf, entlässt ihn, gibt ein
+-- Medikament aus, wirft eine Blutkonserve weg oder schliesst eine
+-- Dokumentation ab. Jede Frist läuft ab, ohne dass etwas passiert,
+-- bis ein Mensch entscheidet.
+-- =====================================================================
+
+Hinweise = {}
+
+-- Bereits gemeldete Fristen, damit nicht im Minutentakt gewarnt wird:
+-- [schluessel] = { [stufe] = true }
+local gemeldet = {}
+
+--- Erstellt einen Hinweis
+--- @param ziel table|nil { charakterId? } — ohne charakterId geht er an alle im Dienst
+--- @param daten table { kategorie, prioritaet?, titel, text?, zielTyp?, zielId? }
+function Hinweise.Erstelle(ziel, daten)
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_hinweis
+ (`character`, kategorie, prioritaet, titel, text, ziel_typ, ziel_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ ziel and ziel.charakterId or nil,
+ daten.kategorie or 'system',
+ daten.prioritaet or 'normal',
+ daten.titel,
+ daten.text,
+ daten.zielTyp,
+ daten.zielId and tostring(daten.zielId) or nil,
+ })
+
+ -- Betroffene Clients sofort informieren.
+ -- Die Feldnamen entsprechen bewusst denen aus Hinweise.Lade, damit ein
+ -- frisch gemeldeter Hinweis genauso anklickbar ist wie ein geladener.
+ local paket = {
+ id = id,
+ kategorie = daten.kategorie or 'system',
+ prioritaet = daten.prioritaet or 'normal',
+ titel = daten.titel,
+ text = daten.text,
+ zielTyp = daten.zielTyp,
+ zielId = daten.zielId,
+ }
+
+ if ziel and ziel.charakterId then
+ local quelle = Dienst.QuelleVonCharakter(ziel.charakterId)
+ if quelle then
+ TriggerClientEvent('aether:medical:hinweis', quelle, paket)
+ end
+ else
+ for quelle in pairs(Dienst.AlleImDienst()) do
+ TriggerClientEvent('aether:medical:hinweis', quelle, paket)
+ end
+ end
+
+ return id
+end
+
+--- Lädt die letzten Hinweise eines Mitarbeiters
+--- @param charakterId number Charakter
+--- @param limit number|nil Anzahl
+--- @return table
+function Hinweise.Lade(charakterId, limit)
+ local zeilen = MySQL.query.await([[
+ SELECT * FROM medical_hinweis
+ WHERE (`character` = ? OR `character` IS NULL)
+ ORDER BY id DESC LIMIT ?
+ ]], { charakterId, limit or 30 }) or {}
+
+ -- Sprungziel auf die Schreibweise der Oberfläche bringen
+ for _, eintrag in ipairs(zeilen) do
+ eintrag.zielTyp = eintrag.ziel_typ
+ eintrag.zielId = eintrag.ziel_id
+ eintrag.ziel_typ, eintrag.ziel_id = nil, nil
+ end
+
+ return zeilen
+end
+
+--- Markiert einen Hinweis als gelesen
+function Hinweise.MarkiereGelesen(charakterId, hinweisId)
+ MySQL.update([[
+ UPDATE medical_hinweis SET gelesen = 1
+ WHERE id = ? AND (`character` = ? OR `character` IS NULL)
+ ]], { hinweisId, charakterId })
+end
+
+--- Meldet eine Frist genau einmal je Stufe
+--- @param schluessel string Eindeutige Kennung des Vorgangs
+--- @param stufe any Stufe der Erinnerung
+--- @return boolean true, wenn jetzt gemeldet werden soll
+local function ErstmalsMelden(schluessel, stufe)
+ gemeldet[schluessel] = gemeldet[schluessel] or {}
+ if gemeldet[schluessel][stufe] then return false end
+ gemeldet[schluessel][stufe] = true
+ return true
+end
+
+--- Merkt eine Erinnerung als bereits erledigt vor.
+--- Ein Modul, das selbst sofort benachrichtigt, verhindert damit, dass
+--- die Fristenprüfung die gleiche Sache kurz darauf noch einmal meldet.
+--- @param schluessel string Eindeutige Kennung des Vorgangs
+--- @param stufe any Stufe der Erinnerung
+function Hinweise.AlsGemeldet(schluessel, stufe)
+ gemeldet[schluessel] = gemeldet[schluessel] or {}
+ gemeldet[schluessel][stufe] = true
+end
+
+-- ---------------------------------------------------------------------
+-- Fristenüberwachung
+--
+-- Läuft im Hintergrund. Jeder Block hier endet mit einem Hinweis —
+-- niemals mit einem UPDATE auf einen medizinischen Vorgang.
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ Wait(5000)
+
+ while true do
+ Wait(Medizin.Config.Erinnerungen.pruefIntervall)
+
+ -- --- Wartende Patienten ---
+ local wartend = MySQL.query.await([[
+ SELECT a.id, a.patient, a.prioritaet, a.grund,
+ TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet,
+ c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.status = 'wartend'
+ ]]) or {}
+
+ for _, eintrag in ipairs(wartend) do
+ local wartet = tonumber(eintrag.wartet) or 0
+ local schluessel = 'warte_' .. eintrag.id
+
+ -- Höchste erreichte Stufe melden, nicht jede einzeln
+ local stufe = nil
+ for _, grenze in ipairs(Medizin.Config.Erinnerungen.warteZeitStufen) do
+ if wartet >= grenze then stufe = grenze end
+ end
+
+ if stufe and ErstmalsMelden(schluessel, stufe) then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient',
+ prioritaet = tonumber(eintrag.prioritaet) <= 2 and 'hoch' or 'normal',
+ titel = ('Patient wartet seit %d Minuten'):format(stufe),
+ text = ('%s %s — %s'):format(eintrag.firstname, eintrag.lastname,
+ tostring(eintrag.grund):sub(1, 80)),
+ zielTyp = 'aufnahme', zielId = eintrag.id,
+ })
+ end
+ end
+
+ -- --- Geplante Operationen ---
+ local operationen = MySQL.query.await([[
+ SELECT o.id, o.kennung, o.art, o.patient,
+ TIMESTAMPDIFF(MINUTE, NOW(), o.geplant_fuer) AS rest,
+ c.firstname, c.lastname
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ WHERE o.status = 'geplant' AND o.geplant_fuer IS NOT NULL
+ AND o.geplant_fuer > DATE_SUB(NOW(), INTERVAL 2 HOUR)
+ ]]) or {}
+
+ for _, operation in ipairs(operationen) do
+ local rest = tonumber(operation.rest) or 0
+ local schluessel = 'op_' .. operation.id
+
+ for _, vorlauf in ipairs(Medizin.Config.Erinnerungen.opVorlauf) do
+ if rest <= vorlauf and rest > 0 and ErstmalsMelden(schluessel, vorlauf) then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'op', prioritaet = 'normal',
+ titel = ('Operation beginnt in %d Minuten'):format(vorlauf),
+ text = ('%s — %s (%s %s)'):format(operation.kennung, operation.art,
+ operation.firstname, operation.lastname),
+ zielTyp = 'operation', zielId = operation.id,
+ })
+ break
+ end
+ end
+
+ -- Der geplante Zeitpunkt ist erreicht — die OP startet trotzdem nicht
+ if rest <= 0 and ErstmalsMelden(schluessel, 'faellig') then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'op', prioritaet = 'hoch',
+ titel = 'Geplante Operation ist fällig',
+ text = ('%s — %s. Beginn muss vom OP-Team eingetragen werden.')
+ :format(operation.kennung, operation.art),
+ zielTyp = 'operation', zielId = operation.id,
+ })
+ end
+ end
+
+ -- --- Offene Behandlungsdokumentationen ---
+ local offeneDoku = MySQL.query.await([[
+ SELECT b.id, b.kennung, b.erstellt_von, c.firstname, c.lastname
+ FROM medical_behandlung b
+ JOIN aether_characters c ON c.id = b.patient
+ WHERE b.status = 'laufend'
+ AND b.erstellt_am < DATE_SUB(NOW(), INTERVAL ? MINUTE)
+ ]], { Medizin.Config.Erinnerungen.dokuOffenNach }) or {}
+
+ for _, doku in ipairs(offeneDoku) do
+ if ErstmalsMelden('doku_' .. doku.id, 'offen') then
+ Hinweise.Erstelle({ charakterId = doku.erstellt_von }, {
+ kategorie = 'doku', prioritaet = 'normal',
+ titel = 'Dokumentation fehlt',
+ text = ('%s (%s %s) ist noch nicht abgeschlossen.')
+ :format(doku.kennung, doku.firstname, doku.lastname),
+ zielTyp = 'behandlung', zielId = doku.id,
+ })
+ end
+ end
+
+ -- --- Intensivpatienten ohne frischen Verlaufseintrag ---
+ --
+ -- Erinnert daran, dass jemand nachschauen sollte. Das System
+ -- schaut nicht nach, misst nichts und ändert keinen Zustand.
+ local intensiv = MySQL.query.await([[
+ SELECT i.id, i.patient, i.zustand, i.verantwortlich,
+ c.firstname, c.lastname,
+ TIMESTAMPDIFF(MINUTE, COALESCE(
+ (SELECT MAX(v.am) FROM medical_intensiv_verlauf v
+ WHERE v.intensiv = i.id),
+ i.aufgenommen_am), NOW()) AS stillSeit,
+ (SELECT COUNT(*) FROM medical_intensiv_verlauf v
+ WHERE v.intensiv = i.id) AS eintraege
+ FROM medical_intensiv i
+ JOIN aether_characters c ON c.id = i.patient
+ WHERE i.beendet_am IS NULL
+ ]]) or {}
+
+ for _, aufenthalt in ipairs(intensiv) do
+ local still = tonumber(aufenthalt.stillSeit) or 0
+
+ -- Die Stufe enthält die Anzahl der Einträge: nach jedem neuen
+ -- Eintrag beginnt die Erinnerung von vorn.
+ local stufe = 'still_' .. tostring(aufenthalt.eintraege)
+
+ if still >= Medizin.Config.Erinnerungen.intensivStilleNach
+ and ErstmalsMelden('intensiv_' .. aufenthalt.id, stufe) then
+ -- Wer zuständig ist, wird persönlich erinnert; sonst das ganze Haus
+ local ziel = aufenthalt.verantwortlich
+ and { charakterId = aufenthalt.verantwortlich } or nil
+
+ Hinweise.Erstelle(ziel, {
+ kategorie = 'patient',
+ prioritaet = aufenthalt.zustand == 'kritisch' and 'hoch' or 'normal',
+ titel = ('Intensivverlauf seit %d Minuten ohne Eintrag'):format(still),
+ text = ('%s %s — zuletzt dokumentiert vor %d Minuten.')
+ :format(aufenthalt.firstname, aufenthalt.lastname, still),
+ zielTyp = 'intensiv', zielId = aufenthalt.id,
+ })
+ end
+ end
+
+ -- --- Laborergebnisse ohne Bewertung ---
+ local befunde = MySQL.query.await([[
+ SELECT l.id, l.kennung, l.art, l.auftrag_von, c.firstname, c.lastname
+ FROM medical_labor l
+ JOIN aether_characters c ON c.id = l.patient
+ WHERE l.status = 'fertig' AND l.befund IS NULL
+ ]]) or {}
+
+ for _, labor in ipairs(befunde) do
+ if ErstmalsMelden('labor_' .. labor.id, 'fertig') then
+ Hinweise.Erstelle({ charakterId = labor.auftrag_von }, {
+ kategorie = 'labor', prioritaet = 'normal',
+ titel = 'Laborergebnis verfügbar',
+ text = ('%s (%s) für %s %s — Bewertung steht aus.')
+ :format(labor.kennung, labor.art, labor.firstname, labor.lastname),
+ zielTyp = 'labor', zielId = labor.id,
+ })
+ end
+ end
+
+ -- --- Medikamente: Mindestbestand und Verfall ---
+ local medikamente = MySQL.query.await([[
+ SELECT id, kennung, name, bestand, mindestbestand, einheit,
+ DATEDIFF(verfaellt_am, CURDATE()) AS tage
+ FROM medical_medikament
+ WHERE aktiv = 1
+ AND (bestand <= mindestbestand
+ OR (verfaellt_am IS NOT NULL
+ AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)))
+ ]], { Medizin.Config.Erinnerungen.medikamentVorlaufTage }) or {}
+
+ for _, medikament in ipairs(medikamente) do
+ local schluessel = 'med_' .. medikament.id
+
+ if medikament.bestand <= medikament.mindestbestand
+ and ErstmalsMelden(schluessel, 'bestand') then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'medikament',
+ prioritaet = medikament.bestand <= 0 and 'hoch' or 'normal',
+ titel = medikament.bestand <= 0 and 'Medikament nicht vorrätig'
+ or 'Medikament fast leer',
+ text = ('%s — Bestand %d %s (Mindestbestand %d).')
+ :format(medikament.name, medikament.bestand,
+ medikament.einheit, medikament.mindestbestand),
+ zielTyp = 'medikament', zielId = medikament.id,
+ })
+ end
+
+ local tage = tonumber(medikament.tage)
+ if tage and tage <= Medizin.Config.Erinnerungen.medikamentVorlaufTage
+ and ErstmalsMelden(schluessel, 'verfall') then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'medikament', prioritaet = 'normal',
+ titel = tage <= 0 and 'Medikament abgelaufen' or 'Medikament läuft ab',
+ text = ('%s — %s. Entscheidung über die Entnahme trifft die Apotheke.')
+ :format(medikament.name,
+ tage <= 0 and 'Verfallsdatum überschritten'
+ or ('noch ' .. tage .. ' Tage')),
+ zielTyp = 'medikament', zielId = medikament.id,
+ })
+ end
+ end
+
+ -- --- Blutkonserven kurz vor Ablauf ---
+ local konserven = MySQL.query.await([[
+ SELECT id, konserve, blutgruppe,
+ DATEDIFF(verfaellt_am, CURDATE()) AS tage
+ FROM medical_blut
+ WHERE status IN ('vorraetig', 'reserviert')
+ AND verfaellt_am IS NOT NULL
+ AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
+ ]], { Medizin.Config.Erinnerungen.blutVorlaufTage }) or {}
+
+ for _, konserve in ipairs(konserven) do
+ local tage = tonumber(konserve.tage) or 0
+ if ErstmalsMelden('blut_' .. konserve.id, tage <= 0 and 'abgelaufen' or 'bald') then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'blut',
+ prioritaet = tage <= 0 and 'hoch' or 'normal',
+ titel = tage <= 0 and 'Blutkonserve abgelaufen' or 'Blutkonserve läuft ab',
+ text = ('%s (%s) — %s. Verwerfen muss jemand von Hand.')
+ :format(konserve.konserve, konserve.blutgruppe,
+ tage <= 0 and 'Ablaufdatum überschritten'
+ or ('noch ' .. tage .. ' Tage')),
+ zielTyp = 'blut', zielId = konserve.id,
+ })
+ end
+ end
+
+ -- --- Blutgruppen mit knappem Bestand ---
+ --
+ -- Erinnert, dass nachbestellt werden sollte. Das System
+ -- bestellt nichts und gibt nichts frei.
+ for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
+ local vorraetig = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE blutgruppe = ? AND status = 'vorraetig'
+ ]], { gruppe })) or 0
+
+ -- Die Stufe ist der Bestand selbst: jede neue Tiefe meldet sich
+ -- einmal, ein wieder aufgefüllter Bestand meldet sich nicht.
+ if vorraetig <= Medizin.Config.Erinnerungen.blutMindestbestand
+ and ErstmalsMelden('blutbestand_' .. gruppe, tostring(vorraetig)) then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'blut',
+ prioritaet = vorraetig <= 0 and 'hoch' or 'normal',
+ titel = vorraetig <= 0 and ('Blutgruppe %s nicht vorrätig'):format(gruppe)
+ or ('Blutgruppe %s wird knapp'):format(gruppe),
+ text = ('Noch %d Konserve(n) im Bestand (Mindestbestand %d).')
+ :format(vorraetig, Medizin.Config.Erinnerungen.blutMindestbestand),
+ })
+ end
+ end
+
+ -- --- Fahrzeuge mit gemeldeten Auffälligkeiten ---
+ --
+ -- Erinnert an das, was der Fahrer gemeldet hat. Kein Hinweis
+ -- setzt den Status, schickt ein Fahrzeug in die Werkstatt oder
+ -- nimmt es aus dem Dienst — das trägt ein Mensch ein.
+ local fahrzeuge = MySQL.query.await([[
+ SELECT id, kennzeichen, bezeichnung, tank, zustand
+ FROM medical_fahrzeug
+ WHERE status IN ('verfuegbar', 'im_einsatz')
+ AND gemeldet_am IS NOT NULL
+ AND gemeldet_am > DATE_SUB(NOW(), INTERVAL 1 HOUR)
+ ]]) or {}
+
+ for _, fahrzeug in ipairs(fahrzeuge) do
+ local tank = tonumber(fahrzeug.tank)
+ local zustand = tonumber(fahrzeug.zustand)
+
+ -- Die Stufe ist ein Zehnerband: so meldet sich ein sinkender
+ -- Wert höchstens zweimal und nicht bei jedem Prozentpunkt.
+ if tank and tank <= Medizin.Config.Erinnerungen.fahrzeugTankUnter
+ and ErstmalsMelden('fahrzeug_' .. fahrzeug.id,
+ 'tank_' .. tostring(math.floor(tank / 10))) then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system',
+ prioritaet = tank <= 5 and 'hoch' or 'normal',
+ titel = ('Tankstand gemeldet · %s'):format(fahrzeug.bezeichnung),
+ text = ('%s (%s) — zuletzt %d %% gemeldet.')
+ :format(fahrzeug.bezeichnung, fahrzeug.kennzeichen, math.floor(tank)),
+ zielTyp = 'fahrzeug', zielId = fahrzeug.id,
+ })
+ end
+
+ if zustand and zustand <= Medizin.Config.Erinnerungen.fahrzeugZustandUnter
+ and ErstmalsMelden('fahrzeug_' .. fahrzeug.id,
+ 'zustand_' .. tostring(math.floor(zustand / 10))) then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system',
+ prioritaet = zustand <= 25 and 'hoch' or 'normal',
+ titel = ('Fahrzeugschaden gemeldet · %s'):format(fahrzeug.bezeichnung),
+ text = ('%s (%s) — Zustand zuletzt %d %%. Ob es in die Werkstatt muss, entscheidet die Wartung.')
+ :format(fahrzeug.bezeichnung, fahrzeug.kennzeichen, math.floor(zustand)),
+ zielTyp = 'fahrzeug', zielId = fahrzeug.id,
+ })
+ end
+ end
+
+ -- --- Aushänge mit Kenntnisnahmefrist ---
+ local fristen = MySQL.query.await([[
+ SELECT a.id, a.titel, a.station,
+ TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS rest
+ FROM medical_aushang a
+ WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 AND a.frist IS NOT NULL
+ AND a.frist > DATE_SUB(NOW(), INTERVAL 1 DAY)
+ ]]) or {}
+
+ for _, aushang in ipairs(fristen) do
+ local rest = tonumber(aushang.rest) or 0
+ local stufe = rest <= 0 and 'abgelaufen'
+ or (rest <= Medizin.Config.Erinnerungen.aushangVorwarnung and 'bald')
+ or nil
+
+ if stufe and ErstmalsMelden('aushang_' .. aushang.id, stufe) then
+ -- Nur ansprechen, wer noch nicht bestätigt hat
+ for _, mitarbeiter in pairs(Dienst.AlleImDienst()) do
+ local passt = aushang.station == nil or aushang.station == mitarbeiter.station
+ if passt then
+ local schon = MySQL.scalar.await([[
+ SELECT 1 FROM medical_aushang_kenntnis
+ WHERE aushang = ? AND `character` = ?
+ ]], { aushang.id, mitarbeiter.charakterId })
+
+ if not schon then
+ Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, {
+ kategorie = 'system',
+ prioritaet = stufe == 'abgelaufen' and 'hoch' or 'normal',
+ titel = stufe == 'abgelaufen'
+ and 'Kenntnisnahme überfällig' or 'Kenntnisnahme offen',
+ text = ('„%s" — bitte zur Kenntnis nehmen.'):format(aushang.titel),
+ zielTyp = 'aushang', zielId = aushang.id,
+ })
+ end
+ end
+ end
+ end
+ end
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Aufräumen abgeschlossener Vorgänge
+-- ---------------------------------------------------------------------
+Kern.Abonniere('aufnahme.entlassen', function(aufnahmeId)
+ gemeldet['warte_' .. tostring(aufnahmeId)] = nil
+end)
+
+Kern.Abonniere('aufnahme.aufgenommen', function(aufnahmeId)
+ gemeldet['warte_' .. tostring(aufnahmeId)] = nil
+end)
+
+Kern.Abonniere('operation.abgeschlossen', function(operationId)
+ gemeldet['op_' .. tostring(operationId)] = nil
+end)
+
+Kern.Abonniere('behandlung.abgeschlossen', function(behandlungId)
+ gemeldet['doku_' .. tostring(behandlungId)] = nil
+end)
+
+Kern.Abonniere('labor.befundet', function(laborId)
+ gemeldet['labor_' .. tostring(laborId)] = nil
+end)
diff --git a/resources/[aether]/aether-medical/server/kern.lua b/resources/[aether]/aether-medical/server/kern.lua
new file mode 100644
index 0000000..6cdb2c4
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/kern.lua
@@ -0,0 +1,241 @@
+-- =====================================================================
+-- aether-medical — Kern
+--
+-- Der Kern kennt keine Medizin. Er stellt bereit:
+-- * eine Modul-Registry (Plugin-System)
+-- * einen Ereignisbus zwischen den Modulen
+-- * ein Handler-Register, an das die Oberfläche Anfragen schickt
+--
+-- Fachmodule registrieren sich selbst und können jederzeit ergänzt
+-- werden, ohne dass der Kern angefasst werden muss.
+-- =====================================================================
+
+Medizin = Medizin or {}
+Kern = {}
+
+-- Registrierte Fachmodule: [id] = { id, name, icon, recht, reihenfolge, aktiv }
+local module = {}
+-- Handler, die die Oberfläche aufrufen darf: [modulId][aktion] = funktion
+local handler = {}
+-- Abonnenten des Ereignisbusses: [ereignis] = { funktion, ... }
+local abonnenten = {}
+
+-- ---------------------------------------------------------------------
+-- Modul-Registry
+-- ---------------------------------------------------------------------
+
+--- Registriert ein Fachmodul.
+--- @param beschreibung table {
+--- id eindeutige Kennung
+--- name Anzeigename in der Seitenleiste
+--- icon Symbol
+--- recht Recht zum Öffnen — Kennung, Liste oder nil (für alle)
+--- reihenfolge Sortierung in der Seitenleiste
+--- aktiv Standardmäßig aktiv?
+--- }
+--- @return boolean
+function Kern.RegistriereModul(beschreibung)
+ if type(beschreibung) ~= 'table' or not beschreibung.id then
+ Aether.Fehler('Modul ohne Kennung kann nicht registriert werden.')
+ return false
+ end
+
+ if module[beschreibung.id] then
+ Aether.Fehler(('Modul "%s" ist bereits registriert.'):format(beschreibung.id))
+ return false
+ end
+
+ module[beschreibung.id] = {
+ id = beschreibung.id,
+ name = beschreibung.name or beschreibung.id,
+ icon = beschreibung.icon or '▪',
+ recht = beschreibung.recht,
+ reihenfolge = beschreibung.reihenfolge or 100,
+ aktiv = beschreibung.aktiv ~= false,
+ beschreibung = beschreibung.beschreibung,
+ }
+
+ handler[beschreibung.id] = handler[beschreibung.id] or {}
+ Aether.Debug(('Medizin-Modul registriert: %s'):format(beschreibung.id))
+ return true
+end
+
+--- Registriert einen Handler, den die Oberfläche aufrufen kann.
+--- @param modulId string Modul
+--- @param aktion string Name der Aktion
+--- @param recht string|nil Erforderliches Recht (nil = nur Modulrecht)
+--- @param funktion function fun(quelle, mitarbeiter, daten): ergebnis
+function Kern.RegistriereAktion(modulId, aktion, recht, funktion)
+ handler[modulId] = handler[modulId] or {}
+ handler[modulId][aktion] = { recht = recht, funktion = funktion }
+end
+
+--- Prüft das Öffnungsrecht eines Moduls.
+--- `recht` darf eine Kennung oder eine Liste sein — bei einer Liste
+--- genügt EIN Recht daraus. Das brauchen Module, die mehrere Bereiche
+--- mit je eigenem Recht unter einem Dach zusammenfassen.
+--- @param mitarbeiter table Mitarbeiterdaten
+--- @param recht string|table|nil Erforderliches Recht
+--- @return boolean
+function Kern.DarfOeffnen(mitarbeiter, recht)
+ if not recht then return true end
+
+ if type(recht) == 'table' then
+ for _, einzeln in ipairs(recht) do
+ if Rechte.Hat(mitarbeiter, einzeln) then return true end
+ end
+ return false
+ end
+
+ return Rechte.Hat(mitarbeiter, recht)
+end
+
+--- Liefert alle Module, die ein Mitarbeiter sehen darf
+--- @param mitarbeiter table Mitarbeiterdaten (mit Rechten)
+--- @return table Liste
+function Kern.SichtbareModule(mitarbeiter)
+ local liste = {}
+ for _, modul in pairs(module) do
+ if modul.aktiv and Kern.DarfOeffnen(mitarbeiter, modul.recht) then
+ liste[#liste + 1] = {
+ id = modul.id, name = modul.name, icon = modul.icon,
+ reihenfolge = modul.reihenfolge, beschreibung = modul.beschreibung,
+ }
+ end
+ end
+ table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end)
+ return liste
+end
+
+--- Liefert alle registrierten Module, auch die abgeschalteten.
+--- Nur für die Administration gedacht.
+--- @return table Liste
+function Kern.AlleModule()
+ local liste = {}
+ for _, modul in pairs(module) do
+ -- Wie viele Aktionen hängen an diesem Modul?
+ local aktionen = 0
+ for _ in pairs(handler[modul.id] or {}) do aktionen = aktionen + 1 end
+
+ liste[#liste + 1] = {
+ id = modul.id, name = modul.name, icon = modul.icon,
+ recht = modul.recht, reihenfolge = modul.reihenfolge,
+ beschreibung = modul.beschreibung,
+ aktiv = modul.aktiv, aktionen = aktionen,
+ }
+ end
+ table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end)
+ return liste
+end
+
+--- Gibt es dieses Modul?
+--- @param modulId string Modul
+--- @return boolean
+function Kern.KenntModul(modulId)
+ return module[modulId] ~= nil
+end
+
+--- Schaltet ein Modul an oder aus (Administration)
+--- @param modulId string Modul
+--- @param aktiv boolean Neuer Zustand
+function Kern.SetzeModulAktiv(modulId, aktiv)
+ if module[modulId] then
+ module[modulId].aktiv = aktiv and true or false
+ end
+end
+
+--- Führt eine Aktion aus — inklusive Rechteprüfung.
+--- Wird von der Oberfläche über ein einziges Netz-Event aufgerufen.
+--- @param quelle number Server-ID
+--- @param mitarbeiter table Mitarbeiterdaten
+--- @param modulId string Modul
+--- @param aktion string Aktion
+--- @param daten table Nutzdaten
+--- @return any Ergebnis, string|nil Fehler
+function Kern.FuehreAus(quelle, mitarbeiter, modulId, aktion, daten)
+ local modul = module[modulId]
+ if not modul or not modul.aktiv then
+ return nil, 'Dieses Modul ist nicht verfügbar.'
+ end
+
+ -- Recht auf das Modul selbst
+ if not Kern.DarfOeffnen(mitarbeiter, modul.recht) then
+ return nil, 'Dir fehlt die Berechtigung für diesen Bereich.'
+ end
+
+ local eintrag = handler[modulId] and handler[modulId][aktion]
+ if not eintrag then
+ return nil, 'Unbekannte Aktion.'
+ end
+
+ -- Zusätzliches Recht auf die einzelne Aktion
+ if eintrag.recht and not Rechte.Hat(mitarbeiter, eintrag.recht) then
+ return nil, 'Dir fehlt die Berechtigung für diese Aktion.'
+ end
+
+ local erfolg, ergebnis, fehler = pcall(eintrag.funktion, quelle, mitarbeiter, daten or {})
+ if not erfolg then
+ Aether.Fehler(('Fehler in %s/%s: %s'):format(modulId, aktion, tostring(ergebnis)))
+ return nil, 'Interner Fehler im Modul.'
+ end
+
+ return ergebnis, fehler
+end
+
+-- ---------------------------------------------------------------------
+-- Ereignisbus zwischen den Modulen
+--
+-- Damit kann ein Modul auf Vorgänge eines anderen reagieren, ohne
+-- es zu kennen. Ausgelöst wird zusätzlich ein FiveM-Event, damit
+-- auch fremde Ressourcen mithören können.
+-- ---------------------------------------------------------------------
+
+--- Abonniert ein Ereignis
+--- @param ereignis string Name
+--- @param funktion function Rückruf
+function Kern.Abonniere(ereignis, funktion)
+ abonnenten[ereignis] = abonnenten[ereignis] or {}
+ abonnenten[ereignis][#abonnenten[ereignis] + 1] = funktion
+end
+
+--- Löst ein Ereignis aus
+--- @param ereignis string Name
+--- @param ... any Nutzdaten
+function Kern.Loese(ereignis, ...)
+ for _, funktion in ipairs(abonnenten[ereignis] or {}) do
+ local erfolg, fehler = pcall(funktion, ...)
+ if not erfolg then
+ Aether.Fehler(('Fehler im Abonnenten von "%s": %s'):format(ereignis, tostring(fehler)))
+ end
+ end
+
+ -- Auch nach aussen melden, damit fremde Ressourcen reagieren können
+ TriggerEvent('aether:medical:' .. ereignis, ...)
+end
+
+-- ---------------------------------------------------------------------
+-- Kennungen
+-- ---------------------------------------------------------------------
+
+--- Erzeugt eine fortlaufende Kennung im Format PRAEFIX-JAHR-NNNN
+--- @param praefix string z. B. 'BEH'
+--- @param tabelle string Tabelle
+--- @param spalte string Spalte mit der Kennung
+--- @return string
+function Kern.ErzeugeKennung(praefix, tabelle, spalte)
+ local jahr = os.date('%Y')
+ local muster = ('%s-%s-%%'):format(praefix, jahr)
+
+ local anzahl = MySQL.scalar.await(
+ ('SELECT COUNT(*) FROM %s WHERE %s LIKE ?'):format(tabelle, spalte), { muster }) or 0
+
+ -- Bei einer Kollision einfach weiterzählen
+ for versuch = 1, 50 do
+ local kandidat = ('%s-%s-%04d'):format(praefix, jahr, anzahl + versuch)
+ local belegt = MySQL.scalar.await(
+ ('SELECT 1 FROM %s WHERE %s = ?'):format(tabelle, spalte), { kandidat })
+ if not belegt then return kandidat end
+ end
+
+ return ('%s-%s-%s'):format(praefix, jahr, tostring(os.time()):sub(-5))
+end
diff --git a/resources/[aether]/aether-medical/server/main.lua b/resources/[aether]/aether-medical/server/main.lua
new file mode 100644
index 0000000..f74fe4c
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/main.lua
@@ -0,0 +1,241 @@
+-- =====================================================================
+-- aether-medical — Server-Schnittstelle
+--
+-- Ein einziges Netz-Event bedient alle Module. Jede Anfrage durchläuft
+-- Dienstprüfung und Rechteprüfung, bevor sie ein Modul erreicht.
+-- =====================================================================
+
+local Core = exports['aether-core']
+
+--- Baut das Datenpaket für die Oberfläche
+--- @param quelle number Server-ID
+--- @param mitarbeiter table Mitarbeiter im Dienst
+--- @return table
+local function BaueTabletDaten(quelle, mitarbeiter)
+ return {
+ klinik = Medizin.Config.Klinik,
+ kuerzel = Medizin.Config.Kuerzel,
+ module = Kern.SichtbareModule(mitarbeiter),
+ mitarbeiter = {
+ name = mitarbeiter.name,
+ personalnummer = mitarbeiter.personalnummer,
+ rolle = mitarbeiter.rolle,
+ rolleName = mitarbeiter.rolleName,
+ station = mitarbeiter.station,
+ funktion = mitarbeiter.funktion,
+ status = mitarbeiter.status,
+ leitstelle = Dienst.Leitstelle() == quelle,
+ },
+ status = Medizin.Config.Dienststatus,
+ funktionen = Medizin.Config.Dienstfunktionen,
+ stationen = Medizin.Config.Stationen,
+ }
+end
+
+-- ---------------------------------------------------------------------
+-- Oberfläche öffnen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:medical:oeffnen', function()
+ local quelle = source
+
+ local mitarbeiter = Dienst.Hole(quelle)
+ if not mitarbeiter then
+ -- Noch nicht im Dienst: Personalakte prüfen und Anmeldung anbieten
+ local charakterId = Core:HoleCharakterId(quelle)
+ if not charakterId then return end
+
+ local geprueft, fehler = Dienst.LadeMitarbeiter(quelle, charakterId)
+ if not geprueft then
+ TriggerClientEvent('aether:medical:fehler', quelle, fehler)
+ return
+ end
+
+ -- Nur Funktionen anbieten, für die auch das Recht vorliegt
+ local waehlbar = {}
+ for _, eintrag in ipairs(Medizin.Config.Dienstfunktionen) do
+ if not eintrag.recht or Rechte.Hat(geprueft, eintrag.recht) then
+ waehlbar[#waehlbar + 1] = { id = eintrag.id, name = eintrag.name }
+ end
+ end
+
+ TriggerClientEvent('aether:medical:anmeldung', quelle, {
+ name = geprueft.name,
+ personalnummer = geprueft.personalnummer,
+ rolleName = geprueft.rolleName,
+ station = geprueft.station,
+ klinik = Medizin.Config.Klinik,
+ funktionen = waehlbar,
+ })
+ return
+ end
+
+ TriggerClientEvent('aether:medical:zeige', quelle, BaueTabletDaten(quelle, mitarbeiter))
+end)
+
+-- ---------------------------------------------------------------------
+-- Dienst
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:medical:dienstAntreten', function(funktion)
+ local quelle = source
+ local mitarbeiter, fehler = Dienst.Antreten(quelle,
+ type(funktion) == 'string' and funktion or nil)
+
+ if not mitarbeiter then
+ TriggerClientEvent('aether:medical:fehler', quelle, fehler)
+ return
+ end
+
+ TriggerClientEvent('aether:medical:zeige', quelle, BaueTabletDaten(quelle, mitarbeiter))
+end)
+
+RegisterNetEvent('aether:medical:dienstBeenden', function()
+ local quelle = source
+ Dienst.Beenden(quelle)
+ TriggerClientEvent('aether:medical:schliessen', quelle)
+end)
+
+RegisterNetEvent('aether:medical:status', function(status)
+ local quelle = source
+ if type(status) ~= 'string' then return end
+ Dienst.SetzeStatus(quelle, status)
+end)
+
+RegisterNetEvent('aether:medical:funktion', function(funktion)
+ local quelle = source
+ if type(funktion) ~= 'string' then return end
+
+ local erfolg, fehler = Dienst.SetzeFunktion(quelle, funktion)
+ if not erfolg and fehler then
+ TriggerClientEvent('aether:medical:fehler', quelle, fehler)
+ end
+end)
+
+RegisterNetEvent('aether:medical:leitstelle', function(zielQuelle)
+ local quelle = source
+ local erfolg, fehler = Dienst.SetzeLeitstelle(quelle,
+ type(zielQuelle) == 'number' and zielQuelle or nil)
+ if not erfolg and fehler then
+ TriggerClientEvent('aether:medical:fehler', quelle, fehler)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Zentraler Modul-Aufruf
+--
+-- Die Oberfläche schickt { modul, aktion, daten, ruf }. Der `ruf` ist
+-- eine laufende Nummer, damit die Antwort zugeordnet werden kann.
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:medical:aufruf', function(modulId, aktion, daten, ruf)
+ local quelle = source
+
+ if type(modulId) ~= 'string' or type(aktion) ~= 'string' then return end
+
+ local mitarbeiter = Dienst.Hole(quelle)
+ if not mitarbeiter then
+ TriggerClientEvent('aether:medical:antwort', quelle, ruf, nil,
+ 'Du bist nicht im Dienst.')
+ return
+ end
+
+ local ergebnis, fehler = Kern.FuehreAus(quelle, mitarbeiter, modulId, aktion, daten)
+ TriggerClientEvent('aether:medical:antwort', quelle, ruf, ergebnis, fehler)
+end)
+
+-- ---------------------------------------------------------------------
+-- Eingehende Notrufe
+--
+-- Sie erzeugen einen Einsatz mit Status „offen". Zugewiesen wird
+-- ausschliesslich von der Leitstelle — nie automatisch.
+--
+-- `EinsatzEingang` stellt das Einsatzmodul bereit. Solange es nicht
+-- geladen ist, wird ein Notruf schlicht nicht angenommen, statt
+-- irgendwo ins Leere zu laufen.
+-- ---------------------------------------------------------------------
+
+--- Nimmt einen Notruf entgegen, sofern das Einsatzmodul geladen ist
+--- @param daten table Einsatzdaten
+--- @return number|nil Einsatz-ID
+local function NotrufAnnehmen(daten)
+ if type(EinsatzEingang) ~= 'function' then
+ Aether.Fehler('Notruf verworfen: Das Einsatzmodul ist nicht geladen.')
+ return nil
+ end
+ return EinsatzEingang(daten)
+end
+
+--- Notruf aus dem Handy (112)
+AddEventHandler('aether:phone:notruf', function(quelle, fraktionId, nummer)
+ if fraktionId ~= 'lsmc' and fraktionId ~= 'medic' and fraktionId ~= 'ems' then return end
+
+ local position = GetEntityCoords(GetPlayerPed(quelle))
+ NotrufAnnehmen({
+ titel = 'Notruf über Telefon',
+ kategorie = 'notruf',
+ prioritaet = 2,
+ ort = 'Position des Anrufers',
+ beschreibung = ('Eingehender Anruf von %s.'):format(nummer or 'unbekannt'),
+ position = { x = position.x, y = position.y, z = position.z },
+ quelle = 'notruf',
+ })
+end)
+
+-- =====================================================================
+-- EXPORTS — Schnittstelle für externe Systeme
+--
+-- Damit lassen sich Notrufe und Hinweise von aussen einspeisen, ohne
+-- dass am Kern etwas geändert werden muss. Keiner dieser Exports
+-- behandelt, diagnostiziert oder entlässt jemanden.
+-- =====================================================================
+
+--- Meldet einen Notfall an die Klinik.
+--- Er erscheint als offener Einsatz — zugewiesen wird er nie automatisch.
+exports('MeldeNotfall', function(daten)
+ if type(daten) ~= 'table' or not daten.titel then return nil end
+ return NotrufAnnehmen(daten)
+end)
+
+--- Erstellt einen Hinweis für alle im Dienst
+exports('MeldeHinweis', function(daten)
+ if type(daten) ~= 'table' or not daten.titel then return false end
+ Hinweise.Erstelle(nil, daten)
+ return true
+end)
+
+--- Ist ein Spieler als Mediziner im Dienst?
+exports('IstImDienst', function(quelle)
+ return Dienst.Hole(quelle) ~= nil
+end)
+
+--- Wie viele Mediziner sind gerade im Dienst?
+exports('AnzahlImDienst', function()
+ local anzahl = 0
+ for _ in pairs(Dienst.AlleImDienst()) do anzahl = anzahl + 1 end
+ return anzahl
+end)
+
+--- Liefert die Dienstdaten eines Mitarbeiters (ohne Rechtetabelle)
+exports('HoleMitarbeiter', function(quelle)
+ local mitarbeiter = Dienst.Hole(quelle)
+ if not mitarbeiter then return nil end
+ return {
+ charakterId = mitarbeiter.charakterId,
+ name = mitarbeiter.name,
+ personalnummer = mitarbeiter.personalnummer,
+ rolle = mitarbeiter.rolle,
+ rolleName = mitarbeiter.rolleName,
+ station = mitarbeiter.station,
+ funktion = mitarbeiter.funktion,
+ status = mitarbeiter.status,
+ }
+end)
+
+--- Erlaubt fremden Ressourcen, ein eigenes Modul zu registrieren
+exports('RegistriereModul', function(beschreibung)
+ return Kern.RegistriereModul(beschreibung)
+end)
+
+--- Erlaubt fremden Ressourcen, eine Aktion beizusteuern
+exports('RegistriereAktion', function(modulId, aktion, recht, funktion)
+ Kern.RegistriereAktion(modulId, aktion, recht, funktion)
+ return true
+end)
diff --git a/resources/[aether]/aether-medical/server/module/behandlung.lua b/resources/[aether]/aether-medical/server/module/behandlung.lua
new file mode 100644
index 0000000..4f18103
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/behandlung.lua
@@ -0,0 +1,673 @@
+-- =====================================================================
+-- aether-medical / Modul: Behandlungsverwaltung
+--
+-- Das System speichert AUSSCHLIESSLICH die Dokumentation.
+--
+-- Es heilt nicht, es diagnostiziert nicht, es schlägt keine Diagnose
+-- vor und es leitet aus Symptomen nichts ab. Jedes Feld ist ein
+-- Freitext, den ein Mediziner schreibt.
+--
+-- Auch die Medikamentengabe wird hier nur DOKUMENTIERT. Das System
+-- verabreicht nichts und verordnet nichts — es bucht den Bestand
+-- nach, weil ein Mensch etwas entnommen hat.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'behandlung', name = 'Behandlungen', icon = '📋',
+ reihenfolge = 5, recht = 'behandlung.dokumentieren',
+ beschreibung = 'Untersuchung, Diagnose, Maßnahmen und Medikamentengaben',
+})
+
+-- ---------------------------------------------------------------------
+-- Grundbestand an Medikamenten
+--
+-- Wird einmalig angelegt, damit sich eine Gabe überhaupt dokumentieren
+-- lässt. Die Pflege übernimmt später die Medikamentenverwaltung.
+-- Die Standarddosierung ist ein VORSCHLAG und nichts weiter.
+-- ---------------------------------------------------------------------
+local GRUNDMEDIKAMENTE = {
+ -- kennung, name, wirkstoff, kategorie, dosierung, einheit, bestand, mindest, btm
+ { 'ANA-01', 'Novalgin', 'Metamizol', 'analgetika', '1 g i.v.', 'Ampulle', 40, 10, 0 },
+ { 'ANA-02', 'Morphin 10 mg', 'Morphin', 'analgetika', '2–10 mg i.v.', 'Ampulle', 12, 4, 1 },
+ { 'ANA-03', 'Ibuprofen 600', 'Ibuprofen', 'analgetika', '600 mg p.o.', 'Tablette', 90, 20, 0 },
+ { 'ANT-01', 'Amoxicillin', 'Amoxicillin', 'antibiotika', '1 g p.o.', 'Tablette', 50, 15, 0 },
+ { 'ANT-02', 'Cefuroxim', 'Cefuroxim', 'antibiotika', '1,5 g i.v.', 'Ampulle', 20, 6, 0 },
+ { 'KRE-01', 'Adrenalin 1 mg', 'Epinephrin', 'kreislauf', '1 mg i.v.', 'Ampulle', 25, 8, 0 },
+ { 'KRE-02', 'Amiodaron', 'Amiodaron', 'kreislauf', '300 mg i.v.', 'Ampulle', 10, 3, 0 },
+ { 'KRE-03', 'Acetylsalicylsäure', 'ASS', 'kreislauf', '250 mg i.v.', 'Ampulle', 18, 6, 0 },
+ { 'NAR-01', 'Propofol', 'Propofol', 'narkose', 'nach Wirkung', 'Ampulle', 15, 5, 1 },
+ { 'NAR-02', 'Midazolam', 'Midazolam', 'narkose', '2–5 mg i.v.', 'Ampulle', 14, 5, 1 },
+ { 'NAR-03', 'Ketamin', 'Esketamin', 'narkose', 'nach Wirkung', 'Ampulle', 10, 4, 1 },
+ { 'INF-01', 'Ringer-Lactat', '—', 'infusion', '500–1000 ml', 'Beutel', 60, 20, 0 },
+ { 'INF-02', 'NaCl 0,9 %', 'Kochsalz', 'infusion', '500 ml', 'Beutel', 80, 25, 0 },
+ { 'VER-01', 'Kompressen steril', '—', 'verband', '—', 'Packung', 120, 30, 0 },
+ { 'VER-02', 'Verbandpäckchen', '—', 'verband', '—', 'Stück', 100, 25, 0 },
+ { 'VER-03', 'Druckverband', '—', 'verband', '—', 'Stück', 40, 12, 0 },
+ { 'SON-01', 'Sauerstoff', 'O₂', 'sonstiges', '2–15 l/min', 'Flasche', 16, 5, 0 },
+}
+
+CreateThread(function()
+ Wait(2000)
+
+ local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_medikament')) or 0
+ if vorhanden > 0 then return end
+
+ for reihenfolge, eintrag in ipairs(GRUNDMEDIKAMENTE) do
+ MySQL.insert.await([[
+ INSERT INTO medical_medikament
+ (kennung, name, wirkstoff, kategorie, dosierung_standard, einheit,
+ bestand, mindestbestand, betaeubungsmittel, lagerort, sortierung)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ eintrag[1], eintrag[2], eintrag[3], eintrag[4], eintrag[5], eintrag[6],
+ eintrag[7], eintrag[8], eintrag[9],
+ eintrag[9] == 1 and 'BTM-Schrank' or 'Apotheke',
+ reihenfolge,
+ })
+ end
+
+ Aether.Debug('Grundbestand an Medikamenten angelegt.')
+end)
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.personalnummer
+ FROM aether_characters c
+ LEFT JOIN medical_personal p ON p.`character` = c.id
+ WHERE c.id = ?
+ ]], { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('behandlung', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'laufend')
+ local bedingungen, werte = {}, {}
+
+ if ordner == 'meine' then
+ bedingungen[#bedingungen + 1] = 'b.erstellt_von = ?'
+ werte[#werte + 1] = mitarbeiter.charakterId
+ elseif ordner == 'abgeschlossen' then
+ bedingungen[#bedingungen + 1] = "b.status = 'abgeschlossen'"
+ else
+ bedingungen[#bedingungen + 1] = "b.status = 'laufend'"
+ end
+
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(b.kennung LIKE ? OR b.diagnose LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ for _ = 1, 4 do werte[#werte + 1] = muster end
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT b.id, b.kennung, b.patient, b.aufnahme, b.einsatz,
+ b.diagnose, b.massnahmen, b.status,
+ b.erstellt_am, b.erstellt_von, b.abgeschlossen_am,
+ TIMESTAMPDIFF(MINUTE, b.erstellt_am, NOW()) AS offenSeit,
+ c.firstname, c.lastname, c.dob,
+ m.allergien, m.hinweis,
+ e.nummer AS einsatzNummer,
+ (SELECT COUNT(*) FROM medical_behandlung_medikament g
+ WHERE g.behandlung = b.id) AS gaben
+ FROM medical_behandlung b
+ JOIN aether_characters c ON c.id = b.patient
+ LEFT JOIN medical_patient m ON m.`character` = b.patient
+ LEFT JOIN medical_einsatz e ON e.id = b.einsatz
+ WHERE %s
+ ORDER BY b.id DESC
+ LIMIT 120
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
+ eintrag.autor = NameVon(eintrag.erstellt_von)
+ eintrag.eigene = eintrag.erstellt_von == mitarbeiter.charakterId
+ eintrag.gaben = tonumber(eintrag.gaben) or 0
+ eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
+ eintrag.erstellt_von = nil
+ end
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ zaehler = {
+ laufend = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_behandlung WHERE status = 'laufend'")) or 0,
+ meine = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_behandlung
+ WHERE status = 'laufend' AND erstellt_von = ?
+ ]], { mitarbeiter.charakterId })) or 0,
+ heute = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_behandlung
+ WHERE status = 'abgeschlossen' AND DATE(abgeschlossen_am) = CURDATE()
+ ]])) or 0,
+ },
+ rechte = {
+ dokumentieren = Rechte.Hat(mitarbeiter, 'behandlung.dokumentieren'),
+ diagnose = Rechte.Hat(mitarbeiter, 'behandlung.diagnose'),
+ abschliessen = Rechte.Hat(mitarbeiter, 'behandlung.abschliessen'),
+ medikament = Rechte.Hat(mitarbeiter, 'medikament.ausgeben'),
+ btm = Rechte.Hat(mitarbeiter, 'medikament.btm'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('behandlung', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local behandlungId = tonumber(daten.id)
+ if not behandlungId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT b.*, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.vorerkrankungen, m.dauermedikation,
+ m.hinweis, m.lichtbild,
+ e.nummer AS einsatzNummer, e.titel AS einsatzTitel,
+ a.grund AS aufnahmeGrund, a.status AS aufnahmeStatus
+ FROM medical_behandlung b
+ JOIN aether_characters c ON c.id = b.patient
+ LEFT JOIN medical_patient m ON m.`character` = b.patient
+ LEFT JOIN medical_einsatz e ON e.id = b.einsatz
+ LEFT JOIN medical_aufnahme a ON a.id = b.aufnahme
+ WHERE b.id = ?
+ ]], { behandlungId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end
+ local b = zeilen[1]
+
+ -- Dokumentierte Medikamentengaben
+ local gaben = MySQL.query.await([[
+ SELECT g.id, g.dosierung, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis,
+ m.id AS medikamentId, m.name, m.wirkstoff, m.einheit, m.betaeubungsmittel
+ FROM medical_behandlung_medikament g
+ JOIN medical_medikament m ON m.id = g.medikament
+ WHERE g.behandlung = ?
+ ORDER BY g.id DESC
+ ]], { behandlungId }) or {}
+
+ for _, gabe in ipairs(gaben) do
+ gabe.autor = NameVon(gabe.gegeben_von)
+ gabe.eigene = gabe.gegeben_von == mitarbeiter.charakterId
+ gabe.btm = gabe.betaeubungsmittel == 1
+ gabe.betaeubungsmittel, gabe.gegeben_von = nil, nil
+ end
+
+ -- Operationen zu diesem Patienten, damit die Doku vollständig wird
+ local operationen = MySQL.query.await([[
+ SELECT id, kennung, art, status, beginn, ende
+ FROM medical_operation
+ WHERE patient = ? ORDER BY id DESC LIMIT 10
+ ]], { b.patient }) or {}
+
+ -- Laborergebnisse zu diesem Patienten
+ local labor = MySQL.query.await([[
+ SELECT id, kennung, art, status, ergebnis, befund, auftrag_am
+ FROM medical_labor
+ WHERE patient = ? ORDER BY id DESC LIMIT 10
+ ]], { b.patient }) or {}
+
+ return {
+ behandlung = {
+ id = b.id, kennung = b.kennung, patient = b.patient,
+ patientName = b.firstname .. ' ' .. b.lastname,
+ geburt = tostring(b.dob):sub(1, 10),
+ aufnahme = b.aufnahme, aufnahmeGrund = b.aufnahmeGrund,
+ aufnahmeStatus = b.aufnahmeStatus,
+ einsatz = b.einsatz, einsatzNummer = b.einsatzNummer,
+ einsatzTitel = b.einsatzTitel,
+ symptome = b.symptome, untersuchung = b.untersuchung,
+ diagnose = b.diagnose, massnahmen = b.massnahmen,
+ empfehlung = b.empfehlung, nachsorge = b.nachsorge,
+ status = b.status,
+ erstelltAm = b.erstellt_am,
+ erstelltVon = NameVon(b.erstellt_von),
+ istMeine = b.erstellt_von == mitarbeiter.charakterId,
+ abgeschlossenAm = b.abgeschlossen_am,
+ abgeschlossenVon = b.abgeschlossen_von
+ and NameVon(b.abgeschlossen_von) or nil,
+ },
+ stamm = {
+ blutgruppe = b.blutgruppe, allergien = b.allergien,
+ vorerkrankungen = b.vorerkrankungen, dauermedikation = b.dauermedikation,
+ hinweis = b.hinweis, lichtbild = b.lichtbild,
+ },
+ gaben = gaben,
+ operationen = operationen,
+ labor = labor,
+ rechte = {
+ dokumentieren = Rechte.Hat(mitarbeiter, 'behandlung.dokumentieren'),
+ diagnose = Rechte.Hat(mitarbeiter, 'behandlung.diagnose'),
+ abschliessen = Rechte.Hat(mitarbeiter, 'behandlung.abschliessen'),
+ medikament = Rechte.Hat(mitarbeiter, 'medikament.ausgeben'),
+ btm = Rechte.Hat(mitarbeiter, 'medikament.btm'),
+ bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- ANLEGEN
+-- =====================================================================
+
+--- Patientensuche mit laufender Aufnahme, damit die Doku andocken kann
+Kern.RegistriereAktion('behandlung', 'patientSuche', 'behandlung.dokumentieren', function(quelle, mitarbeiter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.hinweis,
+ (SELECT a.id FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen')
+ ORDER BY a.id DESC LIMIT 1) AS aufnahme,
+ (SELECT a.grund FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen')
+ ORDER BY a.id DESC LIMIT 1) AS aufnahmeGrund
+ FROM aether_characters c
+ LEFT JOIN medical_patient m ON m.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 20
+ ]], { muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ end
+
+ -- Laufende Einsätze, an denen der Mitarbeiter beteiligt ist
+ local einsaetze = MySQL.query.await([[
+ SELECT e.id, e.nummer, e.titel
+ FROM medical_einsatz e
+ JOIN medical_einsatz_kraft k ON k.einsatz = e.id AND k.bis IS NULL
+ WHERE k.`character` = ? AND e.status <> 'abgeschlossen'
+ ORDER BY e.id DESC LIMIT 10
+ ]], { mitarbeiter.charakterId }) or {}
+
+ return { treffer = treffer, einsaetze = einsaetze }
+end)
+
+Kern.RegistriereAktion('behandlung', 'anlegen', 'behandlung.dokumentieren', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Kein Patient gewählt.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
+ { patientId })
+ if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
+
+ -- Aufnahme und Einsatz sind freiwillig, müssen aber existieren
+ local aufnahmeId = tonumber(daten.aufnahme)
+ if aufnahmeId then
+ local gueltig = MySQL.scalar.await(
+ 'SELECT id FROM medical_aufnahme WHERE id = ? AND patient = ?',
+ { aufnahmeId, patientId })
+ if not gueltig then return nil, 'Diese Aufnahme gehört nicht zu dem Patienten.' end
+ end
+
+ local einsatzId = tonumber(daten.einsatz)
+ if einsatzId then
+ local gueltig = MySQL.scalar.await(
+ 'SELECT id FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not gueltig then return nil, 'Unbekannter Einsatz.' end
+ end
+
+ local kennung = Kern.ErzeugeKennung('BEH', 'medical_behandlung', 'kennung')
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_behandlung
+ (kennung, patient, aufnahme, einsatz, symptome, untersuchung, status, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, 'laufend', ?)
+ ]], {
+ kennung, patientId, aufnahmeId, einsatzId,
+ Text(daten.symptome, 4000),
+ Text(daten.untersuchung, 4000),
+ mitarbeiter.charakterId,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Behandlung angelegt',
+ { typ = 'behandlung', id = id },
+ { kennung = kennung, patient = patientId, aufnahme = aufnahmeId, einsatz = einsatzId })
+
+ Kern.Loese('behandlung.angelegt', id, mitarbeiter)
+
+ return { id = id, kennung = kennung }
+end)
+
+-- =====================================================================
+-- DOKUMENTIEREN
+--
+-- Die Diagnose ist ein eigenes Recht. Wer sie nicht hat, kann alles
+-- andere schreiben — das Feld bleibt dann unverändert.
+-- =====================================================================
+
+Kern.RegistriereAktion('behandlung', 'speichern', 'behandlung.dokumentieren', function(quelle, mitarbeiter, daten)
+ local behandlungId = tonumber(daten.id)
+ if not behandlungId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status, diagnose FROM medical_behandlung WHERE id = ?',
+ { behandlungId })
+ if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end
+
+ local behandlung = zeilen[1]
+ if behandlung.status == 'abgeschlossen' then
+ return nil, 'Eine abgeschlossene Behandlung lässt sich nicht mehr ändern.'
+ end
+
+ -- Diagnose nur mit dem passenden Recht
+ local darfDiagnose = Rechte.Hat(mitarbeiter, 'behandlung.diagnose')
+ local neueDiagnose = darfDiagnose and Text(daten.diagnose, 4000) or behandlung.diagnose
+
+ if not darfDiagnose and Text(daten.diagnose, 4000) ~= nil
+ and Text(daten.diagnose, 4000) ~= behandlung.diagnose then
+ return nil, 'Eine Diagnose darfst du nicht eintragen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_behandlung
+ SET symptome = ?, untersuchung = ?, diagnose = ?,
+ massnahmen = ?, empfehlung = ?, nachsorge = ?
+ WHERE id = ?
+ ]], {
+ Text(daten.symptome, 4000),
+ Text(daten.untersuchung, 4000),
+ neueDiagnose,
+ Text(daten.massnahmen, 4000),
+ Text(daten.empfehlung, 4000),
+ Text(daten.nachsorge, 4000),
+ behandlungId,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Dokumentation gespeichert',
+ { typ = 'behandlung', id = behandlungId },
+ { kennung = behandlung.kennung,
+ diagnoseGeaendert = neueDiagnose ~= behandlung.diagnose })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('behandlung', 'abschliessen', 'behandlung.abschliessen', function(quelle, mitarbeiter, daten)
+ local behandlungId = tonumber(daten.id)
+ if not behandlungId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT id, kennung, status, diagnose, massnahmen, untersuchung
+ FROM medical_behandlung WHERE id = ?
+ ]], { behandlungId })
+ if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end
+
+ local behandlung = zeilen[1]
+ if behandlung.status == 'abgeschlossen' then
+ return nil, 'Diese Behandlung ist bereits abgeschlossen.'
+ end
+
+ -- Eine leere Dokumentation abzuschliessen ergibt keinen Sinn.
+ -- Geprüft wird nur, DASS etwas dasteht — nicht, was.
+ local leer = not behandlung.untersuchung and not behandlung.massnahmen
+ and not behandlung.diagnose
+ if leer then
+ return nil, 'Die Dokumentation ist leer. Bitte zuerst etwas eintragen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_behandlung
+ SET status = 'abgeschlossen', abgeschlossen_von = ?, abgeschlossen_am = NOW()
+ WHERE id = ?
+ ]], { mitarbeiter.charakterId, behandlungId })
+
+ Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Behandlung abgeschlossen',
+ { typ = 'behandlung', id = behandlungId },
+ { kennung = behandlung.kennung, ohneDiagnose = behandlung.diagnose == nil },
+ Text(daten.vermerk, 255))
+
+ Kern.Loese('behandlung.abgeschlossen', behandlungId, mitarbeiter)
+
+ return { ok = true, ohneDiagnose = behandlung.diagnose == nil }
+end)
+
+--- Wieder öffnen, falls etwas nachzutragen ist — immer mit Begründung
+Kern.RegistriereAktion('behandlung', 'wiedereroeffnen', 'behandlung.abschliessen', function(quelle, mitarbeiter, daten)
+ local behandlungId = tonumber(daten.id)
+ if not behandlungId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte begründen, warum die Doku wieder geöffnet wird.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status FROM medical_behandlung WHERE id = ?', { behandlungId })
+ if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end
+ if zeilen[1].status ~= 'abgeschlossen' then
+ return nil, 'Diese Behandlung läuft bereits.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_behandlung
+ SET status = 'laufend', abgeschlossen_von = NULL, abgeschlossen_am = NULL
+ WHERE id = ?
+ ]], { behandlungId })
+
+ Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Behandlung wieder geöffnet',
+ { typ = 'behandlung', id = behandlungId },
+ { kennung = zeilen[1].kennung }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- MEDIKAMENTENGABE
+--
+-- Dokumentation einer Gabe, die ein Mediziner vorgenommen hat.
+-- Das System gibt nichts aus und verordnet nichts.
+-- =====================================================================
+
+--- Auswahlliste der Medikamente
+Kern.RegistriereAktion('behandlung', 'medikamente', 'medikament.ausgeben', function(quelle, mitarbeiter, daten)
+ local medikamente = MySQL.query.await([[
+ SELECT id, kennung, name, wirkstoff, kategorie, dosierung_standard,
+ einheit, bestand, mindestbestand, betaeubungsmittel, hinweis, lagerort,
+ verfaellt_am, DATEDIFF(verfaellt_am, CURDATE()) AS tageBisVerfall
+ FROM medical_medikament
+ WHERE aktiv = 1
+ ORDER BY kategorie, sortierung, name
+ ]]) or {}
+
+ local darfBtm = Rechte.Hat(mitarbeiter, 'medikament.btm')
+
+ for _, eintrag in ipairs(medikamente) do
+ eintrag.btm = eintrag.betaeubungsmittel == 1
+ -- Betäubungsmittel bleiben sichtbar, sind aber ohne Recht nicht wählbar
+ eintrag.waehlbar = not eintrag.btm or darfBtm
+ -- Abgelaufene Praeparate werden markiert, aber nicht gesperrt:
+ -- ob sie verwendbar sind, beurteilt ein Mensch.
+ local tage = tonumber(eintrag.tageBisVerfall)
+ eintrag.abgelaufen = tage ~= nil and tage < 0
+ eintrag.betaeubungsmittel = nil
+ end
+
+ return {
+ medikamente = medikamente,
+ kategorien = Medizin.Config.Medikamentkategorien,
+ darfBtm = darfBtm,
+ }
+end)
+
+Kern.RegistriereAktion('behandlung', 'medikamentGeben', 'medikament.ausgeben', function(quelle, mitarbeiter, daten)
+ local behandlungId = tonumber(daten.id)
+ local medikamentId = tonumber(daten.medikament)
+ local menge = math.floor(tonumber(daten.menge) or 0)
+
+ if not behandlungId or not medikamentId then return nil, 'Unvollständige Angaben.' end
+ if menge < 1 or menge > 999 then return nil, 'Ungültige Menge.' end
+
+ local behandlung = MySQL.query.await(
+ 'SELECT id, kennung, patient, status FROM medical_behandlung WHERE id = ?',
+ { behandlungId })
+ if not behandlung or not behandlung[1] then return nil, 'Behandlung nicht gefunden.' end
+ if behandlung[1].status == 'abgeschlossen' then
+ return nil, 'Die Behandlung ist abgeschlossen. Bitte zuerst wieder öffnen.'
+ end
+
+ local zeilen = MySQL.query.await([[
+ SELECT id, kennung, name, einheit, bestand, mindestbestand, betaeubungsmittel
+ FROM medical_medikament WHERE id = ? AND aktiv = 1
+ ]], { medikamentId })
+ if not zeilen or not zeilen[1] then return nil, 'Unbekanntes Medikament.' end
+
+ local medikament = zeilen[1]
+
+ -- Betäubungsmittel erfordern ein eigenes Recht
+ if medikament.betaeubungsmittel == 1 and not Rechte.Hat(mitarbeiter, 'medikament.btm') then
+ return nil, 'Betäubungsmittel darfst du nicht dokumentieren.'
+ end
+
+ -- Reicht der Bestand? Falls nicht, wird die Gabe trotzdem dokumentiert,
+ -- aber nur auf ausdrücklichen Wunsch und ohne Bestandsbuchung.
+ -- Eine Dokumentation darf nie an einer Lagerzahl scheitern.
+ local ohneBestand = daten.ohneBestand == true
+ if medikament.bestand < menge and not ohneBestand then
+ return nil, ('Im Bestand sind nur %d %s. Du kannst die Gabe trotzdem dokumentieren — dann ohne Bestandsbuchung.')
+ :format(medikament.bestand, medikament.einheit)
+ end
+
+ local gabeId = MySQL.insert.await([[
+ INSERT INTO medical_behandlung_medikament
+ (behandlung, medikament, dosierung, menge, gegeben_von, hinweis)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ]], {
+ behandlungId, medikamentId,
+ Text(daten.dosierung, 64), menge, mitarbeiter.charakterId,
+ ohneBestand
+ and ((Text(daten.hinweis, 200) or '') .. ' [ohne Bestandsbuchung]')
+ or Text(daten.hinweis, 255),
+ })
+
+ -- Bestand nachführen, weil jemand etwas entnommen hat
+ if not ohneBestand then
+ local neuerBestand = medikament.bestand - menge
+
+ MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?',
+ { neuerBestand, medikamentId })
+
+ MySQL.insert.await([[
+ INSERT INTO medical_medikament_buchung
+ (medikament, art, menge, bestand_danach, patient, grund, gebucht_von)
+ VALUES (?, 'ausgabe', ?, ?, ?, ?, ?)
+ ]], {
+ medikamentId, menge, neuerBestand, behandlung[1].patient,
+ 'Gabe zu ' .. behandlung[1].kennung, mitarbeiter.charakterId,
+ })
+
+ -- Mindestbestand erreicht? Erinnern, nicht nachbestellen.
+ if neuerBestand <= medikament.mindestbestand then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'medikament',
+ prioritaet = neuerBestand <= 0 and 'hoch' or 'normal',
+ titel = neuerBestand <= 0 and 'Medikament nicht mehr vorrätig'
+ or 'Medikament fast leer',
+ text = ('%s — noch %d %s.')
+ :format(medikament.name, neuerBestand, medikament.einheit),
+ zielTyp = 'medikament', zielId = medikamentId,
+ })
+ end
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Medikamentengabe dokumentiert',
+ { typ = 'behandlung', id = behandlungId },
+ { medikament = medikament.kennung, menge = menge,
+ btm = medikament.betaeubungsmittel == 1, ohneBestand = ohneBestand })
+
+ Kern.Loese('medikament.gegeben', gabeId, medikamentId, mitarbeiter)
+
+ return { id = gabeId }
+end)
+
+--- Fehleintrag zurücknehmen. Der Bestand wird als Korrektur zurückgebucht.
+Kern.RegistriereAktion('behandlung', 'medikamentZuruecknehmen', 'medikament.ausgeben', function(quelle, mitarbeiter, daten)
+ local gabeId = tonumber(daten.gabe)
+ if not gabeId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte begründen, warum der Eintrag zurückgenommen wird.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT g.id, g.behandlung, g.medikament, g.menge, g.gegeben_von, g.hinweis,
+ m.name, m.bestand, m.einheit, b.status
+ FROM medical_behandlung_medikament g
+ JOIN medical_medikament m ON m.id = g.medikament
+ JOIN medical_behandlung b ON b.id = g.behandlung
+ WHERE g.id = ?
+ ]], { gabeId })
+ if not zeilen or not zeilen[1] then return nil, 'Eintrag nicht gefunden.' end
+
+ local gabe = zeilen[1]
+ if gabe.status == 'abgeschlossen' then
+ return nil, 'Die Behandlung ist abgeschlossen. Bitte zuerst wieder öffnen.'
+ end
+
+ -- Nur der eigene Eintrag, ausser man verwaltet den Bestand
+ if gabe.gegeben_von ~= mitarbeiter.charakterId
+ and not Rechte.Hat(mitarbeiter, 'medikament.bestand') then
+ return nil, 'Du kannst nur eigene Einträge zurücknehmen.'
+ end
+
+ MySQL.query.await('DELETE FROM medical_behandlung_medikament WHERE id = ?', { gabeId })
+
+ -- Nur zurückbuchen, wenn seinerzeit auch gebucht wurde
+ local wurdeGebucht = not (gabe.hinweis and gabe.hinweis:find('ohne Bestandsbuchung', 1, true))
+
+ if wurdeGebucht then
+ local neuerBestand = gabe.bestand + gabe.menge
+
+ MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?',
+ { neuerBestand, gabe.medikament })
+
+ MySQL.insert.await([[
+ INSERT INTO medical_medikament_buchung
+ (medikament, art, menge, bestand_danach, grund, gebucht_von)
+ VALUES (?, 'korrektur', ?, ?, ?, ?)
+ ]], { gabe.medikament, gabe.menge, neuerBestand,
+ 'Rücknahme: ' .. grund, mitarbeiter.charakterId })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Medikamentengabe zurückgenommen',
+ { typ = 'behandlung', id = gabe.behandlung },
+ { medikament = gabe.name, menge = gabe.menge, zurueckgebucht = wurdeGebucht }, grund)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Zählt offene Dokumentationen eines Mitarbeiters
+--- @param charakterId number Charakter
+--- @return number
+exports('OffeneDokumentationen', function(charakterId)
+ if not tonumber(charakterId) then return 0 end
+ return tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_behandlung
+ WHERE status = 'laufend' AND erstellt_von = ?
+ ]], { tonumber(charakterId) })) or 0
+end)
diff --git a/resources/[aether]/aether-medical/server/module/bericht.lua b/resources/[aether]/aether-medical/server/module/bericht.lua
new file mode 100644
index 0000000..5dc0ff0
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/bericht.lua
@@ -0,0 +1,782 @@
+-- =====================================================================
+-- aether-medical / Modul: Berichte
+--
+-- Ein Bericht ist ein Dokument mit einer Unterschrift darunter.
+--
+-- Das System schreibt keinen Satz davon. Es formuliert nichts vor,
+-- fasst nichts zusammen und leitet aus keiner Behandlung selbst eine
+-- Diagnose ab. Wer Text aus einer Behandlung übernehmen will, löst
+-- das ausdrücklich aus — und was dann im Bericht steht, verantwortet
+-- er mit seinem Namen.
+--
+-- Der Weg vom Entwurf zur Freigabe wird Schritt für Schritt von
+-- Menschen gegangen. Kein Bericht reift von selbst, keine Frist gibt
+-- ihn frei, und ein freigegebener Bericht wird nie wieder geändert.
+-- =====================================================================
+
+-- Lesen darf, wer Akten lesen darf. Schreiben und Freigeben verlangen
+-- die jeweils eigenen Rechte.
+Kern.RegistriereModul({
+ id = 'bericht', name = 'Berichte', icon = '📄',
+ reihenfolge = 14, recht = 'akte.lesen',
+ beschreibung = 'Berichtsentwürfe, Freigabe und Unterschriften',
+})
+
+-- Die inhaltlichen Felder eines Berichts, in der Reihenfolge des Bogens
+local FELDER = {
+ { id = 'symptome', name = 'Symptome und Schilderung' },
+ { id = 'untersuchung', name = 'Untersuchungsbefund' },
+ { id = 'diagnose', name = 'Diagnose' },
+ { id = 'massnahmen', name = 'Maßnahmen' },
+ { id = 'empfehlung', name = 'Empfehlung' },
+}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Charakters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Liest eine JSON-Liste aus der Datenbank
+--- @param roh string|nil JSON
+--- @return table Liste
+local function AlsListe(roh)
+ if not roh then return {} end
+ local erfolg, liste = pcall(json.decode, roh)
+ if erfolg and type(liste) == 'table' then return liste end
+ return {}
+end
+
+--- Zeitstempel für Einträge in JSON-Feldern
+--- @return string
+local function Jetzt()
+ return os.date('%Y-%m-%d %H:%M:%S')
+end
+
+--- Baut einen Unterschrifteneintrag
+--- @param mitarbeiter table Unterzeichnender
+--- @param art string verfasser/freigabe/gegenzeichnung
+--- @return table
+local function Unterschrift(mitarbeiter, art)
+ return {
+ charakterId = mitarbeiter.charakterId,
+ name = mitarbeiter.name,
+ personalnummer = mitarbeiter.personalnummer,
+ rolle = mitarbeiter.rolleName,
+ art = art,
+ am = Jetzt(),
+ }
+end
+
+--- Prüft eine Adresse für eine Anlage
+--- @param wert any Eingabe
+--- @return string|nil
+local function Adresse(wert)
+ local roh = Text(wert, 500)
+ if not roh then return nil end
+ if not roh:match('^https?://') then return nil end
+ return roh
+end
+
+--- Lädt einen Bericht mit seinen Begleitdaten
+--- @param berichtId number Bericht
+--- @return table|nil
+local function LadeBericht(berichtId)
+ local zeilen = MySQL.query.await([[
+ SELECT b.*,
+ c.firstname, c.lastname, c.dob,
+ beh.kennung AS behandlungKennung,
+ ein.nummer AS einsatzNummer, ein.titel AS einsatzTitel
+ FROM medical_bericht b
+ LEFT JOIN aether_characters c ON c.id = b.patient
+ LEFT JOIN medical_behandlung beh ON beh.id = b.behandlung
+ LEFT JOIN medical_einsatz ein ON ein.id = b.einsatz
+ WHERE b.id = ?
+ ]], { berichtId })
+ return zeilen and zeilen[1] or nil
+end
+
+--- Darf dieser Mitarbeiter den Inhalt ändern?
+--- Nur der Verfasser, und nur solange der Bericht ein Entwurf ist.
+--- @param eintrag table Berichtszeile
+--- @param mitarbeiter table Handelnder
+--- @return boolean, string|nil Grund der Ablehnung
+local function DarfAendern(eintrag, mitarbeiter)
+ if eintrag.status == 'freigegeben' then
+ return false, 'Ein freigegebener Bericht wird nicht mehr geändert.'
+ end
+ if eintrag.status == 'eingereicht' then
+ return false, 'Der Bericht liegt zur Freigabe vor. Dafür erst zurückziehen.'
+ end
+ if eintrag.erstellt_von ~= mitarbeiter.charakterId then
+ return false, 'Diesen Entwurf hat jemand anderes geschrieben.'
+ end
+ return true, nil
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'meine')
+ local ich = mitarbeiter.charakterId
+
+ local bedingung, werte
+ if ordner == 'freigabe' then
+ bedingung = "b.status = 'eingereicht'"
+ werte = {}
+ elseif ordner == 'freigegeben' then
+ bedingung = "b.status = 'freigegeben'"
+ werte = {}
+ elseif ordner == 'alle' then
+ bedingung = '1 = 1'
+ werte = {}
+ else
+ -- Eigene Entwürfe: was noch niemand ausser dem Verfasser gesehen hat
+ bedingung = "b.status = 'entwurf' AND b.erstellt_von = ?"
+ werte = { ich }
+ end
+
+ -- Freitextsuche über Nummer, Titel und Patientenname
+ local begriff = Text(daten.suche, 60)
+ if begriff then
+ local muster = '%' .. begriff .. '%'
+ bedingung = ('(%s) AND (b.nummer LIKE ? OR b.titel LIKE ? OR CONCAT(c.firstname, " ", c.lastname) LIKE ?)')
+ :format(bedingung)
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT b.id, b.nummer, b.titel, b.patient, b.behandlung, b.einsatz,
+ b.status, b.erstellt_von, b.erstellt_am, b.diagnose,
+ b.unterschriften,
+ c.firstname, c.lastname,
+ beh.kennung AS behandlungKennung,
+ ein.nummer AS einsatzNummer
+ FROM medical_bericht b
+ LEFT JOIN aether_characters c ON c.id = b.patient
+ LEFT JOIN medical_behandlung beh ON beh.id = b.behandlung
+ LEFT JOIN medical_einsatz ein ON ein.id = b.einsatz
+ WHERE %s
+ ORDER BY b.id DESC
+ LIMIT 150
+ ]]):format(bedingung), werte) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.patientName = eintrag.firstname
+ and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil
+ eintrag.autor = NameVon(eintrag.erstellt_von)
+ eintrag.vonMir = eintrag.erstellt_von == ich
+ eintrag.unterschriften = #AlsListe(eintrag.unterschriften)
+ -- Nur ein Hinweis darauf, ob überhaupt eine Diagnose im Bericht steht
+ eintrag.hatDiagnose = eintrag.diagnose ~= nil and eintrag.diagnose ~= ''
+ eintrag.firstname, eintrag.lastname = nil, nil
+ eintrag.erstellt_von, eintrag.diagnose = nil, nil
+ end
+
+ local function Zaehle(sql, parameter)
+ return tonumber(MySQL.scalar.await(sql, parameter)) or 0
+ end
+
+ local zaehler = {
+ meine = Zaehle([[
+ SELECT COUNT(*) FROM medical_bericht
+ WHERE status = 'entwurf' AND erstellt_von = ?
+ ]], { ich }),
+ freigabe = Zaehle("SELECT COUNT(*) FROM medical_bericht WHERE status = 'eingereicht'"),
+ freigegeben = Zaehle([[
+ SELECT COUNT(*) FROM medical_bericht
+ WHERE status = 'freigegeben' AND DATE(erstellt_am) = CURDATE()
+ ]]),
+ -- Eigene Berichte, die noch bei der Freigabe liegen
+ meineOffen = Zaehle([[
+ SELECT COUNT(*) FROM medical_bericht
+ WHERE status = 'eingereicht' AND erstellt_von = ?
+ ]], { ich }),
+ }
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ zaehler = zaehler,
+ status = Medizin.Config.Berichtstatus,
+ felder = FELDER,
+ rechte = {
+ erstellen = Rechte.Hat(mitarbeiter, 'bericht.erstellen'),
+ freigeben = Rechte.Hat(mitarbeiter, 'bericht.freigeben'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINZELNER BERICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ -- Ein fremder Entwurf ist noch niemandes Dokument ausser dem des Verfassers
+ if eintrag.status == 'entwurf' and eintrag.erstellt_von ~= mitarbeiter.charakterId then
+ return nil, 'Dieser Entwurf ist noch nicht eingereicht.'
+ end
+
+ -- Schreiben darf nur, wer den Entwurf verfasst hat und das Recht dazu hat
+ local aenderbar = DarfAendern(eintrag, mitarbeiter)
+ local darfAendern = aenderbar and Rechte.Hat(mitarbeiter, 'bericht.erstellen')
+
+ local unterschriften = AlsListe(eintrag.unterschriften)
+ local anlagen = AlsListe(eintrag.anlagen)
+
+ -- Steht Verfasser und Freigabe in einer Person? Das wird gezeigt,
+ -- nicht verhindert.
+ local selbstFreigabe = false
+ for _, zeile in ipairs(unterschriften) do
+ if zeile.art == 'freigabe' and zeile.charakterId == eintrag.erstellt_von then
+ selbstFreigabe = true
+ end
+ end
+
+ local spur = Protokoll.Lies({ zielTyp = 'bericht', zielId = berichtId, limit = 40 })
+ for _, zeile in ipairs(spur) do
+ zeile.autor = zeile.firstname
+ and (zeile.firstname .. ' ' .. zeile.lastname) or 'System'
+ zeile.firstname, zeile.lastname, zeile.details = nil, nil, nil
+ end
+
+ return {
+ bericht = {
+ id = eintrag.id, nummer = eintrag.nummer, titel = eintrag.titel,
+ status = eintrag.status,
+ patient = eintrag.patient,
+ patientName = eintrag.firstname
+ and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil,
+ patientGeburt = eintrag.dob and tostring(eintrag.dob):sub(1, 10) or nil,
+ behandlung = eintrag.behandlung,
+ behandlungKennung = eintrag.behandlungKennung,
+ einsatz = eintrag.einsatz,
+ einsatzNummer = eintrag.einsatzNummer,
+ einsatzTitel = eintrag.einsatzTitel,
+ symptome = eintrag.symptome, untersuchung = eintrag.untersuchung,
+ diagnose = eintrag.diagnose, massnahmen = eintrag.massnahmen,
+ empfehlung = eintrag.empfehlung,
+ autor = NameVon(eintrag.erstellt_von),
+ vonMir = eintrag.erstellt_von == mitarbeiter.charakterId,
+ erstelltAm = eintrag.erstellt_am,
+ selbstFreigabe = selbstFreigabe,
+ },
+ unterschriften = unterschriften,
+ anlagen = anlagen,
+ spur = spur,
+ felder = FELDER,
+ status = Medizin.Config.Berichtstatus,
+ rechte = {
+ erstellen = Rechte.Hat(mitarbeiter, 'bericht.erstellen'),
+ freigeben = Rechte.Hat(mitarbeiter, 'bericht.freigeben'),
+ aendern = darfAendern,
+ },
+ }
+end)
+
+-- =====================================================================
+-- ANLEGEN UND SCHREIBEN
+-- =====================================================================
+
+--- Patientensuche und die Vorgänge, an die ein Bericht hängen kann
+Kern.RegistriereAktion('bericht', 'quellen', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ local patientId = tonumber(daten.patient)
+
+ local treffer = {}
+ if #begriff >= 2 then
+ local muster = '%' .. begriff .. '%'
+ treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, p.blutgruppe
+ FROM aether_characters c
+ LEFT JOIN medical_patient p ON p.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 20
+ ]], { muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ eintrag.firstname, eintrag.lastname = nil, nil
+ end
+ end
+
+ -- Behandlungen und Einsätze des gewählten Patienten
+ local behandlungen, einsaetze = {}, {}
+ if patientId then
+ behandlungen = MySQL.query.await([[
+ SELECT id, kennung, diagnose, status, erstellt_am
+ FROM medical_behandlung
+ WHERE patient = ?
+ ORDER BY id DESC LIMIT 25
+ ]], { patientId }) or {}
+
+ einsaetze = MySQL.query.await([[
+ SELECT e.id, e.nummer, e.titel, e.kategorie, e.erstellt_am
+ FROM medical_einsatz e
+ WHERE EXISTS (SELECT 1 FROM medical_behandlung b
+ WHERE b.einsatz = e.id AND b.patient = ?)
+ ORDER BY e.id DESC LIMIT 15
+ ]], { patientId }) or {}
+ end
+
+ return { treffer = treffer, behandlungen = behandlungen, einsaetze = einsaetze }
+end)
+
+Kern.RegistriereAktion('bericht', 'anlegen', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local titel = Text(daten.titel, 160)
+ if not titel then return nil, 'Bitte einen Titel angeben.' end
+
+ -- Patient, Behandlung und Einsatz sind freiwillig — nicht jeder
+ -- Bericht gehört zu einem einzelnen Vorgang
+ local patientId = tonumber(daten.patient)
+ if patientId then
+ local person = MySQL.scalar.await(
+ 'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { patientId })
+ if not person then return nil, 'Diese Person ist nicht erfasst.' end
+ end
+
+ local behandlungId = tonumber(daten.behandlung)
+ if behandlungId then
+ local behandlung = MySQL.query.await(
+ 'SELECT id, patient FROM medical_behandlung WHERE id = ?', { behandlungId })
+ if not behandlung or not behandlung[1] then return nil, 'Unbekannte Behandlung.' end
+ -- Die Behandlung bestimmt den Patienten, wenn keiner gewählt wurde
+ patientId = patientId or behandlung[1].patient
+ end
+
+ local einsatzId = tonumber(daten.einsatz)
+ if einsatzId then
+ local einsatz = MySQL.scalar.await(
+ 'SELECT id FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not einsatz then return nil, 'Unbekannter Einsatz.' end
+ end
+
+ local nummer = Kern.ErzeugeKennung('BER', 'medical_bericht', 'nummer')
+
+ local berichtId = MySQL.insert.await([[
+ INSERT INTO medical_bericht
+ (nummer, patient, behandlung, einsatz, titel, status, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, 'entwurf', ?)
+ ]], { nummer, patientId, behandlungId, einsatzId, titel, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht angelegt',
+ { typ = 'bericht', id = berichtId },
+ { nummer = nummer, titel = titel, patient = patientId,
+ behandlung = behandlungId, einsatz = einsatzId })
+
+ Kern.Loese('bericht.angelegt', berichtId, mitarbeiter)
+
+ return { id = berichtId, nummer = nummer }
+end)
+
+Kern.RegistriereAktion('bericht', 'speichern', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ local darf, fehler = DarfAendern(eintrag, mitarbeiter)
+ if not darf then return nil, fehler end
+
+ local titel = Text(daten.titel, 160) or eintrag.titel
+
+ MySQL.update.await([[
+ UPDATE medical_bericht
+ SET titel = ?, symptome = ?, untersuchung = ?, diagnose = ?,
+ massnahmen = ?, empfehlung = ?
+ WHERE id = ?
+ ]], {
+ titel,
+ Text(daten.symptome, 4000),
+ Text(daten.untersuchung, 4000),
+ Text(daten.diagnose, 4000),
+ Text(daten.massnahmen, 4000),
+ Text(daten.empfehlung, 4000),
+ berichtId,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Entwurf gespeichert',
+ { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer })
+
+ return { ok = true }
+end)
+
+--- Übernimmt den Wortlaut einer Behandlung in den Entwurf.
+---
+--- Das ist ein Kopiervorgang, den ein Mensch auslöst — das System
+--- fasst nichts zusammen und formuliert nichts um. Vorhandener Text
+--- im Bericht wird nicht überschrieben, sondern bleibt stehen.
+Kern.RegistriereAktion('bericht', 'uebernehmen', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ local behandlungId = tonumber(daten.behandlung)
+
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+ if not behandlungId then return nil, 'Keine Behandlung gewählt.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ local darf, fehler = DarfAendern(eintrag, mitarbeiter)
+ if not darf then return nil, fehler end
+
+ local zeilen = MySQL.query.await([[
+ SELECT id, kennung, patient, symptome, untersuchung, diagnose,
+ massnahmen, empfehlung
+ FROM medical_behandlung WHERE id = ?
+ ]], { behandlungId })
+ if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end
+
+ local behandlung = zeilen[1]
+
+ -- Nur leere Felder füllen: was der Verfasser geschrieben hat, bleibt
+ local uebernommen = {}
+ local werte = {}
+
+ for _, feld in ipairs(FELDER) do
+ local vorhanden = Text(eintrag[feld.id], 4000)
+ local quelle_ = Text(behandlung[feld.id], 4000)
+
+ if not vorhanden and quelle_ then
+ werte[feld.id] = quelle_
+ uebernommen[#uebernommen + 1] = feld.name
+ else
+ werte[feld.id] = vorhanden
+ end
+ end
+
+ if #uebernommen == 0 then
+ return nil, 'Es gibt nichts zu übernehmen — alle Felder sind bereits gefüllt oder die Behandlung ist leer.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_bericht
+ SET symptome = ?, untersuchung = ?, diagnose = ?, massnahmen = ?,
+ empfehlung = ?, behandlung = COALESCE(behandlung, ?),
+ patient = COALESCE(patient, ?)
+ WHERE id = ?
+ ]], { werte.symptome, werte.untersuchung, werte.diagnose, werte.massnahmen,
+ werte.empfehlung, behandlungId, behandlung.patient, berichtId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Wortlaut aus Behandlung übernommen',
+ { typ = 'bericht', id = berichtId },
+ { nummer = eintrag.nummer, behandlung = behandlung.kennung,
+ felder = uebernommen })
+
+ return { ok = true, uebernommen = uebernommen }
+end)
+
+-- =====================================================================
+-- ANLAGEN
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'anlage', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ local darf, fehler = DarfAendern(eintrag, mitarbeiter)
+ if not darf then return nil, fehler end
+
+ local anlagen = AlsListe(eintrag.anlagen)
+ local entfernen = tonumber(daten.entfernen)
+
+ if entfernen then
+ -- Entfernen ist nur im Entwurf möglich; ab der Einreichung
+ -- gehört die Anlage zum Dokument
+ if entfernen < 1 or entfernen > #anlagen then
+ return nil, 'Diese Anlage gibt es nicht.'
+ end
+
+ local weg = table.remove(anlagen, entfernen)
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Anlage entfernt',
+ { typ = 'bericht', id = berichtId },
+ { nummer = eintrag.nummer, titel = weg and weg.titel or nil })
+ else
+ local titel = Text(daten.titel, 120)
+ local adresse = Adresse(daten.adresse)
+
+ if not titel then return nil, 'Bitte die Anlage benennen.' end
+ if not adresse then return nil, 'Die Adresse muss mit http:// oder https:// beginnen.' end
+ if #anlagen >= 12 then return nil, 'Mehr als zwölf Anlagen sind nicht vorgesehen.' end
+
+ anlagen[#anlagen + 1] = {
+ titel = titel, adresse = adresse,
+ von = mitarbeiter.name, am = Jetzt(),
+ }
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Anlage hinzugefügt',
+ { typ = 'bericht', id = berichtId },
+ { nummer = eintrag.nummer, titel = titel })
+ end
+
+ MySQL.update.await('UPDATE medical_bericht SET anlagen = ? WHERE id = ?',
+ { #anlagen > 0 and json.encode(anlagen) or nil, berichtId })
+
+ return { ok = true, anzahl = #anlagen }
+end)
+
+-- =====================================================================
+-- EINREICHEN UND ZURÜCKZIEHEN
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'einreichen', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ local darf, fehler = DarfAendern(eintrag, mitarbeiter)
+ if not darf then return nil, fehler end
+
+ -- Ein leerer Bericht ist kein Bericht. Welche Felder fehlen, wird
+ -- nur benannt — die Einreichung hält das nicht auf.
+ local gefuellt, leer = 0, {}
+ for _, feld in ipairs(FELDER) do
+ if Text(eintrag[feld.id], 4000) then gefuellt = gefuellt + 1
+ else leer[#leer + 1] = feld.name end
+ end
+
+ if gefuellt == 0 then
+ return nil, 'Der Bericht enthält noch keinen Text.'
+ end
+
+ local unterschriften = AlsListe(eintrag.unterschriften)
+ unterschriften[#unterschriften + 1] = Unterschrift(mitarbeiter, 'verfasser')
+
+ MySQL.update.await([[
+ UPDATE medical_bericht SET status = 'eingereicht', unterschriften = ?
+ WHERE id = ?
+ ]], { json.encode(unterschriften), berichtId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht eingereicht',
+ { typ = 'bericht', id = berichtId },
+ { nummer = eintrag.nummer, leereFelder = leer })
+
+ -- Wer freigeben darf, erfährt davon
+ for _, person in pairs(Dienst.AlleImDienst()) do
+ if Rechte.Hat(person, 'bericht.freigeben')
+ and person.charakterId ~= mitarbeiter.charakterId then
+ Hinweise.Erstelle({ charakterId = person.charakterId }, {
+ kategorie = 'doku', prioritaet = 'normal',
+ titel = 'Bericht liegt zur Freigabe',
+ text = ('%s — %s, von %s.')
+ :format(eintrag.nummer, eintrag.titel, mitarbeiter.name),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+ end
+ end
+
+ Kern.Loese('bericht.eingereicht', berichtId, mitarbeiter)
+
+ return { ok = true, leereFelder = leer }
+end)
+
+Kern.RegistriereAktion('bericht', 'zurueckziehen', 'bericht.erstellen', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte angeben, warum der Bericht zurückgeht.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ if eintrag.status ~= 'eingereicht' then
+ return nil, 'Nur ein eingereichter Bericht lässt sich zurückziehen.'
+ end
+ if eintrag.erstellt_von ~= mitarbeiter.charakterId then
+ return nil, 'Zurückziehen kann nur, wer den Bericht geschrieben hat.'
+ end
+
+ -- Die geleistete Unterschrift bleibt stehen: sie wurde geleistet.
+ -- Dass der Bericht danach wieder geändert wurde, steht im Protokoll.
+ MySQL.update.await("UPDATE medical_bericht SET status = 'entwurf' WHERE id = ?",
+ { berichtId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht zurückgezogen',
+ { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer }, grund)
+
+ Kern.Loese('bericht.zurueckgezogen', berichtId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- FREIGABE
+--
+-- Die Freigabe ist eine Unterschrift. Sie geschieht nie automatisch,
+-- nie nach Ablauf einer Frist und nie durch das System.
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'freigeben', 'bericht.freigeben', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ if eintrag.status ~= 'eingereicht' then
+ return nil, 'Nur ein eingereichter Bericht wird freigegeben.'
+ end
+
+ local unterschriften = AlsListe(eintrag.unterschriften)
+ unterschriften[#unterschriften + 1] = Unterschrift(mitarbeiter, 'freigabe')
+
+ -- Verfasser und Freigabe in einer Person ist möglich und wird
+ -- festgehalten — verhindert wird es nicht.
+ local selbstFreigabe = eintrag.erstellt_von == mitarbeiter.charakterId
+
+ MySQL.update.await([[
+ UPDATE medical_bericht SET status = 'freigegeben', unterschriften = ?
+ WHERE id = ?
+ ]], { json.encode(unterschriften), berichtId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht freigegeben',
+ { typ = 'bericht', id = berichtId },
+ { nummer = eintrag.nummer, selbstFreigabe = selbstFreigabe },
+ Text(daten.vermerk, 255))
+
+ if not selbstFreigabe then
+ Hinweise.Erstelle({ charakterId = eintrag.erstellt_von }, {
+ kategorie = 'doku', prioritaet = 'normal',
+ titel = 'Dein Bericht wurde freigegeben',
+ text = ('%s — %s, freigegeben von %s.')
+ :format(eintrag.nummer, eintrag.titel, mitarbeiter.name),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+ end
+
+ Kern.Loese('bericht.freigegeben', berichtId, mitarbeiter)
+
+ return { ok = true, selbstFreigabe = selbstFreigabe }
+end)
+
+Kern.RegistriereAktion('bericht', 'zurueckweisen', 'bericht.freigeben', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 500)
+ if not grund or #grund < 5 then
+ return nil, 'Eine Zurückweisung braucht eine Begründung.'
+ end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ if eintrag.status ~= 'eingereicht' then
+ return nil, 'Nur ein eingereichter Bericht lässt sich zurückweisen.'
+ end
+
+ MySQL.update.await("UPDATE medical_bericht SET status = 'entwurf' WHERE id = ?",
+ { berichtId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht zurückgewiesen',
+ { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer }, grund)
+
+ Hinweise.Erstelle({ charakterId = eintrag.erstellt_von }, {
+ kategorie = 'doku', prioritaet = 'hoch',
+ titel = 'Bericht zurückgewiesen',
+ text = ('%s — %s: %s'):format(eintrag.nummer, mitarbeiter.name, grund:sub(1, 120)),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+
+ Kern.Loese('bericht.zurueckgewiesen', berichtId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+--- Zeichnet einen Bericht gegen.
+--- Eine Gegenzeichnung ändert nichts am Wortlaut — sie fügt nur eine
+--- weitere Unterschrift hinzu. Zurücknehmen lässt sie sich nicht.
+Kern.RegistriereAktion('bericht', 'gegenzeichnen', 'bericht.freigeben', function(quelle, mitarbeiter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeBericht(berichtId)
+ if not eintrag then return nil, 'Bericht nicht gefunden.' end
+
+ if eintrag.status == 'entwurf' then
+ return nil, 'Ein Entwurf wird nicht gegengezeichnet.'
+ end
+
+ local unterschriften = AlsListe(eintrag.unterschriften)
+
+ -- Zweimal dieselbe Unterschrift sagt nichts Zusätzliches
+ for _, zeile in ipairs(unterschriften) do
+ if zeile.charakterId == mitarbeiter.charakterId
+ and zeile.art == 'gegenzeichnung' then
+ return nil, 'Du hast diesen Bericht bereits gegengezeichnet.'
+ end
+ end
+
+ unterschriften[#unterschriften + 1] = Unterschrift(mitarbeiter, 'gegenzeichnung')
+
+ MySQL.update.await('UPDATE medical_bericht SET unterschriften = ? WHERE id = ?',
+ { json.encode(unterschriften), berichtId })
+
+ Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht gegengezeichnet',
+ { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer },
+ Text(daten.vermerk, 255))
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die freigegebenen Berichte eines Patienten.
+--- Reine Auskunft — keine Ressource kann darüber einen Bericht
+--- schreiben, ändern oder freigeben. Entwürfe bleiben aussen vor.
+--- @param patientId number Charakter
+--- @return table Liste
+exports('HoleBerichte', function(patientId)
+ if not tonumber(patientId) then return {} end
+
+ return MySQL.query.await([[
+ SELECT nummer, titel, diagnose, erstellt_am
+ FROM medical_bericht
+ WHERE patient = ? AND status = 'freigegeben'
+ ORDER BY id DESC
+ LIMIT 50
+ ]], { tonumber(patientId) }) or {}
+end)
diff --git a/resources/[aether]/aether-medical/server/module/blutbank.lua b/resources/[aether]/aether-medical/server/module/blutbank.lua
new file mode 100644
index 0000000..e2074d9
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/blutbank.lua
@@ -0,0 +1,690 @@
+-- =====================================================================
+-- aether-medical / Modul: Blutbank
+--
+-- Ein Lagerbuch für Blutkonserven. Nichts weiter.
+--
+-- Das System transfundiert nicht, wählt keine Konserve aus und
+-- verwirft keine. Eine abgelaufene Konserve bleibt so lange im
+-- Bestand, bis ein Mensch sie herausnimmt — das Ablaufdatum ist
+-- eine Erinnerung, keine Sperre.
+--
+-- Die Verträglichkeitstabelle ist eine Nachschlagehilfe. Passt eine
+-- Konserve laut Tabelle nicht zum Empfänger, wird das deutlich
+-- angezeigt und mitprotokolliert — verhindert wird es nicht. Ob
+-- transfundiert wird, entscheidet ausschliesslich ein Arzt.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'blutbank', name = 'Blutbank', icon = '🩸',
+ reihenfolge = 11, recht = 'blut.sehen',
+ beschreibung = 'Bestand, Reservierungen, Ausgabe und Verfallsdaten',
+})
+
+-- Status und ihre erlaubten Übergänge. Jeder Schritt braucht einen
+-- Menschen — es gibt keinen Weg, den das System von selbst geht.
+local UEBERGAENGE = {
+ vorraetig = { reserviert = true, ausgegeben = true, verworfen = true },
+ reserviert = { vorraetig = true, ausgegeben = true, verworfen = true },
+ ausgegeben = {},
+ verworfen = {},
+}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Charakters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Prüft eine Blutgruppe gegen die Konfiguration
+--- @param wert any Eingabe
+--- @return string|nil Gültige Blutgruppe
+local function Blutgruppe(wert)
+ local gesucht = tostring(wert or '')
+ for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
+ if gruppe == gesucht then return gruppe end
+ end
+ return nil
+end
+
+--- Prüft einen Lagerort gegen die Konfiguration
+--- @param wert any Eingabe
+--- @return string|nil Gültiger Lagerort
+local function Lagerort(wert)
+ local gesucht = tostring(wert or '')
+ for _, ort in ipairs(Medizin.Config.Blutlagerorte) do
+ if ort == gesucht then return ort end
+ end
+ return nil
+end
+
+--- Prüft ein Datum im Format JJJJ-MM-TT
+--- @param wert any Eingabe
+--- @return string|nil
+local function Datum(wert)
+ local roh = Text(wert, 10)
+ if not roh then return nil end
+ if not roh:match('^%d%d%d%d%-%d%d%-%d%d$') then return nil end
+ return roh
+end
+
+--- Passt eine Konserve laut Nachschlagetabelle zum Empfänger?
+---
+--- Liefert `true`, wenn die Kombination in der Tabelle steht, und
+--- `nil`, wenn sich dazu nichts sagen lässt — etwa weil die
+--- Blutgruppe des Patienten nie bestimmt wurde. Der Rückgabewert
+--- sperrt nichts; er wird nur angezeigt und protokolliert.
+--- @param empfaenger string|nil Blutgruppe des Patienten
+--- @param konserve string Blutgruppe der Konserve
+--- @return boolean|nil
+local function Vertraeglich(empfaenger, konserve)
+ if not empfaenger or empfaenger == '' then return nil end
+
+ local erlaubt = Medizin.Config.Blutvertraeglichkeit[empfaenger]
+ if not erlaubt then return nil end
+
+ for _, gruppe in ipairs(erlaubt) do
+ if gruppe == konserve then return true end
+ end
+ return false
+end
+
+--- Lädt eine Konserve mit den Daten des Empfängers
+--- @param konserveId number Konserve
+--- @return table|nil
+local function LadeKonserve(konserveId)
+ local zeilen = MySQL.query.await([[
+ SELECT b.*,
+ DATEDIFF(b.verfaellt_am, CURDATE()) AS tage,
+ c.firstname, c.lastname,
+ p.blutgruppe AS empfaengerGruppe
+ FROM medical_blut b
+ LEFT JOIN aether_characters c ON c.id = b.reserviert_fuer
+ LEFT JOIN medical_patient p ON p.`character` = b.reserviert_fuer
+ WHERE b.id = ?
+ ]], { konserveId })
+
+ return zeilen and zeilen[1] or nil
+end
+
+--- Prüft, ob ein Statuswechsel überhaupt vorgesehen ist
+--- @param von string Aktueller Status
+--- @param nach string Gewünschter Status
+--- @return boolean
+local function DarfWechseln(von, nach)
+ return (UEBERGAENGE[von] or {})[nach] == true
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('blutbank', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'vorraetig')
+ local vorlauf = Medizin.Config.Erinnerungen.blutVorlaufTage
+
+ -- Welche Status gehören in diesen Ordner?
+ local bedingung, werte
+ if ordner == 'reserviert' then
+ bedingung = "b.status = 'reserviert'"
+ elseif ordner == 'archiv' then
+ bedingung = "b.status IN ('ausgegeben', 'verworfen')"
+ else
+ bedingung = "b.status = 'vorraetig'"
+ end
+ werte = {}
+
+ if daten.gruppe and daten.gruppe ~= '' then
+ bedingung = bedingung .. ' AND b.blutgruppe = ?'
+ werte[#werte + 1] = tostring(daten.gruppe)
+ end
+
+ -- Im Bestand steht die älteste Konserve oben. Das ist eine
+ -- Sortierung, keine Vorgabe — welche genommen wird, entscheidet
+ -- weiterhin ein Mensch.
+ local sortierung = ordner == 'archiv'
+ and 'b.id DESC'
+ or 'b.verfaellt_am IS NULL, b.verfaellt_am, b.konserve'
+
+ local liste = MySQL.query.await(([[
+ SELECT b.id, b.konserve, b.blutgruppe, b.menge_ml, b.lagerort, b.status,
+ b.eingelagert_am, b.eingelagert_von, b.verfaellt_am,
+ b.reserviert_fuer, b.reserviert_von,
+ b.ausgegeben_von, b.ausgegeben_am,
+ DATEDIFF(b.verfaellt_am, CURDATE()) AS tage,
+ c.firstname, c.lastname,
+ p.blutgruppe AS empfaengerGruppe
+ FROM medical_blut b
+ LEFT JOIN aether_characters c ON c.id = b.reserviert_fuer
+ LEFT JOIN medical_patient p ON p.`character` = b.reserviert_fuer
+ WHERE %s
+ ORDER BY %s
+ LIMIT 200
+ ]]):format(bedingung, sortierung), werte) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil
+ -- Abgelaufen heisst hier nur: das Datum ist vorbei. Sonst nichts.
+ eintrag.abgelaufen = eintrag.tage ~= nil and eintrag.tage <= 0
+ eintrag.laeuftBaldAb = eintrag.tage ~= nil
+ and eintrag.tage > 0 and eintrag.tage <= vorlauf
+
+ eintrag.empfaenger = eintrag.firstname
+ and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil
+
+ -- Bewusst ohne "and ... or": ein `false` darf hier nicht zu `nil`
+ -- werden, sonst verschwindet genau die Abweichung, um die es geht.
+ if eintrag.reserviert_fuer then
+ eintrag.vertraeglich = Vertraeglich(eintrag.empfaengerGruppe, eintrag.blutgruppe)
+ end
+
+ eintrag.eingelagertVon = NameVon(eintrag.eingelagert_von)
+ eintrag.reserviertVon = eintrag.reserviert_von
+ and NameVon(eintrag.reserviert_von) or nil
+ eintrag.ausgegebenVon = eintrag.ausgegeben_von
+ and NameVon(eintrag.ausgegeben_von) or nil
+
+ eintrag.firstname, eintrag.lastname = nil, nil
+ eintrag.eingelagert_von, eintrag.reserviert_von, eintrag.ausgegeben_von =
+ nil, nil, nil
+ end
+
+ -- Bestand je Blutgruppe
+ local bestand = {}
+ for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
+ local zeilen = MySQL.query.await([[
+ SELECT status, COUNT(*) AS anzahl
+ FROM medical_blut
+ WHERE blutgruppe = ? AND status IN ('vorraetig', 'reserviert')
+ GROUP BY status
+ ]], { gruppe }) or {}
+
+ local eintrag = { gruppe = gruppe, vorraetig = 0, reserviert = 0 }
+ for _, zeile in ipairs(zeilen) do
+ eintrag[zeile.status] = tonumber(zeile.anzahl) or 0
+ end
+
+ eintrag.baldAb = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE blutgruppe = ? AND status IN ('vorraetig', 'reserviert')
+ AND verfaellt_am IS NOT NULL
+ AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
+ ]], { gruppe, vorlauf })) or 0
+
+ eintrag.knapp = eintrag.vorraetig <= Medizin.Config.Erinnerungen.blutMindestbestand
+ bestand[#bestand + 1] = eintrag
+ end
+
+ -- Zählwerte über das ganze Lager
+ local function Zaehle(sql, parameter)
+ return tonumber(MySQL.scalar.await(sql, parameter)) or 0
+ end
+
+ local zaehler = {
+ vorraetig = Zaehle("SELECT COUNT(*) FROM medical_blut WHERE status = 'vorraetig'"),
+ reserviert = Zaehle("SELECT COUNT(*) FROM medical_blut WHERE status = 'reserviert'"),
+ baldAb = Zaehle([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE status IN ('vorraetig', 'reserviert') AND verfaellt_am IS NOT NULL
+ AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
+ AND verfaellt_am >= CURDATE()
+ ]], { vorlauf }),
+ abgelaufen = Zaehle([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE status IN ('vorraetig', 'reserviert')
+ AND verfaellt_am IS NOT NULL AND verfaellt_am < CURDATE()
+ ]]),
+ ausgegebenHeute = Zaehle([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE status = 'ausgegeben' AND DATE(ausgegeben_am) = CURDATE()
+ ]]),
+ }
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ bestand = bestand,
+ zaehler = zaehler,
+ gruppen = Medizin.Config.Blutgruppen,
+ status = Medizin.Config.Blutstatus,
+ lagerorte = Medizin.Config.Blutlagerorte,
+ vertraeglichkeit = Medizin.Config.Blutvertraeglichkeit,
+ vorlaufTage = vorlauf,
+ mindestbestand = Medizin.Config.Erinnerungen.blutMindestbestand,
+ rechte = {
+ verwalten = Rechte.Hat(mitarbeiter, 'blut.verwalten'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINZELNE KONSERVE
+-- =====================================================================
+
+Kern.RegistriereAktion('blutbank', 'konserve', nil, function(quelle, mitarbeiter, daten)
+ local konserveId = tonumber(daten.id)
+ if not konserveId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeKonserve(konserveId)
+ if not eintrag then return nil, 'Konserve nicht gefunden.' end
+
+ local tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil
+
+ -- Auch hier gilt: `false` ist eine Aussage und darf nicht zu `nil` werden
+ local passt = nil
+ if eintrag.reserviert_fuer then
+ passt = Vertraeglich(eintrag.empfaengerGruppe, eintrag.blutgruppe)
+ end
+
+ -- Der Weg der Konserve steht im Protokoll und wird nur gelesen
+ local spur = Protokoll.Lies({ zielTyp = 'blut', zielId = konserveId, limit = 50 })
+
+ for _, zeile in ipairs(spur) do
+ zeile.autor = zeile.firstname
+ and (zeile.firstname .. ' ' .. zeile.lastname) or 'System'
+ zeile.firstname, zeile.lastname = nil, nil
+ zeile.details = nil
+ end
+
+ return {
+ konserve = {
+ id = eintrag.id, kennung = eintrag.konserve,
+ blutgruppe = eintrag.blutgruppe, menge = eintrag.menge_ml,
+ lagerort = eintrag.lagerort, status = eintrag.status,
+ eingelagertAm = eintrag.eingelagert_am,
+ eingelagertVon = NameVon(eintrag.eingelagert_von),
+ verfaelltAm = eintrag.verfaellt_am, tage = tage,
+ abgelaufen = tage ~= nil and tage <= 0,
+ empfaenger = eintrag.reserviert_fuer,
+ empfaengerName = eintrag.firstname
+ and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil,
+ empfaengerGruppe = eintrag.empfaengerGruppe,
+ vertraeglich = passt,
+ reserviertVon = eintrag.reserviert_von
+ and NameVon(eintrag.reserviert_von) or nil,
+ ausgegebenAm = eintrag.ausgegeben_am,
+ ausgegebenVon = eintrag.ausgegeben_von
+ and NameVon(eintrag.ausgegeben_von) or nil,
+ },
+ spur = spur,
+ rechte = {
+ verwalten = Rechte.Hat(mitarbeiter, 'blut.verwalten'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINLAGERN
+-- =====================================================================
+
+Kern.RegistriereAktion('blutbank', 'einlagern', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local gruppe = Blutgruppe(daten.blutgruppe)
+ if not gruppe then return nil, 'Unbekannte Blutgruppe.' end
+
+ local menge = math.floor(tonumber(daten.menge) or 0)
+ if menge < 50 or menge > 1000 then
+ return nil, 'Eine Konserve fasst zwischen 50 und 1000 ml.'
+ end
+
+ local anzahl = math.floor(tonumber(daten.anzahl) or 1)
+ if anzahl < 1 or anzahl > 20 then
+ return nil, 'Es lassen sich 1 bis 20 Konserven auf einmal einlagern.'
+ end
+
+ local lagerort = Lagerort(daten.lagerort)
+ if not lagerort then return nil, 'Unbekannter Lagerort.' end
+
+ -- Ein Verfallsdatum ist freiwillig; ohne eines erinnert später nichts
+ local verfaellt = Datum(daten.verfaellt)
+ if daten.verfaellt and daten.verfaellt ~= '' and not verfaellt then
+ return nil, 'Das Verfallsdatum braucht die Form JJJJ-MM-TT.'
+ end
+
+ local angelegt = {}
+
+ for _ = 1, anzahl do
+ local kennung = Kern.ErzeugeKennung('BK', 'medical_blut', 'konserve')
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_blut
+ (konserve, blutgruppe, menge_ml, lagerort, eingelagert_von, verfaellt_am)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ]], { kennung, gruppe, menge, lagerort, mitarbeiter.charakterId, verfaellt })
+
+ angelegt[#angelegt + 1] = { id = id, kennung = kennung }
+
+ Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve eingelagert',
+ { typ = 'blut', id = id },
+ { konserve = kennung, blutgruppe = gruppe, menge = menge,
+ lagerort = lagerort, verfaellt = verfaellt },
+ Text(daten.vermerk, 255))
+ end
+
+ Kern.Loese('blut.eingelagert', gruppe, anzahl, mitarbeiter)
+
+ return { angelegt = angelegt, anzahl = #angelegt }
+end)
+
+-- =====================================================================
+-- RESERVIEREN
+--
+-- Eine Reservierung legt eine Konserve für einen Patienten beiseite.
+-- Sie transfundiert nichts und verpflichtet zu nichts.
+-- =====================================================================
+
+--- Personensuche für die Reservierung
+Kern.RegistriereAktion('blutbank', 'patientSuche', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob,
+ p.blutgruppe, p.allergien, p.hinweis,
+ (SELECT a.status FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen')
+ LIMIT 1) AS laufend
+ FROM aether_characters c
+ LEFT JOIN medical_patient p ON p.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 20
+ ]], { muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ end
+
+ return { treffer = treffer }
+end)
+
+Kern.RegistriereAktion('blutbank', 'reservieren', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local konserveId = tonumber(daten.id)
+ local patientId = tonumber(daten.patient)
+
+ if not konserveId then return nil, 'Ungültige Kennung.' end
+ if not patientId then return nil, 'Kein Patient gewählt.' end
+
+ local eintrag = LadeKonserve(konserveId)
+ if not eintrag then return nil, 'Konserve nicht gefunden.' end
+
+ if not DarfWechseln(eintrag.status, 'reserviert') then
+ return nil, ('Eine Konserve im Status "%s" lässt sich nicht reservieren.')
+ :format(eintrag.status)
+ end
+
+ local person = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.blutgruppe
+ FROM aether_characters c
+ LEFT JOIN medical_patient p ON p.`character` = c.id
+ WHERE c.id = ? AND c.deleted = 0
+ ]], { patientId })
+ if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
+
+ -- Die Tabelle sagt etwas dazu — entschieden wird trotzdem hier oben
+ local passt = Vertraeglich(person[1].blutgruppe, eintrag.blutgruppe)
+
+ MySQL.update.await([[
+ UPDATE medical_blut
+ SET status = 'reserviert', reserviert_fuer = ?, reserviert_von = ?
+ WHERE id = ?
+ ]], { patientId, mitarbeiter.charakterId, konserveId })
+
+ Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve reserviert',
+ { typ = 'blut', id = konserveId },
+ { konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe,
+ patient = patientId, patientGruppe = person[1].blutgruppe,
+ lautTabelleVertraeglich = passt },
+ Text(daten.vermerk, 255))
+
+ -- Eine Abweichung von der Tabelle wird deutlich gemeldet, nicht verhindert
+ if passt == false then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'blut', prioritaet = 'hoch',
+ titel = 'Reservierung weicht von der Verträglichkeitstabelle ab',
+ text = ('%s (%s) für %s %s mit Blutgruppe %s — bitte prüfen.')
+ :format(eintrag.konserve, eintrag.blutgruppe,
+ person[1].firstname, person[1].lastname, person[1].blutgruppe),
+ zielTyp = 'blut', zielId = konserveId,
+ })
+ end
+
+ Kern.Loese('blut.reserviert', konserveId, patientId, mitarbeiter)
+
+ return { ok = true, vertraeglich = passt }
+end)
+
+Kern.RegistriereAktion('blutbank', 'reservierungLoesen', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local konserveId = tonumber(daten.id)
+ if not konserveId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte angeben, warum die Reservierung entfällt.' end
+
+ local eintrag = LadeKonserve(konserveId)
+ if not eintrag then return nil, 'Konserve nicht gefunden.' end
+ if eintrag.status ~= 'reserviert' then
+ return nil, 'Diese Konserve ist nicht reserviert.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_blut
+ SET status = 'vorraetig', reserviert_fuer = NULL, reserviert_von = NULL
+ WHERE id = ?
+ ]], { konserveId })
+
+ Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Reservierung aufgehoben',
+ { typ = 'blut', id = konserveId },
+ { konserve = eintrag.konserve, patient = eintrag.reserviert_fuer }, grund)
+
+ Kern.Loese('blut.freigegeben', konserveId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- AUSGABE
+--
+-- Dokumentiert, dass eine Konserve das Lager verlassen hat. Was mit
+-- ihr geschieht, entscheidet und verantwortet der Arzt am Patienten.
+-- =====================================================================
+
+Kern.RegistriereAktion('blutbank', 'ausgeben', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local konserveId = tonumber(daten.id)
+ if not konserveId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeKonserve(konserveId)
+ if not eintrag then return nil, 'Konserve nicht gefunden.' end
+
+ if not DarfWechseln(eintrag.status, 'ausgegeben') then
+ return nil, ('Eine Konserve im Status "%s" lässt sich nicht ausgeben.')
+ :format(eintrag.status)
+ end
+
+ -- Ohne Reservierung muss bei der Ausgabe gesagt werden, für wen
+ local patientId = tonumber(daten.patient) or eintrag.reserviert_fuer
+ if not patientId then return nil, 'Bitte angeben, für welchen Patienten.' end
+
+ local vermerk = Text(daten.vermerk, 255)
+ if not vermerk then return nil, 'Bitte einen Vermerk zur Ausgabe hinterlassen.' end
+
+ local person = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.blutgruppe
+ FROM aether_characters c
+ LEFT JOIN medical_patient p ON p.`character` = c.id
+ WHERE c.id = ? AND c.deleted = 0
+ ]], { patientId })
+ if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
+
+ local passt = Vertraeglich(person[1].blutgruppe, eintrag.blutgruppe)
+ local tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil
+ local abgelaufen = tage ~= nil and tage <= 0
+
+ MySQL.update.await([[
+ UPDATE medical_blut
+ SET status = 'ausgegeben', reserviert_fuer = ?,
+ ausgegeben_von = ?, ausgegeben_am = NOW()
+ WHERE id = ?
+ ]], { patientId, mitarbeiter.charakterId, konserveId })
+
+ Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve ausgegeben',
+ { typ = 'blut', id = konserveId },
+ { konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe,
+ patient = patientId, patientGruppe = person[1].blutgruppe,
+ lautTabelleVertraeglich = passt, abgelaufen = abgelaufen }, vermerk)
+
+ if passt == false then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'blut', prioritaet = 'kritisch',
+ titel = 'Ausgabe weicht von der Verträglichkeitstabelle ab',
+ text = ('%s (%s) an %s %s mit Blutgruppe %s — ausgegeben von %s.')
+ :format(eintrag.konserve, eintrag.blutgruppe,
+ person[1].firstname, person[1].lastname,
+ person[1].blutgruppe, mitarbeiter.name),
+ zielTyp = 'blut', zielId = konserveId,
+ })
+ end
+
+ if abgelaufen then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'blut', prioritaet = 'hoch',
+ titel = 'Abgelaufene Konserve ausgegeben',
+ text = ('%s war seit %d Tagen über dem Datum — ausgegeben von %s.')
+ :format(eintrag.konserve, math.abs(tage), mitarbeiter.name),
+ zielTyp = 'blut', zielId = konserveId,
+ })
+ end
+
+ Kern.Loese('blut.ausgegeben', konserveId, patientId, mitarbeiter)
+
+ return { ok = true, vertraeglich = passt, abgelaufen = abgelaufen }
+end)
+
+-- =====================================================================
+-- VERWERFEN
+--
+-- Auch abgelaufene Konserven verschwinden nicht von selbst. Ob eine
+-- Konserve noch brauchbar ist, ist eine fachliche Beurteilung —
+-- keine Datumsrechnung.
+-- =====================================================================
+
+Kern.RegistriereAktion('blutbank', 'verwerfen', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local konserveId = tonumber(daten.id)
+ if not konserveId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund or #grund < 3 then
+ return nil, 'Das Verwerfen einer Konserve erfordert eine Begründung.'
+ end
+
+ local eintrag = LadeKonserve(konserveId)
+ if not eintrag then return nil, 'Konserve nicht gefunden.' end
+
+ if not DarfWechseln(eintrag.status, 'verworfen') then
+ return nil, ('Eine Konserve im Status "%s" lässt sich nicht verwerfen.')
+ :format(eintrag.status)
+ end
+
+ local warReserviert = eintrag.status == 'reserviert'
+
+ MySQL.update.await("UPDATE medical_blut SET status = 'verworfen' WHERE id = ?",
+ { konserveId })
+
+ Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve verworfen',
+ { typ = 'blut', id = konserveId },
+ { konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe,
+ vorherigerStatus = eintrag.status,
+ verfaellt = eintrag.verfaellt_am }, grund)
+
+ -- Eine verworfene Reservierung darf niemandem entgehen
+ if warReserviert and eintrag.reserviert_fuer then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'blut', prioritaet = 'hoch',
+ titel = 'Reservierte Konserve verworfen',
+ text = ('%s war für %s %s zurückgelegt.')
+ :format(eintrag.konserve, eintrag.firstname or '', eintrag.lastname or ''),
+ zielTyp = 'blut', zielId = konserveId,
+ })
+ end
+
+ Kern.Loese('blut.verworfen', konserveId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- LAGERORT
+-- =====================================================================
+
+Kern.RegistriereAktion('blutbank', 'umlagern', 'blut.verwalten', function(quelle, mitarbeiter, daten)
+ local konserveId = tonumber(daten.id)
+ if not konserveId then return nil, 'Ungültige Kennung.' end
+
+ local lagerort = Lagerort(daten.lagerort)
+ if not lagerort then return nil, 'Unbekannter Lagerort.' end
+
+ local eintrag = LadeKonserve(konserveId)
+ if not eintrag then return nil, 'Konserve nicht gefunden.' end
+ if eintrag.status ~= 'vorraetig' and eintrag.status ~= 'reserviert' then
+ return nil, 'Diese Konserve liegt nicht mehr im Lager.'
+ end
+
+ MySQL.update.await('UPDATE medical_blut SET lagerort = ? WHERE id = ?',
+ { lagerort, konserveId })
+
+ Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve umgelagert',
+ { typ = 'blut', id = konserveId },
+ { konserve = eintrag.konserve, von = eintrag.lagerort, nach = lagerort },
+ Text(daten.vermerk, 255))
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert den Bestand je Blutgruppe.
+--- Reine Auskunft — keine Ressource kann darüber eine Konserve
+--- reservieren, ausgeben oder verwerfen.
+--- @return table Liste { blutgruppe, vorraetig, reserviert }
+exports('HoleBlutbestand', function()
+ local liste = {}
+
+ for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
+ liste[#liste + 1] = {
+ blutgruppe = gruppe,
+ vorraetig = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE blutgruppe = ? AND status = 'vorraetig'
+ ]], { gruppe })) or 0,
+ reserviert = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE blutgruppe = ? AND status = 'reserviert'
+ ]], { gruppe })) or 0,
+ }
+ end
+
+ return liste
+end)
diff --git a/resources/[aether]/aether-medical/server/module/dashboard.lua b/resources/[aether]/aether-medical/server/module/dashboard.lua
new file mode 100644
index 0000000..e67d715
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/dashboard.lua
@@ -0,0 +1,226 @@
+-- =====================================================================
+-- aether-medical / Modul: Übersicht (Dashboard)
+--
+-- Zeigt den Stand der Klinik. Jede Zahl wird bei jedem Aufruf frisch
+-- aus der Datenbank gelesen — es gibt keine fest verdrahteten Werte
+-- und keine Kennzahl, die eine Handlung auslöst.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'dashboard', name = 'Übersicht', icon = '⬛',
+ reihenfolge = 1,
+ beschreibung = 'Lage der Klinik auf einen Blick',
+})
+
+--- Zählt und liefert immer eine Zahl zurück
+--- @param sql string Abfrage
+--- @param werte table|nil Parameter
+--- @return number
+local function Zaehle(sql, werte)
+ return tonumber(MySQL.scalar.await(sql, werte or {})) or 0
+end
+
+Kern.RegistriereAktion('dashboard', 'laden', nil, function(quelle, mitarbeiter)
+ local charakterId = mitarbeiter.charakterId
+
+ -- --- Dienst ---
+ local imDienst, notaerzte, aufRtw, imOp = 0, 0, 0, 0
+ local leitstelleName = nil
+
+ for zielQuelle, eintrag in pairs(Dienst.AlleImDienst()) do
+ imDienst = imDienst + 1
+ if eintrag.funktion == 'notarzt' then notaerzte = notaerzte + 1 end
+ if eintrag.funktion == 'rtw' then aufRtw = aufRtw + 1 end
+ if eintrag.funktion == 'op' then imOp = imOp + 1 end
+ if Dienst.Leitstelle() == zielQuelle then leitstelleName = eintrag.name end
+ end
+
+ -- --- Einsätze ---
+ local einsaetze = {
+ offen = Zaehle("SELECT COUNT(*) FROM medical_einsatz WHERE status = 'offen'"),
+ laufend = Zaehle("SELECT COUNT(*) FROM medical_einsatz WHERE status = 'laufend'"),
+ dringend = Zaehle([[
+ SELECT COUNT(*) FROM medical_einsatz
+ WHERE status IN ('offen', 'laufend') AND prioritaet <= 2
+ ]]),
+ }
+
+ -- Die jüngsten offenen Einsätze für die Kurzliste
+ einsaetze.liste = MySQL.query.await([[
+ SELECT id, nummer, titel, kategorie, prioritaet, ort, status, erstellt_am
+ FROM medical_einsatz
+ WHERE status IN ('offen', 'laufend')
+ ORDER BY prioritaet, id DESC
+ LIMIT 6
+ ]]) or {}
+
+ -- --- Patienten ---
+ local patienten = {
+ wartend = Zaehle("SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'wartend'"),
+ aufgenommen = Zaehle("SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'aufgenommen'"),
+ intensiv = Zaehle('SELECT COUNT(*) FROM medical_intensiv WHERE beendet_am IS NULL'),
+ kritisch = Zaehle([[
+ SELECT COUNT(*) FROM medical_intensiv
+ WHERE beendet_am IS NULL AND zustand = 'kritisch'
+ ]]),
+ }
+
+ -- Die längste Wartezeit in der Notaufnahme
+ patienten.laengsteWartezeit = Zaehle([[
+ SELECT COALESCE(MAX(TIMESTAMPDIFF(MINUTE, aufgenommen_am, NOW())), 0)
+ FROM medical_aufnahme WHERE status = 'wartend'
+ ]])
+
+ -- Wartende Patienten für die Kurzliste
+ patienten.warteliste = MySQL.query.await([[
+ SELECT a.id, a.patient, a.grund, a.prioritaet,
+ TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet,
+ c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.status = 'wartend'
+ ORDER BY a.prioritaet, a.aufgenommen_am
+ LIMIT 6
+ ]]) or {}
+
+ -- --- Belegung ---
+ local bettenGesamt = Zaehle([[
+ SELECT COALESCE(SUM(betten), 0) FROM medical_zimmer
+ WHERE art = 'bett' AND status <> 'gesperrt'
+ ]])
+ local bettenBelegt = Zaehle([[
+ SELECT COUNT(*) FROM medical_belegung b
+ JOIN medical_zimmer z ON z.id = b.zimmer
+ WHERE b.frei_am IS NULL AND z.art = 'bett'
+ ]])
+
+ local belegung = {
+ bettenGesamt = bettenGesamt,
+ bettenBelegt = bettenBelegt,
+ bettenFrei = math.max(0, bettenGesamt - bettenBelegt),
+ behandlungFrei = Zaehle([[
+ SELECT COUNT(*) FROM medical_zimmer
+ WHERE art = 'behandlung' AND status = 'frei'
+ ]]),
+ behandlungGesamt = Zaehle("SELECT COUNT(*) FROM medical_zimmer WHERE art = 'behandlung'"),
+ opSaeleGesamt = Zaehle("SELECT COUNT(*) FROM medical_zimmer WHERE art = 'op'"),
+ opLaufend = Zaehle("SELECT COUNT(*) FROM medical_operation WHERE status = 'laufend'"),
+ opGeplant = Zaehle([[
+ SELECT COUNT(*) FROM medical_operation
+ WHERE status = 'geplant' AND geplant_fuer >= NOW()
+ ]]),
+ }
+
+ -- --- Offene Arbeit ---
+ local offen = {
+ dokumentationen = Zaehle("SELECT COUNT(*) FROM medical_behandlung WHERE status = 'laufend'"),
+ eigeneDoku = Zaehle([[
+ SELECT COUNT(*) FROM medical_behandlung
+ WHERE status = 'laufend' AND erstellt_von = ?
+ ]], { charakterId }),
+ laborOffen = Zaehle([[
+ SELECT COUNT(*) FROM medical_labor WHERE status IN ('offen', 'in_arbeit')
+ ]]),
+ laborUnbefundet = Zaehle([[
+ SELECT COUNT(*) FROM medical_labor WHERE status = 'fertig' AND befund IS NULL
+ ]]),
+ berichteEntwurf = Zaehle([[
+ SELECT COUNT(*) FROM medical_bericht
+ WHERE status = 'entwurf' AND erstellt_von = ?
+ ]], { charakterId }),
+ }
+
+ -- --- Kritische Warnungen ---
+ -- Bewusst nur Bestands- und Fristenlage. Kein Patientenzustand,
+ -- den das System selbst bewertet hätte.
+ local warnungen = {}
+
+ local leereMedikamente = Zaehle([[
+ SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND bestand <= 0
+ ]])
+ if leereMedikamente > 0 then
+ warnungen[#warnungen + 1] = {
+ art = 'kritisch', titel = 'Medikamente nicht vorrätig',
+ text = ('%d Positionen sind auf null.'):format(leereMedikamente),
+ ziel = 'medikament',
+ }
+ end
+
+ local knappeMedikamente = Zaehle([[
+ SELECT COUNT(*) FROM medical_medikament
+ WHERE aktiv = 1 AND bestand > 0 AND bestand <= mindestbestand
+ ]])
+ if knappeMedikamente > 0 then
+ warnungen[#warnungen + 1] = {
+ art = 'warn', titel = 'Medikamente unter Mindestbestand',
+ text = ('%d Positionen sollten nachbestellt werden.'):format(knappeMedikamente),
+ ziel = 'medikament',
+ }
+ end
+
+ local ablaufendesBlut = Zaehle([[
+ SELECT COUNT(*) FROM medical_blut
+ WHERE status IN ('vorraetig', 'reserviert') AND verfaellt_am IS NOT NULL
+ AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
+ ]], { Medizin.Config.Erinnerungen.blutVorlaufTage })
+ if ablaufendesBlut > 0 then
+ warnungen[#warnungen + 1] = {
+ art = 'warn', titel = 'Blutkonserven laufen ab',
+ text = ('%d Konserven erreichen bald ihr Ablaufdatum.'):format(ablaufendesBlut),
+ ziel = 'blutbank',
+ }
+ end
+
+ if patienten.kritisch > 0 then
+ warnungen[#warnungen + 1] = {
+ art = 'kritisch', titel = 'Kritische Intensivpatienten',
+ text = ('%d Patienten sind als kritisch eingetragen.'):format(patienten.kritisch),
+ ziel = 'intensiv',
+ }
+ end
+
+ if einsaetze.offen > 0 and imDienst == 0 then
+ warnungen[#warnungen + 1] = {
+ art = 'kritisch', titel = 'Offene Einsätze ohne Besetzung',
+ text = 'Es ist niemand im Dienst.',
+ ziel = 'leitstelle',
+ }
+ end
+
+ if not leitstelleName and einsaetze.offen > 0 then
+ warnungen[#warnungen + 1] = {
+ art = 'warn', titel = 'Leitstelle unbesetzt',
+ text = 'Offene Einsätze werden zurzeit von niemandem verteilt.',
+ ziel = 'leitstelle',
+ }
+ end
+
+ return {
+ mitarbeiter = {
+ name = mitarbeiter.name, rolleName = mitarbeiter.rolleName,
+ station = mitarbeiter.station, funktion = mitarbeiter.funktion,
+ },
+ dienst = {
+ imDienst = imDienst, notaerzte = notaerzte,
+ aufRtw = aufRtw, imOp = imOp,
+ leitstelle = leitstelleName,
+ liste = Dienst.BaueListe(),
+ },
+ einsaetze = einsaetze,
+ patienten = patienten,
+ belegung = belegung,
+ offen = offen,
+ warnungen = warnungen,
+ hinweise = Hinweise.Lade(charakterId, 15),
+ stationen = Medizin.Config.Stationen,
+ }
+end)
+
+--- Markiert einen Hinweis als gelesen
+Kern.RegistriereAktion('dashboard', 'hinweisGelesen', nil, function(quelle, mitarbeiter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ Hinweise.MarkiereGelesen(mitarbeiter.charakterId, id)
+ return { ok = true }
+end)
diff --git a/resources/[aether]/aether-medical/server/module/einsatz.lua b/resources/[aether]/aether-medical/server/module/einsatz.lua
new file mode 100644
index 0000000..508acf0
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/einsatz.lua
@@ -0,0 +1,633 @@
+-- =====================================================================
+-- aether-medical / Modul: Leitstelle und Einsatzverwaltung
+--
+-- Ein Notruf erzeugt einen OFFENEN Einsatz. Mehr nicht.
+--
+-- Das System alarmiert niemanden gezielt, schickt keine Einheit los
+-- und schliesst keinen Einsatz ab. Die Leitstelle entscheidet, wen sie
+-- anfragt — und selbst eine Anfrage ist nur eine Bitte: die Kraft muss
+-- dem Einsatz weiterhin selbst beitreten.
+--
+-- Der Status „laufend" ist keine Entscheidung, sondern die Aufzeichnung
+-- einer Tatsache: es ist jemand gebunden. Abgeschlossen wird immer
+-- von Hand.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'einsatz', name = 'Leitstelle', icon = '🚑',
+ reihenfolge = 4, recht = 'einsatz.erstellen',
+ beschreibung = 'Notrufe, Einsätze und Disposition',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Prüft eine Prioritätsstufe
+local function Prioritaet(wert)
+ local stufe = tonumber(wert) or 3
+ if stufe < 1 or stufe > 4 then return 3 end
+ return math.floor(stufe)
+end
+
+--- Prüft eine Einsatzkategorie
+--- @param kennung any Eingabe
+--- @return string
+local function Kategorie(kennung)
+ for _, eintrag in ipairs(Medizin.Config.Einsatzkategorien) do
+ if eintrag.id == kennung then return eintrag.id end
+ end
+ return 'sonstiges'
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+local function NameVon(charakterId)
+ if not charakterId then return 'System' end
+ local zeilen = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.personalnummer
+ FROM aether_characters c
+ LEFT JOIN medical_personal p ON p.`character` = c.id
+ WHERE c.id = ?
+ ]], { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Wie viele Kräfte sind einem Einsatz gerade zugeordnet?
+--- @param einsatzId number Einsatz
+--- @return number
+local function AnzahlKraefte(einsatzId)
+ return tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_einsatz_kraft WHERE einsatz = ? AND bis IS NULL',
+ { einsatzId })) or 0
+end
+
+--- Hält den Status im Einklang mit der Tatsache, ob jemand gebunden ist.
+--- Das ist Buchführung, keine Entscheidung: ein abgeschlossener Einsatz
+--- wird hier nie wieder geöffnet.
+--- @param einsatzId number Einsatz
+local function StatusNachKraeften(einsatzId)
+ local zeilen = MySQL.query.await(
+ 'SELECT status FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not zeilen or not zeilen[1] then return end
+ if zeilen[1].status == 'abgeschlossen' then return end
+
+ local neu = AnzahlKraefte(einsatzId) > 0 and 'laufend' or 'offen'
+ if neu ~= zeilen[1].status then
+ MySQL.update.await('UPDATE medical_einsatz SET status = ? WHERE id = ?',
+ { neu, einsatzId })
+ end
+end
+
+--- Meldet allen im Dienst, dass sich etwas geändert hat
+local function Melden()
+ for quelle in pairs(Dienst.AlleImDienst()) do
+ TriggerClientEvent('aether:medical:einsatzAenderung', quelle)
+ end
+end
+
+-- =====================================================================
+-- EINGANG
+--
+-- Einziger Weg, auf dem ein Einsatz von aussen entsteht. Global, damit
+-- server/main.lua und andere Ressourcen ihn erreichen.
+-- =====================================================================
+
+--- Nimmt einen Notfall entgegen und legt ihn als offenen Einsatz ab
+--- @param daten table { titel, kategorie?, prioritaet?, ort?, beschreibung?, position?, quelle? }
+--- @return number|nil Einsatz-ID
+function EinsatzEingang(daten)
+ if type(daten) ~= 'table' or not daten.titel then return nil end
+
+ local nummer = Kern.ErzeugeKennung('EIN', 'medical_einsatz', 'nummer')
+ local position = daten.position or {}
+ local prioritaet = Prioritaet(daten.prioritaet)
+ local kategorie = Kategorie(daten.kategorie)
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_einsatz
+ (nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z,
+ beschreibung, status, quelle, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', ?, ?)
+ ]], {
+ nummer,
+ tostring(daten.titel):sub(1, 160),
+ kategorie, prioritaet,
+ daten.ort and tostring(daten.ort):sub(1, 160) or nil,
+ position.x, position.y, position.z,
+ daten.beschreibung and tostring(daten.beschreibung):sub(1, 4000) or nil,
+ daten.quelle or 'system',
+ daten.erstelltVon,
+ })
+
+ -- Alle im Dienst erfahren davon. Ausgewählt wird niemand.
+ Hinweise.Erstelle(nil, {
+ kategorie = 'einsatz',
+ prioritaet = prioritaet == 1 and 'kritisch' or prioritaet == 2 and 'hoch' or 'normal',
+ titel = ('Neuer Einsatz · %s'):format(nummer),
+ text = ('%s%s'):format(daten.titel,
+ daten.ort and (' — ' .. daten.ort) or ''),
+ zielTyp = 'einsatz', zielId = id,
+ })
+
+ Kern.Loese('einsatz.eingegangen', id, kategorie)
+ Melden()
+
+ return id
+end
+
+-- ---------------------------------------------------------------------
+-- Notruf aus dem Verletztenzustand (aether-death)
+--
+-- Ein am Boden liegender Spieler setzt einen Notruf ab. Daraus wird
+-- ein offener Einsatz — niemand wird alarmiert oder hingeschickt.
+-- ---------------------------------------------------------------------
+AddEventHandler('aether:death:notruf', function(quelle, daten)
+ if type(daten) ~= 'table' then return end
+
+ EinsatzEingang({
+ titel = 'Bewusstlose Person',
+ kategorie = 'bewusstlos',
+ prioritaet = 1,
+ ort = 'Position des Anrufers',
+ beschreibung = ('%s liegt verletzt am Boden und hat den Rettungsdienst gerufen.')
+ :format(daten.name or 'Eine Person'),
+ position = daten.position,
+ quelle = 'notruf',
+ })
+end)
+
+-- =====================================================================
+-- ÜBERSICHT UND LEITSTELLE
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'aktuell')
+
+ local bedingung
+ if ordner == 'archiv' then
+ bedingung = "e.status = 'abgeschlossen'"
+ else
+ bedingung = "e.status IN ('offen', 'laufend')"
+ end
+
+ local einsaetze = MySQL.query.await(([[
+ SELECT e.id, e.nummer, e.titel, e.kategorie, e.prioritaet, e.ort,
+ e.pos_x, e.pos_y, e.pos_z, e.status, e.quelle,
+ e.erstellt_am, e.erstellt_von, e.geschlossen_am, e.geschlossen_von,
+ TIMESTAMPDIFF(MINUTE, e.erstellt_am, NOW()) AS offenSeit,
+ (SELECT COUNT(*) FROM medical_einsatz_kraft k
+ WHERE k.einsatz = e.id AND k.bis IS NULL) AS kraefte,
+ (SELECT COUNT(*) FROM medical_einsatz_kraft k
+ WHERE k.einsatz = e.id AND k.bis IS NULL AND k.`character` = ?) AS ichDabei
+ FROM medical_einsatz e
+ WHERE %s
+ ORDER BY e.prioritaet, e.id DESC
+ LIMIT 120
+ ]]):format(bedingung), { mitarbeiter.charakterId }) or {}
+
+ local kategorieNamen, kategorieFarben = {}, {}
+ for _, eintrag in ipairs(Medizin.Config.Einsatzkategorien) do
+ kategorieNamen[eintrag.id] = eintrag.name
+ kategorieFarben[eintrag.id] = eintrag.farbe
+ end
+
+ for _, einsatz in ipairs(einsaetze) do
+ einsatz.kategorieName = kategorieNamen[einsatz.kategorie] or einsatz.kategorie
+ einsatz.farbe = kategorieFarben[einsatz.kategorie]
+ einsatz.kraefte = tonumber(einsatz.kraefte) or 0
+ einsatz.ichDabei = tonumber(einsatz.ichDabei) > 0
+ einsatz.erstelltVon = NameVon(einsatz.erstellt_von)
+ einsatz.geschlossenVon = einsatz.geschlossen_von
+ and NameVon(einsatz.geschlossen_von) or nil
+ einsatz.erstellt_von, einsatz.geschlossen_von = nil, nil
+ end
+
+ -- Verfügbare Kräfte für die Disposition
+ local kraefte = {}
+ for zielQuelle, eintrag in pairs(Dienst.AlleImDienst()) do
+ -- Woran ist diese Kraft gerade gebunden?
+ local gebunden = MySQL.query.await([[
+ SELECT e.id, e.nummer FROM medical_einsatz_kraft k
+ JOIN medical_einsatz e ON e.id = k.einsatz
+ WHERE k.`character` = ? AND k.bis IS NULL AND e.status <> 'abgeschlossen'
+ LIMIT 1
+ ]], { eintrag.charakterId })
+
+ kraefte[#kraefte + 1] = {
+ quelle = zielQuelle,
+ charakterId = eintrag.charakterId,
+ name = eintrag.name,
+ personalnummer = eintrag.personalnummer,
+ rolleName = eintrag.rolleName,
+ station = eintrag.station,
+ funktion = eintrag.funktion,
+ status = eintrag.status,
+ leitstelle = Dienst.Leitstelle() == zielQuelle,
+ gebundenAn = gebunden and gebunden[1] and gebunden[1].nummer or nil,
+ gebundenId = gebunden and gebunden[1] and gebunden[1].id or nil,
+ }
+ end
+
+ table.sort(kraefte, function(a, b) return a.personalnummer < b.personalnummer end)
+
+ return {
+ ordner = ordner,
+ einsaetze = einsaetze,
+ kraefte = kraefte,
+ kategorien = Medizin.Config.Einsatzkategorien,
+ status = Medizin.Config.Dienststatus,
+ funktionen = Medizin.Config.Dienstfunktionen,
+ istLeitstelle = Dienst.Leitstelle() == quelle,
+ leitstelleName = (function()
+ local leit = Dienst.Leitstelle()
+ local eintrag = leit and Dienst.Hole(leit)
+ return eintrag and eintrag.name or nil
+ end)(),
+ zaehler = {
+ offen = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_einsatz WHERE status = 'offen'")) or 0,
+ laufend = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_einsatz WHERE status = 'laufend'")) or 0,
+ heute = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_einsatz
+ WHERE status = 'abgeschlossen' AND DATE(geschlossen_am) = CURDATE()
+ ]])) or 0,
+ },
+ rechte = {
+ erstellen = Rechte.Hat(mitarbeiter, 'einsatz.erstellen'),
+ zuweisen = Rechte.Hat(mitarbeiter, 'einsatz.zuweisen'),
+ schliessen = Rechte.Hat(mitarbeiter, 'einsatz.schliessen'),
+ leitstelle = Rechte.Hat(mitarbeiter, 'dienst.leitstelle'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('einsatz', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await('SELECT * FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end
+
+ local einsatz = zeilen[1]
+
+ local kategorieName = einsatz.kategorie
+ for _, eintrag in ipairs(Medizin.Config.Einsatzkategorien) do
+ if eintrag.id == einsatz.kategorie then kategorieName = eintrag.name end
+ end
+
+ -- Beteiligte Kräfte, auch die bereits abgerückten
+ local kraefte = MySQL.query.await([[
+ SELECT k.`character`, k.seit, k.bis,
+ c.firstname, c.lastname, p.personalnummer, p.rolle
+ FROM medical_einsatz_kraft k
+ LEFT JOIN aether_characters c ON c.id = k.`character`
+ LEFT JOIN medical_personal p ON p.`character` = k.`character`
+ WHERE k.einsatz = ?
+ ORDER BY k.seit
+ ]], { einsatzId }) or {}
+
+ local ichDabei = false
+ for _, kraft in ipairs(kraefte) do
+ kraft.name = (kraft.firstname or 'Unbekannt') .. ' ' .. (kraft.lastname or '')
+ local rolle = Medizin.HoleRolle(kraft.rolle)
+ kraft.rolleName = rolle and rolle.name or kraft.rolle
+ kraft.aktiv = kraft.bis == nil
+ kraft.firstname, kraft.lastname = nil, nil
+ if kraft.aktiv and kraft.character == mitarbeiter.charakterId then ichDabei = true end
+ end
+
+ -- Notizen zum Einsatzverlauf
+ local notizen = MySQL.query.await([[
+ SELECT n.id, n.text, n.am, n.von, c.firstname, c.lastname
+ FROM medical_einsatz_notiz n
+ LEFT JOIN aether_characters c ON c.id = n.von
+ WHERE n.einsatz = ?
+ ORDER BY n.id DESC LIMIT 60
+ ]], { einsatzId }) or {}
+
+ for _, notiz in ipairs(notizen) do
+ notiz.autor = (notiz.firstname or 'Unbekannt') .. ' ' .. (notiz.lastname or '')
+ notiz.firstname, notiz.lastname = nil, nil
+ end
+
+ -- Dokumentation, die auf diesen Einsatz verweist
+ local behandlungen = MySQL.query.await([[
+ SELECT b.id, b.kennung, b.status, b.diagnose, b.erstellt_am,
+ c.firstname, c.lastname
+ FROM medical_behandlung b
+ LEFT JOIN aether_characters c ON c.id = b.patient
+ WHERE b.einsatz = ?
+ ORDER BY b.id DESC LIMIT 20
+ ]], { einsatzId }) or {}
+
+ for _, behandlung in ipairs(behandlungen) do
+ behandlung.patientName = (behandlung.firstname or 'Unbekannt')
+ .. ' ' .. (behandlung.lastname or '')
+ behandlung.firstname, behandlung.lastname = nil, nil
+ end
+
+ return {
+ einsatz = {
+ id = einsatz.id, nummer = einsatz.nummer, titel = einsatz.titel,
+ kategorie = einsatz.kategorie, kategorieName = kategorieName,
+ prioritaet = einsatz.prioritaet, ort = einsatz.ort,
+ beschreibung = einsatz.beschreibung, status = einsatz.status,
+ quelle = einsatz.quelle,
+ position = einsatz.pos_x and
+ { x = einsatz.pos_x, y = einsatz.pos_y, z = einsatz.pos_z } or nil,
+ erstelltAm = einsatz.erstellt_am,
+ erstelltVon = NameVon(einsatz.erstellt_von),
+ geschlossenAm = einsatz.geschlossen_am,
+ geschlossenVon = einsatz.geschlossen_von
+ and NameVon(einsatz.geschlossen_von) or nil,
+ },
+ kraefte = kraefte,
+ notizen = notizen,
+ behandlungen = behandlungen,
+ ichDabei = ichDabei,
+ rechte = {
+ zuweisen = Rechte.Hat(mitarbeiter, 'einsatz.zuweisen'),
+ schliessen = Rechte.Hat(mitarbeiter, 'einsatz.schliessen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINSATZ ANLEGEN
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'erstellen', 'einsatz.erstellen', function(quelle, mitarbeiter, daten)
+ local titel = Text(daten.titel, 160)
+ if not titel or #titel < 3 then return nil, 'Bitte einen Einsatztitel angeben.' end
+
+ local id = EinsatzEingang({
+ titel = titel,
+ kategorie = daten.kategorie,
+ prioritaet = daten.prioritaet,
+ ort = Text(daten.ort, 160),
+ beschreibung = Text(daten.beschreibung, 4000),
+ position = daten.position,
+ quelle = 'manuell',
+ erstelltVon = mitarbeiter.charakterId,
+ })
+
+ if not id then return nil, 'Der Einsatz konnte nicht angelegt werden.' end
+
+ Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz angelegt',
+ { typ = 'einsatz', id = id },
+ { kategorie = Kategorie(daten.kategorie), prioritaet = Prioritaet(daten.prioritaet) })
+
+ return { id = id }
+end)
+
+-- =====================================================================
+-- BEITRETEN UND VERLASSEN
+--
+-- Jede Kraft entscheidet selbst. Auch eine Anfrage der Leitstelle
+-- bindet niemanden — sie muss angenommen werden.
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'beitreten', nil, function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, nummer, titel, status FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end
+ if zeilen[1].status == 'abgeschlossen' then
+ return nil, 'Dieser Einsatz ist bereits abgeschlossen.'
+ end
+
+ -- Erneutes Beitreten nach dem Abrücken ist erlaubt
+ MySQL.query.await([[
+ INSERT INTO medical_einsatz_kraft (einsatz, `character`) VALUES (?, ?)
+ ON DUPLICATE KEY UPDATE bis = NULL, seit = NOW()
+ ]], { einsatzId, mitarbeiter.charakterId })
+
+ StatusNachKraeften(einsatzId)
+
+ Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz übernommen',
+ { typ = 'einsatz', id = einsatzId }, { nummer = zeilen[1].nummer })
+
+ Kern.Loese('einsatz.beigetreten', einsatzId, mitarbeiter)
+ Melden()
+
+ return { ok = true, position = true }
+end)
+
+Kern.RegistriereAktion('einsatz', 'verlassen', nil, function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ MySQL.update.await([[
+ UPDATE medical_einsatz_kraft SET bis = NOW()
+ WHERE einsatz = ? AND `character` = ? AND bis IS NULL
+ ]], { einsatzId, mitarbeiter.charakterId })
+
+ StatusNachKraeften(einsatzId)
+
+ Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz verlassen',
+ { typ = 'einsatz', id = einsatzId }, nil, Text(daten.grund, 255))
+
+ Kern.Loese('einsatz.verlassen', einsatzId, mitarbeiter)
+ Melden()
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- DISPOSITION
+--
+-- Die Leitstelle FRAGT eine Kraft an. Sie zwingt niemanden — der
+-- Einsatz erscheint beim Angefragten mit Wegpunkt, beitreten muss
+-- er selbst.
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'anfragen', 'einsatz.zuweisen', function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ local zielQuelle = tonumber(daten.quelle)
+
+ if not einsatzId or not zielQuelle then return nil, 'Unvollständige Angaben.' end
+
+ local ziel = Dienst.Hole(zielQuelle)
+ if not ziel then return nil, 'Diese Kraft ist nicht im Dienst.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, nummer, titel, ort, pos_x, pos_y, pos_z, status FROM medical_einsatz WHERE id = ?',
+ { einsatzId })
+ if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end
+
+ local einsatz = zeilen[1]
+ if einsatz.status == 'abgeschlossen' then
+ return nil, 'Dieser Einsatz ist bereits abgeschlossen.'
+ end
+
+ -- Bereits dabei? Dann ist eine Anfrage sinnlos
+ local dabei = MySQL.scalar.await([[
+ SELECT 1 FROM medical_einsatz_kraft
+ WHERE einsatz = ? AND `character` = ? AND bis IS NULL
+ ]], { einsatzId, ziel.charakterId })
+ if dabei then return nil, 'Diese Kraft ist dem Einsatz bereits zugeordnet.' end
+
+ TriggerClientEvent('aether:medical:einsatzZuweisung', zielQuelle, {
+ id = einsatz.id,
+ nummer = einsatz.nummer,
+ titel = einsatz.titel,
+ ort = einsatz.ort,
+ von = mitarbeiter.name,
+ position = einsatz.pos_x and
+ { x = einsatz.pos_x, y = einsatz.pos_y, z = einsatz.pos_z } or nil,
+ })
+
+ Hinweise.Erstelle({ charakterId = ziel.charakterId }, {
+ kategorie = 'einsatz', prioritaet = 'hoch',
+ titel = ('Anfrage der Leitstelle · %s'):format(einsatz.nummer),
+ text = ('%s bittet dich um Übernahme: %s%s')
+ :format(mitarbeiter.name, einsatz.titel,
+ einsatz.ort and (' — ' .. einsatz.ort) or ''),
+ zielTyp = 'einsatz', zielId = einsatz.id,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Kraft angefragt',
+ { typ = 'einsatz', id = einsatzId },
+ { nummer = einsatz.nummer, ziel = ziel.personalnummer })
+
+ return { ok = true, name = ziel.name }
+end)
+
+-- =====================================================================
+-- VERLAUF UND ABSCHLUSS
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'notiz', nil, function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ local text = Text(daten.text, 1000)
+
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+ if not text or #text < 2 then return nil, 'Die Notiz ist zu kurz.' end
+
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT id FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not vorhanden then return nil, 'Einsatz nicht gefunden.' end
+
+ local id = MySQL.insert.await(
+ 'INSERT INTO medical_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)',
+ { einsatzId, text, mitarbeiter.charakterId })
+
+ Melden()
+
+ return { id = id }
+end)
+
+--- Priorität nachträglich ändern — immer mit Begründung
+Kern.RegistriereAktion('einsatz', 'prioritaet', 'einsatz.zuweisen', function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, nummer, prioritaet, status FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end
+ if zeilen[1].status == 'abgeschlossen' then
+ return nil, 'Dieser Einsatz ist abgeschlossen.'
+ end
+
+ local neu = Prioritaet(daten.prioritaet)
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte begründen, warum sich die Priorität ändert.' end
+
+ MySQL.update.await('UPDATE medical_einsatz SET prioritaet = ? WHERE id = ?',
+ { neu, einsatzId })
+
+ Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Priorität geändert',
+ { typ = 'einsatz', id = einsatzId },
+ { nummer = zeilen[1].nummer, von = zeilen[1].prioritaet, nach = neu }, grund)
+
+ Melden()
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('einsatz', 'schliessen', 'einsatz.schliessen', function(quelle, mitarbeiter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local vermerk = Text(daten.vermerk, 4000)
+ if not vermerk or #vermerk < 5 then
+ return nil, 'Ein Abschluss erfordert einen Abschlussvermerk.'
+ end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, nummer, titel, status FROM medical_einsatz WHERE id = ?', { einsatzId })
+ if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end
+
+ local einsatz = zeilen[1]
+ if einsatz.status == 'abgeschlossen' then
+ return nil, 'Dieser Einsatz ist bereits abgeschlossen.'
+ end
+
+ -- Der Abschlussvermerk wird als Notiz Teil des Verlaufs
+ MySQL.insert.await(
+ 'INSERT INTO medical_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)',
+ { einsatzId, 'Abschluss: ' .. vermerk, mitarbeiter.charakterId })
+
+ -- Noch gebundene Kräfte werden abgemeldet — der Einsatz ist vorbei
+ MySQL.update.await(
+ 'UPDATE medical_einsatz_kraft SET bis = NOW() WHERE einsatz = ? AND bis IS NULL',
+ { einsatzId })
+
+ MySQL.update.await([[
+ UPDATE medical_einsatz
+ SET status = 'abgeschlossen', geschlossen_von = ?, geschlossen_am = NOW()
+ WHERE id = ?
+ ]], { mitarbeiter.charakterId, einsatzId })
+
+ -- Gibt es schon eine Dokumentation zu diesem Einsatz?
+ local dokumentiert = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_behandlung WHERE einsatz = ?', { einsatzId })) or 0
+
+ Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz abgeschlossen',
+ { typ = 'einsatz', id = einsatzId },
+ { nummer = einsatz.nummer, dokumentationen = dokumentiert }, vermerk)
+
+ -- Erinnern, nicht anlegen: das System erzeugt keine Behandlungsdoku
+ if dokumentiert == 0 then
+ Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, {
+ kategorie = 'doku', prioritaet = 'normal',
+ titel = 'Dokumentation fehlt',
+ text = ('Zu %s (%s) ist noch keine Behandlung dokumentiert.')
+ :format(einsatz.nummer, einsatz.titel),
+ zielTyp = 'einsatz', zielId = einsatzId,
+ })
+ end
+
+ Kern.Loese('einsatz.abgeschlossen', einsatzId, mitarbeiter)
+ Melden()
+
+ return { ok = true, dokumentiert = dokumentiert }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die offenen und laufenden Einsätze
+--- @return table
+exports('HoleOffeneEinsaetze', function()
+ return MySQL.query.await([[
+ SELECT id, nummer, titel, kategorie, prioritaet, ort, status,
+ pos_x, pos_y, pos_z, erstellt_am
+ FROM medical_einsatz
+ WHERE status IN ('offen', 'laufend')
+ ORDER BY prioritaet, id DESC
+ ]]) or {}
+end)
diff --git a/resources/[aether]/aether-medical/server/module/fahrzeug.lua b/resources/[aether]/aether-medical/server/module/fahrzeug.lua
new file mode 100644
index 0000000..9d486d6
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/fahrzeug.lua
@@ -0,0 +1,651 @@
+-- =====================================================================
+-- aether-medical / Modul: Fuhrpark
+--
+-- Ein Fahrzeugbuch. Es fährt nichts, gibt nichts aus und ruft keine
+-- Werkstatt.
+--
+-- Zwei Sorten von Angaben stehen hier nebeneinander, und sie werden
+-- streng auseinandergehalten:
+--
+-- * GEMELDET — Tankstand, Zustand und Position kommen aus dem Spiel,
+-- während jemand fährt. Das sind Tatsachen, keine Entscheidungen.
+-- Sie werden nur festgeschrieben und mit ihrem Alter angezeigt.
+--
+-- * EINGETRAGEN — Einsatzstatus, Wartungsstatus und Zuweisung setzt
+-- ausschliesslich ein Mensch. Kein gemeldeter Wert ändert sie je:
+-- ein leerer Tank stellt kein Fahrzeug ausser Dienst, und ein
+-- Schaden schickt keines in die Werkstatt.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'fahrzeug', name = 'Fuhrpark', icon = '🚐',
+ reihenfolge = 12, recht = 'fahrzeug.sehen',
+ beschreibung = 'Fahrzeuge, Status, Zuweisung und Wartung',
+})
+
+-- ---------------------------------------------------------------------
+-- Grundbestand
+--
+-- Damit sich überhaupt etwas zuweisen lässt. Die Pflege übernimmt
+-- danach die Fuhrparkverwaltung.
+-- ---------------------------------------------------------------------
+local GRUNDFAHRZEUGE = {
+ -- kennzeichen, modell, bezeichnung, art, station, ausstattung
+ { 'LSMC 001', 'ambulance', 'RTW 1', 'rtw', 'notaufnahme',
+ 'Defibrillator, Beatmungsgerät, Notfallrucksack, Trage, Sauerstoff' },
+ { 'LSMC 002', 'ambulance', 'RTW 2', 'rtw', 'notaufnahme',
+ 'Defibrillator, Notfallrucksack, Trage, Sauerstoff' },
+ { 'LSMC 010', 'ambulance', 'NEF 1', 'nef', 'notaufnahme',
+ 'Notfallrucksack, Notfallmedikamente, Perfusor' },
+ { 'LSMC 020', 'ambulance', 'KTW 1', 'ktw', 'rettung',
+ 'Trage, Sauerstoff' },
+}
+
+CreateThread(function()
+ Wait(2500)
+
+ local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_fahrzeug')) or 0
+ if vorhanden > 0 then return end
+
+ for _, eintrag in ipairs(GRUNDFAHRZEUGE) do
+ MySQL.insert.await([[
+ INSERT INTO medical_fahrzeug
+ (kennzeichen, modell, bezeichnung, art, station, ausstattung)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4], eintrag[5], eintrag[6] })
+ end
+
+ Aether.Debug('Grundbestand an Fahrzeugen angelegt.')
+end)
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Charakters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Prüft eine Kennung gegen eine Konfigurationsliste
+--- @param liste table Liste aus Medizin.Config
+--- @param wert any Eingabe
+--- @return string|nil Gültige Kennung
+local function AusListe(liste, wert)
+ local gesucht = tostring(wert or '')
+ for _, eintrag in ipairs(liste) do
+ if eintrag.id == gesucht then return eintrag.id end
+ end
+ return nil
+end
+
+--- Bringt ein Kennzeichen auf eine vergleichbare Schreibweise.
+--- Das Spiel liefert Kennzeichen mit Leerzeichen aufgefüllt.
+--- @param wert any Eingabe
+--- @return string|nil
+local function Kennzeichen(wert)
+ local sauber = Text(wert, 12)
+ if not sauber then return nil end
+ return sauber:upper()
+end
+
+--- Anzeigename aus einer Konfigurationsliste
+--- @param liste table Liste aus Medizin.Config
+--- @param kennung string Kennung
+--- @return string
+local function NameAusListe(liste, kennung)
+ for _, eintrag in ipairs(liste) do
+ if eintrag.id == kennung then return eintrag.name end
+ end
+ return tostring(kennung)
+end
+
+--- Lädt ein Fahrzeug
+--- @param fahrzeugId number Fahrzeug
+--- @return table|nil
+local function LadeFahrzeug(fahrzeugId)
+ local zeilen = MySQL.query.await([[
+ SELECT f.*,
+ TIMESTAMPDIFF(MINUTE, f.gemeldet_am, NOW()) AS meldungAlter
+ FROM medical_fahrzeug f
+ WHERE f.id = ?
+ ]], { fahrzeugId })
+ return zeilen and zeilen[1] or nil
+end
+
+--- Bereitet die gemeldeten Werte für die Anzeige auf.
+--- Wichtig ist dabei nicht der Wert allein, sondern sein Alter: eine
+--- alte Meldung sagt nichts über den jetzigen Zustand.
+--- @param eintrag table Fahrzeugzeile
+local function BereiteMeldung(eintrag)
+ eintrag.tank = eintrag.tank and math.floor(tonumber(eintrag.tank) + 0.5) or nil
+ eintrag.zustand = eintrag.zustand and math.floor(tonumber(eintrag.zustand) + 0.5) or nil
+ eintrag.meldungAlter = eintrag.gemeldet_am and (tonumber(eintrag.meldungAlter) or 0) or nil
+ eintrag.meldungVeraltet = eintrag.meldungAlter ~= nil
+ and eintrag.meldungAlter > Medizin.Config.Erinnerungen.fahrzeugMeldungAltNach
+
+ eintrag.letzterFahrer = eintrag.letzter_fahrer
+ and NameVon(eintrag.letzter_fahrer) or nil
+ eintrag.zugewiesenAn = eintrag.zugewiesen_an
+ and NameVon(eintrag.zugewiesen_an) or nil
+
+ eintrag.artName = NameAusListe(Medizin.Config.Fahrzeugarten, eintrag.art)
+ eintrag.statusName = NameAusListe(Medizin.Config.Fahrzeugstatus, eintrag.status)
+ eintrag.wartungName = NameAusListe(Medizin.Config.Wartungsstatus, eintrag.wartung)
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('fahrzeug', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'dienst')
+
+ -- Welche Status gehören in diesen Ordner?
+ local bedingung, werte = nil, {}
+ if ordner == 'werkstatt' then
+ bedingung = "f.status = 'werkstatt' OR f.wartung <> 'ok'"
+ elseif ordner == 'ausser' then
+ bedingung = "f.status = 'ausser_dienst'"
+ else
+ bedingung = "f.status IN ('verfuegbar', 'im_einsatz')"
+ end
+
+ if daten.art and daten.art ~= '' then
+ bedingung = ('(%s) AND f.art = ?'):format(bedingung)
+ werte[#werte + 1] = tostring(daten.art)
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT f.*,
+ TIMESTAMPDIFF(MINUTE, f.gemeldet_am, NOW()) AS meldungAlter
+ FROM medical_fahrzeug f
+ WHERE %s
+ ORDER BY f.art, f.bezeichnung
+ LIMIT 200
+ ]]):format(bedingung), werte) or {}
+
+ for _, eintrag in ipairs(liste) do
+ BereiteMeldung(eintrag)
+ -- Rohdaten der Position bleiben dem Client erspart, ausser als Wegpunkt
+ eintrag.hatPosition = eintrag.pos_x ~= nil and eintrag.pos_y ~= nil
+ end
+
+ local function Zaehle(sql, parameter)
+ return tonumber(MySQL.scalar.await(sql, parameter)) or 0
+ end
+
+ local zaehler = {
+ verfuegbar = Zaehle("SELECT COUNT(*) FROM medical_fahrzeug WHERE status = 'verfuegbar'"),
+ imEinsatz = Zaehle("SELECT COUNT(*) FROM medical_fahrzeug WHERE status = 'im_einsatz'"),
+ werkstatt = Zaehle([[
+ SELECT COUNT(*) FROM medical_fahrzeug
+ WHERE status = 'werkstatt' OR wartung <> 'ok'
+ ]]),
+ ausserDienst = Zaehle(
+ "SELECT COUNT(*) FROM medical_fahrzeug WHERE status = 'ausser_dienst'"),
+ zugewiesen = Zaehle(
+ 'SELECT COUNT(*) FROM medical_fahrzeug WHERE zugewiesen_an IS NOT NULL'),
+ }
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ zaehler = zaehler,
+ arten = Medizin.Config.Fahrzeugarten,
+ status = Medizin.Config.Fahrzeugstatus,
+ wartungsstatus = Medizin.Config.Wartungsstatus,
+ stationen = Medizin.Config.Stationen,
+ ausstattung = Medizin.Config.Fahrzeugausstattung,
+ grenzen = {
+ tank = Medizin.Config.Erinnerungen.fahrzeugTankUnter,
+ zustand = Medizin.Config.Erinnerungen.fahrzeugZustandUnter,
+ meldung = Medizin.Config.Erinnerungen.fahrzeugMeldungAltNach,
+ },
+ rechte = {
+ zuweisen = Rechte.Hat(mitarbeiter, 'fahrzeug.zuweisen'),
+ wartung = Rechte.Hat(mitarbeiter, 'fahrzeug.wartung'),
+ pflegen = Rechte.Hat(mitarbeiter, 'fahrzeug.pflegen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINZELNES FAHRZEUG
+-- =====================================================================
+
+Kern.RegistriereAktion('fahrzeug', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeFahrzeug(fahrzeugId)
+ if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end
+
+ BereiteMeldung(eintrag)
+
+ -- Der Weg des Fahrzeugs steht im Protokoll und wird nur gelesen
+ local spur = Protokoll.Lies({ zielTyp = 'fahrzeug', zielId = fahrzeugId, limit = 40 })
+
+ for _, zeile in ipairs(spur) do
+ zeile.autor = zeile.firstname
+ and (zeile.firstname .. ' ' .. zeile.lastname) or 'System'
+ zeile.firstname, zeile.lastname, zeile.details = nil, nil, nil
+ end
+
+ return {
+ fahrzeug = {
+ id = eintrag.id, kennzeichen = eintrag.kennzeichen,
+ modell = eintrag.modell, bezeichnung = eintrag.bezeichnung,
+ art = eintrag.art, artName = eintrag.artName,
+ station = eintrag.station,
+ status = eintrag.status, statusName = eintrag.statusName,
+ wartung = eintrag.wartung, wartungName = eintrag.wartungName,
+ ausstattung = eintrag.ausstattung, notiz = eintrag.notiz,
+ tank = eintrag.tank, zustand = eintrag.zustand,
+ gemeldetAm = eintrag.gemeldet_am,
+ meldungAlter = eintrag.meldungAlter,
+ meldungVeraltet = eintrag.meldungVeraltet,
+ letzterFahrer = eintrag.letzterFahrer,
+ zugewiesen = eintrag.zugewiesen_an,
+ zugewiesenAn = eintrag.zugewiesenAn,
+ position = (eintrag.pos_x and eintrag.pos_y)
+ and { x = eintrag.pos_x, y = eintrag.pos_y, z = eintrag.pos_z } or nil,
+ },
+ spur = spur,
+ arten = Medizin.Config.Fahrzeugarten,
+ status = Medizin.Config.Fahrzeugstatus,
+ wartungsstatus = Medizin.Config.Wartungsstatus,
+ stationen = Medizin.Config.Stationen,
+ ausstattung = Medizin.Config.Fahrzeugausstattung,
+ rechte = {
+ zuweisen = Rechte.Hat(mitarbeiter, 'fahrzeug.zuweisen'),
+ wartung = Rechte.Hat(mitarbeiter, 'fahrzeug.wartung'),
+ pflegen = Rechte.Hat(mitarbeiter, 'fahrzeug.pflegen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINSATZSTATUS
+--
+-- Wird immer eingetragen. Weder Telemetrie noch ein laufender Einsatz
+-- setzt ihn — auch ein Fahrzeug mit leerem Tank bleibt "verfügbar",
+-- bis jemand etwas anderes einträgt.
+-- =====================================================================
+
+Kern.RegistriereAktion('fahrzeug', 'status', 'fahrzeug.zuweisen', function(quelle, mitarbeiter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local neu = AusListe(Medizin.Config.Fahrzeugstatus, daten.status)
+ if not neu then return nil, 'Unbekannter Fahrzeugstatus.' end
+
+ local eintrag = LadeFahrzeug(fahrzeugId)
+ if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end
+
+ -- Ein Fahrzeug aus dem Dienst zu nehmen ist Sache der Wartung
+ if (neu == 'werkstatt' or neu == 'ausser_dienst')
+ and not Rechte.Hat(mitarbeiter, 'fahrzeug.wartung') then
+ return nil, 'Fahrzeuge aus dem Dienst nehmen darf nur, wer die Wartung führt.'
+ end
+
+ local grund = Text(daten.grund, 255)
+ if (neu == 'werkstatt' or neu == 'ausser_dienst') and not grund then
+ return nil, 'Bitte angeben, warum das Fahrzeug nicht mehr fährt.'
+ end
+
+ MySQL.update.await('UPDATE medical_fahrzeug SET status = ? WHERE id = ?',
+ { neu, fahrzeugId })
+
+ Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeugstatus gesetzt',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = eintrag.kennzeichen, bezeichnung = eintrag.bezeichnung,
+ von = eintrag.status, nach = neu }, grund)
+
+ -- Ein Fahrzeug, das jemandem zugewiesen war, geht nicht stillschweigend
+ if (neu == 'werkstatt' or neu == 'ausser_dienst') and eintrag.zugewiesen_an then
+ Hinweise.Erstelle({ charakterId = eintrag.zugewiesen_an }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = 'Dein Fahrzeug wurde aus dem Dienst genommen',
+ text = ('%s (%s) — %s.'):format(eintrag.bezeichnung, eintrag.kennzeichen,
+ grund or 'ohne weitere Angabe'),
+ zielTyp = 'fahrzeug', zielId = fahrzeugId,
+ })
+ end
+
+ Kern.Loese('fahrzeug.status', fahrzeugId, neu, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZUWEISUNG
+--
+-- Ein Fahrzeug wird jemandem zugeteilt oder freigegeben. Das öffnet
+-- keine Tür, gibt keinen Schlüssel aus und spawnt nichts.
+-- =====================================================================
+
+--- Wer ist gerade im Dienst?
+Kern.RegistriereAktion('fahrzeug', 'kollegen', 'fahrzeug.zuweisen', function(quelle, mitarbeiter, daten)
+ local liste = {}
+
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ liste[#liste + 1] = {
+ charakterId = eintrag.charakterId,
+ name = eintrag.name,
+ rolleName = eintrag.rolleName,
+ personalnummer = eintrag.personalnummer,
+ funktion = eintrag.funktion,
+ status = eintrag.status,
+ selbst = eintrag.charakterId == mitarbeiter.charakterId,
+ }
+ end
+
+ table.sort(liste, function(a, b)
+ return tostring(a.personalnummer) < tostring(b.personalnummer)
+ end)
+
+ return { liste = liste }
+end)
+
+Kern.RegistriereAktion('fahrzeug', 'zuweisen', 'fahrzeug.zuweisen', function(quelle, mitarbeiter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeFahrzeug(fahrzeugId)
+ if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end
+
+ -- Ohne Ziel wird die Zuweisung aufgehoben
+ local zielId = tonumber(daten.charakter)
+
+ if zielId then
+ if eintrag.status == 'werkstatt' or eintrag.status == 'ausser_dienst' then
+ return nil, ('%s steht auf "%s" und lässt sich nicht zuweisen.')
+ :format(eintrag.bezeichnung, eintrag.statusName or eintrag.status)
+ end
+
+ local zielQuelle = Dienst.QuelleVonCharakter(zielId)
+ if not zielQuelle or not Dienst.Hole(zielQuelle) then
+ return nil, 'Diese Person ist nicht im Dienst.'
+ end
+
+ -- Ein Fahrzeug gehört immer nur einer Person
+ if eintrag.zugewiesen_an and eintrag.zugewiesen_an ~= zielId then
+ local vorher = NameVon(eintrag.zugewiesen_an)
+ if not Text(daten.grund, 255) then
+ return nil, ('%s ist bereits %s zugewiesen — bitte einen Grund angeben.')
+ :format(eintrag.bezeichnung, vorher)
+ end
+ end
+ else
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte angeben, warum die Zuweisung entfällt.' end
+ end
+
+ MySQL.update.await('UPDATE medical_fahrzeug SET zugewiesen_an = ? WHERE id = ?',
+ { zielId, fahrzeugId })
+
+ Protokoll.Schreibe(mitarbeiter, 'fahrzeug',
+ zielId and 'Fahrzeug zugewiesen' or 'Zuweisung aufgehoben',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = eintrag.kennzeichen, bezeichnung = eintrag.bezeichnung,
+ vorher = eintrag.zugewiesen_an, nachher = zielId },
+ Text(daten.grund, 255))
+
+ -- Die betroffene Person erfährt es unmittelbar
+ if zielId and zielId ~= mitarbeiter.charakterId then
+ Hinweise.Erstelle({ charakterId = zielId }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = 'Dir wurde ein Fahrzeug zugewiesen',
+ text = ('%s (%s) — von %s.'):format(eintrag.bezeichnung,
+ eintrag.kennzeichen, mitarbeiter.name),
+ zielTyp = 'fahrzeug', zielId = fahrzeugId,
+ })
+ end
+
+ -- Wer es vorher hatte, ebenfalls
+ if eintrag.zugewiesen_an and eintrag.zugewiesen_an ~= zielId
+ and eintrag.zugewiesen_an ~= mitarbeiter.charakterId then
+ Hinweise.Erstelle({ charakterId = eintrag.zugewiesen_an }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = 'Deine Fahrzeugzuweisung wurde geändert',
+ text = ('%s (%s) ist dir nicht mehr zugewiesen.')
+ :format(eintrag.bezeichnung, eintrag.kennzeichen),
+ zielTyp = 'fahrzeug', zielId = fahrzeugId,
+ })
+ end
+
+ Kern.Loese('fahrzeug.zugewiesen', fahrzeugId, zielId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- WARTUNG
+--
+-- Der Wartungsstatus ist eine Beurteilung. Ein gemeldeter Schaden
+-- setzt ihn nicht — er erinnert nur daran, hinzusehen.
+-- =====================================================================
+
+Kern.RegistriereAktion('fahrzeug', 'wartung', 'fahrzeug.wartung', function(quelle, mitarbeiter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local neu = AusListe(Medizin.Config.Wartungsstatus, daten.wartung)
+ if not neu then return nil, 'Unbekannter Wartungsstatus.' end
+
+ local eintrag = LadeFahrzeug(fahrzeugId)
+ if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end
+
+ -- Ein leeres Feld löscht die Notiz, ein fehlendes lässt sie stehen
+ local notiz = eintrag.notiz
+ if daten.notiz ~= nil then notiz = Text(daten.notiz, 255) end
+
+ MySQL.update.await('UPDATE medical_fahrzeug SET wartung = ?, notiz = ? WHERE id = ?',
+ { neu, notiz, fahrzeugId })
+
+ Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Wartungsstatus gesetzt',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = eintrag.kennzeichen, von = eintrag.wartung, nach = neu }, notiz)
+
+ -- Fällige Wartung meldet das Haus, damit niemand ahnungslos losfährt
+ if neu ~= 'ok' and eintrag.wartung == 'ok' then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = ('Wartung eingetragen · %s'):format(eintrag.bezeichnung),
+ text = ('%s (%s) — %s.'):format(eintrag.bezeichnung, eintrag.kennzeichen,
+ NameAusListe(Medizin.Config.Wartungsstatus, neu)),
+ zielTyp = 'fahrzeug', zielId = fahrzeugId,
+ })
+ end
+
+ Kern.Loese('fahrzeug.wartung', fahrzeugId, neu, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- FUHRPARK PFLEGEN
+-- =====================================================================
+
+Kern.RegistriereAktion('fahrzeug', 'speichern', 'fahrzeug.pflegen', function(quelle, mitarbeiter, daten)
+ local fahrzeugId = tonumber(daten.id)
+
+ local kennzeichen = Kennzeichen(daten.kennzeichen)
+ local modell = Text(daten.modell, 48)
+ local bezeichnung = Text(daten.bezeichnung, 64)
+ local art = AusListe(Medizin.Config.Fahrzeugarten, daten.art)
+
+ if not kennzeichen then return nil, 'Bitte ein Kennzeichen angeben.' end
+ if not modell then return nil, 'Bitte das Fahrzeugmodell angeben.' end
+ if not bezeichnung then return nil, 'Bitte eine Bezeichnung angeben, z. B. RTW 1.' end
+ if not art then return nil, 'Unbekannte Fahrzeugart.' end
+
+ -- Kennzeichen müssen eindeutig bleiben: die Telemetrie findet das
+ -- Fahrzeug ausschliesslich darüber wieder.
+ local kollision = MySQL.scalar.await(
+ 'SELECT id FROM medical_fahrzeug WHERE kennzeichen = ? AND id <> ?',
+ { kennzeichen, fahrzeugId or 0 })
+ if kollision then
+ return nil, ('Das Kennzeichen %s ist bereits vergeben.'):format(kennzeichen)
+ end
+
+ -- Eine Station ist freiwillig, muss aber eine bekannte sein
+ local station = nil
+ if Text(daten.station, 32) then
+ station = AusListe(Medizin.Config.Stationen, daten.station)
+ if not station then return nil, 'Unbekannte Station.' end
+ end
+
+ local ausstattung = Text(daten.ausstattung, 512)
+
+ if fahrzeugId then
+ local vorher = LadeFahrzeug(fahrzeugId)
+ if not vorher then return nil, 'Fahrzeug nicht gefunden.' end
+
+ MySQL.update.await([[
+ UPDATE medical_fahrzeug
+ SET kennzeichen = ?, modell = ?, bezeichnung = ?, art = ?,
+ station = ?, ausstattung = ?
+ WHERE id = ?
+ ]], { kennzeichen, modell, bezeichnung, art, station, ausstattung, fahrzeugId })
+
+ Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeug bearbeitet',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = kennzeichen, bezeichnung = bezeichnung, art = art,
+ vorherigesKennzeichen = vorher.kennzeichen })
+ else
+ fahrzeugId = MySQL.insert.await([[
+ INSERT INTO medical_fahrzeug
+ (kennzeichen, modell, bezeichnung, art, station, ausstattung)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ]], { kennzeichen, modell, bezeichnung, art, station, ausstattung })
+
+ Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeug aufgenommen',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = kennzeichen, bezeichnung = bezeichnung, art = art })
+ end
+
+ Kern.Loese('fahrzeug.geaendert', fahrzeugId, mitarbeiter)
+
+ return { id = fahrzeugId }
+end)
+
+--- Entfernt ein Fahrzeug endgültig.
+--- Möglich erst, wenn es ausser Dienst steht — ausmustern ist ein
+--- eigener Schritt und keine Nebenwirkung des Löschens.
+Kern.RegistriereAktion('fahrzeug', 'entfernen', 'fahrzeug.pflegen', function(quelle, mitarbeiter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte einen Grund angeben.' end
+
+ local eintrag = LadeFahrzeug(fahrzeugId)
+ if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end
+
+ if eintrag.status ~= 'ausser_dienst' then
+ return nil, 'Nur ein Fahrzeug außer Dienst lässt sich aus dem Fuhrpark nehmen.'
+ end
+
+ MySQL.query.await('DELETE FROM medical_fahrzeug WHERE id = ?', { fahrzeugId })
+
+ Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeug aus dem Fuhrpark genommen',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = eintrag.kennzeichen, bezeichnung = eintrag.bezeichnung }, grund)
+
+ Kern.Loese('fahrzeug.geaendert', fahrzeugId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- TELEMETRIE
+--
+-- Der Client meldet, was das Spiel hergibt, solange jemand fährt.
+-- Hier landen ausschliesslich Tatsachen — kein gemeldeter Wert ändert
+-- Status, Wartung oder Zuweisung. Wer daraus etwas ableitet, ist ein
+-- Mensch.
+-- =====================================================================
+
+RegisterNetEvent('aether:medical:fahrzeugMelden', function(meldung)
+ local quelle = source
+
+ -- Nur wer im Dienst ist, meldet für den Fuhrpark
+ local mitarbeiter = Dienst.Hole(quelle)
+ if not mitarbeiter then return end
+ if type(meldung) ~= 'table' then return end
+
+ local kennzeichen = Kennzeichen(meldung.kennzeichen)
+ if not kennzeichen then return end
+
+ -- Fremde Fahrzeuge werden stillschweigend verworfen
+ local fahrzeug = MySQL.query.await(
+ 'SELECT id FROM medical_fahrzeug WHERE kennzeichen = ?', { kennzeichen })
+ if not fahrzeug or not fahrzeug[1] then return end
+
+ --- Bringt einen gemeldeten Wert auf 0 bis 100
+ --- @param wert any Rohwert aus dem Spiel
+ --- @param spanne number Wertebereich, den das Spiel liefert
+ --- @return number|nil
+ local function Prozent(wert, spanne)
+ local zahl = tonumber(wert)
+ if not zahl then return nil end
+ local anteil = (zahl / spanne) * 100
+ return math.max(0, math.min(100, anteil))
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_fahrzeug
+ SET tank = ?, zustand = ?, pos_x = ?, pos_y = ?, pos_z = ?,
+ letzter_fahrer = ?, gemeldet_am = NOW()
+ WHERE id = ?
+ ]], {
+ Prozent(meldung.tank, 100), -- Tankstand kommt bereits als Prozent
+ Prozent(meldung.zustand, 1000), -- Karosseriezustand reicht von 0 bis 1000
+ tonumber(meldung.x), tonumber(meldung.y), tonumber(meldung.z),
+ mitarbeiter.charakterId,
+ fahrzeug[1].id,
+ })
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert den Fuhrpark als Auskunft.
+--- Reine Anzeige — keine Ressource kann darüber ein Fahrzeug zuweisen,
+--- seinen Status setzen oder es in die Werkstatt schicken.
+--- @param nurVerfuegbar boolean|nil Nur einsatzbereite Fahrzeuge
+--- @return table Liste
+exports('HoleFuhrpark', function(nurVerfuegbar)
+ local bedingung = nurVerfuegbar
+ and "WHERE status = 'verfuegbar' AND wartung = 'ok'" or ''
+
+ local zeilen = MySQL.query.await(([[
+ SELECT kennzeichen, bezeichnung, art, station, status, wartung,
+ tank, zustand, gemeldet_am
+ FROM medical_fahrzeug
+ %s
+ ORDER BY art, bezeichnung
+ ]]):format(bedingung)) or {}
+
+ return zeilen
+end)
diff --git a/resources/[aether]/aether-medical/server/module/intensiv.lua b/resources/[aether]/aether-medical/server/module/intensiv.lua
new file mode 100644
index 0000000..bb1fdf0
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/intensiv.lua
@@ -0,0 +1,694 @@
+-- =====================================================================
+-- aether-medical / Modul: Intensivstation
+--
+-- Ein Intensivaufenthalt ist hier eine Akte, kein Monitor.
+--
+-- Das System misst nichts. Es kennt keinen Blutdruck, keine Sättigung
+-- und keinen Alarm. Der Zustand eines Patienten steht genau deshalb
+-- im Datensatz, weil ein Mediziner ihn dort eingetragen hat — er
+-- ändert sich nie von selbst, weder durch Zeitablauf noch durch
+-- irgendeine Rechnung.
+--
+-- Auch Geräte werden nur benannt, nicht gesteuert. Und ein Aufenthalt
+-- endet erst, wenn jemand ihn beendet: nicht mit der Entlassung,
+-- nicht mit dem Bett und nicht mit dem Dienstende.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'intensiv', name = 'Intensiv', icon = '📈',
+ reihenfolge = 10, recht = 'intensiv.sehen',
+ beschreibung = 'Intensivaufenthalte, Zustand, Geräte und Verlauf',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Prüft einen Zustand gegen die Konfiguration
+--- @param wert any Eingabe
+--- @return string|nil Gültige Kennung
+local function Zustand(wert)
+ local gesucht = tostring(wert or '')
+ for _, eintrag in ipairs(Medizin.Config.Intensivzustaende) do
+ if eintrag.id == gesucht then return eintrag.id end
+ end
+ return nil
+end
+
+--- Anzeigename eines Zustands
+--- @param kennung string Zustand
+--- @return string
+local function ZustandName(kennung)
+ for _, eintrag in ipairs(Medizin.Config.Intensivzustaende) do
+ if eintrag.id == kennung then return eintrag.name end
+ end
+ return tostring(kennung)
+end
+
+--- Baut die Sortierreihenfolge der Zustände für MySQL.
+--- Sortiert wird nach dem, was Menschen eingetragen haben — das System
+--- bewertet dabei nichts, es stellt nur das Eingetragene nach oben.
+--- @return string FIELD(...)-Ausdruck
+local function ZustandSortierung()
+ local teile = {}
+ for _, eintrag in ipairs(Medizin.Config.Intensivzustaende) do
+ -- Die Kennungen stammen aus der eigenen Konfiguration
+ teile[#teile + 1] = ("'%s'"):format(eintrag.id:gsub("'", ''))
+ end
+ return ('FIELD(i.zustand, %s)'):format(table.concat(teile, ', '))
+end
+
+--- Lädt einen Intensivaufenthalt mit allen Begleitdaten
+--- @param intensivId number Aufenthalt
+--- @return table|nil Zeile
+local function LadeAufenthalt(intensivId)
+ local zeilen = MySQL.query.await([[
+ SELECT i.*,
+ TIMESTAMPDIFF(MINUTE, i.aufgenommen_am,
+ COALESCE(i.beendet_am, NOW())) AS liegtSeit,
+ c.firstname, c.lastname, c.dob,
+ z.kennung AS zimmerKennung, z.art AS zimmerArt,
+ s.name AS stationName,
+ a.status AS aufnahmeStatus, a.grund AS aufnahmeGrund
+ FROM medical_intensiv i
+ JOIN aether_characters c ON c.id = i.patient
+ LEFT JOIN medical_zimmer z ON z.id = i.zimmer
+ LEFT JOIN medical_station s ON s.id = z.station
+ LEFT JOIN medical_aufnahme a ON a.id = i.aufnahme
+ WHERE i.id = ?
+ ]], { intensivId })
+
+ return zeilen and zeilen[1] or nil
+end
+
+--- Schreibt einen Verlaufseintrag.
+--- Der Text stammt immer von einem Menschen — das System formuliert
+--- hier nichts und fasst nichts zusammen.
+--- @param intensivId number Aufenthalt
+--- @param text string Wortlaut
+--- @param charakterId number Verfasser
+--- @return number Eintrag
+local function SchreibeVerlauf(intensivId, text, charakterId)
+ return MySQL.insert.await(
+ 'INSERT INTO medical_intensiv_verlauf (intensiv, text, von) VALUES (?, ?, ?)',
+ { intensivId, text, charakterId })
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('intensiv', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'laufend')
+ local beendet = ordner == 'beendet'
+
+ local liste = MySQL.query.await(([[
+ SELECT i.id, i.patient, i.aufnahme, i.zimmer, i.zustand, i.geraete, i.notiz,
+ i.verantwortlich, i.aufgenommen_am, i.aufgenommen_von,
+ i.beendet_am, i.beendet_von,
+ TIMESTAMPDIFF(MINUTE, i.aufgenommen_am,
+ COALESCE(i.beendet_am, NOW())) AS liegtSeit,
+ TIMESTAMPDIFF(MINUTE, COALESCE(
+ (SELECT MAX(v.am) FROM medical_intensiv_verlauf v WHERE v.intensiv = i.id),
+ i.aufgenommen_am), NOW()) AS stillSeit,
+ (SELECT COUNT(*) FROM medical_intensiv_verlauf v
+ WHERE v.intensiv = i.id) AS eintraege,
+ c.firstname, c.lastname, c.dob,
+ p.blutgruppe, p.allergien, p.hinweis AS warnhinweis,
+ z.kennung AS zimmerKennung, s.name AS stationName
+ FROM medical_intensiv i
+ JOIN aether_characters c ON c.id = i.patient
+ LEFT JOIN medical_patient p ON p.`character` = i.patient
+ LEFT JOIN medical_zimmer z ON z.id = i.zimmer
+ LEFT JOIN medical_station s ON s.id = z.station
+ WHERE i.beendet_am IS %s NULL
+ ORDER BY %s
+ LIMIT 100
+ ]]):format(beendet and 'NOT' or '',
+ beendet and 'i.beendet_am DESC' or (ZustandSortierung() .. ', i.aufgenommen_am'))) or {}
+
+ local stille = Medizin.Config.Erinnerungen.intensivStilleNach
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
+ eintrag.zustandName = ZustandName(eintrag.zustand)
+ eintrag.liegtSeit = tonumber(eintrag.liegtSeit) or 0
+ eintrag.stillSeit = tonumber(eintrag.stillSeit) or 0
+ eintrag.eintraege = tonumber(eintrag.eintraege) or 0
+ eintrag.verantwortlichName = eintrag.verantwortlich
+ and NameVon(eintrag.verantwortlich) or nil
+ eintrag.aufgenommenVon = NameVon(eintrag.aufgenommen_von)
+ eintrag.beendetVon = eintrag.beendet_von and NameVon(eintrag.beendet_von) or nil
+ -- Nur eine Erinnerung, keine Bewertung der Betreuung
+ eintrag.langeStill = not beendet and eintrag.stillSeit >= stille
+ eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
+ eintrag.aufgenommen_von, eintrag.beendet_von = nil, nil
+ end
+
+ -- Zählwerte über die laufenden Aufenthalte
+ local zaehler = {
+ laufend = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_intensiv WHERE beendet_am IS NULL')) or 0,
+ kritisch = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_intensiv
+ WHERE beendet_am IS NULL AND zustand = 'kritisch'
+ ]])) or 0,
+ ohneArzt = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_intensiv
+ WHERE beendet_am IS NULL AND verantwortlich IS NULL
+ ]])) or 0,
+ beendetHeute = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_intensiv
+ WHERE beendet_am IS NOT NULL AND DATE(beendet_am) = CURDATE()
+ ]])) or 0,
+ }
+
+ -- Wie viele laufende Aufenthalte haben lange keinen Eintrag bekommen?
+ zaehler.langeStill = tonumber(MySQL.scalar.await(([[
+ SELECT COUNT(*) FROM medical_intensiv i
+ WHERE i.beendet_am IS NULL
+ AND COALESCE(
+ (SELECT MAX(v.am) FROM medical_intensiv_verlauf v WHERE v.intensiv = i.id),
+ i.aufgenommen_am) < DATE_SUB(NOW(), INTERVAL ? MINUTE)
+ ]]), { stille })) or 0
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ zaehler = zaehler,
+ zustaende = Medizin.Config.Intensivzustaende,
+ stilleNach = stille,
+ rechte = {
+ betreuen = Rechte.Hat(mitarbeiter, 'intensiv.betreuen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINZELANSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('intensiv', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local intensivId = tonumber(daten.id)
+ if not intensivId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeAufenthalt(intensivId)
+ if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end
+
+ -- Stammdaten, die am Bett gebraucht werden
+ local stamm = MySQL.query.await([[
+ SELECT blutgruppe, allergien, vorerkrankungen, dauermedikation,
+ hinweis, notfallkontakt, lichtbild
+ FROM medical_patient WHERE `character` = ?
+ ]], { eintrag.patient })
+ stamm = stamm and stamm[1] or {}
+
+ -- Der Verlauf — jüngster Eintrag zuerst
+ local verlauf = MySQL.query.await([[
+ SELECT v.id, v.text, v.am, v.von, c.firstname, c.lastname
+ FROM medical_intensiv_verlauf v
+ LEFT JOIN aether_characters c ON c.id = v.von
+ WHERE v.intensiv = ?
+ ORDER BY v.id DESC
+ LIMIT 100
+ ]], { intensivId }) or {}
+
+ for _, zeile in ipairs(verlauf) do
+ zeile.autor = zeile.firstname and (zeile.firstname .. ' ' .. zeile.lastname) or 'unbekannt'
+ zeile.firstname, zeile.lastname, zeile.von = nil, nil, nil
+ end
+
+ return {
+ aufenthalt = {
+ id = eintrag.id, patient = eintrag.patient, aufnahme = eintrag.aufnahme,
+ name = eintrag.firstname .. ' ' .. eintrag.lastname,
+ geburt = tostring(eintrag.dob):sub(1, 10),
+ zustand = eintrag.zustand, zustandName = ZustandName(eintrag.zustand),
+ geraete = eintrag.geraete, notiz = eintrag.notiz,
+ zimmer = eintrag.zimmer, zimmerKennung = eintrag.zimmerKennung,
+ zimmerArt = eintrag.zimmerArt, stationName = eintrag.stationName,
+ aufnahmeStatus = eintrag.aufnahmeStatus,
+ aufnahmeGrund = eintrag.aufnahmeGrund,
+ liegtSeit = tonumber(eintrag.liegtSeit) or 0,
+ verantwortlich = eintrag.verantwortlich,
+ verantwortlichName = eintrag.verantwortlich
+ and NameVon(eintrag.verantwortlich) or nil,
+ aufgenommenAm = eintrag.aufgenommen_am,
+ aufgenommenVon = NameVon(eintrag.aufgenommen_von),
+ beendetAm = eintrag.beendet_am,
+ beendetVon = eintrag.beendet_von and NameVon(eintrag.beendet_von) or nil,
+ laufend = eintrag.beendet_am == nil,
+ },
+ stamm = stamm,
+ verlauf = verlauf,
+ zustaende = Medizin.Config.Intensivzustaende,
+ geraeteliste = Medizin.Config.Intensivgeraete,
+ rechte = {
+ betreuen = Rechte.Hat(mitarbeiter, 'intensiv.betreuen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- AUFNAHME AUF DIE INTENSIVSTATION
+--
+-- Wer auf Intensiv kommt, entscheidet ein Arzt. Kein Zustand, keine
+-- Diagnose und kein Wert bringt jemanden von selbst hierher.
+-- =====================================================================
+
+--- Aufgenommene Patienten ohne laufenden Intensivaufenthalt
+--- sowie die Zimmer, in denen eine Betreuung stattfinden könnte.
+Kern.RegistriereAktion('intensiv', 'kandidaten', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local patienten = MySQL.query.await([[
+ SELECT a.id AS aufnahme, a.patient, a.grund, a.prioritaet, a.station,
+ TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS imHausSeit,
+ c.firstname, c.lastname, c.dob,
+ p.blutgruppe, p.allergien, p.hinweis AS warnhinweis,
+ z.id AS zimmer, z.kennung AS zimmerKennung,
+ s.name AS stationName
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ LEFT JOIN medical_patient p ON p.`character` = a.patient
+ LEFT JOIN medical_zimmer z ON z.id = a.zimmer
+ LEFT JOIN medical_station s ON s.id = z.station
+ WHERE a.status = 'aufgenommen'
+ AND NOT EXISTS (
+ SELECT 1 FROM medical_intensiv i
+ WHERE i.patient = a.patient AND i.beendet_am IS NULL)
+ ORDER BY a.prioritaet, a.aufgenommen_am
+ LIMIT 100
+ ]]) or {}
+
+ for _, eintrag in ipairs(patienten) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
+ eintrag.imHausSeit = tonumber(eintrag.imHausSeit) or 0
+ eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
+ end
+
+ -- Intensivplätze zur Auswahl. Das Bett selbst verwaltet die
+ -- Stationsverwaltung — hier wird nur festgehalten, wo betreut wird.
+ local zimmer = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.art, z.status, z.betten, z.hinweis,
+ s.name AS stationName,
+ (SELECT COUNT(*) FROM medical_belegung b
+ WHERE b.zimmer = z.id AND b.frei_am IS NULL) AS belegt
+ FROM medical_zimmer z
+ JOIN medical_station s ON s.id = z.station
+ WHERE s.aktiv = 1 AND z.art IN ('intensiv', 'isolierung')
+ ORDER BY s.sortierung, z.kennung
+ ]]) or {}
+
+ for _, eintrag in ipairs(zimmer) do
+ eintrag.belegt = tonumber(eintrag.belegt) or 0
+ eintrag.betten = tonumber(eintrag.betten) or 1
+ end
+
+ return { patienten = patienten, zimmer = zimmer }
+end)
+
+Kern.RegistriereAktion('intensiv', 'aufnehmen', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.aufnahme)
+ if not aufnahmeId then return nil, 'Kein Patient gewählt.' end
+
+ local zustand = Zustand(daten.zustand)
+ if not zustand then return nil, 'Bitte einen Zustand eintragen.' end
+
+ local notiz = Text(daten.notiz, 1000)
+ if not notiz or #notiz < 5 then
+ return nil, 'Bitte festhalten, warum der Patient auf die Intensivstation kommt.'
+ end
+
+ local aufnahmen = MySQL.query.await([[
+ SELECT a.id, a.patient, a.status, a.zimmer, c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.id = ?
+ ]], { aufnahmeId })
+ if not aufnahmen or not aufnahmen[1] then return nil, 'Aufnahme nicht gefunden.' end
+
+ local aufnahme = aufnahmen[1]
+ if aufnahme.status ~= 'aufgenommen' then
+ return nil, 'Nur aufgenommene Patienten lassen sich auf die Intensivstation legen.'
+ end
+
+ -- Zwei laufende Aufenthalte für denselben Patienten ergeben keinen Sinn
+ local laufend = MySQL.scalar.await(
+ 'SELECT id FROM medical_intensiv WHERE patient = ? AND beendet_am IS NULL',
+ { aufnahme.patient })
+ if laufend then
+ return nil, 'Für diesen Patienten läuft bereits ein Intensivaufenthalt.'
+ end
+
+ -- Zimmer ist freiwillig: die Betreuung kann auch ohne festen Platz beginnen
+ local zimmerId = tonumber(daten.zimmer)
+ if zimmerId then
+ local zimmer = MySQL.query.await(
+ 'SELECT id FROM medical_zimmer WHERE id = ?', { zimmerId })
+ if not zimmer or not zimmer[1] then return nil, 'Unbekanntes Zimmer.' end
+ end
+
+ -- Die Verantwortung übernimmt nur, wer das ausdrücklich angibt
+ local verantwortlich = daten.selbst == true and mitarbeiter.charakterId or nil
+
+ local geraete = Text(daten.geraete, 512)
+
+ local intensivId = MySQL.insert.await([[
+ INSERT INTO medical_intensiv
+ (patient, aufnahme, zimmer, geraete, verantwortlich, zustand, notiz, aufgenommen_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], { aufnahme.patient, aufnahmeId, zimmerId, geraete, verantwortlich,
+ zustand, notiz, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Intensivaufenthalt eröffnet',
+ { typ = 'intensiv', id = intensivId },
+ { patient = aufnahme.patient, aufnahme = aufnahmeId,
+ zimmer = zimmerId, zustand = zustand,
+ verantwortlich = verantwortlich }, notiz)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient',
+ prioritaet = zustand == 'kritisch' and 'hoch' or 'normal',
+ titel = ('Intensivaufnahme · %s'):format(ZustandName(zustand)),
+ text = ('%s %s wird ab jetzt auf der Intensivstation betreut.')
+ :format(aufnahme.firstname, aufnahme.lastname),
+ zielTyp = 'intensiv', zielId = intensivId,
+ })
+
+ -- Ohne zuständigen Arzt bleibt der Aufenthalt sichtbar offen
+ if not verantwortlich then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'hoch',
+ titel = 'Intensivpatient ohne zuständigen Arzt',
+ text = ('Für %s %s ist noch niemand eingetragen.')
+ :format(aufnahme.firstname, aufnahme.lastname),
+ zielTyp = 'intensiv', zielId = intensivId,
+ })
+ end
+
+ Kern.Loese('intensiv.aufgenommen', intensivId, mitarbeiter)
+
+ return { id = intensivId }
+end)
+
+-- =====================================================================
+-- ZUSTAND
+--
+-- Der Zustand ist eine ärztliche Einschätzung und sonst nichts. Er
+-- wird von Hand gesetzt, mit Begründung, und der Wortlaut dieser
+-- Begründung wandert unverändert in den Verlauf.
+-- =====================================================================
+
+Kern.RegistriereAktion('intensiv', 'zustand', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local intensivId = tonumber(daten.id)
+ if not intensivId then return nil, 'Ungültige Kennung.' end
+
+ local neu = Zustand(daten.zustand)
+ if not neu then return nil, 'Unbekannter Zustand.' end
+
+ local begruendung = Text(daten.grund, 1000)
+ if not begruendung or #begruendung < 3 then
+ return nil, 'Bitte begründen, worauf sich die Einschätzung stützt.'
+ end
+
+ local eintrag = LadeAufenthalt(intensivId)
+ if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end
+ if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end
+
+ MySQL.update.await('UPDATE medical_intensiv SET zustand = ? WHERE id = ?',
+ { neu, intensivId })
+
+ -- Der Wortlaut des Arztes bleibt der Wortlaut des Arztes
+ SchreibeVerlauf(intensivId, begruendung, mitarbeiter.charakterId)
+
+ Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Zustand eingetragen',
+ { typ = 'intensiv', id = intensivId },
+ { patient = eintrag.patient, von = eintrag.zustand, nach = neu }, begruendung)
+
+ -- Eine Verschlechterung auf kritisch erfährt das ganze Haus
+ if neu == 'kritisch' and eintrag.zustand ~= 'kritisch' then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'kritisch',
+ titel = 'Intensivpatient als kritisch eingetragen',
+ text = ('%s %s — eingetragen von %s.')
+ :format(eintrag.firstname, eintrag.lastname, mitarbeiter.name),
+ zielTyp = 'intensiv', zielId = intensivId,
+ })
+ end
+
+ Kern.Loese('intensiv.zustand', intensivId, neu, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- VERLAUF
+-- =====================================================================
+
+Kern.RegistriereAktion('intensiv', 'verlauf', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local intensivId = tonumber(daten.id)
+ if not intensivId then return nil, 'Ungültige Kennung.' end
+
+ local text = Text(daten.text, 1000)
+ if not text or #text < 3 then return nil, 'Bitte einen Text eingeben.' end
+
+ local eintrag = LadeAufenthalt(intensivId)
+ if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end
+ if eintrag.beendet_am then
+ return nil, 'Zu einem beendeten Aufenthalt lässt sich nichts mehr nachtragen.'
+ end
+
+ local verlaufId = SchreibeVerlauf(intensivId, text, mitarbeiter.charakterId)
+
+ Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Verlaufseintrag geschrieben',
+ { typ = 'intensiv', id = intensivId },
+ { patient = eintrag.patient, eintrag = verlaufId })
+
+ Kern.Loese('intensiv.verlauf', intensivId, verlaufId, mitarbeiter)
+
+ return { id = verlaufId }
+end)
+
+-- =====================================================================
+-- GERÄTE
+--
+-- Eine Liste von Namen. Das System schliesst nichts an, trennt nichts
+-- und überwacht nichts — es weiss nur, was jemand notiert hat.
+-- =====================================================================
+
+Kern.RegistriereAktion('intensiv', 'geraete', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local intensivId = tonumber(daten.id)
+ if not intensivId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeAufenthalt(intensivId)
+ if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end
+ if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end
+
+ -- Leere Eingabe heisst: es ist nichts mehr angeschlossen
+ local geraete = Text(daten.geraete, 512)
+
+ MySQL.update.await('UPDATE medical_intensiv SET geraete = ? WHERE id = ?',
+ { geraete, intensivId })
+
+ Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Geräte eingetragen',
+ { typ = 'intensiv', id = intensivId },
+ { patient = eintrag.patient, vorher = eintrag.geraete, nachher = geraete })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZUSTÄNDIGKEIT
+--
+-- Verantwortung wird übernommen oder übertragen, nie zugeteilt.
+-- =====================================================================
+
+--- Wer im Dienst darf einen Intensivaufenthalt verantworten?
+Kern.RegistriereAktion('intensiv', 'betreuer', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local liste = {}
+
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ if Rechte.Hat(eintrag, 'intensiv.betreuen') then
+ liste[#liste + 1] = {
+ charakterId = eintrag.charakterId,
+ name = eintrag.name,
+ rolleName = eintrag.rolleName,
+ personalnummer = eintrag.personalnummer,
+ funktion = eintrag.funktion,
+ status = eintrag.status,
+ selbst = eintrag.charakterId == mitarbeiter.charakterId,
+ }
+ end
+ end
+
+ table.sort(liste, function(a, b) return tostring(a.name) < tostring(b.name) end)
+
+ return { liste = liste }
+end)
+
+Kern.RegistriereAktion('intensiv', 'verantwortlich', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local intensivId = tonumber(daten.id)
+ if not intensivId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = LadeAufenthalt(intensivId)
+ if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end
+ if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end
+
+ -- Ohne Ziel wird die Zuständigkeit niedergelegt
+ local zielId = tonumber(daten.charakter)
+
+ if zielId then
+ local zielQuelle = Dienst.QuelleVonCharakter(zielId)
+ local ziel = zielQuelle and Dienst.Hole(zielQuelle)
+ if not ziel then return nil, 'Diese Person ist nicht im Dienst.' end
+ if not Rechte.Hat(ziel, 'intensiv.betreuen') then
+ return nil, 'Diese Person darf keinen Intensivaufenthalt verantworten.'
+ end
+ else
+ local grund = Text(daten.grund, 255)
+ if not grund then
+ return nil, 'Bitte angeben, warum die Zuständigkeit niedergelegt wird.'
+ end
+ end
+
+ MySQL.update.await('UPDATE medical_intensiv SET verantwortlich = ? WHERE id = ?',
+ { zielId, intensivId })
+
+ Protokoll.Schreibe(mitarbeiter, 'intensiv',
+ zielId and 'Zuständigkeit übernommen oder übertragen' or 'Zuständigkeit niedergelegt',
+ { typ = 'intensiv', id = intensivId },
+ { patient = eintrag.patient, vorher = eintrag.verantwortlich, nachher = zielId },
+ Text(daten.grund, 255))
+
+ if zielId and zielId ~= mitarbeiter.charakterId then
+ -- Die betroffene Person erfährt es unmittelbar
+ Hinweise.Erstelle({ charakterId = zielId }, {
+ kategorie = 'patient', prioritaet = 'hoch',
+ titel = 'Du bist jetzt für einen Intensivpatienten zuständig',
+ text = ('%s %s — eingetragen von %s.')
+ :format(eintrag.firstname, eintrag.lastname, mitarbeiter.name),
+ zielTyp = 'intensiv', zielId = intensivId,
+ })
+ end
+
+ if not zielId then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'hoch',
+ titel = 'Intensivpatient ohne zuständigen Arzt',
+ text = ('Für %s %s ist niemand mehr eingetragen.')
+ :format(eintrag.firstname, eintrag.lastname),
+ zielTyp = 'intensiv', zielId = intensivId,
+ })
+ end
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- AUFENTHALT BEENDEN
+--
+-- Beendet ausschliesslich die Intensivbetreuung. Der Patient bleibt
+-- aufgenommen, sein Bett bleibt belegt, und keine Behandlung wird
+-- dadurch abgeschlossen.
+-- =====================================================================
+
+Kern.RegistriereAktion('intensiv', 'beenden', 'intensiv.betreuen', function(quelle, mitarbeiter, daten)
+ local intensivId = tonumber(daten.id)
+ if not intensivId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 1000)
+ if not grund or #grund < 5 then
+ return nil, 'Das Ende der Intensivbetreuung erfordert eine Begründung.'
+ end
+
+ local eintrag = LadeAufenthalt(intensivId)
+ if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end
+ if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end
+
+ -- Ein kritischer Zustand hält nicht auf, wird aber festgehalten
+ local warKritisch = eintrag.zustand == 'kritisch'
+
+ MySQL.update.await([[
+ UPDATE medical_intensiv
+ SET beendet_am = NOW(), beendet_von = ?
+ WHERE id = ?
+ ]], { mitarbeiter.charakterId, intensivId })
+
+ -- Die Begründung schliesst den Verlauf mit den Worten des Arztes ab
+ SchreibeVerlauf(intensivId, grund, mitarbeiter.charakterId)
+
+ Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Intensivaufenthalt beendet',
+ { typ = 'intensiv', id = intensivId },
+ { patient = eintrag.patient, zustand = eintrag.zustand,
+ warKritisch = warKritisch }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient',
+ prioritaet = warKritisch and 'hoch' or 'normal',
+ titel = 'Intensivbetreuung beendet',
+ text = ('%s %s — der Patient bleibt aufgenommen, Bett und Behandlungen sind unberührt.')
+ :format(eintrag.firstname, eintrag.lastname),
+ zielTyp = eintrag.aufnahme and 'aufnahme' or 'intensiv',
+ zielId = eintrag.aufnahme or intensivId,
+ })
+
+ Kern.Loese('intensiv.beendet', intensivId, mitarbeiter)
+
+ return { ok = true, warKritisch = warKritisch }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liegt dieser Patient gerade auf der Intensivstation?
+--- Reine Auskunft — keine Ressource kann darüber jemanden aufnehmen,
+--- entlassen oder seinen Zustand ändern.
+--- @param patientId number Charakter
+--- @return table|nil { id, zustand, zimmer, seit, verantwortlich }
+exports('HoleIntensivaufenthalt', function(patientId)
+ if not tonumber(patientId) then return nil end
+
+ local zeilen = MySQL.query.await([[
+ SELECT i.id, i.zustand, i.aufgenommen_am, i.verantwortlich,
+ z.kennung AS zimmer
+ FROM medical_intensiv i
+ LEFT JOIN medical_zimmer z ON z.id = i.zimmer
+ WHERE i.patient = ? AND i.beendet_am IS NULL
+ ORDER BY i.id DESC LIMIT 1
+ ]], { tonumber(patientId) })
+
+ if not zeilen or not zeilen[1] then return nil end
+
+ return {
+ id = zeilen[1].id,
+ zustand = zeilen[1].zustand,
+ zimmer = zeilen[1].zimmer,
+ seit = zeilen[1].aufgenommen_am,
+ verantwortlich = zeilen[1].verantwortlich
+ and NameVon(zeilen[1].verantwortlich) or nil,
+ }
+end)
diff --git a/resources/[aether]/aether-medical/server/module/kommunikation.lua b/resources/[aether]/aether-medical/server/module/kommunikation.lua
new file mode 100644
index 0000000..a1f5c53
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/kommunikation.lua
@@ -0,0 +1,760 @@
+-- =====================================================================
+-- aether-medical / Modul: Nachrichten und Aushänge
+--
+-- Ein Postfach und ein Schwarzes Brett.
+--
+-- Was hier steht, hat jemand geschrieben und mit seinem Namen
+-- unterschrieben. Deshalb gilt durchgehend:
+--
+-- * Eine abgeschickte Nachricht wird nicht mehr geändert und nicht
+-- gelöscht. Sie ist raus.
+-- * Ein Aushang wird archiviert, nie entfernt — auch ein
+-- zurückgenommener bleibt nachlesbar.
+-- * Eine Kenntnisnahme bestätigt nur, dass jemand gelesen hat.
+-- Niemand kann sie für einen anderen setzen, und sie ändert
+-- nichts ausser sich selbst.
+-- * Eine Frist läuft ab, ohne dass etwas geschieht. Das System
+-- erinnert daran und wartet.
+-- =====================================================================
+
+-- Lesen darf jeder im Dienst: was gelesen werden muss, soll nicht an
+-- einem Recht scheitern. Geschrieben wird nur mit Recht.
+Kern.RegistriereModul({
+ id = 'kommunikation', name = 'Nachrichten', icon = '✉',
+ reihenfolge = 13, recht = nil,
+ beschreibung = 'Postfach, Rundmeldungen und Schwarzes Brett',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Charakters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Anzeigename einer Station aus der Konfiguration
+--- @param kennung string|nil Stationskennung
+--- @return string
+local function StationName(kennung)
+ if not kennung then return 'gesamte Klinik' end
+ for _, station in ipairs(Medizin.Config.Stationen) do
+ if station.id == kennung then return station.name end
+ end
+ return tostring(kennung)
+end
+
+--- Prüft eine Kennung gegen eine Konfigurationsliste
+--- @param liste table Liste aus Medizin.Config
+--- @param wert any Eingabe
+--- @return string|nil
+local function AusListe(liste, wert)
+ local gesucht = tostring(wert or '')
+ for _, eintrag in ipairs(liste) do
+ if eintrag.id == gesucht then return eintrag.id end
+ end
+ return nil
+end
+
+--- Beschreibt, an wen eine Nachricht ging
+--- @param eintrag table Nachrichtenzeile
+--- @return string
+local function EmpfaengerName(eintrag)
+ if eintrag.empf_typ == 'rundmeldung' then return 'Alle im Haus' end
+ if eintrag.empf_typ == 'station' then
+ return 'Station ' .. StationName(eintrag.empfaenger)
+ end
+ return NameVon(tonumber(eintrag.empfaenger))
+end
+
+--- Kürzt einen Text für die Listenansicht
+--- @param wert string|nil Volltext
+--- @param laenge number Zeichen
+--- @return string
+local function Vorschau(wert, laenge)
+ local roh = tostring(wert or ''):gsub('%s+', ' ')
+ if #roh <= laenge then return roh end
+ return roh:sub(1, laenge) .. '…'
+end
+
+--- Erreicht diese Nachricht den genannten Mitarbeiter?
+--- @param eintrag table Nachrichtenzeile
+--- @param mitarbeiter table Mitarbeiter
+--- @return boolean
+local function IstEmpfaenger(eintrag, mitarbeiter)
+ if eintrag.empf_typ == 'rundmeldung' then return true end
+ if eintrag.empf_typ == 'station' then
+ return eintrag.empfaenger == mitarbeiter.station
+ end
+ return tonumber(eintrag.empfaenger) == mitarbeiter.charakterId
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('kommunikation', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'posteingang')
+ local ich = mitarbeiter.charakterId
+ local liste = {}
+
+ if ordner == 'gesendet' then
+ liste = MySQL.query.await([[
+ SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet,
+ (SELECT COUNT(*) FROM medical_nachricht_gelesen g
+ WHERE g.nachricht = n.id) AS gelesenVon
+ FROM medical_nachricht n
+ WHERE n.absender = ?
+ ORDER BY n.id DESC
+ LIMIT 100
+ ]], { ich }) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.empfaengerName = EmpfaengerName(eintrag)
+ eintrag.vorschau = Vorschau(eintrag.text, 140)
+ eintrag.gelesenVon = tonumber(eintrag.gelesenVon) or 0
+ eintrag.text = nil
+ end
+
+ elseif ordner == 'aushang' or ordner == 'archiv' then
+ local aktiv = ordner == 'aushang' and 1 or 0
+
+ liste = MySQL.query.await([[
+ SELECT a.id, a.titel, a.text, a.kategorie, a.station, a.angeheftet,
+ a.kenntnispflicht, a.frist, a.erstellt_von, a.erstellt_am,
+ a.archiviert_von, a.archiviert_am,
+ TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS restMinuten,
+ (SELECT COUNT(*) FROM medical_aushang_kenntnis k
+ WHERE k.aushang = a.id) AS kenntnisse,
+ (SELECT 1 FROM medical_aushang_kenntnis k
+ WHERE k.aushang = a.id AND k.`character` = ?) AS bestaetigt
+ FROM medical_aushang a
+ WHERE a.aktiv = ? AND (a.station IS NULL OR a.station = ?)
+ ORDER BY a.angeheftet DESC, a.erstellt_am DESC
+ LIMIT 100
+ ]], { ich, aktiv, mitarbeiter.station }) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.autor = NameVon(eintrag.erstellt_von)
+ eintrag.stationName = StationName(eintrag.station)
+ eintrag.angeheftet = tonumber(eintrag.angeheftet) == 1
+ eintrag.kenntnispflicht = tonumber(eintrag.kenntnispflicht) == 1
+ eintrag.kenntnisse = tonumber(eintrag.kenntnisse) or 0
+ eintrag.bestaetigt = eintrag.bestaetigt ~= nil
+ eintrag.restMinuten = eintrag.frist and (tonumber(eintrag.restMinuten) or 0) or nil
+ -- Eine abgelaufene Frist ist ein Zustand, keine Folge
+ eintrag.fristAbgelaufen = eintrag.restMinuten ~= nil and eintrag.restMinuten <= 0
+ eintrag.vorschau = Vorschau(eintrag.text, 200)
+ eintrag.archiviertVon = eintrag.archiviert_von
+ and NameVon(eintrag.archiviert_von) or nil
+ eintrag.erstellt_von, eintrag.archiviert_von = nil, nil
+ eintrag.text = nil
+ end
+
+ else
+ liste = MySQL.query.await([[
+ SELECT n.id, n.absender, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet,
+ c.firstname, c.lastname,
+ (SELECT 1 FROM medical_nachricht_gelesen g
+ WHERE g.nachricht = n.id AND g.`character` = ?) AS gelesen
+ FROM medical_nachricht n
+ JOIN aether_characters c ON c.id = n.absender
+ WHERE n.empf_typ = 'rundmeldung'
+ OR (n.empf_typ = 'person' AND n.empfaenger = ?)
+ OR (n.empf_typ = 'station' AND n.empfaenger = ?)
+ ORDER BY n.id DESC
+ LIMIT 100
+ ]], { ich, tostring(ich), mitarbeiter.station or '' }) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.absenderName = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.empfaengerName = EmpfaengerName(eintrag)
+ eintrag.vorschau = Vorschau(eintrag.text, 140)
+ eintrag.gelesen = eintrag.gelesen ~= nil
+ eintrag.anMich = eintrag.empf_typ == 'person'
+ eintrag.firstname, eintrag.lastname, eintrag.text = nil, nil, nil
+ end
+ end
+
+ -- Zählwerte für die Reiter
+ local ungelesen = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_nachricht n
+ WHERE (n.empf_typ = 'rundmeldung'
+ OR (n.empf_typ = 'person' AND n.empfaenger = ?)
+ OR (n.empf_typ = 'station' AND n.empfaenger = ?))
+ AND NOT EXISTS (SELECT 1 FROM medical_nachricht_gelesen g
+ WHERE g.nachricht = n.id AND g.`character` = ?)
+ ]], { tostring(ich), mitarbeiter.station or '', ich })) or 0
+
+ -- Offene Kenntnisnahmen: was mich betrifft und was ich noch nicht bestätigt habe
+ local offeneKenntnis = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_aushang a
+ WHERE a.aktiv = 1 AND a.kenntnispflicht = 1
+ AND (a.station IS NULL OR a.station = ?)
+ AND NOT EXISTS (SELECT 1 FROM medical_aushang_kenntnis k
+ WHERE k.aushang = a.id AND k.`character` = ?)
+ ]], { mitarbeiter.station, ich })) or 0
+
+ local zaehler = {
+ ungelesen = ungelesen,
+ gesendet = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_nachricht WHERE absender = ?', { ich })) or 0,
+ aushaenge = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_aushang
+ WHERE aktiv = 1 AND (station IS NULL OR station = ?)
+ ]], { mitarbeiter.station })) or 0,
+ offeneKenntnis = offeneKenntnis,
+ }
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ zaehler = zaehler,
+ kategorien = Medizin.Config.Aushangkategorien,
+ stationen = Medizin.Config.Stationen,
+ meineStation = mitarbeiter.station,
+ rechte = {
+ senden = Rechte.Hat(mitarbeiter, 'nachricht.senden'),
+ rundmeldung = Rechte.Hat(mitarbeiter, 'nachricht.rundmeldung'),
+ schreiben = Rechte.Hat(mitarbeiter, 'aushang.schreiben'),
+ archivieren = Rechte.Hat(mitarbeiter, 'aushang.archivieren'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- NACHRICHT LESEN
+--
+-- Als gelesen gilt sie, weil jemand sie geöffnet hat — nicht, weil
+-- Zeit vergangen ist.
+-- =====================================================================
+
+Kern.RegistriereAktion('kommunikation', 'nachricht', nil, function(quelle, mitarbeiter, daten)
+ local nachrichtId = tonumber(daten.id)
+ if not nachrichtId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT n.*, c.firstname, c.lastname
+ FROM medical_nachricht n
+ JOIN aether_characters c ON c.id = n.absender
+ WHERE n.id = ?
+ ]], { nachrichtId })
+ if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end
+
+ local eintrag = zeilen[1]
+
+ -- Fremde Post bleibt fremd
+ local darfLesen = eintrag.absender == mitarbeiter.charakterId
+ or IstEmpfaenger(eintrag, mitarbeiter)
+ if not darfLesen then
+ return nil, 'Diese Nachricht ist nicht an dich gerichtet.'
+ end
+
+ -- Nur Empfänger quittieren, nicht der Absender selbst
+ if IstEmpfaenger(eintrag, mitarbeiter) then
+ MySQL.insert.await([[
+ INSERT IGNORE INTO medical_nachricht_gelesen (nachricht, `character`)
+ VALUES (?, ?)
+ ]], { nachrichtId, mitarbeiter.charakterId })
+ end
+
+ -- Wer hat sie gelesen? Für den Absender die wichtigste Angabe.
+ local leser = {}
+ if eintrag.absender == mitarbeiter.charakterId then
+ leser = MySQL.query.await([[
+ SELECT g.gelesen_am, c.firstname, c.lastname
+ FROM medical_nachricht_gelesen g
+ JOIN aether_characters c ON c.id = g.`character`
+ WHERE g.nachricht = ?
+ ORDER BY g.gelesen_am
+ ]], { nachrichtId }) or {}
+
+ for _, zeile in ipairs(leser) do
+ zeile.name = zeile.firstname .. ' ' .. zeile.lastname
+ zeile.firstname, zeile.lastname = nil, nil
+ end
+ end
+
+ return {
+ nachricht = {
+ id = eintrag.id,
+ absender = eintrag.absender,
+ absenderName = eintrag.firstname .. ' ' .. eintrag.lastname,
+ empfaengerName = EmpfaengerName(eintrag),
+ empfTyp = eintrag.empf_typ,
+ betreff = eintrag.betreff,
+ text = eintrag.text,
+ gesendet = eintrag.gesendet,
+ vonMir = eintrag.absender == mitarbeiter.charakterId,
+ },
+ leser = leser,
+ rechte = {
+ senden = Rechte.Hat(mitarbeiter, 'nachricht.senden'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'gelesen', nil, function(quelle, mitarbeiter, daten)
+ local nachrichtId = tonumber(daten.id)
+ if not nachrichtId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, empfaenger, empf_typ FROM medical_nachricht WHERE id = ?', { nachrichtId })
+ if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end
+
+ if not IstEmpfaenger(zeilen[1], mitarbeiter) then
+ return nil, 'Diese Nachricht ist nicht an dich gerichtet.'
+ end
+
+ MySQL.insert.await([[
+ INSERT IGNORE INTO medical_nachricht_gelesen (nachricht, `character`)
+ VALUES (?, ?)
+ ]], { nachrichtId, mitarbeiter.charakterId })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- NACHRICHT SCHREIBEN
+--
+-- Abgeschickt ist abgeschickt: es gibt hier bewusst kein Bearbeiten
+-- und kein Löschen.
+-- =====================================================================
+
+--- Mögliche Empfänger: das Personal und die Stationen
+Kern.RegistriereAktion('kommunikation', 'empfaenger', 'nachricht.senden', function(quelle, mitarbeiter, daten)
+ local personal = MySQL.query.await([[
+ SELECT p.`character` AS charakterId, p.personalnummer, p.rolle, p.station,
+ c.firstname, c.lastname
+ FROM medical_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ WHERE p.aktiv = 1 AND c.deleted = 0
+ ORDER BY p.personalnummer
+ LIMIT 200
+ ]]) or {}
+
+ -- Wer gerade im Dienst ist, wird gekennzeichnet — erreichbar sind alle
+ local imDienst = {}
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ imDienst[eintrag.charakterId] = true
+ end
+
+ for _, eintrag in ipairs(personal) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.rolleName = Medizin.HoleRolle(eintrag.rolle)
+ and Medizin.HoleRolle(eintrag.rolle).name or eintrag.rolle
+ eintrag.stationName = StationName(eintrag.station)
+ eintrag.imDienst = imDienst[eintrag.charakterId] == true
+ eintrag.selbst = eintrag.charakterId == mitarbeiter.charakterId
+ eintrag.firstname, eintrag.lastname = nil, nil
+ end
+
+ return {
+ personal = personal,
+ stationen = Medizin.Config.Stationen,
+ rechte = {
+ rundmeldung = Rechte.Hat(mitarbeiter, 'nachricht.rundmeldung'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'senden', 'nachricht.senden', function(quelle, mitarbeiter, daten)
+ local typ = tostring(daten.typ or 'person')
+ if typ ~= 'person' and typ ~= 'station' and typ ~= 'rundmeldung' then
+ return nil, 'Unbekannte Empfängerart.'
+ end
+
+ -- Eine Rundmeldung erreicht das ganze Haus und braucht ein eigenes Recht
+ if typ == 'rundmeldung' and not Rechte.Hat(mitarbeiter, 'nachricht.rundmeldung') then
+ return nil, 'Rundmeldungen an alle darfst du nicht senden.'
+ end
+
+ local text = Text(daten.text, 4000)
+ if not text or #text < 2 then return nil, 'Bitte einen Text schreiben.' end
+
+ local betreff = Text(daten.betreff, 160)
+
+ -- Empfänger prüfen und in die gespeicherte Schreibweise bringen
+ local ziel, empfaengerName
+ if typ == 'rundmeldung' then
+ ziel = 'alle'
+ empfaengerName = 'Alle im Haus'
+
+ elseif typ == 'station' then
+ local station = AusListe(Medizin.Config.Stationen, daten.ziel)
+ if not station then return nil, 'Unbekannte Station.' end
+ ziel = station
+ empfaengerName = 'Station ' .. StationName(station)
+
+ else
+ local zielId = tonumber(daten.ziel)
+ if not zielId then return nil, 'Kein Empfänger gewählt.' end
+
+ local person = MySQL.scalar.await(
+ 'SELECT `character` FROM medical_personal WHERE `character` = ? AND aktiv = 1',
+ { zielId })
+ if not person then return nil, 'Diese Person gehört nicht zum Personal.' end
+
+ ziel = tostring(zielId)
+ empfaengerName = NameVon(zielId)
+ end
+
+ local nachrichtId = MySQL.insert.await([[
+ INSERT INTO medical_nachricht (absender, empfaenger, empf_typ, betreff, text)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { mitarbeiter.charakterId, ziel, typ, betreff, text })
+
+ Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Nachricht gesendet',
+ { typ = 'nachricht', id = nachrichtId },
+ { empfaengerTyp = typ, empfaenger = ziel, betreff = betreff })
+
+ -- Die Betroffenen erfahren es sofort — gelesen wird trotzdem von Hand
+ local kopfzeile = betreff or Vorschau(text, 60)
+
+ if typ == 'person' then
+ Hinweise.Erstelle({ charakterId = tonumber(ziel) }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = ('Nachricht von %s'):format(mitarbeiter.name),
+ text = kopfzeile,
+ zielTyp = 'nachricht', zielId = nachrichtId,
+ })
+ else
+ -- Station und Rundmeldung: nur ansprechen, wen sie erreicht
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ local passt = typ == 'rundmeldung' or eintrag.station == ziel
+ if passt and eintrag.charakterId ~= mitarbeiter.charakterId then
+ Hinweise.Erstelle({ charakterId = eintrag.charakterId }, {
+ kategorie = 'system',
+ prioritaet = typ == 'rundmeldung' and 'hoch' or 'normal',
+ titel = typ == 'rundmeldung'
+ and ('Rundmeldung von %s'):format(mitarbeiter.name)
+ or ('Stationsnachricht von %s'):format(mitarbeiter.name),
+ text = kopfzeile,
+ zielTyp = 'nachricht', zielId = nachrichtId,
+ })
+ end
+ end
+ end
+
+ Kern.Loese('nachricht.gesendet', nachrichtId, typ, mitarbeiter)
+
+ return { id = nachrichtId, empfaenger = empfaengerName }
+end)
+
+-- =====================================================================
+-- SCHWARZES BRETT
+-- =====================================================================
+
+Kern.RegistriereAktion('kommunikation', 'aushang', nil, function(quelle, mitarbeiter, daten)
+ local aushangId = tonumber(daten.id)
+ if not aushangId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.*,
+ TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS restMinuten,
+ (SELECT 1 FROM medical_aushang_kenntnis k
+ WHERE k.aushang = a.id AND k.`character` = ?) AS bestaetigt
+ FROM medical_aushang a
+ WHERE a.id = ?
+ ]], { mitarbeiter.charakterId, aushangId })
+ if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end
+
+ local eintrag = zeilen[1]
+
+ -- Wer hat gelesen und wer noch nicht?
+ local kenntnis = MySQL.query.await([[
+ SELECT k.bestaetigt_am, c.firstname, c.lastname, p.personalnummer
+ FROM medical_aushang_kenntnis k
+ JOIN aether_characters c ON c.id = k.`character`
+ LEFT JOIN medical_personal p ON p.`character` = k.`character`
+ WHERE k.aushang = ?
+ ORDER BY k.bestaetigt_am
+ ]], { aushangId }) or {}
+
+ for _, zeile in ipairs(kenntnis) do
+ zeile.name = zeile.firstname .. ' ' .. zeile.lastname
+ zeile.firstname, zeile.lastname = nil, nil
+ end
+
+ -- Wen der Aushang betrifft, der aber noch nicht bestätigt hat.
+ -- Reine Auskunft — niemand wird dadurch zu etwas gezwungen.
+ local offen = {}
+ if tonumber(eintrag.kenntnispflicht) == 1 then
+ offen = MySQL.query.await([[
+ SELECT p.personalnummer, p.station, c.firstname, c.lastname
+ FROM medical_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ WHERE p.aktiv = 1
+ AND (? IS NULL OR p.station = ?)
+ AND NOT EXISTS (SELECT 1 FROM medical_aushang_kenntnis k
+ WHERE k.aushang = ? AND k.`character` = p.`character`)
+ ORDER BY p.personalnummer
+ LIMIT 100
+ ]], { eintrag.station, eintrag.station, aushangId }) or {}
+
+ for _, zeile in ipairs(offen) do
+ zeile.name = zeile.firstname .. ' ' .. zeile.lastname
+ zeile.firstname, zeile.lastname = nil, nil
+ end
+ end
+
+ local restMinuten = eintrag.frist and (tonumber(eintrag.restMinuten) or 0) or nil
+
+ return {
+ aushang = {
+ id = eintrag.id, titel = eintrag.titel, text = eintrag.text,
+ kategorie = eintrag.kategorie,
+ station = eintrag.station, stationName = StationName(eintrag.station),
+ angeheftet = tonumber(eintrag.angeheftet) == 1,
+ kenntnispflicht = tonumber(eintrag.kenntnispflicht) == 1,
+ frist = eintrag.frist, restMinuten = restMinuten,
+ fristAbgelaufen = restMinuten ~= nil and restMinuten <= 0,
+ autor = NameVon(eintrag.erstellt_von),
+ erstelltAm = eintrag.erstellt_am,
+ aktiv = tonumber(eintrag.aktiv) == 1,
+ archiviertAm = eintrag.archiviert_am,
+ archiviertVon = eintrag.archiviert_von
+ and NameVon(eintrag.archiviert_von) or nil,
+ bestaetigt = eintrag.bestaetigt ~= nil,
+ -- Ein Aushang, den schon jemand quittiert hat, wird im Wortlaut
+ -- nicht mehr angefasst — sonst hätte jemand etwas anderes gelesen
+ textGesperrt = #kenntnis > 0,
+ },
+ kenntnis = kenntnis,
+ offen = offen,
+ kategorien = Medizin.Config.Aushangkategorien,
+ stationen = Medizin.Config.Stationen,
+ rechte = {
+ schreiben = Rechte.Hat(mitarbeiter, 'aushang.schreiben'),
+ archivieren = Rechte.Hat(mitarbeiter, 'aushang.archivieren'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'aushangSchreiben', 'aushang.schreiben', function(quelle, mitarbeiter, daten)
+ local aushangId = tonumber(daten.id)
+
+ local titel = Text(daten.titel, 160)
+ local text = Text(daten.text, 8000)
+ local kategorie = AusListe(Medizin.Config.Aushangkategorien, daten.kategorie)
+
+ if not titel then return nil, 'Bitte einen Titel angeben.' end
+ if not text or #text < 5 then return nil, 'Bitte einen Text schreiben.' end
+ if not kategorie then return nil, 'Unbekannte Kategorie.' end
+
+ -- Ohne Station gilt der Aushang für das ganze Haus
+ local station = nil
+ if Text(daten.station, 32) then
+ station = AusListe(Medizin.Config.Stationen, daten.station)
+ if not station then return nil, 'Unbekannte Station.' end
+ end
+
+ local angeheftet = daten.angeheftet == true and 1 or 0
+ local kenntnispflicht = daten.kenntnispflicht == true and 1 or 0
+
+ -- Eine Frist erinnert nur. Sie sperrt nichts und verfällt folgenlos.
+ local frist = Text(daten.frist, 20)
+ if frist and not frist:match('^%d%d%d%d%-%d%d%-%d%d[ T]%d%d:%d%d') then
+ return nil, 'Die Frist braucht die Form JJJJ-MM-TT SS:MM.'
+ end
+ if frist then frist = frist:gsub('T', ' '):sub(1, 16) .. ':00' end
+
+ if aushangId then
+ local vorher = MySQL.query.await(
+ 'SELECT id, titel, text, aktiv FROM medical_aushang WHERE id = ?', { aushangId })
+ if not vorher or not vorher[1] then return nil, 'Aushang nicht gefunden.' end
+ if tonumber(vorher[1].aktiv) ~= 1 then
+ return nil, 'Ein archivierter Aushang wird nicht mehr bearbeitet.'
+ end
+
+ -- Wer schon quittiert hat, hat einen bestimmten Wortlaut gelesen
+ local quittiert = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_aushang_kenntnis WHERE aushang = ?',
+ { aushangId })) or 0
+
+ if quittiert > 0 and (vorher[1].titel ~= titel or vorher[1].text ~= text) then
+ return nil, ('%d Person(en) haben diesen Wortlaut bereits zur Kenntnis genommen. Bitte archivieren und neu veröffentlichen.')
+ :format(quittiert)
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_aushang
+ SET titel = ?, text = ?, kategorie = ?, station = ?,
+ angeheftet = ?, kenntnispflicht = ?, frist = ?
+ WHERE id = ?
+ ]], { titel, text, kategorie, station, angeheftet, kenntnispflicht, frist, aushangId })
+
+ Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Aushang bearbeitet',
+ { typ = 'aushang', id = aushangId },
+ { titel = titel, kategorie = kategorie, station = station,
+ kenntnispflicht = kenntnispflicht == 1, frist = frist })
+ else
+ aushangId = MySQL.insert.await([[
+ INSERT INTO medical_aushang
+ (titel, text, kategorie, station, angeheftet, kenntnispflicht,
+ frist, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], { titel, text, kategorie, station, angeheftet, kenntnispflicht,
+ frist, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Aushang veröffentlicht',
+ { typ = 'aushang', id = aushangId },
+ { titel = titel, kategorie = kategorie, station = station,
+ kenntnispflicht = kenntnispflicht == 1, frist = frist })
+
+ -- Nur die ansprechen, die der Aushang betrifft
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ local passt = station == nil or eintrag.station == station
+ if passt then
+ Hinweise.Erstelle({ charakterId = eintrag.charakterId }, {
+ kategorie = 'system',
+ prioritaet = kenntnispflicht == 1 and 'hoch' or 'normal',
+ titel = ('Neuer Aushang · %s')
+ :format(kategorie == 'anweisung' and 'Dienstanweisung' or 'Schwarzes Brett'),
+ text = titel,
+ zielTyp = 'aushang', zielId = aushangId,
+ })
+ end
+ end
+ end
+
+ Kern.Loese('aushang.geaendert', aushangId, mitarbeiter)
+
+ return { id = aushangId }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'anheften', 'aushang.schreiben', function(quelle, mitarbeiter, daten)
+ local aushangId = tonumber(daten.id)
+ if not aushangId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, titel, angeheftet FROM medical_aushang WHERE id = ? AND aktiv = 1',
+ { aushangId })
+ if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end
+
+ local neu = daten.angeheftet == true and 1 or 0
+
+ MySQL.update.await('UPDATE medical_aushang SET angeheftet = ? WHERE id = ?',
+ { neu, aushangId })
+
+ Protokoll.Schreibe(mitarbeiter, 'kommunikation',
+ neu == 1 and 'Aushang angeheftet' or 'Aushang losgelöst',
+ { typ = 'aushang', id = aushangId }, { titel = zeilen[1].titel })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'aushangArchivieren', 'aushang.archivieren', function(quelle, mitarbeiter, daten)
+ local aushangId = tonumber(daten.id)
+ if not aushangId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte angeben, warum der Aushang abgenommen wird.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, titel, aktiv, kenntnispflicht FROM medical_aushang WHERE id = ?',
+ { aushangId })
+ if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end
+ if tonumber(zeilen[1].aktiv) ~= 1 then return nil, 'Dieser Aushang hängt nicht mehr.' end
+
+ -- Offene Kenntnisnahmen halten nicht auf, werden aber festgehalten
+ local offen = 0
+ if tonumber(zeilen[1].kenntnispflicht) == 1 then
+ offen = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_personal p
+ WHERE p.aktiv = 1
+ AND NOT EXISTS (SELECT 1 FROM medical_aushang_kenntnis k
+ WHERE k.aushang = ? AND k.`character` = p.`character`)
+ ]], { aushangId })) or 0
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_aushang
+ SET aktiv = 0, angeheftet = 0, archiviert_von = ?, archiviert_am = NOW()
+ WHERE id = ?
+ ]], { mitarbeiter.charakterId, aushangId })
+
+ Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Aushang archiviert',
+ { typ = 'aushang', id = aushangId },
+ { titel = zeilen[1].titel, offeneKenntnisnahmen = offen }, grund)
+
+ Kern.Loese('aushang.archiviert', aushangId, mitarbeiter)
+
+ return { ok = true, offen = offen }
+end)
+
+-- =====================================================================
+-- KENNTNISNAHME
+--
+-- Bestätigt ausschliesslich, dass jemand selbst gelesen hat. Sie lässt
+-- sich nicht für andere setzen, nicht zurücknehmen und ändert nichts
+-- ausser sich selbst.
+-- =====================================================================
+
+Kern.RegistriereAktion('kommunikation', 'kenntnis', nil, function(quelle, mitarbeiter, daten)
+ local aushangId = tonumber(daten.id)
+ if not aushangId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, titel, station, aktiv FROM medical_aushang WHERE id = ?', { aushangId })
+ if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end
+
+ local aushang = zeilen[1]
+ if tonumber(aushang.aktiv) ~= 1 then
+ return nil, 'Dieser Aushang hängt nicht mehr.'
+ end
+ if aushang.station and aushang.station ~= mitarbeiter.station then
+ return nil, 'Dieser Aushang betrifft eine andere Station.'
+ end
+
+ local schon = MySQL.scalar.await([[
+ SELECT 1 FROM medical_aushang_kenntnis WHERE aushang = ? AND `character` = ?
+ ]], { aushangId, mitarbeiter.charakterId })
+ if schon then return { ok = true, schonBestaetigt = true } end
+
+ MySQL.insert.await([[
+ INSERT INTO medical_aushang_kenntnis (aushang, `character`) VALUES (?, ?)
+ ]], { aushangId, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Kenntnis genommen',
+ { typ = 'aushang', id = aushangId }, { titel = aushang.titel })
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die aktiven Aushänge einer Station.
+--- Reine Auskunft — keine Ressource kann darüber etwas veröffentlichen
+--- oder eine Kenntnisnahme setzen.
+--- @param station string|nil Stationskennung
+--- @return table Liste
+exports('HoleAushaenge', function(station)
+ return MySQL.query.await([[
+ SELECT a.id, a.titel, a.text, a.kategorie, a.station,
+ a.angeheftet, a.kenntnispflicht, a.frist, a.erstellt_am
+ FROM medical_aushang a
+ WHERE a.aktiv = 1 AND (a.station IS NULL OR a.station = ?)
+ ORDER BY a.angeheftet DESC, a.erstellt_am DESC
+ ]], { station }) or {}
+end)
diff --git a/resources/[aether]/aether-medical/server/module/labor.lua b/resources/[aether]/aether-medical/server/module/labor.lua
new file mode 100644
index 0000000..da41c6e
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/labor.lua
@@ -0,0 +1,540 @@
+-- =====================================================================
+-- aether-medical / Modul: Labor
+--
+-- Das Labor speichert AUSSCHLIESSLICH Ergebnisse.
+--
+-- Deshalb sind zwei Dinge strikt getrennt:
+-- * `ergebnis` — die gemessenen Werte. Trägt das Labor ein.
+-- * `befund` — die Bewertung. Trägt ein Mediziner ein.
+--
+-- Das System vergleicht keine Werte mit Referenzbereichen, markiert
+-- nichts als auffällig und leitet aus keinem Ergebnis eine Diagnose
+-- ab. Ein Messwert ist eine Zahl, keine Aussage.
+--
+-- Auch eine im Labor bestimmte Blutgruppe wandert NICHT von selbst in
+-- die Stammdaten — das muss ein Mediziner ausdrücklich übernehmen.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'labor', name = 'Labor', icon = '🧪',
+ reihenfolge = 6,
+ -- Ein Recht daraus genügt: Auftraggeber, Labor und Befunder teilen sich das Modul
+ recht = { 'labor.beauftragen', 'labor.bearbeiten', 'labor.befunden' },
+ beschreibung = 'Laboraufträge, Ergebnisse und Befunde',
+})
+
+-- Schreibhilfen je Probenart. Reine Textvorlagen — sie enthalten keine
+-- Referenzbereiche und bewerten nichts, sie geben nur die Struktur vor.
+local VORLAGEN = {
+ blut = 'Hb:\nLeukozyten:\nThrombozyten:\nCRP:\nKreatinin:\nNatrium:\nKalium:\n'
+ .. 'Blutgruppe:\n\nBemerkung des Labors:',
+ urin = 'Farbe:\nEiweiß:\nGlukose:\nBlut:\nLeukozyten:\nNitrit:\n\nBemerkung des Labors:',
+ dna = 'Probenart:\nProfil vollständig (ja/nein):\nÜbereinstimmung:\n\nBemerkung des Labors:',
+ gewebe = 'Entnahmeort:\nMakroskopisch:\nMikroskopisch:\n\nBemerkung des Labors:',
+ toxikologie = 'Alkohol:\nOpiate:\nAmphetamine:\nCannabinoide:\nBenzodiazepine:\n'
+ .. '\nBemerkung des Labors:',
+}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Prüft eine Probenart gegen die Konfiguration
+--- @param kennung any Eingabe
+--- @return string|nil
+local function Probenart(kennung)
+ for _, eintrag in ipairs(Medizin.Config.Laborarten) do
+ if eintrag.id == kennung then return eintrag.id end
+ end
+ return nil
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await([[
+ SELECT c.firstname, c.lastname FROM aether_characters c WHERE c.id = ?
+ ]], { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('labor', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'offen')
+ local bedingungen, werte = {}, {}
+
+ if ordner == 'meine' then
+ bedingungen[#bedingungen + 1] = 'l.auftrag_von = ?'
+ werte[#werte + 1] = mitarbeiter.charakterId
+ elseif ordner == 'befund' then
+ -- Ergebnis liegt vor, die Bewertung fehlt noch
+ bedingungen[#bedingungen + 1] = "l.status = 'fertig' AND l.befund IS NULL"
+ elseif ordner == 'archiv' then
+ bedingungen[#bedingungen + 1] = "(l.befund IS NOT NULL OR l.status = 'verworfen')"
+ else
+ bedingungen[#bedingungen + 1] = "l.status IN ('offen', 'in_arbeit')"
+ end
+
+ if daten.art and daten.art ~= '' then
+ bedingungen[#bedingungen + 1] = 'l.art = ?'
+ werte[#werte + 1] = daten.art
+ end
+
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(l.kennung LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ for _ = 1, 3 do werte[#werte + 1] = muster end
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT l.id, l.kennung, l.patient, l.art, l.fragestellung, l.dringend,
+ l.status, l.auftrag_am, l.auftrag_von, l.bearbeitet_am,
+ l.ergebnis IS NOT NULL AS hatErgebnis,
+ l.befund IS NOT NULL AS hatBefund,
+ TIMESTAMPDIFF(MINUTE, l.auftrag_am, NOW()) AS offenSeit,
+ c.firstname, c.lastname, c.dob
+ FROM medical_labor l
+ JOIN aether_characters c ON c.id = l.patient
+ WHERE %s
+ ORDER BY l.dringend DESC, l.id DESC
+ LIMIT 120
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ local artNamen = {}
+ for _, eintrag in ipairs(Medizin.Config.Laborarten) do artNamen[eintrag.id] = eintrag.name end
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
+ eintrag.artName = artNamen[eintrag.art] or eintrag.art
+ eintrag.auftragVon = NameVon(eintrag.auftrag_von)
+ eintrag.eigene = eintrag.auftrag_von == mitarbeiter.charakterId
+ eintrag.dringend = eintrag.dringend == 1
+ eintrag.hatErgebnis = tonumber(eintrag.hatErgebnis) == 1
+ eintrag.hatBefund = tonumber(eintrag.hatBefund) == 1
+ eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
+ eintrag.auftrag_von = nil
+ end
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ arten = Medizin.Config.Laborarten,
+ zaehler = {
+ offen = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_labor WHERE status IN ('offen', 'in_arbeit')")) or 0,
+ befund = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_labor WHERE status = 'fertig' AND befund IS NULL")) or 0,
+ meine = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_labor
+ WHERE auftrag_von = ? AND status <> 'verworfen'
+ ]], { mitarbeiter.charakterId })) or 0,
+ dringend = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_labor
+ WHERE dringend = 1 AND status IN ('offen', 'in_arbeit')
+ ]])) or 0,
+ },
+ rechte = {
+ beauftragen = Rechte.Hat(mitarbeiter, 'labor.beauftragen'),
+ bearbeiten = Rechte.Hat(mitarbeiter, 'labor.bearbeiten'),
+ befunden = Rechte.Hat(mitarbeiter, 'labor.befunden'),
+ stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('labor', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local laborId = tonumber(daten.id)
+ if not laborId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT l.*, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.hinweis
+ FROM medical_labor l
+ JOIN aether_characters c ON c.id = l.patient
+ LEFT JOIN medical_patient m ON m.`character` = l.patient
+ WHERE l.id = ?
+ ]], { laborId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
+ local l = zeilen[1]
+
+ local artName = l.art
+ for _, eintrag in ipairs(Medizin.Config.Laborarten) do
+ if eintrag.id == l.art then artName = eintrag.name end
+ end
+
+ -- Frühere Aufträge desselben Patienten zum Vergleich
+ local frueher = MySQL.query.await([[
+ SELECT id, kennung, art, status, auftrag_am, ergebnis IS NOT NULL AS hatErgebnis
+ FROM medical_labor
+ WHERE patient = ? AND id <> ?
+ ORDER BY id DESC LIMIT 10
+ ]], { l.patient, laborId }) or {}
+
+ for _, eintrag in ipairs(frueher) do
+ eintrag.hatErgebnis = tonumber(eintrag.hatErgebnis) == 1
+ end
+
+ return {
+ auftrag = {
+ id = l.id, kennung = l.kennung, patient = l.patient,
+ patientName = l.firstname .. ' ' .. l.lastname,
+ geburt = tostring(l.dob):sub(1, 10),
+ art = l.art, artName = artName,
+ fragestellung = l.fragestellung,
+ dringend = l.dringend == 1,
+ status = l.status,
+ auftragAm = l.auftrag_am,
+ auftragVon = NameVon(l.auftrag_von),
+ istMeinAuftrag = l.auftrag_von == mitarbeiter.charakterId,
+ bearbeitetAm = l.bearbeitet_am,
+ bearbeitetVon = l.bearbeitet_von and NameVon(l.bearbeitet_von) or nil,
+ ergebnis = l.ergebnis,
+ befund = l.befund,
+ befundAm = l.befund_am,
+ befundVon = l.befund_von and NameVon(l.befund_von) or nil,
+ },
+ stamm = {
+ blutgruppe = l.blutgruppe, allergien = l.allergien, hinweis = l.hinweis,
+ },
+ frueher = frueher,
+ vorlage = VORLAGEN[l.art],
+ rechte = {
+ bearbeiten = Rechte.Hat(mitarbeiter, 'labor.bearbeiten'),
+ befunden = Rechte.Hat(mitarbeiter, 'labor.befunden'),
+ stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'),
+ },
+ blutgruppen = Medizin.Config.Blutgruppen,
+ }
+end)
+
+-- =====================================================================
+-- AUFTRAG STELLEN
+-- =====================================================================
+
+Kern.RegistriereAktion('labor', 'patientSuche', 'labor.beauftragen', function(quelle, mitarbeiter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, m.blutgruppe, m.hinweis
+ FROM aether_characters c
+ LEFT JOIN medical_patient m ON m.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 20
+ ]], { muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ end
+
+ return { treffer = treffer }
+end)
+
+Kern.RegistriereAktion('labor', 'beauftragen', 'labor.beauftragen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Kein Patient gewählt.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
+ { patientId })
+ if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
+
+ local art = Probenart(daten.art)
+ if not art then return nil, 'Unbekannte Probenart.' end
+
+ local fragestellung = Text(daten.fragestellung, 512)
+ if not fragestellung then
+ return nil, 'Bitte angeben, wonach gesucht werden soll.'
+ end
+
+ local dringend = daten.dringend == true
+ local kennung = Kern.ErzeugeKennung('LAB', 'medical_labor', 'kennung')
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_labor
+ (kennung, patient, art, fragestellung, dringend, status, auftrag_von)
+ VALUES (?, ?, ?, ?, ?, 'offen', ?)
+ ]], { kennung, patientId, art, fragestellung, dringend and 1 or 0,
+ mitarbeiter.charakterId })
+
+ -- Optionale Verknüpfung mit einer Behandlung
+ local behandlungId = tonumber(daten.behandlung)
+ if behandlungId then
+ local gueltig = MySQL.scalar.await(
+ 'SELECT id FROM medical_behandlung WHERE id = ? AND patient = ?',
+ { behandlungId, patientId })
+ if gueltig then
+ MySQL.insert.await([[
+ INSERT INTO medical_verknuepfung (von_typ, von_id, zu_typ, zu_id, von)
+ VALUES ('labor', ?, 'behandlung', ?, ?)
+ ]], { tostring(id), tostring(behandlungId), mitarbeiter.charakterId })
+ end
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'labor', 'Laborauftrag gestellt',
+ { typ = 'labor', id = id },
+ { kennung = kennung, art = art, patient = patientId, dringend = dringend })
+
+ -- Das Labor erfährt davon. Bearbeitet wird nichts von selbst.
+ Hinweise.Erstelle(nil, {
+ kategorie = 'labor',
+ prioritaet = dringend and 'hoch' or 'normal',
+ titel = dringend and ('Dringender Laborauftrag · %s'):format(kennung)
+ or ('Neuer Laborauftrag · %s'):format(kennung),
+ text = ('%s %s — %s'):format(person[1].firstname, person[1].lastname, fragestellung),
+ zielTyp = 'labor', zielId = id,
+ })
+
+ Kern.Loese('labor.beauftragt', id, mitarbeiter)
+
+ return { id = id, kennung = kennung }
+end)
+
+-- =====================================================================
+-- BEARBEITUNG IM LABOR
+-- =====================================================================
+
+Kern.RegistriereAktion('labor', 'annehmen', 'labor.bearbeiten', function(quelle, mitarbeiter, daten)
+ local laborId = tonumber(daten.id)
+ if not laborId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status FROM medical_labor WHERE id = ?', { laborId })
+ if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
+ if zeilen[1].status ~= 'offen' then
+ return nil, 'Dieser Auftrag ist bereits in Arbeit oder abgeschlossen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_labor
+ SET status = 'in_arbeit', bearbeitet_von = ?, bearbeitet_am = NOW()
+ WHERE id = ?
+ ]], { mitarbeiter.charakterId, laborId })
+
+ Protokoll.Schreibe(mitarbeiter, 'labor', 'Auftrag in Arbeit genommen',
+ { typ = 'labor', id = laborId }, { kennung = zeilen[1].kennung })
+
+ return { ok = true }
+end)
+
+--- Trägt die gemessenen Werte ein. Bewertet wird hier nichts.
+Kern.RegistriereAktion('labor', 'ergebnis', 'labor.bearbeiten', function(quelle, mitarbeiter, daten)
+ local laborId = tonumber(daten.id)
+ if not laborId then return nil, 'Ungültige Kennung.' end
+
+ local ergebnis = Text(daten.ergebnis, 8000)
+ if not ergebnis or #ergebnis < 3 then return nil, 'Bitte ein Ergebnis eintragen.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT l.id, l.kennung, l.status, l.dringend, l.auftrag_von, l.art,
+ c.firstname, c.lastname
+ FROM medical_labor l
+ JOIN aether_characters c ON c.id = l.patient
+ WHERE l.id = ?
+ ]], { laborId })
+ if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
+
+ local auftrag = zeilen[1]
+ if auftrag.status == 'verworfen' then
+ return nil, 'Dieser Auftrag wurde verworfen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_labor
+ SET ergebnis = ?, status = 'fertig',
+ bearbeitet_von = COALESCE(bearbeitet_von, ?),
+ bearbeitet_am = COALESCE(bearbeitet_am, NOW())
+ WHERE id = ?
+ ]], { ergebnis, mitarbeiter.charakterId, laborId })
+
+ Protokoll.Schreibe(mitarbeiter, 'labor', 'Ergebnis eingetragen',
+ { typ = 'labor', id = laborId }, { kennung = auftrag.kennung, art = auftrag.art })
+
+ -- Den Auftraggeber sofort informieren. Bewertet ist damit nichts.
+ Hinweise.Erstelle({ charakterId = auftrag.auftrag_von }, {
+ kategorie = 'labor',
+ prioritaet = auftrag.dringend == 1 and 'hoch' or 'normal',
+ titel = ('Laborergebnis verfügbar · %s'):format(auftrag.kennung),
+ text = ('%s %s — die Bewertung steht noch aus.')
+ :format(auftrag.firstname, auftrag.lastname),
+ zielTyp = 'labor', zielId = laborId,
+ })
+
+ -- Die Fristenprüfung soll das nicht gleich noch einmal melden
+ Hinweise.AlsGemeldet('labor_' .. laborId, 'fertig')
+
+ Kern.Loese('labor.fertig', laborId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+--- Probe unbrauchbar: Auftrag verwerfen, immer mit Begründung
+Kern.RegistriereAktion('labor', 'verwerfen', 'labor.bearbeiten', function(quelle, mitarbeiter, daten)
+ local laborId = tonumber(daten.id)
+ if not laborId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 512)
+ if not grund then return nil, 'Bitte begründen, warum der Auftrag verworfen wird.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT l.id, l.kennung, l.status, l.auftrag_von, c.firstname, c.lastname
+ FROM medical_labor l
+ JOIN aether_characters c ON c.id = l.patient
+ WHERE l.id = ?
+ ]], { laborId })
+ if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
+
+ local auftrag = zeilen[1]
+ if auftrag.status == 'verworfen' then
+ return nil, 'Dieser Auftrag ist bereits verworfen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_labor
+ SET status = 'verworfen', ergebnis = ?,
+ bearbeitet_von = COALESCE(bearbeitet_von, ?),
+ bearbeitet_am = COALESCE(bearbeitet_am, NOW())
+ WHERE id = ?
+ ]], { 'Verworfen: ' .. grund, mitarbeiter.charakterId, laborId })
+
+ Protokoll.Schreibe(mitarbeiter, 'labor', 'Auftrag verworfen',
+ { typ = 'labor', id = laborId }, { kennung = auftrag.kennung }, grund)
+
+ Hinweise.Erstelle({ charakterId = auftrag.auftrag_von }, {
+ kategorie = 'labor', prioritaet = 'normal',
+ titel = ('Laborauftrag verworfen · %s'):format(auftrag.kennung),
+ text = ('%s %s — %s. Ein neuer Auftrag muss von Hand gestellt werden.')
+ :format(auftrag.firstname, auftrag.lastname, grund),
+ zielTyp = 'labor', zielId = laborId,
+ })
+
+ Hinweise.AlsGemeldet('labor_' .. laborId, 'fertig')
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- BEFUND
+--
+-- Die Bewertung eines Ergebnisses. Sie stammt immer von einem
+-- Mediziner — das System schreibt hier nie etwas hinein.
+-- =====================================================================
+
+Kern.RegistriereAktion('labor', 'befund', 'labor.befunden', function(quelle, mitarbeiter, daten)
+ local laborId = tonumber(daten.id)
+ if not laborId then return nil, 'Ungültige Kennung.' end
+
+ local befund = Text(daten.befund, 8000)
+ if not befund or #befund < 3 then return nil, 'Bitte einen Befund eintragen.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status, ergebnis FROM medical_labor WHERE id = ?', { laborId })
+ if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
+
+ local auftrag = zeilen[1]
+ if not auftrag.ergebnis then
+ return nil, 'Es liegt noch kein Ergebnis vor, das sich bewerten ließe.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_labor SET befund = ?, befund_von = ?, befund_am = NOW()
+ WHERE id = ?
+ ]], { befund, mitarbeiter.charakterId, laborId })
+
+ Protokoll.Schreibe(mitarbeiter, 'labor', 'Befund erstellt',
+ { typ = 'labor', id = laborId }, { kennung = auftrag.kennung })
+
+ Kern.Loese('labor.befundet', laborId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- BLUTGRUPPE ÜBERNEHMEN
+--
+-- Bewusst ein eigener, ausdrücklicher Schritt. Das Labor kann eine
+-- Blutgruppe bestimmt haben — in die Stammdaten trägt sie ein
+-- Mediziner ein, weil er dafür geradesteht.
+-- =====================================================================
+
+Kern.RegistriereAktion('labor', 'blutgruppeUebernehmen', 'patient.stammdaten', function(quelle, mitarbeiter, daten)
+ local laborId = tonumber(daten.id)
+ if not laborId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT l.id, l.kennung, l.patient, l.ergebnis, c.firstname, c.lastname
+ FROM medical_labor l
+ JOIN aether_characters c ON c.id = l.patient
+ WHERE l.id = ?
+ ]], { laborId })
+ if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end
+
+ local auftrag = zeilen[1]
+ if not auftrag.ergebnis then
+ return nil, 'Zu diesem Auftrag liegt kein Ergebnis vor.'
+ end
+
+ -- Die Blutgruppe wird nicht aus dem Text gelesen, sondern gewählt
+ local blutgruppe = nil
+ for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
+ if gruppe == daten.blutgruppe then blutgruppe = gruppe break end
+ end
+ if not blutgruppe then return nil, 'Bitte eine gültige Blutgruppe wählen.' end
+
+ local alt = MySQL.scalar.await(
+ 'SELECT blutgruppe FROM medical_patient WHERE `character` = ?', { auftrag.patient })
+
+ MySQL.query.await([[
+ INSERT INTO medical_patient (`character`, blutgruppe, aktualisiert_von, aktualisiert_am)
+ VALUES (?, ?, ?, NOW())
+ ON DUPLICATE KEY UPDATE
+ blutgruppe = VALUES(blutgruppe),
+ aktualisiert_von = VALUES(aktualisiert_von),
+ aktualisiert_am = NOW()
+ ]], { auftrag.patient, blutgruppe, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'labor', 'Blutgruppe in Stammdaten übernommen',
+ { typ = 'patient', id = auftrag.patient },
+ { quelle = auftrag.kennung, von = alt, nach = blutgruppe })
+
+ Kern.Loese('patient.stammdaten', auftrag.patient, mitarbeiter)
+
+ return { ok = true, blutgruppe = blutgruppe, vorher = alt }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die Laboraufträge eines Patienten, roh und unbewertet
+--- @param patientId number Charakter
+--- @return table
+exports('HoleLaborwerte', function(patientId)
+ if not tonumber(patientId) then return {} end
+ return MySQL.query.await([[
+ SELECT kennung, art, status, fragestellung, ergebnis, befund,
+ auftrag_am, befund_am
+ FROM medical_labor
+ WHERE patient = ?
+ ORDER BY id DESC LIMIT 50
+ ]], { tonumber(patientId) }) or {}
+end)
diff --git a/resources/[aether]/aether-medical/server/module/mdt.lua b/resources/[aether]/aether-medical/server/module/mdt.lua
new file mode 100644
index 0000000..f3d7f28
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/mdt.lua
@@ -0,0 +1,735 @@
+-- =====================================================================
+-- aether-medical / Modul: Medizinisches MDT
+--
+-- Das Herzstück: Patientensuche, Stammdaten und Patientenakte.
+--
+-- Das MDT zeigt an und verknüpft. Es stellt keine Diagnose, es leitet
+-- aus Allergien oder Vorerkrankungen keine Warnung her, die eine
+-- Behandlung verhindert, und es bewertet keinen Befund. Ein roter
+-- Hinweis bedeutet hier immer: ein Mediziner hat ihn eingetragen.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'mdt', name = 'MDT', icon = '🩺',
+ reihenfolge = 2, recht = 'patient.suchen',
+ beschreibung = 'Patientensuche, Stammdaten und Akten',
+})
+
+-- Zulässige Angaben zum Organspendeausweis
+local ORGANSTATUS = { ja = 'Spender', nein = 'Kein Spender', unbekannt = 'Unbekannt' }
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Prüft einen Suchbegriff
+--- @param begriff any Eingabe der Oberfläche
+--- @return boolean
+local function SuchbegriffGueltig(begriff)
+ return type(begriff) == 'string' and #begriff >= 2
+end
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Höchstlänge
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Lädt den Namen eines Mitarbeiters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.personalnummer
+ FROM aether_characters c
+ LEFT JOIN medical_personal p ON p.`character` = c.id
+ WHERE c.id = ?
+ ]], { charakterId })
+
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+ .. (zeilen[1].personalnummer and (' (' .. zeilen[1].personalnummer .. ')') or '')
+end
+
+-- =====================================================================
+-- PATIENTENSUCHE
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'suche', 'patient.suchen', function(quelle, mitarbeiter, daten)
+ if not SuchbegriffGueltig(daten.begriff) then
+ return nil, 'Bitte mindestens zwei Zeichen eingeben.'
+ end
+
+ local muster = '%' .. daten.begriff .. '%'
+
+ -- Sucht über Name, Geburtsdatum und Telefonnummer
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, c.gender,
+ m.blutgruppe, m.allergien, m.hinweis, m.lichtbild,
+ t.number AS telefon,
+ (SELECT COUNT(*) FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status = 'aufgenommen') AS imHaus,
+ (SELECT COUNT(*) FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status = 'wartend') AS wartet
+ FROM aether_characters c
+ LEFT JOIN medical_patient m ON m.`character` = c.id
+ LEFT JOIN aether_phone t ON t.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ?
+ OR c.dob LIKE ? OR t.number LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 40
+ ]], { muster, muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ eintrag.imHaus = tonumber(eintrag.imHaus) > 0
+ eintrag.wartet = tonumber(eintrag.wartet) > 0
+ end
+
+ return { treffer = treffer }
+end)
+
+-- =====================================================================
+-- PATIENTENAKTE
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'oeffnen', 'patient.suchen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, c.nationality,
+ c.job, c.is_dead
+ FROM aether_characters c
+ WHERE c.id = ? AND c.deleted = 0
+ ]], { patientId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Diese Person ist nicht erfasst.' end
+ local person = zeilen[1]
+
+ -- --- Medizinische Stammdaten ---
+ local stamm = MySQL.query.await(
+ 'SELECT * FROM medical_patient WHERE `character` = ?', { patientId })
+ stamm = stamm and stamm[1] or {}
+
+ -- --- Telefonnummer ---
+ local telefon = MySQL.scalar.await(
+ 'SELECT number FROM aether_phone WHERE `character` = ?', { patientId })
+
+ -- --- Aktuelle Aufnahme, falls vorhanden ---
+ local aufnahme = MySQL.query.await([[
+ SELECT a.id, a.grund, a.prioritaet, a.station, a.status, a.aufgenommen_am,
+ a.aufgenommen_von, z.kennung AS zimmer
+ FROM medical_aufnahme a
+ LEFT JOIN medical_zimmer z ON z.id = a.zimmer
+ WHERE a.patient = ? AND a.status IN ('wartend', 'aufgenommen')
+ ORDER BY a.id DESC LIMIT 1
+ ]], { patientId })
+ aufnahme = aufnahme and aufnahme[1] or nil
+
+ if aufnahme then
+ aufnahme.aufgenommenVon = NameVon(aufnahme.aufgenommen_von)
+ aufnahme.aufgenommen_von = nil
+ end
+
+ -- --- Liegt der Patient auf Intensiv? ---
+ local intensiv = MySQL.query.await([[
+ SELECT i.id, i.zustand, i.geraete, i.verantwortlich, i.aufgenommen_am
+ FROM medical_intensiv i
+ WHERE i.patient = ? AND i.beendet_am IS NULL
+ ORDER BY i.id DESC LIMIT 1
+ ]], { patientId })
+ intensiv = intensiv and intensiv[1] or nil
+
+ if intensiv then
+ intensiv.verantwortlichName = NameVon(intensiv.verantwortlich)
+ intensiv.verantwortlich = nil
+ end
+
+ -- --- Zählwerte für die Reiter ---
+ local zaehler = {
+ behandlungen = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_behandlung WHERE patient = ?', { patientId })) or 0,
+ operationen = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_operation WHERE patient = ?', { patientId })) or 0,
+ labor = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_labor WHERE patient = ?', { patientId })) or 0,
+ akten = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_akte WHERE patient = ?', { patientId })) or 0,
+ aufnahmen = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_aufnahme WHERE patient = ?', { patientId })) or 0,
+ notizen = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_patient_notiz WHERE patient = ?', { patientId })) or 0,
+ }
+
+ return {
+ patient = {
+ id = person.id,
+ name = person.firstname .. ' ' .. person.lastname,
+ vorname = person.firstname,
+ nachname = person.lastname,
+ geburt = tostring(person.dob):sub(1, 10),
+ geschlecht = person.gender,
+ herkunft = person.nationality,
+ beruf = person.job,
+ telefon = telefon,
+ -- Nur die Tatsache, dass der Charakter verletzt am Boden liegt.
+ -- Das System leitet daraus nichts ab und stellt nichts fest.
+ verletzt = person.is_dead == 1,
+ },
+ stamm = {
+ blutgruppe = stamm.blutgruppe,
+ allergien = stamm.allergien,
+ vorerkrankungen = stamm.vorerkrankungen,
+ dauermedikation = stamm.dauermedikation,
+ implantate = stamm.implantate,
+ organspender = stamm.organspender or 'unbekannt',
+ organspenderName = ORGANSTATUS[stamm.organspender or 'unbekannt'],
+ notfallkontakt = stamm.notfallkontakt,
+ hinweis = stamm.hinweis,
+ lichtbild = stamm.lichtbild,
+ gepflegtVon = stamm.aktualisiert_von and NameVon(stamm.aktualisiert_von) or nil,
+ gepflegtAm = stamm.aktualisiert_am,
+ },
+ aufnahme = aufnahme,
+ intensiv = intensiv,
+ zaehler = zaehler,
+ stationen = Medizin.Config.Stationen,
+ blutgruppen = Medizin.Config.Blutgruppen,
+ organstatus = ORGANSTATUS,
+ rechte = {
+ stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'),
+ hinweis = Rechte.Hat(mitarbeiter, 'patient.hinweis'),
+ akteLesen = Rechte.Hat(mitarbeiter, 'akte.lesen'),
+ akteErstellen = Rechte.Hat(mitarbeiter, 'akte.erstellen'),
+ akteBearbeiten = Rechte.Hat(mitarbeiter, 'akte.bearbeiten'),
+ akteFreigeben = Rechte.Hat(mitarbeiter, 'akte.freigeben'),
+ akteSchliessen = Rechte.Hat(mitarbeiter, 'akte.schliessen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- VERLAUF
+--
+-- Alle Vorgänge eines Patienten in einer Zeitleiste. Reine Anzeige —
+-- aus der Abfolge wird nichts abgeleitet.
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'verlauf', 'akte.lesen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Ungültige Kennung.' end
+
+ local eintraege = {}
+
+ --- Hängt einen Eintrag an die Zeitleiste
+ local function Anfuegen(art, zeitpunkt, titel, text, ziel, zielId, marker)
+ if not zeitpunkt then return end
+ eintraege[#eintraege + 1] = {
+ art = art, am = zeitpunkt, titel = titel, text = text,
+ ziel = ziel, zielId = zielId, marker = marker,
+ }
+ end
+
+ -- --- Aufnahmen und Entlassungen ---
+ local aufnahmen = MySQL.query.await([[
+ SELECT id, grund, station, status, aufgenommen_am, aufgenommen_von,
+ entlassen_am, entlassen_von, entlass_grund
+ FROM medical_aufnahme WHERE patient = ?
+ ORDER BY id DESC LIMIT 40
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(aufnahmen) do
+ Anfuegen('aufnahme', eintrag.aufgenommen_am, 'Aufnahme',
+ eintrag.grund, 'aufnahme', eintrag.id,
+ 'aufgenommen von ' .. NameVon(eintrag.aufgenommen_von))
+
+ if eintrag.entlassen_am then
+ Anfuegen('entlassung', eintrag.entlassen_am, 'Entlassung',
+ eintrag.entlass_grund, 'aufnahme', eintrag.id,
+ 'entlassen von ' .. NameVon(eintrag.entlassen_von))
+ end
+ end
+
+ -- --- Behandlungen ---
+ local behandlungen = MySQL.query.await([[
+ SELECT id, kennung, diagnose, massnahmen, status, erstellt_am, erstellt_von
+ FROM medical_behandlung WHERE patient = ?
+ ORDER BY id DESC LIMIT 40
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(behandlungen) do
+ Anfuegen('behandlung', eintrag.erstellt_am,
+ 'Behandlung ' .. eintrag.kennung,
+ eintrag.diagnose or eintrag.massnahmen or 'Noch keine Diagnose eingetragen',
+ 'behandlung', eintrag.id,
+ (eintrag.status == 'laufend' and 'läuft · ' or '') .. NameVon(eintrag.erstellt_von))
+ end
+
+ -- --- Operationen ---
+ local operationen = MySQL.query.await([[
+ SELECT id, kennung, art, status, geplant_fuer, beginn, ende, erstellt_von
+ FROM medical_operation WHERE patient = ?
+ ORDER BY id DESC LIMIT 20
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(operationen) do
+ Anfuegen('operation', eintrag.beginn or eintrag.geplant_fuer,
+ 'Operation ' .. eintrag.kennung, eintrag.art,
+ 'operation', eintrag.id,
+ eintrag.status .. ' · ' .. NameVon(eintrag.erstellt_von))
+ end
+
+ -- --- Laboraufträge ---
+ local labor = MySQL.query.await([[
+ SELECT id, kennung, art, status, fragestellung, auftrag_am, auftrag_von, befund
+ FROM medical_labor WHERE patient = ?
+ ORDER BY id DESC LIMIT 30
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(labor) do
+ Anfuegen('labor', eintrag.auftrag_am,
+ 'Labor ' .. eintrag.kennung .. ' · ' .. eintrag.art,
+ eintrag.befund or eintrag.fragestellung or 'Ohne Fragestellung',
+ 'labor', eintrag.id,
+ eintrag.status .. ' · ' .. NameVon(eintrag.auftrag_von))
+ end
+
+ -- --- Verabreichte Medikamente ---
+ local gaben = MySQL.query.await([[
+ SELECT g.id, g.dosierung, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis,
+ m.name, m.einheit
+ FROM medical_behandlung_medikament g
+ JOIN medical_behandlung b ON b.id = g.behandlung
+ JOIN medical_medikament m ON m.id = g.medikament
+ WHERE b.patient = ?
+ ORDER BY g.id DESC LIMIT 40
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(gaben) do
+ Anfuegen('medikament', eintrag.gegeben_am,
+ 'Medikamentengabe: ' .. eintrag.name,
+ ('%s %s%s'):format(eintrag.menge, eintrag.einheit,
+ eintrag.dosierung and (' · ' .. eintrag.dosierung) or ''),
+ nil, nil,
+ 'gegeben von ' .. NameVon(eintrag.gegeben_von))
+ end
+
+ -- --- Vermerke ---
+ local notizen = MySQL.query.await([[
+ SELECT id, text, von, am FROM medical_patient_notiz
+ WHERE patient = ? ORDER BY id DESC LIMIT 30
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(notizen) do
+ Anfuegen('notiz', eintrag.am, 'Vermerk', eintrag.text,
+ nil, nil, NameVon(eintrag.von))
+ end
+
+ -- Jüngstes zuerst
+ table.sort(eintraege, function(a, b) return tostring(a.am) > tostring(b.am) end)
+
+ -- Nur die letzten 60 Einträge übertragen
+ local gekuerzt = {}
+ for i = 1, math.min(#eintraege, 60) do gekuerzt[i] = eintraege[i] end
+
+ return { eintraege = gekuerzt, gesamt = #eintraege }
+end)
+
+-- =====================================================================
+-- STAMMDATEN PFLEGEN
+--
+-- Alles hier trägt ein Mediziner von Hand ein. Die Blutgruppe ist
+-- bewusst ein Freitextfeld aus einer Liste — sie wird NICHT aus einem
+-- Laborergebnis übernommen, denn das wäre eine Feststellung, die
+-- jemand verantworten muss.
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'stammdaten', 'patient.stammdaten', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Ungültige Kennung.' end
+
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { patientId })
+ if not vorhanden then return nil, 'Diese Person ist nicht erfasst.' end
+
+ -- Blutgruppe nur aus der bekannten Liste
+ local blutgruppe = nil
+ if daten.blutgruppe and daten.blutgruppe ~= '' then
+ for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do
+ if gruppe == daten.blutgruppe then blutgruppe = gruppe break end
+ end
+ if not blutgruppe then return nil, 'Unbekannte Blutgruppe.' end
+ end
+
+ local organspender = ORGANSTATUS[daten.organspender] and daten.organspender or 'unbekannt'
+
+ -- Alten Stand für das Protokoll festhalten
+ local alt = MySQL.query.await(
+ 'SELECT blutgruppe, allergien, hinweis FROM medical_patient WHERE `character` = ?',
+ { patientId })
+ alt = alt and alt[1] or {}
+
+ MySQL.query.await([[
+ INSERT INTO medical_patient
+ (`character`, blutgruppe, allergien, vorerkrankungen, dauermedikation,
+ implantate, organspender, notfallkontakt, lichtbild,
+ aktualisiert_von, aktualisiert_am)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
+ ON DUPLICATE KEY UPDATE
+ blutgruppe = VALUES(blutgruppe),
+ allergien = VALUES(allergien),
+ vorerkrankungen = VALUES(vorerkrankungen),
+ dauermedikation = VALUES(dauermedikation),
+ implantate = VALUES(implantate),
+ organspender = VALUES(organspender),
+ notfallkontakt = VALUES(notfallkontakt),
+ lichtbild = VALUES(lichtbild),
+ aktualisiert_von = VALUES(aktualisiert_von),
+ aktualisiert_am = NOW()
+ ]], {
+ patientId, blutgruppe,
+ Text(daten.allergien, 512),
+ Text(daten.vorerkrankungen, 1000),
+ Text(daten.dauermedikation, 1000),
+ Text(daten.implantate, 512),
+ organspender,
+ Text(daten.notfallkontakt, 160),
+ Text(daten.lichtbild, 512),
+ mitarbeiter.charakterId,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'mdt', 'Stammdaten gepflegt',
+ { typ = 'patient', id = patientId },
+ { blutgruppeAlt = alt.blutgruppe, blutgruppeNeu = blutgruppe })
+
+ Kern.Loese('patient.stammdaten', patientId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+--- Warnhinweis setzen oder entfernen — immer mit Begründung
+Kern.RegistriereAktion('mdt', 'hinweisSetzen', 'patient.hinweis', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Ungültige Kennung.' end
+
+ local hinweis = Text(daten.hinweis, 512)
+ local grund = Text(daten.grund, 255)
+
+ if hinweis and not grund then
+ return nil, 'Bitte begründen, warum der Warnhinweis gesetzt wird.'
+ end
+
+ MySQL.query.await([[
+ INSERT INTO medical_patient (`character`, hinweis, aktualisiert_von, aktualisiert_am)
+ VALUES (?, ?, ?, NOW())
+ ON DUPLICATE KEY UPDATE
+ hinweis = VALUES(hinweis),
+ aktualisiert_von = VALUES(aktualisiert_von),
+ aktualisiert_am = NOW()
+ ]], { patientId, hinweis, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'mdt',
+ hinweis and 'Warnhinweis gesetzt' or 'Warnhinweis entfernt',
+ { typ = 'patient', id = patientId }, { hinweis = hinweis }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- VERMERKE
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'notizen', 'akte.lesen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Ungültige Kennung.' end
+
+ local notizen = MySQL.query.await([[
+ SELECT n.id, n.text, n.am, n.von,
+ c.firstname, c.lastname, p.personalnummer
+ FROM medical_patient_notiz n
+ LEFT JOIN aether_characters c ON c.id = n.von
+ LEFT JOIN medical_personal p ON p.`character` = n.von
+ WHERE n.patient = ?
+ ORDER BY n.id DESC LIMIT 60
+ ]], { patientId }) or {}
+
+ for _, eintrag in ipairs(notizen) do
+ eintrag.autor = (eintrag.firstname or 'Unbekannt') .. ' ' .. (eintrag.lastname or '')
+ eintrag.firstname, eintrag.lastname = nil, nil
+ eintrag.eigene = eintrag.von == mitarbeiter.charakterId
+ end
+
+ return { notizen = notizen }
+end)
+
+Kern.RegistriereAktion('mdt', 'notizAnlegen', 'akte.lesen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ local text = Text(daten.text, 1000)
+
+ if not patientId then return nil, 'Ungültige Kennung.' end
+ if not text or #text < 3 then return nil, 'Der Vermerk ist zu kurz.' end
+
+ local id = MySQL.insert.await(
+ 'INSERT INTO medical_patient_notiz (patient, text, von) VALUES (?, ?, ?)',
+ { patientId, text, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'mdt', 'Vermerk angelegt',
+ { typ = 'patient', id = patientId })
+
+ return { id = id }
+end)
+
+-- =====================================================================
+-- AKTEN
+--
+-- Jede Änderung erzeugt eine neue Version. Nichts wird überschrieben,
+-- die Historie bleibt vollständig einsehbar.
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'akten', 'akte.lesen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Ungültige Kennung.' end
+
+ local akten = MySQL.query.await([[
+ SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.version,
+ a.erstellt_am, a.erstellt_von, a.freigabe_am,
+ c.firstname, c.lastname
+ FROM medical_akte a
+ LEFT JOIN aether_characters c ON c.id = a.erstellt_von
+ WHERE a.patient = ?
+ ORDER BY a.id DESC LIMIT 60
+ ]], { patientId }) or {}
+
+ local artNamen = {}
+ for _, eintrag in ipairs(Medizin.Config.Aktenarten) do artNamen[eintrag.id] = eintrag.name end
+
+ for _, akte in ipairs(akten) do
+ akte.typName = artNamen[akte.typ] or akte.typ
+ akte.autor = (akte.firstname or 'Unbekannt') .. ' ' .. (akte.lastname or '')
+ akte.firstname, akte.lastname = nil, nil
+ end
+
+ return { akten = akten, aktenarten = Medizin.Config.Aktenarten }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteOeffnen', 'akte.lesen', function(quelle, mitarbeiter, daten)
+ local akteId = tonumber(daten.id)
+ if not akteId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.*, c.firstname, c.lastname
+ FROM medical_akte a
+ LEFT JOIN aether_characters c ON c.id = a.erstellt_von
+ WHERE a.id = ?
+ ]], { akteId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Akte nicht gefunden.' end
+ local akte = zeilen[1]
+
+ -- Vollständige Versionshistorie
+ local versionen = MySQL.query.await([[
+ SELECT v.version, v.geaendert_am, v.geaendert_von, v.kommentar,
+ c.firstname, c.lastname
+ FROM medical_akte_version v
+ LEFT JOIN aether_characters c ON c.id = v.geaendert_von
+ WHERE v.akte = ?
+ ORDER BY v.version DESC
+ ]], { akteId }) or {}
+
+ for _, version in ipairs(versionen) do
+ version.autor = (version.firstname or 'Unbekannt') .. ' ' .. (version.lastname or '')
+ version.firstname, version.lastname = nil, nil
+ end
+
+ return {
+ akte = {
+ id = akte.id, aktenzeichen = akte.aktenzeichen, patient = akte.patient,
+ typ = akte.typ, titel = akte.titel, status = akte.status,
+ inhalt = akte.inhalt, version = akte.version,
+ erstelltAm = akte.erstellt_am,
+ autor = (akte.firstname or 'Unbekannt') .. ' ' .. (akte.lastname or ''),
+ freigabeAm = akte.freigabe_am,
+ freigabeVon = akte.freigabe_von and NameVon(akte.freigabe_von) or nil,
+ },
+ versionen = versionen,
+ aktenarten = Medizin.Config.Aktenarten,
+ rechte = {
+ bearbeiten = Rechte.Hat(mitarbeiter, 'akte.bearbeiten'),
+ freigeben = Rechte.Hat(mitarbeiter, 'akte.freigeben'),
+ schliessen = Rechte.Hat(mitarbeiter, 'akte.schliessen'),
+ },
+ }
+end)
+
+--- Liefert den Inhalt einer bestimmten Version
+Kern.RegistriereAktion('mdt', 'akteVersion', 'akte.lesen', function(quelle, mitarbeiter, daten)
+ local akteId = tonumber(daten.id)
+ local version = tonumber(daten.version)
+ if not akteId or not version then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT v.version, v.inhalt, v.geaendert_am, v.geaendert_von, v.kommentar
+ FROM medical_akte_version v
+ WHERE v.akte = ? AND v.version = ?
+ ]], { akteId, version })
+
+ if not zeilen or not zeilen[1] then return nil, 'Diese Version gibt es nicht.' end
+
+ local eintrag = zeilen[1]
+ eintrag.autor = NameVon(eintrag.geaendert_von)
+ eintrag.geaendert_von = nil
+
+ return eintrag
+end)
+
+Kern.RegistriereAktion('mdt', 'akteAnlegen', 'akte.erstellen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ local titel = Text(daten.titel, 160)
+
+ if not patientId then return nil, 'Kein Patient gewählt.' end
+ if not titel or #titel < 3 then return nil, 'Bitte einen Titel angeben.' end
+
+ -- Aktenart prüfen
+ local typ = nil
+ for _, eintrag in ipairs(Medizin.Config.Aktenarten) do
+ if eintrag.id == daten.typ then typ = eintrag.id break end
+ end
+ if not typ then return nil, 'Unbekannte Aktenart.' end
+
+ local inhalt = type(daten.inhalt) == 'string' and daten.inhalt:sub(1, 20000) or nil
+ local aktenzeichen = Kern.ErzeugeKennung('AKT', 'medical_akte', 'aktenzeichen')
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_akte
+ (aktenzeichen, patient, typ, titel, status, inhalt, erstellt_von, version)
+ VALUES (?, ?, ?, ?, 'entwurf', ?, ?, 1)
+ ]], { aktenzeichen, patientId, typ, titel, inhalt, mitarbeiter.charakterId })
+
+ -- Erste Version festhalten
+ MySQL.insert.await([[
+ INSERT INTO medical_akte_version (akte, version, inhalt, geaendert_von, kommentar)
+ VALUES (?, 1, ?, ?, 'Anlage')
+ ]], { id, inhalt, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'mdt', 'Akte angelegt',
+ { typ = 'akte', id = id }, { aktenzeichen = aktenzeichen, patient = patientId })
+
+ Kern.Loese('akte.angelegt', id, mitarbeiter)
+
+ return { id = id, aktenzeichen = aktenzeichen }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteSpeichern', 'akte.bearbeiten', function(quelle, mitarbeiter, daten)
+ local akteId = tonumber(daten.id)
+ if not akteId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, aktenzeichen, status, version FROM medical_akte WHERE id = ?', { akteId })
+ if not zeilen or not zeilen[1] then return nil, 'Akte nicht gefunden.' end
+
+ local akte = zeilen[1]
+ if akte.status == 'geschlossen' then
+ return nil, 'Eine geschlossene Akte lässt sich nicht mehr ändern.'
+ end
+
+ local titel = Text(daten.titel, 160)
+ if not titel or #titel < 3 then return nil, 'Bitte einen Titel angeben.' end
+
+ local inhalt = type(daten.inhalt) == 'string' and daten.inhalt:sub(1, 20000) or nil
+ local kommentar = Text(daten.kommentar, 255)
+ if not kommentar then return nil, 'Bitte kurz angeben, was geändert wurde.' end
+
+ local neueVersion = akte.version + 1
+
+ MySQL.update.await([[
+ UPDATE medical_akte SET titel = ?, inhalt = ?, version = ? WHERE id = ?
+ ]], { titel, inhalt, neueVersion, akteId })
+
+ -- Neue Version anhängen — die alte bleibt unangetastet
+ MySQL.insert.await([[
+ INSERT INTO medical_akte_version (akte, version, inhalt, geaendert_von, kommentar)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { akteId, neueVersion, inhalt, mitarbeiter.charakterId, kommentar })
+
+ Protokoll.Schreibe(mitarbeiter, 'mdt', 'Akte bearbeitet',
+ { typ = 'akte', id = akteId },
+ { aktenzeichen = akte.aktenzeichen, version = neueVersion }, kommentar)
+
+ return { version = neueVersion }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteStatus', nil, function(quelle, mitarbeiter, daten)
+ local akteId = tonumber(daten.id)
+ local status = tostring(daten.status or '')
+
+ if not akteId then return nil, 'Ungültige Kennung.' end
+
+ -- Welches Recht der Statuswechsel verlangt
+ local benoetigt = {
+ offen = 'akte.bearbeiten',
+ freigegeben = 'akte.freigeben',
+ geschlossen = 'akte.schliessen',
+ }
+ if not benoetigt[status] then return nil, 'Unbekannter Status.' end
+ if not Rechte.Hat(mitarbeiter, benoetigt[status]) then
+ return nil, 'Dir fehlt die Berechtigung für diesen Schritt.'
+ end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, aktenzeichen, status FROM medical_akte WHERE id = ?', { akteId })
+ if not zeilen or not zeilen[1] then return nil, 'Akte nicht gefunden.' end
+
+ if zeilen[1].status == 'geschlossen' then
+ return nil, 'Diese Akte ist bereits geschlossen.'
+ end
+
+ local grund = Text(daten.grund, 255)
+
+ if status == 'freigegeben' then
+ MySQL.update.await([[
+ UPDATE medical_akte SET status = ?, freigabe_von = ?, freigabe_am = NOW()
+ WHERE id = ?
+ ]], { status, mitarbeiter.charakterId, akteId })
+ else
+ MySQL.update.await('UPDATE medical_akte SET status = ? WHERE id = ?', { status, akteId })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'mdt', 'Aktenstatus geändert',
+ { typ = 'akte', id = akteId },
+ { aktenzeichen = zeilen[1].aktenzeichen, von = zeilen[1].status, nach = status }, grund)
+
+ Kern.Loese('akte.status', akteId, status, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Module
+-- ---------------------------------------------------------------------
+
+--- Liefert die Stammdaten eines Patienten, so wie sie eingetragen sind.
+--- Ausdrücklich ohne Bewertung: was hier steht, hat ein Mediziner
+--- eingetragen, und was fehlt, ist schlicht nicht erhoben.
+--- @param patientId number Charakter
+--- @return table|nil
+exports('HolePatientenStammdaten', function(patientId)
+ if not tonumber(patientId) then return nil end
+
+ local zeilen = MySQL.query.await([[
+ SELECT blutgruppe, allergien, vorerkrankungen, dauermedikation,
+ implantate, organspender, notfallkontakt, hinweis
+ FROM medical_patient WHERE `character` = ?
+ ]], { tonumber(patientId) })
+
+ return zeilen and zeilen[1] or nil
+end)
diff --git a/resources/[aether]/aether-medical/server/module/medikament.lua b/resources/[aether]/aether-medical/server/module/medikament.lua
new file mode 100644
index 0000000..d0e42ae
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/medikament.lua
@@ -0,0 +1,571 @@
+-- =====================================================================
+-- aether-medical / Modul: Medikamentenverwaltung
+--
+-- Bestandsführung und Katalogpflege. Nichts weiter.
+--
+-- Das System gibt kein Medikament aus, verordnet keines, bestellt
+-- nichts nach und entsorgt nichts. Es zählt, was Menschen gebucht
+-- haben, und erinnert, wenn ein Bestand knapp wird oder ein Datum
+-- abläuft. Was daraufhin geschieht, entscheidet die Apotheke.
+--
+-- Abgelaufene Medikamente werden AUFFÄLLIG MARKIERT, aber nicht
+-- gesperrt. Ob ein Präparat noch verwendbar ist, ist eine fachliche
+-- Beurteilung — keine Datumsrechnung.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'medikament', name = 'Medikamente', icon = '💊',
+ reihenfolge = 8, recht = 'medikament.sehen',
+ beschreibung = 'Bestand, Buchungen, Verfallsdaten und BTM-Buch',
+})
+
+-- Buchungsarten und ihre Wirkung auf den Bestand
+local BUCHUNGSARTEN = {
+ eingang = { name = 'Wareneingang', richtung = 1 },
+ ruecknahme = { name = 'Rücknahme', richtung = 1 },
+ ausgabe = { name = 'Ausgabe', richtung = -1 },
+ verfall = { name = 'Verfall entnommen', richtung = -1 },
+ korrektur = { name = 'Korrektur', richtung = 0 },
+}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Prüft eine Kategorie gegen die Konfiguration
+local function Kategorie(kennung)
+ for _, eintrag in ipairs(Medizin.Config.Medikamentkategorien) do
+ if eintrag.id == kennung then return eintrag.id end
+ end
+ return 'sonstiges'
+end
+
+-- =====================================================================
+-- BESTANDSÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('medikament', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local bedingungen, werte = {}, {}
+
+ if daten.stillgelegt == true then
+ bedingungen[#bedingungen + 1] = 'm.aktiv = 0'
+ else
+ bedingungen[#bedingungen + 1] = 'm.aktiv = 1'
+ end
+
+ if daten.kategorie and daten.kategorie ~= '' then
+ bedingungen[#bedingungen + 1] = 'm.kategorie = ?'
+ werte[#werte + 1] = daten.kategorie
+ end
+
+ -- Schnellfilter der Übersicht
+ local filter = tostring(daten.filter or '')
+ if filter == 'knapp' then
+ bedingungen[#bedingungen + 1] = 'm.bestand <= m.mindestbestand'
+ elseif filter == 'leer' then
+ bedingungen[#bedingungen + 1] = 'm.bestand <= 0'
+ elseif filter == 'verfall' then
+ bedingungen[#bedingungen + 1] =
+ 'm.verfaellt_am IS NOT NULL AND m.verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)'
+ werte[#werte + 1] = Medizin.Config.Erinnerungen.medikamentVorlaufTage
+ elseif filter == 'btm' then
+ bedingungen[#bedingungen + 1] = 'm.betaeubungsmittel = 1'
+ end
+
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(m.kennung LIKE ? OR m.name LIKE ? OR m.wirkstoff LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ for _ = 1, 3 do werte[#werte + 1] = muster end
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT m.id, m.kennung, m.name, m.wirkstoff, m.kategorie,
+ m.dosierung_standard, m.einheit, m.bestand, m.mindestbestand,
+ m.lagerort, m.verfaellt_am, m.hinweis, m.betaeubungsmittel, m.aktiv,
+ DATEDIFF(m.verfaellt_am, CURDATE()) AS tageBisVerfall,
+ (SELECT COUNT(*) FROM medical_medikament_buchung b
+ WHERE b.medikament = m.id AND b.art = 'ausgabe'
+ AND b.gebucht_am >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS ausgabenWoche
+ FROM medical_medikament m
+ WHERE %s
+ ORDER BY m.kategorie, m.sortierung, m.name
+ LIMIT 300
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ local kategorieNamen = {}
+ for _, eintrag in ipairs(Medizin.Config.Medikamentkategorien) do
+ kategorieNamen[eintrag.id] = eintrag.name
+ end
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.kategorieName = kategorieNamen[eintrag.kategorie] or eintrag.kategorie
+ eintrag.btm = eintrag.betaeubungsmittel == 1
+ eintrag.aktiv = eintrag.aktiv == 1
+ eintrag.knapp = eintrag.bestand <= eintrag.mindestbestand
+ eintrag.leer = eintrag.bestand <= 0
+ local tage = tonumber(eintrag.tageBisVerfall)
+ eintrag.abgelaufen = tage ~= nil and tage < 0
+ eintrag.laeuftAb = tage ~= nil and tage >= 0
+ and tage <= Medizin.Config.Erinnerungen.medikamentVorlaufTage
+ eintrag.betaeubungsmittel = nil
+ end
+
+ return {
+ liste = liste,
+ kategorien = Medizin.Config.Medikamentkategorien,
+ buchungsarten = BUCHUNGSARTEN,
+ filter = filter,
+ stillgelegt = daten.stillgelegt == true,
+ zaehler = {
+ gesamt = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1')) or 0,
+ knapp = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_medikament
+ WHERE aktiv = 1 AND bestand > 0 AND bestand <= mindestbestand
+ ]])) or 0,
+ leer = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND bestand <= 0')) or 0,
+ verfall = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_medikament
+ WHERE aktiv = 1 AND verfaellt_am IS NOT NULL
+ AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)
+ ]], { Medizin.Config.Erinnerungen.medikamentVorlaufTage })) or 0,
+ btm = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND betaeubungsmittel = 1')) or 0,
+ },
+ rechte = {
+ bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'),
+ btm = Rechte.Hat(mitarbeiter, 'medikament.btm'),
+ katalog = Rechte.Hat(mitarbeiter, 'katalog.bearbeiten'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('medikament', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local medikamentId = tonumber(daten.id)
+ if not medikamentId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT m.*, DATEDIFF(m.verfaellt_am, CURDATE()) AS tageBisVerfall
+ FROM medical_medikament m WHERE m.id = ?
+ ]], { medikamentId })
+ if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end
+
+ local m = zeilen[1]
+
+ -- Buchungshistorie
+ local buchungen = MySQL.query.await([[
+ SELECT b.id, b.art, b.menge, b.bestand_danach, b.grund, b.gebucht_am,
+ b.gebucht_von, b.patient,
+ c.firstname, c.lastname
+ FROM medical_medikament_buchung b
+ LEFT JOIN aether_characters c ON c.id = b.patient
+ WHERE b.medikament = ?
+ ORDER BY b.id DESC LIMIT 60
+ ]], { medikamentId }) or {}
+
+ for _, buchung in ipairs(buchungen) do
+ buchung.artName = BUCHUNGSARTEN[buchung.art] and BUCHUNGSARTEN[buchung.art].name
+ or buchung.art
+ buchung.autor = NameVon(buchung.gebucht_von)
+ buchung.patientName = buchung.firstname
+ and (buchung.firstname .. ' ' .. buchung.lastname) or nil
+ buchung.firstname, buchung.lastname, buchung.gebucht_von = nil, nil, nil
+ end
+
+ -- Zuletzt dokumentierte Gaben
+ local gaben = MySQL.query.await([[
+ SELECT g.id, g.menge, g.dosierung, g.gegeben_am, g.gegeben_von, g.hinweis,
+ b.kennung AS behandlung, c.firstname, c.lastname
+ FROM medical_behandlung_medikament g
+ JOIN medical_behandlung b ON b.id = g.behandlung
+ LEFT JOIN aether_characters c ON c.id = b.patient
+ WHERE g.medikament = ?
+ ORDER BY g.id DESC LIMIT 30
+ ]], { medikamentId }) or {}
+
+ for _, gabe in ipairs(gaben) do
+ gabe.autor = NameVon(gabe.gegeben_von)
+ gabe.patientName = (gabe.firstname or 'Unbekannt') .. ' ' .. (gabe.lastname or '')
+ gabe.ohneBuchung = gabe.hinweis
+ and gabe.hinweis:find('ohne Bestandsbuchung', 1, true) ~= nil or false
+ gabe.firstname, gabe.lastname, gabe.gegeben_von = nil, nil, nil
+ end
+
+ local tage = tonumber(m.tageBisVerfall)
+
+ return {
+ medikament = {
+ id = m.id, kennung = m.kennung, name = m.name, wirkstoff = m.wirkstoff,
+ kategorie = m.kategorie, dosierung = m.dosierung_standard,
+ einheit = m.einheit, bestand = m.bestand, mindestbestand = m.mindestbestand,
+ lagerort = m.lagerort, verfaelltAm = m.verfaellt_am,
+ tageBisVerfall = tage,
+ abgelaufen = tage ~= nil and tage < 0,
+ hinweis = m.hinweis,
+ btm = m.betaeubungsmittel == 1,
+ aktiv = m.aktiv == 1,
+ sortierung = m.sortierung,
+ },
+ buchungen = buchungen,
+ gaben = gaben,
+ kategorien = Medizin.Config.Medikamentkategorien,
+ buchungsarten = BUCHUNGSARTEN,
+ rechte = {
+ bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'),
+ btm = Rechte.Hat(mitarbeiter, 'medikament.btm'),
+ katalog = Rechte.Hat(mitarbeiter, 'katalog.bearbeiten'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- BESTAND BUCHEN
+--
+-- Jede Buchung ist die Aufzeichnung dessen, was ein Mensch im Schrank
+-- getan hat. Das System bewegt nichts von selbst.
+-- =====================================================================
+
+Kern.RegistriereAktion('medikament', 'buchen', 'medikament.bestand', function(quelle, mitarbeiter, daten)
+ local medikamentId = tonumber(daten.id)
+ local art = tostring(daten.art or '')
+ local menge = math.floor(tonumber(daten.menge) or 0)
+
+ if not medikamentId then return nil, 'Ungültige Kennung.' end
+ if not BUCHUNGSARTEN[art] then return nil, 'Unbekannte Buchungsart.' end
+ if art == 'ausgabe' then
+ return nil, 'Eine Ausgabe entsteht nur aus einer dokumentierten Gabe.'
+ end
+ if menge < 1 or menge > 100000 then return nil, 'Ungültige Menge.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte einen Grund für die Buchung angeben.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT id, kennung, name, einheit, bestand, mindestbestand, betaeubungsmittel
+ FROM medical_medikament WHERE id = ?
+ ]], { medikamentId })
+ if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end
+
+ local medikament = zeilen[1]
+
+ -- Betäubungsmittel erfordern zusätzlich das eigene Recht
+ if medikament.betaeubungsmittel == 1 and not Rechte.Hat(mitarbeiter, 'medikament.btm') then
+ return nil, 'Betäubungsmittel darfst du nicht buchen.'
+ end
+
+ local richtung = BUCHUNGSARTEN[art].richtung
+ local neuerBestand
+
+ if art == 'korrektur' then
+ -- Eine Korrektur setzt den Bestand auf den gezählten Wert
+ neuerBestand = menge
+ else
+ neuerBestand = medikament.bestand + (richtung * menge)
+ if neuerBestand < 0 then
+ return nil, ('Das ergäbe einen Bestand von %d. Vorrätig sind %d %s.')
+ :format(neuerBestand, medikament.bestand, medikament.einheit)
+ end
+ end
+
+ MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?',
+ { neuerBestand, medikamentId })
+
+ MySQL.insert.await([[
+ INSERT INTO medical_medikament_buchung
+ (medikament, art, menge, bestand_danach, grund, gebucht_von)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ]], { medikamentId, art, menge, neuerBestand, grund, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'medikament',
+ ('Bestand gebucht: %s'):format(BUCHUNGSARTEN[art].name),
+ { typ = 'medikament', id = medikamentId },
+ { kennung = medikament.kennung, menge = menge,
+ vorher = medikament.bestand, nachher = neuerBestand,
+ btm = medikament.betaeubungsmittel == 1 }, grund)
+
+ -- Erinnern, wenn es knapp wird — nachbestellt wird nichts
+ if neuerBestand <= medikament.mindestbestand then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'medikament',
+ prioritaet = neuerBestand <= 0 and 'hoch' or 'normal',
+ titel = neuerBestand <= 0 and 'Medikament nicht mehr vorrätig'
+ or 'Medikament unter Mindestbestand',
+ text = ('%s — noch %d %s.'):format(medikament.name, neuerBestand,
+ medikament.einheit),
+ zielTyp = 'medikament', zielId = medikamentId,
+ })
+ end
+
+ Kern.Loese('medikament.gebucht', medikamentId, art, mitarbeiter)
+
+ return { bestand = neuerBestand }
+end)
+
+-- =====================================================================
+-- KATALOG PFLEGEN
+--
+-- Werte hier sind Stammdaten und Vorschläge. Die Standarddosierung
+-- ist ein Anhaltspunkt, keine Verordnung.
+-- =====================================================================
+
+Kern.RegistriereAktion('medikament', 'speichern', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten)
+ local kennung = Text(daten.kennung, 24)
+ local name = Text(daten.name, 120)
+
+ if not kennung or #kennung < 2 then return nil, 'Bitte eine Kennung angeben.' end
+ if not name or #name < 2 then return nil, 'Bitte einen Namen angeben.' end
+
+ kennung = kennung:gsub('%s', ''):upper()
+
+ local id = tonumber(daten.id)
+ local btm = daten.btm == true
+
+ -- Betäubungsmittel darf nur kennzeichnen, wer sie auch buchen darf
+ if btm and not Rechte.Hat(mitarbeiter, 'medikament.btm') then
+ return nil, 'Nur mit BTM-Recht lässt sich ein Präparat als Betäubungsmittel führen.'
+ end
+
+ local belegt = MySQL.scalar.await(
+ 'SELECT id FROM medical_medikament WHERE kennung = ? AND (? IS NULL OR id <> ?)',
+ { kennung, id, id })
+ if belegt then return nil, 'Diese Kennung ist bereits vergeben.' end
+
+ local mindestbestand = math.max(0, math.floor(tonumber(daten.mindestbestand) or 0))
+ local sortierung = math.floor(tonumber(daten.sortierung) or 0)
+ local verfaellt = Text(daten.verfaelltAm, 10) -- Format JJJJ-MM-TT
+
+ if verfaellt and not verfaellt:match('^%d%d%d%d%-%d%d%-%d%d$') then
+ return nil, 'Das Verfallsdatum muss im Format JJJJ-MM-TT stehen.'
+ end
+
+ if id then
+ local alt = MySQL.query.await(
+ 'SELECT kennung, name, mindestbestand, verfaellt_am FROM medical_medikament WHERE id = ?',
+ { id })
+ if not alt or not alt[1] then return nil, 'Medikament nicht gefunden.' end
+
+ MySQL.update.await([[
+ UPDATE medical_medikament
+ SET kennung = ?, name = ?, wirkstoff = ?, kategorie = ?,
+ dosierung_standard = ?, einheit = ?, mindestbestand = ?,
+ lagerort = ?, verfaellt_am = ?, hinweis = ?,
+ betaeubungsmittel = ?, sortierung = ?
+ WHERE id = ?
+ ]], {
+ kennung, name, Text(daten.wirkstoff, 120), Kategorie(daten.kategorie),
+ Text(daten.dosierung, 64), Text(daten.einheit, 24) or 'Stück',
+ mindestbestand, Text(daten.lagerort, 64), verfaellt,
+ Text(daten.hinweis, 255), btm and 1 or 0, sortierung, id,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'medikament', 'Medikament bearbeitet',
+ { typ = 'medikament', id = id },
+ { kennung = kennung,
+ verfallAlt = alt[1].verfaellt_am, verfallNeu = verfaellt,
+ mindestAlt = alt[1].mindestbestand, mindestNeu = mindestbestand })
+ else
+ -- Ein neues Präparat startet mit Bestand 0. Der Zugang wird gebucht.
+ id = MySQL.insert.await([[
+ INSERT INTO medical_medikament
+ (kennung, name, wirkstoff, kategorie, dosierung_standard, einheit,
+ bestand, mindestbestand, lagerort, verfaellt_am, hinweis,
+ betaeubungsmittel, sortierung)
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
+ ]], {
+ kennung, name, Text(daten.wirkstoff, 120), Kategorie(daten.kategorie),
+ Text(daten.dosierung, 64), Text(daten.einheit, 24) or 'Stück',
+ mindestbestand, Text(daten.lagerort, 64), verfaellt,
+ Text(daten.hinweis, 255), btm and 1 or 0, sortierung,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'medikament', 'Medikament angelegt',
+ { typ = 'medikament', id = id }, { kennung = kennung, btm = btm })
+ end
+
+ Kern.Loese('medikament.katalog', id, mitarbeiter)
+
+ return { id = id, kennung = kennung }
+end)
+
+--- Stilllegen statt löschen: alte Gaben bleiben nachvollziehbar
+Kern.RegistriereAktion('medikament', 'schalten', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten)
+ local medikamentId = tonumber(daten.id)
+ if not medikamentId then return nil, 'Ungültige Kennung.' end
+
+ local aktiv = daten.aktiv == true
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, name, bestand, einheit FROM medical_medikament WHERE id = ?',
+ { medikamentId })
+ if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end
+
+ local medikament = zeilen[1]
+
+ -- Ein Restbestand wird gemeldet, aber nicht weggebucht
+ if not aktiv and medikament.bestand > 0 then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'medikament', prioritaet = 'normal',
+ titel = 'Stillgelegtes Medikament mit Restbestand',
+ text = ('%s wurde stillgelegt, im Schrank liegen noch %d %s. Die Entnahme muss gebucht werden.')
+ :format(medikament.name, medikament.bestand, medikament.einheit),
+ zielTyp = 'medikament', zielId = medikamentId,
+ })
+ end
+
+ MySQL.update.await('UPDATE medical_medikament SET aktiv = ? WHERE id = ?',
+ { aktiv and 1 or 0, medikamentId })
+
+ Protokoll.Schreibe(mitarbeiter, 'medikament',
+ aktiv and 'Medikament wieder freigegeben' or 'Medikament stillgelegt',
+ { typ = 'medikament', id = medikamentId },
+ { kennung = medikament.kennung, restbestand = medikament.bestand },
+ Text(daten.grund, 255))
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- BTM-BUCH
+--
+-- Lückenlose Bewegungsliste aller Betäubungsmittel. Reine Auskunft;
+-- hier lässt sich nichts ändern.
+-- =====================================================================
+
+Kern.RegistriereAktion('medikament', 'btmBuch', 'medikament.btm', function(quelle, mitarbeiter, daten)
+ local medikamentId = tonumber(daten.medikament)
+
+ local bedingungen = { 'm.betaeubungsmittel = 1' }
+ local werte = {}
+
+ if medikamentId then
+ bedingungen[#bedingungen + 1] = 'm.id = ?'
+ werte[#werte + 1] = medikamentId
+ end
+
+ local bewegungen = MySQL.query.await(([[
+ SELECT b.id, b.art, b.menge, b.bestand_danach, b.grund, b.gebucht_am,
+ b.gebucht_von, b.patient,
+ m.id AS medikamentId, m.kennung, m.name, m.einheit,
+ c.firstname, c.lastname
+ FROM medical_medikament_buchung b
+ JOIN medical_medikament m ON m.id = b.medikament
+ LEFT JOIN aether_characters c ON c.id = b.patient
+ WHERE %s
+ ORDER BY b.id DESC
+ LIMIT 200
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ for _, eintrag in ipairs(bewegungen) do
+ eintrag.artName = BUCHUNGSARTEN[eintrag.art] and BUCHUNGSARTEN[eintrag.art].name
+ or eintrag.art
+ eintrag.autor = NameVon(eintrag.gebucht_von)
+ eintrag.patientName = eintrag.firstname
+ and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil
+ eintrag.firstname, eintrag.lastname, eintrag.gebucht_von = nil, nil, nil
+ end
+
+ -- Gaben, die ohne Bestandsbuchung dokumentiert wurden.
+ -- Das ist keine Unregelmässigkeit, sondern ein bewusster Vermerk —
+ -- er gehört aber ins Buch, damit die Zahlen erklärbar bleiben.
+ local ohneBuchung = MySQL.query.await([[
+ SELECT g.id, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis,
+ m.kennung, m.name, m.einheit,
+ bh.kennung AS behandlung,
+ c.firstname, c.lastname
+ FROM medical_behandlung_medikament g
+ JOIN medical_medikament m ON m.id = g.medikament
+ JOIN medical_behandlung bh ON bh.id = g.behandlung
+ LEFT JOIN aether_characters c ON c.id = bh.patient
+ WHERE m.betaeubungsmittel = 1
+ AND g.hinweis LIKE '%ohne Bestandsbuchung%'
+ ORDER BY g.id DESC LIMIT 60
+ ]]) or {}
+
+ for _, eintrag in ipairs(ohneBuchung) do
+ eintrag.autor = NameVon(eintrag.gegeben_von)
+ eintrag.patientName = (eintrag.firstname or 'Unbekannt')
+ .. ' ' .. (eintrag.lastname or '')
+ eintrag.firstname, eintrag.lastname, eintrag.gegeben_von = nil, nil, nil
+ end
+
+ -- Aktueller Stand aller Betäubungsmittel
+ local bestaende = MySQL.query.await([[
+ SELECT id, kennung, name, einheit, bestand, mindestbestand, lagerort,
+ verfaellt_am, DATEDIFF(verfaellt_am, CURDATE()) AS tageBisVerfall
+ FROM medical_medikament
+ WHERE betaeubungsmittel = 1 AND aktiv = 1
+ ORDER BY name
+ ]]) or {}
+
+ for _, eintrag in ipairs(bestaende) do
+ local tage = tonumber(eintrag.tageBisVerfall)
+ eintrag.abgelaufen = tage ~= nil and tage < 0
+ end
+
+ return {
+ bewegungen = bewegungen,
+ ohneBuchung = ohneBuchung,
+ bestaende = bestaende,
+ gewaehlt = medikamentId,
+ }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert den Bestand eines Medikaments
+--- @param kennung string Kennung, z. B. 'ANA-01'
+--- @return table|nil
+exports('HoleBestand', function(kennung)
+ if type(kennung) ~= 'string' then return nil end
+ local zeilen = MySQL.query.await([[
+ SELECT kennung, name, einheit, bestand, mindestbestand, verfaellt_am, aktiv
+ FROM medical_medikament WHERE kennung = ?
+ ]], { kennung })
+ return zeilen and zeilen[1] or nil
+end)
+
+--- Meldet einen Wareneingang aus einer anderen Ressource.
+--- Der buchende Mitarbeiter muss im Dienst sein — das System bucht nie
+--- aus eigenem Antrieb.
+--- @param charakterId number Buchender Mitarbeiter
+--- @param kennung string Medikament
+--- @param menge number Anzahl
+--- @param grund string Begründung
+--- @return number|nil Neuer Bestand
+exports('BucheEingang', function(charakterId, kennung, menge, grund)
+ local quelle = Dienst.QuelleVonCharakter(tonumber(charakterId) or 0)
+ local mitarbeiter = quelle and Dienst.Hole(quelle)
+ if not mitarbeiter then return nil end
+
+ local medikament = MySQL.scalar.await(
+ 'SELECT id FROM medical_medikament WHERE kennung = ?', { tostring(kennung) })
+ if not medikament then return nil end
+
+ local ergebnis = Kern.FuehreAus(quelle, mitarbeiter, 'medikament', 'buchen', {
+ id = medikament, art = 'eingang',
+ menge = menge, grund = grund or 'Wareneingang',
+ })
+
+ return ergebnis and ergebnis.bestand or nil
+end)
diff --git a/resources/[aether]/aether-medical/server/module/operation.lua b/resources/[aether]/aether-medical/server/module/operation.lua
new file mode 100644
index 0000000..7401816
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/operation.lua
@@ -0,0 +1,807 @@
+-- =====================================================================
+-- aether-medical / Modul: Operationsverwaltung
+--
+-- Operationen werden vollständig manuell dokumentiert.
+--
+-- Das System führt keine Operation durch, startet keine, beendet keine
+-- und entscheidet nie über einen Ausgang. Der geplante Zeitpunkt ist
+-- eine Erinnerung — er beginnt nichts. Beginn und Ende trägt das
+-- OP-Team ein, wenn es soweit ist.
+--
+-- Überschneidungen im OP-Saal werden GEMELDET, nicht verhindert.
+-- Ob zwei Eingriffe im selben Saal ein Problem sind, weiss nur ein
+-- Mensch — vielleicht dauert der erste kürzer als geplant.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'operation', name = 'Operationen', icon = '🔪',
+ reihenfolge = 7,
+ recht = { 'operation.planen', 'operation.dokumentieren' },
+ beschreibung = 'OP-Planung, OP-Team und Operationsbericht',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Prüft eine Funktion im OP-Team
+--- @param kennung any Eingabe
+--- @return string
+local function TeamFunktion(kennung)
+ for _, eintrag in ipairs(Medizin.Config.OpFunktionen) do
+ if eintrag.id == kennung then return eintrag.id end
+ end
+ return 'assistenz'
+end
+
+--- Lädt das OP-Team einer Operation
+--- @param operationId number Operation
+--- @return table
+local function LadeTeam(operationId)
+ local team = MySQL.query.await([[
+ SELECT t.`character`, t.funktion,
+ c.firstname, c.lastname, p.personalnummer, p.rolle
+ FROM medical_operation_team t
+ LEFT JOIN aether_characters c ON c.id = t.`character`
+ LEFT JOIN medical_personal p ON p.`character` = t.`character`
+ WHERE t.operation = ?
+ ]], { operationId }) or {}
+
+ local funktionsNamen = {}
+ for _, eintrag in ipairs(Medizin.Config.OpFunktionen) do
+ funktionsNamen[eintrag.id] = eintrag.name
+ end
+
+ for _, mitglied in ipairs(team) do
+ mitglied.name = (mitglied.firstname or 'Unbekannt') .. ' ' .. (mitglied.lastname or '')
+ local rolle = Medizin.HoleRolle(mitglied.rolle)
+ mitglied.rolleName = rolle and rolle.name or mitglied.rolle
+ mitglied.funktionName = funktionsNamen[mitglied.funktion] or mitglied.funktion
+ mitglied.firstname, mitglied.lastname = nil, nil
+ end
+
+ -- Operateur zuerst, dann der Rest
+ table.sort(team, function(a, b)
+ if a.funktion == b.funktion then return (a.name or '') < (b.name or '') end
+ return a.funktion == 'operateur'
+ end)
+
+ return team
+end
+
+--- Sucht Operationen, die sich im selben Saal zeitlich überschneiden.
+--- Reine Auskunft — das Ergebnis verhindert nichts.
+--- @param saalId number Zimmer
+--- @param geplantFuer string|nil Zeitpunkt
+--- @param ohneId number|nil Eigene Operation ausklammern
+--- @return table
+local function Ueberschneidungen(saalId, geplantFuer, ohneId)
+ if not saalId then return {} end
+
+ local treffer = MySQL.query.await([[
+ SELECT o.id, o.kennung, o.art, o.status, o.geplant_fuer, o.beginn,
+ c.firstname, c.lastname
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ WHERE o.saal = ?
+ AND o.status IN ('geplant', 'laufend')
+ AND (? IS NULL OR o.id <> ?)
+ AND (
+ o.status = 'laufend'
+ OR (? IS NOT NULL AND o.geplant_fuer IS NOT NULL
+ AND ABS(TIMESTAMPDIFF(MINUTE, o.geplant_fuer, ?)) < 120)
+ )
+ ORDER BY o.geplant_fuer
+ ]], { saalId, ohneId, ohneId, geplantFuer, geplantFuer }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.patientName = (eintrag.firstname or '') .. ' ' .. (eintrag.lastname or '')
+ eintrag.firstname, eintrag.lastname = nil, nil
+ end
+
+ return treffer
+end
+
+-- =====================================================================
+-- ÜBERSICHT UND OP-PLAN
+-- =====================================================================
+
+Kern.RegistriereAktion('operation', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'plan')
+
+ local bedingung
+ if ordner == 'archiv' then
+ bedingung = "o.status IN ('abgeschlossen', 'abgesagt')"
+ elseif ordner == 'laufend' then
+ bedingung = "o.status = 'laufend'"
+ else
+ bedingung = "o.status IN ('geplant', 'laufend')"
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT o.id, o.kennung, o.patient, o.art, o.saal, o.status,
+ o.geplant_fuer, o.beginn, o.ende, o.erstellt_von, o.komplikationen,
+ TIMESTAMPDIFF(MINUTE, NOW(), o.geplant_fuer) AS restMinuten,
+ TIMESTAMPDIFF(MINUTE, o.beginn, COALESCE(o.ende, NOW())) AS dauerMinuten,
+ c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.hinweis,
+ z.kennung AS saalName,
+ (SELECT COUNT(*) FROM medical_operation_team t WHERE t.operation = o.id) AS teamGroesse,
+ (SELECT COUNT(*) FROM medical_operation_team t
+ WHERE t.operation = o.id AND t.`character` = ?) AS ichDabei
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ LEFT JOIN medical_patient m ON m.`character` = o.patient
+ LEFT JOIN medical_zimmer z ON z.id = o.saal
+ WHERE %s
+ ORDER BY
+ CASE o.status WHEN 'laufend' THEN 0 ELSE 1 END,
+ o.geplant_fuer IS NULL, o.geplant_fuer, o.id DESC
+ LIMIT 120
+ ]]):format(bedingung), { mitarbeiter.charakterId }) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
+ eintrag.erstelltVon = NameVon(eintrag.erstellt_von)
+ eintrag.ichDabei = tonumber(eintrag.ichDabei) > 0
+ eintrag.teamGroesse = tonumber(eintrag.teamGroesse) or 0
+ eintrag.hatKomplikationen = eintrag.komplikationen ~= nil
+ eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
+ eintrag.erstellt_von, eintrag.komplikationen = nil, nil
+ end
+
+ -- OP-Säle mit ihrer aktuellen Lage
+ local saele = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.status, z.hinweis, s.name AS stationName
+ FROM medical_zimmer z
+ JOIN medical_station s ON s.id = z.station
+ WHERE z.art = 'op'
+ ORDER BY z.kennung
+ ]]) or {}
+
+ for _, saal in ipairs(saele) do
+ local laufend = MySQL.query.await([[
+ SELECT o.id, o.kennung, o.art, o.beginn,
+ TIMESTAMPDIFF(MINUTE, o.beginn, NOW()) AS dauer,
+ c.firstname, c.lastname
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ WHERE o.saal = ? AND o.status = 'laufend'
+ ORDER BY o.beginn LIMIT 1
+ ]], { saal.id })
+
+ if laufend and laufend[1] then
+ saal.laufend = {
+ id = laufend[1].id, kennung = laufend[1].kennung,
+ art = laufend[1].art, dauer = tonumber(laufend[1].dauer) or 0,
+ patientName = laufend[1].firstname .. ' ' .. laufend[1].lastname,
+ }
+ end
+
+ saal.geplant = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_operation
+ WHERE saal = ? AND status = 'geplant'
+ ]], { saal.id })) or 0
+ end
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ saele = saele,
+ funktionen = Medizin.Config.OpFunktionen,
+ zaehler = {
+ geplant = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_operation WHERE status = 'geplant'")) or 0,
+ laufend = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_operation WHERE status = 'laufend'")) or 0,
+ faellig = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_operation
+ WHERE status = 'geplant' AND geplant_fuer IS NOT NULL AND geplant_fuer <= NOW()
+ ]])) or 0,
+ heute = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_operation
+ WHERE status = 'abgeschlossen' AND DATE(ende) = CURDATE()
+ ]])) or 0,
+ },
+ rechte = {
+ planen = Rechte.Hat(mitarbeiter, 'operation.planen'),
+ dokumentieren = Rechte.Hat(mitarbeiter, 'operation.dokumentieren'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('operation', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT o.*, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.vorerkrankungen, m.dauermedikation,
+ m.implantate, m.hinweis, m.lichtbild,
+ z.kennung AS saalName,
+ a.grund AS aufnahmeGrund, a.status AS aufnahmeStatus
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ LEFT JOIN medical_patient m ON m.`character` = o.patient
+ LEFT JOIN medical_zimmer z ON z.id = o.saal
+ LEFT JOIN medical_aufnahme a ON a.id = o.aufnahme
+ WHERE o.id = ?
+ ]], { operationId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end
+ local o = zeilen[1]
+
+ local team = LadeTeam(operationId)
+ local ichDabei = false
+ for _, mitglied in ipairs(team) do
+ if mitglied.character == mitarbeiter.charakterId then ichDabei = true end
+ end
+
+ -- Verfügbare OP-Säle für eine Umplanung
+ local saele = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.status FROM medical_zimmer z
+ WHERE z.art = 'op' ORDER BY z.kennung
+ ]]) or {}
+
+ return {
+ operation = {
+ id = o.id, kennung = o.kennung, patient = o.patient,
+ patientName = o.firstname .. ' ' .. o.lastname,
+ geburt = tostring(o.dob):sub(1, 10),
+ art = o.art, saal = o.saal, saalName = o.saalName,
+ aufnahme = o.aufnahme, aufnahmeGrund = o.aufnahmeGrund,
+ geplantFuer = o.geplant_fuer,
+ beginn = o.beginn, ende = o.ende,
+ verlauf = o.verlauf, komplikationen = o.komplikationen,
+ nachsorge = o.nachsorge,
+ status = o.status,
+ erstelltAm = o.erstellt_am,
+ erstelltVon = NameVon(o.erstellt_von),
+ },
+ stamm = {
+ blutgruppe = o.blutgruppe, allergien = o.allergien,
+ vorerkrankungen = o.vorerkrankungen, dauermedikation = o.dauermedikation,
+ implantate = o.implantate, hinweis = o.hinweis, lichtbild = o.lichtbild,
+ },
+ team = team,
+ ichDabei = ichDabei,
+ saele = saele,
+ funktionen = Medizin.Config.OpFunktionen,
+ -- Zeitgleiche Eingriffe im selben Saal — als Hinweis, nicht als Sperre
+ ueberschneidungen = Ueberschneidungen(o.saal, o.geplant_fuer, o.id),
+ rechte = {
+ planen = Rechte.Hat(mitarbeiter, 'operation.planen'),
+ dokumentieren = Rechte.Hat(mitarbeiter, 'operation.dokumentieren'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- PLANEN
+-- =====================================================================
+
+Kern.RegistriereAktion('operation', 'patientSuche', 'operation.planen', function(quelle, mitarbeiter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {}, saele = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.hinweis,
+ (SELECT a.id FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status = 'aufgenommen'
+ ORDER BY a.id DESC LIMIT 1) AS aufnahme
+ FROM aether_characters c
+ LEFT JOIN medical_patient m ON m.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 20
+ ]], { muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ end
+
+ local saele = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.status,
+ (SELECT COUNT(*) FROM medical_operation o
+ WHERE o.saal = z.id AND o.status = 'laufend') AS belegt
+ FROM medical_zimmer z WHERE z.art = 'op' ORDER BY z.kennung
+ ]]) or {}
+
+ -- Kollegen im Dienst als mögliche Teammitglieder
+ local kollegen = {}
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ kollegen[#kollegen + 1] = {
+ charakterId = eintrag.charakterId,
+ name = eintrag.name,
+ personalnummer = eintrag.personalnummer,
+ rolleName = eintrag.rolleName,
+ }
+ end
+ table.sort(kollegen, function(a, b) return a.personalnummer < b.personalnummer end)
+
+ return { treffer = treffer, saele = saele, kollegen = kollegen }
+end)
+
+--- Kollegen im Dienst, die sich ins OP-Team einteilen lassen
+Kern.RegistriereAktion('operation', 'kollegen', 'operation.planen', function(quelle, mitarbeiter)
+ local kollegen = {}
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ kollegen[#kollegen + 1] = {
+ charakterId = eintrag.charakterId,
+ name = eintrag.name,
+ personalnummer = eintrag.personalnummer,
+ rolleName = eintrag.rolleName,
+ funktion = eintrag.funktion,
+ }
+ end
+ table.sort(kollegen, function(a, b) return a.personalnummer < b.personalnummer end)
+
+ return { kollegen = kollegen, funktionen = Medizin.Config.OpFunktionen }
+end)
+
+Kern.RegistriereAktion('operation', 'planen', 'operation.planen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ if not patientId then return nil, 'Kein Patient gewählt.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
+ { patientId })
+ if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
+
+ local art = Text(daten.art, 160)
+ if not art or #art < 3 then return nil, 'Bitte die Art des Eingriffs angeben.' end
+
+ -- OP-Saal ist freiwillig, muss aber existieren
+ local saalId = nil
+ if daten.saal and tonumber(daten.saal) then
+ local saal = MySQL.query.await(
+ "SELECT id, kennung, status FROM medical_zimmer WHERE id = ? AND art = 'op'",
+ { tonumber(daten.saal) })
+ if not saal or not saal[1] then return nil, 'Unbekannter OP-Saal.' end
+ if saal[1].status == 'gesperrt' then
+ return nil, ('OP-Saal %s ist gesperrt.'):format(saal[1].kennung)
+ end
+ saalId = saal[1].id
+ end
+
+ -- Zeitpunkt in Minuten ab jetzt. 0 heisst: keine Zeitplanung.
+ local vorlauf = math.floor(tonumber(daten.vorlaufMinuten) or 0)
+ if vorlauf < 0 or vorlauf > 20160 then
+ return nil, 'Der Vorlauf muss zwischen 0 Minuten und 14 Tagen liegen.'
+ end
+
+ local aufnahmeId = tonumber(daten.aufnahme)
+ if aufnahmeId then
+ local gueltig = MySQL.scalar.await(
+ 'SELECT id FROM medical_aufnahme WHERE id = ? AND patient = ?',
+ { aufnahmeId, patientId })
+ if not gueltig then aufnahmeId = nil end
+ end
+
+ local kennung = Kern.ErzeugeKennung('OP', 'medical_operation', 'kennung')
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_operation
+ (kennung, patient, aufnahme, art, saal, geplant_fuer, status, erstellt_von)
+ VALUES (?, ?, ?, ?, ?,
+ CASE WHEN ? > 0 THEN DATE_ADD(NOW(), INTERVAL ? MINUTE) ELSE NULL END,
+ 'geplant', ?)
+ ]], { kennung, patientId, aufnahmeId, art, saalId, vorlauf, vorlauf,
+ mitarbeiter.charakterId })
+
+ -- Der Planende gehört als Operateur ins Team, sofern nicht anders gewünscht
+ if daten.selbstOperateur ~= false then
+ MySQL.query.await([[
+ INSERT INTO medical_operation_team (operation, `character`, funktion)
+ VALUES (?, ?, 'operateur')
+ ON DUPLICATE KEY UPDATE funktion = VALUES(funktion)
+ ]], { id, mitarbeiter.charakterId })
+ end
+
+ -- Weitere Teammitglieder aus der Planung
+ if type(daten.team) == 'table' then
+ for _, mitglied in ipairs(daten.team) do
+ local charakterId = tonumber(mitglied.charakterId)
+ if charakterId and charakterId ~= mitarbeiter.charakterId then
+ MySQL.query.await([[
+ INSERT INTO medical_operation_team (operation, `character`, funktion)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE funktion = VALUES(funktion)
+ ]], { id, charakterId, TeamFunktion(mitglied.funktion) })
+ end
+ end
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation geplant',
+ { typ = 'operation', id = id },
+ { kennung = kennung, patient = patientId, art = art,
+ saal = saalId, vorlaufMinuten = vorlauf })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'op', prioritaet = 'normal',
+ titel = ('Operation geplant · %s'):format(kennung),
+ text = ('%s %s — %s%s'):format(person[1].firstname, person[1].lastname, art,
+ vorlauf > 0 and (' (in %d Minuten)'):format(vorlauf) or ' (ohne Zeitangabe)'),
+ zielTyp = 'operation', zielId = id,
+ })
+
+ Kern.Loese('operation.geplant', id, mitarbeiter)
+
+ return {
+ id = id, kennung = kennung,
+ ueberschneidungen = saalId and #Ueberschneidungen(saalId, nil, id) or 0,
+ }
+end)
+
+--- Zeitpunkt oder Saal einer geplanten Operation ändern
+Kern.RegistriereAktion('operation', 'umplanen', 'operation.planen', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status, saal, geplant_fuer FROM medical_operation WHERE id = ?',
+ { operationId })
+ if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end
+
+ local operation = zeilen[1]
+ if operation.status ~= 'geplant' then
+ return nil, 'Nur geplante Operationen lassen sich umplanen.'
+ end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte begründen, warum umgeplant wird.' end
+
+ local saalId = nil
+ if daten.saal and tonumber(daten.saal) then
+ local saal = MySQL.query.await(
+ "SELECT id, kennung, status FROM medical_zimmer WHERE id = ? AND art = 'op'",
+ { tonumber(daten.saal) })
+ if not saal or not saal[1] then return nil, 'Unbekannter OP-Saal.' end
+ if saal[1].status == 'gesperrt' then
+ return nil, ('OP-Saal %s ist gesperrt.'):format(saal[1].kennung)
+ end
+ saalId = saal[1].id
+ end
+
+ local vorlauf = math.floor(tonumber(daten.vorlaufMinuten) or 0)
+ if vorlauf < 0 or vorlauf > 20160 then
+ return nil, 'Der Vorlauf muss zwischen 0 Minuten und 14 Tagen liegen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_operation
+ SET saal = ?,
+ geplant_fuer = CASE WHEN ? > 0 THEN DATE_ADD(NOW(), INTERVAL ? MINUTE) ELSE NULL END
+ WHERE id = ?
+ ]], { saalId, vorlauf, vorlauf, operationId })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation umgeplant',
+ { typ = 'operation', id = operationId },
+ { kennung = operation.kennung, saal = saalId, vorlaufMinuten = vorlauf }, grund)
+
+ Kern.Loese('operation.umgeplant', operationId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('operation', 'absagen', 'operation.planen', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 512)
+ if not grund or #grund < 5 then return nil, 'Eine Absage erfordert eine Begründung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT o.id, o.kennung, o.status, o.art, c.firstname, c.lastname
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ WHERE o.id = ?
+ ]], { operationId })
+ if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end
+
+ local operation = zeilen[1]
+ if operation.status == 'abgeschlossen' then
+ return nil, 'Eine abgeschlossene Operation lässt sich nicht absagen.'
+ end
+ if operation.status == 'abgesagt' then
+ return nil, 'Diese Operation ist bereits abgesagt.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_operation
+ SET status = 'abgesagt',
+ verlauf = CONCAT(COALESCE(verlauf, ''), ?)
+ WHERE id = ?
+ ]], { '\n[Abgesagt] ' .. grund, operationId })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation abgesagt',
+ { typ = 'operation', id = operationId },
+ { kennung = operation.kennung, warLaufend = operation.status == 'laufend' }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'op', prioritaet = 'normal',
+ titel = ('Operation abgesagt · %s'):format(operation.kennung),
+ text = ('%s %s — %s'):format(operation.firstname, operation.lastname, grund),
+ zielTyp = 'operation', zielId = operationId,
+ })
+
+ Kern.Loese('operation.abgeschlossen', operationId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- OP-TEAM
+-- =====================================================================
+
+Kern.RegistriereAktion('operation', 'teamSetzen', 'operation.planen', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ local charakterId = tonumber(daten.charakterId)
+
+ if not operationId or not charakterId then return nil, 'Unvollständige Angaben.' end
+
+ local operation = MySQL.query.await(
+ 'SELECT id, kennung, status FROM medical_operation WHERE id = ?', { operationId })
+ if not operation or not operation[1] then return nil, 'Operation nicht gefunden.' end
+ if operation[1].status == 'abgeschlossen' or operation[1].status == 'abgesagt' then
+ return nil, 'Das Team einer abgeschlossenen Operation lässt sich nicht ändern.'
+ end
+
+ local personal = MySQL.scalar.await(
+ 'SELECT `character` FROM medical_personal WHERE `character` = ? AND aktiv = 1',
+ { charakterId })
+ if not personal then return nil, 'Diese Person gehört nicht zum Personal.' end
+
+ local funktion = TeamFunktion(daten.funktion)
+
+ MySQL.query.await([[
+ INSERT INTO medical_operation_team (operation, `character`, funktion)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE funktion = VALUES(funktion)
+ ]], { operationId, charakterId, funktion })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Team geändert',
+ { typ = 'operation', id = operationId },
+ { kennung = operation[1].kennung, mitglied = charakterId, funktion = funktion })
+
+ -- Das Teammitglied erfährt davon; teilnehmen muss es trotzdem selbst
+ Hinweise.Erstelle({ charakterId = charakterId }, {
+ kategorie = 'op', prioritaet = 'normal',
+ titel = ('Du bist im OP-Team · %s'):format(operation[1].kennung),
+ text = ('Eingeteilt als %s durch %s.')
+ :format(funktion, mitarbeiter.name),
+ zielTyp = 'operation', zielId = operationId,
+ })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('operation', 'teamEntfernen', 'operation.planen', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ local charakterId = tonumber(daten.charakterId)
+
+ if not operationId or not charakterId then return nil, 'Unvollständige Angaben.' end
+
+ MySQL.query.await(
+ 'DELETE FROM medical_operation_team WHERE operation = ? AND `character` = ?',
+ { operationId, charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'Aus OP-Team entfernt',
+ { typ = 'operation', id = operationId }, { mitglied = charakterId },
+ Text(daten.grund, 255))
+
+ return { ok = true }
+end)
+
+--- Selbst dazustossen oder das Team verlassen
+Kern.RegistriereAktion('operation', 'teamWechsel', 'operation.dokumentieren', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local operation = MySQL.query.await(
+ 'SELECT id, kennung, status FROM medical_operation WHERE id = ?', { operationId })
+ if not operation or not operation[1] then return nil, 'Operation nicht gefunden.' end
+ if operation[1].status == 'abgeschlossen' or operation[1].status == 'abgesagt' then
+ return nil, 'Diese Operation ist abgeschlossen.'
+ end
+
+ if daten.beitreten == false then
+ MySQL.query.await(
+ 'DELETE FROM medical_operation_team WHERE operation = ? AND `character` = ?',
+ { operationId, mitarbeiter.charakterId })
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Team verlassen',
+ { typ = 'operation', id = operationId }, { kennung = operation[1].kennung })
+ return { ok = true, dabei = false }
+ end
+
+ MySQL.query.await([[
+ INSERT INTO medical_operation_team (operation, `character`, funktion)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE funktion = VALUES(funktion)
+ ]], { operationId, mitarbeiter.charakterId, TeamFunktion(daten.funktion) })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Team beigetreten',
+ { typ = 'operation', id = operationId },
+ { kennung = operation[1].kennung, funktion = TeamFunktion(daten.funktion) })
+
+ return { ok = true, dabei = true }
+end)
+
+-- =====================================================================
+-- DURCHFÜHRUNG
+--
+-- Beginn und Ende trägt das OP-Team ein. Der geplante Zeitpunkt
+-- startet nichts — er erinnert nur.
+-- =====================================================================
+
+Kern.RegistriereAktion('operation', 'beginnen', 'operation.dokumentieren', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT o.id, o.kennung, o.status, o.art, o.saal,
+ c.firstname, c.lastname
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ WHERE o.id = ?
+ ]], { operationId })
+ if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end
+
+ local operation = zeilen[1]
+ if operation.status ~= 'geplant' then
+ return nil, 'Nur eine geplante Operation lässt sich beginnen.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_operation SET status = 'laufend', beginn = NOW() WHERE id = ?
+ ]], { operationId })
+
+ -- Wer den Beginn einträgt, gehört ins Team
+ MySQL.query.await([[
+ INSERT INTO medical_operation_team (operation, `character`, funktion)
+ VALUES (?, ?, 'assistenz')
+ ON DUPLICATE KEY UPDATE funktion = funktion
+ ]], { operationId, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation begonnen',
+ { typ = 'operation', id = operationId }, { kennung = operation.kennung })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'op', prioritaet = 'normal',
+ titel = ('Operation begonnen · %s'):format(operation.kennung),
+ text = ('%s %s — %s'):format(operation.firstname, operation.lastname, operation.art),
+ zielTyp = 'operation', zielId = operationId,
+ })
+
+ Kern.Loese('operation.begonnen', operationId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+--- Verlauf, Komplikationen und Nachsorge dokumentieren
+Kern.RegistriereAktion('operation', 'dokumentieren', 'operation.dokumentieren', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status FROM medical_operation WHERE id = ?', { operationId })
+ if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end
+
+ if zeilen[1].status == 'abgesagt' then
+ return nil, 'Diese Operation wurde abgesagt.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_operation
+ SET art = COALESCE(?, art), verlauf = ?, komplikationen = ?, nachsorge = ?
+ WHERE id = ?
+ ]], {
+ Text(daten.art, 160),
+ Text(daten.verlauf, 8000),
+ Text(daten.komplikationen, 4000),
+ Text(daten.nachsorge, 4000),
+ operationId,
+ })
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Bericht gespeichert',
+ { typ = 'operation', id = operationId }, { kennung = zeilen[1].kennung })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('operation', 'beenden', 'operation.dokumentieren', function(quelle, mitarbeiter, daten)
+ local operationId = tonumber(daten.id)
+ if not operationId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT o.id, o.kennung, o.status, o.verlauf, o.beginn,
+ c.firstname, c.lastname
+ FROM medical_operation o
+ JOIN aether_characters c ON c.id = o.patient
+ WHERE o.id = ?
+ ]], { operationId })
+ if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end
+
+ local operation = zeilen[1]
+ if operation.status ~= 'laufend' then
+ return nil, 'Nur eine laufende Operation lässt sich beenden.'
+ end
+
+ -- Ein OP-Bericht ohne Verlauf ist kein Bericht.
+ -- Geprüft wird nur, DASS etwas dasteht — nicht, was.
+ local verlauf = Text(daten.verlauf, 8000) or operation.verlauf
+ if not verlauf then
+ return nil, 'Bitte zuerst den Verlauf dokumentieren.'
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_operation
+ SET status = 'abgeschlossen', ende = NOW(),
+ verlauf = ?, komplikationen = ?, nachsorge = ?
+ WHERE id = ?
+ ]], {
+ verlauf,
+ Text(daten.komplikationen, 4000),
+ Text(daten.nachsorge, 4000),
+ operationId,
+ })
+
+ local team = LadeTeam(operationId)
+
+ Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation abgeschlossen',
+ { typ = 'operation', id = operationId },
+ { kennung = operation.kennung, teamGroesse = #team,
+ mitKomplikationen = Text(daten.komplikationen, 4000) ~= nil })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'op', prioritaet = 'normal',
+ titel = ('Operation abgeschlossen · %s'):format(operation.kennung),
+ text = ('%s %s — OP-Saal wird frei.')
+ :format(operation.firstname, operation.lastname),
+ zielTyp = 'operation', zielId = operationId,
+ })
+
+ Kern.Loese('operation.abgeschlossen', operationId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die Operationen eines Patienten
+--- @param patientId number Charakter
+--- @return table
+exports('HoleOperationen', function(patientId)
+ if not tonumber(patientId) then return {} end
+ return MySQL.query.await([[
+ SELECT kennung, art, status, geplant_fuer, beginn, ende,
+ verlauf, komplikationen, nachsorge
+ FROM medical_operation
+ WHERE patient = ?
+ ORDER BY id DESC LIMIT 50
+ ]], { tonumber(patientId) }) or {}
+end)
diff --git a/resources/[aether]/aether-medical/server/module/patienten.lua b/resources/[aether]/aether-medical/server/module/patienten.lua
new file mode 100644
index 0000000..bc699b6
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/patienten.lua
@@ -0,0 +1,806 @@
+-- =====================================================================
+-- aether-medical / Modul: Patientenverwaltung
+--
+-- Anmeldung, Warteliste, Aufnahme, Verlegung und Entlassung.
+--
+-- Nichts davon geschieht von selbst. Das System führt die Warteliste
+-- und zeigt, welche Zimmer frei sind — welcher Patient wann und wohin
+-- aufgenommen wird, entscheidet ausschliesslich ein Mediziner.
+--
+-- Besonders wichtig: die Dringlichkeit wird NICHT aus der Wartezeit
+-- berechnet. Ein Patient, der lange wartet, rutscht nicht automatisch
+-- nach oben — das System erinnert nur daran, dass er wartet.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'patienten', name = 'Patienten', icon = '🧍',
+ reihenfolge = 3, recht = 'patient.suchen',
+ beschreibung = 'Anmeldung, Warteliste, Belegung und Entlassung',
+})
+
+-- Dringlichkeitsstufen. Reine Beschriftung — keine Stufe löst etwas aus.
+local DRINGLICHKEIT = {
+ [1] = { name = 'Sofort', hinweis = 'Lebensbedrohlich' },
+ [2] = { name = 'Dringend', hinweis = 'Zeitkritisch' },
+ [3] = { name = 'Regulär', hinweis = 'Kann warten' },
+ [4] = { name = 'Nachrangig', hinweis = 'Nicht akut' },
+}
+
+-- ---------------------------------------------------------------------
+-- Grundbestand an Stationen und Zimmern
+--
+-- Wird einmalig angelegt, damit sich überhaupt jemand aufnehmen
+-- lässt. Die Verwaltung übernimmt später die Krankenhausverwaltung.
+-- ---------------------------------------------------------------------
+local GRUNDSTATIONEN = {
+ -- kennung, name, art, etage, zimmer = { kennung, betten, art }
+ { 'NA', 'Notaufnahme', 'notaufnahme', 'EG', {
+ { 'NA-1', 1, 'behandlung' }, { 'NA-2', 1, 'behandlung' },
+ { 'NA-3', 1, 'behandlung' }, { 'SCHOCK', 1, 'behandlung' },
+ }},
+ { 'CH', 'Chirurgie', 'normal', '1. OG', {
+ { 'CH-101', 2, 'bett' }, { 'CH-102', 2, 'bett' }, { 'CH-103', 1, 'bett' },
+ }},
+ { 'IN', 'Innere Medizin', 'normal', '1. OG', {
+ { 'IN-111', 2, 'bett' }, { 'IN-112', 2, 'bett' },
+ }},
+ { 'ITS', 'Intensivstation', 'intensiv', '2. OG', {
+ { 'ITS-1', 1, 'intensiv' }, { 'ITS-2', 1, 'intensiv' },
+ { 'ITS-3', 1, 'intensiv' }, { 'ISO-1', 1, 'isolierung' },
+ }},
+ { 'OP', 'OP-Bereich', 'op', '2. OG', {
+ { 'OP-1', 1, 'op' }, { 'OP-2', 1, 'op' },
+ }},
+}
+
+CreateThread(function()
+ Wait(1500)
+
+ local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_station')) or 0
+ if vorhanden > 0 then return end
+
+ for reihenfolge, eintrag in ipairs(GRUNDSTATIONEN) do
+ local stationId = MySQL.insert.await([[
+ INSERT INTO medical_station (kennung, name, art, etage, sortierung)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4], reihenfolge })
+
+ for _, zimmer in ipairs(eintrag[5]) do
+ MySQL.insert.await([[
+ INSERT INTO medical_zimmer (station, kennung, betten, art)
+ VALUES (?, ?, ?, ?)
+ ]], { stationId, zimmer[1], zimmer[2], zimmer[3] })
+ end
+ end
+
+ Aether.Debug('Grundbestand an Stationen und Zimmern angelegt.')
+end)
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Prüft eine Dringlichkeitsstufe
+--- @param wert any Eingabe
+--- @return number 1 bis 4
+local function Dringlichkeit(wert)
+ local stufe = tonumber(wert) or 3
+ if stufe < 1 or stufe > 4 then return 3 end
+ return math.floor(stufe)
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.personalnummer
+ FROM aether_characters c
+ LEFT JOIN medical_personal p ON p.`character` = c.id
+ WHERE c.id = ?
+ ]], { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Wie viele Betten eines Zimmers sind gerade belegt?
+--- @param zimmerId number Zimmer
+--- @return number
+local function BelegteBetten(zimmerId)
+ return tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL',
+ { zimmerId })) or 0
+end
+
+--- Sucht ein freies Bett in einem Zimmer
+--- @param zimmerId number Zimmer
+--- @param betten number Bettenzahl des Zimmers
+--- @return number|nil Bettnummer
+local function FreiesBett(zimmerId, betten)
+ local belegt = MySQL.query.await(
+ 'SELECT bett FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL', { zimmerId }) or {}
+
+ local genommen = {}
+ for _, eintrag in ipairs(belegt) do genommen[tonumber(eintrag.bett)] = true end
+
+ for nummer = 1, betten do
+ if not genommen[nummer] then return nummer end
+ end
+ return nil
+end
+
+--- Gibt alle Betten einer Aufnahme frei
+--- @param aufnahmeId number Aufnahme
+--- @param mitarbeiter table Handelnder
+--- @return table Liste der frei gewordenen Zimmer
+local function BettenFreigeben(aufnahmeId, mitarbeiter)
+ local belegungen = MySQL.query.await([[
+ SELECT b.id, b.zimmer, z.kennung, z.betten
+ FROM medical_belegung b
+ JOIN medical_zimmer z ON z.id = b.zimmer
+ WHERE b.aufnahme = ? AND b.frei_am IS NULL
+ ]], { aufnahmeId }) or {}
+
+ for _, belegung in ipairs(belegungen) do
+ MySQL.update.await(
+ 'UPDATE medical_belegung SET frei_am = NOW(), frei_von = ? WHERE id = ?',
+ { mitarbeiter.charakterId, belegung.id })
+ end
+
+ return belegungen
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('patienten', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local ordner = tostring(daten.ordner or 'warteliste')
+
+ local bedingung, werte
+ if ordner == 'entlassen' then
+ bedingung = "a.status = 'entlassen'"
+ werte = {}
+ elseif ordner == 'station' then
+ bedingung = "a.status = 'aufgenommen'"
+ werte = {}
+ else
+ bedingung = "a.status = 'wartend'"
+ werte = {}
+ end
+
+ if daten.station and daten.station ~= '' then
+ bedingung = bedingung .. ' AND a.station = ?'
+ werte[#werte + 1] = daten.station
+ end
+
+ local liste = MySQL.query.await(([[
+ SELECT a.id, a.patient, a.grund, a.prioritaet, a.station, a.status,
+ a.aufgenommen_am, a.aufgenommen_von, a.entlassen_am, a.entlassen_von,
+ a.entlass_grund,
+ TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet,
+ c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.hinweis,
+ z.kennung AS zimmer, s.name AS stationName,
+ (SELECT COUNT(*) FROM medical_intensiv i
+ WHERE i.aufnahme = a.id AND i.beendet_am IS NULL) AS aufIntensiv
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ LEFT JOIN medical_patient m ON m.`character` = a.patient
+ LEFT JOIN medical_zimmer z ON z.id = a.zimmer
+ LEFT JOIN medical_station s ON s.kennung = a.station
+ WHERE %s
+ ORDER BY a.prioritaet, a.aufgenommen_am
+ LIMIT 150
+ ]]):format(bedingung), werte) or {}
+
+ for _, eintrag in ipairs(liste) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.geburt = tostring(eintrag.dob):sub(1, 10)
+ eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil
+ eintrag.dringlichkeit = DRINGLICHKEIT[eintrag.prioritaet]
+ and DRINGLICHKEIT[eintrag.prioritaet].name or '—'
+ eintrag.aufgenommenVon = NameVon(eintrag.aufgenommen_von)
+ eintrag.entlassenVon = eintrag.entlassen_von and NameVon(eintrag.entlassen_von) or nil
+ eintrag.aufIntensiv = tonumber(eintrag.aufIntensiv) > 0
+ eintrag.aufgenommen_von, eintrag.entlassen_von = nil, nil
+ end
+
+ -- Zählwerte für die Reiter
+ local zaehler = {
+ wartend = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'wartend'")) or 0,
+ aufgenommen = tonumber(MySQL.scalar.await(
+ "SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'aufgenommen'")) or 0,
+ entlassenHeute = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_aufnahme
+ WHERE status = 'entlassen' AND DATE(entlassen_am) = CURDATE()
+ ]])) or 0,
+ }
+
+ -- Belegung je Station: was ist frei, was ist belegt
+ local stationen = MySQL.query.await([[
+ SELECT s.id, s.kennung, s.name, s.art, s.etage,
+ COALESCE(SUM(z.betten), 0) AS betten,
+ COUNT(z.id) AS zimmer
+ FROM medical_station s
+ LEFT JOIN medical_zimmer z ON z.station = s.id AND z.status <> 'gesperrt'
+ WHERE s.aktiv = 1
+ GROUP BY s.id
+ ORDER BY s.sortierung, s.kennung
+ ]]) or {}
+
+ for _, station in ipairs(stationen) do
+ station.belegt = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_belegung b
+ JOIN medical_zimmer z ON z.id = b.zimmer
+ WHERE z.station = ? AND b.frei_am IS NULL
+ ]], { station.id })) or 0
+ station.betten = tonumber(station.betten) or 0
+ station.frei = math.max(0, station.betten - station.belegt)
+ end
+
+ return {
+ ordner = ordner,
+ liste = liste,
+ zaehler = zaehler,
+ stationen = stationen,
+ dringlichkeit = DRINGLICHKEIT,
+ rechte = {
+ aufnehmen = Rechte.Hat(mitarbeiter, 'patient.aufnehmen'),
+ verlegen = Rechte.Hat(mitarbeiter, 'patient.verlegen'),
+ entlassen = Rechte.Hat(mitarbeiter, 'patient.entlassen'),
+ belegen = Rechte.Hat(mitarbeiter, 'station.belegen'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('patienten', 'oeffnen', nil, function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.id)
+ if not aufnahmeId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.*, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.vorerkrankungen, m.dauermedikation,
+ m.hinweis, m.notfallkontakt, m.lichtbild,
+ z.kennung AS zimmer, z.art AS zimmerArt, s.name AS stationName
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ LEFT JOIN medical_patient m ON m.`character` = a.patient
+ LEFT JOIN medical_zimmer z ON z.id = a.zimmer
+ LEFT JOIN medical_station s ON s.kennung = a.station
+ WHERE a.id = ?
+ ]], { aufnahmeId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end
+ local a = zeilen[1]
+
+ -- Welches Bett belegt der Patient gerade?
+ local bett = MySQL.query.await([[
+ SELECT b.bett, b.belegt_am, b.belegt_von, z.kennung
+ FROM medical_belegung b
+ JOIN medical_zimmer z ON z.id = b.zimmer
+ WHERE b.aufnahme = ? AND b.frei_am IS NULL
+ LIMIT 1
+ ]], { aufnahmeId })
+ bett = bett and bett[1] or nil
+
+ -- Laufende Behandlungen zu dieser Aufnahme
+ local behandlungen = MySQL.query.await([[
+ SELECT id, kennung, diagnose, status, erstellt_am, erstellt_von
+ FROM medical_behandlung WHERE aufnahme = ?
+ ORDER BY id DESC LIMIT 20
+ ]], { aufnahmeId }) or {}
+
+ for _, eintrag in ipairs(behandlungen) do
+ eintrag.autor = NameVon(eintrag.erstellt_von)
+ eintrag.erstellt_von = nil
+ end
+
+ return {
+ aufnahme = {
+ id = a.id, patient = a.patient,
+ name = a.firstname .. ' ' .. a.lastname,
+ geburt = tostring(a.dob):sub(1, 10),
+ grund = a.grund, prioritaet = a.prioritaet,
+ dringlichkeit = DRINGLICHKEIT[a.prioritaet] and DRINGLICHKEIT[a.prioritaet].name or '—',
+ station = a.station, stationName = a.stationName,
+ zimmer = a.zimmer, zimmerArt = a.zimmerArt,
+ bett = bett and bett.bett or nil,
+ status = a.status,
+ aufgenommenAm = a.aufgenommen_am,
+ aufgenommenVon = NameVon(a.aufgenommen_von),
+ entlassenAm = a.entlassen_am,
+ entlassenVon = a.entlassen_von and NameVon(a.entlassen_von) or nil,
+ entlassGrund = a.entlass_grund,
+ },
+ stamm = {
+ blutgruppe = a.blutgruppe, allergien = a.allergien,
+ vorerkrankungen = a.vorerkrankungen, dauermedikation = a.dauermedikation,
+ hinweis = a.hinweis, notfallkontakt = a.notfallkontakt,
+ lichtbild = a.lichtbild,
+ },
+ behandlungen = behandlungen,
+ dringlichkeit = DRINGLICHKEIT,
+ rechte = {
+ aufnehmen = Rechte.Hat(mitarbeiter, 'patient.aufnehmen'),
+ verlegen = Rechte.Hat(mitarbeiter, 'patient.verlegen'),
+ entlassen = Rechte.Hat(mitarbeiter, 'patient.entlassen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- ANMELDUNG
+--
+-- Der erste Schritt: ein Patient meldet sich oder wird gebracht.
+-- Er landet auf der Warteliste — mehr passiert nicht.
+-- =====================================================================
+
+Kern.RegistriereAktion('patienten', 'anmelden', 'patient.aufnehmen', function(quelle, mitarbeiter, daten)
+ local patientId = tonumber(daten.patient)
+ local grund = Text(daten.grund, 512)
+
+ if not patientId then return nil, 'Kein Patient gewählt.' end
+ if not grund or #grund < 3 then return nil, 'Bitte den Grund der Vorstellung angeben.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
+ { patientId })
+ if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end
+
+ -- Doppelte Anmeldung verhindern
+ local offen = MySQL.scalar.await([[
+ SELECT id FROM medical_aufnahme
+ WHERE patient = ? AND status IN ('wartend', 'aufgenommen')
+ ]], { patientId })
+ if offen then
+ return nil, 'Für diesen Patienten läuft bereits eine Aufnahme.'
+ end
+
+ local prioritaet = Dringlichkeit(daten.prioritaet)
+
+ local id = MySQL.insert.await([[
+ INSERT INTO medical_aufnahme
+ (patient, grund, prioritaet, status, aufgenommen_von)
+ VALUES (?, ?, ?, 'wartend', ?)
+ ]], { patientId, grund, prioritaet, mitarbeiter.charakterId })
+
+ Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient angemeldet',
+ { typ = 'aufnahme', id = id },
+ { patient = patientId, prioritaet = prioritaet })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient',
+ prioritaet = prioritaet <= 2 and 'hoch' or 'normal',
+ titel = ('Patient wartet · %s'):format(DRINGLICHKEIT[prioritaet].name),
+ text = ('%s %s — %s'):format(person[1].firstname, person[1].lastname, grund:sub(1, 80)),
+ zielTyp = 'aufnahme', zielId = id,
+ })
+
+ Kern.Loese('aufnahme.angemeldet', id, mitarbeiter)
+
+ return { id = id }
+end)
+
+--- Personensuche für die Anmeldung
+Kern.RegistriereAktion('patienten', 'patientSuche', 'patient.aufnehmen', function(quelle, mitarbeiter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob,
+ m.blutgruppe, m.allergien, m.hinweis,
+ (SELECT a.status FROM medical_aufnahme a
+ WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen')
+ LIMIT 1) AS laufend
+ FROM aether_characters c
+ LEFT JOIN medical_patient m ON m.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 20
+ ]], { muster, muster, muster, muster }) or {}
+
+ for _, eintrag in ipairs(treffer) do
+ eintrag.dob = tostring(eintrag.dob):sub(1, 10)
+ end
+
+ return { treffer = treffer }
+end)
+
+-- =====================================================================
+-- AUFNAHME AUF STATION
+-- =====================================================================
+
+--- Freie Zimmer einer Station — reine Auskunft, das System wählt keines aus
+Kern.RegistriereAktion('patienten', 'freieZimmer', nil, function(quelle, mitarbeiter, daten)
+ local bedingung, werte = 's.aktiv = 1', {}
+
+ if daten.station and daten.station ~= '' then
+ bedingung = bedingung .. ' AND s.kennung = ?'
+ werte[#werte + 1] = daten.station
+ end
+
+ local zimmer = MySQL.query.await(([[
+ SELECT z.id, z.kennung, z.betten, z.art, z.status, z.hinweis,
+ s.kennung AS station, s.name AS stationName, s.art AS stationArt
+ FROM medical_zimmer z
+ JOIN medical_station s ON s.id = z.station
+ WHERE %s
+ ORDER BY s.sortierung, z.kennung
+ ]]):format(bedingung), werte) or {}
+
+ for _, eintrag in ipairs(zimmer) do
+ eintrag.belegt = BelegteBetten(eintrag.id)
+ eintrag.frei = math.max(0, eintrag.betten - eintrag.belegt)
+ -- Verfügbar heisst: nicht gesperrt, nicht in Reinigung, noch Platz
+ eintrag.verfuegbar = eintrag.status == 'frei' and eintrag.frei > 0
+ end
+
+ return { zimmer = zimmer }
+end)
+
+Kern.RegistriereAktion('patienten', 'aufnehmen', 'patient.aufnehmen', function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.id)
+ if not aufnahmeId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.id, a.patient, a.status, c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.id = ?
+ ]], { aufnahmeId })
+ if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end
+
+ local aufnahme = zeilen[1]
+ if aufnahme.status ~= 'wartend' then
+ return nil, 'Dieser Patient ist bereits aufgenommen oder entlassen.'
+ end
+
+ -- Station prüfen
+ local station = MySQL.query.await(
+ 'SELECT kennung, name FROM medical_station WHERE kennung = ? AND aktiv = 1',
+ { tostring(daten.station or '') })
+ if not station or not station[1] then return nil, 'Unbekannte Station.' end
+
+ -- Zimmer ist freiwillig — ein Patient kann auch ohne Bett aufgenommen werden
+ local zimmerId, bettNummer = nil, nil
+ if daten.zimmer and tonumber(daten.zimmer) then
+ local zimmer = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.betten, z.status, s.kennung AS station
+ FROM medical_zimmer z
+ JOIN medical_station s ON s.id = z.station
+ WHERE z.id = ?
+ ]], { tonumber(daten.zimmer) })
+ if not zimmer or not zimmer[1] then return nil, 'Unbekanntes Zimmer.' end
+
+ if zimmer[1].status ~= 'frei' then
+ return nil, ('Zimmer %s steht nicht zur Verfügung (%s).')
+ :format(zimmer[1].kennung, zimmer[1].status)
+ end
+
+ bettNummer = FreiesBett(zimmer[1].id, zimmer[1].betten)
+ if not bettNummer then
+ return nil, ('In Zimmer %s ist kein Bett mehr frei.'):format(zimmer[1].kennung)
+ end
+
+ zimmerId = zimmer[1].id
+ end
+
+ MySQL.update.await([[
+ UPDATE medical_aufnahme
+ SET status = 'aufgenommen', station = ?, zimmer = ?
+ WHERE id = ?
+ ]], { station[1].kennung, zimmerId, aufnahmeId })
+
+ if zimmerId then
+ MySQL.insert.await([[
+ INSERT INTO medical_belegung (zimmer, bett, patient, aufnahme, belegt_von)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { zimmerId, bettNummer, aufnahme.patient, aufnahmeId, mitarbeiter.charakterId })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient aufgenommen',
+ { typ = 'aufnahme', id = aufnahmeId },
+ { station = station[1].kennung, zimmer = zimmerId, bett = bettNummer },
+ Text(daten.vermerk, 255))
+
+ Kern.Loese('aufnahme.aufgenommen', aufnahmeId, mitarbeiter)
+
+ return { ok = true, bett = bettNummer }
+end)
+
+-- =====================================================================
+-- VERLEGUNG
+-- =====================================================================
+
+Kern.RegistriereAktion('patienten', 'verlegen', 'patient.verlegen', function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.id)
+ if not aufnahmeId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.id, a.patient, a.status, a.station, a.zimmer,
+ c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.id = ?
+ ]], { aufnahmeId })
+ if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end
+
+ local aufnahme = zeilen[1]
+ if aufnahme.status ~= 'aufgenommen' then
+ return nil, 'Nur aufgenommene Patienten lassen sich verlegen.'
+ end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte den Grund der Verlegung angeben.' end
+
+ local station = MySQL.query.await(
+ 'SELECT kennung, name FROM medical_station WHERE kennung = ? AND aktiv = 1',
+ { tostring(daten.station or '') })
+ if not station or not station[1] then return nil, 'Unbekannte Station.' end
+
+ -- Neues Zimmer prüfen, bevor das alte freigegeben wird
+ local zimmerId, bettNummer = nil, nil
+ if daten.zimmer and tonumber(daten.zimmer) then
+ local zimmer = MySQL.query.await(
+ 'SELECT id, kennung, betten, status FROM medical_zimmer WHERE id = ?',
+ { tonumber(daten.zimmer) })
+ if not zimmer or not zimmer[1] then return nil, 'Unbekanntes Zimmer.' end
+
+ if zimmer[1].status ~= 'frei' then
+ return nil, ('Zimmer %s steht nicht zur Verfügung.'):format(zimmer[1].kennung)
+ end
+
+ -- Verlegung ins selbe Zimmer ist keine Verlegung
+ if zimmer[1].id == aufnahme.zimmer then
+ return nil, 'Der Patient liegt bereits in diesem Zimmer.'
+ end
+
+ bettNummer = FreiesBett(zimmer[1].id, zimmer[1].betten)
+ if not bettNummer then
+ return nil, ('In Zimmer %s ist kein Bett mehr frei.'):format(zimmer[1].kennung)
+ end
+
+ zimmerId = zimmer[1].id
+ end
+
+ local vorher = BettenFreigeben(aufnahmeId, mitarbeiter)
+
+ MySQL.update.await('UPDATE medical_aufnahme SET station = ?, zimmer = ? WHERE id = ?',
+ { station[1].kennung, zimmerId, aufnahmeId })
+
+ if zimmerId then
+ MySQL.insert.await([[
+ INSERT INTO medical_belegung (zimmer, bett, patient, aufnahme, belegt_von)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { zimmerId, bettNummer, aufnahme.patient, aufnahmeId, mitarbeiter.charakterId })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient verlegt',
+ { typ = 'aufnahme', id = aufnahmeId },
+ { vonStation = aufnahme.station, nachStation = station[1].kennung,
+ zimmer = zimmerId, bett = bettNummer }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'normal',
+ titel = 'Neue Verlegung',
+ text = ('%s %s wurde auf %s verlegt.')
+ :format(aufnahme.firstname, aufnahme.lastname, station[1].name),
+ zielTyp = 'aufnahme', zielId = aufnahmeId,
+ })
+
+ -- Frei gewordene Zimmer melden — das Freigeben zur Reinigung bleibt manuell
+ for _, belegung in ipairs(vorher) do
+ if BelegteBetten(belegung.zimmer) == 0 then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'niedrig',
+ titel = 'Zimmer wurde frei',
+ text = ('Zimmer %s ist jetzt leer.'):format(belegung.kennung),
+ zielTyp = 'zimmer', zielId = belegung.zimmer,
+ })
+ end
+ end
+
+ Kern.Loese('aufnahme.verlegt', aufnahmeId, mitarbeiter)
+
+ return { ok = true, bett = bettNummer }
+end)
+
+-- =====================================================================
+-- DRINGLICHKEIT
+--
+-- Wird ausschliesslich von Hand gesetzt. Wartezeit ändert sie nie.
+-- =====================================================================
+
+Kern.RegistriereAktion('patienten', 'prioritaet', 'patient.aufnehmen', function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.id)
+ if not aufnahmeId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, prioritaet, status FROM medical_aufnahme WHERE id = ?', { aufnahmeId })
+ if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end
+ if zeilen[1].status == 'entlassen' then
+ return nil, 'Dieser Vorgang ist bereits abgeschlossen.'
+ end
+
+ local neu = Dringlichkeit(daten.prioritaet)
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte begründen, warum sich die Dringlichkeit ändert.' end
+
+ MySQL.update.await('UPDATE medical_aufnahme SET prioritaet = ? WHERE id = ?',
+ { neu, aufnahmeId })
+
+ Protokoll.Schreibe(mitarbeiter, 'patienten', 'Dringlichkeit geändert',
+ { typ = 'aufnahme', id = aufnahmeId },
+ { von = zeilen[1].prioritaet, nach = neu }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ENTLASSUNG
+--
+-- Geschieht nie automatisch. Auch nicht, wenn alle Behandlungen
+-- abgeschlossen sind — das entscheidet ein Arzt.
+-- =====================================================================
+
+Kern.RegistriereAktion('patienten', 'entlassen', 'patient.entlassen', function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.id)
+ if not aufnahmeId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 512)
+ if not grund or #grund < 5 then
+ return nil, 'Eine Entlassung erfordert eine Begründung.'
+ end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.id, a.patient, a.status, c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.id = ?
+ ]], { aufnahmeId })
+ if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end
+
+ local aufnahme = zeilen[1]
+ if aufnahme.status == 'entlassen' then
+ return nil, 'Dieser Patient ist bereits entlassen.'
+ end
+
+ -- Offene Punkte NUR melden, nicht verhindern und nicht abschliessen
+ local offeneBehandlungen = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_behandlung
+ WHERE aufnahme = ? AND status = 'laufend'
+ ]], { aufnahmeId })) or 0
+
+ local aufIntensiv = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_intensiv
+ WHERE aufnahme = ? AND beendet_am IS NULL
+ ]], { aufnahmeId })) or 0
+
+ local freigeworden = BettenFreigeben(aufnahmeId, mitarbeiter)
+
+ MySQL.update.await([[
+ UPDATE medical_aufnahme
+ SET status = 'entlassen', zimmer = NULL,
+ entlassen_von = ?, entlassen_am = NOW(), entlass_grund = ?
+ WHERE id = ?
+ ]], { mitarbeiter.charakterId, grund, aufnahmeId })
+
+ Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient entlassen',
+ { typ = 'aufnahme', id = aufnahmeId },
+ { patient = aufnahme.patient,
+ offeneBehandlungen = offeneBehandlungen,
+ aufIntensiv = aufIntensiv > 0 }, grund)
+
+ -- Frei gewordene Zimmer melden
+ for _, belegung in ipairs(freigeworden) do
+ if BelegteBetten(belegung.zimmer) == 0 then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'niedrig',
+ titel = 'Zimmer wurde frei',
+ text = ('Zimmer %s ist jetzt leer.'):format(belegung.kennung),
+ zielTyp = 'zimmer', zielId = belegung.zimmer,
+ })
+ end
+ end
+
+ -- Laufende Dokumentation bleibt offen und wird nur in Erinnerung gerufen
+ if offeneBehandlungen > 0 then
+ Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, {
+ kategorie = 'doku', prioritaet = 'normal',
+ titel = 'Dokumentation noch offen',
+ text = ('%s %s wurde entlassen — %d Behandlung(en) sind noch nicht abgeschlossen.')
+ :format(aufnahme.firstname, aufnahme.lastname, offeneBehandlungen),
+ zielTyp = 'aufnahme', zielId = aufnahmeId,
+ })
+ end
+
+ -- Ein laufender Intensivaufenthalt wird NICHT beendet, nur gemeldet
+ if aufIntensiv > 0 then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'hoch',
+ titel = 'Entlassung trotz laufender Intensivbetreuung',
+ text = ('%s %s wurde entlassen, der Intensivaufenthalt ist noch offen. Bitte prüfen.')
+ :format(aufnahme.firstname, aufnahme.lastname),
+ zielTyp = 'aufnahme', zielId = aufnahmeId,
+ })
+ end
+
+ Kern.Loese('aufnahme.entlassen', aufnahmeId, mitarbeiter)
+
+ return { ok = true, offeneBehandlungen = offeneBehandlungen }
+end)
+
+-- =====================================================================
+-- ZIMMERSTATUS
+--
+-- Reinigung und Sperrung setzt immer ein Mensch. Ein leer gewordenes
+-- Zimmer wird nicht von selbst wieder als frei gemeldet.
+-- =====================================================================
+
+Kern.RegistriereAktion('patienten', 'zimmerStatus', 'station.belegen', function(quelle, mitarbeiter, daten)
+ local zimmerId = tonumber(daten.id)
+ local status = tostring(daten.status or '')
+
+ if not zimmerId then return nil, 'Ungültige Kennung.' end
+
+ local erlaubt = { frei = true, reinigung = true, gesperrt = true }
+ if not erlaubt[status] then return nil, 'Unbekannter Zimmerstatus.' end
+
+ local zimmer = MySQL.query.await(
+ 'SELECT id, kennung, betten FROM medical_zimmer WHERE id = ?', { zimmerId })
+ if not zimmer or not zimmer[1] then return nil, 'Zimmer nicht gefunden.' end
+
+ -- Ein belegtes Zimmer lässt sich nicht sperren, ohne die Belegung zu klären
+ if status == 'gesperrt' and BelegteBetten(zimmerId) > 0 then
+ return nil, 'In diesem Zimmer liegen noch Patienten.'
+ end
+
+ MySQL.update.await('UPDATE medical_zimmer SET status = ?, hinweis = ? WHERE id = ?',
+ { status, Text(daten.hinweis, 255), zimmerId })
+
+ Protokoll.Schreibe(mitarbeiter, 'patienten', 'Zimmerstatus gesetzt',
+ { typ = 'zimmer', id = zimmerId },
+ { kennung = zimmer[1].kennung, status = status })
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liegt dieser Patient gerade im Krankenhaus?
+--- Reine Auskunft — keine Ressource kann darüber jemanden aufnehmen.
+--- @param patientId number Charakter
+--- @return table|nil { status, station, zimmer, seit }
+exports('HoleAufenthalt', function(patientId)
+ if not tonumber(patientId) then return nil end
+
+ local zeilen = MySQL.query.await([[
+ SELECT a.status, a.station, a.aufgenommen_am, z.kennung AS zimmer
+ FROM medical_aufnahme a
+ LEFT JOIN medical_zimmer z ON z.id = a.zimmer
+ WHERE a.patient = ? AND a.status IN ('wartend', 'aufgenommen')
+ ORDER BY a.id DESC LIMIT 1
+ ]], { tonumber(patientId) })
+
+ if not zeilen or not zeilen[1] then return nil end
+ return {
+ status = zeilen[1].status,
+ station = zeilen[1].station,
+ zimmer = zeilen[1].zimmer,
+ seit = zeilen[1].aufgenommen_am,
+ }
+end)
diff --git a/resources/[aether]/aether-medical/server/module/station.lua b/resources/[aether]/aether-medical/server/module/station.lua
new file mode 100644
index 0000000..b7daa1e
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/station.lua
@@ -0,0 +1,822 @@
+-- =====================================================================
+-- aether-medical / Modul: Stationen und Zimmerbelegung
+--
+-- Der Bettenspiegel zeigt, wer wo liegt und was frei ist. Mehr nicht.
+--
+-- Das System belegt kein Bett, gibt keines frei, schiebt niemanden von
+-- einem Zimmer ins andere und setzt ein Zimmer nach der Entlassung
+-- nicht von selbst wieder auf "frei". Jede Zeile hier entsteht, weil
+-- ein Mensch sie ausgelöst hat — und trägt dessen Namen.
+--
+-- Auch die Reinigung ist eine Entscheidung: ein leer gewordenes
+-- Zimmer meldet sich, bleibt aber in dem Status, in dem es war.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'station', name = 'Stationen', icon = '🛏',
+ reihenfolge = 9, recht = 'station.sehen',
+ beschreibung = 'Bettenspiegel, Zimmerstatus und Stationsverwaltung',
+})
+
+-- Zimmerstatus, die von Hand gesetzt werden dürfen
+local ZIMMERSTATUS = {
+ frei = true,
+ reinigung = true,
+ gesperrt = true,
+}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Name eines Mitarbeiters für die Anzeige
+--- @param charakterId number|nil Charakter
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return 'unbekannt' end
+ local zeilen = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeilen or not zeilen[1] then return 'unbekannt' end
+ return zeilen[1].firstname .. ' ' .. zeilen[1].lastname
+end
+
+--- Prüft eine Kennung aus einer Konfigurationsliste
+--- @param liste table Liste aus Medizin.Config
+--- @param wert any Eingabe
+--- @return string|nil Gültige Kennung
+local function AusListe(liste, wert)
+ local gesucht = tostring(wert or '')
+ for _, eintrag in ipairs(liste) do
+ if eintrag.id == gesucht then return eintrag.id end
+ end
+ return nil
+end
+
+--- Wie viele Betten eines Zimmers sind gerade belegt?
+--- @param zimmerId number Zimmer
+--- @return number
+local function BelegteBetten(zimmerId)
+ return tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL',
+ { zimmerId })) or 0
+end
+
+--- Liefert die Nummern der belegten Betten eines Zimmers
+--- @param zimmerId number Zimmer
+--- @return table { [bettNummer] = true }
+local function BelegteBettNummern(zimmerId)
+ local zeilen = MySQL.query.await(
+ 'SELECT bett FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL',
+ { zimmerId }) or {}
+
+ local genommen = {}
+ for _, eintrag in ipairs(zeilen) do genommen[tonumber(eintrag.bett)] = true end
+ return genommen
+end
+
+--- Sucht das erste freie Bett eines Zimmers
+--- @param zimmerId number Zimmer
+--- @param betten number Bettenzahl des Zimmers
+--- @return number|nil Bettnummer
+local function FreiesBett(zimmerId, betten)
+ local genommen = BelegteBettNummern(zimmerId)
+ for nummer = 1, betten do
+ if not genommen[nummer] then return nummer end
+ end
+ return nil
+end
+
+--- Meldet ein leer gewordenes Zimmer — ohne seinen Status anzufassen
+--- @param zimmerId number Zimmer
+--- @param kennung string Zimmerkennung für den Text
+local function MeldeWennLeer(zimmerId, kennung)
+ if BelegteBetten(zimmerId) > 0 then return end
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'niedrig',
+ titel = 'Zimmer wurde frei',
+ text = ('Zimmer %s ist jetzt leer. Reinigung und Freigabe bleiben eine Entscheidung.')
+ :format(kennung),
+ zielTyp = 'zimmer', zielId = zimmerId,
+ })
+end
+
+-- =====================================================================
+-- BETTENSPIEGEL
+--
+-- Eine reine Auskunft: welche Station, welches Zimmer, welches Bett,
+-- wer liegt darin. Das System bewertet die Belegung nicht und schlägt
+-- keine Verteilung vor.
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local darfVerwalten = Rechte.Hat(mitarbeiter, 'station.verwalten')
+ -- Abgeschaltete Stationen sieht nur, wer sie auch pflegen darf
+ local zeigeInaktive = darfVerwalten and daten.alle == true
+
+ local stationen = MySQL.query.await(([[
+ SELECT id, kennung, name, art, etage, aktiv, sortierung
+ FROM medical_station
+ %s
+ ORDER BY sortierung, kennung
+ ]]):format(zeigeInaktive and '' or 'WHERE aktiv = 1')) or {}
+
+ local zimmerListe = MySQL.query.await([[
+ SELECT id, station, kennung, betten, art, status, hinweis
+ FROM medical_zimmer
+ ORDER BY kennung
+ ]]) or {}
+
+ -- Alle laufenden Belegungen in einem Zug — je Zimmer zugeordnet
+ local belegungen = MySQL.query.await([[
+ SELECT b.id, b.zimmer, b.bett, b.patient, b.aufnahme,
+ b.belegt_am, b.belegt_von,
+ TIMESTAMPDIFF(MINUTE, b.belegt_am, NOW()) AS liegtSeit,
+ c.firstname, c.lastname,
+ a.status AS aufnahmeStatus, a.prioritaet, a.grund,
+ p.blutgruppe, p.allergien, p.hinweis AS warnhinweis,
+ (SELECT COUNT(*) FROM medical_intensiv i
+ WHERE i.aufnahme = b.aufnahme AND i.beendet_am IS NULL) AS aufIntensiv
+ FROM medical_belegung b
+ JOIN aether_characters c ON c.id = b.patient
+ LEFT JOIN medical_aufnahme a ON a.id = b.aufnahme
+ LEFT JOIN medical_patient p ON p.`character` = b.patient
+ WHERE b.frei_am IS NULL
+ ]]) or {}
+
+ -- Belegungen nach Zimmer sortieren, damit die Betten schnell zu füllen sind
+ local belegungNachZimmer = {}
+ for _, belegung in ipairs(belegungen) do
+ belegung.name = belegung.firstname .. ' ' .. belegung.lastname
+ belegung.belegtVon = NameVon(belegung.belegt_von)
+ belegung.liegtSeit = tonumber(belegung.liegtSeit) or 0
+ belegung.aufIntensiv = (tonumber(belegung.aufIntensiv) or 0) > 0
+ belegung.firstname, belegung.lastname, belegung.belegt_von = nil, nil, nil
+
+ local zimmerId = tonumber(belegung.zimmer)
+ belegungNachZimmer[zimmerId] = belegungNachZimmer[zimmerId] or {}
+ belegungNachZimmer[zimmerId][tonumber(belegung.bett)] = belegung
+ end
+
+ -- Zimmer den Stationen zuordnen und jedes Bett einzeln aufführen
+ local zimmerNachStation = {}
+ for _, zimmer in ipairs(zimmerListe) do
+ zimmer.betten = tonumber(zimmer.betten) or 1
+
+ local imZimmer = belegungNachZimmer[tonumber(zimmer.id)] or {}
+ zimmer.bettenListe = {}
+
+ for nummer = 1, zimmer.betten do
+ zimmer.bettenListe[nummer] = {
+ nummer = nummer,
+ belegung = imZimmer[nummer],
+ }
+ end
+
+ zimmer.belegt = 0
+ for _ in pairs(imZimmer) do zimmer.belegt = zimmer.belegt + 1 end
+ zimmer.frei = math.max(0, zimmer.betten - zimmer.belegt)
+ -- Verfügbar heisst: nicht gesperrt, nicht in Reinigung, noch Platz
+ zimmer.verfuegbar = zimmer.status == 'frei' and zimmer.frei > 0
+
+ local stationId = tonumber(zimmer.station)
+ zimmerNachStation[stationId] = zimmerNachStation[stationId] or {}
+ table.insert(zimmerNachStation[stationId], zimmer)
+ end
+
+ -- Zählwerte über das ganze Haus
+ local zaehler = {
+ betten = 0, belegt = 0, frei = 0,
+ zimmer = 0, gesperrt = 0, reinigung = 0,
+ }
+
+ for _, station in ipairs(stationen) do
+ station.zimmer = zimmerNachStation[tonumber(station.id)] or {}
+ station.aktiv = tonumber(station.aktiv) == 1
+
+ station.betten, station.belegt, station.frei = 0, 0, 0
+ for _, zimmer in ipairs(station.zimmer) do
+ station.betten = station.betten + zimmer.betten
+ station.belegt = station.belegt + zimmer.belegt
+ -- Als frei zählt nur, was auch belegt werden dürfte
+ if zimmer.status == 'frei' then station.frei = station.frei + zimmer.frei end
+
+ zaehler.zimmer = zaehler.zimmer + 1
+ if zimmer.status == 'gesperrt' then zaehler.gesperrt = zaehler.gesperrt + 1 end
+ if zimmer.status == 'reinigung' then zaehler.reinigung = zaehler.reinigung + 1 end
+ end
+
+ zaehler.betten = zaehler.betten + station.betten
+ zaehler.belegt = zaehler.belegt + station.belegt
+ zaehler.frei = zaehler.frei + station.frei
+ end
+
+ -- Aufgenommene Patienten, die in keinem Bett liegen.
+ -- Das ist kein Fehler — es wird nur sichtbar gemacht.
+ local ohneBett = MySQL.query.await([[
+ SELECT a.id, a.patient, a.station, a.grund, a.prioritaet, a.aufgenommen_am,
+ TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet,
+ c.firstname, c.lastname,
+ s.name AS stationName,
+ p.blutgruppe, p.allergien, p.hinweis AS warnhinweis
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ LEFT JOIN medical_station s ON s.kennung = a.station
+ LEFT JOIN medical_patient p ON p.`character` = a.patient
+ WHERE a.status = 'aufgenommen'
+ AND NOT EXISTS (
+ SELECT 1 FROM medical_belegung b
+ WHERE b.aufnahme = a.id AND b.frei_am IS NULL)
+ ORDER BY a.prioritaet, a.aufgenommen_am
+ LIMIT 100
+ ]]) or {}
+
+ for _, eintrag in ipairs(ohneBett) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.wartet = tonumber(eintrag.wartet) or 0
+ eintrag.firstname, eintrag.lastname = nil, nil
+ end
+
+ return {
+ stationen = stationen,
+ ohneBett = ohneBett,
+ zaehler = zaehler,
+ stationsarten = Medizin.Config.Stationsarten,
+ zimmerarten = Medizin.Config.Zimmerarten,
+ zimmerstatus = Medizin.Config.Zimmerstatus,
+ rechte = {
+ belegen = Rechte.Hat(mitarbeiter, 'station.belegen'),
+ verwalten = darfVerwalten,
+ verlegen = Rechte.Hat(mitarbeiter, 'patient.verlegen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINZELNES ZIMMER
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'zimmer', nil, function(quelle, mitarbeiter, daten)
+ local zimmerId = tonumber(daten.id)
+ if not zimmerId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.betten, z.art, z.status, z.hinweis,
+ s.id AS stationId, s.kennung AS station, s.name AS stationName,
+ s.art AS stationArt, s.etage
+ FROM medical_zimmer z
+ JOIN medical_station s ON s.id = z.station
+ WHERE z.id = ?
+ ]], { zimmerId })
+ if not zeilen or not zeilen[1] then return nil, 'Zimmer nicht gefunden.' end
+
+ local zimmer = zeilen[1]
+ zimmer.betten = tonumber(zimmer.betten) or 1
+
+ -- Wer liegt gerade darin?
+ local aktuell = MySQL.query.await([[
+ SELECT b.id, b.bett, b.patient, b.aufnahme, b.belegt_am, b.belegt_von,
+ TIMESTAMPDIFF(MINUTE, b.belegt_am, NOW()) AS liegtSeit,
+ c.firstname, c.lastname, c.dob,
+ a.status AS aufnahmeStatus, a.prioritaet, a.grund,
+ p.blutgruppe, p.allergien, p.hinweis AS warnhinweis
+ FROM medical_belegung b
+ JOIN aether_characters c ON c.id = b.patient
+ LEFT JOIN medical_aufnahme a ON a.id = b.aufnahme
+ LEFT JOIN medical_patient p ON p.`character` = b.patient
+ WHERE b.zimmer = ? AND b.frei_am IS NULL
+ ORDER BY b.bett
+ ]], { zimmerId }) or {}
+
+ -- Betten einzeln aufführen, damit auch die leeren sichtbar bleiben
+ local nachBett = {}
+ for _, belegung in ipairs(aktuell) do
+ belegung.name = belegung.firstname .. ' ' .. belegung.lastname
+ belegung.geburt = tostring(belegung.dob):sub(1, 10)
+ belegung.belegtVon = NameVon(belegung.belegt_von)
+ belegung.liegtSeit = tonumber(belegung.liegtSeit) or 0
+ belegung.firstname, belegung.lastname, belegung.dob = nil, nil, nil
+ belegung.belegt_von = nil
+ nachBett[tonumber(belegung.bett)] = belegung
+ end
+
+ local bettenListe = {}
+ for nummer = 1, zimmer.betten do
+ bettenListe[nummer] = { nummer = nummer, belegung = nachBett[nummer] }
+ end
+
+ -- Frühere Belegungen — die Spur bleibt, auch wenn das Bett längst frei ist
+ local verlauf = MySQL.query.await([[
+ SELECT b.bett, b.belegt_am, b.belegt_von, b.frei_am, b.frei_von,
+ c.firstname, c.lastname
+ FROM medical_belegung b
+ JOIN aether_characters c ON c.id = b.patient
+ WHERE b.zimmer = ? AND b.frei_am IS NOT NULL
+ ORDER BY b.frei_am DESC
+ LIMIT 25
+ ]], { zimmerId }) or {}
+
+ for _, eintrag in ipairs(verlauf) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.belegtVon = NameVon(eintrag.belegt_von)
+ eintrag.freiVon = NameVon(eintrag.frei_von)
+ eintrag.firstname, eintrag.lastname = nil, nil
+ eintrag.belegt_von, eintrag.frei_von = nil, nil
+ end
+
+ return {
+ zimmer = zimmer,
+ betten = bettenListe,
+ verlauf = verlauf,
+ zimmerarten = Medizin.Config.Zimmerarten,
+ zimmerstatus = Medizin.Config.Zimmerstatus,
+ rechte = {
+ belegen = Rechte.Hat(mitarbeiter, 'station.belegen'),
+ verwalten = Rechte.Hat(mitarbeiter, 'station.verwalten'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- BETT ZUWEISEN
+--
+-- Nur für aufgenommene Patienten, die in keinem Bett liegen. Wer
+-- bereits eines hat, wird über die Patientenverwaltung verlegt —
+-- damit eine Verlegung auch als Verlegung dokumentiert ist.
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'bettZuweisen', 'station.belegen', function(quelle, mitarbeiter, daten)
+ local aufnahmeId = tonumber(daten.aufnahme)
+ local zimmerId = tonumber(daten.zimmer)
+
+ if not aufnahmeId then return nil, 'Kein Patient gewählt.' end
+ if not zimmerId then return nil, 'Kein Zimmer gewählt.' end
+
+ local aufnahmen = MySQL.query.await([[
+ SELECT a.id, a.patient, a.status, a.station, a.zimmer,
+ c.firstname, c.lastname
+ FROM medical_aufnahme a
+ JOIN aether_characters c ON c.id = a.patient
+ WHERE a.id = ?
+ ]], { aufnahmeId })
+ if not aufnahmen or not aufnahmen[1] then return nil, 'Aufnahme nicht gefunden.' end
+
+ local aufnahme = aufnahmen[1]
+ if aufnahme.status ~= 'aufgenommen' then
+ return nil, 'Nur aufgenommene Patienten bekommen ein Bett.'
+ end
+
+ -- Doppelbelegung ausschliessen
+ local schonBelegt = MySQL.scalar.await(
+ 'SELECT id FROM medical_belegung WHERE aufnahme = ? AND frei_am IS NULL', { aufnahmeId })
+ if schonBelegt then
+ return nil, 'Der Patient liegt bereits in einem Bett. Eine Verlegung nimmt die Patientenverwaltung vor.'
+ end
+
+ local zimmerZeilen = MySQL.query.await([[
+ SELECT z.id, z.kennung, z.betten, z.art, z.status,
+ s.kennung AS station, s.name AS stationName, s.aktiv AS stationAktiv
+ FROM medical_zimmer z
+ JOIN medical_station s ON s.id = z.station
+ WHERE z.id = ?
+ ]], { zimmerId })
+ if not zimmerZeilen or not zimmerZeilen[1] then return nil, 'Unbekanntes Zimmer.' end
+
+ local zimmer = zimmerZeilen[1]
+ if tonumber(zimmer.stationAktiv) ~= 1 then
+ return nil, 'Diese Station ist abgeschaltet.'
+ end
+ if zimmer.status ~= 'frei' then
+ return nil, ('Zimmer %s steht nicht zur Verfügung (%s).'):format(zimmer.kennung, zimmer.status)
+ end
+
+ -- Ein Bett auf einer anderen Station ist eine Verlegung und braucht das Recht dazu
+ local wechseltStation = aufnahme.station ~= nil and aufnahme.station ~= zimmer.station
+ if wechseltStation and not Rechte.Hat(mitarbeiter, 'patient.verlegen') then
+ return nil, 'Dieses Bett liegt auf einer anderen Station — dafür fehlt dir das Recht zur Verlegung.'
+ end
+
+ -- Bettwunsch prüfen, sonst das erste freie Bett nehmen
+ local bettNummer = tonumber(daten.bett)
+ local betten = tonumber(zimmer.betten) or 1
+
+ if bettNummer then
+ if bettNummer < 1 or bettNummer > betten then
+ return nil, ('Zimmer %s hat kein Bett %d.'):format(zimmer.kennung, bettNummer)
+ end
+ if BelegteBettNummern(zimmerId)[bettNummer] then
+ return nil, ('Bett %d ist belegt.'):format(bettNummer)
+ end
+ else
+ bettNummer = FreiesBett(zimmerId, betten)
+ if not bettNummer then
+ return nil, ('In Zimmer %s ist kein Bett mehr frei.'):format(zimmer.kennung)
+ end
+ end
+
+ MySQL.insert.await([[
+ INSERT INTO medical_belegung (zimmer, bett, patient, aufnahme, belegt_von)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { zimmerId, bettNummer, aufnahme.patient, aufnahmeId, mitarbeiter.charakterId })
+
+ -- Die Aufnahme führt mit, wo der Patient liegt
+ MySQL.update.await('UPDATE medical_aufnahme SET station = ?, zimmer = ? WHERE id = ?',
+ { zimmer.station, zimmerId, aufnahmeId })
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Bett zugewiesen',
+ { typ = 'zimmer', id = zimmerId },
+ { aufnahme = aufnahmeId, patient = aufnahme.patient,
+ zimmer = zimmer.kennung, bett = bettNummer,
+ vonStation = aufnahme.station, nachStation = zimmer.station },
+ Text(daten.vermerk, 255))
+
+ -- Ein Stationswechsel wird zusätzlich gemeldet, damit ihn niemand übersieht
+ if wechseltStation then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'normal',
+ titel = 'Patient auf andere Station gelegt',
+ text = ('%s %s liegt jetzt auf %s, Zimmer %s.')
+ :format(aufnahme.firstname, aufnahme.lastname, zimmer.stationName, zimmer.kennung),
+ zielTyp = 'aufnahme', zielId = aufnahmeId,
+ })
+ end
+
+ Kern.Loese('belegung.zugewiesen', aufnahmeId, zimmerId, bettNummer, mitarbeiter)
+
+ return { ok = true, zimmer = zimmer.kennung, bett = bettNummer }
+end)
+
+-- =====================================================================
+-- BETT FREIGEBEN
+--
+-- Gibt nur das Bett frei — der Patient bleibt aufgenommen. Entlassen
+-- wird ausschliesslich über die Patientenverwaltung.
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'bettFreigeben', 'station.belegen', function(quelle, mitarbeiter, daten)
+ local belegungId = tonumber(daten.id)
+ if not belegungId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund or #grund < 3 then
+ return nil, 'Bitte angeben, warum das Bett frei wird.'
+ end
+
+ local zeilen = MySQL.query.await([[
+ SELECT b.id, b.zimmer, b.bett, b.patient, b.aufnahme, b.frei_am,
+ z.kennung AS zimmerKennung,
+ c.firstname, c.lastname
+ FROM medical_belegung b
+ JOIN medical_zimmer z ON z.id = b.zimmer
+ JOIN aether_characters c ON c.id = b.patient
+ WHERE b.id = ?
+ ]], { belegungId })
+ if not zeilen or not zeilen[1] then return nil, 'Belegung nicht gefunden.' end
+
+ local belegung = zeilen[1]
+ if belegung.frei_am then return nil, 'Dieses Bett ist bereits frei.' end
+
+ MySQL.update.await(
+ 'UPDATE medical_belegung SET frei_am = NOW(), frei_von = ? WHERE id = ?',
+ { mitarbeiter.charakterId, belegungId })
+
+ -- Die Aufnahme verliert ihr Zimmer, bleibt aber auf ihrer Station
+ if belegung.aufnahme then
+ MySQL.update.await(
+ 'UPDATE medical_aufnahme SET zimmer = NULL WHERE id = ? AND zimmer = ?',
+ { belegung.aufnahme, belegung.zimmer })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Bett freigegeben',
+ { typ = 'zimmer', id = belegung.zimmer },
+ { belegung = belegungId, patient = belegung.patient,
+ zimmer = belegung.zimmerKennung, bett = belegung.bett,
+ aufnahme = belegung.aufnahme }, grund)
+
+ -- Der Patient ist weiterhin im Haus — das darf nicht untergehen
+ if belegung.aufnahme then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'patient', prioritaet = 'normal',
+ titel = 'Patient ohne Bett',
+ text = ('%s %s ist weiterhin aufgenommen, liegt aber in keinem Zimmer mehr.')
+ :format(belegung.firstname, belegung.lastname),
+ zielTyp = 'aufnahme', zielId = belegung.aufnahme,
+ })
+ end
+
+ MeldeWennLeer(belegung.zimmer, belegung.zimmerKennung)
+ Kern.Loese('belegung.freigegeben', belegungId, belegung.zimmer, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZIMMERSTATUS
+--
+-- Reinigung und Sperrung setzt immer ein Mensch. Ein leer gewordenes
+-- Zimmer wird nicht von selbst wieder als frei gemeldet.
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'zimmerStatus', 'station.belegen', function(quelle, mitarbeiter, daten)
+ local zimmerId = tonumber(daten.id)
+ local status = tostring(daten.status or '')
+
+ if not zimmerId then return nil, 'Ungültige Kennung.' end
+ if not ZIMMERSTATUS[status] then return nil, 'Unbekannter Zimmerstatus.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, status, hinweis FROM medical_zimmer WHERE id = ?', { zimmerId })
+ if not zeilen or not zeilen[1] then return nil, 'Zimmer nicht gefunden.' end
+
+ local zimmer = zeilen[1]
+
+ -- Ein belegtes Zimmer lässt sich nicht sperren, ohne die Belegung zu klären
+ if status == 'gesperrt' and BelegteBetten(zimmerId) > 0 then
+ return nil, 'In diesem Zimmer liegen noch Patienten.'
+ end
+
+ -- Ein leerer Hinweis löscht den alten; ein fehlendes Feld lässt ihn stehen
+ local hinweis = zimmer.hinweis
+ if daten.hinweis ~= nil then hinweis = Text(daten.hinweis, 255) end
+
+ MySQL.update.await('UPDATE medical_zimmer SET status = ?, hinweis = ? WHERE id = ?',
+ { status, hinweis, zimmerId })
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmerstatus gesetzt',
+ { typ = 'zimmer', id = zimmerId },
+ { kennung = zimmer.kennung, von = zimmer.status, nach = status }, hinweis)
+
+ Kern.Loese('zimmer.status', zimmerId, status, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- STATIONSVERWALTUNG
+--
+-- Stammdatenpflege. Eine Station wird abgeschaltet, nie gelöscht —
+-- sonst verlieren alte Aufnahmen ihren Ort.
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'stationSpeichern', 'station.verwalten', function(quelle, mitarbeiter, daten)
+ local stationId = tonumber(daten.id)
+
+ local kennung = Text(daten.kennung, 24)
+ local name = Text(daten.name, 120)
+ local art = AusListe(Medizin.Config.Stationsarten, daten.art)
+
+ if not kennung then return nil, 'Bitte eine Kennung angeben.' end
+ if not name then return nil, 'Bitte einen Namen angeben.' end
+ if not art then return nil, 'Unbekannte Stationsart.' end
+
+ kennung = kennung:upper()
+
+ -- Kennungen müssen eindeutig bleiben, weil Aufnahmen darauf verweisen
+ local kollision = MySQL.scalar.await(
+ 'SELECT id FROM medical_station WHERE kennung = ? AND id <> ?',
+ { kennung, stationId or 0 })
+ if kollision then return nil, ('Die Kennung %s ist bereits vergeben.'):format(kennung) end
+
+ local etage = Text(daten.etage, 24)
+ local sortierung = math.floor(tonumber(daten.sortierung) or 0)
+
+ if stationId then
+ local vorher = MySQL.query.await(
+ 'SELECT kennung FROM medical_station WHERE id = ?', { stationId })
+ if not vorher or not vorher[1] then return nil, 'Station nicht gefunden.' end
+
+ MySQL.update.await([[
+ UPDATE medical_station
+ SET kennung = ?, name = ?, art = ?, etage = ?, sortierung = ?
+ WHERE id = ?
+ ]], { kennung, name, art, etage, sortierung, stationId })
+
+ -- Aufnahmen verweisen über die Kennung — die muss mitwandern
+ if vorher[1].kennung ~= kennung then
+ MySQL.update.await('UPDATE medical_aufnahme SET station = ? WHERE station = ?',
+ { kennung, vorher[1].kennung })
+ end
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Station bearbeitet',
+ { typ = 'station', id = stationId },
+ { kennung = kennung, name = name, art = art, etage = etage })
+ else
+ stationId = MySQL.insert.await([[
+ INSERT INTO medical_station (kennung, name, art, etage, sortierung)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { kennung, name, art, etage, sortierung })
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Station angelegt',
+ { typ = 'station', id = stationId },
+ { kennung = kennung, name = name, art = art, etage = etage })
+ end
+
+ Kern.Loese('station.geaendert', stationId, mitarbeiter)
+
+ return { id = stationId }
+end)
+
+Kern.RegistriereAktion('station', 'stationAktiv', 'station.verwalten', function(quelle, mitarbeiter, daten)
+ local stationId = tonumber(daten.id)
+ if not stationId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, name, aktiv FROM medical_station WHERE id = ?', { stationId })
+ if not zeilen or not zeilen[1] then return nil, 'Station nicht gefunden.' end
+
+ local aktiv = daten.aktiv == true
+
+ -- Eine Station mit Patienten darin wird nicht abgeschaltet
+ if not aktiv then
+ local belegt = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_belegung b
+ JOIN medical_zimmer z ON z.id = b.zimmer
+ WHERE z.station = ? AND b.frei_am IS NULL
+ ]], { stationId })) or 0
+
+ if belegt > 0 then
+ return nil, ('Auf dieser Station liegen noch %d Patient(en).'):format(belegt)
+ end
+ end
+
+ MySQL.update.await('UPDATE medical_station SET aktiv = ? WHERE id = ?',
+ { aktiv and 1 or 0, stationId })
+
+ Protokoll.Schreibe(mitarbeiter, 'station',
+ aktiv and 'Station eingeschaltet' or 'Station abgeschaltet',
+ { typ = 'station', id = stationId },
+ { kennung = zeilen[1].kennung }, Text(daten.grund, 255))
+
+ Kern.Loese('station.geaendert', stationId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZIMMERVERWALTUNG
+-- =====================================================================
+
+Kern.RegistriereAktion('station', 'zimmerSpeichern', 'station.verwalten', function(quelle, mitarbeiter, daten)
+ local zimmerId = tonumber(daten.id)
+ local stationId = tonumber(daten.station)
+
+ local kennung = Text(daten.kennung, 24)
+ local art = AusListe(Medizin.Config.Zimmerarten, daten.art)
+ local betten = math.floor(tonumber(daten.betten) or 0)
+
+ if not kennung then return nil, 'Bitte eine Kennung angeben.' end
+ if not art then return nil, 'Unbekannte Zimmerart.' end
+ if betten < 1 or betten > 12 then return nil, 'Ein Zimmer hat 1 bis 12 Betten.' end
+
+ kennung = kennung:upper()
+
+ if zimmerId then
+ local vorher = MySQL.query.await(
+ 'SELECT id, station, betten FROM medical_zimmer WHERE id = ?', { zimmerId })
+ if not vorher or not vorher[1] then return nil, 'Zimmer nicht gefunden.' end
+
+ -- Die Station eines bestehenden Zimmers bleibt, wie sie ist
+ stationId = tonumber(vorher[1].station)
+
+ -- Betten lassen sich nicht wegkürzen, solange jemand darin liegt
+ local genommen = BelegteBettNummern(zimmerId)
+ for nummer in pairs(genommen) do
+ if nummer > betten then
+ return nil, ('Bett %d ist belegt — die Bettenzahl lässt sich nicht verringern.')
+ :format(nummer)
+ end
+ end
+ end
+
+ if not stationId then return nil, 'Keine Station gewählt.' end
+
+ local station = MySQL.query.await(
+ 'SELECT id, kennung, name FROM medical_station WHERE id = ?', { stationId })
+ if not station or not station[1] then return nil, 'Unbekannte Station.' end
+
+ -- Innerhalb einer Station soll jede Zimmerkennung nur einmal vorkommen
+ local kollision = MySQL.scalar.await(
+ 'SELECT id FROM medical_zimmer WHERE station = ? AND kennung = ? AND id <> ?',
+ { stationId, kennung, zimmerId or 0 })
+ if kollision then
+ return nil, ('Zimmer %s gibt es auf dieser Station bereits.'):format(kennung)
+ end
+
+ local hinweis = Text(daten.hinweis, 255)
+
+ if zimmerId then
+ MySQL.update.await([[
+ UPDATE medical_zimmer SET kennung = ?, betten = ?, art = ?, hinweis = ?
+ WHERE id = ?
+ ]], { kennung, betten, art, hinweis, zimmerId })
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmer bearbeitet',
+ { typ = 'zimmer', id = zimmerId },
+ { kennung = kennung, betten = betten, art = art, station = station[1].kennung })
+ else
+ -- Neue Zimmer starten gesperrt: erst freigeben, dann belegen
+ zimmerId = MySQL.insert.await([[
+ INSERT INTO medical_zimmer (station, kennung, betten, art, status, hinweis)
+ VALUES (?, ?, ?, ?, 'gesperrt', ?)
+ ]], { stationId, kennung, betten, art, hinweis })
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmer angelegt',
+ { typ = 'zimmer', id = zimmerId },
+ { kennung = kennung, betten = betten, art = art, station = station[1].kennung })
+ end
+
+ Kern.Loese('zimmer.geaendert', zimmerId, mitarbeiter)
+
+ return { id = zimmerId }
+end)
+
+--- Entfernt ein Zimmer, das nie benutzt wurde.
+--- Sobald jemand darin gelegen hat, bleibt es bestehen — sonst verlöre
+--- der Belegungsverlauf seinen Ort. Dann hilft nur noch "gesperrt".
+Kern.RegistriereAktion('station', 'zimmerEntfernen', 'station.verwalten', function(quelle, mitarbeiter, daten)
+ local zimmerId = tonumber(daten.id)
+ if not zimmerId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, station FROM medical_zimmer WHERE id = ?', { zimmerId })
+ if not zeilen or not zeilen[1] then return nil, 'Zimmer nicht gefunden.' end
+
+ local benutzt = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_belegung WHERE zimmer = ?', { zimmerId })) or 0
+ if benutzt > 0 then
+ return nil, 'In diesem Zimmer lagen bereits Patienten — es lässt sich nur noch sperren.'
+ end
+
+ -- Auch ein nie belegtes Zimmer kann in einer laufenden OP-Planung stehen
+ local imOpPlan = tonumber(MySQL.scalar.await([[
+ SELECT COUNT(*) FROM medical_operation
+ WHERE saal = ? AND status IN ('geplant', 'laufend')
+ ]], { zimmerId })) or 0
+ if imOpPlan > 0 then
+ return nil, 'Für diesen Saal sind noch Operationen geplant.'
+ end
+
+ MySQL.query.await('DELETE FROM medical_zimmer WHERE id = ?', { zimmerId })
+
+ Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmer entfernt',
+ { typ = 'zimmer', id = zimmerId },
+ { kennung = zeilen[1].kennung }, Text(daten.grund, 255))
+
+ Kern.Loese('zimmer.geaendert', zimmerId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert den Bettenspiegel als flache Auskunft.
+--- Reine Anzeige — keine Ressource kann darüber ein Bett belegen.
+---
+--- `betten` ist die gesamte Bettenzahl, `frei` zählt nur, was auch
+--- belegt werden dürfte: gesperrte Zimmer und Zimmer in Reinigung
+--- bleiben aussen vor.
+--- @return table Liste { station, name, betten, belegt, frei }
+exports('HoleBettenspiegel', function()
+ local stationen = MySQL.query.await([[
+ SELECT id, kennung AS station, name
+ FROM medical_station
+ WHERE aktiv = 1
+ ORDER BY sortierung, kennung
+ ]]) or {}
+
+ for _, eintrag in ipairs(stationen) do
+ local zimmerListe = MySQL.query.await(
+ 'SELECT id, betten, status FROM medical_zimmer WHERE station = ?',
+ { eintrag.id }) or {}
+
+ eintrag.betten, eintrag.belegt, eintrag.frei = 0, 0, 0
+
+ for _, zimmer in ipairs(zimmerListe) do
+ local betten = tonumber(zimmer.betten) or 1
+ local belegt = BelegteBetten(zimmer.id)
+
+ eintrag.betten = eintrag.betten + betten
+ eintrag.belegt = eintrag.belegt + belegt
+ if zimmer.status == 'frei' then
+ eintrag.frei = eintrag.frei + math.max(0, betten - belegt)
+ end
+ end
+
+ eintrag.id = nil
+ end
+
+ return stationen
+end)
diff --git a/resources/[aether]/aether-medical/server/module/vorlage.lua b/resources/[aether]/aether-medical/server/module/vorlage.lua
new file mode 100644
index 0000000..a6febda
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/module/vorlage.lua
@@ -0,0 +1,366 @@
+-- =====================================================================
+-- aether-medical / Modul: Bausteine (Textvorlagen)
+--
+-- Vorformulierte Textblöcke für den Behandlungs- und Berichtsbogen.
+--
+-- Ein Baustein ist ein Zettel mit vorbereitetem Text — nicht mehr.
+-- Entscheidend ist, was er NICHT tut:
+--
+-- * Er schreibt nichts in die Datenbank. Ein Baustein füllt nur das
+-- Formular; gespeichert wird danach von Hand, wie immer.
+-- * Er überschreibt nichts. Was jemand geschrieben hat, bleibt
+-- stehen — der Text wird angehängt, nicht ersetzt.
+-- * Er wird nie von selbst eingesetzt. Es gibt keine Vorauswahl
+-- nach Diagnose, Einsatzart oder sonst etwas.
+--
+-- Damit bleibt der Wortlaut die Verantwortung dessen, der speichert.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'vorlage', name = 'Bausteine', icon = '🧩',
+ reihenfolge = 15, recht = 'katalog.bearbeiten',
+ beschreibung = 'Textvorlagen für Behandlungs- und Berichtsbogen',
+})
+
+-- ---------------------------------------------------------------------
+-- Bereiche und ihre Felder
+--
+-- Ein Bereich entspricht einem Bogen. Die Feldkennungen sind genau
+-- die des jeweiligen Formulars — ein Baustein kann nur füllen, was
+-- es dort auch gibt.
+-- ---------------------------------------------------------------------
+local BEREICHE = {
+ { id = 'behandlung', name = 'Behandlungsbogen', felder = {
+ { id = 'symptome', name = 'Symptome / Anamnese' },
+ { id = 'untersuchung', name = 'Untersuchung' },
+ { id = 'diagnose', name = 'Diagnose' },
+ { id = 'massnahmen', name = 'Maßnahmen / Behandlung' },
+ { id = 'empfehlung', name = 'Empfehlung' },
+ { id = 'nachsorge', name = 'Nachsorge' },
+ }},
+ { id = 'bericht', name = 'Berichtsbogen', felder = {
+ { id = 'symptome', name = 'Symptome und Schilderung' },
+ { id = 'untersuchung', name = 'Untersuchungsbefund' },
+ { id = 'diagnose', name = 'Diagnose' },
+ { id = 'massnahmen', name = 'Maßnahmen' },
+ { id = 'empfehlung', name = 'Empfehlung' },
+ }},
+}
+
+-- ---------------------------------------------------------------------
+-- Grundbestand
+--
+-- Damit die Bausteine von Anfang an brauchbar sind. Jeder Text ist
+-- ein Gerüst mit Lücken — auszufüllen hat ihn ein Mensch.
+-- ---------------------------------------------------------------------
+local GRUNDBAUSTEINE = {
+ { bereich = 'behandlung', name = 'Wundversorgung', sortierung = 1, inhalt = {
+ untersuchung = 'Lokalisation:\nWundart:\nGröße:\nBlutung:\nDurchblutung, Motorik, Sensibilität distal:',
+ massnahmen = 'Wundreinigung:\nWundverschluss:\nVerband:\nTetanusschutz:',
+ nachsorge = 'Verbandwechsel:\nWiedervorstellung bei:\nFadenzug:',
+ }},
+ { bereich = 'behandlung', name = 'Reanimation', sortierung = 2, inhalt = {
+ symptome = 'Auffindesituation:\nZeitpunkt des Kollapses:\nErsthelfermaßnahmen:',
+ untersuchung = 'Bewusstsein:\nAtmung:\nInitialer Rhythmus:\nPupillen:',
+ massnahmen = 'Beginn Thoraxkompression:\nBeatmung:\nDefibrillationen:\nMedikamente:\nROSC um:',
+ }},
+ { bereich = 'behandlung', name = 'Verkehrsunfall — Erstversorgung', sortierung = 3, inhalt = {
+ symptome = 'Unfallhergang:\nAnschnallgurt:\nAirbag ausgelöst:\nEingeklemmt:',
+ untersuchung = 'Bodycheck:\nHWS:\nThorax:\nAbdomen:\nBecken:\nExtremitäten:\nVitalwerte:',
+ massnahmen = 'Immobilisation:\nZugang:\nSchmerztherapie:\nTransportziel:',
+ }},
+ { bereich = 'behandlung', name = 'Routinekontrolle', sortierung = 4, inhalt = {
+ symptome = 'Anlass der Vorstellung:\nBeschwerden seit letzter Kontrolle:',
+ untersuchung = 'Allgemeinzustand:\nVitalwerte:\nBefund:',
+ empfehlung = 'Weiteres Vorgehen:\nNächste Kontrolle:',
+ }},
+ { bereich = 'bericht', name = 'Notarzteinsatzprotokoll', sortierung = 1, inhalt = {
+ symptome = 'Einsatzmeldung:\nAuffindesituation:\nSchilderung des Patienten:',
+ untersuchung = 'Erstbefund:\nVitalwerte bei Eintreffen:\nVerlauf während des Transports:',
+ massnahmen = 'Maßnahmen vor Ort:\nMedikamente:\nÜbergabe an:',
+ }},
+ { bereich = 'bericht', name = 'Verlaufsbericht', sortierung = 2, inhalt = {
+ symptome = 'Beschwerdebild im Verlauf:',
+ untersuchung = 'Befunde im Verlauf:\nLaborwerte:',
+ massnahmen = 'Bisherige Behandlung:\nÄnderungen:',
+ empfehlung = 'Weiteres Vorgehen:',
+ }},
+ { bereich = 'bericht', name = 'Entlassbericht', sortierung = 3, inhalt = {
+ diagnose = 'Hauptdiagnose:\nNebendiagnosen:',
+ massnahmen = 'Durchgeführte Behandlung:\nOperationen:\nMedikation während des Aufenthalts:',
+ empfehlung = 'Medikation bei Entlassung:\nSchonung:\nWiedervorstellung bei:\nKontrolltermin:',
+ }},
+}
+
+CreateThread(function()
+ Wait(3000)
+
+ local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_vorlage')) or 0
+ if vorhanden > 0 then return end
+
+ for _, baustein in ipairs(GRUNDBAUSTEINE) do
+ local kennung = Kern.ErzeugeKennung('VOR', 'medical_vorlage', 'kennung')
+
+ MySQL.insert.await([[
+ INSERT INTO medical_vorlage (kennung, bereich, name, inhalt, sortierung)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { kennung, baustein.bereich, baustein.name,
+ json.encode(baustein.inhalt), baustein.sortierung })
+ end
+
+ Aether.Debug('Grundbestand an Textbausteinen angelegt.')
+end)
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt einen Text und liefert nil bei leerer Eingabe
+--- @param wert any Eingabe
+--- @param laenge number Maximale Zeichenzahl
+--- @return string|nil
+local function Text(wert, laenge)
+ if type(wert) ~= 'string' then return nil end
+ local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '')
+ if sauber == '' then return nil end
+ return sauber:sub(1, laenge)
+end
+
+--- Sucht einen Bereich
+--- @param kennung any Bereichskennung
+--- @return table|nil
+local function HoleBereich(kennung)
+ local gesucht = tostring(kennung or '')
+ for _, bereich in ipairs(BEREICHE) do
+ if bereich.id == gesucht then return bereich end
+ end
+ return nil
+end
+
+--- Liest den Inhalt eines Bausteins aus der Datenbank
+--- @param roh string|nil JSON
+--- @return table [feldId] = text
+local function AlsInhalt(roh)
+ if not roh then return {} end
+ local erfolg, tabelle = pcall(json.decode, roh)
+ if erfolg and type(tabelle) == 'table' then return tabelle end
+ return {}
+end
+
+--- Prüft einen eingereichten Inhalt gegen die Felder eines Bereichs.
+--- Felder, die es im Bogen nicht gibt, werden verworfen — ein Baustein
+--- soll nur füllen können, was auch existiert.
+--- @param bereich table Bereich
+--- @param roh any Eingabe
+--- @return table Geprüfter Inhalt, number Anzahl gefüllter Felder
+local function PruefeInhalt(bereich, roh)
+ local inhalt, anzahl = {}, 0
+ if type(roh) ~= 'table' then return inhalt, 0 end
+
+ for _, feld in ipairs(bereich.felder) do
+ local wert = Text(roh[feld.id], 4000)
+ if wert then
+ inhalt[feld.id] = wert
+ anzahl = anzahl + 1
+ end
+ end
+
+ return inhalt, anzahl
+end
+
+--- Lädt die einsetzbaren Bausteine eines Bereichs
+--- @param bereichId string Bereich
+--- @return table Liste
+local function LadeBausteine(bereichId)
+ local zeilen = MySQL.query.await([[
+ SELECT id, kennung, name, inhalt, sortierung
+ FROM medical_vorlage
+ WHERE bereich = ? AND aktiv = 1
+ ORDER BY sortierung, name
+ ]], { bereichId }) or {}
+
+ for _, zeile in ipairs(zeilen) do
+ zeile.inhalt = AlsInhalt(zeile.inhalt)
+ end
+
+ return zeilen
+end
+
+-- =====================================================================
+-- ABRUF AUS DEN BÖGEN
+--
+-- Dieselbe Auskunft, in jedem Bogen unter seinem eigenen Modul
+-- erreichbar. Sie liefert Text zum Einsetzen und sonst nichts.
+-- =====================================================================
+
+for _, bereich in ipairs(BEREICHE) do
+ Kern.RegistriereAktion(bereich.id, 'bausteine', nil, function(quelle, mitarbeiter, daten)
+ return {
+ bereich = bereich.id,
+ bereichName = bereich.name,
+ felder = bereich.felder,
+ bausteine = LadeBausteine(bereich.id),
+ }
+ end)
+end
+
+-- =====================================================================
+-- PFLEGE
+-- =====================================================================
+
+Kern.RegistriereAktion('vorlage', 'uebersicht', nil, function(quelle, mitarbeiter, daten)
+ local gewaehlt = HoleBereich(daten.bereich) or BEREICHE[1]
+
+ local liste = MySQL.query.await([[
+ SELECT id, kennung, bereich, name, inhalt, aktiv, sortierung
+ FROM medical_vorlage
+ WHERE bereich = ?
+ ORDER BY sortierung, name
+ ]], { gewaehlt.id }) or {}
+
+ for _, zeile in ipairs(liste) do
+ local inhalt = AlsInhalt(zeile.inhalt)
+ zeile.inhalt = inhalt
+ zeile.aktiv = tonumber(zeile.aktiv) == 1
+
+ -- Welche Felder füllt dieser Baustein?
+ zeile.felder = {}
+ for _, feld in ipairs(gewaehlt.felder) do
+ if inhalt[feld.id] then
+ zeile.felder[#zeile.felder + 1] = feld.name
+ end
+ end
+ end
+
+ -- Zählwerte je Bereich für die Reiterleiste
+ local bereiche = {}
+ for _, bereich in ipairs(BEREICHE) do
+ bereiche[#bereiche + 1] = {
+ id = bereich.id, name = bereich.name, felder = bereich.felder,
+ anzahl = tonumber(MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM medical_vorlage WHERE bereich = ? AND aktiv = 1',
+ { bereich.id })) or 0,
+ }
+ end
+
+ return {
+ bereich = gewaehlt.id,
+ felder = gewaehlt.felder,
+ liste = liste,
+ bereiche = bereiche,
+ }
+end)
+
+Kern.RegistriereAktion('vorlage', 'speichern', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten)
+ local vorlageId = tonumber(daten.id)
+
+ local bereich = HoleBereich(daten.bereich)
+ if not bereich then return nil, 'Unbekannter Bereich.' end
+
+ local name = Text(daten.name, 120)
+ if not name then return nil, 'Bitte einen Namen angeben.' end
+
+ local inhalt, gefuellt = PruefeInhalt(bereich, daten.inhalt)
+ if gefuellt == 0 then
+ return nil, 'Ein Baustein ohne Text füllt nichts aus.'
+ end
+
+ local sortierung = math.floor(tonumber(daten.sortierung) or 0)
+
+ if vorlageId then
+ local vorher = MySQL.query.await(
+ 'SELECT id, kennung, bereich, name FROM medical_vorlage WHERE id = ?',
+ { vorlageId })
+ if not vorher or not vorher[1] then return nil, 'Baustein nicht gefunden.' end
+
+ -- Der Bereich bleibt: die Felder eines Bogens passen nicht auf einen anderen
+ MySQL.update.await([[
+ UPDATE medical_vorlage SET name = ?, inhalt = ?, sortierung = ?
+ WHERE id = ?
+ ]], { name, json.encode(inhalt), sortierung, vorlageId })
+
+ Protokoll.Schreibe(mitarbeiter, 'vorlage', 'Baustein bearbeitet',
+ { typ = 'vorlage', id = vorlageId },
+ { kennung = vorher[1].kennung, name = name,
+ bereich = vorher[1].bereich, felder = gefuellt })
+ else
+ local kennung = Kern.ErzeugeKennung('VOR', 'medical_vorlage', 'kennung')
+
+ vorlageId = MySQL.insert.await([[
+ INSERT INTO medical_vorlage (kennung, bereich, name, inhalt, sortierung)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { kennung, bereich.id, name, json.encode(inhalt), sortierung })
+
+ Protokoll.Schreibe(mitarbeiter, 'vorlage', 'Baustein angelegt',
+ { typ = 'vorlage', id = vorlageId },
+ { kennung = kennung, name = name, bereich = bereich.id, felder = gefuellt })
+ end
+
+ Kern.Loese('vorlage.geaendert', vorlageId, mitarbeiter)
+
+ return { id = vorlageId }
+end)
+
+Kern.RegistriereAktion('vorlage', 'aktiv', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten)
+ local vorlageId = tonumber(daten.id)
+ if not vorlageId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, name FROM medical_vorlage WHERE id = ?', { vorlageId })
+ if not zeilen or not zeilen[1] then return nil, 'Baustein nicht gefunden.' end
+
+ local aktiv = daten.aktiv == true
+
+ MySQL.update.await('UPDATE medical_vorlage SET aktiv = ? WHERE id = ?',
+ { aktiv and 1 or 0, vorlageId })
+
+ Protokoll.Schreibe(mitarbeiter, 'vorlage',
+ aktiv and 'Baustein eingeschaltet' or 'Baustein abgeschaltet',
+ { typ = 'vorlage', id = vorlageId },
+ { kennung = zeilen[1].kennung, name = zeilen[1].name })
+
+ Kern.Loese('vorlage.geaendert', vorlageId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+--- Entfernt einen Baustein endgültig.
+--- Ein Baustein ist Text zum Einsetzen und keine Aufzeichnung über
+--- einen Patienten — was damit geschrieben wurde, steht ohnehin in
+--- den Bögen und bleibt dort unberührt.
+Kern.RegistriereAktion('vorlage', 'entfernen', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten)
+ local vorlageId = tonumber(daten.id)
+ if not vorlageId then return nil, 'Ungültige Kennung.' end
+
+ local grund = Text(daten.grund, 255)
+ if not grund then return nil, 'Bitte einen Grund angeben.' end
+
+ local zeilen = MySQL.query.await(
+ 'SELECT id, kennung, name, bereich FROM medical_vorlage WHERE id = ?', { vorlageId })
+ if not zeilen or not zeilen[1] then return nil, 'Baustein nicht gefunden.' end
+
+ MySQL.query.await('DELETE FROM medical_vorlage WHERE id = ?', { vorlageId })
+
+ Protokoll.Schreibe(mitarbeiter, 'vorlage', 'Baustein entfernt',
+ { typ = 'vorlage', id = vorlageId },
+ { kennung = zeilen[1].kennung, name = zeilen[1].name,
+ bereich = zeilen[1].bereich }, grund)
+
+ Kern.Loese('vorlage.geaendert', vorlageId, mitarbeiter)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die aktiven Bausteine eines Bereichs.
+--- Reine Auskunft — keine Ressource kann darüber einen Bogen ausfüllen
+--- oder eine Dokumentation speichern.
+--- @param bereichId string behandlung/bericht
+--- @return table Liste
+exports('HoleBausteine', function(bereichId)
+ if not HoleBereich(bereichId) then return {} end
+ return LadeBausteine(bereichId)
+end)
diff --git a/resources/[aether]/aether-medical/server/protokoll.lua b/resources/[aether]/aether-medical/server/protokoll.lua
new file mode 100644
index 0000000..4acb45e
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/protokoll.lua
@@ -0,0 +1,66 @@
+-- =====================================================================
+-- aether-medical — Protokoll
+--
+-- Jede Entscheidung im System hinterlässt eine Spur: wer, wann, was
+-- und warum. Einträge werden nur geschrieben und gelesen, nie
+-- geändert oder entfernt.
+-- =====================================================================
+
+Protokoll = {}
+
+--- Schreibt einen Protokolleintrag
+--- @param mitarbeiter table|nil Handelnder Mitarbeiter
+--- @param modul string Modulkennung
+--- @param aktion string Was wurde getan
+--- @param ziel table|nil { typ, id }
+--- @param details table|nil Beliebige Zusatzdaten
+--- @param grund string|nil Begründung, falls angegeben
+function Protokoll.Schreibe(mitarbeiter, modul, aktion, ziel, details, grund)
+ MySQL.insert([[
+ INSERT INTO medical_protokoll
+ (`character`, personalnummer, modul, aktion, ziel_typ, ziel_id, details, grund)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ mitarbeiter and mitarbeiter.charakterId or nil,
+ mitarbeiter and mitarbeiter.personalnummer or nil,
+ modul, aktion,
+ ziel and ziel.typ or nil,
+ ziel and tostring(ziel.id) or nil,
+ details and json.encode(details) or nil,
+ grund,
+ })
+end
+
+--- Liest Protokolleinträge mit einfachen Filtern
+--- @param filter table { modul?, charakterId?, zielTyp?, zielId?, limit? }
+--- @return table
+function Protokoll.Lies(filter)
+ filter = filter or {}
+ local bedingungen, werte = {}, {}
+
+ if filter.modul then
+ bedingungen[#bedingungen + 1] = 'modul = ?'
+ werte[#werte + 1] = filter.modul
+ end
+ if filter.charakterId then
+ bedingungen[#bedingungen + 1] = '`character` = ?'
+ werte[#werte + 1] = filter.charakterId
+ end
+ if filter.zielTyp and filter.zielId then
+ bedingungen[#bedingungen + 1] = 'ziel_typ = ? AND ziel_id = ?'
+ werte[#werte + 1] = filter.zielTyp
+ werte[#werte + 1] = tostring(filter.zielId)
+ end
+
+ local wo = #bedingungen > 0 and ('WHERE ' .. table.concat(bedingungen, ' AND ')) or ''
+ werte[#werte + 1] = math.min(filter.limit or 100, 500)
+
+ return MySQL.query.await(([[
+ SELECT p.*, c.firstname, c.lastname
+ FROM medical_protokoll p
+ LEFT JOIN aether_characters c ON c.id = p.`character`
+ %s
+ ORDER BY p.id DESC
+ LIMIT ?
+ ]]):format(wo), werte) or {}
+end
diff --git a/resources/[aether]/aether-medical/server/rechte.lua b/resources/[aether]/aether-medical/server/rechte.lua
new file mode 100644
index 0000000..004b2c4
--- /dev/null
+++ b/resources/[aether]/aether-medical/server/rechte.lua
@@ -0,0 +1,61 @@
+-- =====================================================================
+-- aether-medical — Rechteprüfung
+--
+-- Ein Mitarbeiter hat ein Recht, wenn seine Rolle es vorsieht ODER es
+-- ihm einzeln gewährt wurde — und es ihm nicht einzeln entzogen wurde.
+-- Der Entzug hat immer Vorrang.
+-- =====================================================================
+
+Rechte = {}
+
+--- Wandelt eine JSON-Liste in eine Menge um
+--- @param roh string|nil JSON-Array
+--- @return table { [wert] = true }
+local function ZuMenge(roh)
+ local menge = {}
+ if not roh then return menge end
+
+ local erfolg, liste = pcall(json.decode, roh)
+ if erfolg and type(liste) == 'table' then
+ for _, wert in ipairs(liste) do menge[wert] = true end
+ end
+ return menge
+end
+
+--- Baut die vollständige Rechtemenge eines Mitarbeiters
+--- @param personal table Zeile aus medical_personal
+--- @return table { [rechtId] = true }
+function Rechte.Berechne(personal)
+ local menge = Medizin.RechteDerRolle(personal.rolle)
+
+ -- Einzeln gewährte Rechte ergänzen
+ for recht in pairs(ZuMenge(personal.rechte_extra)) do
+ menge[recht] = true
+ end
+
+ -- Einzeln entzogene Rechte haben Vorrang
+ for recht in pairs(ZuMenge(personal.rechte_entzug)) do
+ menge[recht] = nil
+ end
+
+ return menge
+end
+
+--- Prüft ein einzelnes Recht
+--- @param mitarbeiter table Mitarbeiterdaten (mit Feld `rechte`)
+--- @param recht string Recht-Kennung
+--- @return boolean
+function Rechte.Hat(mitarbeiter, recht)
+ if not mitarbeiter or not mitarbeiter.rechte then return false end
+ if not recht then return true end
+ return mitarbeiter.rechte[recht] == true
+end
+
+--- Prüft, ob ein Mitarbeiter über einem anderen steht.
+--- Verhindert, dass jemand Gleichrangige oder Höhere verwaltet.
+--- @param mitarbeiter table Handelnder
+--- @param zielRolle string Rolle der Zielperson
+--- @return boolean
+function Rechte.StehtUeber(mitarbeiter, zielRolle)
+ return Medizin.Stufe(mitarbeiter.rolle) > Medizin.Stufe(zielRolle)
+end
diff --git a/resources/[aether]/aether-medical/shared/config.lua b/resources/[aether]/aether-medical/shared/config.lua
new file mode 100644
index 0000000..d6a9052
--- /dev/null
+++ b/resources/[aether]/aether-medical/shared/config.lua
@@ -0,0 +1,271 @@
+-- =====================================================================
+-- aether-medical — Konfiguration
+--
+-- Alles, was ein Serverbetreiber anpassen will, ohne Code zu ändern.
+-- Kein Wert hier löst je eine medizinische Maßnahme aus — es sind
+-- Kategorien, Orte und Erinnerungsfristen.
+-- =====================================================================
+
+Medizin = Medizin or {}
+Medizin.Config = {}
+
+-- Klinik
+Medizin.Config.Klinik = 'Los Santos Medical Center'
+Medizin.Config.Kuerzel = 'LSMC'
+
+-- Reichweite, in der ein Terminal bedient werden kann (Meter)
+Medizin.Config.TerminalWeite = 2.5
+
+-- Terminals: Krankenhaus-PCs an festen Orten
+Medizin.Config.Terminals = {
+ { name = 'Notaufnahme', pos = vector3(309.05, -595.20, 43.28) },
+ { name = 'Empfang', pos = vector3(298.60, -584.60, 43.26) },
+ { name = 'Intensiv', pos = vector3(331.20, -595.30, 43.28) },
+ { name = 'Sandy Shores', pos = vector3(1839.60, 3672.90, 34.28) },
+ { name = 'Paleto Bay', pos = vector3(-247.60, 6331.30, 32.43) },
+}
+
+-- Stationen der Klinik. Die Kennung wird im Personaldatensatz gespeichert.
+Medizin.Config.Stationen = {
+ { id = 'notaufnahme', name = 'Notaufnahme', art = 'notaufnahme' },
+ { id = 'rettung', name = 'Rettungsdienst', art = 'notaufnahme' },
+ { id = 'chirurgie', name = 'Chirurgie', art = 'normal' },
+ { id = 'innere', name = 'Innere Medizin', art = 'normal' },
+ { id = 'intensiv', name = 'Intensivstation', art = 'intensiv' },
+ { id = 'op', name = 'OP-Bereich', art = 'op' },
+ { id = 'labor', name = 'Labor', art = 'behandlung' },
+ { id = 'leitstelle', name = 'Leitstelle', art = 'normal' },
+}
+
+-- Arten einer Station. Reine Einordnung — keine Art schränkt ein,
+-- welcher Patient dort liegen darf. Das entscheidet die Station selbst.
+Medizin.Config.Stationsarten = {
+ { id = 'notaufnahme', name = 'Notaufnahme' },
+ { id = 'normal', name = 'Normalstation' },
+ { id = 'intensiv', name = 'Intensivstation' },
+ { id = 'op', name = 'OP-Bereich' },
+ { id = 'behandlung', name = 'Behandlungsbereich' },
+}
+
+-- Arten eines Zimmers
+Medizin.Config.Zimmerarten = {
+ { id = 'bett', name = 'Bettenzimmer' },
+ { id = 'behandlung', name = 'Behandlungsraum' },
+ { id = 'intensiv', name = 'Intensivplatz' },
+ { id = 'isolierung', name = 'Isolierzimmer' },
+ { id = 'op', name = 'Operationssaal' },
+}
+
+-- Zimmerstatus. Jeder wird von Hand gesetzt — kein Zimmer wechselt
+-- ihn von selbst, auch nicht, wenn der letzte Patient gegangen ist.
+Medizin.Config.Zimmerstatus = {
+ { id = 'frei', name = 'Frei', farbe = '#34d399' },
+ { id = 'reinigung', name = 'Reinigung', farbe = '#f59e0b' },
+ { id = 'gesperrt', name = 'Gesperrt', farbe = '#ef4444' },
+}
+
+-- Dienstfunktion: was macht jemand gerade konkret?
+Medizin.Config.Dienstfunktionen = {
+ { id = 'station', name = 'Stationsdienst', recht = nil },
+ { id = 'rtw', name = 'Rettungswagen', recht = nil },
+ { id = 'notarzt', name = 'Notarzt', recht = 'dienst.notarzt' },
+ { id = 'op', name = 'OP-Dienst', recht = 'dienst.op' },
+ { id = 'leitstelle', name = 'Leitstelle', recht = 'dienst.leitstelle' },
+}
+
+-- Dienststatus, den ein Mediziner selbst wählt
+Medizin.Config.Dienststatus = {
+ { id = 'verfuegbar', name = 'Verfügbar', farbe = '#34d399' },
+ { id = 'im_einsatz', name = 'Im Einsatz', farbe = '#f59e0b' },
+ { id = 'am_patient', name = 'Am Patienten', farbe = '#38bdf8' },
+ { id = 'im_op', name = 'Im OP', farbe = '#a78bfa' },
+ { id = 'pause', name = 'Pause', farbe = '#8a93a5' },
+ { id = 'nicht_frei', name = 'Nicht verfügbar', farbe = '#ef4444' },
+}
+
+-- Einsatzkategorien der Leitstelle
+Medizin.Config.Einsatzkategorien = {
+ { id = 'notruf', name = 'Notruf 112', farbe = '#ef4444' },
+ { id = 'bewusstlos', name = 'Bewusstlose Person', farbe = '#e0503f' },
+ { id = 'verkehr', name = 'Verkehrsunfall', farbe = '#f59e0b' },
+ { id = 'schuss', name = 'Schussverletzung', farbe = '#ff2e57' },
+ { id = 'explosion', name = 'Explosion', farbe = '#f97316' },
+ { id = 'grossschaden', name = 'Großschadenslage', farbe = '#dc2626' },
+ { id = 'verlegung', name = 'Verlegung', farbe = '#38bdf8' },
+ { id = 'polizei', name = 'Polizeianforderung', farbe = '#4aa8ff' },
+ { id = 'unterstuetzung', name = 'Unterstützung', farbe = '#a78bfa' },
+ { id = 'sonstiges', name = 'Sonstiges', farbe = '#8a93a5' },
+}
+
+-- Aktenarten
+Medizin.Config.Aktenarten = {
+ { id = 'behandlung', name = 'Behandlungsakte' },
+ { id = 'notfall', name = 'Notfallakte' },
+ { id = 'op', name = 'OP-Akte' },
+ { id = 'befund', name = 'Befundakte' },
+ { id = 'pflege', name = 'Pflegebericht' },
+ { id = 'gutachten', name = 'Gutachten' },
+}
+
+-- Stationen eines Berichts auf seinem Weg. Jeder Schritt wird von
+-- Hand gegangen — kein Bericht reift von selbst zur Freigabe.
+Medizin.Config.Berichtstatus = {
+ { id = 'entwurf', name = 'Entwurf', farbe = '#8697a4' },
+ { id = 'eingereicht', name = 'Eingereicht', farbe = '#f59e0b' },
+ { id = 'freigegeben', name = 'Freigegeben', farbe = '#34d399' },
+}
+
+-- Laborauftragsarten
+Medizin.Config.Laborarten = {
+ { id = 'blut', name = 'Blutprobe' },
+ { id = 'dna', name = 'DNA-Probe' },
+ { id = 'urin', name = 'Urinprobe' },
+ { id = 'gewebe', name = 'Gewebeprobe' },
+ { id = 'toxikologie', name = 'Toxikologie' },
+}
+
+-- Blutgruppen für die Blutbank
+Medizin.Config.Blutgruppen = {
+ '0-', '0+', 'A-', 'A+', 'B-', 'B+', 'AB-', 'AB+',
+}
+
+-- Status einer Blutkonserve. Jeder wird von Hand gesetzt — auch eine
+-- abgelaufene Konserve bleibt vorrätig, bis jemand sie verwirft.
+Medizin.Config.Blutstatus = {
+ { id = 'vorraetig', name = 'Vorrätig', farbe = '#34d399' },
+ { id = 'reserviert', name = 'Reserviert', farbe = '#f59e0b' },
+ { id = 'ausgegeben', name = 'Ausgegeben', farbe = '#38bdf8' },
+ { id = 'verworfen', name = 'Verworfen', farbe = '#8a93a5' },
+}
+
+-- Lagerorte der Blutbank
+Medizin.Config.Blutlagerorte = {
+ 'Blutbank Kühlschrank A', 'Blutbank Kühlschrank B',
+ 'Notaufnahme', 'OP-Bereich', 'Intensivstation', 'Rettungswagen',
+}
+
+-- Nachschlagetabelle: welche Konserve passt zu welchem Empfänger.
+--
+-- Das ist eine AUSKUNFT und nichts weiter. Das System sperrt keine
+-- Konserve, wählt keine aus und verhindert keine Ausgabe — es weist
+-- nur darauf hin, wenn eine Kombination von der Tabelle abweicht.
+-- Ob transfundiert wird, entscheidet immer ein Arzt.
+Medizin.Config.Blutvertraeglichkeit = {
+ ['0-'] = { '0-' },
+ ['0+'] = { '0-', '0+' },
+ ['A-'] = { '0-', 'A-' },
+ ['A+'] = { '0-', '0+', 'A-', 'A+' },
+ ['B-'] = { '0-', 'B-' },
+ ['B+'] = { '0-', '0+', 'B-', 'B+' },
+ ['AB-'] = { '0-', 'A-', 'B-', 'AB-' },
+ ['AB+'] = { '0-', '0+', 'A-', 'A+', 'B-', 'B+', 'AB-', 'AB+' },
+}
+
+-- Kategorien im Medikamentenschrank
+Medizin.Config.Medikamentkategorien = {
+ { id = 'analgetika', name = 'Schmerzmittel' },
+ { id = 'antibiotika', name = 'Antibiotika' },
+ { id = 'kreislauf', name = 'Kreislauf & Herz' },
+ { id = 'narkose', name = 'Narkose & Sedierung' },
+ { id = 'infusion', name = 'Infusionen' },
+ { id = 'verband', name = 'Verbandmaterial' },
+ { id = 'sonstiges', name = 'Sonstiges' },
+}
+
+-- Zustand eines Intensivpatienten. Wird ausschliesslich von Hand
+-- gesetzt — weder eine Messung noch ein Zeitablauf ändert ihn je.
+-- Die Reihenfolge bestimmt nur, was in der Liste oben steht.
+Medizin.Config.Intensivzustaende = {
+ { id = 'kritisch', name = 'Kritisch', farbe = '#ef4444' },
+ { id = 'stabil', name = 'Stabil', farbe = '#38bdf8' },
+ { id = 'besserung', name = 'Besserung', farbe = '#34d399' },
+}
+
+-- Vorschläge für angeschlossene Geräte. Reine Textbausteine — das
+-- System steuert kein Gerät, liest von keinem einen Wert und meldet
+-- keinen Alarm. Es hält nur fest, was jemand angeschlossen hat.
+Medizin.Config.Intensivgeraete = {
+ 'Beatmung', 'Monitoring', 'Perfusor', 'Dialyse', 'ZVK',
+ 'Arterie', 'Magensonde', 'Blasenkatheter', 'Schrittmacher', 'ECMO',
+}
+
+-- Kategorien für Aushänge am Schwarzen Brett. Keine Kategorie löst
+-- etwas aus — auch eine Dienstanweisung bleibt Text an einer Wand.
+Medizin.Config.Aushangkategorien = {
+ { id = 'anweisung', name = 'Dienstanweisung', farbe = '#ef4444' },
+ { id = 'warnung', name = 'Warnung', farbe = '#f59e0b' },
+ { id = 'hygiene', name = 'Hygiene', farbe = '#38bdf8' },
+ { id = 'information', name = 'Information', farbe = '#8697a4' },
+}
+
+-- Arten von Rettungsfahrzeugen
+Medizin.Config.Fahrzeugarten = {
+ { id = 'rtw', name = 'Rettungswagen' },
+ { id = 'nef', name = 'Notarzteinsatzfahrzeug' },
+ { id = 'ktw', name = 'Krankentransportwagen' },
+ { id = 'hubschrauber', name = 'Rettungshubschrauber' },
+ { id = 'sonstiges', name = 'Sonstiges' },
+}
+
+-- Einsatzstatus eines Fahrzeugs. Wird von Hand gesetzt — keine
+-- Telemetrie und kein Einsatz ändert ihn je von selbst.
+Medizin.Config.Fahrzeugstatus = {
+ { id = 'verfuegbar', name = 'Verfügbar', farbe = '#34d399' },
+ { id = 'im_einsatz', name = 'Im Einsatz', farbe = '#f59e0b' },
+ { id = 'werkstatt', name = 'In der Werkstatt', farbe = '#38bdf8' },
+ { id = 'ausser_dienst', name = 'Außer Dienst', farbe = '#8a93a5' },
+}
+
+-- Wartungsstatus. Auch dieser wird ausschliesslich eingetragen.
+Medizin.Config.Wartungsstatus = {
+ { id = 'ok', name = 'In Ordnung' },
+ { id = 'faellig', name = 'Wartung fällig' },
+ { id = 'werkstatt', name = 'In der Werkstatt' },
+}
+
+-- Vorschläge für die Ausstattung. Reine Textbausteine — das System
+-- prüft nicht nach, ob etwas an Bord ist.
+Medizin.Config.Fahrzeugausstattung = {
+ 'Defibrillator', 'Beatmungsgerät', 'Notfallrucksack', 'Trage',
+ 'Vakuummatratze', 'Spineboard', 'Sauerstoff', 'Absaugpumpe',
+ 'Perfusor', 'Notfallmedikamente',
+}
+
+-- Funktionen im OP-Team
+Medizin.Config.OpFunktionen = {
+ { id = 'operateur', name = 'Operateur' },
+ { id = 'assistenz', name = 'Assistenz' },
+ { id = 'anaesthesie', name = 'Anästhesie' },
+ { id = 'pflege', name = 'OP-Pflege' },
+}
+
+-- ---------------------------------------------------------------------
+-- Erinnerungen (NUR Hinweise — keine davon führt etwas aus)
+-- ---------------------------------------------------------------------
+Medizin.Config.Erinnerungen = {
+ -- Wie oft die Fristen geprüft werden (Millisekunden)
+ pruefIntervall = 60000,
+ -- Nach wie vielen Minuten ein wartender Patient gemeldet wird
+ warteZeitStufen = { 10, 30, 60 },
+ -- Wie viele Minuten vor einer geplanten OP erinnert wird
+ opVorlauf = { 30, 10 },
+ -- Nach wie vielen Minuten eine offene Behandlungsdoku gemeldet wird
+ dokuOffenNach = 45,
+ -- Nach wie vielen Minuten ohne Verlaufseintrag ein Intensivpatient
+ -- in Erinnerung gerufen wird
+ intensivStilleNach = 30,
+ -- Wie viele Tage vor Ablauf einer Blutkonserve erinnert wird
+ blutVorlaufTage = 3,
+ -- Ab wie wenigen vorrätigen Konserven je Blutgruppe erinnert wird
+ blutMindestbestand = 2,
+ -- Ab welchem gemeldeten Tankstand (Prozent) an ein Fahrzeug erinnert wird
+ fahrzeugTankUnter = 20,
+ -- Ab welchem gemeldeten Zustand (Prozent) an ein Fahrzeug erinnert wird
+ fahrzeugZustandUnter = 60,
+ -- Ab wann eine Fahrzeugmeldung als veraltet gilt (Minuten)
+ fahrzeugMeldungAltNach = 15,
+ -- Wie viele Tage vor Ablauf eines Medikaments erinnert wird
+ medikamentVorlaufTage = 14,
+ -- Wie viele Minuten vor Ablauf einer Kenntnisnahmefrist erinnert wird
+ aushangVorwarnung = 60,
+}
diff --git a/resources/[aether]/aether-medical/shared/rollen.lua b/resources/[aether]/aether-medical/shared/rollen.lua
new file mode 100644
index 0000000..45bc829
--- /dev/null
+++ b/resources/[aether]/aether-medical/shared/rollen.lua
@@ -0,0 +1,234 @@
+-- =====================================================================
+-- aether-medical — Rollen und Rechte
+--
+-- Rechte sind einzelne, benannte Schalter. Eine Rolle ist nur ein
+-- Vorschlagspaket — jedes Recht kann pro Mitarbeiter zusaetzlich
+-- gewaehrt oder entzogen werden (`rechte_extra` / `rechte_entzug`).
+--
+-- Ein Recht erlaubt immer nur, etwas zu DOKUMENTIEREN oder zu
+-- ORGANISIEREN. Kein Recht laesst das System selbst behandeln.
+-- =====================================================================
+
+Medizin = Medizin or {}
+
+-- ---------------------------------------------------------------------
+-- Katalog aller Rechte
+-- Gruppiert nur fuer die Darstellung in der Administration.
+-- ---------------------------------------------------------------------
+Medizin.Rechte = {
+ { gruppe = 'Dienst', eintraege = {
+ { id = 'dienst.antreten', name = 'Dienst antreten' },
+ { id = 'dienst.leitstelle', name = 'Leitstelle übernehmen' },
+ { id = 'dienst.notarzt', name = 'Als Notarzt fahren' },
+ { id = 'dienst.op', name = 'OP-Dienst übernehmen' },
+ }},
+ { gruppe = 'Patienten', eintraege = {
+ { id = 'patient.suchen', name = 'Patienten suchen' },
+ { id = 'patient.stammdaten', name = 'Stammdaten pflegen' },
+ { id = 'patient.hinweis', name = 'Warnhinweise setzen' },
+ { id = 'patient.aufnehmen', name = 'Patienten aufnehmen' },
+ { id = 'patient.verlegen', name = 'Patienten verlegen' },
+ { id = 'patient.entlassen', name = 'Patienten entlassen' },
+ }},
+ { gruppe = 'Akten & Berichte', eintraege = {
+ { id = 'akte.lesen', name = 'Akten einsehen' },
+ { id = 'akte.erstellen', name = 'Akten anlegen' },
+ { id = 'akte.bearbeiten', name = 'Akten bearbeiten' },
+ { id = 'akte.freigeben', name = 'Akten freigeben' },
+ { id = 'akte.schliessen', name = 'Akten schließen' },
+ { id = 'bericht.erstellen', name = 'Berichte schreiben' },
+ { id = 'bericht.freigeben', name = 'Berichte freigeben' },
+ }},
+ { gruppe = 'Behandlung', eintraege = {
+ { id = 'behandlung.dokumentieren', name = 'Behandlung dokumentieren' },
+ { id = 'behandlung.diagnose', name = 'Diagnose eintragen' },
+ { id = 'behandlung.abschliessen', name = 'Behandlung abschließen' },
+ { id = 'operation.planen', name = 'Operation planen' },
+ { id = 'operation.dokumentieren', name = 'Operation dokumentieren' },
+ }},
+ { gruppe = 'Medikamente & Labor', eintraege = {
+ { id = 'medikament.sehen', name = 'Medikamentenbestand einsehen' },
+ { id = 'medikament.ausgeben', name = 'Ausgabe dokumentieren' },
+ { id = 'medikament.bestand', name = 'Bestand buchen und pflegen' },
+ { id = 'medikament.btm', name = 'Betäubungsmittel dokumentieren' },
+ { id = 'labor.beauftragen', name = 'Laboraufträge stellen' },
+ { id = 'labor.bearbeiten', name = 'Laborergebnisse eintragen' },
+ { id = 'labor.befunden', name = 'Befund bewerten' },
+ { id = 'blut.sehen', name = 'Blutbank einsehen' },
+ { id = 'blut.verwalten', name = 'Blutkonserven verwalten' },
+ }},
+ { gruppe = 'Einsätze', eintraege = {
+ { id = 'einsatz.erstellen', name = 'Einsatz anlegen' },
+ { id = 'einsatz.zuweisen', name = 'Kräfte zuweisen' },
+ { id = 'einsatz.schliessen', name = 'Einsatz abschließen' },
+ }},
+ { gruppe = 'Klinik', eintraege = {
+ { id = 'station.sehen', name = 'Stationsbelegung einsehen' },
+ { id = 'station.belegen', name = 'Zimmer belegen und freigeben' },
+ { id = 'station.verwalten', name = 'Stationen und Zimmer pflegen' },
+ { id = 'intensiv.sehen', name = 'Intensivstation einsehen' },
+ { id = 'intensiv.betreuen', name = 'Intensivverlauf dokumentieren' },
+ { id = 'fahrzeug.sehen', name = 'Fuhrpark einsehen' },
+ { id = 'fahrzeug.zuweisen', name = 'Fahrzeuge zuweisen' },
+ { id = 'fahrzeug.wartung', name = 'Wartungsstatus setzen' },
+ { id = 'fahrzeug.pflegen', name = 'Fuhrpark pflegen' },
+ }},
+ { gruppe = 'Kommunikation', eintraege = {
+ { id = 'nachricht.senden', name = 'Nachrichten schreiben' },
+ { id = 'nachricht.rundmeldung', name = 'Rundmeldung an alle senden' },
+ { id = 'aushang.schreiben', name = 'Aushänge veröffentlichen' },
+ { id = 'aushang.archivieren', name = 'Aushänge archivieren' },
+ }},
+ { gruppe = 'Verwaltung', eintraege = {
+ { id = 'personal.sehen', name = 'Personalliste einsehen' },
+ { id = 'personal.rolle', name = 'Rollen zuweisen' },
+ { id = 'personal.rechte', name = 'Einzelrechte vergeben' },
+ { id = 'personal.einstellen', name = 'Personal einstellen/entlassen' },
+ { id = 'katalog.bearbeiten', name = 'Medikamente und Vorlagen pflegen' },
+ { id = 'protokoll.lesen', name = 'Protokoll einsehen' },
+ { id = 'statistik.sehen', name = 'Statistik einsehen' },
+ { id = 'admin.module', name = 'Module verwalten' },
+ }},
+}
+
+--- Liefert alle Rechte-IDs als flache Liste
+--- @return table
+function Medizin.AlleRechte()
+ local liste = {}
+ for _, gruppe in ipairs(Medizin.Rechte) do
+ for _, recht in ipairs(gruppe.eintraege) do
+ liste[#liste + 1] = recht.id
+ end
+ end
+ return liste
+end
+
+-- ---------------------------------------------------------------------
+-- Rollen — aufsteigend nach Verantwortung
+-- `rechte` ist ein Vorschlagspaket, kein festes Gesetz.
+-- `erbt` uebernimmt alle Rechte der genannten Rolle.
+-- ---------------------------------------------------------------------
+Medizin.Rollen = {
+ {
+ id = 'praktikant', name = 'Praktikant', stufe = 0,
+ rechte = {
+ 'dienst.antreten', 'patient.suchen', 'akte.lesen',
+ 'medikament.sehen', 'station.sehen', 'fahrzeug.sehen',
+ 'nachricht.senden',
+ },
+ },
+ {
+ id = 'rettungssanitaeter', name = 'Rettungssanitäter', stufe = 1,
+ erbt = 'praktikant',
+ rechte = {
+ 'einsatz.erstellen', 'behandlung.dokumentieren',
+ 'bericht.erstellen', 'akte.erstellen',
+ },
+ },
+ {
+ id = 'notfallsanitaeter', name = 'Notfallsanitäter', stufe = 2,
+ erbt = 'rettungssanitaeter',
+ rechte = {
+ 'patient.aufnehmen', 'patient.stammdaten',
+ 'medikament.ausgeben', 'labor.beauftragen',
+ 'einsatz.schliessen', 'akte.bearbeiten',
+ },
+ },
+ {
+ id = 'pflegekraft', name = 'Pflegekraft', stufe = 3,
+ erbt = 'notfallsanitaeter',
+ rechte = {
+ 'station.belegen', 'patient.verlegen',
+ 'intensiv.sehen', 'blut.sehen', 'medikament.bestand',
+ 'labor.bearbeiten',
+ },
+ },
+ {
+ id = 'assistenzarzt', name = 'Assistenzarzt', stufe = 4,
+ erbt = 'pflegekraft',
+ rechte = {
+ 'behandlung.diagnose', 'behandlung.abschliessen',
+ 'patient.hinweis', 'labor.befunden',
+ 'operation.dokumentieren', 'intensiv.betreuen',
+ },
+ },
+ {
+ id = 'facharzt', name = 'Facharzt', stufe = 5,
+ erbt = 'assistenzarzt',
+ rechte = {
+ 'patient.entlassen', 'operation.planen',
+ 'medikament.btm', 'blut.verwalten',
+ 'akte.freigeben', 'bericht.freigeben',
+ 'dienst.op', 'dienst.notarzt',
+ },
+ },
+ {
+ id = 'oberarzt', name = 'Oberarzt', stufe = 6,
+ erbt = 'facharzt',
+ rechte = {
+ 'akte.schliessen', 'einsatz.zuweisen', 'dienst.leitstelle',
+ 'fahrzeug.zuweisen', 'fahrzeug.wartung',
+ 'personal.sehen', 'statistik.sehen',
+ 'nachricht.rundmeldung', 'aushang.schreiben',
+ },
+ },
+ {
+ id = 'chefarzt', name = 'Chefarzt', stufe = 7,
+ erbt = 'oberarzt',
+ rechte = {
+ 'personal.rolle', 'station.verwalten', 'fahrzeug.pflegen',
+ 'protokoll.lesen', 'aushang.archivieren',
+ },
+ },
+ {
+ id = 'klinikleitung', name = 'Klinikleitung', stufe = 8,
+ erbt = 'chefarzt',
+ rechte = {
+ 'personal.rechte', 'personal.einstellen',
+ 'katalog.bearbeiten', 'admin.module',
+ },
+ },
+}
+
+--- Sucht eine Rolle
+--- @param rollenId string Kennung
+--- @return table|nil
+function Medizin.HoleRolle(rollenId)
+ for _, rolle in ipairs(Medizin.Rollen) do
+ if rolle.id == rollenId then return rolle end
+ end
+ return nil
+end
+
+--- Sammelt alle Rechte einer Rolle, inklusive geerbter.
+--- @param rollenId string Kennung
+--- @return table [recht] = true
+function Medizin.RechteDerRolle(rollenId)
+ local menge = {}
+ -- Schutz gegen versehentliche Ringe in der Vererbung
+ local besucht = {}
+ local aktuell = rollenId
+
+ while aktuell and not besucht[aktuell] do
+ besucht[aktuell] = true
+
+ local rolle = Medizin.HoleRolle(aktuell)
+ if not rolle then break end
+
+ for _, recht in ipairs(rolle.rechte or {}) do
+ menge[recht] = true
+ end
+
+ aktuell = rolle.erbt
+ end
+
+ return menge
+end
+
+--- Rangstufe einer Rolle
+--- @param rollenId string Kennung
+--- @return number
+function Medizin.Stufe(rollenId)
+ local rolle = Medizin.HoleRolle(rollenId)
+ return rolle and rolle.stufe or -1
+end
diff --git a/resources/[aether]/aether-phone/client/main.lua b/resources/[aether]/aether-phone/client/main.lua
new file mode 100644
index 0000000..4c6559a
--- /dev/null
+++ b/resources/[aether]/aether-phone/client/main.lua
@@ -0,0 +1,185 @@
+-- =====================================================================
+-- Aether Phone — Clientlogik
+-- Öffnet das Handy und reicht alle Aktionen an den Server weiter.
+-- =====================================================================
+
+local istOffen = false
+local istGeladen = false
+
+-- Taste zum Oeffnen (F1)
+local TASTE_HANDY = 288
+
+local function SendeAnNui(nachricht)
+ SendNuiMessage(json.encode(nachricht))
+end
+
+local function ZeigeSpielmeldung(text)
+ if not text or text == '' then return end
+ BeginTextCommandThefeedPost('STRING')
+ AddTextComponentSubstringPlayerName(text)
+ EndTextCommandThefeedPostTicker(false, true)
+end
+
+--- Schliesst das Handy
+local function Schliessen()
+ if not istOffen then return end
+ istOffen = false
+ SetNuiFocus(false, false)
+ SendeAnNui({ aktion = 'schliessen' })
+ TriggerServerEvent('aether:phone:schliessen')
+ pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
+end
+
+-- ---------------------------------------------------------------------
+-- Ereignisse vom Server
+-- ---------------------------------------------------------------------
+AddEventHandler('aether:client:bereit', function()
+ istGeladen = true
+end)
+
+RegisterNetEvent('aether:phone:zeige', function(zustand)
+ istOffen = true
+ SetNuiFocus(true, true)
+ pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
+ zustand.aktion = 'oeffnen'
+ SendeAnNui(zustand)
+end)
+
+RegisterNetEvent('aether:phone:zustand', function(zustand)
+ zustand.aktion = 'zustand'
+ SendeAnNui(zustand)
+end)
+
+RegisterNetEvent('aether:phone:fehler', function(text)
+ if istOffen then
+ SendeAnNui({ aktion = 'fehler', text = text })
+ else
+ ZeigeSpielmeldung(text)
+ end
+end)
+
+--- Ergebnis der Entsperrung
+RegisterNetEvent('aether:phone:entsperrt', function(erfolg)
+ SendeAnNui({ aktion = 'entsperrt', erfolg = erfolg })
+end)
+
+--- Neue Nachricht (auch bei geschlossenem Handy als Hinweis)
+RegisterNetEvent('aether:phone:neueNachricht', function(nachricht)
+ if istOffen then
+ SendeAnNui({ aktion = 'neueNachricht', nachricht = nachricht })
+ else
+ ZeigeSpielmeldung(('~b~Neue Nachricht~s~ von %s'):format(nachricht.von))
+ end
+end)
+
+--- Eingehender Anruf — Handy oeffnet sich automatisch
+RegisterNetEvent('aether:phone:eingehenderAnruf', function(vonNummer)
+ if not istOffen then
+ istOffen = true
+ SetNuiFocus(true, true)
+ pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
+ end
+ SendeAnNui({ aktion = 'eingehenderAnruf', nummer = vonNummer })
+end)
+
+--- Statusaenderung eines Anrufs
+RegisterNetEvent('aether:phone:anrufStatus', function(status, zusatz)
+ SendeAnNui({ aktion = 'anrufStatus', status = status, zusatz = zusatz })
+end)
+
+--- Akkustand hat sich geaendert
+RegisterNetEvent('aether:phone:akku', function(stand)
+ SendeAnNui({ aktion = 'akku', stand = stand })
+ if stand > 0 and stand <= Handy.AKKU_WARNUNG and not istOffen then
+ ZeigeSpielmeldung(('~o~Handy-Akku~s~ nur noch %d %%'):format(stand))
+ elseif stand <= 0 then
+ if istOffen then Schliessen() end
+ ZeigeSpielmeldung('~r~Handy-Akku leer~s~')
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Oeffnen per Taste
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ while true do
+ Wait(0)
+ if istGeladen and not istOffen and IsControlJustReleased(0, TASTE_HANDY) then
+ if not IsEntityDead(PlayerPedId()) then
+ TriggerServerEvent('aether:phone:oeffnen')
+ end
+ end
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- NUI-Callbacks
+-- ---------------------------------------------------------------------
+RegisterNUICallback('schliessen', function(_, rueckgabe)
+ Schliessen()
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('pinEinrichten', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:pinEinrichten', tostring(daten.pin or ''), daten.faceId and true or false)
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('entsperren', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:entsperren', tostring(daten.pin or ''))
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('faceId', function(_, rueckgabe)
+ TriggerServerEvent('aether:phone:faceId')
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('kontaktSpeichern', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:kontaktSpeichern',
+ tostring(daten.name or ''), tostring(daten.nummer or ''), tonumber(daten.id) or 0)
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('kontaktLoeschen', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:kontaktLoeschen', tonumber(daten.id) or 0)
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('senden', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:senden', tostring(daten.nummer or ''), tostring(daten.text or ''))
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('anrufen', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:anrufen', tostring(daten.nummer or ''))
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('annehmen', function(_, rueckgabe)
+ TriggerServerEvent('aether:phone:annehmen')
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('auflegen', function(_, rueckgabe)
+ TriggerServerEvent('aether:phone:auflegen')
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('einstellungen', function(daten, rueckgabe)
+ TriggerServerEvent('aether:phone:einstellungen', daten)
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('akkuTauschen', function(_, rueckgabe)
+ TriggerServerEvent('aether:phone:akkuTauschen')
+ rueckgabe({ ok = true })
+end)
+
+-- ---------------------------------------------------------------------
+-- Aufraeumen
+-- ---------------------------------------------------------------------
+AddEventHandler('onResourceStop', function(ressource)
+ if ressource ~= GetCurrentResourceName() then return end
+ SetNuiFocus(false, false)
+end)
diff --git a/resources/[aether]/aether-phone/fxmanifest.lua b/resources/[aether]/aether-phone/fxmanifest.lua
new file mode 100644
index 0000000..b3c7b64
--- /dev/null
+++ b/resources/[aether]/aether-phone/fxmanifest.lua
@@ -0,0 +1,35 @@
+fx_version 'cerulean'
+game 'gta5'
+lua54 'yes'
+
+name 'aether-phone'
+author 'Jerrit Fritzsche'
+description 'AetherOS — Handy mit Telefon, Nachrichten, Kontakten und Bank'
+version '0.1.0'
+
+ui_page 'html/index.html'
+
+files {
+ 'html/index.html',
+}
+
+shared_scripts {
+ '@aether-core/shared/config.lua',
+ '@aether-core/shared/utils.lua',
+ 'shared/config.lua',
+}
+
+client_scripts {
+ 'client/main.lua',
+}
+
+server_scripts {
+ '@oxmysql/lib/MySQL.lua',
+ 'server/main.lua',
+}
+
+dependencies {
+ 'aether-core',
+ 'aether-inventory',
+ 'oxmysql',
+}
diff --git a/resources/[aether]/aether-phone/html/index.html b/resources/[aether]/aether-phone/html/index.html
new file mode 100644
index 0000000..43a39dd
--- /dev/null
+++ b/resources/[aether]/aether-phone/html/index.html
@@ -0,0 +1,2654 @@
+
+
+
+
+
+
+
+
+AetherOS — RP Handy
+
+
+
+
+ AetherOS
+ Ein voll interaktiver Web-Prototyp eines RP-Smartphones für deinen deutschsprachigen FiveM GTA-Server. Alles läuft ohne Server — reines HTML/CSS/JS zum Ansehen.
+ So bedienst du es
+
+ ↑ Wischen/Tippen auf den Sperrbildschirm entsperrt das Handy.
+ ← Mitteilungen am Sperrbildschirm nach links wegwischen .
+ ▬ Die Leiste unten bringt dich zurück zum Homescreen.
+ 🔐 Beim Erststart Code festlegen . Entsperren per Face-ID oder Code (Einstellungen → Sicherheit).
+
+
+ Push-Mitteilung auslösen
+ Im Server kommen diese Events vom Backend. Hier zum Testen manuell:
+
+ 💬 Nachricht
+ 🏦 Bank
+ 📡 Dispatch
+ 🐦 Birdy
+
+
+ Gesperrte Apps
+ Manche Apps (z. B. Darkchat) sind nicht für jeden verfügbar — im Spiel per Gamemechanik freizuschalten. Hier zum Testen:
+ 🔓 Darkchat freischalten
+
+ Hardware & Items
+ Handy, SIM-Karte und Akku sind im Spiel eigene Items , die kombiniert werden. Akkustand: 78%
+
+ 🪫 Akku entladen
+ 🔋 Neuen Akku einsetzen
+ 📶 SIM entfernen / einsetzen
+ 📱 Handy aus / an
+
+
+
+
+
+
+
+
+
+
+
+
+ 14:32
+
+ Kein Netz
+
+
+
+
+ 78%
+
+
+
+
+
+
+
+
Dienstag, 21. Juli
+
14:32
+
+
Tippen zum Entsperren · ← Mitteilung wegwischen
+
+
+
+
+
+
+
+
+
+
+
+
Unbekannt
+
Anruf läuft…
+
?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🪫
+
Akku leer
+
Setze einen geladenen Akku (Item) ein, um das Handy zu nutzen.
+
+
+
+
+
+
+
+
+
+ Enthaltene Apps
+
+ 📞 Telefon & Wähler
+ 💬 Nachrichten
+ 🏦 Bank / Fleeca
+ 🐦 Birdy (Social)
+ 📸 Kamera & Galerie
+ 🎵 Musik
+ 📡 Dispatch / Leitstelle
+ 🏛️ Staat / Staatskasse
+ 🚗 Garage
+ 📒 Gelbe Seiten
+ 🔒 Darkchat (freischaltbar)
+ 🌤️ Wetter
+ 🧮 Rechner
+ ⚙️ Einstellungen
+
+ Für FiveM gedacht
+ Datenmodelle (Kontakte, Konten, Chats, Posts) sind bewusst getrennt vom UI gehalten — später einfach an NUI-Callbacks & deine Datenbank anbinden.
+
+
+
+
+
+
diff --git a/resources/[aether]/aether-phone/server/main.lua b/resources/[aether]/aether-phone/server/main.lua
new file mode 100644
index 0000000..d8f1ffb
--- /dev/null
+++ b/resources/[aether]/aether-phone/server/main.lua
@@ -0,0 +1,554 @@
+-- =====================================================================
+-- Aether Phone — Serverlogik
+-- Telefonnummer, Kontakte, Nachrichten, Anrufe und Sicherheit.
+-- Die PIN verlaesst den Server nicht.
+-- =====================================================================
+
+local Core = exports['aether-core']
+local Inventory = exports['aether-inventory']
+
+-- Laufende Anrufe: [anruferQuelle] = { ziel = quelle, nummer = ..., seit = ... }
+local aktiveAnrufe = {}
+
+-- Wer hat sein Handy gerade entsperrt? (fuer Aktionen, die das voraussetzen)
+local entsperrt = {}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+local function CharakterVon(quelle)
+ return Core:HoleCharakterId(quelle)
+end
+
+--- Erzeugt eine freie Telefonnummer im Format 555-XXXX
+--- @return string
+local function ErzeugeNummer()
+ for _ = 1, 50 do
+ local nummer = ('555-%04d'):format(math.random(0, 9999))
+ local belegt = MySQL.scalar.await('SELECT `character` FROM aether_phone WHERE number = ?', { nummer })
+ if not belegt then return nummer end
+ end
+ -- Notfalls eine Nummer mit anderer Vorwahl
+ return ('%03d-%04d'):format(math.random(200, 999), math.random(0, 9999))
+end
+
+--- Laedt (oder erzeugt) den Handy-Datensatz eines Charakters
+--- @param charakterId number Charakter-ID
+--- @return table
+local function HoleGeraet(charakterId)
+ local zeilen = MySQL.query.await('SELECT * FROM aether_phone WHERE `character` = ?', { charakterId })
+
+ if zeilen and zeilen[1] then return zeilen[1] end
+
+ local nummer = ErzeugeNummer()
+ MySQL.insert.await('INSERT INTO aether_phone (`character`, number) VALUES (?, ?)',
+ { charakterId, nummer })
+ Aether.Info(('Telefonnummer %s fuer Charakter #%d vergeben.'):format(nummer, charakterId))
+
+ local neue = MySQL.query.await('SELECT * FROM aether_phone WHERE `character` = ?', { charakterId })
+ return neue[1]
+end
+
+--- Sucht den Charakter zu einer Telefonnummer
+--- @param nummer string Telefonnummer
+--- @return number|nil Charakter-ID
+local function CharakterZuNummer(nummer)
+ return MySQL.scalar.await('SELECT `character` FROM aether_phone WHERE number = ?', { nummer })
+end
+
+--- Sucht die Server-ID zu einer Telefonnummer (nur wenn online)
+--- @param nummer string Telefonnummer
+--- @return number|nil
+local function QuelleZuNummer(nummer)
+ local charakterId = CharakterZuNummer(nummer)
+ if not charakterId then return nil end
+ return Core:HoleQuelleNachCharakter(charakterId)
+end
+
+--- Prueft die Hardware: Handy, SIM und Akku muessen vorhanden sein
+--- @param quelle number Server-ID
+--- @return boolean hatHandy, boolean hatSim
+local function PruefeHardware(quelle)
+ local handy = (Inventory:ZaehleItem(quelle, Handy.ITEM_HANDY) or 0) > 0
+ local sim = (Inventory:ZaehleItem(quelle, Handy.ITEM_SIM) or 0) > 0
+ return handy, sim
+end
+
+--- Laedt die Kontakte eines Charakters
+local function HoleKontakte(charakterId)
+ local zeilen = MySQL.query.await(
+ 'SELECT id, name, number FROM aether_phone_contacts WHERE `character` = ? ORDER BY name ASC',
+ { charakterId }) or {}
+
+ local liste = {}
+ -- Notrufe stehen immer oben
+ for nummer, eintrag in pairs(Handy.NOTRUFE) do
+ liste[#liste + 1] = { id = 0, name = eintrag.name, nummer = nummer, notruf = true }
+ end
+ for _, zeile in ipairs(zeilen) do
+ liste[#liste + 1] = { id = zeile.id, name = zeile.name, nummer = zeile.number }
+ end
+ return liste
+end
+
+--- Laedt die Unterhaltungen (letzte Nachricht je Gegenstelle)
+local function HoleChats(nummer)
+ local zeilen = MySQL.query.await([[
+ SELECT sender, receiver, text, gelesen, created_at
+ FROM aether_phone_messages
+ WHERE sender = ? OR receiver = ?
+ ORDER BY id DESC
+ LIMIT 300
+ ]], { nummer, nummer }) or {}
+
+ local chats = {}
+ for _, zeile in ipairs(zeilen) do
+ local gegenstelle = (zeile.sender == nummer) and zeile.receiver or zeile.sender
+ chats[gegenstelle] = chats[gegenstelle] or {}
+ table.insert(chats[gegenstelle], 1, {
+ von = (zeile.sender == nummer) and 'me' or 'them',
+ text = zeile.text,
+ zeit = tostring(zeile.created_at):sub(12, 16),
+ })
+ end
+ return chats
+end
+
+--- Laedt den Anrufverlauf
+local function HoleAnrufe(nummer)
+ local zeilen = MySQL.query.await([[
+ SELECT caller, callee, verpasst, created_at
+ FROM aether_phone_calls
+ WHERE caller = ? OR callee = ?
+ ORDER BY id DESC LIMIT 30
+ ]], { nummer, nummer }) or {}
+
+ local liste = {}
+ for _, zeile in ipairs(zeilen) do
+ local eingehend = zeile.callee == nummer
+ liste[#liste + 1] = {
+ nummer = eingehend and zeile.caller or zeile.callee,
+ art = eingehend and 'eingehend' or 'ausgehend',
+ verpasst = zeile.verpasst == 1,
+ zeit = tostring(zeile.created_at):sub(1, 16),
+ }
+ end
+ return liste
+end
+
+--- Baut den kompletten Zustand fuer die Oberflaeche (ohne PIN!)
+--- @param quelle number Server-ID
+--- @return table|nil
+local function BaueZustand(quelle)
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return nil end
+
+ local person = Core:HoleSpielerInfo(quelle)
+ local geraet = HoleGeraet(charakterId)
+ local hatHandy, hatSim = PruefeHardware(quelle)
+
+ local dock = { 'phone', 'messages', 'bank', 'camera' }
+ local erfolg, entschluesselt = pcall(json.decode, geraet.dock or '')
+ if erfolg and type(entschluesselt) == 'table' then dock = entschluesselt end
+
+ local freigeschaltet = {}
+ local ok2, entsch2 = pcall(json.decode, geraet.unlocked or '{}')
+ if ok2 and type(entsch2) == 'table' then freigeschaltet = entsch2 end
+
+ return {
+ name = ((person and person.vorname or '') .. ' ' .. (person and person.nachname or '')),
+ nummer = geraet.number,
+ kontostand = person and person.konto or 0,
+ kontonummer = person and person.iban or '—',
+ theme = geraet.theme,
+ wallpaper = geraet.wallpaper,
+ dockApps = dock,
+ freigeschaltet = freigeschaltet,
+ handy = hatHandy,
+ sim = hatSim,
+ akku = geraet.battery,
+ -- Die PIN selbst wird NIE geschickt, nur ob eine eingerichtet ist
+ pinGesetzt = geraet.pin ~= nil,
+ faceId = geraet.faceid == 1,
+ kontakte = HoleKontakte(charakterId),
+ chats = HoleChats(geraet.number),
+ anrufe = HoleAnrufe(geraet.number),
+ }
+end
+
+--- Schickt den Zustand an einen Spieler
+local function SendeZustand(quelle, hinweis)
+ local zustand = BaueZustand(quelle)
+ if not zustand then return end
+ zustand.hinweis = hinweis
+ TriggerClientEvent('aether:phone:zustand', quelle, zustand)
+end
+
+local function Ablehnen(quelle, grund)
+ TriggerClientEvent('aether:phone:fehler', quelle, grund)
+end
+
+-- ---------------------------------------------------------------------
+-- Handy oeffnen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:oeffnen', function()
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return end
+
+ local hatHandy = PruefeHardware(quelle)
+ if not hatHandy then
+ Ablehnen(quelle, 'Du hast kein Handy dabei.')
+ return
+ end
+
+ local geraet = HoleGeraet(charakterId)
+ if geraet.battery <= 0 then
+ Ablehnen(quelle, 'Der Akku ist leer.')
+ return
+ end
+
+ local zustand = BaueZustand(quelle)
+ if zustand then
+ TriggerClientEvent('aether:phone:zeige', quelle, zustand)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Sicherheit: Ersteinrichtung und Entsperren
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:pinEinrichten', function(pin, faceId)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return end
+
+ if type(pin) ~= 'string' or not pin:match('^%d%d%d%d$') then
+ Ablehnen(quelle, 'Der Code muss aus vier Ziffern bestehen.')
+ return
+ end
+
+ MySQL.update.await('UPDATE aether_phone SET pin = ?, faceid = ? WHERE `character` = ?',
+ { pin, faceId and 1 or 0, charakterId })
+
+ entsperrt[quelle] = true
+ TriggerClientEvent('aether:phone:entsperrt', quelle, true)
+end)
+
+RegisterNetEvent('aether:phone:entsperren', function(pin)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return end
+
+ local gespeichert = MySQL.scalar.await(
+ 'SELECT pin FROM aether_phone WHERE `character` = ?', { charakterId })
+
+ if gespeichert and gespeichert == pin then
+ entsperrt[quelle] = true
+ TriggerClientEvent('aether:phone:entsperrt', quelle, true)
+ else
+ entsperrt[quelle] = false
+ TriggerClientEvent('aether:phone:entsperrt', quelle, false)
+ end
+end)
+
+--- Entsperren per Face-ID (nur wenn dafuer eingerichtet)
+RegisterNetEvent('aether:phone:faceId', function()
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return end
+
+ local aktiv = MySQL.scalar.await(
+ 'SELECT faceid FROM aether_phone WHERE `character` = ?', { charakterId })
+
+ if aktiv == 1 then
+ entsperrt[quelle] = true
+ TriggerClientEvent('aether:phone:entsperrt', quelle, true)
+ else
+ TriggerClientEvent('aether:phone:entsperrt', quelle, false)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Kontakte
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:kontaktSpeichern', function(name, nummer, kontaktId)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId or not entsperrt[quelle] then return end
+
+ if type(name) ~= 'string' or #name < 1 or #name > 48 then
+ Ablehnen(quelle, 'Bitte einen Namen angeben.') return
+ end
+ if not Handy.NummerGueltig(nummer) then
+ Ablehnen(quelle, 'Ungültige Telefonnummer.') return
+ end
+
+ if type(kontaktId) == 'number' and kontaktId > 0 then
+ MySQL.update.await(
+ 'UPDATE aether_phone_contacts SET name = ?, number = ? WHERE id = ? AND `character` = ?',
+ { name, nummer, kontaktId, charakterId })
+ else
+ MySQL.insert.await(
+ 'INSERT INTO aether_phone_contacts (`character`, name, number) VALUES (?, ?, ?)',
+ { charakterId, name, nummer })
+ end
+
+ SendeZustand(quelle, 'Kontakt gespeichert.')
+end)
+
+RegisterNetEvent('aether:phone:kontaktLoeschen', function(kontaktId)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId or not entsperrt[quelle] then return end
+ if type(kontaktId) ~= 'number' then return end
+
+ MySQL.update.await('DELETE FROM aether_phone_contacts WHERE id = ? AND `character` = ?',
+ { kontaktId, charakterId })
+ SendeZustand(quelle, 'Kontakt gelöscht.')
+end)
+
+-- ---------------------------------------------------------------------
+-- Nachrichten
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:senden', function(anNummer, text)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId or not entsperrt[quelle] then return end
+
+ local hatHandy, hatSim = PruefeHardware(quelle)
+ if not hatHandy or not hatSim then
+ Ablehnen(quelle, 'Ohne SIM-Karte kannst du keine Nachrichten senden.')
+ return
+ end
+
+ if not Handy.NummerGueltig(anNummer) then
+ Ablehnen(quelle, 'Ungültige Telefonnummer.') return
+ end
+ if type(text) ~= 'string' then return end
+
+ text = text:gsub('^%s+', ''):gsub('%s+$', '')
+ if #text < 1 or #text > 512 then
+ Ablehnen(quelle, 'Die Nachricht ist leer oder zu lang.') return
+ end
+
+ local geraet = HoleGeraet(charakterId)
+
+ MySQL.insert.await(
+ 'INSERT INTO aether_phone_messages (sender, receiver, text) VALUES (?, ?, ?)',
+ { geraet.number, anNummer, text })
+
+ -- Empfaenger benachrichtigen, falls online
+ local zielQuelle = QuelleZuNummer(anNummer)
+ if zielQuelle then
+ TriggerClientEvent('aether:phone:neueNachricht', zielQuelle, {
+ von = geraet.number,
+ text = text,
+ zeit = os.date('%H:%M'),
+ })
+ SendeZustand(zielQuelle)
+ end
+
+ SendeZustand(quelle)
+end)
+
+-- ---------------------------------------------------------------------
+-- Anrufe
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:anrufen', function(nummer)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId or not entsperrt[quelle] then return end
+
+ local hatHandy, hatSim = PruefeHardware(quelle)
+ if not hatHandy or not hatSim then
+ Ablehnen(quelle, 'Ohne SIM-Karte kannst du nicht telefonieren.')
+ return
+ end
+ if not Handy.NummerGueltig(nummer) then
+ Ablehnen(quelle, 'Ungültige Telefonnummer.') return
+ end
+
+ local geraet = HoleGeraet(charakterId)
+ if nummer == geraet.number then
+ Ablehnen(quelle, 'Du kannst dich nicht selbst anrufen.') return
+ end
+
+ -- Notrufe gehen an die Leitstelle der jeweiligen Fraktion
+ if Handy.NOTRUFE[nummer] then
+ TriggerEvent('aether:phone:notruf', quelle, Handy.NOTRUFE[nummer].fraktion, geraet.number)
+ MySQL.insert('INSERT INTO aether_phone_calls (caller, callee) VALUES (?, ?)',
+ { geraet.number, nummer })
+ TriggerClientEvent('aether:phone:anrufStatus', quelle, 'notruf',
+ Handy.NOTRUFE[nummer].name)
+ return
+ end
+
+ local zielQuelle = QuelleZuNummer(nummer)
+ if not zielQuelle then
+ MySQL.insert('INSERT INTO aether_phone_calls (caller, callee, verpasst) VALUES (?, ?, 1)',
+ { geraet.number, nummer })
+ TriggerClientEvent('aether:phone:anrufStatus', quelle, 'nichtErreichbar')
+ return
+ end
+
+ -- Ist die Gegenstelle schon im Gespraech?
+ for _, anruf in pairs(aktiveAnrufe) do
+ if anruf.ziel == zielQuelle or anruf.anrufer == zielQuelle then
+ TriggerClientEvent('aether:phone:anrufStatus', quelle, 'besetzt')
+ return
+ end
+ end
+
+ aktiveAnrufe[quelle] = {
+ anrufer = quelle, ziel = zielQuelle,
+ nummer = geraet.number, zielNummer = nummer, angenommen = false,
+ }
+
+ TriggerClientEvent('aether:phone:eingehenderAnruf', zielQuelle, geraet.number)
+ TriggerClientEvent('aether:phone:anrufStatus', quelle, 'klingelt', nummer)
+end)
+
+RegisterNetEvent('aether:phone:annehmen', function()
+ local quelle = source
+
+ for anruferQuelle, anruf in pairs(aktiveAnrufe) do
+ if anruf.ziel == quelle then
+ anruf.angenommen = true
+ MySQL.insert('INSERT INTO aether_phone_calls (caller, callee) VALUES (?, ?)',
+ { anruf.nummer, anruf.zielNummer })
+ TriggerClientEvent('aether:phone:anrufStatus', anruferQuelle, 'verbunden')
+ TriggerClientEvent('aether:phone:anrufStatus', quelle, 'verbunden')
+ return
+ end
+ end
+end)
+
+RegisterNetEvent('aether:phone:auflegen', function()
+ local quelle = source
+
+ for anruferQuelle, anruf in pairs(aktiveAnrufe) do
+ if anruferQuelle == quelle or anruf.ziel == quelle then
+ -- Nicht angenommene Anrufe als verpasst protokollieren
+ if not anruf.angenommen then
+ MySQL.insert('INSERT INTO aether_phone_calls (caller, callee, verpasst) VALUES (?, ?, 1)',
+ { anruf.nummer, anruf.zielNummer })
+ end
+ TriggerClientEvent('aether:phone:anrufStatus', anruferQuelle, 'beendet')
+ TriggerClientEvent('aether:phone:anrufStatus', anruf.ziel, 'beendet')
+ aktiveAnrufe[anruferQuelle] = nil
+ return
+ end
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Einstellungen speichern
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:einstellungen', function(einstellungen)
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId or not entsperrt[quelle] then return end
+ if type(einstellungen) ~= 'table' then return end
+
+ local theme = (einstellungen.theme == 'light') and 'light' or 'dark'
+ local wallpaper = tostring(einstellungen.wallpaper or 'wp-aurora'):sub(1, 32)
+ local dock = type(einstellungen.dockApps) == 'table' and einstellungen.dockApps or nil
+
+ MySQL.update.await([[
+ UPDATE aether_phone SET theme = ?, wallpaper = ?, dock = COALESCE(?, dock)
+ WHERE `character` = ?
+ ]], { theme, wallpaper, dock and json.encode(dock) or nil, charakterId })
+end)
+
+-- ---------------------------------------------------------------------
+-- Akkuverbrauch
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ while true do
+ Wait(Handy.AKKU_INTERVALL)
+
+ for _, quelle in ipairs(GetPlayers()) do
+ quelle = tonumber(quelle)
+ local charakterId = CharakterVon(quelle)
+
+ if charakterId then
+ local hatHandy = PruefeHardware(quelle)
+ if hatHandy then
+ local geraet = HoleGeraet(charakterId)
+ if geraet.battery > 0 then
+ local neu = math.max(0, geraet.battery - 1)
+ MySQL.update('UPDATE aether_phone SET battery = ? WHERE `character` = ?',
+ { neu, charakterId })
+ TriggerClientEvent('aether:phone:akku', quelle, neu)
+ end
+ end
+ end
+ end
+ end
+end)
+
+--- Akku austauschen: verbraucht ein Akku-Item und laedt auf 100 %
+RegisterNetEvent('aether:phone:akkuTauschen', function()
+ local quelle = source
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return end
+
+ if not Inventory:EntferneItem(quelle, Handy.ITEM_AKKU, 1) then
+ Ablehnen(quelle, 'Du hast keinen Ersatz-Akku dabei.')
+ return
+ end
+
+ MySQL.update.await('UPDATE aether_phone SET battery = 100 WHERE `character` = ?', { charakterId })
+ TriggerClientEvent('aether:phone:akku', quelle, 100)
+ SendeZustand(quelle, 'Akku getauscht.')
+end)
+
+-- ---------------------------------------------------------------------
+-- Aufraeumen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:phone:schliessen', function()
+ entsperrt[source] = false
+end)
+
+AddEventHandler('playerDropped', function()
+ local quelle = source
+ entsperrt[quelle] = nil
+
+ -- Laufende Anrufe beenden
+ for anruferQuelle, anruf in pairs(aktiveAnrufe) do
+ if anruferQuelle == quelle or anruf.ziel == quelle then
+ local anderer = (anruferQuelle == quelle) and anruf.ziel or anruferQuelle
+ TriggerClientEvent('aether:phone:anrufStatus', anderer, 'beendet')
+ aktiveAnrufe[anruferQuelle] = nil
+ end
+ end
+end)
+
+-- =====================================================================
+-- EXPORTS
+-- =====================================================================
+
+--- Liefert die Telefonnummer eines Spielers
+exports('HoleNummer', function(quelle)
+ local charakterId = CharakterVon(quelle)
+ if not charakterId then return nil end
+ return HoleGeraet(charakterId).number
+end)
+
+--- Schickt eine Nachricht von einer Dienstnummer an einen Spieler
+--- (z. B. Rechnung vom EMS, Bestaetigung eines Jobs)
+exports('SendeNachricht', function(anNummer, vonNummer, text)
+ if not Handy.NummerGueltig(anNummer) then return false end
+
+ MySQL.insert('INSERT INTO aether_phone_messages (sender, receiver, text) VALUES (?, ?, ?)',
+ { vonNummer or 'System', anNummer, tostring(text):sub(1, 512) })
+
+ local zielQuelle = QuelleZuNummer(anNummer)
+ if zielQuelle then
+ TriggerClientEvent('aether:phone:neueNachricht', zielQuelle, {
+ von = vonNummer or 'System', text = text, zeit = os.date('%H:%M'),
+ })
+ end
+ return true
+end)
diff --git a/resources/[aether]/aether-phone/shared/config.lua b/resources/[aether]/aether-phone/shared/config.lua
new file mode 100644
index 0000000..269b76d
--- /dev/null
+++ b/resources/[aether]/aether-phone/shared/config.lua
@@ -0,0 +1,33 @@
+-- =====================================================================
+-- Aether Phone — Einstellungen
+-- =====================================================================
+
+Handy = Handy or {}
+
+-- Items, die zusammen ein funktionsfaehiges Handy ergeben
+Handy.ITEM_HANDY = 'handy'
+Handy.ITEM_SIM = 'sim'
+Handy.ITEM_AKKU = 'akku'
+
+-- Akkuverbrauch: alle X Millisekunden ein Prozent
+Handy.AKKU_INTERVALL = 180000 -- 3 Minuten
+-- Ab diesem Stand warnt das Handy
+Handy.AKKU_WARNUNG = 15
+
+-- Notrufnummern, die immer erreichbar sind (auch ohne Kontakt)
+Handy.NOTRUFE = {
+ ['110'] = { name = 'Polizei (Notruf)', fraktion = 'lspd' },
+ ['112'] = { name = 'Rettungsdienst', fraktion = 'ems' },
+}
+
+-- Wie viele Nachrichten je Unterhaltung geladen werden
+Handy.MAX_NACHRICHTEN = 50
+
+--- Prueft, ob eine Telefonnummer das erwartete Format hat
+--- @param nummer any Zu pruefender Wert
+--- @return boolean
+function Handy.NummerGueltig(nummer)
+ if type(nummer) ~= 'string' then return false end
+ if Handy.NOTRUFE[nummer] then return true end
+ return nummer:match('^%d%d%d%-%d%d%d%d$') ~= nil
+end
diff --git a/resources/[aether]/aether-police/client/main.lua b/resources/[aether]/aether-police/client/main.lua
new file mode 100644
index 0000000..ab89c2d
--- /dev/null
+++ b/resources/[aether]/aether-police/client/main.lua
@@ -0,0 +1,376 @@
+-- =====================================================================
+-- aether-police — Clientlogik
+-- Öffnet das Tablet und vermittelt zwischen Oberfläche und Server.
+-- =====================================================================
+
+local istOffen = false
+local istGeladen = false
+local imDienst = false
+
+local function SendeAnNui(nachricht)
+ SendNuiMessage(json.encode(nachricht))
+end
+
+local function ZeigeSpielmeldung(text)
+ if not text or text == '' then return end
+ BeginTextCommandThefeedPost('STRING')
+ AddTextComponentSubstringPlayerName(text)
+ EndTextCommandThefeedPostTicker(false, true)
+end
+
+local function Schliessen()
+ if not istOffen then return end
+ istOffen = false
+ SetNuiFocus(false, false)
+ SendeAnNui({ aktion = 'schliessen' })
+ pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
+end
+
+-- ---------------------------------------------------------------------
+-- Ereignisse vom Server
+-- ---------------------------------------------------------------------
+AddEventHandler('aether:client:bereit', function()
+ istGeladen = true
+end)
+
+--- Tablet mit vollem Zustand anzeigen
+RegisterNetEvent('aether:police:zeige', function(daten)
+ istOffen = true
+ imDienst = true
+ SetNuiFocus(true, true)
+ pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
+ daten.aktion = 'oeffnen'
+ SendeAnNui(daten)
+end)
+
+--- Anmeldebildschirm (noch nicht im Dienst)
+RegisterNetEvent('aether:police:anmeldung', function(daten)
+ istOffen = true
+ SetNuiFocus(true, true)
+ pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
+ daten.aktion = 'anmeldung'
+ SendeAnNui(daten)
+end)
+
+RegisterNetEvent('aether:police:schliessen', function()
+ imDienst = false
+ Schliessen()
+ ZeigeSpielmeldung('Dienst beendet.')
+end)
+
+--- Antwort auf einen Modul-Aufruf
+RegisterNetEvent('aether:police:antwort', function(ruf, ergebnis, fehler)
+ SendeAnNui({ aktion = 'antwort', ruf = ruf, ergebnis = ergebnis, fehler = fehler })
+end)
+
+--- Dienstliste (Beamte im Dienst, für Dashboard und Karte)
+RegisterNetEvent('aether:police:dienstliste', function(liste)
+ if istOffen then
+ SendeAnNui({ aktion = 'dienstliste', beamte = liste })
+ end
+end)
+
+--- Hinweis / Erinnerung
+RegisterNetEvent('aether:police:hinweis', function(hinweis)
+ if istOffen then
+ SendeAnNui({ aktion = 'hinweis', hinweis = hinweis })
+ else
+ -- Auch ausserhalb des Tablets erreichbar, damit Fristen nicht untergehen
+ local praefix = hinweis.prioritaet == 'kritisch' and '~r~' or
+ hinweis.prioritaet == 'hoch' and '~o~' or '~b~'
+ ZeigeSpielmeldung(praefix .. hinweis.titel .. '~s~' ..
+ (hinweis.text and ('\n' .. hinweis.text) or ''))
+ end
+end)
+
+RegisterNetEvent('aether:police:fehler', function(text)
+ if istOffen then
+ SendeAnNui({ aktion = 'fehler', text = text })
+ else
+ ZeigeSpielmeldung(text)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Fahrzeug-Telemetrie
+--
+-- Meldet Tatsachen über das gefahrene Dienstfahrzeug: Position,
+-- Tankstand, Zustand. Der Server verwirft die Meldung, wenn das
+-- Kennzeichen nicht zum Fuhrpark gehört.
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ while true do
+ Wait(30000) -- alle 30 Sekunden genügt
+
+ if imDienst then
+ local ped = PlayerPedId()
+ if IsPedInAnyVehicle(ped, false) then
+ local fahrzeug = GetVehiclePedIsIn(ped, false)
+ -- Nur melden, wenn man selbst am Steuer sitzt
+ if GetPedInVehicleSeat(fahrzeug, -1) == ped then
+ local position = GetEntityCoords(fahrzeug)
+ TriggerServerEvent('aether:police:fahrzeugMelden', {
+ kennzeichen = GetVehicleNumberPlateText(fahrzeug),
+ tank = GetVehicleFuelLevel(fahrzeug),
+ zustand = GetVehicleBodyHealth(fahrzeug),
+ x = position.x, y = position.y, z = position.z,
+ })
+ end
+ end
+ end
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Gewahrsam
+-- Der Client führt nur aus, was ein Beamter entschieden hat.
+-- ---------------------------------------------------------------------
+
+--- In eine Zelle verbracht werden
+RegisterNetEvent('aether:police:gewahrsamVerbringen', function(daten)
+ Schliessen()
+ DoScreenFadeOut(600)
+ Wait(800)
+
+ local ped = PlayerPedId()
+ SetEntityCoordsNoOffset(ped, daten.x + 0.0, daten.y + 0.0, daten.z + 0.0, false, false, false)
+ SetEntityHeading(ped, daten.w or 0.0)
+
+ -- Warten, bis die Umgebung geladen ist
+ local versuche = 0
+ while not HasCollisionLoadedAroundEntity(ped) and versuche < 60 do
+ Wait(50)
+ versuche = versuche + 1
+ end
+
+ DoScreenFadeIn(800)
+
+ if daten.rest then
+ ZeigeSpielmeldung(('~y~Gewahrsam~s~ · %s\n%s\nRestzeit laut Akte: %d Minuten')
+ :format(daten.zelle or '', daten.grund or '', math.max(0, daten.rest)))
+ else
+ ZeigeSpielmeldung(('~y~Du wurdest in Gewahrsam genommen~s~ · %s'):format(daten.zelle or ''))
+ end
+end)
+
+--- Entlassung durch einen Beamten
+RegisterNetEvent('aether:police:gewahrsamEntlassen', function(daten)
+ DoScreenFadeOut(600)
+ Wait(800)
+
+ local ped = PlayerPedId()
+ SetEntityCoordsNoOffset(ped, daten.x + 0.0, daten.y + 0.0, daten.z + 0.0, false, false, false)
+ SetEntityHeading(ped, daten.w or 0.0)
+
+ local versuche = 0
+ while not HasCollisionLoadedAroundEntity(ped) and versuche < 60 do
+ Wait(50)
+ versuche = versuche + 1
+ end
+
+ DoScreenFadeIn(800)
+ ZeigeSpielmeldung('~g~Du wurdest aus dem Gewahrsam entlassen.~s~')
+end)
+
+-- ---------------------------------------------------------------------
+-- Terminals an den Wachen
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ for _, terminal in ipairs(Polizei.Config.Terminals) do
+ local blip = AddBlipForCoord(terminal.pos.x, terminal.pos.y, terminal.pos.z)
+ SetBlipSprite(blip, 60)
+ SetBlipColour(blip, 3)
+ SetBlipScale(blip, 0.8)
+ SetBlipAsShortRange(blip, true)
+ BeginTextCommandSetBlipName('STRING')
+ AddTextComponentString('Polizeiwache ' .. terminal.name)
+ EndTextCommandSetBlipName(blip)
+ end
+
+ while true do
+ local pause = 800
+
+ if istGeladen and not istOffen then
+ local position = GetEntityCoords(PlayerPedId())
+
+ for _, terminal in ipairs(Polizei.Config.Terminals) do
+ if #(position - terminal.pos) <= Polizei.Config.TerminalWeite then
+ pause = 0
+ SetTextFont(4)
+ SetTextScale(0.34, 0.34)
+ SetTextColour(255, 255, 255, 215)
+ SetTextCentre(true)
+ SetTextOutline()
+ SetTextEntry('STRING')
+ AddTextComponentString('[E] Terminal ' .. terminal.name)
+ SetDrawOrigin(terminal.pos.x, terminal.pos.y, terminal.pos.z + 0.9, 0)
+ DrawText(0.0, 0.0)
+ ClearDrawOrigin()
+
+ if IsControlJustReleased(0, 38) then
+ TriggerServerEvent('aether:police:oeffnen')
+ end
+ break
+ end
+ end
+ end
+
+ Wait(pause)
+ end
+end)
+
+--- Tablet jederzeit öffnen, sobald man im Dienst ist
+RegisterCommand('mdt', function()
+ if istGeladen and not istOffen then
+ TriggerServerEvent('aether:police:oeffnen')
+ end
+end, false)
+RegisterKeyMapping('mdt', 'Polizei-Tablet öffnen', 'keyboard', 'F5')
+
+-- ---------------------------------------------------------------------
+-- Panikknopf (10-13)
+-- ---------------------------------------------------------------------
+local panikGesperrtBis = 0
+
+RegisterCommand('panik', function()
+ if not imDienst then return end
+
+ -- Kurze Sperre, damit der Alarm nicht versehentlich mehrfach ausgeht
+ if GetGameTimer() < panikGesperrtBis then return end
+ panikGesperrtBis = GetGameTimer() + 10000
+
+ TriggerServerEvent('aether:police:panik')
+ ZeigeSpielmeldung('~r~10-13 ausgelöst~s~ — Verstärkung wird alarmiert.')
+end, false)
+RegisterKeyMapping('panik', 'Panikknopf (10-13)', 'keyboard', 'F7')
+
+-- ---------------------------------------------------------------------
+-- Einsatzmarkierungen auf der Karte
+-- ---------------------------------------------------------------------
+local einsatzBlips = {}
+
+--- Entfernt alle Einsatzmarkierungen
+local function LeereEinsatzBlips()
+ for id, blip in pairs(einsatzBlips) do
+ RemoveBlip(blip)
+ einsatzBlips[id] = nil
+ end
+end
+
+--- Setzt Markierungen für die übergebenen Einsätze
+local function SetzeEinsatzBlips(einsaetze)
+ LeereEinsatzBlips()
+
+ for _, einsatz in ipairs(einsaetze or {}) do
+ if einsatz.pos_x then
+ local blip = AddBlipForCoord(einsatz.pos_x + 0.0, einsatz.pos_y + 0.0, einsatz.pos_z + 0.0)
+ SetBlipSprite(blip, 161)
+ -- Offene Einsätze fallen durch Blinken auf
+ SetBlipColour(blip, einsatz.status == 'offen' and 1 or 5)
+ SetBlipScale(blip, einsatz.prioritaet == 1 and 1.0 or 0.8)
+ SetBlipFlashes(blip, einsatz.status == 'offen' and einsatz.prioritaet <= 2)
+ BeginTextCommandSetBlipName('STRING')
+ AddTextComponentString(einsatz.nummer .. ' · ' .. einsatz.titel)
+ EndTextCommandSetBlipName(blip)
+ einsatzBlips[einsatz.id] = blip
+ end
+ end
+end
+
+--- Es hat sich etwas an den Einsätzen geändert
+RegisterNetEvent('aether:police:einsatzAenderung', function()
+ if istOffen then
+ SendeAnNui({ aktion = 'einsatzAenderung' })
+ end
+end)
+
+--- Panikalarm einer anderen Einheit
+RegisterNetEvent('aether:police:panikAlarm', function(daten)
+ PlaySoundFrontend(-1, 'Lose_1st', 'GTAO_FM_Events_Soundset', true)
+ ZeigeSpielmeldung(('~r~10-13~s~ — %s (%s) benötigt Verstärkung!')
+ :format(daten.name, daten.dienstnummer))
+
+ if daten.position then
+ SetNewWaypoint(daten.position.x, daten.position.y)
+ end
+
+ if istOffen then
+ SendeAnNui({ aktion = 'panikAlarm', daten = daten })
+ end
+end)
+
+--- Die Leitstelle bittet um Übernahme eines Einsatzes
+RegisterNetEvent('aether:police:einsatzZuweisung', function(daten)
+ PlaySoundFrontend(-1, 'Event_Start_Text', 'GTAO_FM_Events_Soundset', true)
+
+ if daten.position then
+ SetNewWaypoint(daten.position.x, daten.position.y)
+ end
+
+ if istOffen then
+ SendeAnNui({ aktion = 'einsatzZuweisung', daten = daten })
+ else
+ ZeigeSpielmeldung(('~b~Zuweisung von %s~s~\n%s — %s')
+ :format(daten.von, daten.nummer, daten.titel))
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- NUI-Callbacks
+-- ---------------------------------------------------------------------
+RegisterNUICallback('schliessen', function(_, rueckgabe)
+ Schliessen()
+ rueckgabe({ ok = true })
+end)
+
+--- Zentraler Modul-Aufruf: die Oberfläche fragt, der Server antwortet
+RegisterNUICallback('aufruf', function(daten, rueckgabe)
+ TriggerServerEvent('aether:police:aufruf',
+ tostring(daten.modul or ''), tostring(daten.aktion or ''),
+ daten.daten or {}, daten.ruf)
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('dienstAntreten', function(_, rueckgabe)
+ TriggerServerEvent('aether:police:dienstAntreten')
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('dienstBeenden', function(_, rueckgabe)
+ TriggerServerEvent('aether:police:dienstBeenden')
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('status', function(daten, rueckgabe)
+ TriggerServerEvent('aether:police:status', tostring(daten.status or ''))
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('leitstelle', function(daten, rueckgabe)
+ TriggerServerEvent('aether:police:leitstelle', tonumber(daten.quelle))
+ rueckgabe({ ok = true })
+end)
+
+--- Einsatzmarkierungen auf der Karte auffrischen
+RegisterNUICallback('einsatzBlips', function(daten, rueckgabe)
+ SetzeEinsatzBlips(daten.einsaetze or {})
+ rueckgabe({ ok = true })
+end)
+
+--- Wegpunkt auf der Karte setzen (z. B. zu einem Einsatzort)
+RegisterNUICallback('wegpunkt', function(daten, rueckgabe)
+ if daten.x and daten.y then
+ SetNewWaypoint(daten.x + 0.0, daten.y + 0.0)
+ ZeigeSpielmeldung('Wegpunkt gesetzt.')
+ end
+ rueckgabe({ ok = true })
+end)
+
+-- ---------------------------------------------------------------------
+-- Aufräumen
+-- ---------------------------------------------------------------------
+AddEventHandler('onResourceStop', function(ressource)
+ if ressource ~= GetCurrentResourceName() then return end
+ SetNuiFocus(false, false)
+ LeereEinsatzBlips()
+end)
diff --git a/resources/[aether]/aether-police/fxmanifest.lua b/resources/[aether]/aether-police/fxmanifest.lua
new file mode 100644
index 0000000..d8b274a
--- /dev/null
+++ b/resources/[aether]/aether-police/fxmanifest.lua
@@ -0,0 +1,73 @@
+fx_version 'cerulean'
+game 'gta5'
+lua54 'yes'
+
+name 'aether-police'
+author 'Jerrit Fritzsche'
+description 'Polizei-Managementsystem — modulare Verwaltungssoftware für den Polizeidienst'
+version '0.1.0'
+
+ui_page 'html/index.html'
+
+files {
+ 'html/index.html',
+ 'html/css/app.css',
+ 'html/js/app.js',
+ 'html/js/module/dashboard.js',
+ 'html/js/module/mdt.js',
+ 'html/js/module/einsatz.js',
+ 'html/js/module/anzeige.js',
+ 'html/js/module/gewahrsam.js',
+ 'html/js/module/beweis.js',
+ 'html/js/module/asservat.js',
+ 'html/js/module/bericht.js',
+ 'html/js/module/fahndung.js',
+ 'html/js/module/fahrzeug.js',
+ 'html/js/module/personal.js',
+ 'html/js/module/kommunikation.js',
+ 'html/js/module/statistik.js',
+ 'html/js/module/admin.js',
+}
+
+shared_scripts {
+ '@aether-core/shared/config.lua',
+ '@aether-core/shared/utils.lua',
+ 'shared/config.lua',
+ 'shared/rollen.lua',
+}
+
+client_scripts {
+ 'client/main.lua',
+}
+
+-- Reihenfolge ist wichtig: Kern und Basisdienste vor den Fachmodulen.
+server_scripts {
+ '@oxmysql/lib/MySQL.lua',
+ 'server/kern.lua',
+ 'server/rechte.lua',
+ 'server/protokoll.lua',
+ 'server/dienst.lua',
+ 'server/hinweise.lua',
+ -- --- Fachmodule (hier neue Module ergänzen) ---
+ 'server/module/dashboard.lua',
+ 'server/module/mdt.lua',
+ 'server/module/einsatz.lua',
+ 'server/module/anzeige.lua',
+ 'server/module/gewahrsam.lua',
+ 'server/module/beweis.lua',
+ 'server/module/asservat.lua',
+ 'server/module/bericht.lua',
+ 'server/module/fahndung.lua',
+ 'server/module/fahrzeug.lua',
+ 'server/module/personal.lua',
+ 'server/module/kommunikation.lua',
+ 'server/module/statistik.lua',
+ 'server/module/admin.lua',
+ -- --- Schnittstelle zuletzt ---
+ 'server/main.lua',
+}
+
+dependencies {
+ 'aether-core',
+ 'oxmysql',
+}
diff --git a/resources/[aether]/aether-police/html/css/app.css b/resources/[aether]/aether-police/html/css/app.css
new file mode 100644
index 0000000..c948684
--- /dev/null
+++ b/resources/[aether]/aether-police/html/css/app.css
@@ -0,0 +1,1079 @@
+/* =====================================================================
+ aether-police — Oberfläche
+ Behördensoftware-Optik: ruhig, sachlich, hohe Informationsdichte.
+ Dunkelmodus ist Standard, helles Design über .thema-hell.
+===================================================================== */
+
+/* ---- Farbwelt ---- */
+.thema-dunkel {
+ --hg: #0b0e14;
+ --flaeche: #121722;
+ --flaeche-2: #1a2130;
+ --flaeche-3: #232c3d;
+ --rand: rgba(255,255,255,0.09);
+ --rand-stark: rgba(255,255,255,0.16);
+ --text: #e8edf5;
+ --text-leise: #8b97ad;
+ --akzent: #3b82f6;
+ --akzent-weich: rgba(59,130,246,0.14);
+ --gut: #34d399;
+ --warn: #f59e0b;
+ --kritisch: #ef4444;
+ --info: #8b5cf6;
+}
+
+.thema-hell {
+ --hg: #eef1f6;
+ --flaeche: #ffffff;
+ --flaeche-2: #f5f7fa;
+ --flaeche-3: #e8ecf3;
+ --rand: rgba(15,23,42,0.10);
+ --rand-stark: rgba(15,23,42,0.20);
+ --text: #0f172a;
+ --text-leise: #5a6478;
+ --akzent: #2563eb;
+ --akzent-weich: rgba(37,99,235,0.10);
+ --gut: #059669;
+ --warn: #d97706;
+ --kritisch: #dc2626;
+ --info: #7c3aed;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0; height: 100%; overflow: hidden;
+ background: transparent;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ color: var(--text);
+ font-size: 14px;
+}
+
+button, input, select, textarea { font-family: inherit; color: inherit; }
+[hidden] { display: none !important; }
+
+/* =====================================================================
+ Anmeldung
+===================================================================== */
+.anmeldung {
+ position: fixed; inset: 0; z-index: 20;
+ display: grid; place-items: center;
+ background: rgba(6,9,14,0.55); backdrop-filter: blur(6px);
+}
+.anmeldung-karte {
+ width: 380px; padding: 34px 30px; text-align: center;
+ background: var(--flaeche); border: 1px solid var(--rand);
+ border-radius: 18px; box-shadow: 0 40px 80px -30px rgba(0,0,0,0.7);
+}
+.wappen {
+ width: 64px; height: 64px; margin: 0 auto 14px; border-radius: 16px;
+ display: grid; place-items: center; font-size: 30px;
+ background: linear-gradient(150deg, var(--akzent), #1e40af); color: #fff;
+}
+.an-behoerde {
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em;
+ color: var(--text-leise); font-weight: 700;
+}
+.anmeldung-karte h1 { font-size: 22px; margin: 6px 0 20px; font-weight: 800; }
+.an-zeile {
+ display: flex; justify-content: space-between; padding: 9px 0;
+ border-bottom: 1px solid var(--rand); font-size: 13px;
+}
+.an-zeile span { color: var(--text-leise); }
+.an-fuss {
+ margin: 18px 0 0; font-size: 11px; line-height: 1.5; color: var(--text-leise);
+}
+
+/* =====================================================================
+ Tablet-Grundgerüst
+===================================================================== */
+.tablet {
+ position: fixed; inset: 0; z-index: 10;
+ display: grid; grid-template-columns: 232px 1fr;
+ background: var(--hg);
+}
+
+/* ---- Seitenleiste ---- */
+.seitenleiste {
+ display: flex; flex-direction: column;
+ background: var(--flaeche); border-right: 1px solid var(--rand);
+}
+.marke {
+ display: flex; align-items: center; gap: 11px;
+ padding: 18px 18px; border-bottom: 1px solid var(--rand);
+}
+.marke-wappen {
+ width: 36px; height: 36px; border-radius: 10px; flex: none;
+ display: grid; place-items: center; font-size: 18px;
+ background: linear-gradient(150deg, var(--akzent), #1e40af); color: #fff;
+}
+.marke-text b { display: block; font-size: 15px; font-weight: 800; letter-spacing: 0.02em; }
+.marke-text small { display: block; font-size: 10.5px; color: var(--text-leise); }
+
+.navigation { flex: 1; padding: 10px 9px; overflow-y: auto; }
+.navigation button {
+ width: 100%; display: flex; align-items: center; gap: 11px;
+ padding: 10px 12px; margin-bottom: 2px;
+ border: none; background: none; border-radius: 9px;
+ color: var(--text-leise); font-size: 13.5px; font-weight: 600;
+ cursor: pointer; text-align: left; transition: .12s;
+}
+.navigation button:hover { background: var(--flaeche-2); color: var(--text); }
+.navigation button.aktiv { background: var(--akzent-weich); color: var(--akzent); font-weight: 700; }
+.navigation button .nav-symbol { width: 18px; text-align: center; font-size: 15px; }
+.navigation button .nav-zaehler {
+ margin-left: auto; background: var(--kritisch); color: #fff;
+ font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 999px;
+}
+
+.seitenleiste-fuss {
+ display: flex; gap: 6px; padding: 12px; border-top: 1px solid var(--rand);
+}
+
+/* ---- Hauptbereich ---- */
+.hauptbereich { display: grid; grid-template-rows: auto 1fr; min-width: 0; }
+
+.kopfleiste {
+ display: flex; align-items: center; gap: 14px;
+ padding: 12px 18px; border-bottom: 1px solid var(--rand);
+ background: var(--flaeche);
+}
+.suche-feld {
+ flex: 1; max-width: 620px; position: relative;
+ display: flex; align-items: center;
+}
+.suche-symbol {
+ position: absolute; left: 12px; color: var(--text-leise); font-size: 16px;
+}
+.suche-feld input {
+ width: 100%; padding: 10px 14px 10px 34px;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ border-radius: 10px; font-size: 13px; outline: none;
+}
+.suche-feld input:focus { border-color: var(--akzent); background: var(--hg); }
+
+.kopf-rechts { margin-left: auto; display: flex; align-items: center; gap: 9px; }
+
+/* Dienststatus */
+.status-waehler { position: relative; }
+.status-knopf {
+ display: flex; align-items: center; gap: 8px;
+ padding: 8px 13px; border-radius: 9px; cursor: pointer;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ font-size: 12.5px; font-weight: 700;
+}
+.status-punkt {
+ width: 9px; height: 9px; border-radius: 50%; background: var(--gut); flex: none;
+}
+.status-liste {
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 5;
+ min-width: 180px; padding: 6px;
+ background: var(--flaeche-2); border: 1px solid var(--rand-stark);
+ border-radius: 11px; box-shadow: 0 20px 40px -16px rgba(0,0,0,0.6);
+}
+.status-liste button {
+ width: 100%; display: flex; align-items: center; gap: 9px;
+ padding: 9px 10px; border: none; background: none; border-radius: 7px;
+ font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left;
+}
+.status-liste button:hover { background: var(--flaeche-3); }
+
+.beamter-chip {
+ display: flex; align-items: center; gap: 11px;
+ padding: 6px 12px 6px 13px; border-radius: 10px;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+}
+.beamter-text b { display: block; font-size: 12.5px; font-weight: 700; line-height: 1.3; }
+.beamter-text small { display: block; font-size: 10.5px; color: var(--text-leise); }
+.beamter-nummer {
+ font-size: 11px; font-weight: 800; color: var(--akzent);
+ background: var(--akzent-weich); padding: 3px 8px; border-radius: 6px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* ---- Inhaltsbereich ---- */
+.inhalt { overflow-y: auto; padding: 20px 22px; }
+.inhalt::-webkit-scrollbar, .navigation::-webkit-scrollbar,
+.schublade-inhalt::-webkit-scrollbar { width: 8px; }
+.inhalt::-webkit-scrollbar-thumb, .navigation::-webkit-scrollbar-thumb,
+.schublade-inhalt::-webkit-scrollbar-thumb {
+ background: var(--rand-stark); border-radius: 4px;
+}
+
+/* =====================================================================
+ Wiederverwendbare Bausteine
+===================================================================== */
+.seiten-kopf {
+ display: flex; align-items: flex-end; justify-content: space-between;
+ gap: 14px; margin-bottom: 18px;
+}
+.seiten-kopf h2 { margin: 0; font-size: 20px; font-weight: 800; letter-spacing: -0.01em; }
+.seiten-kopf p { margin: 3px 0 0; font-size: 12.5px; color: var(--text-leise); }
+
+.karte {
+ background: var(--flaeche); border: 1px solid var(--rand);
+ border-radius: 14px; padding: 16px;
+}
+.karte-kopf {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 12px;
+}
+.karte-titel {
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em;
+ color: var(--text-leise); font-weight: 800;
+}
+
+.raster { display: grid; gap: 14px; }
+.raster.k2 { grid-template-columns: repeat(2, 1fr); }
+.raster.k3 { grid-template-columns: repeat(3, 1fr); }
+.raster.k4 { grid-template-columns: repeat(4, 1fr); }
+.raster.k6 { grid-template-columns: repeat(6, 1fr); }
+@media (max-width: 1400px) { .raster.k6 { grid-template-columns: repeat(3, 1fr); } }
+@media (max-width: 1100px) {
+ .raster.k4, .raster.k3 { grid-template-columns: repeat(2, 1fr); }
+ .tablet { grid-template-columns: 64px 1fr; }
+ .marke-text, .navigation button span:not(.nav-symbol) { display: none; }
+}
+
+/* Kennzahl */
+.kennzahl { background: var(--flaeche); border: 1px solid var(--rand); border-radius: 12px; padding: 14px 16px; }
+.kennzahl .kz-titel {
+ font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em;
+ color: var(--text-leise); font-weight: 800;
+}
+.kennzahl .kz-wert {
+ font-size: 26px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums;
+}
+.kennzahl .kz-zusatz { font-size: 11.5px; color: var(--text-leise); margin-top: 2px; }
+.kennzahl.hoch .kz-wert { color: var(--kritisch); }
+.kennzahl.warn .kz-wert { color: var(--warn); }
+.kennzahl.gut .kz-wert { color: var(--gut); }
+
+/* Listen */
+.liste { display: flex; flex-direction: column; }
+.liste-zeile {
+ display: flex; align-items: center; gap: 12px;
+ padding: 10px 2px; border-bottom: 1px solid var(--rand);
+}
+.liste-zeile:last-child { border-bottom: none; }
+.liste-zeile .lz-haupt { flex: 1; min-width: 0; }
+.liste-zeile .lz-titel { font-size: 13px; font-weight: 700; }
+.liste-zeile .lz-unter { font-size: 11.5px; color: var(--text-leise); margin-top: 1px; }
+.liste-leer {
+ padding: 22px; text-align: center; font-size: 12.5px; color: var(--text-leise);
+}
+
+/* Marken / Etiketten */
+.marker {
+ font-size: 10px; font-weight: 800; padding: 3px 8px; border-radius: 999px;
+ display: inline-block; white-space: nowrap;
+}
+.marker.gut { background: rgba(52,211,153,0.15); color: var(--gut); }
+.marker.warn { background: rgba(245,158,11,0.15); color: var(--warn); }
+.marker.kritisch { background: rgba(239,68,68,0.15); color: var(--kritisch); }
+.marker.info { background: rgba(139,92,246,0.15); color: var(--info); }
+.marker.neutral { background: var(--flaeche-3); color: var(--text-leise); }
+
+/* Prioritätsstreifen */
+.prio { width: 3px; align-self: stretch; border-radius: 2px; flex: none; }
+.prio.p1 { background: var(--kritisch); }
+.prio.p2 { background: var(--warn); }
+.prio.p3 { background: var(--akzent); }
+.prio.p4 { background: var(--text-leise); }
+
+/* Knöpfe */
+.knopf {
+ padding: 10px 16px; border-radius: 9px; border: 1px solid transparent;
+ background: var(--akzent); color: #fff;
+ font-size: 13px; font-weight: 700; cursor: pointer; transition: .12s;
+}
+.knopf:hover { filter: brightness(1.08); }
+.knopf:active { transform: scale(0.98); }
+.knopf:disabled { opacity: .45; cursor: not-allowed; }
+.knopf.leise { background: var(--flaeche-2); color: var(--text); border-color: var(--rand); }
+.knopf.leise:hover { background: var(--flaeche-3); filter: none; }
+.knopf.gefahr { background: var(--kritisch); }
+.knopf.gross { width: 100%; padding: 13px; font-size: 14px; margin-top: 18px; }
+.knopf.klein { padding: 7px 10px; font-size: 12px; }
+.knopf.symbol { padding: 8px 11px; font-size: 15px; position: relative; }
+
+.zaehler {
+ position: absolute; top: 2px; right: 2px;
+ background: var(--kritisch); color: #fff;
+ font-size: 9px; font-weight: 800; min-width: 15px; height: 15px;
+ border-radius: 999px; display: grid; place-items: center; padding: 0 3px;
+}
+
+/* Warnhinweise */
+.warnung {
+ display: flex; align-items: center; gap: 11px;
+ padding: 11px 14px; border-radius: 10px; margin-bottom: 8px;
+ border: 1px solid var(--rand); background: var(--flaeche);
+ font-size: 12.5px; cursor: pointer;
+}
+.warnung .w-symbol { font-size: 16px; }
+.warnung.hoch { border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.07); }
+.warnung.normal { border-color: rgba(245,158,11,0.35); background: rgba(245,158,11,0.06); }
+.warnung.niedrig { border-color: var(--rand); }
+
+/* =====================================================================
+ Mitteilungs-Schublade
+===================================================================== */
+.schublade {
+ position: absolute; top: 0; right: 0; bottom: 0; width: 360px; z-index: 15;
+ background: var(--flaeche); border-left: 1px solid var(--rand-stark);
+ display: grid; grid-template-rows: auto 1fr;
+ box-shadow: -20px 0 50px -20px rgba(0,0,0,0.5);
+ animation: schubladeRein .18s ease;
+}
+@keyframes schubladeRein { from { transform: translateX(24px); opacity: 0; } }
+.schublade-kopf {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 15px 16px; border-bottom: 1px solid var(--rand);
+}
+.schublade-inhalt { overflow-y: auto; padding: 10px 12px; }
+.hinweis-eintrag {
+ padding: 11px 12px; border-radius: 10px; margin-bottom: 7px;
+ background: var(--flaeche-2); border: 1px solid var(--rand); cursor: pointer;
+}
+.hinweis-eintrag.ungelesen { border-left: 3px solid var(--akzent); }
+.hinweis-eintrag .he-kopf {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+}
+.hinweis-eintrag .he-titel { font-size: 12.5px; font-weight: 700; }
+.hinweis-eintrag .he-text { font-size: 11.5px; color: var(--text-leise); margin-top: 3px; line-height: 1.45; }
+.hinweis-eintrag .he-zeit { font-size: 10px; color: var(--text-leise); }
+
+/* =====================================================================
+ Fenster (Dialoge)
+===================================================================== */
+.fenster-ebene { position: absolute; inset: 0; z-index: 18; pointer-events: none; }
+.fenster-ebene > * { pointer-events: auto; }
+.fenster-hg {
+ position: absolute; inset: 0; background: rgba(4,7,12,0.6); backdrop-filter: blur(3px);
+ display: grid; place-items: center; padding: 30px;
+}
+.fenster {
+ width: min(720px, 92%); max-height: 86vh; display: grid; grid-template-rows: auto 1fr auto;
+ background: var(--flaeche); border: 1px solid var(--rand-stark);
+ border-radius: 15px; box-shadow: 0 40px 90px -30px rgba(0,0,0,0.8);
+ animation: fensterRein .16s ease;
+}
+@keyframes fensterRein { from { opacity: 0; transform: translateY(10px) scale(.99); } }
+.fenster-kopf {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 15px 18px; border-bottom: 1px solid var(--rand);
+}
+.fenster-kopf h3 { margin: 0; font-size: 15.5px; font-weight: 800; }
+.fenster-koerper { padding: 18px; overflow-y: auto; }
+.fenster-fuss {
+ display: flex; gap: 9px; justify-content: flex-end;
+ padding: 13px 18px; border-top: 1px solid var(--rand);
+}
+
+/* Formularelemente */
+.feld { margin-bottom: 14px; }
+.feld label {
+ display: block; font-size: 11px; font-weight: 800; color: var(--text-leise);
+ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px;
+}
+.feld input, .feld select, .feld textarea {
+ width: 100%; padding: 10px 12px; border-radius: 9px;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ font-size: 13px; outline: none;
+}
+.feld input:focus, .feld select:focus, .feld textarea:focus { border-color: var(--akzent); }
+.feld textarea { min-height: 90px; resize: vertical; line-height: 1.5; }
+.feld-hinweis { font-size: 11px; color: var(--text-leise); margin-top: 5px; line-height: 1.45; }
+
+/* =====================================================================
+ Kurzmeldungen
+===================================================================== */
+.meldungen {
+ position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
+ z-index: 25; display: flex; flex-direction: column; gap: 8px; align-items: center;
+ pointer-events: none;
+}
+.meldung {
+ padding: 11px 20px; border-radius: 10px; font-size: 13px; font-weight: 600;
+ background: var(--flaeche-3); border: 1px solid var(--rand-stark);
+ box-shadow: 0 16px 34px -14px rgba(0,0,0,0.6);
+ animation: meldungRein .2s ease;
+}
+@keyframes meldungRein { from { opacity: 0; transform: translateY(10px); } }
+.meldung.gut { border-color: var(--gut); }
+.meldung.fehler { border-color: var(--kritisch); }
+
+/* Ladeanzeige */
+.laden {
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: 12px; padding: 60px; color: var(--text-leise); font-size: 13px;
+}
+.spinner {
+ width: 26px; height: 26px; border-radius: 50%;
+ border: 2.5px solid var(--rand-stark); border-top-color: var(--akzent);
+ animation: dreh .7s linear infinite;
+}
+@keyframes dreh { to { transform: rotate(360deg); } }
+
+/* Grundsatz-Hinweis (erscheint an Entscheidungspunkten) */
+.grundsatz {
+ display: flex; gap: 10px; padding: 11px 13px; border-radius: 9px;
+ background: var(--akzent-weich); border: 1px solid rgba(59,130,246,0.25);
+ font-size: 11.5px; line-height: 1.5; color: var(--text-leise); margin-top: 12px;
+}
+.grundsatz b { color: var(--text); }
+
+/* =====================================================================
+ MDT
+===================================================================== */
+.mdt-reiter { display: flex; gap: 5px; margin-bottom: 14px; }
+.mdt-reiter button {
+ padding: 9px 16px; border-radius: 9px; cursor: pointer;
+ background: var(--flaeche); border: 1px solid var(--rand);
+ font-size: 13px; font-weight: 700; color: var(--text-leise); transition: .12s;
+}
+.mdt-reiter button:hover { color: var(--text); }
+.mdt-reiter button.aktiv {
+ background: var(--akzent-weich); border-color: var(--akzent); color: var(--akzent);
+}
+
+.mdt-suchleiste { display: flex; gap: 8px; }
+.mdt-suchleiste input[type="search"] {
+ flex: 1; padding: 10px 13px; border-radius: 9px;
+ background: var(--flaeche); border: 1px solid var(--rand);
+ font-size: 13px; outline: none;
+}
+.mdt-suchleiste input:focus { border-color: var(--akzent); }
+.mdt-suchleiste select {
+ padding: 10px 12px; border-radius: 9px;
+ background: var(--flaeche); border: 1px solid var(--rand); font-size: 12.5px;
+}
+
+/* Anklickbare Treffer */
+.liste-zeile.treffer { cursor: pointer; border-radius: 8px; padding-inline: 8px; transition: .1s; }
+.liste-zeile.treffer:hover { background: var(--flaeche-2); }
+.treffer-text { cursor: pointer; }
+.treffer-text:hover { color: var(--akzent); }
+
+/* Aktenkopf */
+.akte-kopf {
+ display: flex; gap: 18px; align-items: flex-start;
+ padding-bottom: 16px; border-bottom: 1px solid var(--rand);
+}
+.akte-bild {
+ width: 108px; height: 132px; flex: none; border-radius: 10px;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ display: grid; place-items: center; overflow: hidden;
+}
+.akte-bild img { width: 100%; height: 100%; object-fit: cover; }
+.akte-bild .kein-bild {
+ font-size: 10.5px; color: var(--text-leise); text-align: center; padding: 8px;
+}
+.akte-kopf-text { flex: 1; min-width: 0; }
+.akte-kopf-text h3 { margin: 0 0 12px; font-size: 20px; font-weight: 800; }
+.akte-zeilen {
+ display: grid; grid-template-columns: auto 1fr auto 1fr; gap: 6px 14px;
+ font-size: 12.5px; align-items: baseline;
+}
+.akte-zeilen span { color: var(--text-leise); }
+.akte-zeilen b { font-weight: 700; }
+.akte-kopf-aktionen { display: flex; flex-direction: column; gap: 6px; flex: none; }
+
+.akte-inhalt { font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
+
+/* Kennzeichen-Darstellung */
+.kennzeichen {
+ font-family: "Arial Narrow", Arial, sans-serif; font-weight: 800; font-size: 13px;
+ letter-spacing: 0.05em; padding: 5px 9px; border-radius: 5px;
+ background: #f3efe4; color: #1a2a5a; border: 2px solid #c9c4b4; flex: none;
+}
+.kennzeichen-gross {
+ font-family: "Arial Narrow", Arial, sans-serif; font-weight: 800; font-size: 22px;
+ letter-spacing: 0.06em; background: #f3efe4; color: #1a2a5a; border: 3px solid #c9c4b4;
+ height: 66px; width: 132px;
+}
+.farbtupfer {
+ display: inline-block; width: 11px; height: 11px; border-radius: 3px;
+ border: 1px solid var(--rand-stark); vertical-align: -1px;
+}
+
+/* =====================================================================
+ Einsätze & Dispatch
+===================================================================== */
+.dispatch { display: grid; grid-template-columns: 1fr 340px; gap: 14px; align-items: start; }
+@media (max-width: 1200px) { .dispatch { grid-template-columns: 1fr; } }
+.dispatch-spalte { min-width: 0; }
+
+.einsatz-karte {
+ display: flex; align-items: center; gap: 12px;
+ padding: 11px 12px; margin-bottom: 7px; border-radius: 10px;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ cursor: pointer; transition: .12s;
+}
+.einsatz-karte:hover { border-color: var(--akzent); transform: translateX(2px); }
+.einsatz-karte.offen { border-left: 3px solid var(--kritisch); }
+.einsatz-karte .ek-haupt { flex: 1; min-width: 0; }
+.einsatz-karte .ek-titel {
+ font-size: 13.5px; font-weight: 700;
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
+}
+.einsatz-karte .ek-unter {
+ font-size: 11.5px; color: var(--text-leise); margin-top: 3px;
+ display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
+}
+.einsatz-karte .ek-kategorie {
+ font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 999px;
+ background: color-mix(in srgb, var(--kf) 18%, transparent); color: var(--kf);
+}
+.einsatz-karte .ek-rechts {
+ display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none;
+}
+.einsatz-karte .ek-einheiten {
+ font-size: 11px; color: var(--text-leise); font-weight: 700;
+}
+
+/* =====================================================================
+ Strafenkatalog & Anzeigen
+===================================================================== */
+.anzeige-raster { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
+@media (max-width: 1200px) { .anzeige-raster { grid-template-columns: 1fr; } }
+
+.katalog-suche {
+ padding: 7px 11px; border-radius: 8px; font-size: 12.5px; outline: none;
+ background: var(--flaeche-2); border: 1px solid var(--rand); max-width: 220px;
+}
+.katalog-suche:focus { border-color: var(--akzent); }
+
+.katalog-liste { max-height: 520px; overflow-y: auto; }
+.katalog-gruppe { margin-bottom: 14px; }
+.katalog-gruppe:last-child { margin-bottom: 0; }
+.katalog-titel {
+ font-size: 10.5px; font-weight: 800; text-transform: uppercase;
+ letter-spacing: 0.06em; color: var(--text-leise);
+ padding: 5px 0 7px; border-bottom: 1px solid var(--rand); margin-bottom: 6px;
+}
+
+.delikt {
+ display: flex; align-items: flex-start; gap: 10px;
+ padding: 8px 9px; border-radius: 8px; cursor: pointer; transition: .1s;
+ border: 1px solid transparent;
+}
+.delikt:hover { background: var(--flaeche-2); }
+.delikt.gewaehlt { background: var(--akzent-weich); border-color: var(--akzent); }
+.delikt input { margin-top: 2px; accent-color: var(--akzent); flex: none; }
+.delikt-name { font-size: 12.5px; font-weight: 600; }
+.delikt-unter { font-size: 11px; color: var(--text-leise); margin-top: 2px; }
+
+/* Vorschlag des Katalogs — bewusst als Zitat, nicht als Ergebnis */
+.vorschlag-block {
+ border: 1px dashed var(--rand-stark); border-radius: 11px;
+ padding: 13px 14px; margin: 4px 0 14px; background: var(--flaeche-2);
+}
+.vorschlag-kopf { display: flex; align-items: center; justify-content: space-between; }
+.vorschlag-werte { display: flex; gap: 26px; margin-top: 10px; }
+.vorschlag-werte span {
+ display: block; font-size: 10.5px; color: var(--text-leise);
+ text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800;
+}
+.vorschlag-werte b { font-size: 17px; font-weight: 800; }
+.vorschlag-hinweise {
+ margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--rand);
+ font-size: 11.5px; color: var(--text-leise); line-height: 1.6;
+}
+
+.entscheidung-block {
+ border: 1px solid var(--akzent); border-radius: 11px;
+ padding: 14px; background: var(--akzent-weich);
+}
+
+.abweichung {
+ border: 1px solid var(--warn); border-radius: 9px;
+ background: rgba(245,158,11,0.08); padding: 11px 13px; margin-top: 12px;
+}
+.abweichung-kopf { font-size: 12.5px; font-weight: 800; color: var(--warn); }
+
+.person-waehler { display: flex; gap: 8px; }
+.person-waehler input { flex: 1; }
+
+.anzeige-zeile { align-items: flex-start; }
+
+/* Folgen einer Bestätigung */
+.folgen {
+ border: 1px solid var(--rand); border-radius: 10px;
+ padding: 12px 14px; background: var(--flaeche-2);
+}
+.folgen-titel {
+ font-size: 11px; font-weight: 800; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--text-leise); margin-bottom: 10px;
+}
+.folge {
+ display: flex; gap: 11px; align-items: flex-start;
+ font-size: 12.5px; line-height: 1.55; padding: 6px 0;
+}
+.folge-symbol { font-size: 15px; flex: none; }
+
+/* =====================================================================
+ Gewahrsam
+===================================================================== */
+.zellen {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px;
+}
+.zelle {
+ padding: 12px 13px; border-radius: 10px; text-align: center;
+ border: 1px solid var(--rand); background: var(--flaeche-2);
+}
+.zelle.frei { border-style: dashed; opacity: .65; }
+.zelle.belegt { border-color: var(--warn); background: rgba(245,158,11,0.07); }
+.zelle.abgelaufen { border-color: var(--kritisch); background: rgba(239,68,68,0.09); }
+.zelle-name {
+ font-size: 10.5px; font-weight: 800; text-transform: uppercase;
+ letter-spacing: 0.06em; color: var(--text-leise);
+}
+.zelle-status { font-size: 12.5px; color: var(--text-leise); margin-top: 8px; }
+.zelle-insasse { font-size: 13px; font-weight: 700; margin-top: 7px; }
+.zelle-rest {
+ font-size: 12px; font-weight: 800; margin-top: 3px;
+ font-variant-numeric: tabular-nums; color: var(--warn);
+}
+.zelle.abgelaufen .zelle-rest { color: var(--kritisch); }
+
+.gewahrsam-zeile { align-items: flex-start; }
+.gewahrsam-zeile.abgelaufen { background: rgba(239,68,68,0.05); border-radius: 8px; }
+.gewahrsam-rechts {
+ display: flex; flex-direction: column; align-items: flex-end; gap: 7px; flex: none;
+}
+.rest-anzeige {
+ font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums;
+ color: var(--warn);
+}
+.rest-anzeige.abgelaufen { color: var(--kritisch); }
+
+/* =====================================================================
+ Beweise & Asservate
+===================================================================== */
+.beweis-symbol {
+ font-size: 20px; width: 32px; text-align: center; flex: none;
+}
+
+/* Verwahrkette */
+.kette { position: relative; padding-left: 4px; }
+.kette-schritt { display: flex; gap: 14px; position: relative; padding-bottom: 16px; }
+.kette-schritt:last-child { padding-bottom: 0; }
+/* Verbindungslinie zwischen den Schritten */
+.kette-schritt:not(:last-child)::before {
+ content: ""; position: absolute; left: 13px; top: 28px; bottom: 0;
+ width: 2px; background: var(--rand-stark);
+}
+.kette-punkt {
+ width: 28px; height: 28px; border-radius: 50%; flex: none;
+ display: grid; place-items: center; font-size: 11px; font-weight: 800;
+ background: var(--akzent-weich); color: var(--akzent);
+ border: 1px solid var(--akzent); position: relative; z-index: 1;
+}
+.kette-inhalt { padding-top: 3px; }
+.kette-aktion { font-size: 13px; font-weight: 700; }
+.kette-meta { font-size: 11.5px; color: var(--text-leise); margin-top: 2px; }
+.kette-notiz {
+ font-size: 12px; margin-top: 5px; padding: 7px 10px; border-radius: 7px;
+ background: var(--flaeche-2); border: 1px solid var(--rand); line-height: 1.5;
+}
+
+/* =====================================================================
+ Berichte
+===================================================================== */
+.unterschriften {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px;
+}
+.unterschrift {
+ padding: 12px 14px; border-radius: 10px;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ /* Angedeutete Unterschriftenlinie */
+ border-bottom: 2px solid var(--akzent);
+}
+.unterschrift .us-name {
+ font-size: 14px; font-weight: 700; font-style: italic;
+ padding-bottom: 6px;
+}
+.unterschrift .us-meta { font-size: 11px; color: var(--text-leise); }
+.unterschrift .us-zeit {
+ font-size: 10.5px; color: var(--text-leise); margin-top: 3px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* =====================================================================
+ Fahndungen
+===================================================================== */
+.fahndungen { display: flex; flex-direction: column; gap: 9px; }
+
+.fahndung-karte {
+ display: flex; align-items: center; gap: 14px;
+ padding: 12px; border-radius: 11px; cursor: pointer; transition: .12s;
+ background: var(--flaeche-2); border: 1px solid var(--rand);
+ border-left: 4px solid var(--text-leise);
+}
+.fahndung-karte:hover { border-color: var(--akzent); transform: translateX(2px); }
+.fahndung-karte.gefahr-bewaffnet { border-left-color: var(--warn); }
+.fahndung-karte.gefahr-gefaehrlich { border-left-color: var(--kritisch); }
+
+.fahndung-karte .fk-bild {
+ width: 56px; height: 68px; flex: none; border-radius: 8px; overflow: hidden;
+ background: var(--flaeche-3); display: grid; place-items: center;
+}
+.fahndung-karte .fk-bild img { width: 100%; height: 100%; object-fit: cover; }
+.fahndung-karte .fk-symbol { font-size: 26px; opacity: .6; }
+
+.fahndung-karte .fk-haupt { flex: 1; min-width: 0; }
+.fahndung-karte .fk-titel { font-size: 14px; font-weight: 700; }
+.fahndung-karte .fk-grund {
+ font-size: 12.5px; color: var(--text); margin-top: 3px; line-height: 1.45;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+.fahndung-karte .fk-meta { font-size: 11px; color: var(--text-leise); margin-top: 5px; }
+
+.fahndung-karte .fk-rechts {
+ display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none;
+}
+.fahndung-karte .fk-prio { font-size: 10.5px; color: var(--text-leise); font-weight: 700; }
+
+.fahndung-bild {
+ max-width: 100%; max-height: 420px; border-radius: 10px;
+ border: 1px solid var(--rand); display: block;
+}
+
+/* =====================================================================
+ Fuhrpark
+===================================================================== */
+.fz-zeile { align-items: flex-start; }
+
+.fz-werte {
+ display: grid; grid-template-columns: auto 1fr auto 1fr;
+ gap: 6px 9px; align-items: center; margin-top: 7px; max-width: 460px;
+}
+.fz-wert {
+ font-size: 11px; font-weight: 700; color: var(--text-leise);
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+}
+.fz-balken {
+ height: 6px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden;
+}
+.fz-balken > i { display: block; height: 100%; border-radius: 4px; transition: width .3s; }
+
+.fz-anzeige { margin-top: 4px; }
+.fz-anzeige-zeile {
+ display: flex; justify-content: space-between; align-items: baseline;
+ font-size: 12.5px; margin-bottom: 5px;
+}
+.fz-anzeige-zeile span { color: var(--text-leise); }
+.fz-anzeige-zeile b { font-size: 15px; font-variant-numeric: tabular-nums; }
+
+/* =====================================================================
+ Personalverwaltung
+===================================================================== */
+.pv-verteilung { display: flex; flex-direction: column; gap: 7px; margin-top: 4px; }
+.pv-verteilung-zeile {
+ display: grid; grid-template-columns: 150px 1fr 34px;
+ gap: 10px; align-items: center;
+}
+.pv-vt-name { font-size: 12.5px; color: var(--text-leise); }
+.pv-vt-balken {
+ height: 7px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden;
+}
+.pv-vt-balken > i {
+ display: block; height: 100%; border-radius: 4px;
+ background: var(--akzent); transition: width .3s;
+}
+.pv-vt-zahl {
+ font-size: 12.5px; font-weight: 800; text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Dienstpunkt und Dienstnummer in der Liste */
+.pv-punkt {
+ width: 8px; height: 8px; border-radius: 50%; flex: none;
+ background: var(--flaeche-3); border: 1px solid var(--rand);
+}
+.pv-punkt.an { background: var(--gut); border-color: var(--gut); }
+.pv-nummer {
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 11.5px; font-weight: 700; color: var(--text-leise);
+ min-width: 84px; flex: none;
+}
+.pv-dienstdauer {
+ font-size: 12px; font-weight: 700; color: var(--text-leise);
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+}
+.pv-notiz {
+ margin-top: 12px; font-size: 12.5px; line-height: 1.5;
+ color: var(--text-leise); border-left: 2px solid var(--rand); padding-left: 10px;
+}
+
+/* Rechtematrix */
+.pv-rechte { display: flex; flex-direction: column; gap: 16px; margin-top: 6px; }
+.pv-rechte-gruppe { display: flex; flex-direction: column; gap: 4px; }
+.pv-rechte-titel {
+ font-size: 11px; font-weight: 800; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--text-leise); margin-bottom: 3px;
+}
+.pv-recht {
+ display: grid; grid-template-columns: 1fr auto auto;
+ gap: 10px; align-items: center;
+ padding: 8px 10px; border-radius: 8px; background: var(--flaeche-2);
+ border: 1px solid transparent;
+}
+.pv-recht.aktiv { border-color: var(--rand); }
+.pv-recht-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.pv-recht-name { font-size: 12.5px; font-weight: 600; }
+.pv-recht-id {
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 10px; color: var(--text-leise);
+}
+.pv-recht-schalter { display: flex; gap: 3px; flex: none; }
+.pv-schalter {
+ padding: 5px 9px; border-radius: 7px; cursor: pointer;
+ background: var(--flaeche-3); border: 1px solid var(--rand);
+ color: var(--text-leise); font-size: 11px; font-weight: 800; transition: .12s;
+}
+.pv-schalter:hover:not(:disabled) { color: var(--text); border-color: var(--akzent); }
+.pv-schalter.gewaehlt {
+ background: var(--akzent); border-color: var(--akzent); color: #fff;
+}
+.pv-schalter:disabled { opacity: .35; cursor: not-allowed; }
+
+/* Einstellungsdialog */
+.pv-trefferliste { max-height: 210px; overflow-y: auto; margin-bottom: 14px; }
+.pv-trefferliste .liste-zeile.gewaehlt {
+ border-color: var(--akzent); background: var(--flaeche-3);
+}
+
+/* =====================================================================
+ Interne Kommunikation
+===================================================================== */
+.komm-zaehler {
+ display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 999px;
+ background: var(--kritisch); color: #fff;
+ font-size: 10px; font-weight: 800; vertical-align: middle;
+}
+
+/* Posteingang */
+.komm-zeile.ungelesen .lz-titel { font-weight: 800; }
+.komm-punkt {
+ width: 7px; height: 7px; border-radius: 50%; flex: none; background: transparent;
+}
+.komm-punkt.neu { background: var(--akzent); }
+.komm-zeit {
+ font-size: 11px; color: var(--text-leise); white-space: nowrap; flex: none;
+ font-variant-numeric: tabular-nums;
+}
+
+.komm-kopf {
+ display: flex; justify-content: space-between; align-items: baseline;
+ gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--rand);
+}
+.komm-kopf-unter {
+ display: block; font-size: 11px; color: var(--text-leise); margin-top: 2px;
+}
+.komm-text {
+ margin-top: 12px; font-size: 13px; line-height: 1.6;
+ white-space: normal; word-break: break-word;
+}
+
+/* Empfängerauswahl */
+.komm-empf-arten { display: flex; gap: 5px; margin-bottom: 10px; }
+.komm-empfliste { max-height: 230px; overflow-y: auto; margin-top: 8px; }
+.komm-empfliste .liste-zeile.gewaehlt {
+ border-color: var(--akzent); background: var(--flaeche-3);
+}
+
+/* Aushänge */
+.komm-aushaenge { display: flex; flex-direction: column; gap: 12px; }
+.komm-aushang.angeheftet { border-color: var(--warn); }
+.komm-aushang-marker { display: flex; gap: 5px; align-items: center; }
+.komm-aushang-fuss {
+ display: flex; justify-content: space-between; align-items: flex-end;
+ gap: 12px; margin-top: 14px; padding-top: 11px; border-top: 1px solid var(--rand);
+ flex-wrap: wrap;
+}
+.komm-aushang-fuss .feld-hinweis { margin: 0; }
+.komm-aushang-aktionen {
+ display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
+}
+
+.komm-haken {
+ width: auto !important; margin-right: 7px; vertical-align: -1px;
+}
+.feld label:has(.komm-haken) {
+ text-transform: none; letter-spacing: 0; font-size: 12.5px;
+ color: var(--text); cursor: pointer;
+}
+
+/* =====================================================================
+ Statistik
+===================================================================== */
+.st-leiste {
+ display: flex; justify-content: space-between; align-items: center;
+ gap: 12px; flex-wrap: wrap; margin-bottom: 16px;
+}
+.st-leiste .mdt-reiter { margin: 0; }
+.st-zeitraum { display: flex; gap: 4px; }
+
+.st-unter {
+ display: block; font-size: 10.5px; color: var(--text-leise); margin-top: 3px;
+}
+
+/* Zwei Karten nebeneinander */
+.st-spalten {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 14px;
+}
+
+/* Wertepaare */
+.st-werte {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 4px 18px; margin-top: 4px;
+}
+.st-werte > div {
+ display: flex; justify-content: space-between; align-items: baseline;
+ gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--rand);
+}
+.st-werte span { font-size: 12.5px; color: var(--text-leise); }
+.st-werte b { font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
+
+.st-fuss {
+ margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rand);
+ font-size: 11.5px; line-height: 1.5; color: var(--text-leise);
+}
+.st-fuss b { color: var(--text); }
+
+/* Waagerechte Verteilung */
+.st-verteilung { display: flex; flex-direction: column; gap: 7px; margin-top: 4px; }
+.st-vt-zeile {
+ display: grid; grid-template-columns: 160px 1fr 52px 46px;
+ gap: 10px; align-items: center;
+}
+.st-vt-name {
+ font-size: 12.5px; color: var(--text-leise);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.st-vt-balken {
+ height: 7px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden;
+}
+.st-vt-balken > i {
+ display: block; height: 100%; border-radius: 4px;
+ background: var(--akzent); transition: width .3s;
+}
+.st-vt-zahl {
+ font-size: 12.5px; font-weight: 800; text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+.st-vt-anteil {
+ font-size: 11px; color: var(--text-leise); text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+/* Tagesverlauf */
+.st-legende {
+ display: flex; gap: 16px; align-items: center; flex-wrap: wrap;
+ font-size: 11.5px; color: var(--text-leise); margin-bottom: 10px;
+}
+.st-legende .feld-hinweis { margin: 0 0 0 auto; }
+.st-punkt {
+ display: inline-block; width: 8px; height: 8px; border-radius: 2px;
+ margin-right: 5px; vertical-align: -1px;
+}
+.st-punkt.einsatz { background: var(--akzent); }
+.st-punkt.anzeige { background: var(--warn); }
+
+.st-verlauf {
+ display: flex; align-items: flex-end; gap: 3px;
+ height: 150px; overflow-x: auto; padding-bottom: 2px;
+}
+.st-tag {
+ display: flex; flex-direction: column; align-items: center; gap: 4px;
+ flex: 1 0 18px; height: 100%;
+}
+.st-saeulen {
+ display: flex; align-items: flex-end; gap: 2px;
+ width: 100%; height: 100%; justify-content: center;
+}
+.st-saeulen > i {
+ display: block; width: 6px; min-height: 2px; border-radius: 2px 2px 0 0;
+ transition: height .3s;
+}
+.st-saeulen > i.einsatz { background: var(--akzent); }
+.st-saeulen > i.anzeige { background: var(--warn); }
+.st-tag-text {
+ font-size: 9.5px; color: var(--text-leise); font-variant-numeric: tabular-nums;
+}
+
+/* Besetzung nach Tageszeit */
+.st-stunden {
+ display: flex; align-items: flex-end; gap: 4px; height: 170px; margin-top: 6px;
+}
+.st-stunde {
+ display: flex; flex-direction: column; align-items: center; gap: 5px;
+ flex: 1; height: 100%;
+}
+.st-stunde-balken {
+ display: flex; align-items: flex-end; width: 100%; height: 100%;
+ background: var(--flaeche-2); border-radius: 5px 5px 0 0; overflow: hidden;
+}
+.st-stunde-balken > i {
+ display: block; width: 100%; min-height: 2px;
+ background: var(--akzent); transition: height .3s;
+}
+.st-stunde-text {
+ font-size: 10px; color: var(--text-leise); font-variant-numeric: tabular-nums;
+}
+
+.st-ungenutzt {
+ display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px;
+}
+
+/* =====================================================================
+ Administration
+===================================================================== */
+.adm-icon { font-size: 17px; width: 24px; text-align: center; flex: none; }
+
+.adm-modul.aus, .adm-delikt.aus { opacity: .5; }
+.adm-modul.aus .lz-titel { text-decoration: line-through; }
+
+.adm-vorschlag {
+ font-size: 12px; font-weight: 700; color: var(--text-leise);
+ font-variant-numeric: tabular-nums; white-space: nowrap;
+ min-width: 72px; text-align: right; flex: none;
+}
+
+/* Zwei Felder nebeneinander im Formular */
+.adm-formular {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 14px;
+}
+
+.adm-protokoll { align-items: flex-start; }
+.adm-grund {
+ margin-top: 4px; font-size: 11.5px; font-style: italic; color: var(--text-leise);
+ border-left: 2px solid var(--rand); padding-left: 8px;
+}
+
+/* =====================================================================
+ Zweitschreibweisen der Bausteine
+ Personal, Statistik und Administration nutzen diese Varianten.
+ Gleiche Optik, nur andere Klassennamen — hier zusammengeführt,
+ damit beide Schreibweisen sicher greifen.
+===================================================================== */
+.raster-2 { grid-template-columns: repeat(2, 1fr); }
+.raster-3 { grid-template-columns: repeat(3, 1fr); }
+.raster-4 { grid-template-columns: repeat(4, 1fr); }
+@media (max-width: 1100px) {
+ .raster-3, .raster-4 { grid-template-columns: repeat(2, 1fr); }
+}
+
+/* Kennzahl mit Wert zuerst, Beschriftung darunter */
+.kennzahl .kz-zahl {
+ display: block; font-size: 24px; font-weight: 800;
+ font-variant-numeric: tabular-nums; line-height: 1.15;
+}
+.kennzahl .kz-text {
+ display: block; font-size: 10.5px; text-transform: uppercase;
+ letter-spacing: 0.06em; color: var(--text-leise); font-weight: 800; margin-top: 5px;
+}
diff --git a/resources/[aether]/aether-police/html/index.html b/resources/[aether]/aether-police/html/index.html
new file mode 100644
index 0000000..ca11b0e
--- /dev/null
+++ b/resources/[aether]/aether-police/html/index.html
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+LSPD — Dienstsystem
+
+
+
+
+
+
+
+
★
+
Los Santos Police Department
+
—
+
Dienstnummer —
+
Dienstgrad —
+
Abteilung —
+
Dienst antreten
+
Abbrechen
+
+ Das System unterstützt deine Entscheidungen — es trifft niemals selbst welche.
+
+
+
+
+
+
+
+
+
+
+
★
+
+ LSPD
+ Dienstsystem
+
+
+
+
+
+
+ ◐
+ ⏻
+
+
+
+
+
+
+
+
+
+ ⌕
+
+
+
+
+
+
+
+
+
+ ✉0
+
+
+
+
+
+
✕
+
+
+
+
+
+
+
+
+
+
+
+
+ Mitteilungen
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/[aether]/aether-police/html/js/app.js b/resources/[aether]/aether-police/html/js/app.js
new file mode 100644
index 0000000..c39e106
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/app.js
@@ -0,0 +1,536 @@
+/* =====================================================================
+ 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) =>
+ `${sicher(k.text)} `
+ ).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) =>
+ `
+ ${sicher(m.icon)}
+ ${sicher(m.name)}
+ `
+ ).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) =>
+ `
+
+ ${sicher(s.name)}
+ `
+ ).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();
diff --git a/resources/[aether]/aether-police/html/js/module/admin.js b/resources/[aether]/aether-police/html/js/module/admin.js
new file mode 100644
index 0000000..3685b5c
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/admin.js
@@ -0,0 +1,495 @@
+/* =====================================================================
+ 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 `
+ ${moeglich.map((e) => `
+
+ ${sicher(e.text)} `).join("")}
+
`;
+ }
+
+ 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 = `
+
+
+
Module
+
Jeder Baustein lässt sich einzeln abschalten. Abgeschaltete Module
+ verschwinden aus der Seitenleiste — ihre Daten bleiben unangetastet.
+
+
+
+ ${reiterleiste()}
+
+
+
${aktiv}/${d.module.length}
+ Module aktiv
+
${d.beamteImDienst}
+ Beamte im Dienst
+
+ ${d.module.reduce((s, m) => s + m.aktionen, 0)}
+ registrierte Aktionen
+
+
+
+
Registrierte Module
+
+ ${d.module.map(modulZeile).join("")}
+
+
+
+
+
+ Konfiguration
+ Steht in config.lua — Änderung erfordert Neustart
+
+
+ ${d.konfiguration.map((k) =>
+ `
${sicher(k.name)} ${sicher(k.wert)}
`).join("")}
+
+
+ `;
+
+ verdrahteModule(behaelter);
+ }
+
+ function modulZeile(m) {
+ return `
+
${sicher(m.icon)}
+
+
${sicher(m.name)}
+
+ ${sicher(m.beschreibung || "")}
+ ${m.rechtName ? ` · Recht: ${sicher(m.rechtName)}`
+ : m.recht ? ` · Recht: ${sicher(String(m.recht))}` : " · für alle sichtbar"}
+
+
+
${m.aktionen} Aktionen
+ ${m.unverzichtbar
+ ? `
+ unverzichtbar `
+ : `
+ ${m.aktiv ? "Abschalten" : "Einschalten"} `}
+
`;
+ }
+
+ 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: `${sicher(modul.name)} wird
+ ${einschalten ? "wieder in der Seitenleiste erscheinen"
+ : "für alle Beamten aus der Seitenleiste verschwinden"}.
+ ${einschalten ? "" : `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.
`}
+ Grund (freiwillig)
+
`,
+ 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 = `
+
+
+
Strafenkatalog
+
Die Werte hier sind Vorschläge . Sie werden nie automatisch verhängt —
+ ein Beamter kann jederzeit begründet davon abweichen.
+
+
+ Delikt anlegen
+
+
+ ${reiterleiste()}
+
+
+
+
+ Alle Kategorien
+ ${d.kategorien.map((k) =>
+ `
+ ${sicher(k.kategorie)} `).join("")}
+
+
+ ${z.katalogStillgelegt ? "Auch stillgelegte" : "Nur aktive"}
+
+
+
+ ${Object.keys(gruppen).length
+ ? Object.keys(gruppen).map((kategorie) => `
+
+
+ ${sicher(kategorie)}
+ ${gruppen[kategorie].length} Delikte
+
+
${gruppen[kategorie].map(deliktZeile).join("")}
+
`).join("")
+ : `Kein Delikt gefunden.
`}
+ `;
+
+ verdrahteKatalog(behaelter);
+ }
+
+ function deliktZeile(delikt) {
+ return `
+
${sicher(delikt.kennung)}
+
+
${sicher(delikt.bezeichnung)}
+
+ ${sicher(delikt.paragraph || "ohne Paragraf")}
+ ${delikt.hinweis ? " · " + sicher(delikt.hinweis) : ""}
+
+
+
+ ${delikt.haft || 0} Min
+
+ ${euro(delikt.geld || 0)}
+ ${delikt.aktiv ? "" : `
stillgelegt `}
+
Bearbeiten
+
+ ${delikt.aktiv ? "Stilllegen" : "Freigeben"}
+
`;
+ }
+
+ 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: `${sicher(delikt.kennung)} — ${sicher(delikt.bezeichnung)}
+ ${freigeben
+ ? "Das Delikt steht wieder zur Auswahl."
+ : "Das Delikt lässt sich für neue Anzeigen nicht mehr auswählen."}
+ Gelöscht wird nichts — bestehende Anzeigen behalten
+ ihren Eintrag und bleiben nachvollziehbar.
+ Grund (freiwillig)
+
`,
+ 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: `
+
+ Bezeichnung
+
+ Paragraf
+
+
+
+
+ Hinweis für den Beamten
+
+ Sortierung
+
+
+
+
ℹ
+
Diese Werte sind Vorschläge . Das System verhängt sie nie —
+ es rechnet sie nur zusammen und legt sie dem Beamten vor.
+
`,
+ 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 = `
+
+
+
Protokoll
+
Jede Entscheidung im System steht hier mit Beamtem, Zeit und Begründung.
+ Einträge lassen sich weder ändern noch entfernen.
+
+
${d.gesamt.toLocaleString("de-DE")} Einträge gesamt
+
+
+ ${reiterleiste()}
+
+
+
+ Alle Module
+ ${d.module.map((m) =>
+ `
+ ${sicher(m.modul)} `).join("")}
+
+
+ Filter zurücksetzen
+
+
+
+ ${d.eintraege.length
+ ? d.eintraege.map(protokollZeile).join("")
+ : `
${d.unbekannteNummer
+ ? "Diese Dienstnummer gibt es nicht."
+ : "Kein Eintrag gefunden."}
`}
+
+ `;
+
+ 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 `
+
${sicher(e.modul)}
+
+
${sicher(e.aktion)}
+
+ ${sicher(e.name)}${e.dienstnummer ? " (" + sicher(e.dienstnummer) + ")" : ""}
+ ${e.ziel_typ ? ` · ${sicher(e.ziel_typ)} ${sicher(e.ziel_id || "")}` : ""}
+ ${details ? ` · ${details}` : ""}
+
+ ${e.grund ? `
„${sicher(e.grund)}"
` : ""}
+
+
${zeit(e.am || e.erstellt_am)}
+
`;
+ }
+
+ 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 = `Administration
+
Für keinen Bereich der Administration hast du eine Berechtigung.
`;
+ 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 = `Administration
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("admin", { rendern });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/anzeige.js b/resources/[aether]/aether-police/html/js/module/anzeige.js
new file mode 100644
index 0000000..b008a44
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/anzeige.js
@@ -0,0 +1,553 @@
+/* =====================================================================
+ aether-police / Ansicht: Strafenkatalog & Anzeigen
+
+ Der Katalog liefert einen VORSCHLAG. Die Felder darunter gehören dem
+ Beamten — er kann sie jederzeit ändern. Weicht er ab, wird das
+ sichtbar gemacht und eine Begründung verlangt.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, euro, aufruf, meldung, fenster, bestaetigen, $ } = App;
+
+ const z = {
+ reiter: "erfassen", // erfassen | vorgaenge | katalog
+ katalog: [],
+ rechte: {},
+ auswahl: new Set(), // gewählte Delikt-Kennungen
+ vorschlag: null, // { delikte, haft, geld, hinweise }
+ beschuldigter: null, // { id, name }
+ anzeigen: [],
+ filterStatus: "",
+ };
+
+ /* ---- Bausteine --------------------------------------------------- */
+
+ function reiterLeiste() {
+ const reiter = [
+ { id: "erfassen", name: "Anzeige erfassen", symbol: "✎" },
+ { id: "vorgaenge", name: "Vorgänge", symbol: "📋" },
+ ];
+ if (z.rechte.bearbeiten) reiter.push({ id: "katalog", name: "Katalog pflegen", symbol: "⚙" });
+
+ return `${reiter.map((r) =>
+ `
+ ${r.symbol} ${r.name} `).join("")}
`;
+ }
+
+ /** Zeigt Minuten als „1 Std 30 Min" */
+ function haftText(minuten) {
+ const m = Math.max(0, Math.floor(minuten || 0));
+ if (m === 0) return "keine Haft";
+ if (m < 60) return m + " Min";
+ return Math.floor(m / 60) + " Std " + (m % 60 ? (m % 60) + " Min" : "");
+ }
+
+ /* =====================================================================
+ ANZEIGE ERFASSEN
+ ===================================================================== */
+
+ function katalogAuswahl() {
+ if (!z.katalog.length) return `Der Katalog ist leer.
`;
+
+ return z.katalog.map((gruppe) => `
+
+
${sicher(gruppe.kategorie)}
+ ${gruppe.delikte.map((d) => `
+
+
+
+
${sicher(d.bezeichnung)}
+
+ ${d.paragraph ? sicher(d.paragraph) + " · " : ""}
+ ${haftText(d.haft)} · ${euro(d.geld)}
+
+
+ `).join("")}
+
`).join("");
+ }
+
+ function erfassenAnsicht(behaelter) {
+ const v = z.vorschlag;
+
+ behaelter.innerHTML = `
+
+
+
Anzeige erfassen
+
Der Katalog schlägt vor — entschieden wird von dir.
+
+
+
+ ${reiterLeiste()}
+
+
+
+
+ Strafenkatalog
+
+
+
${katalogAuswahl()}
+
+
+
+
Vorgang
+
+
+
Beschuldigte Person
+
+
+ Suchen
+
+ ${z.beschuldigter
+ ? `
Ausgewählt: ${sicher(z.beschuldigter.name)}
`
+ : `
Ohne Beschuldigten kann keine Anzeige erfasst werden.
`}
+
+
+ ${v ? `
+
+
+ Vorschlag des Katalogs
+ ${v.delikte.length} Delikt${v.delikte.length === 1 ? "" : "e"}
+
+
+
Haft ${haftText(v.haft)}
+
Geldstrafe ${euro(v.geld)}
+
+ ${v.hinweise.length ? `
+
+ ${v.hinweise.map((h) => `
ℹ ${sicher(h)}
`).join("")}
+
` : ""}
+
+
+
+
Deine Entscheidung
+
+
+
+
⚠ Du weichst vom Vorschlag ab
+
+ Begründung (erforderlich)
+
+
+
+
+
+
ℹ
+
Der Katalog ist ein Nachschlagewerk . Beide Werte lassen sich frei
+ anpassen — gespeichert werden Vorschlag und Entscheidung getrennt, damit
+ nachvollziehbar bleibt, wer was festgelegt hat.
+
+
+
Als Entwurf erfassen
+
+ Es wird noch nichts eingezogen und niemand kommt in Haft.
+
+
`
+ : `
Wähle links mindestens ein Delikt aus.
`}
+
+
`;
+
+ verdrahteErfassen(behaelter);
+ }
+
+ function verdrahteErfassen(behaelter) {
+ // Delikt an-/abwählen
+ behaelter.querySelectorAll("[data-delikt]").forEach((k) =>
+ k.addEventListener("change", async () => {
+ const kennung = k.dataset.delikt;
+ if (k.checked) z.auswahl.add(kennung); else z.auswahl.delete(kennung);
+ await berechneVorschlag();
+ erfassenAnsicht(behaelter);
+ }));
+
+ // Katalogsuche (nur Anzeige filtern)
+ const suche = $("an-katalogsuche");
+ if (suche) suche.addEventListener("input", () => {
+ const begriff = suche.value.trim().toLowerCase();
+ behaelter.querySelectorAll(".delikt").forEach((el) => {
+ const text = el.textContent.toLowerCase();
+ el.style.display = !begriff || text.includes(begriff) ? "" : "none";
+ });
+ behaelter.querySelectorAll(".katalog-gruppe").forEach((g) => {
+ const sichtbar = [...g.querySelectorAll(".delikt")].some((e) => e.style.display !== "none");
+ g.style.display = sichtbar ? "" : "none";
+ });
+ });
+
+ // Person suchen
+ const personSuche = () => {
+ const feld = $("an-person");
+ const begriff = feld.value.trim();
+ if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
+ personWaehlen(behaelter, begriff);
+ };
+ const personKnopf = $("an-personsuche");
+ if (personKnopf) personKnopf.addEventListener("click", personSuche);
+ const personFeld = $("an-person");
+ if (personFeld) personFeld.addEventListener("keydown", (e) => { if (e.key === "Enter") personSuche(); });
+
+ // Abweichung sichtbar machen
+ const pruefeAbweichung = () => {
+ if (!z.vorschlag) return;
+ const haft = +($("an-haft") || {}).value || 0;
+ const geld = +($("an-geld") || {}).value || 0;
+ const abweichung = haft !== z.vorschlag.haft || geld !== z.vorschlag.geld;
+ const block = $("an-abweichung");
+ if (block) block.hidden = !abweichung;
+ };
+ ["an-haft", "an-geld"].forEach((id) => {
+ const el = $(id);
+ if (el) el.addEventListener("input", pruefeAbweichung);
+ });
+
+ // Erfassen
+ const speichern = $("an-speichern");
+ if (speichern) speichern.addEventListener("click", async () => {
+ if (!z.beschuldigter) { meldung("Bitte eine Person auswählen.", "fehler"); return; }
+
+ const haft = +($("an-haft") || {}).value || 0;
+ const geld = +($("an-geld") || {}).value || 0;
+ const abweichung = haft !== z.vorschlag.haft || geld !== z.vorschlag.geld;
+ const grund = ($("an-grund") || {}).value || "";
+
+ if (abweichung && !z.rechte.abweichen) {
+ meldung("Dir fehlt die Berechtigung, vom Vorschlag abzuweichen.", "fehler");
+ return;
+ }
+ if (abweichung && grund.trim().length < 5) {
+ meldung("Bitte begründe die Abweichung.", "fehler");
+ return;
+ }
+
+ try {
+ await aufruf("anzeige", "erstellen", {
+ beschuldigter: z.beschuldigter.id,
+ delikte: [...z.auswahl],
+ haft, geld, begruendung: grund,
+ });
+ meldung("Als Entwurf erfasst — Bestätigung steht noch aus.", "gut");
+ z.auswahl.clear(); z.vorschlag = null; z.beschuldigter = null;
+ z.reiter = "vorgaenge";
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ }
+
+ /** Holt den Vorschlag zur aktuellen Auswahl vom Server */
+ async function berechneVorschlag() {
+ if (!z.auswahl.size) { z.vorschlag = null; return; }
+ try {
+ z.vorschlag = await aufruf("anzeige", "vorschlag", { delikte: [...z.auswahl] });
+ } catch (f) { meldung(f.message, "fehler"); z.vorschlag = null; }
+ }
+
+ /** Personensuche über das MDT-Modul */
+ async function personWaehlen(behaelter, begriff) {
+ try {
+ const d = await aufruf("mdt", "personSuchen", { begriff });
+ const treffer = d.treffer || [];
+
+ fenster({
+ titel: "Person auswählen",
+ inhalt: treffer.length
+ ? `${treffer.map((p) =>
+ `
+
+
${sicher(p.firstname + " " + p.lastname)}
+
geb. ${sicher(String(p.dob || "").slice(0, 10))}
+
+
`).join("")}
`
+ : `Keine Treffer.
`,
+ nachOeffnen: (el) => {
+ el.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", () => {
+ z.beschuldigter = { id: +zeile.dataset.wahl, name: zeile.dataset.name };
+ el.remove();
+ erfassenAnsicht(behaelter);
+ }));
+ },
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* =====================================================================
+ VORGÄNGE
+ ===================================================================== */
+
+ function vorgaengeAnsicht(behaelter) {
+ const statusArt = { entwurf: "warn", bestaetigt: "info", verworfen: "neutral" };
+ const statusText = { entwurf: "Entwurf", bestaetigt: "Bestätigt", verworfen: "Verworfen" };
+
+ behaelter.innerHTML = `
+
+
Vorgänge Erfasste Anzeigen und ihr Bearbeitungsstand
+
+ Alle
+ Nur Entwürfe
+ Nur bestätigte
+ Nur verworfene
+
+
+
+ ${reiterLeiste()}
+
+
+ ${z.anzeigen.length ? `
${z.anzeigen.map((a) => `
+
+
+
+ ${sicher((a.b_vorname || "") + " " + (a.b_nachname || ""))}
+ ${a.abweichung ? 'abweichend ' : ""}
+
+
+ ${sicher((a.delikte || []).map((d) => d.name).join(", ") || "—")}
+
+
+ ${haftText(a.haft_final)} · ${euro(a.geld_final)}
+ ${a.abweichung ? ` (Vorschlag: ${haftText(a.haft_empfohlen)} · ${euro(a.geld_empfohlen)}) ` : ""}
+ · erfasst von ${sicher((a.e_vorname || "") + " " + (a.e_nachname || ""))} · ${zeit(a.erstellt_am)}
+
+ ${a.begruendung ? `
„${sicher(a.begruendung)}"
` : ""}
+
+
+ ${sicher(statusText[a.status] || a.status)}
+ ${a.status === "entwurf" && z.rechte.bestaetigen
+ ? `Bestätigen ` : ""}
+ ${a.status === "entwurf"
+ ? `Verwerfen ` : ""}
+
+
`).join("")}
`
+ : `
Keine Vorgänge.
`}
+
`;
+
+ $("an-filter").addEventListener("change", (e) => {
+ z.filterStatus = e.target.value;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-bestaetigen]").forEach((b) =>
+ b.addEventListener("click", () => bestaetigenDialog(behaelter, +b.dataset.bestaetigen)));
+
+ behaelter.querySelectorAll("[data-verwerfen]").forEach((b) =>
+ b.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Entwurf verwerfen",
+ gefahr: true,
+ inhalt: `Grund
+
`,
+ bestaetigenText: "Verwerfen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("anzeige", "verwerfen", {
+ id: +b.dataset.verwerfen,
+ grund: el.querySelector("#av-grund").value.trim(),
+ });
+ zu(); meldung("Entwurf verworfen."); rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }));
+ }
+
+ /** Bestätigungsdialog — benennt genau, was ausgeführt wird */
+ function bestaetigenDialog(behaelter, anzeigeId) {
+ const a = z.anzeigen.find((x) => x.id === anzeigeId);
+ if (!a) return;
+
+ bestaetigen({
+ titel: "Anzeige bestätigen",
+ inhalt: `
+
+ ${sicher((a.b_vorname || "") + " " + (a.b_nachname || ""))}
+ ${sicher((a.delikte || []).map((d) => d.name).join(", "))}
+
+
+
+
Mit deiner Bestätigung geschieht Folgendes:
+
+
💶
+
Die Geldstrafe von ${euro(a.geld_final)} wird eingezogen und
+ der Staatskasse zugeführt.
+
+
+
🔒
+
${a.haft_final > 0
+ ? `Die festgelegte Haftdauer von ${haftText(a.haft_final)} wird
+ vermerkt — eine Einweisung in Gewahrsam erfolgt dadurch nicht .
+ Das ist ein eigener Schritt.`
+ : "Es ist keine Haft vorgesehen."}
+
+
+
📄
+
Der Vorgang wird mit deinem Namen als bestätigend festgehalten.
+
+
+
+ ${a.abweichung ? `
+
+
⚠ Abweichung vom Katalogvorschlag
+
+ Vorschlag war ${haftText(a.haft_empfohlen)} · ${euro(a.geld_empfohlen)}.
+ ${a.begruendung ? `Begründung: „${sicher(a.begruendung)}"` : "Keine Begründung hinterlegt."}
+
+
` : ""}`,
+ bestaetigenText: "Bestätigen",
+ bestaetigt: async (_, zu) => {
+ try {
+ const ergebnis = await aufruf("anzeige", "bestaetigen", { id: anzeigeId });
+ zu();
+ meldung(ergebnis.eingezogen > 0
+ ? "Bestätigt · " + euro(ergebnis.eingezogen) + " eingezogen."
+ : "Bestätigt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* =====================================================================
+ KATALOGPFLEGE
+ ===================================================================== */
+
+ function katalogAnsicht(behaelter) {
+ behaelter.innerHTML = `
+
+
Strafenkatalog pflegen
+
Die Werte hier sind Empfehlungen — sie binden niemanden.
+
+ Eintrag
+
+
+ ${reiterLeiste()}
+
+
+ ${z.katalog.map((gruppe) => `
+
+
${sicher(gruppe.kategorie)}
+
${gruppe.delikte.map((d) => `
+
+
${sicher(d.kennung)}
+
+
${sicher(d.bezeichnung)}
+
${d.paragraph ? sicher(d.paragraph) + " · " : ""}
+ ${haftText(d.haft)} · ${euro(d.geld)}
+ ${d.hinweis ? " · " + sicher(d.hinweis) : ""}
+
+
Ändern
+
Deaktivieren
+
`).join("")}
+
`).join("")}
+
`;
+
+ $("kat-neu").addEventListener("click", () => katalogDialog(behaelter, null));
+
+ behaelter.querySelectorAll("[data-kat-bearbeiten]").forEach((b) =>
+ b.addEventListener("click", () => {
+ const kennung = b.dataset.katBearbeiten;
+ let eintrag = null;
+ z.katalog.forEach((g) => g.delikte.forEach((d) => { if (d.kennung === kennung) eintrag = d; }));
+ katalogDialog(behaelter, eintrag);
+ }));
+
+ behaelter.querySelectorAll("[data-kat-weg]").forEach((b) =>
+ b.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Eintrag deaktivieren",
+ inhalt: `
+ Der Eintrag verschwindet aus der Auswahl, bleibt aber in bestehenden Anzeigen
+ nachvollziehbar. Gelöscht wird nichts.
`,
+ bestaetigenText: "Deaktivieren",
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("anzeige", "katalogDeaktivieren", { kennung: b.dataset.katWeg });
+ zu(); meldung("Eintrag deaktiviert."); rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }));
+ }
+
+ function katalogDialog(behaelter, eintrag) {
+ const e = eintrag || {};
+ fenster({
+ titel: eintrag ? "Eintrag ändern" : "Neuer Katalogeintrag",
+ inhalt: `
+
+ Bezeichnung
+
+ Gesetzliche Grundlage
+
+
+ Hinweis für Beamte
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("anzeige", "katalogSpeichern", {
+ kennung: el.querySelector("#kd-kennung").value.trim(),
+ kategorie: el.querySelector("#kd-kategorie").value.trim(),
+ bezeichnung: el.querySelector("#kd-bezeichnung").value.trim(),
+ paragraph: el.querySelector("#kd-paragraph").value.trim(),
+ haft: +el.querySelector("#kd-haft").value,
+ geld: +el.querySelector("#kd-geld").value,
+ hinweis: el.querySelector("#kd-hinweis").value.trim(),
+ });
+ zu(); meldung("Gespeichert.", "gut"); rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ const k = await aufruf("anzeige", "katalog", {});
+ z.katalog = k.katalog;
+ z.rechte = k.rechte;
+
+ if (z.reiter === "vorgaenge") {
+ const v = await aufruf("anzeige", "liste", { status: z.filterStatus });
+ z.anzeigen = v.anzeigen;
+ z.rechte = Object.assign({}, z.rechte, v.rechte);
+ vorgaengeAnsicht(behaelter);
+ } else if (z.reiter === "katalog") {
+ katalogAnsicht(behaelter);
+ } else {
+ erfassenAnsicht(behaelter);
+ }
+
+ behaelter.querySelectorAll("[data-reiter]").forEach((b) =>
+ b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); }));
+
+ } catch (f) {
+ behaelter.innerHTML = `Anzeigen
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("anzeige", { rendern });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/asservat.js b/resources/[aether]/aether-police/html/js/module/asservat.js
new file mode 100644
index 0000000..4c43f96
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/asservat.js
@@ -0,0 +1,394 @@
+/* =====================================================================
+ aether-police / Ansicht: Asservatenkammer
+
+ Jedes Objekt trägt eine lückenlose Verwahrkette: wer hat es
+ eingelagert, ausgegeben, zurückgenommen oder vernichtet.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App;
+
+ const z = {
+ daten: null,
+ offen: null,
+ filterKategorie: "",
+ filterStatus: "",
+ suche: "",
+ };
+
+ const katSymbol = {
+ waffe: "🔫", drogen: "💊", bargeld: "💵", schmuck: "💍",
+ datentraeger: "💾", dokument: "📄", sonstiges: "📦",
+ };
+
+ const statusArt = {
+ eingelagert: "gut", ausgegeben: "warn",
+ vernichtet: "kritisch", zurueckgegeben: "neutral",
+ };
+ const statusText = {
+ eingelagert: "Eingelagert", ausgegeben: "Ausgegeben",
+ vernichtet: "Vernichtet", zurueckgegeben: "Zurückgegeben",
+ };
+
+ function katName(id) {
+ const treffer = (z.daten.kategorien || []).find((k) => k.id === id);
+ return treffer ? treffer.name : id;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Asservatenkammer
+
Jeder Schritt wird mit Namen festgehalten — die Verwahrkette bleibt lückenlos.
+
+ ${d.rechte.einlagern ? `
+ Einlagern ` : ""}
+
+
+
+
Eingelagert
+
${d.bestand.eingelagert || 0}
+
Ausgegeben
+
${d.bestand.ausgegeben || 0}
+
Zurückgegeben
+
${d.bestand.zurueckgegeben || 0}
+
Vernichtet
+
${d.bestand.vernichtet || 0}
+
+
+
+
+
+ Alle Kategorien
+ ${(d.kategorien || []).map((k) =>
+ `${sicher(k.name)} `).join("")}
+
+
+ Alle Status
+ ${["eingelagert", "ausgegeben", "zurueckgegeben", "vernichtet"].map((s) =>
+ `${statusText[s]} `).join("")}
+
+
+
+
+ ${d.asservate.length ? `
${d.asservate.map((a) => `
+
+
${katSymbol[a.kategorie] || "📦"}
+
+
${sicher(a.bezeichnung)}
+ ${a.menge ? `${sicher(a.menge)} ` : ""}
+
+ ${sicher(a.kennung)} · ${sicher(katName(a.kategorie))} · ${sicher(a.lagerort)}
+ ${a.aktenzeichen ? " · " + sicher(a.aktenzeichen) : ""}
+
+
+ verantwortlich: ${sicher(a.dienstnummer || "")}
+ ${sicher((a.firstname || "") + " " + (a.lastname || ""))} · ${zeit(a.eingelagert_am)}
+
+
+
${sicher(statusText[a.status] || a.status)}
+
`).join("")}
`
+ : `
Keine Asservate gefunden.
`}
+
`;
+
+ const neu = $("as-neu");
+ if (neu) neu.addEventListener("click", () => einlagernDialog(behaelter));
+
+ const suchfeld = $("as-suche");
+ suchfeld.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); }
+ });
+ $("as-kategorie").addEventListener("change", (e) => {
+ z.filterKategorie = e.target.value; rendern(behaelter);
+ });
+ $("as-status").addEventListener("change", (e) => {
+ z.filterStatus = e.target.value; rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-asservat]").forEach((el) =>
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.asservat)));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------ */
+
+ function einzelAnsicht(behaelter, d) {
+ const a = d.asservat;
+
+ behaelter.innerHTML = `
+
+
+
${katSymbol[a.kategorie] || "📦"} ${sicher(a.bezeichnung)}
+
${sicher(a.kennung)} · ${sicher(katName(a.kategorie))}
+
+
+ ‹ Zurück
+ ${a.status === "eingelagert" && d.rechte.ausgeben
+ ? `Ausgeben ` : ""}
+ ${a.status === "ausgegeben" && d.rechte.ausgeben
+ ? `Zurücknehmen ` : ""}
+ ${a.status === "eingelagert"
+ ? `Umlagern ` : ""}
+ ${a.status !== "vernichtet" && d.rechte.vernichten
+ ? `Vernichten ` : ""}
+
+
+
+
+
+
+ Objekt
+ ${sicher(statusText[a.status] || a.status)}
+
+
+ Kennung ${sicher(a.kennung)}
+ Kategorie ${sicher(katName(a.kategorie))}
+ Menge ${sicher(a.menge || "—")}
+ Lagerort ${sicher(a.lagerort)}
+ Verantwortlich ${sicher(a.dienstnummer || "")}
+ ${sicher((a.firstname || "") + " " + (a.lastname || ""))}
+ Eingelagert ${zeit(a.eingelagert_am)}
+
+
+
+
+
Zuordnung
+
+ ${a.aktenzeichen ? `
+
Akte
+
${sicher(a.akte_titel || "")}
+
${sicher(a.aktenzeichen)}
` : ""}
+ ${a.beweis_kennung ? `
+
Beweis
+
${sicher(a.beweis_bezeichnung || "")}
+
${sicher(a.beweis_kennung)}
` : ""}
+ ${!a.aktenzeichen && !a.beweis_kennung
+ ? `
Keine Zuordnung hinterlegt.
` : ""}
+
+
+
+
+
+ Verwahrkette
+ ${d.historie.length} Schritte
+
+
${d.historie.map((h, i) => `
+
+
${i + 1}
+
+
${sicher(h.aktion)}
+
+ ${sicher(h.dienstnummer || "")}
+ ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)}
+
+ ${h.notiz ? `
${sicher(h.notiz)}
` : ""}
+
+
`).join("")}
+
+
`;
+
+ $("as-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); });
+
+ const ausgeben = $("as-ausgeben");
+ if (ausgeben) ausgeben.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Asservat ausgeben",
+ inhalt: `
+
+ ${sicher(a.kennung)} — ${sicher(a.bezeichnung)}
+ Empfänger
+
+ Grund
+
`,
+ bestaetigenText: "Ausgeben",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("asservat", "ausgeben", {
+ id: a.id,
+ empfaenger: el.querySelector("#aa-empf").value.trim(),
+ grund: el.querySelector("#aa-grund").value.trim(),
+ });
+ zu(); meldung("Ausgegeben.", "gut"); oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const zurueckgeben = $("as-zurueckgeben");
+ if (zurueckgeben) zurueckgeben.addEventListener("click", () => {
+ fenster({
+ titel: "Zurücknehmen",
+ inhalt: `
+ Lagerort
+
+ Vermerk
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Zurücknehmen", klick: async (el, zu) => {
+ try {
+ await aufruf("asservat", "zurueckgeben", {
+ id: a.id,
+ lagerort: el.querySelector("#az-ort").value.trim(),
+ notiz: el.querySelector("#az-notiz").value.trim(),
+ });
+ zu(); meldung("Zurückgenommen.", "gut"); oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const umlagern = $("as-umlagern");
+ if (umlagern) umlagern.addEventListener("click", () => {
+ fenster({
+ titel: "Umlagern",
+ inhalt: `Neuer Lagerort
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Umlagern", klick: async (el, zu) => {
+ try {
+ await aufruf("asservat", "umlagern", {
+ id: a.id, lagerort: el.querySelector("#au-ort").value.trim(),
+ });
+ zu(); meldung("Umgelagert.", "gut"); oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const vernichten = $("as-vernichten");
+ if (vernichten) vernichten.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Asservat vernichten",
+ gefahr: true,
+ inhalt: `
+
+
Endgültige Maßnahme
+
+
🔥
+
${sicher(a.kennung)} — ${sicher(a.bezeichnung)} wird als vernichtet
+ gekennzeichnet.
+
+
+
📄
+
Der Datensatz bleibt bestehen — vernichtet wird der Gegenstand,
+ nicht seine Dokumentation. Alle Beamten werden benachrichtigt.
+
+
+ Zweiter Beamter als Zeuge
+
+ Begründung (ausführlich)
+
`,
+ bestaetigenText: "Vernichtung bestätigen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("asservat", "vernichten", {
+ id: a.id,
+ zeuge: el.querySelector("#av-zeuge").value.trim(),
+ grund: el.querySelector("#av-grund").value.trim(),
+ });
+ zu(); meldung("Vernichtung dokumentiert."); oeffne(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+ }
+
+ /* ---- Einlagern ---------------------------------------------------- */
+
+ function einlagernDialog(behaelter) {
+ fenster({
+ titel: "Asservat einlagern",
+ inhalt: `
+
+
Kategorie
+
+ ${(z.daten.kategorien || []).map((k) =>
+ `${sicher(k.name)} `).join("")}
+
+
Menge / Umfang
+
+
+ Bezeichnung
+
+ Lagerort
+
+
+ Vermerk
+
+
+
ℹ
+
Du wirst als verantwortlich eingetragen. Jede weitere Bewegung
+ des Objekts wird in der Verwahrkette festgehalten.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Einlagern", klick: async (el, zu) => {
+ try {
+ const ergebnis = await aufruf("asservat", "einlagern", {
+ kategorie: el.querySelector("#an-kat").value,
+ bezeichnung: el.querySelector("#an-bez").value.trim(),
+ menge: el.querySelector("#an-menge").value.trim(),
+ lagerort: el.querySelector("#an-ort").value.trim(),
+ akte: +el.querySelector("#an-akte").value || null,
+ beweis: +el.querySelector("#an-beweis").value || null,
+ notiz: el.querySelector("#an-notiz").value.trim(),
+ });
+ zu(); meldung("Eingelagert als " + ergebnis.kennung + ".", "gut");
+ oeffne(behaelter, ergebnis.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function oeffne(behaelter, asservatId) {
+ try {
+ const d = await aufruf("asservat", "oeffnen", { id: asservatId });
+ z.offen = asservatId;
+ // Kategorien für die Benennung mitführen
+ if (!z.daten) z.daten = { kategorien: [] };
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("asservat", "liste", {
+ kategorie: z.filterKategorie, status: z.filterStatus, begriff: z.suche,
+ });
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Asservate
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("asservat", { rendern });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("asservat", "liste", { begriff });
+ return (d.asservate || []).slice(0, 5).map((a) => ({
+ bereich: "Asservat", titel: a.bezeichnung,
+ unter: a.kennung + " · " + a.lagerort,
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/bericht.js b/resources/[aether]/aether-police/html/js/module/bericht.js
new file mode 100644
index 0000000..6ee494b
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/bericht.js
@@ -0,0 +1,602 @@
+/* =====================================================================
+ aether-police / Ansicht: Berichtswesen
+
+ Drei Stufen: Entwurf → Eingereicht → Freigegeben. Keine davon wird
+ automatisch erreicht. Unterschriften sind verbindlich.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App;
+
+ const z = {
+ daten: null,
+ offen: null,
+ filterStatus: "",
+ nurEigene: false,
+ suche: "",
+ };
+
+ const statusArt = { entwurf: "warn", eingereicht: "info", freigegeben: "gut" };
+ const statusText = { entwurf: "Entwurf", eingereicht: "Eingereicht", freigegeben: "Freigegeben" };
+
+ /* ---- Übersicht ---------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Berichte
+
Entwurf → Eingereicht → Freigegeben. Jede Stufe verlangt eine Entscheidung.
+
+ ${d.rechte.erstellen ? `
+ Bericht anlegen ` : ""}
+
+
+
+
+
Meine offenen Entwürfe
+
${d.eigeneOffen}
+
warten auf Ausarbeitung
+
+
+
Zur Prüfung eingereicht
+
${d.zurPruefung}
+
${d.rechte.freigeben ? "warten auf deine Freigabe" : "in Bearbeitung"}
+
+
+
+
+
+
+ Alle Stufen
+ Entwürfe
+ Eingereicht
+ Freigegeben
+
+
+ ${z.nurEigene ? "Alle anzeigen" : "Nur meine"}
+
+
+
+
+ ${d.berichte.length ? `
${d.berichte.map((b) => `
+
+
+
+
${sicher(b.titel)}
+ ${b.eigener ? 'eigener ' : ""}
+
+ ${sicher(b.nummer)}
+ ${b.einsatz_nummer ? " · Einsatz " + sicher(b.einsatz_nummer) : ""}
+ · ${sicher(b.dienstnummer || "")} ${sicher((b.firstname || "") + " " + (b.lastname || ""))}
+ · ${zeit(b.erstellt_am)}
+
+
+
+ ${sicher(statusText[b.status] || b.status)}
+ ${b.unterschriften.length
+ ? `✍ ${b.unterschriften.length} ` : ""}
+
+
`).join("")}
`
+ : `
Keine Berichte gefunden.
`}
+
`;
+
+ const neu = $("br-neu");
+ if (neu) neu.addEventListener("click", () => anlegenDialog(behaelter));
+
+ const suchfeld = $("br-suche");
+ suchfeld.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); }
+ });
+ $("br-status").addEventListener("change", (e) => {
+ z.filterStatus = e.target.value; rendern(behaelter);
+ });
+ $("br-eigene").addEventListener("click", () => {
+ z.nurEigene = !z.nurEigene; rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-bericht]").forEach((el) =>
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.bericht)));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------ */
+
+ function einzelAnsicht(behaelter, d) {
+ const b = d.bericht;
+ const bearbeitbar = d.rechte.bearbeiten;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(b.titel)}
+
${sicher(b.nummer)}
+ ${b.einsatz_nummer ? " · Einsatz " + sicher(b.einsatz_nummer) : ""}
+ ${b.aktenzeichen ? " · Akte " + sicher(b.aktenzeichen) : ""}
+
+
+ ‹ Zurück
+ ${bearbeitbar ? `✎ Bearbeiten ` : ""}
+ ${!d.habeUnterschrieben && b.status !== "freigegeben"
+ ? `✍ Unterschreiben ` : ""}
+ ${b.status === "entwurf" && bearbeitbar
+ ? `Einreichen ` : ""}
+ ${b.status === "eingereicht" && d.rechte.freigeben
+ ? `Nachbessern
+ Freigeben ` : ""}
+
+
+
+ ${b.status === "freigegeben" ? `
+
+ 🔒
+ Dieser Bericht ist freigegeben und damit inhaltlich gesperrt —
+ auch für den Verfasser.
+
` : ""}
+
+
+
+
+ Sachverhalt
+ ${sicher(statusText[b.status] || b.status)}
+
+
+ Verfasser ${sicher(b.dienstnummer || "")}
+ ${sicher((b.firstname || "") + " " + (b.lastname || ""))}
+ Angelegt ${zeit(b.erstellt_am)}
+
+
${b.beschreibung
+ ? sicher(b.beschreibung).replace(/\n/g, " ")
+ : "Noch keine Beschreibung erfasst. "}
+
+
Getroffene Maßnahmen
+
${b.massnahmen
+ ? sicher(b.massnahmen).replace(/\n/g, " ")
+ : "Keine Maßnahmen dokumentiert. "}
+
+
+
+
+ Beteiligte
+ ${bearbeitbar ? `+ ` : ""}
+
+ ${b.beteiligte.length ? `
${b.beteiligte.map((p, i) => `
+
+
+ ${p.typ === "beamter" ? (p.dienstnummer || "Beamter") : "Person"}
+
+
${sicher(p.name)}
+ ${p.rolle ? `
${sicher(p.rolle)}
` : ""}
+
+ ${bearbeitbar && p.charakterId !== b.erstellt_von
+ ? `
✕ ` : ""}
+
`).join("")}
`
+ : `
Keine Beteiligten erfasst.
`}
+
+
+
+
+ Anlagen & Beweise
+ ${bearbeitbar ? `+ ` : ""}
+
+ ${b.anlagen.length ? `
${b.anlagen.map((a, i) => `
+
+
+ ${a.typ === "beweis" ? sicher(a.kennung) : "Anlage"}
+
+
${sicher(a.titel)}
+ ${a.adresse ? `
${sicher(a.adresse)}
` : ""}
+
+ ${a.adresse
+ ? `
Öffnen ` : ""}
+ ${bearbeitbar ? `
✕ ` : ""}
+
`).join("")}
`
+ : `
Keine Anlagen.
`}
+
+
+
+
+ Unterschriften
+ ${b.unterschriften.length}
+
+ ${b.unterschriften.length ? `
${b.unterschriften.map((u) => `
+
+
${sicher(u.name)}
+
${sicher(u.dienstnummer || "")} · ${sicher(u.rolle || "")}
+
${zeit(u.am)}${u.freigabe ? " · Freigabe" : ""}
+
`).join("")}
`
+ : `
Noch nicht unterschrieben.
`}
+
+
ℹ
+
Eine Unterschrift ist verbindlich und wird nicht zurückgenommen.
+ Sie bestätigt, dass der Inhalt aus deiner Sicht zutrifft.
+
+
+
`;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const b = d.bericht;
+
+ $("br-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); });
+
+ const bearbeiten = $("br-bearbeiten");
+ if (bearbeiten) bearbeiten.addEventListener("click", () => {
+ const unterschrieben = b.unterschriften.length > 0;
+ fenster({
+ titel: "Bericht bearbeiten",
+ inhalt: `
+ Titel
+
+ Sachverhalt
+
+ Getroffene Maßnahmen
+
+ ${unterschrieben ? `
+
+
⚠ Der Bericht wurde bereits unterschrieben
+
+ Grund der Änderung (erforderlich)
+
+
+
` : ""}`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("bericht", "speichern", {
+ id: b.id,
+ titel: el.querySelector("#be-titel").value.trim(),
+ beschreibung: el.querySelector("#be-text").value,
+ massnahmen: el.querySelector("#be-mass").value,
+ grund: (el.querySelector("#be-grund") || {}).value || "",
+ });
+ zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const beteiligt = $("br-beteiligt");
+ if (beteiligt) beteiligt.addEventListener("click", () => beteiligtDialog(behaelter, b.id));
+
+ behaelter.querySelectorAll("[data-bet-weg]").forEach((el) =>
+ el.addEventListener("click", async () => {
+ try {
+ await aufruf("bericht", "beteiligtEntfernen", { id: b.id, index: +el.dataset.betWeg });
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }));
+
+ const anlage = $("br-anlage");
+ if (anlage) anlage.addEventListener("click", () => anlageDialog(behaelter, b.id));
+
+ behaelter.querySelectorAll("[data-anl-weg]").forEach((el) =>
+ el.addEventListener("click", async () => {
+ try {
+ await aufruf("bericht", "anlageEntfernen", { id: b.id, index: +el.dataset.anlWeg });
+ oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }));
+
+ const unterschreiben = $("br-unterschreiben");
+ if (unterschreiben) unterschreiben.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Bericht unterschreiben",
+ inhalt: `
+
+
✍
+
Du bestätigst, dass der Inhalt von ${sicher(b.nummer)} aus deiner
+ Sicht zutrifft. Die Unterschrift ist verbindlich und kann nicht
+ zurückgenommen werden.
+
+
`,
+ bestaetigenText: "Unterschreiben",
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("bericht", "unterschreiben", { id: b.id });
+ zu(); meldung("Unterschrieben.", "gut"); oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const einreichen = $("br-einreichen");
+ if (einreichen) einreichen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Bericht einreichen",
+ inhalt: `
+ ${sicher(b.nummer)} geht zur Prüfung an die Führung. Bis zur Freigabe
+ kannst du ihn weiterhin ändern — jede Änderung nach dem Unterschreiben wird
+ allerdings vermerkt.
`,
+ bestaetigenText: "Einreichen",
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("bericht", "einreichen", { id: b.id });
+ zu(); meldung("Eingereicht.", "gut"); oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const freigeben = $("br-freigeben");
+ if (freigeben) freigeben.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Bericht freigeben",
+ inhalt: `
+
+
Mit der Freigabe geschieht Folgendes:
+
+
🔒
+
Der Bericht wird inhaltlich gesperrt — auch der Verfasser kann
+ ihn danach nicht mehr ändern.
+
+
+
✍
+
Du unterschreibst den Bericht damit zugleich als freigebender Beamter.
+
+
+ Vermerk
+
`,
+ bestaetigenText: "Freigeben",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("bericht", "freigeben", {
+ id: b.id, vermerk: el.querySelector("#bf-vermerk").value.trim(),
+ });
+ zu(); meldung("Freigegeben.", "gut"); oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ const zurueckweisen = $("br-zurueckweisen");
+ if (zurueckweisen) zurueckweisen.addEventListener("click", () => {
+ fenster({
+ titel: "Zur Nachbesserung zurückgeben",
+ inhalt: `Was soll nachgebessert werden?
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Zurückweisen", art: "gefahr", klick: async (el, zu) => {
+ try {
+ await aufruf("bericht", "zurueckweisen", {
+ id: b.id, grund: el.querySelector("#bz-grund").value.trim(),
+ });
+ zu(); meldung("Zurückgewiesen."); oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+ }
+
+ /* ---- Dialoge ------------------------------------------------------ */
+
+ function anlegenDialog(behaelter) {
+ aufruf("bericht", "bezuege", {}).then((bez) => {
+ fenster({
+ titel: "Bericht anlegen",
+ inhalt: `
+ Titel
+
+ Bezug zu einem Einsatz
+
+ — ohne Einsatzbezug —
+ ${(bez.einsaetze || []).map((e) =>
+ `${sicher(e.nummer)} · ${sicher(e.titel)} `).join("")}
+
+ Sachverhalt
+
+ Getroffene Maßnahmen
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Anlegen", klick: async (el, zu) => {
+ try {
+ const ergebnis = await aufruf("bericht", "erstellen", {
+ titel: el.querySelector("#bn-titel").value.trim(),
+ einsatz: +el.querySelector("#bn-einsatz").value || null,
+ beschreibung: el.querySelector("#bn-text").value,
+ massnahmen: el.querySelector("#bn-mass").value,
+ });
+ zu(); meldung("Bericht " + ergebnis.nummer + " angelegt.", "gut");
+ oeffne(behaelter, ergebnis.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ }).catch((f) => meldung(f.message, "fehler"));
+ }
+
+ function beteiligtDialog(behaelter, berichtId) {
+ fenster({
+ titel: "Beteiligten hinzufügen",
+ inhalt: `
+ Art
+
+ Person (Zivilist)
+ Beamter
+
+
+
Suchen
+
+
+ Suchen
+
+
Oder unten einen Namen frei eintragen.
+
+
+ Name (frei)
+
+ Beteiligung
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Hinzufügen", klick: async (el, zu) => {
+ try {
+ await aufruf("bericht", "beteiligtHinzu", {
+ id: berichtId,
+ typ: el.querySelector("#bb-typ").value,
+ name: el.querySelector("#bb-name").value.trim(),
+ rolle: el.querySelector("#bb-rolle").value.trim(),
+ charakterId: el.dataset.gewaehlt ? +el.dataset.gewaehlt : null,
+ });
+ zu(); oeffne(behaelter, berichtId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ nachOeffnen: (el) => {
+ const suchen = async () => {
+ const begriff = el.querySelector("#bb-suche").value.trim();
+ if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
+ try {
+ const d = await aufruf("mdt", "personSuchen", { begriff });
+ const ziel = el.querySelector("#bb-treffer");
+ ziel.innerHTML = (d.treffer || []).length
+ ? `${d.treffer.slice(0, 6).map((p) =>
+ `
+
${sicher(p.firstname + " " + p.lastname)}
+
geb. ${sicher(String(p.dob || "").slice(0, 10))}
+
`).join("")}
`
+ : `Keine Treffer.
`;
+
+ ziel.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", () => {
+ el.dataset.gewaehlt = zeile.dataset.wahl;
+ el.querySelector("#bb-name").value = zeile.dataset.name;
+ ziel.innerHTML = `Ausgewählt: ${sicher(zeile.dataset.name)}
`;
+ }));
+ } catch (f) { meldung(f.message, "fehler"); }
+ };
+ el.querySelector("#bb-suchen").addEventListener("click", suchen);
+ el.querySelector("#bb-suche").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); suchen(); }
+ });
+ },
+ });
+ }
+
+ function anlageDialog(behaelter, berichtId) {
+ fenster({
+ titel: "Anlage hinzufügen",
+ inhalt: `
+ Art
+
+ Datei / Bild
+ Vorhandenes Beweismittel
+
+
+
+
+
+
+
Beweismittel suchen
+
+
+ Suchen
+
+
+
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Hinzufügen", klick: async (el, zu) => {
+ const typ = el.querySelector("#ba-typ").value;
+ try {
+ await aufruf("bericht", "anlageHinzu", {
+ id: berichtId, typ,
+ titel: (el.querySelector("#ba-titel") || {}).value || "",
+ adresse: (el.querySelector("#ba-adresse") || {}).value || "",
+ beweisId: el.dataset.beweis ? +el.dataset.beweis : null,
+ });
+ zu(); oeffne(behaelter, berichtId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ nachOeffnen: (el) => {
+ el.querySelector("#ba-typ").addEventListener("change", (e) => {
+ const istBeweis = e.target.value === "beweis";
+ el.querySelector("#ba-datei").hidden = istBeweis;
+ el.querySelector("#ba-beweis").hidden = !istBeweis;
+ });
+
+ const suchen = async () => {
+ const begriff = el.querySelector("#ba-suche").value.trim();
+ if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
+ try {
+ const d = await aufruf("bericht", "beweisSuche", { begriff });
+ const ziel = el.querySelector("#ba-treffer");
+ ziel.innerHTML = (d.beweise || []).length
+ ? `${d.beweise.map((b) =>
+ `
+
${sicher(b.kennung)}
+
${sicher(b.bezeichnung)}
+
${sicher(b.fundort || "")}
+
`).join("")}
`
+ : `Keine Treffer.
`;
+
+ ziel.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", () => {
+ el.dataset.beweis = zeile.dataset.wahl;
+ ziel.innerHTML = `Ausgewählt.
`;
+ }));
+ } catch (f) { meldung(f.message, "fehler"); }
+ };
+ el.querySelector("#ba-suchen").addEventListener("click", suchen);
+ },
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function oeffne(behaelter, berichtId) {
+ try {
+ const d = await aufruf("bericht", "oeffnen", { id: berichtId });
+ z.offen = berichtId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("bericht", "liste", {
+ status: z.filterStatus, nurEigene: z.nurEigene, begriff: z.suche,
+ });
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Berichte
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("bericht", {
+ rendern,
+ aktualisieren: () => {
+ if (App.zustand.aktivesModul === "bericht") rendern($("inhalt"));
+ },
+ });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("bericht", "liste", { begriff });
+ return (d.berichte || []).slice(0, 5).map((b) => ({
+ bereich: "Bericht", titel: b.titel,
+ unter: b.nummer + " · " + (statusText[b.status] || b.status),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/beweis.js b/resources/[aether]/aether-police/html/js/module/beweis.js
new file mode 100644
index 0000000..99d622d
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/beweis.js
@@ -0,0 +1,368 @@
+/* =====================================================================
+ aether-police / Ansicht: Beweisverwaltung
+
+ Es gibt keine automatische Spurenerkennung — jeder Beweis wird hier
+ von einem Beamten erfasst und beschrieben.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App;
+
+ const z = {
+ daten: null,
+ offen: null, // geöffneter Beweis
+ filterArt: "",
+ suche: "",
+ };
+
+ const artSymbol = {
+ foto: "📷", video: "🎬", fingerabdruck: "🖐", dna: "🧬",
+ huelse: "🔩", dokument: "📄", aussage: "🗣", gegenstand: "📦",
+ };
+
+ function artName(id) {
+ const treffer = (z.daten.arten || []).find((a) => a.id === id);
+ return treffer ? treffer.name : id;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Beweismittel
+
Jeder Beweis wird von einem Beamten erfasst — es gibt keine automatische Spurenerkennung.
+
+ ${d.rechte.erfassen ? `
+ Beweis erfassen ` : ""}
+
+
+
+
+
+ Alle Arten
+ ${(d.arten || []).map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+
+
+
+ ${d.beweise.length ? `
${d.beweise.map((b) => `
+
+
${artSymbol[b.art] || "•"}
+
+
${sicher(b.bezeichnung)}
+
+ ${sicher(b.kennung)} · ${sicher(artName(b.art))}
+ ${b.fundort ? " · Fundort: " + sicher(b.fundort) : ""}
+ ${b.aktenzeichen ? " · " + sicher(b.aktenzeichen) : ""}
+
+
+ erfasst von ${sicher(b.dienstnummer || "")}
+ ${sicher((b.firstname || "") + " " + (b.lastname || ""))} · ${zeit(b.erfasst_am)}
+
+
+ ${b.datei ? '
Anlage ' : ""}
+
`).join("")}
`
+ : `
Keine Beweismittel gefunden.
`}
+
`;
+
+ const neu = $("bw-neu");
+ if (neu) neu.addEventListener("click", () => erfassenDialog(behaelter));
+
+ const suchfeld = $("bw-suche");
+ suchfeld.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); }
+ });
+ $("bw-art").addEventListener("change", (e) => {
+ z.filterArt = e.target.value; rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-beweis]").forEach((el) =>
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.beweis)));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------ */
+
+ function einzelAnsicht(behaelter, d) {
+ const b = d.beweis;
+
+ behaelter.innerHTML = `
+
+
+
${artSymbol[b.art] || "•"} ${sicher(b.bezeichnung)}
+
${sicher(b.kennung)} · ${sicher(artName(b.art))}
+
+
+ ‹ Zurück
+ ✎ Ändern
+ ${d.rechte.loeschen ? `Entfernen ` : ""}
+
+
+
+
+
+
Beweismittel
+
+ Kennung ${sicher(b.kennung)}
+ Art ${sicher(artName(b.art))}
+ Fundort ${sicher(b.fundort || "—")}
+ Aufgefunden ${zeit(b.gefunden_am)}
+ Erfasst von ${sicher(b.dienstnummer || "")}
+ ${sicher((b.firstname || "") + " " + (b.lastname || ""))}
+ Erfasst am ${zeit(b.erfasst_am)}
+
+ ${b.beschreibung
+ ? `
${sicher(b.beschreibung).replace(/\n/g, " ")}
`
+ : ""}
+ ${b.datei ? `
` : ""}
+
+
+
+
+ Verknüpfte Akten
+ + Akte
+
+ ${d.akten.length ? `
${d.akten.map((a) => `
+
+
${sicher(a.typ)}
+
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)}
+
+
${sicher(a.status)}
+
`).join("")}
`
+ : `
Noch keiner Akte zugeordnet.
`}
+
+
+
+
Zugehörige Asservate
+ ${d.asservate.length ? `
${d.asservate.map((a) => `
+
+
${sicher(a.kennung)}
+
+
${sicher(a.bezeichnung)}
+
${sicher(a.lagerort)}
+
+
${sicher(a.status)}
+
`).join("")}
`
+ : `
Keine Asservate hinterlegt.
`}
+
+
+
+
Vorgangshistorie
+ ${d.historie.length ? `
${d.historie.map((h) => `
+
+
+
${sicher(h.aktion)}
+
${sicher(h.dienstnummer || "")}
+ ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)}
+ ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
`
+ : `
Keine Einträge.
`}
+
+
`;
+
+ $("bw-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); });
+
+ $("bw-bearbeiten").addEventListener("click", () => {
+ fenster({
+ titel: "Beweis ändern",
+ inhalt: `
+ Bezeichnung
+
+ Fundort
+
+ Beschreibung
+
+ Anlage (Adresse)
+
+ Grund der Änderung (erforderlich)
+
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("beweis", "bearbeiten", {
+ id: b.id,
+ bezeichnung: el.querySelector("#be-bez").value.trim(),
+ fundort: el.querySelector("#be-ort").value.trim(),
+ beschreibung: el.querySelector("#be-text").value,
+ datei: el.querySelector("#be-datei").value.trim(),
+ grund: el.querySelector("#be-grund").value.trim(),
+ });
+ zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, b.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const entfernen = $("bw-entfernen");
+ if (entfernen) entfernen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Beweismittel entfernen",
+ gefahr: true,
+ inhalt: `
+
+
+
⚠
+
Der Beweis ${sicher(b.kennung)} wird aus dem Bestand entfernt.
+ Der Vorgang bleibt im Protokoll sichtbar und alle Beamten werden benachrichtigt.
+
+
+
+ Begründung (ausführlich, erforderlich)
+
+
`,
+ bestaetigenText: "Entfernen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("beweis", "entfernen", {
+ id: b.id, grund: el.querySelector("#bl-grund").value.trim(),
+ });
+ zu(); meldung("Beweis entfernt."); z.offen = null; rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ $("bw-akte").addEventListener("click", () => akteVerknuepfenDialog(behaelter, b.id));
+ }
+
+ /* ---- Dialoge ------------------------------------------------------ */
+
+ function erfassenDialog(behaelter) {
+ const arten = (z.daten.arten || []);
+
+ fenster({
+ titel: "Beweis erfassen",
+ inhalt: `
+
+
Art
+
+ ${arten.map((a) => `${sicher(a.name)} `).join("")}
+
+
Fundort
+
+
+ Bezeichnung
+
+ Beschreibung
+
+ Anlage (Bild-/Videoadresse)
+
+
+
ℹ
+
Der Fundort ist Pflicht — er gehört zur Beweiskette. Das System
+ erkennt keine Spuren; alles hier ist deine Dokumentation.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Erfassen", klick: async (el, zu) => {
+ try {
+ const ergebnis = await aufruf("beweis", "erfassen", {
+ art: el.querySelector("#bn-art").value,
+ bezeichnung: el.querySelector("#bn-bez").value.trim(),
+ beschreibung: el.querySelector("#bn-text").value,
+ fundort: el.querySelector("#bn-ort").value.trim(),
+ datei: el.querySelector("#bn-datei").value.trim(),
+ });
+ zu(); meldung("Beweis " + ergebnis.kennung + " erfasst.", "gut");
+ oeffne(behaelter, ergebnis.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ function akteVerknuepfenDialog(behaelter, beweisId) {
+ fenster({
+ titel: "Mit Akte verknüpfen",
+ inhalt: `
+
+
Akte suchen
+
+
+ Suchen
+
+
+
`,
+ nachOeffnen: (el) => {
+ const suchen = async () => {
+ const begriff = el.querySelector("#bv-suche").value.trim();
+ if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
+ try {
+ const d = await aufruf("beweis", "aktenSuche", { begriff });
+ const ziel = el.querySelector("#bv-treffer");
+ ziel.innerHTML = (d.akten || []).length
+ ? `${d.akten.map((a) =>
+ `
+
${sicher(a.typ)}
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)}
+
`).join("")}
`
+ : `Keine Treffer.
`;
+
+ ziel.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", async () => {
+ try {
+ await aufruf("beweis", "akteVerknuepfen", {
+ beweisId, akteId: +zeile.dataset.wahl,
+ });
+ el.remove(); meldung("Verknüpft.", "gut"); oeffne(behaelter, beweisId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }));
+ } catch (f) { meldung(f.message, "fehler"); }
+ };
+ el.querySelector("#bv-suchen").addEventListener("click", suchen);
+ el.querySelector("#bv-suche").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); suchen(); }
+ });
+ },
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function oeffne(behaelter, beweisId) {
+ try {
+ const d = await aufruf("beweis", "oeffnen", { id: beweisId });
+ z.offen = beweisId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("beweis", "liste", { art: z.filterArt, begriff: z.suche });
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Beweise
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("beweis", { rendern });
+
+ /* Globale Suche bedienen */
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("beweis", "liste", { begriff });
+ return (d.beweise || []).slice(0, 5).map((b) => ({
+ bereich: "Beweis", titel: b.bezeichnung,
+ unter: b.kennung + " · " + (b.fundort || ""),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/dashboard.js b/resources/[aether]/aether-police/html/js/module/dashboard.js
new file mode 100644
index 0000000..085fe00
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/dashboard.js
@@ -0,0 +1,246 @@
+/* =====================================================================
+ aether-police / Ansicht: Dashboard
+
+ Zeigt die Lage der Dienststelle. Sämtliche Zahlen kommen bei jedem
+ Aufruf frisch vom Server — nichts ist fest hinterlegt.
+ Die Ansicht bewertet nichts und löst keine Maßnahme aus.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, dauer, aufruf, meldung, anClient } = App;
+
+ /** Zwischenspeicher der zuletzt geladenen Daten (für Auffrischungen) */
+ let letzteDaten = null;
+
+ /* ---- Bausteine --------------------------------------------------- */
+
+ function kennzahl(titel, wert, zusatz, art) {
+ return `
+
${sicher(titel)}
+
${sicher(wert)}
+ ${zusatz ? `
${sicher(zusatz)}
` : ""}
+
`;
+ }
+
+ /** Übersetzt eine Prioritätszahl in eine Streifenklasse */
+ const prioKlasse = (p) => "p" + Math.min(4, Math.max(1, p || 3));
+
+ function warnungen(liste) {
+ if (!liste || !liste.length) return "";
+ const symbol = { hoch: "⚠", normal: "ℹ", niedrig: "•" };
+ return liste.map((w) =>
+ `
+ ${symbol[w.prioritaet] || "•"}
+ ${sicher(w.text)}
+
`
+ ).join("");
+ }
+
+ function beamtenListe(beamte) {
+ if (!beamte.length) return `Niemand im Dienst.
`;
+
+ const statusFarbe = {
+ verfuegbar: "gut", einsatz: "warn", pause: "neutral", nicht: "kritisch",
+ };
+
+ return `${beamte.map((b) =>
+ `
+
${sicher(b.dienstnummer)}
+
+
${sicher(b.name)}${b.leitstelle ? ' Leitstelle ' : ""}
+
${sicher(b.rolleName)} · ${sicher(b.abteilung)}
+
+
${dauer((Date.now() / 1000) - b.seit)}
+
`).join("")}
`;
+ }
+
+ function einsatzListe(einsaetze) {
+ if (!einsaetze.length) return `Keine offenen Einsätze.
`;
+
+ return `${einsaetze.map((e) =>
+ `
+
+
+
${sicher(e.titel)}
+
${sicher(e.nummer)} · ${sicher(e.ort || "Ort unbekannt")} · ${zeit(e.erstellt_am)}
+
+
${sicher(e.status)}
+ ${e.pos_x ? `
Route ` : ""}
+
`).join("")}
`;
+ }
+
+ function gewahrsamListe(eintraege) {
+ if (!eintraege.length) return `Niemand in Gewahrsam.
`;
+
+ return `${eintraege.map((g) => {
+ const rest = Number(g.rest) || 0;
+ const abgelaufen = rest <= 0;
+ return `
+
+
+
${sicher(g.firstname + " " + g.lastname)}
+
${sicher(g.grund || "—")}${g.zelle ? " · Zelle " + sicher(g.zelle) : ""}
+
+
+ ${abgelaufen ? "Frist abgelaufen" : rest + " Min"}
+
+
`;
+ }).join("")}
`;
+ }
+
+ function fahndungsListe(fahndungen) {
+ if (!fahndungen.length) return `Keine aktiven Fahndungen.
`;
+
+ const gefahrKlasse = { gefaehrlich: "kritisch", bewaffnet: "warn", normal: "neutral" };
+
+ return `${fahndungen.map((f) =>
+ `
+
+
+
${sicher(f.bezeichnung)}
+
${sicher(f.grund)}
+
+
${sicher(f.gefahr)}
+
`).join("")}
`;
+ }
+
+ function mitteilungsListe(mitteilungen) {
+ if (!mitteilungen.length) return `Keine Mitteilungen.
`;
+
+ return `${mitteilungen.map((m) =>
+ `
+
+
${sicher(m.betreff || "Mitteilung")}
+
${sicher((m.firstname || "") + " " + (m.lastname || ""))} · ${zeit(m.gesendet)}
+
+
`).join("")}
`;
+ }
+
+ /* ---- Aufbau ------------------------------------------------------ */
+
+ function zeichne(behaelter, d) {
+ const k = d.kennzahlen;
+
+ behaelter.innerHTML = `
+
+
+
Übersicht
+
Lage der Dienststelle — Stand ${new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}
+
+
↻ Aktualisieren
+
+
+ ${warnungen(d.warnungen)}
+
+
+ ${kennzahl("Im Dienst", k.imDienst, `${k.verfuegbar} verfügbar`, k.imDienst > 0 ? "gut" : "")}
+ ${kennzahl("Offene Einsätze", k.einsaetzeOffen, null, k.einsaetzeOffen > 0 ? "warn" : "")}
+ ${kennzahl("Anzeigen (Entwurf)", k.anzeigenOffen, "warten auf Bestätigung")}
+ ${kennzahl("Fahndungen", k.fahndungen)}
+ ${kennzahl("In Gewahrsam", k.gefangene,
+ k.haftAbgelaufen > 0 ? `${k.haftAbgelaufen} Frist abgelaufen` : null,
+ k.haftAbgelaufen > 0 ? "hoch" : "")}
+ ${kennzahl("Dienstfahrzeuge", `${k.fahrzeugeEinsatz}/${k.fahrzeugeGesamt}`, "im Einsatz")}
+
+
+
+
+
+ Offene Einsätze
+ ${d.einsaetze.length}
+
+ ${einsatzListe(d.einsaetze)}
+
+
+
+
+ Beamte im Dienst
+ ${d.beamte.length}
+
+ ${beamtenListe(d.beamte)}
+
+
+
+
+ Gewahrsam
+ ${d.gewahrsam.length}
+
+ ${gewahrsamListe(d.gewahrsam)}
+
+
ℹ
+
Fristen dienen nur der Erinnerung . Eine Entlassung erfolgt
+ ausschließlich manuell durch einen Beamten.
+
+
+
+
+
+ Aktive Fahndungen
+ ${d.fahndungen.length}
+
+ ${fahndungsListe(d.fahndungen)}
+
+
+
+
+ Meine offenen Berichte
+ ${d.berichte.length}
+
+ ${d.berichte.length
+ ? `
${d.berichte.map((b) =>
+ `
+
+
${sicher(b.titel)}
+
${sicher(b.nummer)} · ${zeit(b.erstellt_am)}
+
+
Entwurf
+
`).join("")}
`
+ : `
Keine offenen Berichte.
`}
+
+
+
+
Neueste Mitteilungen
+ ${mitteilungsListe(d.mitteilungen)}
+
+
+ `;
+
+ // Aktualisieren
+ App.$("db-aktualisieren").addEventListener("click", () => laden(behaelter));
+
+ // Route zu einem Einsatzort setzen
+ behaelter.querySelectorAll("[data-weg]").forEach((b) => {
+ b.addEventListener("click", () => {
+ const [x, y] = b.dataset.weg.split(",").map(Number);
+ anClient("wegpunkt", { x, y });
+ });
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------- */
+
+ async function laden(behaelter) {
+ try {
+ const daten = await aufruf("dashboard", "laden", {});
+ letzteDaten = daten;
+ App.zustand.hinweise = daten.hinweise || [];
+ zeichne(behaelter, daten);
+ } catch (fehler) {
+ behaelter.innerHTML =
+ `Übersicht
+
Die Daten konnten nicht geladen werden: ${sicher(fehler.message)}
`;
+ }
+ }
+
+ /* ---- Registrierung ----------------------------------------------- */
+
+ App.registriereAnsicht("dashboard", {
+ rendern: (behaelter) => laden(behaelter),
+ // Wird aufgerufen, wenn sich im Hintergrund etwas geändert hat
+ aktualisieren: () => {
+ const behaelter = App.$("inhalt");
+ if (App.zustand.aktivesModul === "dashboard" && behaelter) laden(behaelter);
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/einsatz.js b/resources/[aether]/aether-police/html/js/module/einsatz.js
new file mode 100644
index 0000000..7236834
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/einsatz.js
@@ -0,0 +1,483 @@
+/* =====================================================================
+ aether-police / Ansicht: Einsätze & Dispatch
+
+ Zwei Spalten: links das Einsatzaufkommen, rechts die verfügbaren
+ Einheiten. Zugewiesen wird nur auf ausdrückliche Anweisung der
+ Leitstelle — und auch dann entscheidet die Einheit selbst, ob sie
+ beitritt.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App;
+
+ const z = {
+ daten: null,
+ offenerEinsatz: null,
+ nurOffene: true,
+ };
+
+ /* ---- Bausteine --------------------------------------------------- */
+
+ const statusText = {
+ offen: "Offen", zugewiesen: "Zugewiesen",
+ vorort: "Vor Ort", abgeschlossen: "Abgeschlossen",
+ };
+ const statusArt = {
+ offen: "kritisch", zugewiesen: "warn", vorort: "info", abgeschlossen: "neutral",
+ };
+ const quelleText = {
+ manuell: "manuell erfasst", notruf: "Notruf", panik: "Panikknopf", extern: "extern",
+ };
+
+ /** Farbe einer Kategorie aus der Serverkonfiguration */
+ function kategorieFarbe(id) {
+ const treffer = (z.daten.kategorien || []).find((k) => k.id === id);
+ return treffer ? treffer.farbe : "#8a93a5";
+ }
+ function kategorieName(id) {
+ const treffer = (z.daten.kategorien || []).find((k) => k.id === id);
+ return treffer ? treffer.name : id;
+ }
+
+ function einsatzKarte(e) {
+ return `
+
+
+
+
+ ${sicher(e.titel)}
+ ${e.ichDabei > 0 ? 'Du bist dabei ' : ""}
+
+
+
+ ${sicher(kategorieName(e.kategorie))}
+
+ ${sicher(e.nummer)} · ${sicher(e.ort || "Ort unbekannt")} · ${zeit(e.erstellt_am)}
+ ${e.quelle !== "manuell" ? " · " + sicher(quelleText[e.quelle] || e.quelle) : ""}
+
+
+
+ ${sicher(statusText[e.status] || e.status)}
+ ${e.einheiten || 0} 🚓
+
+
`;
+ }
+
+ function einheitenListe(beamte) {
+ if (!beamte.length) return `Niemand im Dienst.
`;
+
+ const farbe = { verfuegbar: "gut", einsatz: "warn", pause: "neutral", nicht: "kritisch" };
+ const text = { verfuegbar: "Verfügbar", einsatz: "Im Einsatz", pause: "Pause", nicht: "Nicht verfügbar" };
+
+ return `${beamte.map((b) =>
+ `
+
${sicher(b.dienstnummer)}
+
+
${sicher(b.name)}
+ ${b.leitstelle ? 'Leitstelle ' : ""}
+
${sicher(text[b.status] || b.status)} · ${sicher(b.abteilung)}
+
+
`).join("")}
`;
+ }
+
+ /* ---- Übersicht --------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+ const offene = d.einsaetze.filter((e) => e.status === "offen");
+ const laufende = d.einsaetze.filter((e) => e.status !== "offen" && e.status !== "abgeschlossen");
+ const erledigt = d.einsaetze.filter((e) => e.status === "abgeschlossen");
+
+ behaelter.innerHTML = `
+
+
+
Einsätze & Dispatch
+
${d.istLeitstelle
+ ? "Du führst die Leitstelle — du entscheidest über die Zuweisung."
+ : "Einsätze werden nicht automatisch verteilt. Tritt bei, wenn du übernimmst."}
+
+
+
+ ${z.nurOffene ? "Auch abgeschlossene" : "Nur laufende"}
+
+ + Einsatz anlegen
+
+
+
+
+
+
+
+ Offen — keine Einheit zugeordnet
+ ${offene.length}
+
+ ${offene.length ? offene.map(einsatzKarte).join("")
+ : `
Keine offenen Einsätze.
`}
+
+
+
+
+ In Bearbeitung
+ ${laufende.length}
+
+ ${laufende.length ? laufende.map(einsatzKarte).join("")
+ : `
Keine laufenden Einsätze.
`}
+
+
+ ${!z.nurOffene ? `
+
+
+ Abgeschlossen
+ ${erledigt.length}
+
+ ${erledigt.length ? erledigt.map(einsatzKarte).join("")
+ : `
Nichts abgeschlossen.
`}
+
` : ""}
+
+
+
+
+
+ Einheiten im Dienst
+ ${d.beamteImDienst.length}
+
+ ${einheitenListe(d.beamteImDienst)}
+
+
ℹ
+
Eine Zuweisung ist eine Aufforderung , kein Befehl an das System.
+ Die Einheit entscheidet selbst, ob sie den Einsatz übernimmt.
+
+
+
+
`;
+
+ $("e-neu").addEventListener("click", () => einsatzAnlegen(behaelter));
+ $("e-filter").addEventListener("click", () => {
+ z.nurOffene = !z.nurOffene;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-einsatz]").forEach((el) =>
+ el.addEventListener("click", () => oeffneEinsatz(behaelter, +el.dataset.einsatz)));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------ */
+
+ function einsatzAnsicht(behaelter, d) {
+ const e = d.einsatz;
+ const aktiv = e.status !== "abgeschlossen";
+ const beteiligt = (e.beamte || []).filter((b) => !b.verlassen);
+
+ behaelter.innerHTML = `
+
+
+
${sicher(e.titel)}
+
${sicher(e.nummer)} · ${sicher(kategorieName(e.kategorie))} ·
+ ${sicher(quelleText[e.quelle] || e.quelle)} · ${zeit(e.erstellt_am)}
+
+
‹ Zurück
+
+
+
+
+
+
+ Lage
+ ${sicher(statusText[e.status] || e.status)}
+
+
+ Priorität ${sicher(e.prioritaet)} ${e.prioritaet === 1 ? "(höchste)" : ""}
+ Ort ${sicher(e.ort || "unbekannt")}
+ Gemeldet von ${e.erstellt_von
+ ? sicher((e.ersteller_vorname || "") + " " + (e.ersteller_nachname || ""))
+ : sicher(quelleText[e.quelle] || "extern")}
+
+ ${e.beschreibung ? `
${sicher(e.beschreibung).replace(/\n/g, " ")}
` : ""}
+
+
+ ${e.pos_x ? `📍 Route setzen ` : ""}
+ ${aktiv && !e.ichDabei ? `Einsatz übernehmen ` : ""}
+ ${aktiv && e.ichDabei ? `Einsatz verlassen ` : ""}
+ ${aktiv && e.ichDabei ? `Status: Vor Ort ` : ""}
+ ${aktiv && d.rechte.schliessen ? `Abschließen ` : ""}
+
+
+
+
+
+ Einsatzverlauf
+ ${aktiv ? `+ Notiz ` : ""}
+
+ ${(e.notizen || []).length ? `
${e.notizen.map((n) =>
+ `
+
${zeit(n.am).slice(-5)}
+
+
${sicher(n.text)}
+
${sicher(n.dienstnummer || "")}
+ ${sicher((n.firstname || "") + " " + (n.lastname || ""))}
+
+
`).join("")}
`
+ : `
Noch keine Einträge.
`}
+
+
+
+
+
+
+ Beteiligte Einheiten
+ ${beteiligt.length}
+
+ ${beteiligt.length ? `
${beteiligt.map((b) =>
+ `
+
${sicher(b.dienstnummer || "—")}
+
+
${sicher((b.firstname || "") + " " + (b.lastname || ""))}
+
seit ${zeit(b.beigetreten)}
+
+
`).join("")}
`
+ : `
Keine Einheit vor Ort.
`}
+
+
+ ${aktiv && d.rechte.zuweisen ? `
+
+
Einheit anfordern
+
${d.beamteImDienst.map((b) =>
+ `
+
${sicher(b.dienstnummer)}
+
+
${sicher(b.name)}
+
${sicher(b.status)}
+
+
Anfordern
+
`).join("")}
+
+
ℹ
+
Die Einheit erhält eine Anfrage mit Wegpunkt. Ob sie übernimmt,
+ entscheidet sie selbst.
+
+
` : ""}
+
+
`;
+
+ /* ---- Aktionen ---- */
+ $("e-zurueck").addEventListener("click", () => { z.offenerEinsatz = null; rendern(behaelter); });
+
+ const route = $("e-route");
+ if (route) route.addEventListener("click", () =>
+ anClient("wegpunkt", { x: e.pos_x, y: e.pos_y }));
+
+ const beitreten = $("e-beitreten");
+ if (beitreten) beitreten.addEventListener("click", async () => {
+ try {
+ await aufruf("einsatz", "beitreten", { id: e.id });
+ meldung("Du hast den Einsatz übernommen.", "gut");
+ oeffneEinsatz(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const verlassen = $("e-verlassen");
+ if (verlassen) verlassen.addEventListener("click", async () => {
+ try {
+ await aufruf("einsatz", "verlassen", { id: e.id });
+ meldung("Du hast den Einsatz verlassen.");
+ oeffneEinsatz(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const vorort = $("e-vorort");
+ if (vorort) vorort.addEventListener("click", async () => {
+ try {
+ await aufruf("einsatz", "status", { id: e.id, status: "vorort" });
+ oeffneEinsatz(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+
+ const notiz = $("e-notiz");
+ if (notiz) notiz.addEventListener("click", () => {
+ fenster({
+ titel: "Notiz zum Einsatz",
+ inhalt: `Eintrag
+
+
Der Verlauf hilft später beim Bericht.
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Eintragen", klick: async (el, zu) => {
+ const text = el.querySelector("#en-text").value.trim();
+ if (text.length < 2) { meldung("Bitte einen Text eingeben.", "fehler"); return; }
+ try {
+ await aufruf("einsatz", "notiz", { id: e.id, text });
+ zu(); oeffneEinsatz(behaelter, e.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ behaelter.querySelectorAll("[data-zuweisen]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ try {
+ await aufruf("einsatz", "zuweisen", { id: e.id, quelle: +b.dataset.zuweisen });
+ meldung("Anfrage an die Einheit gesendet.", "gut");
+ } catch (f) { meldung(f.message, "fehler"); }
+ }));
+
+ const schliessen = $("e-schliessen");
+ if (schliessen) schliessen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Einsatz abschließen",
+ inhalt: `
+
+ ${sicher(e.nummer)} — ${sicher(e.titel)}
+ Alle Beteiligungen werden beendet. Es wird festgehalten, dass du den Einsatz
+ abgeschlossen hast.
+
+ Abschlussvermerk
+
+ ${d.rechte.bericht ? `
+
+
+ Berichtsentwurf vorbereiten — Titel, Beteiligte und der Einsatzverlauf
+ werden übernommen. Der Bericht bleibt ein Entwurf , bis du ihn ausarbeitest.
+ ` : ""}`,
+ bestaetigenText: "Einsatz abschließen",
+ bestaetigt: async (el, zu) => {
+ const kasten = el.querySelector("#es-bericht");
+ try {
+ const ergebnis = await aufruf("einsatz", "abschliessen", {
+ id: e.id,
+ grund: el.querySelector("#es-grund").value.trim(),
+ berichtAnlegen: kasten ? kasten.checked : false,
+ });
+ zu();
+ meldung(ergebnis.berichtNummer
+ ? "Einsatz abgeschlossen · Entwurf " + ergebnis.berichtNummer + " angelegt."
+ : "Einsatz abgeschlossen.", "gut");
+ z.offenerEinsatz = null;
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+ }
+
+ async function oeffneEinsatz(behaelter, einsatzId) {
+ try {
+ const d = await aufruf("einsatz", "oeffnen", { id: einsatzId });
+ z.offenerEinsatz = einsatzId;
+ // Kategorien für die Anzeige mitführen
+ if (!z.daten) z.daten = { kategorien: [] };
+ d.kategorien = z.daten.kategorien;
+ einsatzAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* ---- Anlegen ------------------------------------------------------ */
+
+ function einsatzAnlegen(behaelter) {
+ const kategorien = (z.daten && z.daten.kategorien) || [];
+
+ fenster({
+ titel: "Einsatz anlegen",
+ inhalt: `
+ Titel
+
+ Kategorie
+
+ ${kategorien.map((k) => `${sicher(k.name)} `).join("")}
+
+ Priorität
+
+ 1 — höchste (sofort)
+ 2 — hoch
+ 3 — normal
+ 4 — niedrig
+
+ Ort
+
+ Beschreibung
+
+
+
+ Meine aktuelle Position als Einsatzort übernehmen
+ `,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Einsatz anlegen", klick: async (el, zu) => {
+ try {
+ const ergebnis = await aufruf("einsatz", "erstellen", {
+ titel: el.querySelector("#ne-titel").value.trim(),
+ kategorie: el.querySelector("#ne-kategorie").value,
+ prioritaet: +el.querySelector("#ne-prio").value,
+ ort: el.querySelector("#ne-ort").value.trim(),
+ beschreibung: el.querySelector("#ne-text").value,
+ eigenePosition: el.querySelector("#ne-hier").checked,
+ });
+ zu();
+ meldung("Einsatz " + ergebnis.nummer + " angelegt.", "gut");
+ oeffneEinsatz(behaelter, ergebnis.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("einsatz", "liste", { nurOffene: z.nurOffene });
+
+ // Markierungen im Spiel setzen
+ anClient("einsatzBlips", { einsaetze: z.daten.einsaetze });
+
+ if (z.offenerEinsatz) await oeffneEinsatz(behaelter, z.offenerEinsatz);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML =
+ `Einsätze
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ /* ---- Nachrichten vom Client -------------------------------------- */
+
+ window.addEventListener("message", (ereignis) => {
+ const n = ereignis.data || {};
+
+ // Eine Einheit wurde angefordert — der Beamte entscheidet selbst
+ if (n.aktion === "einsatzZuweisung") {
+ const d = n.daten || {};
+ bestaetigen({
+ titel: "Einsatz übernehmen?",
+ inhalt: `
+ ${sicher(d.von)} bittet dich um Übernahme:
+ ${sicher(d.nummer)} — ${sicher(d.titel)}
+ ${d.ort ? "Ort: " + sicher(d.ort) : "Ort unbekannt"}
+ Ein Wegpunkt wurde bereits gesetzt.
`,
+ bestaetigenText: "Übernehmen",
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("einsatz", "beitreten", { id: d.id });
+ zu(); meldung("Einsatz übernommen.", "gut");
+ if (App.zustand.aktivesModul === "einsatz") rendern($("inhalt"));
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ // Im Hintergrund hat sich etwas geändert
+ if (n.aktion === "einsatzAenderung" && App.zustand.aktivesModul === "einsatz") {
+ rendern($("inhalt"));
+ }
+ });
+
+ /* ---- Registrierung ------------------------------------------------ */
+
+ App.registriereAnsicht("einsatz", {
+ rendern,
+ aktualisieren: () => {
+ if (App.zustand.aktivesModul === "einsatz") rendern($("inhalt"));
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/fahndung.js b/resources/[aether]/aether-police/html/js/module/fahndung.js
new file mode 100644
index 0000000..aad294e
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/fahndung.js
@@ -0,0 +1,458 @@
+/* =====================================================================
+ aether-police / Ansicht: Fahndungen
+
+ Fahndungen werden von Beamten ausgeschrieben und von Beamten
+ aufgehoben. Nichts davon geschieht von selbst.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App;
+
+ const z = {
+ daten: null,
+ offen: null,
+ filterTyp: "",
+ filterGefahr: "",
+ archiv: false,
+ suche: "",
+ };
+
+ const gefahrArt = { normal: "neutral", bewaffnet: "warn", gefaehrlich: "kritisch" };
+ const gefahrText = { normal: "Normal", bewaffnet: "Bewaffnet", gefaehrlich: "Gefährlich" };
+ const prioText = { 1: "Sofort", 2: "Hoch", 3: "Normal", 4: "Niedrig" };
+
+ /* ---- Übersicht ---------------------------------------------------- */
+
+ function fahndungKarte(f) {
+ return `
+
+
+ ${f.bild ? `
`
+ : `
${f.typ === "person" ? "👤" : "🚗"} `}
+
+
+
${sicher(f.bezeichnung)}
+
${sicher(f.grund)}
+
+ ${sicher(f.dienstnummer || "")} ${sicher((f.firstname || "") + " " + (f.lastname || ""))}
+ · ${zeit(f.erstellt_am)}
+ ${!f.aktiv && f.aufgehoben_am
+ ? ` · aufgehoben ${zeit(f.aufgehoben_am)} durch
+ ${sicher((f.a_vorname || "") + " " + (f.a_nachname || ""))}` : ""}
+
+
+
+ ${sicher(gefahrText[f.gefahr] || f.gefahr)}
+ Prio ${sicher(prioText[f.prioritaet] || f.prioritaet)}
+
+
`;
+ }
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Fahndungen
+
Werden von Beamten ausgeschrieben — und nur von Beamten wieder aufgehoben.
+
+ ${d.rechte.erstellen ? `
+ Fahndung ausschreiben ` : ""}
+
+
+
+
Normal
+
${d.bestand.normal || 0}
+
Bewaffnet
+
${d.bestand.bewaffnet || 0}
+
Gefährlich
+
${d.bestand.gefaehrlich || 0}
+
+
+
+
+
+ Alle Arten
+ Personen
+ Fahrzeuge
+
+
+ Alle Stufen
+ Normal
+ Bewaffnet
+ Gefährlich
+
+
+ ${z.archiv ? "Aktive zeigen" : "Archiv"}
+
+
+
+
+ ${d.fahndungen.length
+ ? `
${d.fahndungen.map(fahndungKarte).join("")}
`
+ : `
${z.archiv ? "Keine aufgehobenen Fahndungen." : "Keine aktiven Fahndungen."}
`}
+
`;
+
+ const neu = $("fa-neu");
+ if (neu) neu.addEventListener("click", () => ausschreibenDialog(behaelter));
+
+ const suchfeld = $("fa-suche");
+ suchfeld.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); }
+ });
+ $("fa-typ").addEventListener("change", (e) => { z.filterTyp = e.target.value; rendern(behaelter); });
+ $("fa-gefahr").addEventListener("change", (e) => { z.filterGefahr = e.target.value; rendern(behaelter); });
+ $("fa-archiv").addEventListener("click", () => { z.archiv = !z.archiv; rendern(behaelter); });
+
+ behaelter.querySelectorAll("[data-fahndung]").forEach((el) =>
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.fahndung)));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------ */
+
+ function einzelAnsicht(behaelter, d) {
+ const f = d.fahndung;
+ const ziel = f.ziel;
+ const aktiv = f.aktiv === 1;
+
+ behaelter.innerHTML = `
+
+
+
${f.typ === "person" ? "👤" : "🚗"} ${sicher(f.bezeichnung)}
+
Fahndung #${f.id} · ${sicher(gefahrText[f.gefahr] || f.gefahr)}
+ · Priorität ${sicher(prioText[f.prioritaet] || f.prioritaet)}
+
+
+ ‹ Zurück
+ ${aktiv && d.rechte.bearbeiten
+ ? `✎ Bearbeiten ` : ""}
+ ${aktiv && d.rechte.aufheben
+ ? `Fahndung aufheben ` : ""}
+
+
+
+ ${!aktiv ? `
+
+ ✓
+ Diese Fahndung wurde am ${zeit(f.aufgehoben_am)} von
+ ${sicher((f.a_vorname || "") + " " + (f.a_nachname || ""))} aufgehoben.
+
`
+ : f.gefahr !== "normal" ? `
+
+ ⚠
+ Gefahrenstufe ${sicher(gefahrText[f.gefahr])}.
+ ${f.gefahr === "gefaehrlich"
+ ? "Zugriff nur mit ausreichender Verstärkung."
+ : "Es ist mit Bewaffnung zu rechnen."}
+
` : ""}
+
+
+
+
Fahndungsgrund
+
${sicher(f.grund).replace(/\n/g, " ")}
+
+ ${f.hinweise ? `
+
Hinweise für die Streife
+
${sicher(f.hinweise).replace(/\n/g, " ")}
+ ` : ""}
+
+
+ Ausgeschrieben ${sicher(f.dienstnummer || "")}
+ ${sicher((f.firstname || "") + " " + (f.lastname || ""))}
+ Am ${zeit(f.erstellt_am)}
+
+
+
+
+
+ ${f.typ === "person" ? "Gesuchte Person" : "Gesuchtes Fahrzeug"}
+ ${ziel && f.typ === "person"
+ ? `Akte öffnen ` : ""}
+ ${ziel && f.typ === "fahrzeug"
+ ? `Halterdaten ` : ""}
+
+
+ ${!ziel ? `
Das Ziel ist nicht mehr im System auffindbar.
`
+ : f.typ === "person" ? `
+
+
+ ${ziel.lichtbild ? `
`
+ : `
Kein Lichtbild `}
+
+
+ Name ${sicher(ziel.firstname + " " + ziel.lastname)}
+ Geboren ${sicher(String(ziel.dob || "").slice(0, 10))}
+ Warnhinweis ${sicher(ziel.warnhinweis || "—")}
+
+
`
+ : `
+ Kennzeichen ${sicher(ziel.plate)}
+ Modell ${sicher(ziel.name || ziel.model)}
+ Farbe ${ziel.farbe
+ ? ` ${sicher(ziel.farbe)}`
+ : "—"}
+ Halter ${sicher((ziel.firstname || "unbekannt") + " " + (ziel.lastname || ""))}
+ Status ${sicher(ziel.state === "impound" ? "sichergestellt"
+ : ziel.state === "out" ? "unterwegs" : "eingeparkt")}
+
`}
+
+
+ ${f.bild ? `
+
+
Fahndungsbild
+
+
` : ""}
+
+
+
Vorgangshistorie
+ ${d.historie.length ? `
${d.historie.map((h) => `
+
+
+
${sicher(h.aktion)}
+
${sicher(h.dienstnummer || "")}
+ ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)}
+ ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
`
+ : `
Keine Einträge.
`}
+
+
`;
+
+ $("fa-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); });
+
+ // Sprünge ins MDT — die Akte wird dort direkt geöffnet
+ behaelter.querySelectorAll("[data-person]").forEach((el) =>
+ el.addEventListener("click", () =>
+ App.oeffneModul("mdt", { person: el.dataset.person })));
+ behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) =>
+ el.addEventListener("click", () =>
+ App.oeffneModul("mdt", { fahrzeug: el.dataset.fahrzeug })));
+
+ const bearbeiten = $("fa-bearbeiten");
+ if (bearbeiten) bearbeiten.addEventListener("click", () => bearbeitenDialog(behaelter, f));
+
+ const aufheben = $("fa-aufheben");
+ if (aufheben) aufheben.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Fahndung aufheben",
+ inhalt: `
+
+ ${sicher(f.bezeichnung)} ${sicher(f.grund)}
+
+
+
+
📄
+
Die Fahndung wandert ins Archiv und bleibt dort nachvollziehbar.
+ Es wird festgehalten, dass du sie aufgehoben hast.
+
+
+
+ Grund der Aufhebung
+
+
`,
+ bestaetigenText: "Fahndung aufheben",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("fahndung", "aufheben", {
+ id: f.id, grund: el.querySelector("#fh-grund").value.trim(),
+ });
+ zu(); meldung("Fahndung aufgehoben.", "gut"); z.offen = null; rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ });
+ });
+ }
+
+ /* ---- Dialoge ------------------------------------------------------ */
+
+ function ausschreibenDialog(behaelter) {
+ let gewaehlt = null;
+
+ fenster({
+ titel: "Fahndung ausschreiben",
+ inhalt: `
+ Art der Fahndung
+
+ Person
+ Fahrzeug
+
+
+
+
Ziel suchen
+
+
+ Suchen
+
+
Noch kein Ziel ausgewählt.
+
+
+
+
Gefahrenstufe
+
+ Normal
+ Bewaffnet
+ Gefährlich
+
+
Priorität
+
+ 1 — Sofort
+ 2 — Hoch
+ 3 — Normal
+ 4 — Niedrig
+
+
+
+ Fahndungsgrund
+
+ Hinweise für die Streife
+
+ Fahndungsbild (Adresse)
+
+
+
+
ℹ
+
Die Gefahrenstufe bestimmt, wie dringlich alle Beamten benachrichtigt werden.
+ Bei gefährlich geht ein kritischer Hinweis an alle im Dienst.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Ausschreiben", klick: async (el, zu) => {
+ if (!gewaehlt) { meldung("Bitte ein Ziel auswählen.", "fehler"); return; }
+ try {
+ const ergebnis = await aufruf("fahndung", "erstellen", {
+ typ: el.querySelector("#fn-typ").value,
+ zielId: gewaehlt,
+ gefahr: el.querySelector("#fn-gefahr").value,
+ prioritaet: +el.querySelector("#fn-prio").value,
+ grund: el.querySelector("#fn-grund").value.trim(),
+ hinweise: el.querySelector("#fn-hinweise").value.trim(),
+ bild: el.querySelector("#fn-bild").value.trim(),
+ });
+ zu(); meldung("Fahndung ausgeschrieben: " + ergebnis.bezeichnung, "gut");
+ oeffne(behaelter, ergebnis.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ nachOeffnen: (el) => {
+ const suchen = async () => {
+ const begriff = el.querySelector("#fn-suche").value.trim();
+ if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
+
+ try {
+ const d = await aufruf("fahndung", "zielSuche", {
+ typ: el.querySelector("#fn-typ").value, begriff,
+ });
+ const anzeige = el.querySelector("#fn-gewaehlt");
+ anzeige.innerHTML = (d.treffer || []).length
+ ? `${d.treffer.slice(0, 6).map((t) =>
+ `
+
${sicher(t.titel)}
+
${sicher(t.unter)}
+
`).join("")}
`
+ : `Keine Treffer.
`;
+
+ anzeige.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", () => {
+ gewaehlt = zeile.dataset.wahl;
+ anzeige.innerHTML = `Ausgewählt: ${sicher(zeile.dataset.titel)} `;
+ }));
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ };
+
+ el.querySelector("#fn-suchen").addEventListener("click", suchen);
+ el.querySelector("#fn-suche").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); suchen(); }
+ });
+ // Bei Wechsel der Art die Auswahl verwerfen
+ el.querySelector("#fn-typ").addEventListener("change", () => {
+ gewaehlt = null;
+ el.querySelector("#fn-gewaehlt").textContent = "Noch kein Ziel ausgewählt.";
+ });
+ },
+ });
+ }
+
+ function bearbeitenDialog(behaelter, f) {
+ fenster({
+ titel: "Fahndung bearbeiten",
+ inhalt: `
+
+
Gefahrenstufe
+
+ ${["normal", "bewaffnet", "gefaehrlich"].map((g) =>
+ `${gefahrText[g]} `).join("")}
+
+
Priorität
+
+ ${[1, 2, 3, 4].map((p) =>
+ `${p} — ${prioText[p]} `).join("")}
+
+
+ Fahndungsgrund
+
+ Hinweise
+
+ Fahndungsbild (Adresse)
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("fahndung", "bearbeiten", {
+ id: f.id,
+ gefahr: el.querySelector("#fb-gefahr").value,
+ prioritaet: +el.querySelector("#fb-prio").value,
+ grund: el.querySelector("#fb-grund").value.trim(),
+ hinweise: el.querySelector("#fb-hinweise").value.trim(),
+ bild: el.querySelector("#fb-bild").value.trim(),
+ });
+ zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, f.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function oeffne(behaelter, fahndungId) {
+ try {
+ const d = await aufruf("fahndung", "oeffnen", { id: fahndungId });
+ z.offen = fahndungId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("fahndung", "liste", {
+ typ: z.filterTyp, gefahr: z.filterGefahr, archiv: z.archiv, begriff: z.suche,
+ });
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Fahndungen
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("fahndung", {
+ rendern,
+ aktualisieren: () => {
+ if (App.zustand.aktivesModul === "fahndung") rendern($("inhalt"));
+ },
+ });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("fahndung", "liste", { begriff });
+ return (d.fahndungen || []).slice(0, 5).map((f) => ({
+ bereich: "Fahndung", titel: f.bezeichnung,
+ unter: f.grund + " · " + (gefahrText[f.gefahr] || f.gefahr),
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/fahrzeug.js b/resources/[aether]/aether-police/html/js/module/fahrzeug.js
new file mode 100644
index 0000000..390c1a5
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/fahrzeug.js
@@ -0,0 +1,451 @@
+/* =====================================================================
+ aether-police / Ansicht: Fuhrpark
+
+ Zeigt Standort, Tankstand, Zustand und letzten Fahrer — das sind
+ gemeldete Tatsachen. Zuweisung, Status und Wartung setzt immer ein
+ Beamter; es gibt keine automatische Fahrzeugvergabe.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App;
+
+ const z = {
+ daten: null,
+ offen: null,
+ filterAbteilung: "",
+ filterStatus: "",
+ filterWartung: "",
+ suche: "",
+ };
+
+ const statusArt = {
+ verfuegbar: "gut", im_einsatz: "warn",
+ werkstatt: "kritisch", ausser_dienst: "neutral",
+ };
+ const wartungArt = { ok: "gut", faellig: "warn", werkstatt: "kritisch" };
+
+ /* ---- Bausteine --------------------------------------------------- */
+
+ /** Balken für Tank oder Zustand */
+ function balken(anteil, art) {
+ const wert = Math.max(0, Math.min(100, anteil));
+ const farbe = wert < 20 ? "var(--kritisch)" : wert < 50 ? "var(--warn)" : "var(--gut)";
+ return `
+
+
`;
+ }
+
+ function statusName(id) {
+ return (z.daten.statusliste && z.daten.statusliste[id]) || id;
+ }
+ function wartungName(id) {
+ return (z.daten.wartungsliste && z.daten.wartungsliste[id]) || id;
+ }
+ function abteilungName(id) {
+ const treffer = (z.daten.abteilungen || []).find((a) => a.id === id);
+ return treffer ? treffer.name : id;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Fuhrpark
+
Standort, Tank und Zustand werden gemeldet — Zuweisung und Status setzt ein Beamter.
+
+ ${d.rechte.wartung ? `
+ Fahrzeug aufnehmen ` : ""}
+
+
+
+
Verfügbar
+
${d.bestand.verfuegbar || 0}
+
Im Einsatz
+
${d.bestand.im_einsatz || 0}
+
Werkstatt
+
${d.bestand.werkstatt || 0}
+
Wartung offen
+
${d.wartungFaellig}
+
+
+
+
+
+ Alle Abteilungen
+ ${(d.abteilungen || []).map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+
+ Alle Status
+ ${Object.keys(d.statusliste || {}).map((s) =>
+ `${sicher(d.statusliste[s])} `).join("")}
+
+
+
+
+ ${d.fahrzeuge.length ? `
${d.fahrzeuge.map((f) => `
+
+
${sicher(f.kennzeichen)}
+
+
${sicher(f.bezeichnung)}
+ ${f.wartung !== "ok"
+ ? `${sicher(wartungName(f.wartung))} ` : ""}
+
+
+ ${sicher(f.modell)} · ${sicher(abteilungName(f.abteilung))}
+ ${f.z_nummer ? " · zugewiesen: " + sicher(f.z_nummer) : ""}
+
+
+ ⛽ ${Math.round(f.tank)} % ${balken(f.tank, "Tank")}
+ 🔧 ${Math.round(f.zustand / 10)} % ${balken(f.zustand / 10, "Zustand")}
+
+
+
+ ${sicher(statusName(f.status))}
+ ${f.pos_x ? `Standort ` : ""}
+
+
`).join("")}
`
+ : `
Kein Fahrzeug im Fuhrpark.
`}
+
`;
+
+ const neu = $("fz-neu");
+ if (neu) neu.addEventListener("click", () => anlegenDialog(behaelter));
+
+ const suchfeld = $("fz-suche");
+ suchfeld.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); }
+ });
+ $("fz-abteilung").addEventListener("change", (e) => {
+ z.filterAbteilung = e.target.value; rendern(behaelter);
+ });
+ $("fz-status").addEventListener("change", (e) => {
+ z.filterStatus = e.target.value; rendern(behaelter);
+ });
+
+ // Standort auf der Karte markieren, ohne die Zeile zu öffnen
+ behaelter.querySelectorAll("[data-weg]").forEach((b) =>
+ b.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const [x, y] = b.dataset.weg.split(",").map(Number);
+ anClient("wegpunkt", { x, y });
+ }));
+
+ behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) =>
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.fahrzeug)));
+ }
+
+ /* ---- Einzelansicht ------------------------------------------------ */
+
+ function einzelAnsicht(behaelter, d) {
+ const f = d.fahrzeug;
+
+ behaelter.innerHTML = `
+
+
+
${sicher(f.bezeichnung)}
+
${sicher(f.kennzeichen)} · ${sicher(f.modell)} · ${sicher(abteilungName(f.abteilung))}
+
+
+ ‹ Zurück
+ ${f.pos_x ? `📍 Standort ` : ""}
+ ${d.rechte.zuweisen ? `Status ` : ""}
+ ${d.rechte.wartung ? `Wartung ` : ""}
+ ${d.rechte.zuweisen ? `Zuweisen ` : ""}
+
+
+
+ ${f.wartung !== "ok" ? `
+
+ 🔧
+ ${sicher(wartungName(f.wartung))} — das Fahrzeug sollte vor dem
+ nächsten Einsatz geprüft werden.
+
` : ""}
+
+
+
+
+ Zustand
+ ${sicher(statusName(f.status))}
+
+
+
+
+ ⛽ Tankstand
+ ${Math.round(f.tank)} %
+
+ ${balken(f.tank, "Tank")}
+
+ 🔧 Karosserie
+ ${Math.round(f.zustand / 10)} %
+
+ ${balken(f.zustand / 10, "Zustand")}
+
+
+
+ Wartung ${sicher(wartungName(f.wartung))}
+ Zugewiesen an ${f.z_nummer
+ ? sicher(f.z_nummer + " " + (f.z_vorname || "") + " " + (f.z_nachname || ""))
+ : "— frei —"}
+ Letzter Fahrer ${f.l_nummer
+ ? sicher(f.l_nummer + " " + (f.l_vorname || "") + " " + (f.l_nachname || ""))
+ : "— nicht erfasst —"}
+ Standort ${f.pos_x
+ ? `${Math.round(f.pos_x)}, ${Math.round(f.pos_y)}`
+ : "— nicht gemeldet —"}
+
+
+
+
ℹ
+
Tank, Zustand, Standort und letzter Fahrer werden vom Fahrzeug
+ gemeldet . Status, Zuweisung und Wartung setzt ausschließlich ein Beamter.
+
+
+
+
+
+ Verwaltung
+ ${d.rechte.wartung && !f.zugewiesen_an
+ ? `Ausmustern ` : ""}
+
+ ${f.zugewiesen_an ? `
+
+
+
${sicher(f.z_nummer || "")}
+
+
${sicher((f.z_vorname || "") + " " + (f.z_nachname || ""))}
+
Fahrzeug ist dieser Person zugewiesen
+
+ ${d.rechte.zuweisen
+ ? `
Freigeben ` : ""}
+
+
`
+ : `
Das Fahrzeug ist keiner Person zugewiesen.
`}
+
+
+
+
Vorgangshistorie
+ ${d.historie.length ? `
${d.historie.map((h) => `
+
+
+
${sicher(h.aktion)}
+
${sicher(h.dienstnummer || "")}
+ ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)}
+ ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
`
+ : `
Keine Einträge.
`}
+
+
`;
+
+ verdrahteEinzel(behaelter, d);
+ }
+
+ function verdrahteEinzel(behaelter, d) {
+ const f = d.fahrzeug;
+
+ $("fz-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); });
+
+ const standort = $("fz-standort");
+ if (standort) standort.addEventListener("click", () =>
+ anClient("wegpunkt", { x: f.pos_x, y: f.pos_y }));
+
+ const zuweisen = $("fz-zuweisen");
+ if (zuweisen) zuweisen.addEventListener("click", () => {
+ const beamte = d.beamteImDienst || [];
+ fenster({
+ titel: "Fahrzeug zuweisen",
+ inhalt: `
+
+ ${sicher(f.bezeichnung)} (${sicher(f.kennzeichen)})
+ ${beamte.length ? `${beamte.map((b) =>
+ `
+
${sicher(b.dienstnummer)}
+
${sicher(b.name)}
+
${sicher(b.rolleName)} · ${sicher(b.abteilung)}
+
`).join("")}
`
+ : `Niemand im Dienst.
`}
+ Vermerk
+
`,
+ nachOeffnen: (el) => {
+ el.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", async () => {
+ try {
+ await aufruf("fahrzeug", "zuweisen", {
+ id: f.id, charakterId: +zeile.dataset.wahl,
+ grund: el.querySelector("#fzz-grund").value.trim(),
+ });
+ el.remove(); meldung("Zugewiesen.", "gut"); oeffne(behaelter, f.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }));
+ },
+ });
+ });
+
+ const freigeben = $("fz-freigeben");
+ if (freigeben) freigeben.addEventListener("click", async () => {
+ try {
+ await aufruf("fahrzeug", "zuweisen", { id: f.id, freigeben: true });
+ meldung("Zuweisung aufgehoben."); oeffne(behaelter, f.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ });
+
+ const statusSetzen = $("fz-status-setzen");
+ if (statusSetzen) statusSetzen.addEventListener("click", () => {
+ fenster({
+ titel: "Status setzen",
+ inhalt: `
+ Neuer Status
+
+ ${Object.keys(d.statusliste).map((s) =>
+ `${sicher(d.statusliste[s])} `).join("")}
+
+ Vermerk
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("fahrzeug", "status", {
+ id: f.id, status: el.querySelector("#fzs-status").value,
+ grund: el.querySelector("#fzs-grund").value.trim(),
+ });
+ zu(); meldung("Status gesetzt.", "gut"); oeffne(behaelter, f.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const wartung = $("fz-wartung");
+ if (wartung) wartung.addEventListener("click", () => {
+ fenster({
+ titel: "Wartungsstatus",
+ inhalt: `
+ Zustand der Wartung
+
+ ${Object.keys(d.wartungsliste).map((w) =>
+ `${sicher(d.wartungsliste[w])} `).join("")}
+
+ Vermerk
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("fahrzeug", "wartung", {
+ id: f.id, wartung: el.querySelector("#fzw-wartung").value,
+ vermerk: el.querySelector("#fzw-vermerk").value.trim(),
+ });
+ zu(); meldung("Wartungsstatus gesetzt.", "gut"); oeffne(behaelter, f.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const entfernen = $("fz-entfernen");
+ if (entfernen) entfernen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Fahrzeug ausmustern",
+ gefahr: true,
+ inhalt: `
+
+ ${sicher(f.bezeichnung)} (${sicher(f.kennzeichen)}) wird aus dem Fuhrpark
+ entfernt. Der Vorgang bleibt im Protokoll sichtbar.
+ Grund
+
`,
+ bestaetigenText: "Ausmustern",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("fahrzeug", "entfernen", {
+ id: f.id, grund: el.querySelector("#fze-grund").value.trim(),
+ });
+ zu(); meldung("Ausgemustert."); z.offen = null; rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ });
+ });
+ }
+
+ /* ---- Anlegen ------------------------------------------------------ */
+
+ function anlegenDialog(behaelter) {
+ fenster({
+ titel: "Fahrzeug aufnehmen",
+ inhalt: `
+
+ Bezeichnung
+
+ Abteilung
+
+ ${(z.daten.abteilungen || []).map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+
+ Das Kennzeichen muss mit dem im Spiel übereinstimmen — nur dann können
+ Tank, Zustand und Standort gemeldet werden.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Aufnehmen", klick: async (el, zu) => {
+ try {
+ const ergebnis = await aufruf("fahrzeug", "anlegen", {
+ kennzeichen: el.querySelector("#fn-kz").value.trim(),
+ modell: el.querySelector("#fn-modell").value.trim(),
+ bezeichnung: el.querySelector("#fn-bez").value.trim(),
+ abteilung: el.querySelector("#fn-abteilung").value,
+ });
+ zu(); meldung("Fahrzeug aufgenommen.", "gut");
+ oeffne(behaelter, ergebnis.id);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function oeffne(behaelter, fahrzeugId) {
+ try {
+ const d = await aufruf("fahrzeug", "oeffnen", { id: fahrzeugId });
+ z.offen = fahrzeugId;
+ einzelAnsicht(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("fahrzeug", "liste", {
+ abteilung: z.filterAbteilung, status: z.filterStatus,
+ wartung: z.filterWartung, begriff: z.suche,
+ });
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Fuhrpark
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("fahrzeug", { rendern });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("fahrzeug", "liste", { begriff });
+ return (d.fahrzeuge || []).slice(0, 5).map((f) => ({
+ bereich: "Dienstfahrzeug", titel: f.bezeichnung,
+ unter: f.kennzeichen + " · " + f.modell,
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/gewahrsam.js b/resources/[aether]/aether-police/html/js/module/gewahrsam.js
new file mode 100644
index 0000000..2febf7d
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/gewahrsam.js
@@ -0,0 +1,432 @@
+/* =====================================================================
+ aether-police / Ansicht: Gewahrsamsverwaltung
+
+ Zeigt Zellenbelegung und laufende Fristen. Abgelaufene Fristen werden
+ deutlich hervorgehoben — aber niemand wird dadurch entlassen. Die
+ Freilassung ist immer eine bewusste Handlung eines Beamten.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App;
+
+ const z = {
+ daten: null,
+ reiter: "aktuell", // aktuell | verlauf
+ };
+
+ /* Laufender Ticker, damit die Restzeiten mitlaufen */
+ let ticker = null;
+
+ /* ---- Bausteine --------------------------------------------------- */
+
+ /** Sekunden als „12:34" oder „abgelaufen" */
+ function restText(sekunden) {
+ if (sekunden <= 0) return "abgelaufen";
+ const m = Math.floor(sekunden / 60);
+ const s = Math.floor(sekunden % 60);
+ return m + ":" + String(s).padStart(2, "0") + " Min";
+ }
+
+ function reiterLeiste() {
+ return `
+ 🔒 Aktueller Bestand
+ 📋 Verlauf
+
`;
+ }
+
+ function zellenUebersicht(zellen) {
+ return `${zellen.map((zelle) => `
+
+
${sicher(zelle.name)}
+ ${zelle.frei
+ ? `
frei
`
+ : `
${sicher(zelle.insasse.name)}
+
+ ${restText(zelle.insasse.rest)}
+
`}
+
`).join("")}
`;
+ }
+
+ function bestandListe(offene) {
+ if (!offene.length) return `Niemand in Gewahrsam.
`;
+
+ return `${offene.map((g) => `
+
+
+
+
+ ${sicher(g.firstname + " " + g.lastname)}
+ ${g.zelle ? `${sicher(g.zelle)} ` : ""}
+ ${g.online ? "" : 'offline '}
+
+
${sicher(g.grund)}
+
+ ${g.haft_minuten} Min festgelegt · eingewiesen von
+ ${sicher(g.e_dienstnummer || "")} ${sicher((g.e_vorname || "") + " " + (g.e_nachname || ""))}
+ · ${zeit(g.beginn)}
+
+
+
+
+ ${restText(g.rest_sekunden)}
+
+
+ ${z.daten.rechte.entlassen
+ ? `Frist ` : ""}
+ ${z.daten.rechte.einweisen
+ ? `Zelle ` : ""}
+ ${z.daten.rechte.entlassen
+ ? `Entlassen ` : ""}
+
+
+
`).join("")}
`;
+ }
+
+ /* ---- Aktuelle Ansicht -------------------------------------------- */
+
+ function aktuellAnsicht(behaelter) {
+ const d = z.daten;
+ const abgelaufen = d.offene.filter((g) => g.abgelaufen).length;
+
+ behaelter.innerHTML = `
+
+
+
Gewahrsam
+
Fristen laufen mit — entlassen wird ausschließlich durch einen Beamten.
+
+ ${d.rechte.einweisen
+ ? `
+ In Gewahrsam nehmen ` : ""}
+
+
+ ${reiterLeiste()}
+
+ ${abgelaufen > 0 ? `
+
+ ⏰
+ ${abgelaufen} Haftzeit${abgelaufen === 1 ? "" : "en"} abgelaufen.
+ Die Person${abgelaufen === 1 ? " bleibt" : "en bleiben"} im Gewahrsam, bis ein Beamter
+ über die Entlassung entscheidet.
+
` : ""}
+
+
+
+
+ Zellenbelegung
+ ${d.zellen.filter((c) => !c.frei).length} / ${d.zellen.length}
+
+ ${zellenUebersicht(d.zellen)}
+
+
+
+
+ Aktueller Bestand
+ ${d.offene.length}
+
+ ${bestandListe(d.offene)}
+
+
ℹ
+
Das System erinnert an ablaufende Fristen — es entlässt niemanden.
+ Auch eine längst abgelaufene Haftzeit bleibt offen stehen, bis jemand entscheidet.
+
+
+
`;
+
+ verdrahteAktuell(behaelter);
+ starteTicker(behaelter);
+ }
+
+ function verdrahteAktuell(behaelter) {
+ const neu = $("gw-neu");
+ if (neu) neu.addEventListener("click", () => einweisenDialog(behaelter));
+
+ behaelter.querySelectorAll("[data-entlassen]").forEach((b) =>
+ b.addEventListener("click", () => entlassenDialog(behaelter, +b.dataset.entlassen)));
+
+ behaelter.querySelectorAll("[data-frist]").forEach((b) =>
+ b.addEventListener("click", () => fristDialog(behaelter, +b.dataset.frist)));
+
+ behaelter.querySelectorAll("[data-zelle]").forEach((b) =>
+ b.addEventListener("click", () => zelleDialog(behaelter, +b.dataset.zelle)));
+ }
+
+ /** Lässt die Restzeiten sekündlich weiterlaufen, ohne neu zu laden */
+ function starteTicker(behaelter) {
+ clearInterval(ticker);
+ ticker = setInterval(() => {
+ const felder = behaelter.querySelectorAll("[data-rest]");
+ if (!felder.length) { clearInterval(ticker); return; }
+
+ felder.forEach((el) => {
+ let rest = +el.dataset.rest - 1;
+ el.dataset.rest = rest;
+ el.textContent = restText(rest);
+ if (rest <= 0) el.classList.add("abgelaufen");
+ });
+ }, 1000);
+ }
+
+ /* ---- Einweisen ---------------------------------------------------- */
+
+ function einweisenDialog(behaelter) {
+ const freieZellen = z.daten.zellen.filter((c) => c.frei);
+ let gewaehlt = null; // { id, name }
+
+ const f = fenster({
+ titel: "In Gewahrsam nehmen",
+ inhalt: `
+
+
Person
+
+
+ Suchen
+
+
Noch niemand ausgewählt.
+
+
+
+
+ Haftdauer (Minuten)
+
+
+
+ Zelle
+
+ — ohne feste Zelle —
+ ${freieZellen.map((c) => `${sicher(c.name)} `).join("")}
+
+
+
+
+
+ Grund
+
+
+
+
+
ℹ
+
Die Haftdauer legst du fest. Ein Katalogvorschlag aus einer Anzeige
+ ist nur eine Empfehlung und wird hier nicht automatisch übernommen.
+ ${z.daten.verbringen
+ ? " Die Person wird in die gewählte Zelle verbracht."
+ : " Die Person wird nicht automatisch verbracht — das geschieht im Rollenspiel."}
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "In Gewahrsam nehmen", klick: async (el, zu) => {
+ if (!gewaehlt) { meldung("Bitte eine Person auswählen.", "fehler"); return; }
+ try {
+ await aufruf("gewahrsam", "einweisen", {
+ charakterId: gewaehlt.id,
+ dauer: +el.querySelector("#gw-dauer").value,
+ zelle: el.querySelector("#gw-zelle").value || null,
+ grund: el.querySelector("#gw-grund").value.trim(),
+ });
+ zu(); meldung("In Gewahrsam genommen.", "gut"); rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ nachOeffnen: (el) => {
+ const suchen = async () => {
+ const begriff = el.querySelector("#gw-person").value.trim();
+ if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
+
+ try {
+ const d = await aufruf("mdt", "personSuchen", { begriff });
+ const treffer = d.treffer || [];
+ if (!treffer.length) { meldung("Keine Treffer."); return; }
+
+ // Ergebnisse direkt unter dem Feld anbieten
+ const anzeige = el.querySelector("#gw-gewaehlt");
+ anzeige.innerHTML = `${treffer.slice(0, 6).map((p) =>
+ `
+
${sicher(p.firstname + " " + p.lastname)}
+
geb. ${sicher(String(p.dob || "").slice(0, 10))}
+
`).join("")}
`;
+
+ anzeige.querySelectorAll("[data-wahl]").forEach((zeile) =>
+ zeile.addEventListener("click", () => {
+ gewaehlt = { id: +zeile.dataset.wahl, name: zeile.dataset.name };
+ anzeige.innerHTML = `Ausgewählt: ${sicher(gewaehlt.name)} `;
+ }));
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ };
+
+ el.querySelector("#gw-suchen").addEventListener("click", suchen);
+ el.querySelector("#gw-person").addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); suchen(); }
+ });
+ },
+ });
+ return f;
+ }
+
+ /* ---- Entlassen ---------------------------------------------------- */
+
+ function entlassenDialog(behaelter, gewahrsamId) {
+ const g = z.daten.offene.find((x) => x.id === gewahrsamId);
+ if (!g) return;
+
+ const vorzeitig = g.rest_sekunden > 0;
+ const restMinuten = Math.ceil(g.rest_sekunden / 60);
+
+ bestaetigen({
+ titel: vorzeitig ? "Vorzeitig entlassen" : "Entlassen",
+ gefahr: vorzeitig,
+ inhalt: `
+
+ ${sicher(g.firstname + " " + g.lastname)}
+ ${sicher(g.grund)}
+
+
+ ${vorzeitig ? `
+
+
⚠ Die Frist läuft noch ${restMinuten} Minuten
+
+ Eine vorzeitige Entlassung ist möglich — sie verlangt aber eine Begründung
+ und wird als solche protokolliert.
+
+
`
+ : `
+
+
⏰
+
Die festgelegte Haftzeit ist abgelaufen. Das System hat lediglich
+ daran erinnert — die Entlassung führst du aus.
+
+
`}
+
+
+ Vermerk${vorzeitig ? " (erforderlich)" : ""}
+
+
`,
+ bestaetigenText: "Entlassung ausführen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("gewahrsam", "entlassen", {
+ id: gewahrsamId,
+ grund: el.querySelector("#ge-grund").value.trim(),
+ });
+ zu(); meldung("Entlassung durchgeführt.", "gut"); rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Frist anpassen ----------------------------------------------- */
+
+ function fristDialog(behaelter, gewahrsamId) {
+ fenster({
+ titel: "Haftzeit anpassen",
+ inhalt: `
+
+
Änderung in Minuten
+
+
Beispiel: -10 verkürzt um zehn Minuten,
+ 15 verlängert um fünfzehn.
+
+
+ Grund (erforderlich)
+
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Frist ändern", klick: async (el, zu) => {
+ try {
+ await aufruf("gewahrsam", "fristAendern", {
+ id: gewahrsamId,
+ minuten: +el.querySelector("#gf-minuten").value,
+ grund: el.querySelector("#gf-grund").value.trim(),
+ });
+ zu(); meldung("Frist angepasst.", "gut"); rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Zelle wechseln ----------------------------------------------- */
+
+ function zelleDialog(behaelter, gewahrsamId) {
+ const frei = z.daten.zellen.filter((c) => c.frei);
+ if (!frei.length) { meldung("Keine freie Zelle verfügbar.", "fehler"); return; }
+
+ fenster({
+ titel: "Zelle wechseln",
+ inhalt: `Neue Zelle
+
+ ${frei.map((c) => `${sicher(c.name)} `).join("")}
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Verlegen", klick: async (el, zu) => {
+ try {
+ await aufruf("gewahrsam", "zelleWechseln", {
+ id: gewahrsamId, zelle: el.querySelector("#gz-zelle").value,
+ });
+ zu(); meldung("Verlegt.", "gut"); rendern(behaelter);
+ } catch (fehler) { meldung(fehler.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* ---- Verlauf ------------------------------------------------------ */
+
+ function verlaufAnsicht(behaelter) {
+ const v = z.daten.verlauf;
+
+ behaelter.innerHTML = `
+
+
Gewahrsam — Verlauf
+
Abgeschlossene Vorgänge, nachvollziehbar mit entscheidendem Beamten
+
+
+ ${reiterLeiste()}
+
+
+ ${v.length ? `
${v.map((g) => `
+
+
+
${sicher(g.firstname + " " + g.lastname)}
+
${sicher(g.grund)}
+
+ ${g.haft_minuten} Min · ${zeit(g.beginn)} bis ${zeit(g.entlassen_am)}
+ · entlassen durch ${sicher((g.e_vorname || "") + " " + (g.e_nachname || ""))}
+ ${g.entlass_grund ? ` · „${sicher(g.entlass_grund)}"` : ""}
+
+
+
`).join("")}
`
+ : `
Keine abgeschlossenen Vorgänge.
`}
+
`;
+
+ behaelter.querySelectorAll("[data-reiter]").forEach((b) =>
+ b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); }));
+ }
+
+ /* ---- Laden -------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("gewahrsam", "liste", {});
+
+ if (z.reiter === "verlauf") verlaufAnsicht(behaelter);
+ else aktuellAnsicht(behaelter);
+
+ behaelter.querySelectorAll("[data-reiter]").forEach((b) =>
+ b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); }));
+
+ } catch (fehler) {
+ behaelter.innerHTML = `Gewahrsam
+
Konnte nicht geladen werden: ${sicher(fehler.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("gewahrsam", {
+ rendern,
+ aktualisieren: () => {
+ if (App.zustand.aktivesModul === "gewahrsam") rendern($("inhalt"));
+ },
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/kommunikation.js b/resources/[aether]/aether-police/html/js/module/kommunikation.js
new file mode 100644
index 0000000..142bf0d
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/kommunikation.js
@@ -0,0 +1,568 @@
+/* =====================================================================
+ aether-police / Ansicht: Interne Kommunikation
+
+ Posteingang und Aushänge. Der Posteingang merkt sich, was du gelesen
+ hast — das ist Anzeige. Eine Kenntnisnahme ist dagegen eine Erklärung
+ und wird nur durch deinen ausdrücklichen Klick gesetzt.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ reiter: "eingang", // eingang | gesendet | aushang | archiv
+ daten: null,
+ aushangDaten: null,
+ offeneNachricht: null,
+ offenerAushang: null,
+ };
+
+ // Farbgebung der Aushangkategorien
+ const kategorieArt = {
+ anweisung: "info", information: "neutral", warnung: "kritisch",
+ };
+ const kategorieSymbol = {
+ anweisung: "📋", information: "ℹ", warnung: "⚠",
+ };
+
+ /* ---- Gerüst -------------------------------------------------------- */
+
+ function reiterleiste(d) {
+ const eintraege = [
+ { id: "eingang", text: "Posteingang", zaehler: d.ungelesen },
+ { id: "gesendet", text: "Gesendet" },
+ { id: "aushang", text: "Aushänge", zaehler: d.offeneKenntnis },
+ { id: "archiv", text: "Archiv" },
+ ];
+
+ return `
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)}
+ ${e.zaehler ? `${e.zaehler} ` : ""}
+ `).join("")}
+
`;
+ }
+
+ /* ---- Posteingang --------------------------------------------------- */
+
+ function postliste(behaelter) {
+ const d = z.daten;
+ const gesendet = z.reiter === "gesendet";
+
+ behaelter.innerHTML = `
+
+
+
Kommunikation
+
Nachrichten und Aushänge der Behörde. Zugestellt wird automatisch —
+ gelesen, beantwortet und zur Kenntnis genommen wird von Hand.
+
+ ${d.rechte.senden
+ ? `
✉ Nachricht verfassen ` : ""}
+
+
+ ${reiterleiste(d)}
+
+
+ ${(d.nachrichten || []).length
+ ? d.nachrichten.map((n) => postzeile(n, gesendet)).join("")
+ : `
${gesendet
+ ? "Du hast noch nichts gesendet."
+ : "Keine Nachrichten."}
`}
+
+ `;
+
+ verdrahtePost(behaelter);
+ }
+
+ function postzeile(n, gesendet) {
+ const typMarker = n.empf_typ === "rundmeldung"
+ ? `Rundmeldung `
+ : n.empf_typ === "abteilung"
+ ? `Abteilung ` : "";
+
+ return `
+
+
+
${sicher(n.betreff || "(kein Betreff)")}
+
+ ${gesendet ? "an " + sicher(n.empfaengerName)
+ : "von " + sicher(n.absenderName)
+ + (n.dienstnummer ? " · " + sicher(n.dienstnummer) : "")}
+ — ${sicher(n.vorschau)}
+
+
+ ${typMarker}
+
${zeit(n.gesendet)}
+
`;
+ }
+
+ function verdrahtePost(behaelter) {
+ verdrahteReiter(behaelter);
+
+ behaelter.querySelectorAll("[data-nachricht]").forEach((el) => {
+ el.addEventListener("click", () => nachrichtOeffnen(behaelter, +el.dataset.nachricht));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => verfassen(behaelter));
+ }
+
+ function verdrahteReiter(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.reiter = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+ }
+
+ /* ---- Nachricht lesen ------------------------------------------------ */
+
+ async function nachrichtOeffnen(behaelter, id) {
+ try {
+ const n = await aufruf("kommunikation", "nachrichtOeffnen", { id });
+
+ fenster({
+ titel: n.betreff || "(kein Betreff)",
+ inhalt: `
+
+
+ ${sicher(n.absenderName)}
+
+ ${sicher(n.dienstnummer || "")} ${n.rolleName ? "· " + sicher(n.rolleName) : ""}
+
+
+
${zeit(n.gesendet)}
+
+ ${n.empfaengerName
+ ? `Gerichtet an: ${sicher(n.empfaengerName)}
` : ""}
+ ${sicher(n.text).replace(/\n/g, " ")}
`,
+ knoepfe: n.antwortAn
+ ? [{ text: "Antworten", klick: (_, zu) => {
+ zu();
+ verfassen(behaelter, {
+ empfTyp: "person", empfaenger: n.antwortAn,
+ empfaengerName: n.absenderName,
+ betreff: n.betreff && n.betreff.startsWith("Re:")
+ ? n.betreff : "Re: " + (n.betreff || ""),
+ });
+ } },
+ { text: "Schließen", art: "leise", klick: (_, zu) => zu() }]
+ : [{ text: "Schließen", art: "leise", klick: (_, zu) => zu() }],
+ });
+
+ // Der Lesestatus hat sich geändert — Liste auffrischen
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* ---- Nachricht verfassen -------------------------------------------- */
+
+ async function verfassen(behaelter, vorgabe) {
+ vorgabe = vorgabe || {};
+
+ let empfaengerDaten;
+ try {
+ empfaengerDaten = await aufruf("kommunikation", "empfaenger", {});
+ } catch (f) { return meldung(f.message, "fehler"); }
+
+ let gewaehlt = vorgabe.empfaenger
+ ? { typ: vorgabe.empfTyp, id: vorgabe.empfaenger, name: vorgabe.empfaengerName }
+ : null;
+
+ fenster({
+ titel: "Nachricht verfassen",
+ inhalt: `
+ Empfänger
+
+ Beamter
+ Abteilung
+ ${empfaengerDaten.rundmeldung
+ ? `Alle Einheiten ` : ""}
+
+
+
+ Betreff
+
+ Nachricht
+
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Senden", klick: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte einen Empfänger wählen.", "fehler");
+ try {
+ await aufruf("kommunikation", "senden", {
+ empfTyp: gewaehlt.typ,
+ empfaenger: gewaehlt.id,
+ betreff: el.querySelector("[name=betreff]").value.trim(),
+ text: el.querySelector("[name=text]").value.trim(),
+ });
+ zu();
+ meldung("Nachricht gesendet.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ nachOeffnen: (hg) => {
+ const bereich = hg.querySelector("[data-empfbereich]");
+
+ /** Zeichnet den Empfängerbereich passend zur gewählten Art */
+ function zeichne(art) {
+ hg.querySelectorAll("[data-art]").forEach((b) =>
+ b.classList.toggle("gewaehlt", b.dataset.art === art));
+
+ if (art === "rundmeldung") {
+ gewaehlt = { typ: "rundmeldung", id: "alle", name: "Alle Einheiten" };
+ bereich.innerHTML = `
+
📣
+
Diese Nachricht erreicht jeden Beamten der Behörde.
+
`;
+ return;
+ }
+
+ if (art === "abteilung") {
+ gewaehlt = null;
+ bereich.innerHTML = `
+ — Abteilung wählen —
+ ${empfaengerDaten.abteilungen.map((a) =>
+ `${sicher(a.name)} `).join("")}
+ `;
+ bereich.querySelector("[name=abt]").addEventListener("change", (e) => {
+ gewaehlt = e.target.value
+ ? { typ: "abteilung", id: e.target.value, name: e.target.value } : null;
+ });
+ return;
+ }
+
+ // Einzelner Beamter: Liste mit Suchfeld
+ bereich.innerHTML = `
+
+
`;
+
+ const liste = bereich.querySelector("[data-liste]");
+ const suchfeld = bereich.querySelector("[name=suche]");
+
+ function zeichneListe(begriff) {
+ const gefiltert = empfaengerDaten.beamte.filter((b) => {
+ if (!begriff) return true;
+ const text = (b.firstname + " " + b.lastname + " " + b.dienstnummer).toLowerCase();
+ return text.includes(begriff.toLowerCase());
+ }).slice(0, 40);
+
+ liste.innerHTML = gefiltert.length
+ ? gefiltert.map((b) => `
+
+
+
${sicher(b.dienstnummer)}
+
+
${sicher(b.firstname)} ${sicher(b.lastname)}
+
${sicher(b.rolleName)}
+
+
`).join("")
+ : `Kein Beamter gefunden.
`;
+
+ liste.querySelectorAll("[data-beamter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const b = empfaengerDaten.beamte.find((x) => x.character === +el.dataset.beamter);
+ gewaehlt = { typ: "person", id: b.character,
+ name: b.firstname + " " + b.lastname };
+ liste.querySelectorAll("[data-beamter]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ });
+ });
+ }
+
+ zeichneListe(gewaehlt && gewaehlt.name ? gewaehlt.name : "");
+ suchfeld.addEventListener("input", () => zeichneListe(suchfeld.value.trim()));
+ }
+
+ hg.querySelectorAll("[data-art]").forEach((b) =>
+ b.addEventListener("click", () => zeichne(b.dataset.art)));
+
+ zeichne(gewaehlt ? gewaehlt.typ : "person");
+ },
+ });
+ }
+
+ /* ---- Aushänge -------------------------------------------------------- */
+
+ function aushangListe(behaelter) {
+ const d = z.aushangDaten;
+
+ behaelter.innerHTML = `
+
+
+
${d.archiv ? "Archivierte Aushänge" : "Aushänge"}
+
Dienstanweisungen und Mitteilungen der Leitung. Eine Kenntnisnahme
+ bestätigst ausschließlich du selbst.
+
+ ${d.rechte.schreiben && !d.archiv
+ ? `
+ Aushang verfassen ` : ""}
+
+
+ ${reiterleiste(z.daten || { ungelesen: 0, offeneKenntnis: 0 })}
+
+
+ ${(d.aushaenge || []).length
+ ? d.aushaenge.map((a) => aushangKarte(a, d)).join("")
+ : `
${d.archiv
+ ? "Das Archiv ist leer." : "Zurzeit hängt nichts aus."}
`}
+
+ `;
+
+ verdrahteAushaenge(behaelter);
+ }
+
+ function aushangKarte(a, d) {
+ const art = kategorieArt[a.kategorie] || "neutral";
+ const offen = a.betroffen - a.kenntnisse;
+
+ return `
+
+
+ ${kategorieSymbol[a.kategorie] || ""} ${sicher(a.titel)}
+
+
+ ${a.angeheftet ? `angeheftet ` : ""}
+ ${sicher(a.kategorieName)}
+ ${a.abteilungName
+ ? `${sicher(a.abteilungName)} ` : ""}
+
+
+
+
${sicher(a.text).replace(/\n/g, " ")}
+
+
+
+ ${sicher(a.autor)}${a.dienstnummer ? " (" + sicher(a.dienstnummer) + ")" : ""}
+ · ${zeit(a.erstellt_am)}
+ ${a.frist ? ` · Kenntnisnahme bis ${zeit(a.frist)}` : ""}
+
+
+ ${a.kenntnispflicht ? (a.bestaetigt
+ ? `✓ zur Kenntnis genommen `
+ : (a.aktiv === 1
+ ? `
+ Kenntnis bestätigen ` : "")) : ""}
+ ${a.kenntnispflicht && d.rechte.schreiben
+ ? `
+ ${a.kenntnisse}/${a.betroffen} bestätigt${offen > 0 ? " · " + offen + " offen" : ""}
+ ` : ""}
+ ${d.rechte.schreiben && a.aktiv === 1
+ ? `
+ ${a.angeheftet ? "Loslösen" : "Anheften"} ` : ""}
+ ${d.rechte.archivieren && a.aktiv === 1
+ ? `Archivieren ` : ""}
+
+
+
`;
+ }
+
+ function verdrahteAushaenge(behaelter) {
+ verdrahteReiter(behaelter);
+
+ const neu = behaelter.querySelector("[data-neu-aushang]");
+ if (neu) neu.addEventListener("click", () => aushangVerfassen(behaelter));
+
+ behaelter.querySelectorAll("[data-kenntnis]").forEach((el) => {
+ el.addEventListener("click", () => kenntnisBestaetigen(behaelter, +el.dataset.kenntnis));
+ });
+
+ behaelter.querySelectorAll("[data-stand]").forEach((el) => {
+ el.addEventListener("click", () => kenntnisStand(+el.dataset.stand));
+ });
+
+ behaelter.querySelectorAll("[data-anheften]").forEach((el) => {
+ el.addEventListener("click", async () => {
+ try {
+ await aufruf("kommunikation", "anheften", {
+ id: +el.dataset.anheften, angeheftet: el.dataset.wert === "1",
+ });
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ behaelter.querySelectorAll("[data-archiv]").forEach((el) => {
+ el.addEventListener("click", () => aushangArchivieren(behaelter, +el.dataset.archiv));
+ });
+ }
+
+ /* ---- Kenntnisnahme ---------------------------------------------------- */
+
+ function kenntnisBestaetigen(behaelter, id) {
+ // Bewusst über den Bestätigungsdialog: das ist eine Erklärung, kein Klick
+ bestaetigen({
+ titel: "Kenntnisnahme bestätigen",
+ inhalt: `Du erklärst damit, den Aushang gelesen und verstanden zu haben.
+ Die Bestätigung wird mit deinem Namen und der Uhrzeit vermerkt.
`,
+ bestaetigenText: "Zur Kenntnis genommen",
+ bestaetigt: async (_, zu) => {
+ try {
+ await aufruf("kommunikation", "kenntnisnahme", { id });
+ zu();
+ meldung("Kenntnisnahme vermerkt.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ async function kenntnisStand(id) {
+ try {
+ const d = await aufruf("kommunikation", "kenntnisStand", { id });
+
+ fenster({
+ titel: "Stand der Kenntnisnahmen",
+ inhalt: `
+ ${sicher(d.titel)}
+ ${d.gesamt - d.offen} von ${d.gesamt} Beamten haben bestätigt.
+ ${d.frist ? "Frist: " + zeit(d.frist) + "." : ""}
+ Das System erinnert nur — es bestätigt für niemanden.
+
+ ${d.beamte.map((b) => `
+
+
${sicher(b.dienstnummer)}
+
+ ${b.bestaetigt_am
+ ? `
${zeit(b.bestaetigt_am)} `
+ : `
offen `}
+
`).join("")}
+
`,
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* ---- Aushang verfassen und archivieren -------------------------------- */
+
+ function aushangVerfassen(behaelter) {
+ const d = z.aushangDaten;
+
+ bestaetigen({
+ titel: "Aushang verfassen",
+ inhalt: `
+ Titel
+
+ Art
+
+ ${Object.keys(d.kategorien).map((k) =>
+ `${sicher(d.kategorien[k])} `).join("")}
+
+ Gilt für
+
+ Gesamte Behörde
+ ${d.abteilungen.map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+ Inhalt
+
+
+ Oben anheften
+
+
+
+ Kenntnisnahme verlangen
+
Beamte bestätigen selbst. Das System erinnert nur
+ an offene Bestätigungen und setzt nie eine.
+
+
+ Frist für die Kenntnisnahme (Minuten)
+
+
`,
+ bestaetigenText: "Veröffentlichen",
+ bestaetigt: async (el, zu) => {
+ const kenntnispflicht = el.querySelector("[name=kenntnispflicht]").checked;
+ const frist = +el.querySelector("[name=frist]").value;
+ try {
+ await aufruf("kommunikation", "aushangErstellen", {
+ titel: el.querySelector("[name=titel]").value.trim(),
+ text: el.querySelector("[name=text]").value.trim(),
+ kategorie: el.querySelector("[name=kategorie]").value,
+ abteilung: el.querySelector("[name=abteilung]").value,
+ angeheftet: el.querySelector("[name=angeheftet]").checked,
+ kenntnispflicht,
+ fristMinuten: kenntnispflicht && frist > 0 ? frist : null,
+ });
+ zu();
+ meldung("Aushang veröffentlicht.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ // Das Fristfeld ist nur sinnvoll, wenn eine Kenntnisnahme verlangt wird
+ const haken = hg.querySelector("[name=kenntnispflicht]");
+ const fristfeld = hg.querySelector("[data-fristfeld]");
+ haken.addEventListener("change", () => {
+ fristfeld.style.display = haken.checked ? "" : "none";
+ });
+ },
+ });
+ }
+
+ function aushangArchivieren(behaelter, id) {
+ bestaetigen({
+ titel: "Aushang archivieren",
+ gefahr: true,
+ inhalt: `Der Aushang verschwindet vom Brett und wandert ins Archiv.
+ Bereits bestätigte Kenntnisnahmen bleiben erhalten.
+ Grund (freiwillig)
+
`,
+ bestaetigenText: "Archivieren",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("kommunikation", "aushangArchivieren", {
+ id, grund: el.querySelector("[name=grund]").value.trim(),
+ });
+ zu();
+ meldung("Aushang archiviert.", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Laden ------------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ try {
+ // Die Zähler der Reiterleiste kommen immer aus der Übersicht
+ z.daten = await aufruf("kommunikation", "uebersicht", {
+ ordner: z.reiter === "gesendet" ? "gesendet" : "eingang",
+ });
+
+ // Sprungziel aus einem Hinweis, z. B. „Neuer Aushang"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.aushang) {
+ App.zustand.modulZiel = null;
+ z.reiter = "aushang";
+ } else if (ziel && ziel.nachricht) {
+ App.zustand.modulZiel = null;
+ z.reiter = "eingang";
+ postliste(behaelter);
+ return nachrichtOeffnen(behaelter, ziel.nachricht);
+ }
+
+ if (z.reiter === "aushang" || z.reiter === "archiv") {
+ z.aushangDaten = await aufruf("kommunikation", "aushaenge", {
+ archiv: z.reiter === "archiv",
+ });
+ aushangListe(behaelter);
+ } else {
+ postliste(behaelter);
+ }
+ } catch (f) {
+ behaelter.innerHTML = `Kommunikation
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("kommunikation", { rendern });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/mdt.js b/resources/[aether]/aether-police/html/js/module/mdt.js
new file mode 100644
index 0000000..2ea9240
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/mdt.js
@@ -0,0 +1,892 @@
+/* =====================================================================
+ aether-police / Ansicht: MDT
+
+ Drei Reiter: Personen, Fahrzeuge, Akten.
+ Die Ansicht zeigt und dokumentiert — sie bewertet nichts und löst
+ keine Maßnahme aus. Jede Änderung verlangt eine Bestätigung.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App;
+
+ /* Zustand der Ansicht */
+ const z = {
+ reiter: "personen", // personen | fahrzeuge | akten
+ suche: "",
+ treffer: [],
+ offenePerson: null, // geladene Personenakte
+ offenesFahrzeug: null,
+ offeneAkte: null,
+ akteFilter: { typ: "", status: "" },
+ stammdaten: null, // Aktenarten, Beteiligtenrollen
+ };
+
+ /* ---- kleine Bausteine ------------------------------------------- */
+
+ const gefahrText = ["keine", "gering", "erhöht", "hoch"];
+
+ /** Marke für die Gefahrenstufe einer Person */
+ function gefahrMarke(stufe) {
+ const s = Number(stufe) || 0;
+ if (s === 0) return "";
+ const art = s >= 3 ? "kritisch" : s === 2 ? "warn" : "neutral";
+ return `Gefahr: ${gefahrText[s]} `;
+ }
+
+ function reiterLeiste() {
+ const reiter = [
+ { id: "personen", name: "Personen", symbol: "👤" },
+ { id: "fahrzeuge", name: "Fahrzeuge", symbol: "🚗" },
+ { id: "akten", name: "Akten", symbol: "🗂" },
+ ];
+ return `${reiter.map((r) =>
+ `
+ ${r.symbol} ${r.name}
+ `).join("")}
`;
+ }
+
+ /* =====================================================================
+ PERSONEN
+ ===================================================================== */
+
+ function personenTrefferListe() {
+ if (!z.treffer.length) {
+ return `${z.suche ? "Keine Treffer." : "Suchbegriff eingeben — Name, Geburtsdatum oder Telefonnummer."}
`;
+ }
+
+ return `${z.treffer.map((p) =>
+ `
+
+
+ ${sicher(p.firstname + " " + p.lastname)}
+ ${p.fahndung > 0 ? 'Fahndung ' : ""}
+ ${gefahrMarke(p.gefahrenstufe)}
+
+
+ geb. ${sicher(String(p.dob || "").slice(0, 10))}
+ ${p.telefon ? " · ☎ " + sicher(p.telefon) : ""}
+ ${p.job ? " · " + sicher(p.job) : ""}
+
+
+
Akte öffnen
+
`).join("")}
`;
+ }
+
+ /** Zeichnet eine vollständige Personenakte */
+ function personenAkte(d) {
+ const p = d.person;
+
+ const lizenzName = {
+ perso: "Personalausweis", fs: "Führerschein", ws: "Waffenschein",
+ gk: "Gesundheitskarte", vk: "Visitenkarte",
+ };
+
+ return `
+
+
${p.lichtbild
+ ? `
`
+ : `
Kein Lichtbild `}
+
+
${sicher(p.firstname + " " + p.lastname)}
+
+ Geboren ${sicher(String(p.dob || "").slice(0, 10))}
+ Geschlecht ${sicher(p.gender || "—")}
+ Herkunft ${sicher(p.nationality || "—")}
+ Beruf ${sicher(p.job || "—")}
+ Telefon ${sicher(d.telefon || "—")}
+ Fingerabdruck ${sicher(p.fingerabdruck || "nicht erfasst")}
+ DNA ${sicher(p.dna || "nicht erfasst")}
+
+
+
+ + Notiz
+ ⚠ Warnhinweis
+ 🔬 ED-Daten
+ 🗂 Akte anlegen
+
+
+
+ ${p.warnhinweis ? `
+
+ ⚠
+ Warnhinweis: ${sicher(p.warnhinweis)} — Gefahrenstufe ${gefahrText[Number(p.gefahrenstufe) || 0]}
+
` : ""}
+
+ ${d.fahndung ? `
+
+ 🔍
+ Zur Fahndung ausgeschrieben: ${sicher(d.fahndung.grund)}
+ (${sicher(d.fahndung.gefahr)})
+
` : ""}
+
+ ${d.gewahrsam ? `
+
+ 🔒
+ In Gewahrsam — ${sicher(d.gewahrsam.grund || "ohne Grundangabe")};
+ Frist ${Number(d.gewahrsam.rest) <= 0 ? "abgelaufen" : "noch " + d.gewahrsam.rest + " Minuten"}.
+ Eine Entlassung entscheidet ein Beamter.
+
` : ""}
+
+
+
+
Dokumente & Lizenzen
+ ${d.lizenzen.length ? `
${d.lizenzen.map((l) =>
+ `
+
+
${sicher(lizenzName[l.typ] || l.typ)}
+
+ ${l.nummer ? sicher(l.nummer) : "—"}
+ ${l.klassen ? " · Klassen " + sicher(l.klassen.join(", ")) : ""}
+ ${l.gueltigBis ? " · gültig bis " + sicher(l.gueltigBis) : ""}
+
+
+
+ ${l.entzogen ? "entzogen" : l.gueltig ? "gültig" : "abgelaufen"}
+
+
`).join("")}
`
+ : `
Keine Dokumente hinterlegt.
`}
+
+
+
+
Bekannte Fahrzeuge
+ ${d.fahrzeuge.length ? `
${d.fahrzeuge.map((f) =>
+ `
+
+
${sicher(f.name || f.model)}
+ ${f.fahndung > 0 ? 'Fahndung ' : ""}
+
${sicher(f.plate)} · ${sicher(f.state === "impound" ? "sichergestellt" : f.state === "out" ? "unterwegs" : "eingeparkt")}
+
+
`).join("")}
`
+ : `
Keine Fahrzeuge zugeordnet.
`}
+
+
+
+
+ Vorgänge & Anzeigen
+ ${d.anzeigen.length}
+
+ ${d.anzeigen.length ? `
${d.anzeigen.map((a) =>
+ `
+
+
${sicher((a.delikte || []).map((x) => x.name).join(", ") || "Anzeige")}
+
${zeit(a.erstellt_am)}
+ ${a.status === "bestaetigt" ? ` · ${a.haft_final} Min · ${App.euro(a.geld_final)}` : ""}
+
+
+ ${sicher(a.status)}
+
+
`).join("")}
`
+ : `
Keine Vorgänge erfasst.
`}
+
+
+
+
Verknüpfte Akten
+ ${d.akten.length ? `
${d.akten.map((a) =>
+ `
+
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)} · ${sicher(a.typ)}
+ ${a.rolle ? " · " + sicher(a.rolle) : ""}
+
+
${sicher(a.status)}
+
`).join("")}
`
+ : `
Keine Akten verknüpft.
`}
+
+
+
+
Bekannte Kontakte
+ ${d.kontakte.length ? `
${d.kontakte.map((k) =>
+ `
+
+
${sicher(k.name)}
+
☎ ${sicher(k.number)}
+
+ ${k.charakterId ? '
bekannt ' : ""}
+
`).join("")}
`
+ : `
Keine Kontakte bekannt.
`}
+
+
+
+
+ Notizen
+ ${d.notizen.length}
+
+ ${d.notizen.length ? `
${d.notizen.map((n) =>
+ `
+
+
${sicher(n.text)}
+
${sicher((n.firstname || "") + " " + (n.lastname || ""))} · ${zeit(n.am)}
+
+
✕
+
`).join("")}
`
+ : `
Keine Notizen.
`}
+
+
+ `;
+ }
+
+ /* ---- Aktionen an der Personenakte -------------------------------- */
+
+ function verdrahtePersonenakte(behaelter, charakterId) {
+ // Notiz hinzufügen
+ const notizKnopf = $("p-notiz");
+ if (notizKnopf) notizKnopf.addEventListener("click", () => {
+ fenster({
+ titel: "Notiz hinzufügen",
+ inhalt: `
+
Vermerk
+
+
Notizen sind für alle Beamten sichtbar und werden protokolliert.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", art: "", klick: async (el, zu) => {
+ const text = el.querySelector("#nz-text").value.trim();
+ if (text.length < 3) { meldung("Bitte einen Text eingeben.", "fehler"); return; }
+ try {
+ await aufruf("mdt", "personNotiz", { charakterId, text });
+ zu(); meldung("Notiz gespeichert.", "gut");
+ oeffnePerson(behaelter, charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ // Notiz entfernen
+ behaelter.querySelectorAll("[data-notiz-weg]").forEach((b) => {
+ b.addEventListener("click", async (e) => {
+ e.stopPropagation();
+ try {
+ await aufruf("mdt", "personNotizLoeschen", { id: +b.dataset.notizWeg });
+ oeffnePerson(behaelter, charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ });
+ });
+
+ // Warnhinweis setzen
+ const warnKnopf = $("p-warn");
+ if (warnKnopf) warnKnopf.addEventListener("click", () => {
+ const p = z.offenePerson.person;
+ bestaetigen({
+ titel: "Warnhinweis festlegen",
+ inhalt: `
+
+ Hinweis (leer = entfernen)
+
+
+
+ Gefahrenstufe
+
+ ${[0, 1, 2, 3].map((s) =>
+ `${s} — ${gefahrText[s]} `
+ ).join("")}
+
+
+
+ Begründung
+
+
`,
+ bestaetigenText: "Warnhinweis setzen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("mdt", "personWarnhinweis", {
+ charakterId,
+ hinweis: el.querySelector("#wh-text").value.trim(),
+ stufe: +el.querySelector("#wh-stufe").value,
+ grund: el.querySelector("#wh-grund").value.trim(),
+ });
+ zu(); meldung("Warnhinweis aktualisiert.", "gut");
+ oeffnePerson(behaelter, charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ // Erkennungsdienstliche Daten
+ const edKnopf = $("p-ed");
+ if (edKnopf) edKnopf.addEventListener("click", () => {
+ const p = z.offenePerson.person;
+ fenster({
+ titel: "Erkennungsdienstliche Daten",
+ inhalt: `
+ Fingerabdruck-Kennung
+
+ DNA-Kennung
+
+ Lichtbild (Bild-Adresse)
+
+
Die Aufnahme selbst erfolgt im Rollenspiel — hier wird nur dokumentiert.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("mdt", "personStammdaten", {
+ charakterId,
+ fingerabdruck: el.querySelector("#ed-finger").value.trim(),
+ dna: el.querySelector("#ed-dna").value.trim(),
+ lichtbild: el.querySelector("#ed-bild").value.trim(),
+ });
+ zu(); meldung("Daten gespeichert.", "gut");
+ oeffnePerson(behaelter, charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ // Akte direkt zu dieser Person anlegen
+ const akteKnopf = $("p-akte-neu");
+ if (akteKnopf) akteKnopf.addEventListener("click", () =>
+ akteAnlegenDialog(behaelter, { typ: "person", id: charakterId }));
+
+ // Sprünge in andere Akten
+ behaelter.querySelectorAll("[data-akte]").forEach((el) =>
+ el.addEventListener("click", () => { z.reiter = "akten"; oeffneAkte(behaelter, +el.dataset.akte); }));
+ behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) =>
+ el.addEventListener("click", () => { z.reiter = "fahrzeuge"; oeffneFahrzeug(behaelter, el.dataset.fahrzeug); }));
+ behaelter.querySelectorAll("[data-person]").forEach((el) =>
+ el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person)));
+ }
+
+ async function oeffnePerson(behaelter, charakterId) {
+ try {
+ const daten = await aufruf("mdt", "personAkte", { charakterId });
+ z.offenePerson = daten;
+ z.reiter = "personen";
+ zeichne(behaelter, personenAkte(daten), () => verdrahtePersonenakte(behaelter, charakterId), true);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* =====================================================================
+ FAHRZEUGE
+ ===================================================================== */
+
+ function fahrzeugTrefferListe() {
+ if (!z.treffer.length) {
+ return `${z.suche ? "Keine Treffer." : "Kennzeichen, Modell oder Halter eingeben."}
`;
+ }
+
+ return `${z.treffer.map((f) =>
+ `
+
${sicher(f.plate)}
+
+
${sicher(f.name || f.model)}
+ ${f.fahndung > 0 ? 'Fahndung ' : ""}
+ ${f.state === "impound" ? 'sichergestellt ' : ""}
+
+
Halter: ${sicher((f.firstname || "unbekannt") + " " + (f.lastname || ""))}
+
+
Details
+
`).join("")}
`;
+ }
+
+ function fahrzeugAkte(d) {
+ const f = d.fahrzeug;
+ const zustandText = { in: "eingeparkt", out: "unterwegs", impound: "sichergestellt" };
+
+ return `
+
+
${sicher(f.plate)}
+
+
${sicher(f.name || f.model)}
+
+ Modell ${sicher(f.model)}
+ Klasse ${sicher(f.klasse || "—")}
+ Farbe ${f.farbe ? ` ${sicher(f.farbe)}` : "—"}
+ Status ${sicher(zustandText[f.state] || f.state)}
+ Standort ${sicher(f.garage || "—")}
+ Kilometerstand ${sicher(f.mileage || 0)} km
+
+
+
+ ${f.owner ? `👤 Halterakte ` : ""}
+ 🗂 Akte anlegen
+
+
+
+ ${d.fahndung ? `
+
+ 🔍
+ Fahrzeug zur Fahndung ausgeschrieben: ${sicher(d.fahndung.grund)}
+
` : ""}
+
+
+
+
Halter
+ ${f.owner ? `
+
+
+
${sicher((f.firstname || "") + " " + (f.lastname || ""))}
+
geb. ${sicher(String(f.dob || "").slice(0, 10))}
+
+
Akte öffnen
+
`
+ : `
Kein Halter eingetragen.
`}
+
+
+
+
Verknüpfte Akten
+ ${d.akten.length ? `
${d.akten.map((a) =>
+ `
+
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)} · ${sicher(a.typ)}
+
+
`).join("")}
`
+ : `
Keine Akten verknüpft.
`}
+
+
+
+
Historie
+ ${d.historie.length ? `
${d.historie.map((h) =>
+ `
+
+
${sicher(h.aktion)}
+
${sicher((h.firstname || "") + " " + (h.lastname || ""))}
+ ${h.dienstnummer ? " · " + sicher(h.dienstnummer) : ""} · ${zeit(h.am)}
+ ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
`
+ : `
Keine dokumentierten Vorgänge.
`}
+
+
+ `;
+ }
+
+ async function oeffneFahrzeug(behaelter, kennzeichen) {
+ try {
+ const daten = await aufruf("mdt", "fahrzeugAkte", { kennzeichen });
+ z.offenesFahrzeug = daten;
+ z.reiter = "fahrzeuge";
+ zeichne(behaelter, fahrzeugAkte(daten), () => {
+ behaelter.querySelectorAll("[data-person]").forEach((el) =>
+ el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person)));
+ behaelter.querySelectorAll("[data-akte]").forEach((el) =>
+ el.addEventListener("click", () => oeffneAkte(behaelter, +el.dataset.akte)));
+ const neu = $("v-akte-neu");
+ if (neu) neu.addEventListener("click", () =>
+ akteAnlegenDialog(behaelter, { typ: "fahrzeug", id: daten.fahrzeug.plate }));
+ }, true);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ /* =====================================================================
+ AKTEN
+ ===================================================================== */
+
+ function aktenListe(akten) {
+ if (!akten.length) return `Keine Akten gefunden.
`;
+
+ const statusArt = { offen: "warn", pruefung: "info", geschlossen: "neutral" };
+
+ return `${akten.map((a) =>
+ `
+
${sicher(a.typ)}
+
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)} ·
+ ${sicher((a.firstname || "") + " " + (a.lastname || ""))} · ${zeit(a.erstellt_am)}
+ ${a.version > 1 ? ` · Version ${a.version}` : ""}
+
+
${sicher(a.status)}
+
`).join("")}
`;
+ }
+
+ function akteAnsicht(d) {
+ const a = d.akte;
+ const statusArt = { offen: "warn", pruefung: "info", geschlossen: "neutral" };
+
+ return `
+
+
+
${sicher(a.titel)}
+
+ Aktenzeichen ${sicher(a.aktenzeichen)}
+ Art ${sicher(a.typ)}
+ Angelegt von ${sicher((a.firstname || "") + " " + (a.lastname || ""))}
+ Angelegt am ${zeit(a.erstellt_am)}
+ Version ${sicher(a.version)}
+
+
+
+ ${sicher(a.status)}
+ ${d.darfBearbeiten ? `✎ Bearbeiten ` : ""}
+ ${d.darfBearbeiten ? `🔗 Verknüpfen ` : ""}
+ ${d.darfSchliessen && a.status !== "geschlossen"
+ ? `Akte schließen ` : ""}
+
+
+
+
+
+
Sachverhalt
+
${a.inhalt ? sicher(a.inhalt).replace(/\n/g, " ") : "Kein Inhalt hinterlegt. "}
+
+
+
+
+ Verknüpfungen
+ ${d.verknuepfungen.length}
+
+ ${d.verknuepfungen.length ? `
${d.verknuepfungen.map((v) =>
+ `
+
${sicher(v.typ)}
+
+
+ ${sicher(v.bezeichnung || v.ziel_id)}
+
+ ${v.rolle ? `
${sicher(v.rolle)}
` : ""}
+
+ ${d.darfBearbeiten ? `
✕ ` : ""}
+
`).join("")}
`
+ : `
Nichts verknüpft.
`}
+
+
+
+
Änderungshistorie
+ ${d.versionen.length ? `
${d.versionen.map((v) =>
+ `
+
v${sicher(v.version)}
+
+
${sicher(v.grund || "ohne Angabe")}
+
${sicher((v.firstname || "") + " " + (v.lastname || ""))} · ${zeit(v.geaendert_am)}
+
+
`).join("")}
`
+ : `
Keine Änderungen.
`}
+
+
+ `;
+ }
+
+ async function oeffneAkte(behaelter, akteId) {
+ try {
+ const daten = await aufruf("mdt", "akteOeffnen", { id: akteId });
+ z.offeneAkte = daten;
+ z.reiter = "akten";
+ zeichne(behaelter, akteAnsicht(daten), () => verdrahteAkte(behaelter, daten), true);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ function verdrahteAkte(behaelter, d) {
+ const a = d.akte;
+
+ const bearbeiten = $("a-bearbeiten");
+ if (bearbeiten) bearbeiten.addEventListener("click", () => {
+ fenster({
+ titel: "Akte bearbeiten",
+ inhalt: `
+ Titel
+
+ Sachverhalt
+
+ Grund der Änderung
+
+
Jede Änderung erzeugt eine neue Version. Die alte bleibt nachvollziehbar.
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("mdt", "akteBearbeiten", {
+ id: a.id,
+ titel: el.querySelector("#ab-titel").value.trim(),
+ inhalt: el.querySelector("#ab-inhalt").value,
+ grund: el.querySelector("#ab-grund").value.trim(),
+ });
+ zu(); meldung("Akte gespeichert.", "gut");
+ oeffneAkte(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ const verknuepfen = $("a-verknuepfen");
+ if (verknuepfen) verknuepfen.addEventListener("click", () => {
+ const rollen = (z.stammdaten && z.stammdaten.rollen) || [];
+ fenster({
+ titel: "Verknüpfung hinzufügen",
+ inhalt: `
+ Art
+
+ Person (Charakter-ID)
+ Fahrzeug (Kennzeichen)
+ Beweis (Kennung)
+ Einsatz (Nummer)
+
+ Kennung
+
+ Beteiligung
+
+ — keine Angabe —
+ ${rollen.map((r) => `${sicher(r.name)} `).join("")}
+
`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Verknüpfen", klick: async (el, zu) => {
+ try {
+ await aufruf("mdt", "akteVerknuepfen", {
+ akteId: a.id,
+ typ: el.querySelector("#vk-typ").value,
+ zielId: el.querySelector("#vk-ziel").value.trim(),
+ rolle: el.querySelector("#vk-rolle").value || null,
+ });
+ zu(); meldung("Verknüpfung angelegt.", "gut");
+ oeffneAkte(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ });
+
+ behaelter.querySelectorAll("[data-vk-weg]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ try {
+ await aufruf("mdt", "akteVerknuepfungLoesen", { id: +b.dataset.vkWeg });
+ oeffneAkte(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }));
+
+ const schliessen = $("a-schliessen");
+ if (schliessen) schliessen.addEventListener("click", () => {
+ bestaetigen({
+ titel: "Akte schließen",
+ gefahr: true,
+ inhalt: `
+ Die Akte ${sicher(a.aktenzeichen)} wird als abgeschlossen gekennzeichnet.
+ Es wird festgehalten, dass du diese Entscheidung getroffen hast.
+ Grund
`,
+ bestaetigenText: "Akte schließen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("mdt", "akteStatus", {
+ id: a.id, status: "geschlossen",
+ grund: el.querySelector("#as-grund").value.trim(),
+ });
+ zu(); meldung("Akte geschlossen.", "gut");
+ oeffneAkte(behaelter, a.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ });
+
+ behaelter.querySelectorAll("[data-person]").forEach((el) =>
+ el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person)));
+ behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) =>
+ el.addEventListener("click", () => oeffneFahrzeug(behaelter, el.dataset.fahrzeug)));
+ }
+
+ /** Dialog zum Anlegen einer Akte, optional gleich verknüpft */
+ function akteAnlegenDialog(behaelter, verknuepfung) {
+ const arten = (z.stammdaten && z.stammdaten.aktenarten) || [];
+ const rollen = (z.stammdaten && z.stammdaten.rollen) || [];
+
+ fenster({
+ titel: "Akte anlegen",
+ inhalt: `
+ Aktenart
+
+ ${arten.map((a) => `${sicher(a.name)} `).join("")}
+
+ Titel
+
+ Sachverhalt
+
+ ${verknuepfung ? `
+ Beteiligung der verknüpften ${verknuepfung.typ === "person" ? "Person" : "Sache"}
+
+ — keine Angabe —
+ ${rollen.map((r) => `${sicher(r.name)} `).join("")}
+
` : ""}`,
+ knoepfe: [
+ { text: "Abbrechen", klick: (_, zu) => zu() },
+ { text: "Akte anlegen", klick: async (el, zu) => {
+ try {
+ const ergebnis = await aufruf("mdt", "akteErstellen", {
+ typ: el.querySelector("#an-typ").value,
+ titel: el.querySelector("#an-titel").value.trim(),
+ inhalt: el.querySelector("#an-inhalt").value,
+ verknuepfeTyp: verknuepfung && verknuepfung.typ,
+ verknuepfeId: verknuepfung && verknuepfung.id,
+ verknuepfeRolle: verknuepfung ? (el.querySelector("#an-rolle") || {}).value : null,
+ });
+ zu();
+ meldung("Akte " + ergebnis.aktenzeichen + " angelegt.", "gut");
+ oeffneAkte(behaelter, ergebnis.id);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }},
+ ],
+ });
+ }
+
+ /* =====================================================================
+ Gerüst
+ ===================================================================== */
+
+ /**
+ * Zeichnet den MDT-Rahmen mit Inhalt.
+ * @param {boolean} detail Wenn true, wird ein Zurück-Knopf gezeigt.
+ */
+ function zeichne(behaelter, inhalt, nachher, detail) {
+ const suchfeld = {
+ personen: "Name, Geburtsdatum oder Telefonnummer …",
+ fahrzeuge: "Kennzeichen, Modell oder Halter …",
+ akten: "Aktenzeichen oder Titel …",
+ }[z.reiter];
+
+ behaelter.innerHTML = `
+
+
MDT Personen, Fahrzeuge und Akten
+ ${z.reiter === "akten"
+ ? `
+ Neue Akte ` : ""}
+
+
+ ${reiterLeiste()}
+
+ ${detail
+ ? `‹ Zurück zur Suche `
+ : `
+
+ Suchen
+ ${z.reiter === "akten" ? `
+
+ Alle Arten
+ ${((z.stammdaten && z.stammdaten.aktenarten) || []).map((a) =>
+ `${sicher(a.name)} `).join("")}
+
+
+ Alle Status
+ Offen
+ In Prüfung
+ Geschlossen
+ ` : ""}
+
`}
+
+ ${inhalt}
+ `;
+
+ // Reiter wechseln
+ behaelter.querySelectorAll("[data-reiter]").forEach((b) =>
+ b.addEventListener("click", () => {
+ z.reiter = b.dataset.reiter;
+ z.suche = ""; z.treffer = [];
+ rendern(behaelter);
+ }));
+
+ const zurueck = $("mdt-zurueck");
+ if (zurueck) zurueck.addEventListener("click", () => rendern(behaelter));
+
+ const neueAkte = $("mdt-akte-neu");
+ if (neueAkte) neueAkte.addEventListener("click", () => akteAnlegenDialog(behaelter, null));
+
+ // Suche
+ const suchfeldEl = $("mdt-suche");
+ if (suchfeldEl) {
+ const suchen = () => { z.suche = suchfeldEl.value.trim(); ausfuehrenSuche(behaelter); };
+ suchfeldEl.addEventListener("keydown", (e) => { if (e.key === "Enter") suchen(); });
+ $("mdt-suchen").addEventListener("click", suchen);
+ }
+
+ ["mdt-filter-typ", "mdt-filter-status"].forEach((id) => {
+ const el = $(id);
+ if (el) el.addEventListener("change", () => {
+ z.akteFilter.typ = ($("mdt-filter-typ") || {}).value || "";
+ z.akteFilter.status = ($("mdt-filter-status") || {}).value || "";
+ ausfuehrenSuche(behaelter);
+ });
+ });
+
+ if (nachher) nachher();
+ }
+
+ /** Führt die Suche des aktiven Reiters aus */
+ async function ausfuehrenSuche(behaelter) {
+ try {
+ if (z.reiter === "personen") {
+ if (!z.suche) { z.treffer = []; zeichne(behaelter, personenTrefferListe(), () => verdrahteTreffer(behaelter)); return; }
+ const d = await aufruf("mdt", "personSuchen", { begriff: z.suche });
+ z.treffer = d.treffer;
+ zeichne(behaelter, personenTrefferListe(), () => verdrahteTreffer(behaelter));
+
+ } else if (z.reiter === "fahrzeuge") {
+ if (!z.suche) { z.treffer = []; zeichne(behaelter, fahrzeugTrefferListe(), () => verdrahteTreffer(behaelter)); return; }
+ const d = await aufruf("mdt", "fahrzeugSuchen", { begriff: z.suche });
+ z.treffer = d.treffer;
+ zeichne(behaelter, fahrzeugTrefferListe(), () => verdrahteTreffer(behaelter));
+
+ } else {
+ const d = await aufruf("mdt", "aktenListe", {
+ begriff: z.suche, typ: z.akteFilter.typ, status: z.akteFilter.status,
+ });
+ zeichne(behaelter, aktenListe(d.akten), () => verdrahteTreffer(behaelter));
+ }
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ function verdrahteTreffer(behaelter) {
+ behaelter.querySelectorAll("[data-person]").forEach((el) =>
+ el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person)));
+ behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) =>
+ el.addEventListener("click", () => oeffneFahrzeug(behaelter, el.dataset.fahrzeug)));
+ behaelter.querySelectorAll("[data-akte]").forEach((el) =>
+ el.addEventListener("click", () => oeffneAkte(behaelter, +el.dataset.akte)));
+ }
+
+ async function rendern(behaelter) {
+ // Stammdaten einmalig holen (Aktenarten, Beteiligtenrollen)
+ if (!z.stammdaten) {
+ try { z.stammdaten = await aufruf("mdt", "stammdaten", {}); } catch (f) { z.stammdaten = {}; }
+ }
+
+ /* Sprungziel eines anderen Moduls auswerten, z. B. aus einer Fahndung.
+ Es gilt nur einmal und wird danach verworfen. */
+ const ziel = App.zustand.modulZiel;
+ if (ziel) {
+ App.zustand.modulZiel = null;
+ if (ziel.person) { await oeffnePerson(behaelter, +ziel.person); return; }
+ if (ziel.fahrzeug) { await oeffneFahrzeug(behaelter, ziel.fahrzeug); return; }
+ if (ziel.akte) { await oeffneAkte(behaelter, +ziel.akte); return; }
+ }
+
+ await ausfuehrenSuche(behaelter);
+ }
+
+ /* ---- Registrierung ----------------------------------------------- */
+
+ App.registriereAnsicht("mdt", { rendern });
+
+ /* Globale Suche bedienen */
+ App.registriereSuche(async (begriff) => {
+ const treffer = [];
+ try {
+ const personen = await aufruf("mdt", "personSuchen", { begriff });
+ (personen.treffer || []).slice(0, 5).forEach((p) => treffer.push({
+ bereich: "Person", titel: p.firstname + " " + p.lastname,
+ unter: "geb. " + String(p.dob || "").slice(0, 10),
+ }));
+ } catch (e) { /* Bereich überspringen */ }
+
+ try {
+ const fahrzeuge = await aufruf("mdt", "fahrzeugSuchen", { begriff });
+ (fahrzeuge.treffer || []).slice(0, 5).forEach((f) => treffer.push({
+ bereich: "Fahrzeug", titel: f.plate + " · " + (f.name || f.model),
+ unter: "Halter: " + ((f.firstname || "unbekannt") + " " + (f.lastname || "")),
+ }));
+ } catch (e) { /* Bereich überspringen */ }
+
+ try {
+ const akten = await aufruf("mdt", "aktenListe", { begriff });
+ (akten.akten || []).slice(0, 5).forEach((a) => treffer.push({
+ bereich: "Akte", titel: a.titel, unter: a.aktenzeichen + " · " + a.typ,
+ }));
+ } catch (e) { /* Bereich überspringen */ }
+
+ return treffer;
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/personal.js b/resources/[aether]/aether-police/html/js/module/personal.js
new file mode 100644
index 0000000..c5b1c5f
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/personal.js
@@ -0,0 +1,633 @@
+/* =====================================================================
+ aether-police / Ansicht: Personalverwaltung
+
+ Dienstgrade und Rechte werden hier vergeben — jede Änderung ist eine
+ bewusste Entscheidung eines Vorgesetzten. Das System befördert,
+ degradiert oder entlässt niemanden von selbst; es zeigt nur an, was
+ gilt, und woher ein Recht kommt.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, zeit, dauer, aufruf, meldung, fenster, bestaetigen } = App;
+
+ const z = {
+ daten: null,
+ offen: null, // Charakter-ID der geöffneten Personalakte
+ suche: "",
+ filterRolle: "",
+ filterAbteilung: "",
+ ehemalige: false,
+ };
+
+ // Woher stammt ein Recht — Beschriftung und Farbe des Markers
+ const herkunftText = {
+ rolle: { text: "aus Rolle", art: "gut" },
+ zusaetzlich: { text: "zusätzlich", art: "warn" },
+ entzogen: { text: "entzogen", art: "kritisch" },
+ keins: { text: "kein Recht", art: "neutral" },
+ };
+
+ /* ---- Bausteine --------------------------------------------------- */
+
+ /** Name einer Abteilung anhand ihrer Kennung */
+ function abteilungName(id) {
+ const treffer = ((z.daten && z.daten.abteilungen) || []).find((a) => a.id === id);
+ return treffer ? treffer.name : id;
+ }
+
+ /**
+ * Auswahlfeld der Dienstgrade.
+ * @param {string} gewaehlt vorausgewählte Rolle
+ * @param {number|undefined} maxStufe nur Rollen unterhalb dieser Stufe anbieten
+ */
+ function rollenFeld(gewaehlt, maxStufe) {
+ const optionen = ((z.daten && z.daten.rollen) || [])
+ // Schranke: nur Rollen unterhalb des eigenen Rangs sind wählbar
+ .filter((r) => maxStufe === undefined || r.stufe < maxStufe)
+ .map((r) => `
+ ${sicher(r.name)}
+ `).join("");
+ return `${optionen} `;
+ }
+
+ /** Auswahlfeld der Abteilungen */
+ function abteilungsFeld(gewaehlt) {
+ const optionen = ((z.daten && z.daten.abteilungen) || [])
+ .map((a) => `
+ ${sicher(a.name)}
+ `).join("");
+ return `${optionen} `;
+ }
+
+ /* ---- Übersicht ---------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+ const liste = d.personal || [];
+
+ // Kennzahlen ausschließlich aus den gelieferten Daten
+ const gesamt = (d.verteilung || []).reduce((summe, v) => summe + v.anzahl, 0);
+ const imDienst = liste.filter((p) => p.imDienst).length;
+ const abweichend = liste.filter((p) => p.abweichungen > 0).length;
+
+ // Verteilung nach Rollen, in der Rangfolge der Rollenliste
+ const verteilungZeilen = (d.rollen || []).map((r) => {
+ const eintrag = (d.verteilung || []).find((v) => v.rolle === r.id);
+ const anzahl = eintrag ? eintrag.anzahl : 0;
+ const anteil = gesamt > 0 ? Math.round((anzahl / gesamt) * 100) : 0;
+ return `
+
${sicher(r.name)}
+
+
${anzahl}
+
`;
+ }).join("");
+
+ const rollenOptionen = (d.rollen || []).map((r) =>
+ `
+ ${sicher(r.name)} `).join("");
+ const abteilungsOptionen = (d.abteilungen || []).map((a) =>
+ `
+ ${sicher(a.name)} `).join("");
+
+ behaelter.innerHTML = `
+
+
+
Personal
+
Dienstgrade und Rechte vergibt ein Vorgesetzter — das System schlägt nichts vor
+ und befördert niemanden automatisch.
+
+ ${d.rechte.einstellen
+ ? `
+ Beamten einstellen ` : ""}
+
+
+
+
${gesamt}
+ im Bestand
+
${imDienst}
+ gerade im Dienst
+
${liste.length}
+ in dieser Ansicht
+
${abweichend}
+ mit Einzelrechten
+
+
+
+
Verteilung nach Dienstgrad
+
${verteilungZeilen || `
Kein Personal erfasst.
`}
+
+
+
+
+
+ Alle Dienstgrade ${rollenOptionen}
+
+
+ Alle Abteilungen ${abteilungsOptionen}
+
+
+ ${z.ehemalige ? "Ehemalige" : "Aktive"}
+
+
+
+
+ ${liste.length ? liste.map(zeile).join("")
+ : `
Kein Personal gefunden.
`}
+
+ `;
+
+ verdrahteUebersicht(behaelter);
+ }
+
+ /** Eine Zeile der Personalliste */
+ function zeile(p) {
+ const punkt = p.imDienst
+ ? ` `
+ : ` `;
+
+ return `
+ ${punkt}
+
${sicher(p.dienstnummer)}
+
+
${sicher(p.firstname)} ${sicher(p.lastname)}
+
${sicher(p.rolleName)} · ${sicher(abteilungName(p.abteilung))}
+ · ${dauer(p.dienstzeit || 0)} Dienstzeit
+
+ ${p.abweichungen > 0
+ ? `
+ ${p.abweichungen} Einzelrecht${p.abweichungen === 1 ? "" : "e"} ` : ""}
+ ${p.aktiv ? "" : `
entlassen `}
+
`;
+ }
+
+ function verdrahteUebersicht(behaelter) {
+ const suchfeld = behaelter.querySelector("[data-suche]");
+ if (suchfeld) {
+ let warten = null;
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(() => {
+ z.suche = suchfeld.value.trim();
+ rendern(behaelter);
+ }, 260);
+ });
+ }
+
+ behaelter.querySelectorAll("[data-f]").forEach((feld) => {
+ feld.addEventListener("change", () => {
+ if (feld.dataset.f === "rolle") z.filterRolle = feld.value;
+ else z.filterAbteilung = feld.value;
+ rendern(behaelter);
+ });
+ });
+
+ const umschalter = behaelter.querySelector("[data-ehemalige]");
+ if (umschalter) umschalter.addEventListener("click", () => {
+ z.ehemalige = !z.ehemalige;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-oeffne]").forEach((el) => {
+ el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne));
+ });
+
+ const neu = behaelter.querySelector("[data-neu]");
+ if (neu) neu.addEventListener("click", () => einstellungsFenster(behaelter));
+ }
+
+ /* ---- Personalakte -------------------------------------------------- */
+
+ function akte(behaelter, d) {
+ const p = d.personal;
+
+ // Rechte-Matrix nach Gruppen
+ const rechteBloecke = (d.rechte || []).map((gruppe) => {
+ const zeilen = gruppe.eintraege.map((r) => {
+ const h = herkunftText[r.herkunft] || herkunftText.keins;
+ const aktiv = r.herkunft === "rolle" || r.herkunft === "zusaetzlich";
+
+ // Umschalten nur, wenn der Betrachter den Datensatz verwalten darf
+ const bedienbar = d.darfVerwalten && d.meineRechte.rechte;
+ const gesperrt = !bedienbar || (!r.vergebbar && !aktiv);
+
+ return `
+
+ ${sicher(r.name)}
+ ${sicher(r.id)}
+
+
${h.text}
+
+ ${["rolle", "zusaetzlich", "entzogen"].map((modus) => {
+ const gewaehlt =
+ (modus === "rolle" && (r.herkunft === "rolle" || r.herkunft === "keins")) ||
+ modus === r.herkunft;
+ const beschriftung =
+ modus === "rolle" ? "Rolle" : modus === "zusaetzlich" ? "+" : "-";
+ const titel =
+ modus === "rolle" ? "Es gilt das Rollenpaket"
+ : modus === "zusaetzlich" ? "Zusätzlich gewähren"
+ : "Einzeln entziehen";
+ return `
+ ${beschriftung} `;
+ }).join("")}
+
+
`;
+ }).join("");
+
+ return `
+
${sicher(gruppe.gruppe)}
+ ${zeilen}
+
`;
+ }).join("");
+
+ // Letzte Dienste
+ const dienste = (d.dienste || []).map((s) =>
+ `
+
${sicher(abteilungName(s.abteilung))}
+
+
${zeit(s.beginn)}
+
${s.ende ? "bis " + zeit(s.ende) : "läuft noch"}
+
+
${dauer(s.sekunden || 0)}
+
`).join("");
+
+ behaelter.innerHTML = `
+
+
+
${sicher(p.name)}
+
${sicher(p.dienstnummer)} · ${sicher(p.rolleName)}
+ · ${sicher(abteilungName(p.abteilung))}
+ ${p.aktiv ? "" : " · entlassen "}
+
+
← Übersicht
+
+
+ ${!d.darfVerwalten ? `
+
+
🔒
+
${sicher(d.sperrgrund || "Dieser Datensatz ist für dich schreibgeschützt.")}
+ Du siehst ihn nur.
+
` : ""}
+
+
+
${dauer(d.gesamtzeit || 0)}
+ Dienstzeit gesamt
+
${zeit(p.eingestellt)}
+ eingestellt am
+
${sicher(p.geburt)}
+ geboren
+
+
+ ${d.darfVerwalten ? `
+
+
Maßnahmen
+
+ ${d.meineRechte.rolle ? `
+ Dienstgrad ändern
+ Abteilung ändern
+ Notiz bearbeiten ` : ""}
+ ${d.meineRechte.einstellen && p.aktiv
+ ? `Entlassen ` : ""}
+
+ ${p.notiz ? `
${sicher(p.notiz)}
` : ""}
+
` : ""}
+
+
+
+ Rechte
+ „Rolle" folgt dem Dienstgrad — + und -
+ weichen bewusst davon ab.
+
+
${rechteBloecke}
+
+
+
+
Letzte Dienste
+
${dienste || `
Noch kein Dienst erfasst.
`}
+
+ `;
+
+ verdrahteAkte(behaelter, d);
+ }
+
+ function verdrahteAkte(behaelter, d) {
+ const p = d.personal;
+
+ behaelter.querySelector("[data-zurueck]").addEventListener("click", () => {
+ z.offen = null;
+ rendern(behaelter);
+ });
+
+ behaelter.querySelectorAll("[data-recht]").forEach((knopf) => {
+ knopf.addEventListener("click", () =>
+ rechtUmschalten(behaelter, p, knopf.dataset.recht, knopf.dataset.modus));
+ });
+
+ const rolle = behaelter.querySelector("[data-rolle]");
+ if (rolle) rolle.addEventListener("click", () => rollenFenster(behaelter, d));
+
+ const abteilung = behaelter.querySelector("[data-abteilung]");
+ if (abteilung) abteilung.addEventListener("click", () => abteilungsFenster(behaelter, d));
+
+ const notiz = behaelter.querySelector("[data-notiz]");
+ if (notiz) notiz.addEventListener("click", () => notizFenster(behaelter, d));
+
+ const entlassen = behaelter.querySelector("[data-entlassen]");
+ if (entlassen) entlassen.addEventListener("click", () => entlassungsFenster(behaelter, d));
+ }
+
+ /* ---- Rechte umschalten -------------------------------------------- */
+
+ async function rechtUmschalten(behaelter, person, recht, modus) {
+ // Ein einzelnes Recht ist eine kleine, aber bewusste Entscheidung
+ const beschreibung = modus === "rolle"
+ ? "Für dieses Recht gilt künftig wieder das Paket des Dienstgrads."
+ : modus === "zusaetzlich"
+ ? "Dieses Recht wird zusätzlich zum Dienstgrad gewährt."
+ : "Dieses Recht wird trotz Dienstgrad einzeln entzogen.";
+
+ bestaetigen({
+ titel: "Recht ändern",
+ inhalt: `${sicher(person.name)} · ${sicher(recht)}
+ ${beschreibung}
+ Vermerk (freiwillig)
+
`,
+ bestaetigenText: "Recht setzen",
+ gefahr: modus === "entzogen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("personal", "rechtSetzen", {
+ charakterId: person.charakterId, recht, modus,
+ vermerk: el.querySelector("[name=vermerk]").value.trim(),
+ });
+ zu();
+ meldung("Recht geändert.", "gut");
+ oeffne(behaelter, person.charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Dienstgrad, Abteilung, Notiz ---------------------------------- */
+
+ function rollenFenster(behaelter, d) {
+ const p = d.personal;
+
+ bestaetigen({
+ titel: "Dienstgrad ändern",
+ inhalt: `
+ Aktuell: ${sicher(p.rolleName)}
+ Neuer Dienstgrad
+ ${rollenFeld(p.rolle, d.meineStufe)}
+
Rollen auf oder über deinem eigenen Rang
+ stehen nicht zur Wahl.
+ Vermerk (Pflicht)
+
`,
+ bestaetigenText: "Dienstgrad setzen",
+ bestaetigt: async (el, zu) => {
+ const vermerk = el.querySelector("[name=vermerk]").value.trim();
+ if (vermerk.length < 3) return meldung("Bitte einen Vermerk angeben.", "fehler");
+ try {
+ await aufruf("personal", "rolleAendern", {
+ charakterId: p.charakterId,
+ rolle: el.querySelector("[name=rolle]").value,
+ vermerk,
+ });
+ zu();
+ meldung("Dienstgrad geändert.", "gut");
+ oeffne(behaelter, p.charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function abteilungsFenster(behaelter, d) {
+ const p = d.personal;
+
+ bestaetigen({
+ titel: "Abteilung ändern",
+ inhalt: `Abteilung
+ ${abteilungsFeld(p.abteilung)}
`,
+ bestaetigenText: "Versetzen",
+ bestaetigt: async (el, zu) => {
+ try {
+ await aufruf("personal", "abteilungAendern", {
+ charakterId: p.charakterId,
+ abteilung: el.querySelector("[name=abteilung]").value,
+ });
+ zu();
+ meldung("Abteilung geändert.", "gut");
+ oeffne(behaelter, p.charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ function notizFenster(behaelter, d) {
+ const p = d.personal;
+
+ fenster({
+ titel: "Personalnotiz",
+ inhalt: `Notiz (max. 255 Zeichen)
+
`,
+ knoepfe: [
+ { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
+ { text: "Speichern", klick: async (el, zu) => {
+ try {
+ await aufruf("personal", "notiz", {
+ charakterId: p.charakterId,
+ notiz: el.querySelector("[name=notiz]").value.trim(),
+ });
+ zu();
+ meldung("Notiz gespeichert.", "gut");
+ oeffne(behaelter, p.charakterId);
+ } catch (f) { meldung(f.message, "fehler"); }
+ } },
+ ],
+ });
+ }
+
+ function entlassungsFenster(behaelter, d) {
+ const p = d.personal;
+
+ bestaetigen({
+ titel: "Beamten entlassen",
+ gefahr: true,
+ inhalt: `
+ ${sicher(p.name)} (${sicher(p.dienstnummer)}) verliert sofort allen Zugriff
+ und wird aus einem laufenden Dienst abgemeldet.
+ Der Personaldatensatz bleibt erhalten und kann später
+ wieder aktiviert werden — gelöscht wird nichts.
+ Begründung (Pflicht)
+
`,
+ bestaetigenText: "Entlassen",
+ bestaetigt: async (el, zu) => {
+ const grund = el.querySelector("[name=grund]").value.trim();
+ if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler");
+ try {
+ await aufruf("personal", "entlassen", { charakterId: p.charakterId, grund });
+ zu();
+ meldung("Beamter entlassen.", "gut");
+ z.offen = null;
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ });
+ }
+
+ /* ---- Einstellung ---------------------------------------------------- */
+
+ async function einstellungsFenster(behaelter) {
+ let gewaehlt = null; // ausgewählte Person aus der Suche
+
+ let vorschlag = "";
+ try {
+ const antwort = await aufruf("personal", "nummerVorschlag", {});
+ vorschlag = antwort.dienstnummer;
+ } catch (f) { /* Vorschlag ist optional */ }
+
+ const dialog = bestaetigen({
+ titel: "Beamten einstellen",
+ inhalt: `
+ Person suchen
+
+
+
Mindestens zwei Zeichen eingeben.
+
+ `,
+ bestaetigenText: "Einstellen",
+ bestaetigt: async (el, zu) => {
+ if (!gewaehlt) return meldung("Bitte zuerst eine Person auswählen.", "fehler");
+ try {
+ const antwort = await aufruf("personal", "einstellen", {
+ charakterId: gewaehlt.id,
+ dienstnummer: el.querySelector("[name=dienstnummer]").value.trim(),
+ rolle: el.querySelector("[name=rolle]").value,
+ abteilung: el.querySelector("[name=abteilung]").value,
+ notiz: el.querySelector("[name=notiz]").value.trim(),
+ });
+ zu();
+ meldung("Eingestellt als " + antwort.dienstnummer + ".", "gut");
+ rendern(behaelter);
+ } catch (f) { meldung(f.message, "fehler"); }
+ },
+ nachOeffnen: (hg) => {
+ // Auswahlfelder erst hier einsetzen, sie brauchen z.daten
+ hg.querySelector("[data-rollenfeld]").innerHTML =
+ rollenFeld("anwaerter", z.daten.meineStufe);
+ hg.querySelector("[data-abteilungsfeld]").innerHTML =
+ abteilungsFeld("streife");
+
+ const suchfeld = hg.querySelector("[name=suche]");
+ const trefferliste = hg.querySelector("[data-treffer]");
+ const formular = hg.querySelector("[data-formular]");
+ let warten = null;
+
+ suchfeld.addEventListener("input", () => {
+ clearTimeout(warten);
+ warten = setTimeout(async () => {
+ const begriff = suchfeld.value.trim();
+ if (begriff.length < 2) {
+ trefferliste.innerHTML =
+ `Mindestens zwei Zeichen eingeben.
`;
+ return;
+ }
+ try {
+ const antwort = await aufruf("personal", "personSuche", { begriff });
+ const treffer = antwort.treffer || [];
+ trefferliste.innerHTML = treffer.length
+ ? treffer.map((t) => `
+
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(String(t.dob).slice(0, 10))}
+
+ ${t.dienstnummer && t.aktiv
+ ? `
bereits im Dienst `
+ : t.dienstnummer
+ ? `
ehemals ${sicher(t.dienstnummer)} `
+ : ""}
+
`).join("")
+ : `Keine Person gefunden.
`;
+
+ trefferliste.querySelectorAll("[data-person]").forEach((el) => {
+ el.addEventListener("click", () => {
+ const person = treffer.find((t) => t.id === +el.dataset.person);
+ if (person && person.dienstnummer && person.aktiv) {
+ return meldung("Diese Person ist bereits im Personalbestand.", "fehler");
+ }
+ gewaehlt = person;
+ trefferliste.querySelectorAll("[data-person]")
+ .forEach((zeile) => zeile.classList.remove("gewaehlt"));
+ el.classList.add("gewaehlt");
+ formular.style.display = "";
+ });
+ });
+ } catch (f) { meldung(f.message, "fehler"); }
+ }, 260);
+ });
+ },
+ });
+
+ return dialog;
+ }
+
+ /* ---- Laden ---------------------------------------------------------- */
+
+ async function oeffne(behaelter, charakterId) {
+ try {
+ const d = await aufruf("personal", "oeffnen", { charakterId });
+ z.offen = charakterId;
+ akte(behaelter, d);
+ } catch (f) { meldung(f.message, "fehler"); }
+ }
+
+ async function rendern(behaelter) {
+ try {
+ z.daten = await aufruf("personal", "liste", {
+ begriff: z.suche, rolle: z.filterRolle,
+ abteilung: z.filterAbteilung, ehemalige: z.ehemalige,
+ });
+
+ // Sprungziel aus einem anderen Modul, z. B. „Personalakte öffnen"
+ const ziel = App.zustand.modulZiel;
+ if (ziel && ziel.person) {
+ App.zustand.modulZiel = null;
+ return oeffne(behaelter, ziel.person);
+ }
+
+ if (z.offen) await oeffne(behaelter, z.offen);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Personal
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("personal", { rendern });
+
+ App.registriereSuche(async (begriff) => {
+ try {
+ const d = await aufruf("personal", "liste", { begriff });
+ return (d.personal || []).slice(0, 5).map((p) => ({
+ bereich: "Personal",
+ titel: p.firstname + " " + p.lastname,
+ unter: p.dienstnummer + " · " + p.rolleName,
+ }));
+ } catch (e) { return []; }
+ });
+})();
diff --git a/resources/[aether]/aether-police/html/js/module/statistik.js b/resources/[aether]/aether-police/html/js/module/statistik.js
new file mode 100644
index 0000000..57db86e
--- /dev/null
+++ b/resources/[aether]/aether-police/html/js/module/statistik.js
@@ -0,0 +1,373 @@
+/* =====================================================================
+ aether-police / Ansicht: Statistik
+
+ Zahlen über die Behörde, nicht über einzelne Beamte. Es gibt hier
+ bewusst keine Rangliste — jede Kennzahl beschreibt einen Vorgang,
+ keine Leistung.
+===================================================================== */
+"use strict";
+
+(() => {
+ const { sicher, aufruf, meldung, euro } = App;
+
+ const z = {
+ reiter: "uebersicht", // uebersicht | abdeckung | katalog
+ tage: 30,
+ daten: null,
+ };
+
+ /* ---- Bausteine ------------------------------------------------------ */
+
+ /** Runde Zahl mit Tausenderpunkt */
+ function zahl(wert) {
+ return Math.round(wert || 0).toLocaleString("de-DE");
+ }
+
+ /** Kennzahlkachel */
+ function kachel(wert, text, unter) {
+ return `
+ ${wert}
+ ${sicher(text)}
+ ${unter ? `${sicher(unter)} ` : ""}
+
`;
+ }
+
+ /** Waagerechtes Balkendiagramm aus einer Verteilung */
+ function verteilung(daten, leer) {
+ if (!daten || !daten.eintraege.length) {
+ return `${sicher(leer || "Keine Daten im Zeitraum.")}
`;
+ }
+
+ return `
+ ${daten.eintraege.map((e) => `
+
+
${sicher(e.name)}
+
+
${zahl(e.anzahl)}
+
${e.anteil} %
+
`).join("")}
+
`;
+ }
+
+ /** Säulendiagramm des Tagesverlaufs */
+ function verlauf(zeilen) {
+ if (!zeilen || !zeilen.length) {
+ return `Im Zeitraum wurde nichts erfasst.
`;
+ }
+
+ // Höchstwert bestimmt die Skalierung beider Reihen
+ let hoechst = 1;
+ zeilen.forEach((t) => {
+ hoechst = Math.max(hoechst, +t.einsaetze || 0, +t.anzeigen || 0);
+ });
+
+ return `
+
+ Einsätze
+ Anzeigen
+ Höchstwert: ${hoechst}
+
+
+ ${zeilen.map((t) => {
+ const einsaetze = +t.einsaetze || 0;
+ const anzeigen = +t.anzeigen || 0;
+ const tag = String(t.tag).slice(0, 10);
+ return `
+
+
+
+
+
${sicher(tag.slice(8))}
+
`;
+ }).join("")}
+
`;
+ }
+
+ function reiterleiste() {
+ const eintraege = [
+ { id: "uebersicht", text: "Überblick" },
+ { id: "abdeckung", text: "Dienstabdeckung" },
+ { id: "katalog", text: "Katalogauswertung" },
+ ];
+
+ const zeitraeume = (z.daten && z.daten.zeitraeume) || [7, 30, 90];
+
+ return `
+
+
+ ${eintraege.map((e) => `
+
+ ${sicher(e.text)} `).join("")}
+
+
+ ${zeitraeume.map((t) => `
+
+ ${t} Tage `).join("")}
+
+
`;
+ }
+
+ /* ---- Überblick -------------------------------------------------------- */
+
+ function uebersicht(behaelter) {
+ const d = z.daten;
+ const e = d.einsaetze, a = d.anzeigen, g = d.gewahrsam, v = d.vorgaenge, p = d.personal;
+
+ behaelter.innerHTML = `
+
+
+
Statistik
+
Auswertung der letzten ${d.tage} Tage. Alle Zahlen beschreiben Vorgänge
+ der Behörde — es gibt hier keine Bewertung einzelner Beamter.
+
+
+
+ ${reiterleiste()}
+
+
+ ${kachel(zahl(e.gesamt), "Einsätze", e.offen + " offen")}
+ ${kachel(zahl(a.gesamt), "Anzeigen", a.bestaetigt + " bestätigt")}
+ ${kachel(zahl(g.einweisungen), "Einweisungen", g.laufend + " laufend")}
+ ${kachel(zahl(p.dienstStunden), "Dienststunden", p.bestand + " Beamte im Bestand")}
+
+
+
+
Verlauf
+ ${verlauf(d.verlauf)}
+
+
+
+
+
Einsätze nach Kategorie
+ ${verteilung(e.nachKategorie)}
+
+ Ein Einsatz bleibt im Schnitt
+ ${zahl(e.laufzeitMinuten)} Minuten offen.
+
+
+
+
+
Einsätze nach Priorität
+ ${verteilung(e.nachPrioritaet)}
+
+
+
+
+
+
Anzeigen
+
+
Bestätigt ${zahl(a.bestaetigt)}
+
Noch Entwurf ${zahl(a.entwurf)}
+
Ø festgelegte Haft ${zahl(a.haftDurchschnitt)} Min
+
Ø festgelegte Geldstrafe ${euro(a.geldDurchschnitt)}
+
+
+ In ${zahl(a.abweichungen)} von ${zahl(a.bestaetigt)} bestätigten Anzeigen
+ wich die Entscheidung vom Katalogvorschlag ab. Das ist eine Aussage über den
+ Katalog — nicht über die Beamten.
+
+
+
+
+
Gewahrsam
+
+
Einweisungen ${zahl(g.einweisungen)}
+
Laufend ${zahl(g.laufend)}
+
Ø festgelegte Dauer ${zahl(g.haftDurchschnitt)} Min
+
Vorzeitig entlassen ${zahl(g.vorzeitig)}
+
+
+ Zwischen Fristablauf und tatsächlicher Entlassung lagen im Schnitt
+ ${zahl(g.verzugMinuten)} Minuten . Das System entlässt niemanden von
+ selbst — der Wert zeigt, wie schnell jemand erreichbar war.
+
+
+
+
+
+
+
Vorgänge
+
+
Akten angelegt ${zahl(v.akten)}
+
Akten offen ${zahl(v.aktenOffen)}
+
Berichte ${zahl(v.berichte)}
+
Berichte im Entwurf ${zahl(v.berichteEntwurf)}
+
Fahndungen ${zahl(v.fahndungen)}
+
Fahndungen aktiv ${zahl(v.fahndungenAktiv)}
+
Beweise erfasst ${zahl(v.beweise)}
+
Asservate eingelagert ${zahl(v.asservate)}
+
+
+
+
+
Akten nach Art
+ ${verteilung(v.aktenNachTyp)}
+
+
+
+
+
+ Personal nach Abteilung
+ ${p.imDienst} gerade im Dienst
+
+ ${verteilung(p.nachAbteilung)}
+
+ `;
+
+ verdrahte(behaelter);
+ }
+
+ /* ---- Dienstabdeckung --------------------------------------------------- */
+
+ function abdeckung(behaelter) {
+ const d = z.daten;
+ const hoechst = Math.max(d.hoechstwert, 0.1);
+
+ // Wo ist die Besetzung am dünnsten?
+ const schwach = [...d.stunden].sort((a, b) => a.mittel - b.mittel).slice(0, 3);
+
+ behaelter.innerHTML = `
+
+
+
Dienstabdeckung
+
Mittlere Besetzung je Tageszeit über ${d.tage} Tage — ein Planungswert.
+ Er sagt nichts darüber, wer Dienst hatte.
+
+
+
+ ${reiterleiste()}
+
+
+ ${kachel(zahl(d.schichten), "Schichten im Zeitraum")}
+ ${kachel(d.hoechstwert.toFixed(2), "stärkste Stunde", "gleichzeitig im Dienst")}
+ ${kachel(schwach.map((s) => s.stunde + " Uhr").join(", "), "dünnste Stunden")}
+
+
+
+
+ Besetzung nach Tageszeit
+ Durchschnittlich gleichzeitig im Dienst
+
+
+ ${d.stunden.map((s) => `
+
+
+
+
+
${String(s.stunde).padStart(2, "0")}
+
`).join("")}
+
+
+
+
+
Schichten nach Abteilung
+ ${verteilung(d.nachAbteilung)}
+
+ `;
+
+ verdrahte(behaelter);
+ }
+
+ /* ---- Katalogauswertung -------------------------------------------------- */
+
+ function katalog(behaelter) {
+ const d = z.daten;
+
+ behaelter.innerHTML = `
+
+
+
Katalogauswertung
+
Welche Delikte kommen vor und wie oft weicht die Entscheidung vom
+ Vorschlag ab. Eine hohe Abweichungsquote heißt: der Katalogwert passt
+ nicht — nicht, dass jemand falsch entschieden hat.
+
+
+
+ ${reiterleiste()}
+
+
+ ${kachel(zahl(d.anzeigen), "ausgewertete Anzeigen", "letzte " + d.tage + " Tage")}
+ ${kachel(zahl(d.delikte.length), "verwendete Delikte")}
+ ${kachel(zahl(d.ungenutzt.length), "nie verwendet", "von " + d.katalogGesamt + " im Katalog")}
+
+
+
+
Häufigste Delikte
+ ${d.delikte.length ? `
+
+ ${d.delikte.map((delikt) => `
+
+
${sicher(delikt.kennung)}
+
+
${sicher(delikt.name)}
+
${sicher(delikt.paragraph || "")}
+
+
${zahl(delikt.anzahl)}×
+
0 ? "info" : "neutral"}"
+ title="Anteil der Anzeigen mit diesem Delikt, in denen vom Vorschlag abgewichen wurde">
+ ${delikt.abweichungsAnteil} % abweichend
+
`).join("")}
+
`
+ : `
Im Zeitraum wurde keine Anzeige bestätigt.
`}
+
+
+
+
+ Im Zeitraum nie verwendet
+ Kandidaten zum Aufräumen des Katalogs
+
+ ${d.ungenutzt.length ? `
+
+ ${d.ungenutzt.map((delikt) => `
+
+ ${sicher(delikt.kennung)} · ${sicher(delikt.bezeichnung)} `).join("")}
+
`
+ : `
Jedes Delikt des Katalogs kam vor.
`}
+
+ `;
+
+ verdrahte(behaelter);
+ }
+
+ /* ---- Verdrahtung --------------------------------------------------------- */
+
+ function verdrahte(behaelter) {
+ behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.reiter = el.dataset.reiter;
+ rendern(behaelter);
+ });
+ });
+
+ behaelter.querySelectorAll("[data-tage]").forEach((el) => {
+ el.addEventListener("click", () => {
+ z.tage = +el.dataset.tage;
+ rendern(behaelter);
+ });
+ });
+ }
+
+ /* ---- Laden ---------------------------------------------------------------- */
+
+ async function rendern(behaelter) {
+ const aktionen = {
+ uebersicht: "uebersicht", abdeckung: "abdeckung", katalog: "katalog",
+ };
+
+ try {
+ z.daten = await aufruf("statistik", aktionen[z.reiter], { tage: z.tage });
+ // Der Server bestimmt den gültigen Zeitraum
+ z.tage = z.daten.tage;
+
+ if (z.reiter === "abdeckung") abdeckung(behaelter);
+ else if (z.reiter === "katalog") katalog(behaelter);
+ else uebersicht(behaelter);
+ } catch (f) {
+ behaelter.innerHTML = `Statistik
+
Konnte nicht geladen werden: ${sicher(f.message)}
`;
+ }
+ }
+
+ App.registriereAnsicht("statistik", { rendern });
+})();
diff --git a/resources/[aether]/aether-police/server/dienst.lua b/resources/[aether]/aether-police/server/dienst.lua
new file mode 100644
index 0000000..b303019
--- /dev/null
+++ b/resources/[aether]/aether-police/server/dienst.lua
@@ -0,0 +1,243 @@
+-- =====================================================================
+-- aether-police — Dienstsystem
+--
+-- Speichert ausschliesslich Statusinformationen. Es gibt keine
+-- automatische Uniform und keine automatische Fahrzeugausgabe.
+-- =====================================================================
+
+Dienst = {}
+
+-- Beamte im Dienst: [quelle] = beamter
+local imDienst = {}
+-- Wer hält die Leitstelle? (Server-ID oder nil)
+local leitstelle = nil
+
+local Core = exports['aether-core']
+
+-- ---------------------------------------------------------------------
+-- Zugriff
+-- ---------------------------------------------------------------------
+
+--- Liefert die Beamtendaten eines Spielers im Dienst
+--- @param quelle number Server-ID
+--- @return table|nil
+function Dienst.Hole(quelle)
+ return imDienst[quelle]
+end
+
+--- Liefert alle Beamten im Dienst
+--- @return table [quelle] = beamter
+function Dienst.AlleImDienst()
+ return imDienst
+end
+
+--- Sucht die Server-ID zu einer Charakter-ID
+--- @param charakterId number Charakter
+--- @return number|nil
+function Dienst.QuelleVonCharakter(charakterId)
+ for quelle, beamter in pairs(imDienst) do
+ if beamter.charakterId == charakterId then return quelle end
+ end
+ return nil
+end
+
+--- Wer hält die Leitstelle?
+--- @return number|nil Server-ID
+function Dienst.Leitstelle()
+ return leitstelle
+end
+
+-- ---------------------------------------------------------------------
+-- Personalakte laden
+-- ---------------------------------------------------------------------
+
+--- Lädt den Personaldatensatz eines Charakters und baut daraus den Beamten
+--- @param quelle number Server-ID
+--- @param charakterId number Charakter
+--- @return table|nil Beamter, string|nil Fehler
+function Dienst.LadeBeamten(quelle, charakterId)
+ local zeilen = MySQL.query.await(
+ 'SELECT * FROM police_personal WHERE `character` = ? AND aktiv = 1', { charakterId })
+
+ if not zeilen or not zeilen[1] then
+ return nil, 'Du bist nicht im Personalbestand der Behörde.'
+ end
+
+ local personal = zeilen[1]
+ local person = Core:HoleSpielerInfo(quelle)
+
+ local rolle = Polizei.HoleRolle(personal.rolle)
+
+ return {
+ quelle = quelle,
+ charakterId = charakterId,
+ name = ((person and person.vorname or '') .. ' ' .. (person and person.nachname or '')),
+ dienstnummer = personal.dienstnummer,
+ rolle = personal.rolle,
+ rolleName = rolle and rolle.name or personal.rolle,
+ stufe = Polizei.Stufe(personal.rolle),
+ abteilung = personal.abteilung,
+ rechte = Rechte.Berechne(personal),
+ status = 'verfuegbar',
+ seit = os.time(),
+ dienstId = nil,
+ }
+end
+
+-- ---------------------------------------------------------------------
+-- Dienst antreten und beenden
+-- ---------------------------------------------------------------------
+
+--- Meldet einen Beamten zum Dienst an
+--- @param quelle number Server-ID
+--- @return table|nil Beamter, string|nil Fehler
+function Dienst.Antreten(quelle)
+ if imDienst[quelle] then return imDienst[quelle] end
+
+ local charakterId = Core:HoleCharakterId(quelle)
+ if not charakterId then return nil, 'Kein Charakter geladen.' end
+
+ local beamter, fehler = Dienst.LadeBeamten(quelle, charakterId)
+ if not beamter then return nil, fehler end
+
+ if not Rechte.Hat(beamter, 'dienst.antreten') then
+ return nil, 'Dir fehlt die Berechtigung, den Dienst anzutreten.'
+ end
+
+ -- Dienstzeit protokollieren
+ beamter.dienstId = MySQL.insert.await(
+ 'INSERT INTO police_dienst (`character`, abteilung) VALUES (?, ?)',
+ { charakterId, beamter.abteilung })
+
+ imDienst[quelle] = beamter
+
+ Protokoll.Schreibe(beamter, 'dienst', 'Dienst angetreten')
+ Kern.Loese('dienst.angetreten', quelle, beamter)
+ Dienst.Verteile()
+
+ return beamter
+end
+
+--- Meldet einen Beamten vom Dienst ab
+--- @param quelle number Server-ID
+function Dienst.Beenden(quelle)
+ local beamter = imDienst[quelle]
+ if not beamter then return end
+
+ if beamter.dienstId then
+ MySQL.update('UPDATE police_dienst SET ende = NOW(), sekunden = ? WHERE id = ?',
+ { os.time() - beamter.seit, beamter.dienstId })
+ end
+
+ imDienst[quelle] = nil
+
+ -- Leitstelle freigeben, wenn der Inhaber geht
+ if leitstelle == quelle then
+ leitstelle = nil
+ end
+
+ Protokoll.Schreibe(beamter, 'dienst', 'Dienst beendet')
+ Kern.Loese('dienst.beendet', quelle, beamter)
+ Dienst.Verteile()
+end
+
+--- Setzt den Status eines Beamten (verfügbar, Einsatz, Pause ...)
+--- @param quelle number Server-ID
+--- @param status string Statuskennung
+--- @return boolean
+function Dienst.SetzeStatus(quelle, status)
+ local beamter = imDienst[quelle]
+ if not beamter then return false end
+
+ local gueltig = false
+ for _, eintrag in ipairs(Polizei.Config.Dienststatus) do
+ if eintrag.id == status then gueltig = true break end
+ end
+ if not gueltig then return false end
+
+ beamter.status = status
+ Kern.Loese('dienst.status', quelle, beamter, status)
+ Dienst.Verteile()
+ return true
+end
+
+--- Übernimmt oder überträgt die Leitstelle
+--- @param quelle number Handelnder
+--- @param zielQuelle number|nil Ziel (nil = selbst übernehmen)
+--- @return boolean, string|nil
+function Dienst.SetzeLeitstelle(quelle, zielQuelle)
+ local beamter = imDienst[quelle]
+ if not beamter then return false end
+
+ if not Rechte.Hat(beamter, 'dienst.leitstelle') then
+ return false, 'Dir fehlt die Berechtigung für die Leitstelle.'
+ end
+
+ if zielQuelle and zielQuelle ~= quelle then
+ -- Zuweisen an andere setzt voraus, dass man über ihnen steht
+ local ziel = imDienst[zielQuelle]
+ if not ziel then return false, 'Diese Person ist nicht im Dienst.' end
+ if not Rechte.StehtUeber(beamter, ziel.rolle) then
+ return false, 'Du kannst die Leitstelle nur an rangniedrigere Beamte übertragen.'
+ end
+ leitstelle = zielQuelle
+ Protokoll.Schreibe(beamter, 'dienst', 'Leitstelle übertragen',
+ { typ = 'person', id = ziel.charakterId })
+ else
+ leitstelle = quelle
+ Protokoll.Schreibe(beamter, 'dienst', 'Leitstelle übernommen')
+ end
+
+ Kern.Loese('dienst.leitstelle', leitstelle)
+ Dienst.Verteile()
+ return true
+end
+
+-- ---------------------------------------------------------------------
+-- Verteilung an die Clients
+-- ---------------------------------------------------------------------
+
+--- Baut die Liste der Beamten im Dienst (für Dashboard und Karte)
+--- @return table
+function Dienst.BaueListe()
+ local liste = {}
+ for quelle, beamter in pairs(imDienst) do
+ local position = GetEntityCoords(GetPlayerPed(quelle))
+ liste[#liste + 1] = {
+ quelle = quelle,
+ charakterId = beamter.charakterId,
+ name = beamter.name,
+ dienstnummer = beamter.dienstnummer,
+ rolle = beamter.rolle,
+ rolleName = beamter.rolleName,
+ abteilung = beamter.abteilung,
+ status = beamter.status,
+ leitstelle = leitstelle == quelle,
+ seit = beamter.seit,
+ position = { x = position.x, y = position.y, z = position.z },
+ }
+ end
+ table.sort(liste, function(a, b) return a.dienstnummer < b.dienstnummer end)
+ return liste
+end
+
+--- Schickt die Dienstliste an alle Beamten im Dienst
+function Dienst.Verteile()
+ local liste = Dienst.BaueListe()
+ for quelle in pairs(imDienst) do
+ TriggerClientEvent('aether:police:dienstliste', quelle, liste)
+ end
+end
+
+-- Positionen regelmäßig auffrischen (für die Einsatzkarte)
+CreateThread(function()
+ while true do
+ Wait(5000)
+ if next(imDienst) then Dienst.Verteile() end
+ end
+end)
+
+-- Beim Verlassen sauber abmelden
+AddEventHandler('playerDropped', function()
+ Dienst.Beenden(source)
+end)
diff --git a/resources/[aether]/aether-police/server/hinweise.lua b/resources/[aether]/aether-police/server/hinweise.lua
new file mode 100644
index 0000000..b97feb3
--- /dev/null
+++ b/resources/[aether]/aether-police/server/hinweise.lua
@@ -0,0 +1,219 @@
+-- =====================================================================
+-- aether-police — Benachrichtigungen und Fristenüberwachung
+--
+-- WICHTIG: Dieses Modul ERINNERT ausschliesslich. Es entlässt niemanden,
+-- schliesst nichts ab und ergreift keine Maßnahme. Jede Meldung endet
+-- in einer Aufforderung an einen Beamten, selbst zu entscheiden.
+-- =====================================================================
+
+Hinweise = {}
+
+-- Bereits gemeldete Fristen, damit nicht im Minutentakt gewarnt wird:
+-- [gewahrsamId] = { [stufe] = true }
+local gemeldet = {}
+
+--- Erstellt einen Hinweis
+--- @param ziel table { charakterId? } — ohne charakterId geht er an alle im Dienst
+--- @param daten table { kategorie, prioritaet?, titel, text?, zielTyp?, zielId? }
+function Hinweise.Erstelle(ziel, daten)
+ local id = MySQL.insert.await([[
+ INSERT INTO police_hinweis
+ (`character`, kategorie, prioritaet, titel, text, ziel_typ, ziel_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ ziel and ziel.charakterId or nil,
+ daten.kategorie or 'system',
+ daten.prioritaet or 'normal',
+ daten.titel,
+ daten.text,
+ daten.zielTyp,
+ daten.zielId and tostring(daten.zielId) or nil,
+ })
+
+ -- Betroffene Clients sofort informieren.
+ -- Die Feldnamen entsprechen bewusst denen aus Hinweise.Lade, damit ein
+ -- frisch gemeldeter Hinweis genauso anklickbar ist wie ein geladener.
+ local paket = {
+ id = id,
+ kategorie = daten.kategorie or 'system',
+ prioritaet = daten.prioritaet or 'normal',
+ titel = daten.titel,
+ text = daten.text,
+ zielTyp = daten.zielTyp,
+ zielId = daten.zielId,
+ }
+
+ if ziel and ziel.charakterId then
+ local quelle = Dienst.QuelleVonCharakter(ziel.charakterId)
+ if quelle then
+ TriggerClientEvent('aether:police:hinweis', quelle, paket)
+ end
+ else
+ for quelle in pairs(Dienst.AlleImDienst()) do
+ TriggerClientEvent('aether:police:hinweis', quelle, paket)
+ end
+ end
+end
+
+--- Lädt ungelesene Hinweise eines Beamten
+--- @param charakterId number Charakter
+--- @param limit number|nil Anzahl
+--- @return table
+function Hinweise.Lade(charakterId, limit)
+ local zeilen = MySQL.query.await([[
+ SELECT * FROM police_hinweis
+ WHERE (`character` = ? OR `character` IS NULL)
+ ORDER BY id DESC LIMIT ?
+ ]], { charakterId, limit or 30 }) or {}
+
+ -- Sprungziel auf die Schreibweise der Oberfläche bringen
+ for _, eintrag in ipairs(zeilen) do
+ eintrag.zielTyp = eintrag.ziel_typ
+ eintrag.zielId = eintrag.ziel_id
+ eintrag.ziel_typ, eintrag.ziel_id = nil, nil
+ end
+
+ return zeilen
+end
+
+--- Markiert einen Hinweis als gelesen
+function Hinweise.MarkiereGelesen(charakterId, hinweisId)
+ MySQL.update('UPDATE police_hinweis SET gelesen = 1 WHERE id = ? AND (`character` = ? OR `character` IS NULL)',
+ { hinweisId, charakterId })
+end
+
+-- ---------------------------------------------------------------------
+-- Fristenüberwachung
+--
+-- Läuft im Hintergrund und erzeugt NUR Erinnerungen. Ein Gewahrsam
+-- endet niemals von selbst — er bleibt offen, bis ein Beamter die
+-- Entlassung ausführt.
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ Wait(5000)
+
+ while true do
+ Wait(Polizei.Config.Erinnerungen.pruefIntervall)
+
+ -- --- Gewahrsam: auslaufende Fristen ---
+ local offene = MySQL.query.await([[
+ SELECT g.id, g.character, g.faellig_am, g.grund,
+ TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest,
+ c.firstname, c.lastname
+ FROM police_gewahrsam g
+ JOIN aether_characters c ON c.id = g.character
+ WHERE g.entlassen_am IS NULL
+ ]]) or {}
+
+ for _, eintrag in ipairs(offene) do
+ local rest = tonumber(eintrag.rest) or 0
+ gemeldet[eintrag.id] = gemeldet[eintrag.id] or {}
+
+ for _, stufe in ipairs(Polizei.Config.Erinnerungen.gewahrsamStufen) do
+ -- Erinnern, sobald die Restzeit die Stufe erreicht oder unterschreitet
+ if rest <= stufe and not gemeldet[eintrag.id][stufe] then
+ gemeldet[eintrag.id][stufe] = true
+
+ local name = eintrag.firstname .. ' ' .. eintrag.lastname
+ local titel, text, prio
+
+ if stufe > 0 then
+ titel = ('Gewahrsam: noch %d Minuten'):format(stufe)
+ text = ('%s — Haftzeit endet bald. Entlassung muss manuell erfolgen.')
+ :format(name)
+ prio = 'normal'
+ else
+ titel = 'Haftzeit abgelaufen'
+ text = ('%s — die festgelegte Haftzeit ist abgelaufen. Bitte über die Entlassung entscheiden.')
+ :format(name)
+ prio = 'hoch'
+ end
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'gewahrsam', prioritaet = prio,
+ titel = titel, text = text,
+ zielTyp = 'gewahrsam', zielId = eintrag.id,
+ })
+ break -- pro Durchlauf nur eine Stufe melden
+ end
+ end
+ end
+
+ -- --- Berichte, die zu lange im Entwurf liegen ---
+ local entwuerfe = MySQL.query.await([[
+ SELECT b.id, b.nummer, b.titel, b.erstellt_von
+ FROM police_bericht b
+ WHERE b.status = 'entwurf'
+ AND b.erstellt_am < DATE_SUB(NOW(), INTERVAL ? MINUTE)
+ ]], { Polizei.Config.Erinnerungen.berichtOffenNach }) or {}
+
+ for _, bericht in ipairs(entwuerfe) do
+ -- Nur einmal je Bericht erinnern
+ local schluessel = 'bericht_' .. bericht.id
+ if not gemeldet[schluessel] then
+ gemeldet[schluessel] = true
+ Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, {
+ kategorie = 'bericht', prioritaet = 'normal',
+ titel = 'Offener Bericht',
+ text = ('%s „%s" liegt noch als Entwurf vor.'):format(bericht.nummer, bericht.titel),
+ zielTyp = 'bericht', zielId = bericht.id,
+ })
+ end
+ end
+
+ -- --- Aushänge mit Kenntnisnahmefrist ---
+ -- Das System erinnert nur; es bestätigt keine Kenntnisnahme selbst.
+ local fristen = MySQL.query.await([[
+ SELECT a.id, a.titel, a.abteilung,
+ TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS rest
+ FROM police_aushang a
+ WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 AND a.frist IS NOT NULL
+ AND a.frist > DATE_SUB(NOW(), INTERVAL 1 DAY)
+ ]]) or {}
+
+ for _, aushang in ipairs(fristen) do
+ local rest = tonumber(aushang.rest) or 0
+ local schluessel = 'aushang_' .. aushang.id
+
+ -- Einmal vorwarnen, einmal beim Ablauf
+ local stufe = rest <= 0 and 'abgelaufen'
+ or (rest <= Polizei.Config.Erinnerungen.aushangVorwarnung and 'bald')
+ or nil
+
+ if stufe then
+ gemeldet[schluessel] = gemeldet[schluessel] or {}
+ if not gemeldet[schluessel][stufe] then
+ gemeldet[schluessel][stufe] = true
+
+ -- Nur Beamte ansprechen, die noch nicht bestätigt haben
+ for quelle, beamter in pairs(Dienst.AlleImDienst()) do
+ local passt = aushang.abteilung == nil
+ or aushang.abteilung == beamter.abteilung
+ if passt then
+ local schon = MySQL.scalar.await([[
+ SELECT 1 FROM police_aushang_kenntnis
+ WHERE aushang = ? AND `character` = ?
+ ]], { aushang.id, beamter.charakterId })
+
+ if not schon then
+ Hinweise.Erstelle({ charakterId = beamter.charakterId }, {
+ kategorie = 'system',
+ prioritaet = stufe == 'abgelaufen' and 'hoch' or 'normal',
+ titel = stufe == 'abgelaufen'
+ and 'Kenntnisnahme überfällig' or 'Kenntnisnahme offen',
+ text = ('„%s" — bitte zur Kenntnis nehmen.'):format(aushang.titel),
+ zielTyp = 'aushang', zielId = aushang.id,
+ })
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end)
+
+--- Räumt gemeldete Fristen auf, wenn ein Gewahrsam abgeschlossen wurde
+Kern.Abonniere('gewahrsam.entlassen', function(gewahrsamId)
+ gemeldet[gewahrsamId] = nil
+end)
diff --git a/resources/[aether]/aether-police/server/kern.lua b/resources/[aether]/aether-police/server/kern.lua
new file mode 100644
index 0000000..fcc9b98
--- /dev/null
+++ b/resources/[aether]/aether-police/server/kern.lua
@@ -0,0 +1,227 @@
+-- =====================================================================
+-- aether-police — Kern
+--
+-- Der Kern kennt keine Fachlogik. Er stellt bereit:
+-- * eine Modul-Registry (Plugin-System)
+-- * einen Ereignisbus zwischen den Modulen
+-- * ein Handler-Register, an das die Oberfläche Anfragen schickt
+--
+-- Fachmodule registrieren sich selbst und können jederzeit ergänzt
+-- werden, ohne dass der Kern angefasst werden muss.
+-- =====================================================================
+
+Polizei = Polizei or {}
+Kern = {}
+
+-- Registrierte Fachmodule: [id] = { id, name, icon, recht, reihenfolge, aktiv }
+local module = {}
+-- Handler, die die Oberfläche aufrufen darf: [modulId][aktion] = funktion
+local handler = {}
+-- Abonnenten des Ereignisbusses: [ereignis] = { funktion, ... }
+local abonnenten = {}
+
+-- ---------------------------------------------------------------------
+-- Modul-Registry
+-- ---------------------------------------------------------------------
+
+--- Registriert ein Fachmodul.
+--- @param beschreibung table {
+--- id eindeutige Kennung
+--- name Anzeigename in der Seitenleiste
+--- icon Symbol
+--- recht Recht, das zum Öffnen nötig ist (nil = für alle)
+--- reihenfolge Sortierung in der Seitenleiste
+--- aktiv Standardmäßig aktiv?
+--- }
+function Kern.RegistriereModul(beschreibung)
+ if type(beschreibung) ~= 'table' or not beschreibung.id then
+ Aether.Fehler('Modul ohne Kennung kann nicht registriert werden.')
+ return false
+ end
+
+ if module[beschreibung.id] then
+ Aether.Fehler(('Modul "%s" ist bereits registriert.'):format(beschreibung.id))
+ return false
+ end
+
+ module[beschreibung.id] = {
+ id = beschreibung.id,
+ name = beschreibung.name or beschreibung.id,
+ icon = beschreibung.icon or '▪',
+ recht = beschreibung.recht,
+ reihenfolge = beschreibung.reihenfolge or 100,
+ aktiv = beschreibung.aktiv ~= false,
+ beschreibung = beschreibung.beschreibung,
+ }
+
+ handler[beschreibung.id] = handler[beschreibung.id] or {}
+ Aether.Debug(('Polizei-Modul registriert: %s'):format(beschreibung.id))
+ return true
+end
+
+--- Registriert einen Handler, den die Oberfläche aufrufen kann.
+--- @param modulId string Modul
+--- @param aktion string Name der Aktion
+--- @param recht string|nil Erforderliches Recht (nil = nur Modulrecht)
+--- @param funktion function fun(quelle, beamter, daten): ergebnis
+function Kern.RegistriereAktion(modulId, aktion, recht, funktion)
+ handler[modulId] = handler[modulId] or {}
+ handler[modulId][aktion] = { recht = recht, funktion = funktion }
+end
+
+--- Prüft das Öffnungsrecht eines Moduls.
+--- `recht` darf eine Kennung oder eine Liste sein — bei einer Liste
+--- genügt EIN Recht daraus. Das brauchen Module, die mehrere Bereiche
+--- mit je eigenem Recht unter einem Dach zusammenfassen.
+--- @param beamter table Beamtendaten
+--- @param recht string|table|nil Erforderliches Recht
+--- @return boolean
+function Kern.DarfOeffnen(beamter, recht)
+ if not recht then return true end
+
+ if type(recht) == 'table' then
+ for _, einzeln in ipairs(recht) do
+ if Rechte.Hat(beamter, einzeln) then return true end
+ end
+ return false
+ end
+
+ return Rechte.Hat(beamter, recht)
+end
+
+--- Liefert alle Module, die ein Beamter sehen darf
+--- @param beamter table Beamtendaten (mit Rechten)
+--- @return table Liste
+function Kern.SichtbareModule(beamter)
+ local liste = {}
+ for _, modul in pairs(module) do
+ if modul.aktiv and Kern.DarfOeffnen(beamter, modul.recht) then
+ liste[#liste + 1] = {
+ id = modul.id, name = modul.name, icon = modul.icon,
+ reihenfolge = modul.reihenfolge, beschreibung = modul.beschreibung,
+ }
+ end
+ end
+ table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end)
+ return liste
+end
+
+--- Liefert alle registrierten Module, auch die abgeschalteten.
+--- Nur für die Administration gedacht.
+--- @return table Liste
+function Kern.AlleModule()
+ local liste = {}
+ for _, modul in pairs(module) do
+ -- Wie viele Aktionen hängen an diesem Modul?
+ local aktionen = 0
+ for _ in pairs(handler[modul.id] or {}) do aktionen = aktionen + 1 end
+
+ liste[#liste + 1] = {
+ id = modul.id, name = modul.name, icon = modul.icon,
+ recht = modul.recht, reihenfolge = modul.reihenfolge,
+ beschreibung = modul.beschreibung,
+ aktiv = modul.aktiv, aktionen = aktionen,
+ }
+ end
+ table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end)
+ return liste
+end
+
+--- Gibt es dieses Modul?
+--- @param modulId string Modul
+--- @return boolean
+function Kern.KenntModul(modulId)
+ return module[modulId] ~= nil
+end
+
+--- Schaltet ein Modul an oder aus (Administration)
+--- @param modulId string Modul
+--- @param aktiv boolean Neuer Zustand
+function Kern.SetzeModulAktiv(modulId, aktiv)
+ if module[modulId] then
+ module[modulId].aktiv = aktiv and true or false
+ end
+end
+
+--- Führt eine Aktion aus — inklusive Rechteprüfung.
+--- Wird von der Oberfläche über ein einziges Netz-Event aufgerufen.
+--- @param quelle number Server-ID
+--- @param beamter table Beamtendaten
+--- @param modulId string Modul
+--- @param aktion string Aktion
+--- @param daten table Nutzdaten
+--- @return any Ergebnis, string|nil Fehler
+function Kern.FuehreAus(quelle, beamter, modulId, aktion, daten)
+ local modul = module[modulId]
+ if not modul or not modul.aktiv then
+ return nil, 'Dieses Modul ist nicht verfügbar.'
+ end
+
+ -- Recht auf das Modul selbst
+ if not Kern.DarfOeffnen(beamter, modul.recht) then
+ return nil, 'Dir fehlt die Berechtigung für diesen Bereich.'
+ end
+
+ local eintrag = handler[modulId] and handler[modulId][aktion]
+ if not eintrag then
+ return nil, 'Unbekannte Aktion.'
+ end
+
+ -- Zusätzliches Recht auf die einzelne Aktion
+ if eintrag.recht and not Rechte.Hat(beamter, eintrag.recht) then
+ return nil, 'Dir fehlt die Berechtigung für diese Aktion.'
+ end
+
+ local erfolg, ergebnis, fehler = pcall(eintrag.funktion, quelle, beamter, daten or {})
+ if not erfolg then
+ Aether.Fehler(('Fehler in %s/%s: %s'):format(modulId, aktion, tostring(ergebnis)))
+ return nil, 'Interner Fehler im Modul.'
+ end
+
+ return ergebnis, fehler
+end
+
+-- ---------------------------------------------------------------------
+-- Ereignisbus zwischen den Modulen
+-- ---------------------------------------------------------------------
+
+--- Abonniert ein internes Ereignis
+--- @param ereignis string Name
+--- @param funktion function Rückruf
+function Kern.Abonniere(ereignis, funktion)
+ abonnenten[ereignis] = abonnenten[ereignis] or {}
+ table.insert(abonnenten[ereignis], funktion)
+end
+
+--- Löst ein internes Ereignis aus.
+--- Fehler eines Abonnenten dürfen die anderen nicht mitreißen.
+--- @param ereignis string Name
+--- @param ... any Nutzdaten
+function Kern.Loese(ereignis, ...)
+ for _, funktion in ipairs(abonnenten[ereignis] or {}) do
+ local erfolg, fehler = pcall(funktion, ...)
+ if not erfolg then
+ Aether.Fehler(('Abonnent von "%s" schlug fehl: %s'):format(ereignis, tostring(fehler)))
+ end
+ end
+
+ -- Zusätzlich nach außen geben, damit fremde Ressourcen andocken können
+ TriggerEvent('aether:police:' .. ereignis, ...)
+end
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen für Fachmodule
+-- ---------------------------------------------------------------------
+
+--- Erzeugt eine fortlaufende Kennung, z. B. AZ-2026-0042
+--- @param praefix string Vorsilbe
+--- @param tabelle string Tabellenname
+--- @param spalte string Spalte mit der Kennung
+--- @return string
+function Kern.ErzeugeKennung(praefix, tabelle, spalte)
+ local jahr = os.date('%Y')
+ local anzahl = MySQL.scalar.await(
+ ('SELECT COUNT(*) FROM %s WHERE %s LIKE ?'):format(tabelle, spalte),
+ { praefix .. '-' .. jahr .. '-%' }) or 0
+ return ('%s-%s-%04d'):format(praefix, jahr, anzahl + 1)
+end
diff --git a/resources/[aether]/aether-police/server/main.lua b/resources/[aether]/aether-police/server/main.lua
new file mode 100644
index 0000000..31c44fd
--- /dev/null
+++ b/resources/[aether]/aether-police/server/main.lua
@@ -0,0 +1,195 @@
+-- =====================================================================
+-- aether-police — Server-Schnittstelle
+--
+-- Ein einziges Netz-Event bedient alle Module. Jede Anfrage durchläuft
+-- Dienstprüfung und Rechteprüfung, bevor sie ein Modul erreicht.
+-- =====================================================================
+
+local Core = exports['aether-core']
+
+-- ---------------------------------------------------------------------
+-- Tablet öffnen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:police:oeffnen', function()
+ local quelle = source
+
+ local beamter = Dienst.Hole(quelle)
+ if not beamter then
+ -- Noch nicht im Dienst: Personalakte prüfen und Anmeldung anbieten
+ local charakterId = Core:HoleCharakterId(quelle)
+ if not charakterId then return end
+
+ local geprueft, fehler = Dienst.LadeBeamten(quelle, charakterId)
+ if not geprueft then
+ TriggerClientEvent('aether:police:fehler', quelle, fehler)
+ return
+ end
+
+ TriggerClientEvent('aether:police:anmeldung', quelle, {
+ name = geprueft.name,
+ dienstnummer = geprueft.dienstnummer,
+ rolleName = geprueft.rolleName,
+ abteilung = geprueft.abteilung,
+ behoerde = Polizei.Config.Behoerde,
+ })
+ return
+ end
+
+ TriggerClientEvent('aether:police:zeige', quelle, {
+ behoerde = Polizei.Config.Behoerde,
+ kuerzel = Polizei.Config.Kuerzel,
+ module = Kern.SichtbareModule(beamter),
+ beamter = {
+ name = beamter.name, dienstnummer = beamter.dienstnummer,
+ rolle = beamter.rolle, rolleName = beamter.rolleName,
+ abteilung = beamter.abteilung, status = beamter.status,
+ leitstelle = Dienst.Leitstelle() == quelle,
+ },
+ status = Polizei.Config.Dienststatus,
+ abteilungen = Polizei.Config.Abteilungen,
+ })
+end)
+
+-- ---------------------------------------------------------------------
+-- Dienst
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:police:dienstAntreten', function()
+ local quelle = source
+ local beamter, fehler = Dienst.Antreten(quelle)
+
+ if not beamter then
+ TriggerClientEvent('aether:police:fehler', quelle, fehler)
+ return
+ end
+
+ TriggerClientEvent('aether:police:zeige', quelle, {
+ behoerde = Polizei.Config.Behoerde,
+ kuerzel = Polizei.Config.Kuerzel,
+ module = Kern.SichtbareModule(beamter),
+ beamter = {
+ name = beamter.name, dienstnummer = beamter.dienstnummer,
+ rolle = beamter.rolle, rolleName = beamter.rolleName,
+ abteilung = beamter.abteilung, status = beamter.status,
+ leitstelle = Dienst.Leitstelle() == quelle,
+ },
+ status = Polizei.Config.Dienststatus,
+ abteilungen = Polizei.Config.Abteilungen,
+ })
+end)
+
+RegisterNetEvent('aether:police:dienstBeenden', function()
+ local quelle = source
+ Dienst.Beenden(quelle)
+ TriggerClientEvent('aether:police:schliessen', quelle)
+end)
+
+RegisterNetEvent('aether:police:status', function(status)
+ local quelle = source
+ if type(status) ~= 'string' then return end
+ Dienst.SetzeStatus(quelle, status)
+end)
+
+RegisterNetEvent('aether:police:leitstelle', function(zielQuelle)
+ local quelle = source
+ local erfolg, fehler = Dienst.SetzeLeitstelle(quelle,
+ type(zielQuelle) == 'number' and zielQuelle or nil)
+ if not erfolg and fehler then
+ TriggerClientEvent('aether:police:fehler', quelle, fehler)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Zentraler Modul-Aufruf
+--
+-- Die Oberfläche schickt { modul, aktion, daten, ruf }. Der `ruf` ist
+-- eine laufende Nummer, damit die Antwort zugeordnet werden kann.
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:police:aufruf', function(modulId, aktion, daten, ruf)
+ local quelle = source
+
+ if type(modulId) ~= 'string' or type(aktion) ~= 'string' then return end
+
+ local beamter = Dienst.Hole(quelle)
+ if not beamter then
+ TriggerClientEvent('aether:police:antwort', quelle, ruf, nil,
+ 'Du bist nicht im Dienst.')
+ return
+ end
+
+ local ergebnis, fehler = Kern.FuehreAus(quelle, beamter, modulId, aktion, daten)
+ TriggerClientEvent('aether:police:antwort', quelle, ruf, ergebnis, fehler)
+end)
+
+-- ---------------------------------------------------------------------
+-- Eingehende Notrufe aus anderen Modulen
+--
+-- Sie erzeugen einen Einsatz mit Status „offen". Zugewiesen wird
+-- ausschliesslich von der Leitstelle — nie automatisch.
+-- ---------------------------------------------------------------------
+
+--- Notruf aus dem Handy (110)
+AddEventHandler('aether:phone:notruf', function(quelle, fraktionId, nummer)
+ if fraktionId ~= 'lspd' and fraktionId ~= 'polizei' then return end
+
+ local position = GetEntityCoords(GetPlayerPed(quelle))
+ EinsatzEingang({
+ titel = 'Notruf über Telefon',
+ kategorie = 'notruf',
+ prioritaet = 2,
+ ort = 'Position des Anrufers',
+ beschreibung = ('Eingehender Anruf von %s.'):format(nummer or 'unbekannt'),
+ position = { x = position.x, y = position.y, z = position.z },
+ quelle = 'notruf',
+ })
+end)
+
+-- =====================================================================
+-- EXPORTS — Schnittstelle für externe Systeme
+-- Damit lassen sich Notrufe und Hinweise von aussen einspeisen, ohne
+-- dass am Kern etwas geändert werden muss.
+-- =====================================================================
+
+--- Meldet einen Notruf an die Polizei.
+--- Er erscheint als offener Einsatz — zugewiesen wird er nie automatisch.
+exports('MeldeNotruf', function(daten)
+ if type(daten) ~= 'table' or not daten.titel then return nil end
+ return EinsatzEingang(daten)
+end)
+
+--- Erstellt einen Hinweis für alle Beamten im Dienst
+exports('MeldeHinweis', function(daten)
+ if type(daten) ~= 'table' or not daten.titel then return false end
+ Hinweise.Erstelle(nil, daten)
+ return true
+end)
+
+--- Ist ein Spieler als Polizeibeamter im Dienst?
+exports('IstImDienst', function(quelle)
+ return Dienst.Hole(quelle) ~= nil
+end)
+
+--- Liefert die Dienstdaten eines Beamten (ohne Rechtetabelle)
+exports('HoleBeamten', function(quelle)
+ local beamter = Dienst.Hole(quelle)
+ if not beamter then return nil end
+ return {
+ charakterId = beamter.charakterId,
+ name = beamter.name,
+ dienstnummer = beamter.dienstnummer,
+ rolle = beamter.rolle,
+ rolleName = beamter.rolleName,
+ abteilung = beamter.abteilung,
+ status = beamter.status,
+ }
+end)
+
+--- Erlaubt fremden Ressourcen, ein eigenes Modul zu registrieren
+exports('RegistriereModul', function(beschreibung)
+ return Kern.RegistriereModul(beschreibung)
+end)
+
+--- Erlaubt fremden Ressourcen, eine Aktion beizusteuern
+exports('RegistriereAktion', function(modulId, aktion, recht, funktion)
+ Kern.RegistriereAktion(modulId, aktion, recht, funktion)
+ return true
+end)
diff --git a/resources/[aether]/aether-police/server/module/admin.lua b/resources/[aether]/aether-police/server/module/admin.lua
new file mode 100644
index 0000000..44b82b2
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/admin.lua
@@ -0,0 +1,357 @@
+-- =====================================================================
+-- aether-police / Modul: Administration
+--
+-- Drei getrennte Bereiche mit je eigenem Recht:
+-- * Module (admin.module) — Bausteine an- und abschalten
+-- * Katalog (katalog.bearbeiten) — Delikte und ihre Vorschlagswerte
+-- * Protokoll (protokoll.lesen) — wer hat wann was getan
+--
+-- Auch hier gilt: der Katalog liefert VORSCHLÄGE. Was hier eingetragen
+-- wird, ist nie eine Strafe, sondern ein Richtwert, von dem ein Beamter
+-- jederzeit begründet abweichen darf.
+--
+-- Nichts wird gelöscht. Delikte werden stillgelegt, damit alte Anzeigen
+-- nachvollziehbar bleiben.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'admin', name = 'Administration', icon = '⚙',
+ reihenfolge = 14,
+ -- Ein Recht aus dieser Liste genügt zum Öffnen; die einzelnen
+ -- Bereiche prüfen danach noch einmal getrennt.
+ recht = { 'admin.module', 'katalog.bearbeiten', 'protokoll.lesen' },
+ beschreibung = 'Module, Strafenkatalog und Protokoll',
+})
+
+-- Module, die sich nicht abschalten lassen — sonst sperrt man sich aus
+local UNVERZICHTBAR = { admin = true, dashboard = true }
+
+-- Schlüssel, unter dem die abgeschalteten Module gespeichert werden
+local SCHLUESSEL_AUS = 'module_abgeschaltet'
+
+-- ---------------------------------------------------------------------
+-- Einstellungen lesen und schreiben
+-- ---------------------------------------------------------------------
+
+--- Liest einen Wert aus police_einstellung
+--- @param schluessel string Kennung
+--- @return any|nil
+local function LeseEinstellung(schluessel)
+ local roh = MySQL.scalar.await(
+ 'SELECT wert FROM police_einstellung WHERE schluessel = ?', { schluessel })
+ if not roh then return nil end
+
+ local erfolg, wert = pcall(json.decode, roh)
+ return erfolg and wert or nil
+end
+
+--- Schreibt einen Wert nach police_einstellung
+--- @param schluessel string Kennung
+--- @param wert any Beliebiger Wert
+local function SchreibeEinstellung(schluessel, wert)
+ MySQL.query.await([[
+ INSERT INTO police_einstellung (schluessel, wert) VALUES (?, ?)
+ ON DUPLICATE KEY UPDATE wert = VALUES(wert)
+ ]], { schluessel, json.encode(wert) })
+end
+
+-- ---------------------------------------------------------------------
+-- Beim Start: gespeicherte Modulzustände wiederherstellen
+-- Läuft erst, wenn alle Moduldateien geladen und registriert sind.
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ Wait(0)
+
+ local abgeschaltet = LeseEinstellung(SCHLUESSEL_AUS)
+ if type(abgeschaltet) ~= 'table' then return end
+
+ for _, modulId in ipairs(abgeschaltet) do
+ if not UNVERZICHTBAR[modulId] and Kern.KenntModul(modulId) then
+ Kern.SetzeModulAktiv(modulId, false)
+ Aether.Debug(('Polizei-Modul abgeschaltet (gespeichert): %s'):format(modulId))
+ end
+ end
+end)
+
+-- =====================================================================
+-- MODULE
+-- =====================================================================
+
+--- Welche Bereiche darf dieser Beamte sehen? Steuert die Reiter der Oberfläche.
+Kern.RegistriereAktion('admin', 'bereiche', nil, function(quelle, beamter)
+ return {
+ module = Rechte.Hat(beamter, 'admin.module'),
+ katalog = Rechte.Hat(beamter, 'katalog.bearbeiten'),
+ protokoll = Rechte.Hat(beamter, 'protokoll.lesen'),
+ }
+end)
+
+Kern.RegistriereAktion('admin', 'module', 'admin.module', function(quelle, beamter)
+ local module = Kern.AlleModule()
+
+ for _, modul in ipairs(module) do
+ modul.unverzichtbar = UNVERZICHTBAR[modul.id] == true
+ -- Welches Recht öffnet dieses Modul, und wie heißt es im Klartext?
+ modul.rechtName = nil
+ if modul.recht then
+ for _, gruppe in ipairs(Polizei.Rechte) do
+ for _, recht in ipairs(gruppe.eintraege) do
+ if recht.id == modul.recht then modul.rechtName = recht.name end
+ end
+ end
+ end
+ end
+
+ -- Werte aus config.lua zur Ansicht — sie leben in der Datei, nicht in der DB
+ local konfiguration = {
+ { name = 'Behörde', wert = Polizei.Config.Behoerde },
+ { name = 'Kürzel', wert = Polizei.Config.Kuerzel },
+ { name = 'Terminals', wert = tostring(#Polizei.Config.Terminals) },
+ { name = 'Abteilungen', wert = tostring(#Polizei.Config.Abteilungen) },
+ { name = 'Rollen', wert = tostring(#Polizei.Rollen) },
+ { name = 'Rechte', wert = tostring(#Polizei.AlleRechte()) },
+ { name = 'Fristprüfung alle', wert = (Polizei.Config.Erinnerungen.pruefIntervall / 1000)
+ .. ' Sekunden' },
+ }
+
+ return {
+ module = module,
+ konfiguration = konfiguration,
+ beamteImDienst = (function()
+ local anzahl = 0
+ for _ in pairs(Dienst.AlleImDienst()) do anzahl = anzahl + 1 end
+ return anzahl
+ end)(),
+ }
+end)
+
+Kern.RegistriereAktion('admin', 'modulSchalten', 'admin.module', function(quelle, beamter, daten)
+ local modulId = tostring(daten.id or '')
+ local aktiv = daten.aktiv == true
+
+ if not Kern.KenntModul(modulId) then return nil, 'Unbekanntes Modul.' end
+ if UNVERZICHTBAR[modulId] then
+ return nil, 'Dieses Modul lässt sich nicht abschalten.'
+ end
+
+ Kern.SetzeModulAktiv(modulId, aktiv)
+
+ -- Zustand merken, damit er einen Neustart übersteht
+ local abgeschaltet, name = {}, modulId
+ for _, modul in ipairs(Kern.AlleModule()) do
+ if not modul.aktiv then abgeschaltet[#abgeschaltet + 1] = modul.id end
+ if modul.id == modulId then name = modul.name end
+ end
+ SchreibeEinstellung(SCHLUESSEL_AUS, abgeschaltet)
+
+ Protokoll.Schreibe(beamter, 'admin',
+ aktiv and 'Modul eingeschaltet' or 'Modul abgeschaltet',
+ { typ = 'modul', id = modulId }, nil, daten.grund)
+
+ -- Alle im Dienst darüber informieren. Die Seitenleiste aktualisiert sich
+ -- beim nächsten Öffnen des Tablets — laufende Arbeit wird nicht unterbrochen.
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system', prioritaet = 'niedrig',
+ titel = aktiv and 'Modul eingeschaltet' or 'Modul abgeschaltet',
+ text = ('„%s" — beim nächsten Öffnen des Tablets sichtbar.'):format(name),
+ })
+
+ Kern.Loese('admin.modulGeschaltet', modulId, aktiv)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- STRAFENKATALOG
+--
+-- Die Werte hier sind Vorschläge. Sie werden nie automatisch verhängt.
+-- =====================================================================
+
+Kern.RegistriereAktion('admin', 'katalog', 'katalog.bearbeiten', function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if not daten.stillgelegt then
+ bedingungen[#bedingungen + 1] = 'aktiv = 1'
+ end
+ if daten.kategorie and daten.kategorie ~= '' then
+ bedingungen[#bedingungen + 1] = 'kategorie = ?'
+ werte[#werte + 1] = daten.kategorie
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(kennung LIKE ? OR bezeichnung LIKE ? OR paragraph LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local delikte = MySQL.query.await(([[
+ SELECT id, kennung, kategorie, bezeichnung, paragraph, haft, geld,
+ hinweis, aktiv, sortierung
+ FROM police_katalog
+ WHERE %s
+ ORDER BY kategorie, sortierung, kennung
+ LIMIT 500
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ for _, delikt in ipairs(delikte) do
+ delikt.aktiv = delikt.aktiv == 1
+ end
+
+ local kategorien = MySQL.query.await(
+ 'SELECT DISTINCT kategorie FROM police_katalog ORDER BY kategorie') or {}
+
+ return {
+ delikte = delikte,
+ kategorien = kategorien,
+ stillgelegt = daten.stillgelegt == true,
+ gesamt = MySQL.scalar.await('SELECT COUNT(*) FROM police_katalog WHERE aktiv = 1') or 0,
+ }
+end)
+
+Kern.RegistriereAktion('admin', 'katalogSpeichern', 'katalog.bearbeiten', function(quelle, beamter, daten)
+ local kennung = type(daten.kennung) == 'string'
+ and daten.kennung:gsub('%s', ''):upper() or ''
+ local bezeichnung = type(daten.bezeichnung) == 'string' and daten.bezeichnung:sub(1, 160) or ''
+ local kategorie = type(daten.kategorie) == 'string' and daten.kategorie:sub(1, 48) or ''
+
+ if #kennung < 2 then return nil, 'Bitte eine Kennung angeben.' end
+ if #bezeichnung < 3 then return nil, 'Bitte eine Bezeichnung angeben.' end
+ if #kategorie < 2 then return nil, 'Bitte eine Kategorie angeben.' end
+
+ local haft = math.max(0, math.floor(tonumber(daten.haft) or 0))
+ local geld = math.max(0, math.floor(tonumber(daten.geld) or 0))
+ if haft > 100000 or geld > 10000000 then
+ return nil, 'Der Vorschlagswert ist unplausibel hoch.'
+ end
+
+ local id = tonumber(daten.id)
+ local hinweis = type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 255) or nil
+ local sortierung = math.floor(tonumber(daten.sortierung) or 0)
+
+ -- Kennung darf nur einmal vergeben sein
+ local belegt = MySQL.scalar.await(
+ 'SELECT id FROM police_katalog WHERE kennung = ? AND (? IS NULL OR id <> ?)',
+ { kennung, id, id })
+ if belegt then return nil, 'Diese Kennung ist bereits vergeben.' end
+
+ if id then
+ local alt = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, haft, geld FROM police_katalog WHERE id = ?', { id })
+ if not alt or not alt[1] then return nil, 'Delikt nicht gefunden.' end
+
+ MySQL.update.await([[
+ UPDATE police_katalog
+ SET kennung = ?, kategorie = ?, bezeichnung = ?, paragraph = ?,
+ haft = ?, geld = ?, hinweis = ?, sortierung = ?
+ WHERE id = ?
+ ]], { kennung, kategorie, bezeichnung, daten.paragraph, haft, geld,
+ hinweis, sortierung, id })
+
+ Protokoll.Schreibe(beamter, 'admin', 'Delikt geändert',
+ { typ = 'katalog', id = id },
+ { kennung = kennung,
+ haftAlt = alt[1].haft, haftNeu = haft,
+ geldAlt = alt[1].geld, geldNeu = geld })
+ else
+ id = MySQL.insert.await([[
+ INSERT INTO police_katalog
+ (kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, sortierung)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], { kennung, kategorie, bezeichnung, daten.paragraph, haft, geld,
+ hinweis, sortierung })
+
+ Protokoll.Schreibe(beamter, 'admin', 'Delikt angelegt',
+ { typ = 'katalog', id = id },
+ { kennung = kennung, haft = haft, geld = geld })
+ end
+
+ Kern.Loese('katalog.geaendert', id, beamter)
+
+ return { id = id }
+end)
+
+Kern.RegistriereAktion('admin', 'katalogSchalten', 'katalog.bearbeiten', function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local aktiv = daten.aktiv == true
+
+ local delikt = MySQL.query.await(
+ 'SELECT kennung, bezeichnung FROM police_katalog WHERE id = ?', { id })
+ if not delikt or not delikt[1] then return nil, 'Delikt nicht gefunden.' end
+
+ -- Stilllegen statt löschen: bestehende Anzeigen bleiben nachvollziehbar
+ MySQL.update.await('UPDATE police_katalog SET aktiv = ? WHERE id = ?',
+ { aktiv and 1 or 0, id })
+
+ Protokoll.Schreibe(beamter, 'admin',
+ aktiv and 'Delikt wieder freigegeben' or 'Delikt stillgelegt',
+ { typ = 'katalog', id = id },
+ { kennung = delikt[1].kennung }, daten.grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- PROTOKOLL
+--
+-- Reine Auskunft. Einträge lassen sich weder ändern noch entfernen.
+-- =====================================================================
+
+Kern.RegistriereAktion('admin', 'protokoll', 'protokoll.lesen', function(quelle, beamter, daten)
+ local filter = { limit = math.min(tonumber(daten.limit) or 100, 500) }
+
+ if daten.modul and daten.modul ~= '' then filter.modul = daten.modul end
+
+ -- Nach Beamtem filtern: über die Dienstnummer, nicht über eine ID.
+ -- Eine unbekannte Nummer liefert eine leere Liste, keinen Fehler —
+ -- sonst bricht die Ansicht schon beim Tippen ab.
+ local unbekannteNummer = false
+ if type(daten.dienstnummer) == 'string' and #daten.dienstnummer >= 2 then
+ local charakterId = MySQL.scalar.await(
+ 'SELECT `character` FROM police_personal WHERE dienstnummer = ?',
+ { daten.dienstnummer:upper() })
+ if charakterId then
+ filter.charakterId = charakterId
+ else
+ unbekannteNummer = true
+ end
+ end
+
+ local eintraege = unbekannteNummer and {} or Protokoll.Lies(filter)
+
+ for _, eintrag in ipairs(eintraege) do
+ eintrag.name = eintrag.firstname
+ and (eintrag.firstname .. ' ' .. eintrag.lastname) or 'System'
+ eintrag.firstname, eintrag.lastname = nil, nil
+
+ -- Details lesbar machen
+ if eintrag.details then
+ local erfolg, wert = pcall(json.decode, eintrag.details)
+ eintrag.details = erfolg and wert or nil
+ end
+ end
+
+ -- Welche Module tauchen im Protokoll überhaupt auf?
+ local module = MySQL.query.await(
+ 'SELECT DISTINCT modul FROM police_protokoll ORDER BY modul') or {}
+
+ return {
+ eintraege = eintraege,
+ module = module,
+ unbekannteNummer = unbekannteNummer,
+ gesamt = MySQL.scalar.await('SELECT COUNT(*) FROM police_protokoll') or 0,
+ }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert alle registrierten Module mit ihrem Zustand
+--- @return table
+exports('HoleModule', function()
+ return Kern.AlleModule()
+end)
diff --git a/resources/[aether]/aether-police/server/module/anzeige.lua b/resources/[aether]/aether-police/server/module/anzeige.lua
new file mode 100644
index 0000000..fe51d96
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/anzeige.lua
@@ -0,0 +1,443 @@
+-- =====================================================================
+-- aether-police / Modul: Strafenkatalog & Anzeigen
+--
+-- Der Katalog ist ein NACHSCHLAGEWERK. Er berechnet einen Vorschlag —
+-- entschieden wird von einem Beamten. Deshalb speichert jede Anzeige
+-- beides getrennt:
+-- haft_empfohlen / geld_empfohlen = Vorschlag des Katalogs
+-- haft_final / geld_final = Entscheidung des Beamten
+-- Weicht der Beamte ab, ist eine Begründung Pflicht.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'anzeige', name = 'Anzeigen', icon = '⚖',
+ reihenfolge = 4, recht = 'anzeige.erstellen',
+ beschreibung = 'Strafenkatalog und Anzeigenerfassung',
+})
+
+-- ---------------------------------------------------------------------
+-- Grundbestand des Katalogs
+-- Wird nur angelegt, wenn die Tabelle leer ist. Danach pflegt ihn die
+-- Administration — hier wird nichts mehr überschrieben.
+-- ---------------------------------------------------------------------
+local GRUNDKATALOG = {
+ -- kennung, kategorie, bezeichnung, paragraph, haft (Min), geld, hinweis
+ { 'v1', 'Verkehr', 'Überhöhte Geschwindigkeit', '§ 3 StVO', 0, 450,
+ 'Bei erheblicher Überschreitung Fahrverbot prüfen.' },
+ { 'v2', 'Verkehr', 'Fahren ohne Fahrerlaubnis', '§ 21 StVG', 10, 1200,
+ 'Führerscheinstatus im MDT prüfen.' },
+ { 'v3', 'Verkehr', 'Unerlaubtes Entfernen vom Unfallort', '§ 142 StGB', 30, 5000, nil },
+ { 'v4', 'Verkehr', 'Trunkenheit im Verkehr', '§ 316 StGB', 20, 3500,
+ 'Bei Gefährdung § 315c prüfen.' },
+ { 'v5', 'Verkehr', 'Verbotenes Kraftfahrzeugrennen', '§ 315d StGB', 45, 9000, nil },
+
+ { 'e1', 'Eigentum', 'Diebstahl (geringwertig)', '§ 242 StGB', 15, 800, nil },
+ { 'e2', 'Eigentum', 'Einbruchdiebstahl', '§ 243 StGB', 45, 6000, nil },
+ { 'e3', 'Eigentum', 'Sachbeschädigung', '§ 303 StGB', 10, 1500, nil },
+ { 'e4', 'Eigentum', 'Hehlerei', '§ 259 StGB', 25, 4000, nil },
+
+ { 'g1', 'Gewalt', 'Körperverletzung', '§ 223 StGB', 45, 8000, nil },
+ { 'g2', 'Gewalt', 'Gefährliche Körperverletzung', '§ 224 StGB', 90, 15000, nil },
+ { 'g3', 'Gewalt', 'Raub', '§ 249 StGB', 120, 25000, nil },
+ { 'g4', 'Gewalt', 'Widerstand gegen Vollstreckungsbeamte', '§ 113 StGB', 30, 4000, nil },
+ { 'g5', 'Gewalt', 'Bedrohung', '§ 241 StGB', 20, 2500, nil },
+ { 'g6', 'Gewalt', 'Freiheitsberaubung', '§ 239 StGB', 60, 9000, nil },
+
+ { 'w1', 'Waffen', 'Unerlaubter Waffenbesitz', '§ 52 WaffG', 60, 10000,
+ 'Waffenschein im MDT prüfen — ggf. Einziehung dokumentieren.' },
+ { 'w2', 'Waffen', 'Führen einer Waffe ohne Erlaubnis', '§ 52 WaffG', 30, 5000, nil },
+
+ { 'b1', 'Betäubungsmittel', 'Besitz von Betäubungsmitteln', '§ 29 BtMG', 25, 3000, nil },
+ { 'b2', 'Betäubungsmittel', 'Handel mit Betäubungsmitteln', '§ 29a BtMG', 110, 20000, nil },
+
+ { 's1', 'Sonstige', 'Hausfriedensbruch', '§ 123 StGB', 15, 1500, nil },
+ { 's2', 'Sonstige', 'Beleidigung', '§ 185 StGB', 0, 800, nil },
+ { 's3', 'Sonstige', 'Falsche Verdächtigung', '§ 164 StGB', 30, 4500, nil },
+ { 's4', 'Sonstige', 'Urkundenfälschung', '§ 267 StGB', 40, 6000, nil },
+}
+
+CreateThread(function()
+ Wait(3000)
+
+ local anzahl = MySQL.scalar.await('SELECT COUNT(*) FROM police_katalog') or 0
+ if anzahl > 0 then return end
+
+ for reihenfolge, eintrag in ipairs(GRUNDKATALOG) do
+ MySQL.insert.await([[
+ INSERT INTO police_katalog
+ (kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, sortierung)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4],
+ eintrag[5], eintrag[6], eintrag[7], reihenfolge })
+ end
+
+ Aether.Info(('Strafenkatalog mit %d Einträgen angelegt.'):format(#GRUNDKATALOG))
+end)
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Lädt den aktiven Katalog, nach Kategorien gruppiert
+--- @return table
+local function LadeKatalog()
+ local eintraege = MySQL.query.await([[
+ SELECT id, kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis
+ FROM police_katalog WHERE aktiv = 1
+ ORDER BY sortierung ASC, id ASC
+ ]]) or {}
+
+ local gruppen, reihenfolge = {}, {}
+ for _, eintrag in ipairs(eintraege) do
+ if not gruppen[eintrag.kategorie] then
+ gruppen[eintrag.kategorie] = {}
+ reihenfolge[#reihenfolge + 1] = eintrag.kategorie
+ end
+ table.insert(gruppen[eintrag.kategorie], eintrag)
+ end
+
+ local liste = {}
+ for _, kategorie in ipairs(reihenfolge) do
+ liste[#liste + 1] = { kategorie = kategorie, delikte = gruppen[kategorie] }
+ end
+ return liste
+end
+
+--- Rechnet den Vorschlag für eine Auswahl von Delikten aus.
+--- Der Katalog ist die einzige Quelle — der Client schickt nur Kennungen.
+--- @param kennungen table Liste von Katalog-Kennungen
+--- @return table|nil { delikte, haft, geld }, string|nil Fehler
+local function BerechneVorschlag(kennungen)
+ if type(kennungen) ~= 'table' or #kennungen == 0 then
+ return nil, 'Bitte mindestens ein Delikt auswählen.'
+ end
+ if #kennungen > 25 then
+ return nil, 'Zu viele Delikte auf einmal.'
+ end
+
+ local delikte, haft, geld = {}, 0, 0
+
+ for _, kennung in ipairs(kennungen) do
+ local eintrag = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, paragraph, haft, geld FROM police_katalog WHERE kennung = ? AND aktiv = 1',
+ { tostring(kennung) })
+
+ if not eintrag or not eintrag[1] then
+ return nil, ('Unbekanntes Delikt: %s'):format(tostring(kennung))
+ end
+
+ local delikt = eintrag[1]
+ delikte[#delikte + 1] = {
+ kennung = delikt.kennung, name = delikt.bezeichnung,
+ paragraph = delikt.paragraph, haft = delikt.haft, geld = delikt.geld,
+ }
+ haft = haft + (delikt.haft or 0)
+ geld = geld + (delikt.geld or 0)
+ end
+
+ return { delikte = delikte, haft = haft, geld = geld }
+end
+
+-- =====================================================================
+-- KATALOG ABRUFEN
+-- =====================================================================
+
+Kern.RegistriereAktion('anzeige', 'katalog', nil, function(quelle, beamter)
+ return {
+ katalog = LadeKatalog(),
+ rechte = {
+ bestaetigen = Rechte.Hat(beamter, 'anzeige.bestaetigen'),
+ abweichen = Rechte.Hat(beamter, 'anzeige.abweichen'),
+ bearbeiten = Rechte.Hat(beamter, 'katalog.bearbeiten'),
+ },
+ }
+end)
+
+--- Berechnet den Vorschlag zu einer Auswahl — reine Vorschau, speichert nichts
+Kern.RegistriereAktion('anzeige', 'vorschlag', nil, function(quelle, beamter, daten)
+ local vorschlag, fehler = BerechneVorschlag(daten.delikte)
+ if not vorschlag then return nil, fehler end
+
+ -- Zusätzliche Hinweise der gewählten Delikte mitliefern
+ local hinweise = {}
+ for _, kennung in ipairs(daten.delikte) do
+ local hinweis = MySQL.scalar.await(
+ 'SELECT hinweis FROM police_katalog WHERE kennung = ? AND aktiv = 1', { kennung })
+ if hinweis and hinweis ~= '' then hinweise[#hinweise + 1] = hinweis end
+ end
+
+ return {
+ delikte = vorschlag.delikte,
+ haft = vorschlag.haft,
+ geld = vorschlag.geld,
+ hinweise = hinweise,
+ }
+end)
+
+-- =====================================================================
+-- ANZEIGE ERFASSEN
+--
+-- Erzeugt einen ENTWURF. Nichts wird eingezogen, niemand kommt in Haft.
+-- =====================================================================
+
+Kern.RegistriereAktion('anzeige', 'erstellen', 'anzeige.erstellen', function(quelle, beamter, daten)
+ local beschuldigter = tonumber(daten.beschuldigter)
+ if not beschuldigter then return nil, 'Kein Beschuldigter ausgewählt.' end
+
+ local person = MySQL.scalar.await(
+ 'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { beschuldigter })
+ if not person then return nil, 'Diese Person existiert nicht.' end
+
+ local vorschlag, fehler = BerechneVorschlag(daten.delikte)
+ if not vorschlag then return nil, fehler end
+
+ -- Werte des Beamten übernehmen; fehlen sie, gilt der Vorschlag
+ local haftFinal = daten.haft ~= nil and math.max(0, math.min(1440, math.floor(tonumber(daten.haft) or 0))) or vorschlag.haft
+ local geldFinal = daten.geld ~= nil and math.max(0, math.min(10000000, math.floor(tonumber(daten.geld) or 0))) or vorschlag.geld
+
+ local weichtAb = (haftFinal ~= vorschlag.haft) or (geldFinal ~= vorschlag.geld)
+ local begruendung = type(daten.begruendung) == 'string'
+ and daten.begruendung:gsub('^%s+', ''):gsub('%s+$', '') or ''
+
+ -- Abweichen ist ein eigenes Recht und verlangt eine Begründung
+ if weichtAb then
+ if not Rechte.Hat(beamter, 'anzeige.abweichen') then
+ return nil, 'Dir fehlt die Berechtigung, vom Katalogvorschlag abzuweichen.'
+ end
+ if #begruendung < 5 then
+ return nil, 'Bei Abweichung vom Vorschlag ist eine Begründung erforderlich.'
+ end
+ end
+
+ local anzeigeId = MySQL.insert.await([[
+ INSERT INTO police_anzeige
+ (akte, beschuldigter, delikte, haft_empfohlen, geld_empfohlen,
+ haft_final, geld_final, begruendung, status, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'entwurf', ?)
+ ]], {
+ tonumber(daten.akte), beschuldigter, json.encode(vorschlag.delikte),
+ vorschlag.haft, vorschlag.geld, haftFinal, geldFinal,
+ begruendung ~= '' and begruendung or nil, beamter.charakterId,
+ })
+
+ Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige als Entwurf erfasst',
+ { typ = 'person', id = beschuldigter },
+ { anzeige = anzeigeId, haft = haftFinal, geld = geldFinal, abweichung = weichtAb },
+ begruendung ~= '' and begruendung or nil)
+
+ Kern.Loese('anzeige.erstellt', anzeigeId, beamter)
+
+ return { id = anzeigeId, entwurf = true }
+end)
+
+-- =====================================================================
+-- BESTÄTIGEN
+--
+-- Erst hier wird die Entscheidung des Beamten ausgeführt: Das Bußgeld
+-- wird eingezogen. Eine Haft entsteht dadurch NICHT — die Einweisung
+-- in Gewahrsam ist ein eigener, bewusster Schritt.
+-- =====================================================================
+
+Kern.RegistriereAktion('anzeige', 'bestaetigen', 'anzeige.bestaetigen', function(quelle, beamter, daten)
+ local anzeigeId = tonumber(daten.id)
+ if not anzeigeId then return nil, 'Ungültige Kennung.' end
+
+ local anzeige = MySQL.query.await('SELECT * FROM police_anzeige WHERE id = ?', { anzeigeId })
+ if not anzeige or not anzeige[1] then return nil, 'Anzeige nicht gefunden.' end
+
+ local eintrag = anzeige[1]
+ if eintrag.status ~= 'entwurf' then
+ return nil, 'Diese Anzeige wurde bereits abschließend bearbeitet.'
+ end
+
+ -- Vier-Augen-Prinzip: Wer erfasst hat, darf nur mit dem Recht
+ -- „bestaetigen" auch selbst bestätigen — das ist hier gegeben,
+ -- wird aber protokolliert.
+ local selbstbestaetigung = eintrag.erstellt_von == beamter.charakterId
+
+ -- Bußgeld einziehen (erst Bargeld, dann Konto)
+ local Core = exports['aether-core']
+ local eingezogen = 0
+
+ if eintrag.geld_final > 0 then
+ local zielQuelle = Core:HoleQuelleNachCharakter(eintrag.beschuldigter)
+ local bezahlt = false
+
+ if zielQuelle then
+ bezahlt = Core:EntferneGeld(zielQuelle, 'bargeld', eintrag.geld_final, 'Bußgeld')
+ if not bezahlt then
+ bezahlt = Core:EntferneGeld(zielQuelle, 'konto', eintrag.geld_final, 'Bußgeld')
+ end
+ else
+ -- Offline: direkt vom Konto, auch ins Minus (Forderung bleibt bestehen)
+ MySQL.update.await('UPDATE aether_characters SET bank = bank - ? WHERE id = ?',
+ { eintrag.geld_final, eintrag.beschuldigter })
+ bezahlt = true
+ end
+
+ if bezahlt then
+ eingezogen = eintrag.geld_final
+ MySQL.update('UPDATE aether_treasury SET betrag = betrag + ? WHERE id = 1',
+ { eintrag.geld_final })
+ end
+ end
+
+ MySQL.update.await([[
+ UPDATE police_anzeige
+ SET status = 'bestaetigt', bestaetigt_von = ?, bestaetigt_am = NOW()
+ WHERE id = ?
+ ]], { beamter.charakterId, anzeigeId })
+
+ Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige bestätigt',
+ { typ = 'person', id = eintrag.beschuldigter },
+ { anzeige = anzeigeId, eingezogen = eingezogen,
+ selbstbestaetigung = selbstbestaetigung })
+
+ -- Erinnerung, falls eine Haft festgelegt wurde — aber NICHT ausgeführt
+ if eintrag.haft_final > 0 then
+ Hinweise.Erstelle({ charakterId = beamter.charakterId }, {
+ kategorie = 'gewahrsam', prioritaet = 'normal',
+ titel = 'Haftzeit festgelegt — Einweisung offen',
+ text = ('Die Anzeige sieht %d Minuten vor. Die Einweisung in Gewahrsam ist ein eigener Schritt.')
+ :format(eintrag.haft_final),
+ zielTyp = 'person', zielId = eintrag.beschuldigter,
+ })
+ end
+
+ Kern.Loese('anzeige.bestaetigt', anzeigeId, beamter)
+
+ return {
+ ok = true,
+ eingezogen = eingezogen,
+ haft = eintrag.haft_final,
+ beschuldigter = eintrag.beschuldigter,
+ }
+end)
+
+--- Entwurf verwerfen
+Kern.RegistriereAktion('anzeige', 'verwerfen', 'anzeige.erstellen', function(quelle, beamter, daten)
+ local anzeigeId = tonumber(daten.id)
+ if not anzeigeId then return nil, 'Ungültige Kennung.' end
+
+ local anzeige = MySQL.query.await(
+ 'SELECT erstellt_von, status, beschuldigter FROM police_anzeige WHERE id = ?', { anzeigeId })
+ if not anzeige or not anzeige[1] then return nil, 'Anzeige nicht gefunden.' end
+ if anzeige[1].status ~= 'entwurf' then
+ return nil, 'Nur Entwürfe können verworfen werden.'
+ end
+
+ -- Fremde Entwürfe darf nur verwerfen, wer bestätigen darf
+ if anzeige[1].erstellt_von ~= beamter.charakterId
+ and not Rechte.Hat(beamter, 'anzeige.bestaetigen') then
+ return nil, 'Du kannst nur eigene Entwürfe verwerfen.'
+ end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or nil
+
+ MySQL.update.await("UPDATE police_anzeige SET status = 'verworfen' WHERE id = ?", { anzeigeId })
+
+ Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige verworfen',
+ { typ = 'person', id = anzeige[1].beschuldigter }, { anzeige = anzeigeId }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ÜBERSICHT UND EINZELANSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('anzeige', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.status and daten.status ~= '' then
+ bedingungen[#bedingungen + 1] = 'a.status = ?'
+ werte[#werte + 1] = daten.status
+ end
+ if daten.nurEigene then
+ bedingungen[#bedingungen + 1] = 'a.erstellt_von = ?'
+ werte[#werte + 1] = beamter.charakterId
+ end
+
+ local anzeigen = MySQL.query.await(([[
+ SELECT a.id, a.beschuldigter, a.delikte, a.haft_empfohlen, a.geld_empfohlen,
+ a.haft_final, a.geld_final, a.begruendung, a.status,
+ a.erstellt_am, a.bestaetigt_am,
+ b.firstname AS b_vorname, b.lastname AS b_nachname,
+ e.firstname AS e_vorname, e.lastname AS e_nachname
+ FROM police_anzeige a
+ LEFT JOIN aether_characters b ON b.id = a.beschuldigter
+ LEFT JOIN aether_characters e ON e.id = a.erstellt_von
+ WHERE %s
+ ORDER BY
+ CASE WHEN a.status = 'entwurf' THEN 0 ELSE 1 END,
+ a.id DESC
+ LIMIT 60
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ for _, anzeige in ipairs(anzeigen) do
+ local erfolg, liste = pcall(json.decode, anzeige.delikte)
+ anzeige.delikte = (erfolg and type(liste) == 'table') and liste or {}
+ -- Weicht die Entscheidung vom Vorschlag ab?
+ anzeige.abweichung = (anzeige.haft_final ~= anzeige.haft_empfohlen)
+ or (anzeige.geld_final ~= anzeige.geld_empfohlen)
+ end
+
+ return {
+ anzeigen = anzeigen,
+ rechte = {
+ bestaetigen = Rechte.Hat(beamter, 'anzeige.bestaetigen'),
+ abweichen = Rechte.Hat(beamter, 'anzeige.abweichen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- KATALOGPFLEGE (Administration)
+-- =====================================================================
+
+Kern.RegistriereAktion('anzeige', 'katalogSpeichern', 'katalog.bearbeiten', function(quelle, beamter, daten)
+ local kennung = type(daten.kennung) == 'string' and daten.kennung:gsub('%s', '') or ''
+ local bezeichnung = type(daten.bezeichnung) == 'string'
+ and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or ''
+
+ if #kennung < 1 or #kennung > 24 then return nil, 'Ungültige Kennung.' end
+ if #bezeichnung < 3 then return nil, 'Bitte eine Bezeichnung angeben.' end
+
+ local haft = math.max(0, math.min(1440, math.floor(tonumber(daten.haft) or 0)))
+ local geld = math.max(0, math.min(10000000, math.floor(tonumber(daten.geld) or 0)))
+
+ MySQL.query.await([[
+ INSERT INTO police_katalog
+ (kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, aktiv)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1)
+ ON DUPLICATE KEY UPDATE
+ kategorie = VALUES(kategorie), bezeichnung = VALUES(bezeichnung),
+ paragraph = VALUES(paragraph), haft = VALUES(haft), geld = VALUES(geld),
+ hinweis = VALUES(hinweis), aktiv = 1
+ ]], {
+ kennung,
+ type(daten.kategorie) == 'string' and daten.kategorie:sub(1, 48) or 'Sonstige',
+ bezeichnung,
+ type(daten.paragraph) == 'string' and daten.paragraph:sub(1, 48) or nil,
+ haft, geld,
+ type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 512) or nil,
+ })
+
+ Protokoll.Schreibe(beamter, 'anzeige', 'Katalogeintrag gespeichert',
+ { typ = 'katalog', id = kennung }, { haft = haft, geld = geld })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('anzeige', 'katalogDeaktivieren', 'katalog.bearbeiten', function(quelle, beamter, daten)
+ local kennung = tostring(daten.kennung or '')
+ if kennung == '' then return nil, 'Ungültige Kennung.' end
+
+ -- Nicht löschen, damit alte Anzeigen nachvollziehbar bleiben
+ MySQL.update.await('UPDATE police_katalog SET aktiv = 0 WHERE kennung = ?', { kennung })
+
+ Protokoll.Schreibe(beamter, 'anzeige', 'Katalogeintrag deaktiviert',
+ { typ = 'katalog', id = kennung })
+
+ return { ok = true }
+end)
diff --git a/resources/[aether]/aether-police/server/module/asservat.lua b/resources/[aether]/aether-police/server/module/asservat.lua
new file mode 100644
index 0000000..96c4d6b
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/asservat.lua
@@ -0,0 +1,320 @@
+-- =====================================================================
+-- aether-police / Modul: Asservatenkammer
+--
+-- Jedes Objekt hat eine Kennung, einen Lagerort, einen Verantwortlichen
+-- und eine lückenlose Historie. Nichts wird automatisch eingelagert,
+-- ausgegeben oder vernichtet — jeder Schritt ist eine Handlung eines
+-- Beamten und wird mit Namen festgehalten.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'asservat', name = 'Asservate', icon = '📦',
+ reihenfolge = 7, recht = 'asservat.einlagern',
+ beschreibung = 'Asservatenkammer und Verwahrkette',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+local function KategorieGueltig(kategorie)
+ for _, eintrag in ipairs(Polizei.Config.Asservatkategorien) do
+ if eintrag.id == kategorie then return true end
+ end
+ return false
+end
+
+--- Schreibt einen Historieneintrag zu einem Asservat
+--- @param asservatId number
+--- @param aktion string
+--- @param beamter table
+--- @param notiz string|nil
+local function Historie(asservatId, aktion, beamter, notiz)
+ MySQL.insert('INSERT INTO police_asservat_historie (asservat, aktion, von, notiz) VALUES (?, ?, ?, ?)',
+ { asservatId, aktion, beamter.charakterId, notiz })
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('asservat', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.kategorie and daten.kategorie ~= '' then
+ bedingungen[#bedingungen + 1] = 'a.kategorie = ?'
+ werte[#werte + 1] = daten.kategorie
+ end
+ if daten.status and daten.status ~= '' then
+ bedingungen[#bedingungen + 1] = 'a.status = ?'
+ werte[#werte + 1] = daten.status
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(a.bezeichnung LIKE ? OR a.kennung LIKE ? OR a.lagerort LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local asservate = MySQL.query.await(([[
+ SELECT a.id, a.kennung, a.kategorie, a.bezeichnung, a.menge, a.lagerort,
+ a.status, a.eingelagert_am, a.akte, a.beweis,
+ c.firstname, c.lastname, p.dienstnummer,
+ k.aktenzeichen
+ FROM police_asservat a
+ LEFT JOIN aether_characters c ON c.id = a.verantwortlich
+ LEFT JOIN police_personal p ON p.`character` = a.verantwortlich
+ LEFT JOIN police_akte k ON k.id = a.akte
+ WHERE %s
+ ORDER BY
+ CASE a.status WHEN 'eingelagert' THEN 0 WHEN 'ausgegeben' THEN 1 ELSE 2 END,
+ a.id DESC
+ LIMIT 80
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ -- Bestandszahlen für die Übersicht
+ local zahlen = MySQL.query.await([[
+ SELECT status, COUNT(*) AS anzahl FROM police_asservat GROUP BY status
+ ]]) or {}
+
+ local bestand = { eingelagert = 0, ausgegeben = 0, vernichtet = 0, zurueckgegeben = 0 }
+ for _, zeile in ipairs(zahlen) do
+ bestand[zeile.status] = zeile.anzahl
+ end
+
+ return {
+ asservate = asservate,
+ kategorien = Polizei.Config.Asservatkategorien,
+ bestand = bestand,
+ rechte = {
+ einlagern = Rechte.Hat(beamter, 'asservat.einlagern'),
+ ausgeben = Rechte.Hat(beamter, 'asservat.ausgeben'),
+ vernichten = Rechte.Hat(beamter, 'asservat.vernichten'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('asservat', 'oeffnen', nil, function(quelle, beamter, daten)
+ local asservatId = tonumber(daten.id)
+ if not asservatId then return nil, 'Ungültige Kennung.' end
+
+ local asservat = MySQL.query.await([[
+ SELECT a.*, c.firstname, c.lastname, p.dienstnummer,
+ k.aktenzeichen, k.titel AS akte_titel,
+ b.kennung AS beweis_kennung, b.bezeichnung AS beweis_bezeichnung
+ FROM police_asservat a
+ LEFT JOIN aether_characters c ON c.id = a.verantwortlich
+ LEFT JOIN police_personal p ON p.`character` = a.verantwortlich
+ LEFT JOIN police_akte k ON k.id = a.akte
+ LEFT JOIN police_beweis b ON b.id = a.beweis
+ WHERE a.id = ?
+ ]], { asservatId })
+
+ if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end
+
+ -- Lückenlose Verwahrkette
+ local historie = MySQL.query.await([[
+ SELECT h.aktion, h.am, h.notiz, c.firstname, c.lastname, p.dienstnummer
+ FROM police_asservat_historie h
+ LEFT JOIN aether_characters c ON c.id = h.von
+ LEFT JOIN police_personal p ON p.`character` = h.von
+ WHERE h.asservat = ?
+ ORDER BY h.id ASC
+ ]], { asservatId }) or {}
+
+ return {
+ asservat = asservat[1],
+ historie = historie,
+ rechte = {
+ ausgeben = Rechte.Hat(beamter, 'asservat.ausgeben'),
+ vernichten = Rechte.Hat(beamter, 'asservat.vernichten'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINLAGERN
+-- =====================================================================
+
+Kern.RegistriereAktion('asservat', 'einlagern', 'asservat.einlagern', function(quelle, beamter, daten)
+ if not KategorieGueltig(daten.kategorie) then return nil, 'Unbekannte Kategorie.' end
+
+ local bezeichnung = type(daten.bezeichnung) == 'string'
+ and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #bezeichnung < 3 or #bezeichnung > 160 then
+ return nil, 'Die Bezeichnung muss 3 bis 160 Zeichen lang sein.'
+ end
+
+ local lagerort = type(daten.lagerort) == 'string' and daten.lagerort:sub(1, 64) or ''
+ if #lagerort < 2 then return nil, 'Bitte einen Lagerort angeben.' end
+
+ local kennung = Kern.ErzeugeKennung('AS', 'police_asservat', 'kennung')
+
+ local asservatId = MySQL.insert.await([[
+ INSERT INTO police_asservat
+ (kennung, kategorie, bezeichnung, menge, lagerort, verantwortlich, akte, beweis, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'eingelagert')
+ ]], {
+ kennung, daten.kategorie, bezeichnung,
+ type(daten.menge) == 'string' and daten.menge:sub(1, 48) or nil,
+ lagerort, beamter.charakterId,
+ tonumber(daten.akte), tonumber(daten.beweis),
+ })
+
+ Historie(asservatId, 'Eingelagert', beamter,
+ type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil)
+
+ Protokoll.Schreibe(beamter, 'asservat', 'Asservat eingelagert',
+ { typ = 'asservat', id = asservatId },
+ { kennung = kennung, kategorie = daten.kategorie, lagerort = lagerort })
+
+ Kern.Loese('asservat.eingelagert', asservatId, beamter)
+
+ return { id = asservatId, kennung = kennung }
+end)
+
+-- =====================================================================
+-- AUSGEBEN
+--
+-- Wer etwas herausgibt, übernimmt die Verantwortung dafür — deshalb
+-- sind Empfänger und Grund Pflicht.
+-- =====================================================================
+
+Kern.RegistriereAktion('asservat', 'ausgeben', 'asservat.ausgeben', function(quelle, beamter, daten)
+ local asservatId = tonumber(daten.id)
+ if not asservatId then return nil, 'Ungültige Kennung.' end
+
+ local asservat = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, status FROM police_asservat WHERE id = ?', { asservatId })
+ if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end
+
+ if asservat[1].status ~= 'eingelagert' then
+ return nil, 'Nur eingelagerte Asservate können ausgegeben werden.'
+ end
+
+ local empfaenger = type(daten.empfaenger) == 'string'
+ and daten.empfaenger:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #empfaenger < 2 then return nil, 'Bitte den Empfänger angeben.' end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 3 then return nil, 'Bitte einen Grund für die Ausgabe angeben.' end
+
+ MySQL.update.await("UPDATE police_asservat SET status = 'ausgegeben' WHERE id = ?", { asservatId })
+
+ Historie(asservatId, 'Ausgegeben an ' .. empfaenger, beamter, grund)
+
+ Protokoll.Schreibe(beamter, 'asservat', 'Asservat ausgegeben',
+ { typ = 'asservat', id = asservatId },
+ { kennung = asservat[1].kennung, empfaenger = empfaenger }, grund)
+
+ return { ok = true }
+end)
+
+--- Rückgabe in die Kammer
+Kern.RegistriereAktion('asservat', 'zurueckgeben', 'asservat.ausgeben', function(quelle, beamter, daten)
+ local asservatId = tonumber(daten.id)
+ if not asservatId then return nil, 'Ungültige Kennung.' end
+
+ local asservat = MySQL.query.await(
+ 'SELECT kennung, status FROM police_asservat WHERE id = ?', { asservatId })
+ if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end
+ if asservat[1].status ~= 'ausgegeben' then
+ return nil, 'Nur ausgegebene Asservate können zurückgenommen werden.'
+ end
+
+ local lagerort = type(daten.lagerort) == 'string' and daten.lagerort:sub(1, 64) or nil
+
+ MySQL.update.await([[
+ UPDATE police_asservat
+ SET status = 'eingelagert', lagerort = COALESCE(?, lagerort), verantwortlich = ?
+ WHERE id = ?
+ ]], { lagerort, beamter.charakterId, asservatId })
+
+ Historie(asservatId, 'Zurückgenommen', beamter,
+ type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil)
+
+ Protokoll.Schreibe(beamter, 'asservat', 'Asservat zurückgenommen',
+ { typ = 'asservat', id = asservatId }, { kennung = asservat[1].kennung })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- VERNICHTEN
+--
+-- Endgültig und nur mit eigenem Recht. Der Datensatz bleibt bestehen —
+-- vernichtet wird der Gegenstand, nicht seine Dokumentation.
+-- =====================================================================
+
+Kern.RegistriereAktion('asservat', 'vernichten', 'asservat.vernichten', function(quelle, beamter, daten)
+ local asservatId = tonumber(daten.id)
+ if not asservatId then return nil, 'Ungültige Kennung.' end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 5 then
+ return nil, 'Die Vernichtung erfordert eine ausführliche Begründung.'
+ end
+
+ local zeuge = type(daten.zeuge) == 'string' and daten.zeuge:sub(1, 64) or ''
+ if #zeuge < 2 then
+ return nil, 'Bitte einen zweiten Beamten als Zeugen angeben.'
+ end
+
+ local asservat = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, status FROM police_asservat WHERE id = ?', { asservatId })
+ if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end
+ if asservat[1].status == 'vernichtet' then
+ return nil, 'Dieses Asservat wurde bereits vernichtet.'
+ end
+
+ MySQL.update.await("UPDATE police_asservat SET status = 'vernichtet' WHERE id = ?", { asservatId })
+
+ Historie(asservatId, 'Vernichtet (Zeuge: ' .. zeuge .. ')', beamter, grund)
+
+ Protokoll.Schreibe(beamter, 'asservat', 'Asservat vernichtet',
+ { typ = 'asservat', id = asservatId },
+ { kennung = asservat[1].kennung, bezeichnung = asservat[1].bezeichnung, zeuge = zeuge },
+ grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'beweis', prioritaet = 'normal',
+ titel = 'Asservat vernichtet',
+ text = ('%s (%s) durch %s, Zeuge: %s')
+ :format(asservat[1].kennung, asservat[1].bezeichnung, beamter.dienstnummer, zeuge),
+ zielTyp = 'asservat', zielId = asservatId,
+ })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- LAGERORT ÄNDERN
+-- =====================================================================
+
+Kern.RegistriereAktion('asservat', 'umlagern', 'asservat.einlagern', function(quelle, beamter, daten)
+ local asservatId = tonumber(daten.id)
+ local lagerort = type(daten.lagerort) == 'string' and daten.lagerort:sub(1, 64) or ''
+
+ if not asservatId then return nil, 'Ungültige Kennung.' end
+ if #lagerort < 2 then return nil, 'Bitte einen Lagerort angeben.' end
+
+ local asservat = MySQL.query.await(
+ 'SELECT kennung, lagerort, status FROM police_asservat WHERE id = ?', { asservatId })
+ if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end
+ if asservat[1].status ~= 'eingelagert' then
+ return nil, 'Nur eingelagerte Asservate können umgelagert werden.'
+ end
+
+ MySQL.update.await('UPDATE police_asservat SET lagerort = ? WHERE id = ?',
+ { lagerort, asservatId })
+
+ Historie(asservatId, ('Umgelagert: %s → %s'):format(asservat[1].lagerort, lagerort), beamter)
+
+ Protokoll.Schreibe(beamter, 'asservat', 'Asservat umgelagert',
+ { typ = 'asservat', id = asservatId },
+ { von = asservat[1].lagerort, nach = lagerort })
+
+ return { ok = true }
+end)
diff --git a/resources/[aether]/aether-police/server/module/bericht.lua b/resources/[aether]/aether-police/server/module/bericht.lua
new file mode 100644
index 0000000..4eb8e93
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/bericht.lua
@@ -0,0 +1,582 @@
+-- =====================================================================
+-- aether-police / Modul: Berichtswesen
+--
+-- Ein Bericht durchläuft drei Stufen: Entwurf → Eingereicht → Freigegeben.
+-- Keine davon wird automatisch erreicht. Auch die Freigabe ist eine
+-- bewusste Handlung eines berechtigten Beamten und wird mit Namen
+-- festgehalten. Unterschriften sind verbindlich und nicht widerrufbar.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'bericht', name = 'Berichte', icon = '📄',
+ reihenfolge = 8, recht = 'bericht.erstellen',
+ beschreibung = 'Einsatzberichte verfassen und freigeben',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Entschlüsselt ein JSON-Feld sicher zu einer Liste
+--- @param roh string|nil
+--- @return table
+local function ZuListe(roh)
+ if not roh then return {} end
+ local erfolg, liste = pcall(json.decode, roh)
+ return (erfolg and type(liste) == 'table') and liste or {}
+end
+
+--- Lädt einen Bericht samt aufbereiteter Felder
+--- @param berichtId number
+--- @return table|nil
+local function LadeBericht(berichtId)
+ local zeilen = MySQL.query.await([[
+ SELECT b.*, c.firstname, c.lastname, p.dienstnummer,
+ e.nummer AS einsatz_nummer, e.titel AS einsatz_titel,
+ a.aktenzeichen, a.titel AS akte_titel
+ FROM police_bericht b
+ LEFT JOIN aether_characters c ON c.id = b.erstellt_von
+ LEFT JOIN police_personal p ON p.`character` = b.erstellt_von
+ LEFT JOIN police_einsatz e ON e.id = b.einsatz
+ LEFT JOIN police_akte a ON a.id = b.akte
+ WHERE b.id = ?
+ ]], { berichtId })
+
+ if not zeilen or not zeilen[1] then return nil end
+
+ local bericht = zeilen[1]
+ bericht.beteiligte = ZuListe(bericht.beteiligte)
+ bericht.anlagen = ZuListe(bericht.anlagen)
+ bericht.unterschriften = ZuListe(bericht.unterschriften)
+
+ return bericht
+end
+
+--- Darf der Beamte diesen Bericht inhaltlich ändern?
+--- @param bericht table
+--- @param beamter table
+--- @return boolean
+local function DarfBearbeiten(bericht, beamter)
+ -- Freigegebene Berichte sind gesperrt — auch für den Verfasser
+ if bericht.status == 'freigegeben' then return false end
+ if bericht.erstellt_von == beamter.charakterId then return true end
+ -- Fremde Berichte darf nur bearbeiten, wer auch freigeben darf
+ return Rechte.Hat(beamter, 'bericht.freigeben')
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.status and daten.status ~= '' then
+ bedingungen[#bedingungen + 1] = 'b.status = ?'
+ werte[#werte + 1] = daten.status
+ end
+ if daten.nurEigene then
+ bedingungen[#bedingungen + 1] = 'b.erstellt_von = ?'
+ werte[#werte + 1] = beamter.charakterId
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] = '(b.titel LIKE ? OR b.nummer LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local berichte = MySQL.query.await(([[
+ SELECT b.id, b.nummer, b.titel, b.status, b.erstellt_am, b.erstellt_von,
+ b.unterschriften, b.einsatz, b.akte,
+ c.firstname, c.lastname, p.dienstnummer,
+ e.nummer AS einsatz_nummer
+ FROM police_bericht b
+ LEFT JOIN aether_characters c ON c.id = b.erstellt_von
+ LEFT JOIN police_personal p ON p.`character` = b.erstellt_von
+ LEFT JOIN police_einsatz e ON e.id = b.einsatz
+ WHERE %s
+ ORDER BY
+ CASE b.status WHEN 'entwurf' THEN 0 WHEN 'eingereicht' THEN 1 ELSE 2 END,
+ b.id DESC
+ LIMIT 80
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ for _, bericht in ipairs(berichte) do
+ bericht.unterschriften = ZuListe(bericht.unterschriften)
+ bericht.eigener = bericht.erstellt_von == beamter.charakterId
+ end
+
+ -- Zahlen für die Übersicht
+ local offen = MySQL.scalar.await(
+ "SELECT COUNT(*) FROM police_bericht WHERE status = 'entwurf' AND erstellt_von = ?",
+ { beamter.charakterId }) or 0
+ local zurPruefung = MySQL.scalar.await(
+ "SELECT COUNT(*) FROM police_bericht WHERE status = 'eingereicht'") or 0
+
+ return {
+ berichte = berichte,
+ eigeneOffen = offen,
+ zurPruefung = zurPruefung,
+ rechte = {
+ erstellen = Rechte.Hat(beamter, 'bericht.erstellen'),
+ freigeben = Rechte.Hat(beamter, 'bericht.freigeben'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('bericht', 'oeffnen', nil, function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+
+ -- Habe ich bereits unterschrieben?
+ local habeUnterschrieben = false
+ for _, unterschrift in ipairs(bericht.unterschriften) do
+ if unterschrift.charakterId == beamter.charakterId then
+ habeUnterschrieben = true
+ break
+ end
+ end
+
+ return {
+ bericht = bericht,
+ habeUnterschrieben = habeUnterschrieben,
+ rechte = {
+ bearbeiten = DarfBearbeiten(bericht, beamter),
+ freigeben = Rechte.Hat(beamter, 'bericht.freigeben'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- ANLEGEN
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'erstellen', 'bericht.erstellen', function(quelle, beamter, daten)
+ local titel = type(daten.titel) == 'string'
+ and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #titel < 3 or #titel > 160 then
+ return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.'
+ end
+
+ -- Bezüge prüfen, falls angegeben
+ local einsatzId = tonumber(daten.einsatz)
+ if einsatzId then
+ local vorhanden = MySQL.scalar.await('SELECT id FROM police_einsatz WHERE id = ?', { einsatzId })
+ if not vorhanden then return nil, 'Der angegebene Einsatz existiert nicht.' end
+ end
+
+ local akteId = tonumber(daten.akte)
+ if akteId then
+ local vorhanden = MySQL.scalar.await('SELECT id FROM police_akte WHERE id = ?', { akteId })
+ if not vorhanden then return nil, 'Die angegebene Akte existiert nicht.' end
+ end
+
+ local nummer = Kern.ErzeugeKennung('BER', 'police_bericht', 'nummer')
+
+ -- Der Verfasser ist von Beginn an als beteiligter Beamter geführt
+ local beteiligte = { {
+ typ = 'beamter', charakterId = beamter.charakterId,
+ name = beamter.name, dienstnummer = beamter.dienstnummer,
+ rolle = 'Verfasser',
+ } }
+
+ local berichtId = MySQL.insert.await([[
+ INSERT INTO police_bericht
+ (nummer, einsatz, akte, titel, beschreibung, massnahmen, beteiligte,
+ anlagen, unterschriften, status, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', 'entwurf', ?)
+ ]], {
+ nummer, einsatzId, akteId, titel,
+ type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
+ type(daten.massnahmen) == 'string' and daten.massnahmen or nil,
+ json.encode(beteiligte), beamter.charakterId,
+ })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Bericht angelegt',
+ { typ = 'bericht', id = berichtId }, { nummer = nummer })
+
+ Kern.Loese('bericht.erstellt', berichtId, beamter)
+
+ return { id = berichtId, nummer = nummer }
+end)
+
+-- =====================================================================
+-- BEARBEITEN
+--
+-- Freigegebene Berichte sind gesperrt — auch für den Verfasser.
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'speichern', 'bericht.erstellen', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+
+ if not DarfBearbeiten(bericht, beamter) then
+ return nil, bericht.status == 'freigegeben'
+ and 'Freigegebene Berichte können nicht mehr geändert werden.'
+ or 'Du kannst nur eigene Berichte bearbeiten.'
+ end
+
+ -- Liegen bereits Unterschriften vor, ist eine Änderung heikel
+ if #bericht.unterschriften > 0 then
+ local grund = type(daten.grund) == 'string' and daten.grund or ''
+ if #grund < 5 then
+ return nil, 'Der Bericht wurde bereits unterschrieben. Eine Änderung erfordert eine Begründung.'
+ end
+ end
+
+ MySQL.update.await([[
+ UPDATE police_bericht SET titel = ?, beschreibung = ?, massnahmen = ? WHERE id = ?
+ ]], {
+ type(daten.titel) == 'string' and daten.titel:sub(1, 160) or bericht.titel,
+ type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
+ type(daten.massnahmen) == 'string' and daten.massnahmen or nil,
+ berichtId,
+ })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Bericht bearbeitet',
+ { typ = 'bericht', id = berichtId },
+ { unterschriften = #bericht.unterschriften },
+ type(daten.grund) == 'string' and daten.grund or nil)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Beteiligte
+-- ---------------------------------------------------------------------
+
+Kern.RegistriereAktion('bericht', 'beteiligtHinzu', 'bericht.erstellen', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+ if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
+
+ local typ = (daten.typ == 'beamter') and 'beamter' or 'person'
+ local name = type(daten.name) == 'string'
+ and daten.name:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #name < 2 then return nil, 'Bitte einen Namen angeben.' end
+
+ local charakterId = tonumber(daten.charakterId)
+
+ -- Doppelte Einträge vermeiden
+ for _, eintrag in ipairs(bericht.beteiligte) do
+ if charakterId and eintrag.charakterId == charakterId then
+ return nil, 'Diese Person ist bereits aufgeführt.'
+ end
+ end
+
+ table.insert(bericht.beteiligte, {
+ typ = typ, charakterId = charakterId, name = name:sub(1, 64),
+ rolle = type(daten.rolle) == 'string' and daten.rolle:sub(1, 48) or nil,
+ dienstnummer = type(daten.dienstnummer) == 'string' and daten.dienstnummer:sub(1, 16) or nil,
+ })
+
+ MySQL.update.await('UPDATE police_bericht SET beteiligte = ? WHERE id = ?',
+ { json.encode(bericht.beteiligte), berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Beteiligten ergänzt',
+ { typ = 'bericht', id = berichtId }, { name = name, rolle = daten.rolle })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('bericht', 'beteiligtEntfernen', 'bericht.erstellen', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ local index = tonumber(daten.index)
+ if not berichtId or not index then return nil, 'Unvollständige Angaben.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+ if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
+
+ local eintrag = bericht.beteiligte[index]
+ if not eintrag then return nil, 'Eintrag nicht gefunden.' end
+
+ -- Der Verfasser bleibt immer aufgeführt
+ if eintrag.charakterId == bericht.erstellt_von then
+ return nil, 'Der Verfasser kann nicht entfernt werden.'
+ end
+
+ table.remove(bericht.beteiligte, index)
+
+ MySQL.update.await('UPDATE police_bericht SET beteiligte = ? WHERE id = ?',
+ { json.encode(bericht.beteiligte), berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Beteiligten entfernt',
+ { typ = 'bericht', id = berichtId }, { name = eintrag.name })
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Anlagen (Dateien und Beweisbezüge)
+-- ---------------------------------------------------------------------
+
+Kern.RegistriereAktion('bericht', 'anlageHinzu', 'bericht.erstellen', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+ if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
+
+ local eintrag
+
+ if daten.typ == 'beweis' then
+ local beweisId = tonumber(daten.beweisId)
+ if not beweisId then return nil, 'Kein Beweismittel gewählt.' end
+
+ local beweis = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, art FROM police_beweis WHERE id = ?', { beweisId })
+ if not beweis or not beweis[1] then return nil, 'Beweismittel nicht gefunden.' end
+
+ for _, vorhanden in ipairs(bericht.anlagen) do
+ if vorhanden.typ == 'beweis' and vorhanden.beweisId == beweisId then
+ return nil, 'Dieses Beweismittel ist bereits aufgeführt.'
+ end
+ end
+
+ eintrag = {
+ typ = 'beweis', beweisId = beweisId,
+ kennung = beweis[1].kennung, titel = beweis[1].bezeichnung, art = beweis[1].art,
+ }
+ else
+ local titel = type(daten.titel) == 'string'
+ and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #titel < 2 then return nil, 'Bitte eine Bezeichnung angeben.' end
+
+ eintrag = {
+ typ = 'datei', titel = titel:sub(1, 120),
+ adresse = type(daten.adresse) == 'string' and daten.adresse:sub(1, 2000) or nil,
+ }
+ end
+
+ table.insert(bericht.anlagen, eintrag)
+
+ MySQL.update.await('UPDATE police_bericht SET anlagen = ? WHERE id = ?',
+ { json.encode(bericht.anlagen), berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Anlage ergänzt',
+ { typ = 'bericht', id = berichtId }, eintrag)
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('bericht', 'anlageEntfernen', 'bericht.erstellen', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ local index = tonumber(daten.index)
+ if not berichtId or not index then return nil, 'Unvollständige Angaben.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+ if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end
+
+ local eintrag = bericht.anlagen[index]
+ if not eintrag then return nil, 'Anlage nicht gefunden.' end
+
+ table.remove(bericht.anlagen, index)
+
+ MySQL.update.await('UPDATE police_bericht SET anlagen = ? WHERE id = ?',
+ { json.encode(bericht.anlagen), berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Anlage entfernt',
+ { typ = 'bericht', id = berichtId }, { titel = eintrag.titel })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- UNTERSCHREIBEN
+--
+-- Eine Unterschrift ist verbindlich und wird nicht zurückgenommen.
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'unterschreiben', nil, function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+
+ if bericht.status == 'freigegeben' then
+ return nil, 'Der Bericht ist bereits freigegeben.'
+ end
+
+ for _, unterschrift in ipairs(bericht.unterschriften) do
+ if unterschrift.charakterId == beamter.charakterId then
+ return nil, 'Du hast bereits unterschrieben.'
+ end
+ end
+
+ table.insert(bericht.unterschriften, {
+ charakterId = beamter.charakterId,
+ name = beamter.name,
+ dienstnummer = beamter.dienstnummer,
+ rolle = beamter.rolleName,
+ am = os.date('%Y-%m-%d %H:%M:%S'),
+ })
+
+ MySQL.update.await('UPDATE police_bericht SET unterschriften = ? WHERE id = ?',
+ { json.encode(bericht.unterschriften), berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Bericht unterschrieben',
+ { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- EINREICHEN UND FREIGEBEN
+-- =====================================================================
+
+Kern.RegistriereAktion('bericht', 'einreichen', 'bericht.erstellen', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+
+ if bericht.status ~= 'entwurf' then
+ return nil, 'Nur Entwürfe können eingereicht werden.'
+ end
+ if bericht.erstellt_von ~= beamter.charakterId
+ and not Rechte.Hat(beamter, 'bericht.freigeben') then
+ return nil, 'Nur der Verfasser kann seinen Bericht einreichen.'
+ end
+ if not bericht.beschreibung or #bericht.beschreibung < 20 then
+ return nil, 'Der Bericht enthält noch keine ausreichende Beschreibung.'
+ end
+ if #bericht.unterschriften == 0 then
+ return nil, 'Bitte unterschreibe den Bericht, bevor du ihn einreichst.'
+ end
+
+ MySQL.update.await("UPDATE police_bericht SET status = 'eingereicht' WHERE id = ?", { berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Bericht eingereicht',
+ { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer })
+
+ -- Die Führung darüber informieren, dass eine Prüfung ansteht
+ Hinweise.Erstelle(nil, {
+ kategorie = 'bericht', prioritaet = 'normal',
+ titel = 'Bericht zur Prüfung eingereicht',
+ text = ('%s „%s" von %s wartet auf Freigabe.')
+ :format(bericht.nummer, bericht.titel, beamter.dienstnummer),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('bericht', 'freigeben', 'bericht.freigeben', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+
+ if bericht.status == 'freigegeben' then
+ return nil, 'Der Bericht ist bereits freigegeben.'
+ end
+ if bericht.status ~= 'eingereicht' then
+ return nil, 'Nur eingereichte Berichte können freigegeben werden.'
+ end
+
+ -- Mit der Freigabe unterschreibt der Freigebende zugleich
+ local schonDrin = false
+ for _, unterschrift in ipairs(bericht.unterschriften) do
+ if unterschrift.charakterId == beamter.charakterId then schonDrin = true break end
+ end
+ if not schonDrin then
+ table.insert(bericht.unterschriften, {
+ charakterId = beamter.charakterId, name = beamter.name,
+ dienstnummer = beamter.dienstnummer, rolle = beamter.rolleName,
+ am = os.date('%Y-%m-%d %H:%M:%S'), freigabe = true,
+ })
+ end
+
+ MySQL.update.await(
+ "UPDATE police_bericht SET status = 'freigegeben', unterschriften = ? WHERE id = ?",
+ { json.encode(bericht.unterschriften), berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Bericht freigegeben',
+ { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }, daten.vermerk)
+
+ Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, {
+ kategorie = 'bericht', prioritaet = 'normal',
+ titel = 'Bericht freigegeben',
+ text = ('%s wurde von %s freigegeben.'):format(bericht.nummer, beamter.dienstnummer),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+
+ Kern.Loese('bericht.freigegeben', berichtId, beamter)
+
+ return { ok = true }
+end)
+
+--- Zurück in den Entwurf (Nachbesserung anfordern)
+Kern.RegistriereAktion('bericht', 'zurueckweisen', 'bericht.freigeben', function(quelle, beamter, daten)
+ local berichtId = tonumber(daten.id)
+ if not berichtId then return nil, 'Ungültige Kennung.' end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 5 then return nil, 'Bitte gib an, was nachgebessert werden soll.' end
+
+ local bericht = LadeBericht(berichtId)
+ if not bericht then return nil, 'Bericht nicht gefunden.' end
+ if bericht.status ~= 'eingereicht' then
+ return nil, 'Nur eingereichte Berichte können zurückgewiesen werden.'
+ end
+
+ MySQL.update.await("UPDATE police_bericht SET status = 'entwurf' WHERE id = ?", { berichtId })
+
+ Protokoll.Schreibe(beamter, 'bericht', 'Bericht zurückgewiesen',
+ { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }, grund)
+
+ Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, {
+ kategorie = 'bericht', prioritaet = 'hoch',
+ titel = 'Bericht zur Nachbesserung',
+ text = ('%s wurde zurückgewiesen: %s'):format(bericht.nummer, grund),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- HILFSABFRAGEN FÜR DIE OBERFLÄCHE
+-- =====================================================================
+
+--- Offene Einsätze und Akten zur Auswahl beim Anlegen
+Kern.RegistriereAktion('bericht', 'bezuege', nil, function(quelle, beamter, daten)
+ local einsaetze = MySQL.query.await([[
+ SELECT id, nummer, titel FROM police_einsatz
+ ORDER BY id DESC LIMIT 30
+ ]]) or {}
+
+ return { einsaetze = einsaetze }
+end)
+
+--- Beweismittel zur Auswahl als Anlage
+Kern.RegistriereAktion('bericht', 'beweisSuche', 'bericht.erstellen', function(quelle, beamter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { beweise = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local beweise = MySQL.query.await([[
+ SELECT id, kennung, art, bezeichnung, fundort
+ FROM police_beweis
+ WHERE kennung LIKE ? OR bezeichnung LIKE ?
+ ORDER BY id DESC LIMIT 20
+ ]], { muster, muster }) or {}
+
+ return { beweise = beweise }
+end)
diff --git a/resources/[aether]/aether-police/server/module/beweis.lua b/resources/[aether]/aether-police/server/module/beweis.lua
new file mode 100644
index 0000000..90cd52d
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/beweis.lua
@@ -0,0 +1,305 @@
+-- =====================================================================
+-- aether-police / Modul: Beweisverwaltung
+--
+-- Es gibt KEINE automatische Spurenerkennung. Jeder Beweis wird von
+-- einem Beamten erfasst, beschrieben und zugeordnet. Das System
+-- vergibt lediglich eine Kennung und hält die Verknüpfungen zusammen.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'beweis', name = 'Beweise', icon = '🔬',
+ reihenfolge = 6, recht = 'beweis.erfassen',
+ beschreibung = 'Beweismittel erfassen und zuordnen',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Prüft, ob eine Beweisart bekannt ist
+local function ArtGueltig(art)
+ for _, eintrag in ipairs(Polizei.Config.Beweisarten) do
+ if eintrag.id == art then return true end
+ end
+ return false
+end
+
+--- Lädt die Verknüpfungen eines Beweises (welche Akten nutzen ihn?)
+--- @param beweisId number
+--- @return table
+local function LadeVerknuepfungen(beweisId)
+ return MySQL.query.await([[
+ SELECT v.id, a.id AS akte, a.aktenzeichen, a.titel, a.typ, a.status
+ FROM police_verknuepfung v
+ JOIN police_akte a ON a.id = v.akte
+ WHERE v.typ = 'beweis' AND v.ziel_id = ?
+ ORDER BY a.id DESC
+ ]], { tostring(beweisId) }) or {}
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('beweis', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.art and daten.art ~= '' then
+ bedingungen[#bedingungen + 1] = 'b.art = ?'
+ werte[#werte + 1] = daten.art
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(b.bezeichnung LIKE ? OR b.kennung LIKE ? OR b.fundort LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+ if daten.akte then
+ bedingungen[#bedingungen + 1] = 'b.akte = ?'
+ werte[#werte + 1] = tonumber(daten.akte)
+ end
+
+ local beweise = MySQL.query.await(([[
+ SELECT b.id, b.kennung, b.art, b.bezeichnung, b.beschreibung, b.fundort,
+ b.gefunden_am, b.datei, b.akte, b.erfasst_am,
+ c.firstname, c.lastname, p.dienstnummer,
+ a.aktenzeichen
+ FROM police_beweis b
+ LEFT JOIN aether_characters c ON c.id = b.erfasst_von
+ LEFT JOIN police_personal p ON p.`character` = b.erfasst_von
+ LEFT JOIN police_akte a ON a.id = b.akte
+ WHERE %s
+ ORDER BY b.id DESC LIMIT 80
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ return {
+ beweise = beweise,
+ arten = Polizei.Config.Beweisarten,
+ rechte = {
+ erfassen = Rechte.Hat(beamter, 'beweis.erfassen'),
+ loeschen = Rechte.Hat(beamter, 'beweis.loeschen'),
+ akte = Rechte.Hat(beamter, 'akte.bearbeiten'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('beweis', 'oeffnen', nil, function(quelle, beamter, daten)
+ local beweisId = tonumber(daten.id)
+ if not beweisId then return nil, 'Ungültige Kennung.' end
+
+ local beweis = MySQL.query.await([[
+ SELECT b.*, c.firstname, c.lastname, p.dienstnummer, a.aktenzeichen, a.titel AS akte_titel
+ FROM police_beweis b
+ LEFT JOIN aether_characters c ON c.id = b.erfasst_von
+ LEFT JOIN police_personal p ON p.`character` = b.erfasst_von
+ LEFT JOIN police_akte a ON a.id = b.akte
+ WHERE b.id = ?
+ ]], { beweisId })
+
+ if not beweis or not beweis[1] then return nil, 'Beweis nicht gefunden.' end
+
+ -- Asservate, die zu diesem Beweis gehören
+ local asservate = MySQL.query.await([[
+ SELECT id, kennung, kategorie, bezeichnung, status, lagerort
+ FROM police_asservat WHERE beweis = ?
+ ]], { beweisId }) or {}
+
+ return {
+ beweis = beweis[1],
+ akten = LadeVerknuepfungen(beweisId),
+ asservate = asservate,
+ historie = Protokoll.Lies({ zielTyp = 'beweis', zielId = beweisId, limit = 30 }),
+ rechte = {
+ loeschen = Rechte.Hat(beamter, 'beweis.loeschen'),
+ akte = Rechte.Hat(beamter, 'akte.bearbeiten'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- ERFASSEN
+-- =====================================================================
+
+Kern.RegistriereAktion('beweis', 'erfassen', 'beweis.erfassen', function(quelle, beamter, daten)
+ if not ArtGueltig(daten.art) then return nil, 'Unbekannte Beweisart.' end
+
+ local bezeichnung = type(daten.bezeichnung) == 'string'
+ and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #bezeichnung < 3 or #bezeichnung > 160 then
+ return nil, 'Die Bezeichnung muss 3 bis 160 Zeichen lang sein.'
+ end
+
+ local fundort = type(daten.fundort) == 'string'
+ and daten.fundort:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #fundort < 2 then
+ return nil, 'Bitte einen Fundort angeben — er gehört zur Beweiskette.'
+ end
+
+ -- Akte prüfen, falls angegeben
+ local akteId = tonumber(daten.akte)
+ if akteId then
+ local vorhanden = MySQL.scalar.await('SELECT id FROM police_akte WHERE id = ?', { akteId })
+ if not vorhanden then return nil, 'Die angegebene Akte existiert nicht.' end
+ end
+
+ local kennung = Kern.ErzeugeKennung('BM', 'police_beweis', 'kennung')
+
+ local beweisId = MySQL.insert.await([[
+ INSERT INTO police_beweis
+ (kennung, akte, art, bezeichnung, beschreibung, fundort, datei, erfasst_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ kennung, akteId, daten.art, bezeichnung,
+ type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
+ fundort,
+ type(daten.datei) == 'string' and daten.datei:sub(1, 2000) or nil,
+ beamter.charakterId,
+ })
+
+ -- Ist eine Akte angegeben, gleich verknüpfen
+ if akteId then
+ MySQL.insert('INSERT INTO police_verknuepfung (akte, typ, ziel_id) VALUES (?, ?, ?)',
+ { akteId, 'beweis', tostring(beweisId) })
+ end
+
+ Protokoll.Schreibe(beamter, 'beweis', 'Beweis erfasst',
+ { typ = 'beweis', id = beweisId },
+ { kennung = kennung, art = daten.art, fundort = fundort })
+
+ Kern.Loese('beweis.erfasst', beweisId, beamter)
+
+ return { id = beweisId, kennung = kennung }
+end)
+
+-- =====================================================================
+-- BEARBEITEN
+-- =====================================================================
+
+Kern.RegistriereAktion('beweis', 'bearbeiten', 'beweis.erfassen', function(quelle, beamter, daten)
+ local beweisId = tonumber(daten.id)
+ if not beweisId then return nil, 'Ungültige Kennung.' end
+
+ local beweis = MySQL.query.await(
+ 'SELECT erfasst_von, kennung FROM police_beweis WHERE id = ?', { beweisId })
+ if not beweis or not beweis[1] then return nil, 'Beweis nicht gefunden.' end
+
+ -- Fremde Beweise darf nur ändern, wer auch löschen darf
+ if beweis[1].erfasst_von ~= beamter.charakterId
+ and not Rechte.Hat(beamter, 'beweis.loeschen') then
+ return nil, 'Du kannst nur eigene Beweise bearbeiten.'
+ end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 3 then
+ return nil, 'Änderungen an Beweismitteln erfordern einen Grund.'
+ end
+
+ MySQL.update.await([[
+ UPDATE police_beweis
+ SET bezeichnung = ?, beschreibung = ?, fundort = ?, datei = ?
+ WHERE id = ?
+ ]], {
+ type(daten.bezeichnung) == 'string' and daten.bezeichnung:sub(1, 160) or nil,
+ type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
+ type(daten.fundort) == 'string' and daten.fundort:sub(1, 160) or nil,
+ type(daten.datei) == 'string' and daten.datei:sub(1, 2000) or nil,
+ beweisId,
+ })
+
+ Protokoll.Schreibe(beamter, 'beweis', 'Beweis geändert',
+ { typ = 'beweis', id = beweisId }, { kennung = beweis[1].kennung }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ENTFERNEN
+--
+-- Beweise verschwinden nicht still — das Entfernen verlangt ein
+-- eigenes Recht und eine Begründung und bleibt im Protokoll sichtbar.
+-- =====================================================================
+
+Kern.RegistriereAktion('beweis', 'entfernen', 'beweis.loeschen', function(quelle, beamter, daten)
+ local beweisId = tonumber(daten.id)
+ if not beweisId then return nil, 'Ungültige Kennung.' end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 5 then
+ return nil, 'Das Entfernen eines Beweismittels erfordert eine ausführliche Begründung.'
+ end
+
+ local beweis = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, art FROM police_beweis WHERE id = ?', { beweisId })
+ if not beweis or not beweis[1] then return nil, 'Beweis nicht gefunden.' end
+
+ -- Verknüpfte Asservate müssen zuerst geklärt werden
+ local asservate = MySQL.scalar.await(
+ "SELECT COUNT(*) FROM police_asservat WHERE beweis = ? AND status = 'eingelagert'",
+ { beweisId }) or 0
+ if asservate > 0 then
+ return nil, ('Zu diesem Beweis sind noch %d Asservate eingelagert. Bitte zuerst klären.')
+ :format(asservate)
+ end
+
+ MySQL.update.await('DELETE FROM police_verknuepfung WHERE typ = ? AND ziel_id = ?',
+ { 'beweis', tostring(beweisId) })
+ MySQL.update.await('DELETE FROM police_beweis WHERE id = ?', { beweisId })
+
+ Protokoll.Schreibe(beamter, 'beweis', 'Beweis entfernt',
+ { typ = 'beweis', id = beweisId },
+ { kennung = beweis[1].kennung, bezeichnung = beweis[1].bezeichnung }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'beweis', prioritaet = 'hoch',
+ titel = 'Beweismittel entfernt',
+ text = ('%s (%s) wurde von %s entfernt: %s')
+ :format(beweis[1].kennung, beweis[1].bezeichnung, beamter.dienstnummer, grund),
+ })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- MIT AKTE VERKNÜPFEN
+-- =====================================================================
+
+Kern.RegistriereAktion('beweis', 'akteVerknuepfen', 'akte.bearbeiten', function(quelle, beamter, daten)
+ local beweisId = tonumber(daten.beweisId)
+ local akteId = tonumber(daten.akteId)
+ if not beweisId or not akteId then return nil, 'Unvollständige Angaben.' end
+
+ local akte = MySQL.query.await(
+ 'SELECT aktenzeichen FROM police_akte WHERE id = ?', { akteId })
+ if not akte or not akte[1] then return nil, 'Akte nicht gefunden.' end
+
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT id FROM police_verknuepfung WHERE akte = ? AND typ = ? AND ziel_id = ?',
+ { akteId, 'beweis', tostring(beweisId) })
+ if vorhanden then return nil, 'Diese Verknüpfung besteht bereits.' end
+
+ MySQL.insert.await('INSERT INTO police_verknuepfung (akte, typ, ziel_id) VALUES (?, ?, ?)',
+ { akteId, 'beweis', tostring(beweisId) })
+
+ Protokoll.Schreibe(beamter, 'beweis', 'Mit Akte verknüpft',
+ { typ = 'beweis', id = beweisId }, { akte = akte[1].aktenzeichen })
+
+ return { ok = true }
+end)
+
+--- Aktensuche für die Verknüpfungsauswahl
+Kern.RegistriereAktion('beweis', 'aktenSuche', 'akte.lesen', function(quelle, beamter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { akten = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local akten = MySQL.query.await([[
+ SELECT id, aktenzeichen, typ, titel, status
+ FROM police_akte
+ WHERE aktenzeichen LIKE ? OR titel LIKE ?
+ ORDER BY id DESC LIMIT 20
+ ]], { muster, muster }) or {}
+
+ return { akten = akten }
+end)
diff --git a/resources/[aether]/aether-police/server/module/dashboard.lua b/resources/[aether]/aether-police/server/module/dashboard.lua
new file mode 100644
index 0000000..520ca68
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/dashboard.lua
@@ -0,0 +1,170 @@
+-- =====================================================================
+-- aether-police / Modul: Dashboard
+--
+-- Zeigt eine Lageübersicht. Alle Zahlen werden bei jedem Aufruf frisch
+-- aus der Datenbank bzw. dem Dienstsystem geholt — nichts ist fest
+-- programmiert. Das Dashboard bewertet nichts, es zeigt nur an.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'dashboard', name = 'Übersicht', icon = '▤',
+ reihenfolge = 1,
+ beschreibung = 'Lageübersicht der Dienststelle',
+})
+
+--- Sammelt alle Kennzahlen für die Übersicht
+Kern.RegistriereAktion('dashboard', 'laden', nil, function(quelle, beamter)
+ local charakterId = beamter.charakterId
+
+ -- --- Beamte im Dienst ---
+ local dienstliste = Dienst.BaueListe()
+ local nachStatus = {}
+ for _, eintrag in ipairs(dienstliste) do
+ nachStatus[eintrag.status] = (nachStatus[eintrag.status] or 0) + 1
+ end
+
+ -- --- Offene Einsätze ---
+ local einsaetze = MySQL.query.await([[
+ SELECT id, nummer, titel, kategorie, prioritaet, ort, status, erstellt_am,
+ pos_x, pos_y, pos_z
+ FROM police_einsatz
+ WHERE status <> 'abgeschlossen'
+ ORDER BY prioritaet ASC, id DESC
+ LIMIT 20
+ ]]) or {}
+
+ -- --- Offene Anzeigen (Entwürfe warten auf Bestätigung) ---
+ local anzeigenOffen = MySQL.scalar.await(
+ "SELECT COUNT(*) FROM police_anzeige WHERE status = 'entwurf'") or 0
+
+ -- --- Aktive Fahndungen ---
+ local fahndungen = MySQL.query.await([[
+ SELECT id, typ, bezeichnung, grund, gefahr, prioritaet
+ FROM police_fahndung
+ WHERE aktiv = 1
+ ORDER BY prioritaet ASC, id DESC
+ LIMIT 10
+ ]]) or {}
+
+ -- --- Gefangene und auslaufende Haftzeiten ---
+ local gewahrsam = MySQL.query.await([[
+ SELECT g.id, g.character, g.grund, g.faellig_am, g.zelle,
+ TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest,
+ c.firstname, c.lastname
+ FROM police_gewahrsam g
+ JOIN aether_characters c ON c.id = g.character
+ WHERE g.entlassen_am IS NULL
+ ORDER BY g.faellig_am ASC
+ ]]) or {}
+
+ local abgelaufen = 0
+ for _, eintrag in ipairs(gewahrsam) do
+ if (tonumber(eintrag.rest) or 0) <= 0 then abgelaufen = abgelaufen + 1 end
+ end
+
+ -- --- Offene Berichte des eigenen Beamten ---
+ local eigeneBerichte = MySQL.query.await([[
+ SELECT id, nummer, titel, status, erstellt_am
+ FROM police_bericht
+ WHERE erstellt_von = ? AND status = 'entwurf'
+ ORDER BY id DESC LIMIT 10
+ ]], { charakterId }) or {}
+
+ -- --- Akten, die auf Freigabe warten ---
+ local akteFreigabe = MySQL.scalar.await(
+ "SELECT COUNT(*) FROM police_akte WHERE status = 'pruefung'") or 0
+
+ -- --- Dienstfahrzeuge im Einsatz ---
+ local fahrzeuge = MySQL.query.await([[
+ SELECT id, kennzeichen, bezeichnung, status, wartung, tank, zugewiesen_an
+ FROM police_fahrzeug
+ ORDER BY kennzeichen ASC
+ ]]) or {}
+
+ local fahrzeugeImEinsatz = 0
+ local wartungFaellig = 0
+ for _, fahrzeug in ipairs(fahrzeuge) do
+ if fahrzeug.status ~= 'verfuegbar' then fahrzeugeImEinsatz = fahrzeugeImEinsatz + 1 end
+ if fahrzeug.wartung ~= 'ok' then wartungFaellig = wartungFaellig + 1 end
+ end
+
+ -- --- Neueste Mitteilungen ---
+ local mitteilungen = MySQL.query.await([[
+ SELECT n.id, n.betreff, n.text, n.gesendet, c.firstname, c.lastname
+ FROM police_nachricht n
+ LEFT JOIN aether_characters c ON c.id = n.absender
+ WHERE n.empf_typ = 'rundmeldung'
+ OR (n.empf_typ = 'person' AND n.empfaenger = ?)
+ OR (n.empf_typ = 'abteilung' AND n.empfaenger = ?)
+ ORDER BY n.id DESC LIMIT 5
+ ]], { tostring(charakterId), beamter.abteilung }) or {}
+
+ -- --- Warnungen zusammenstellen (reine Hinweise) ---
+ local warnungen = {}
+ if abgelaufen > 0 then
+ warnungen[#warnungen + 1] = {
+ prioritaet = 'hoch',
+ text = ('%d Haftzeit%s abgelaufen — Entlassung muss manuell entschieden werden.')
+ :format(abgelaufen, abgelaufen == 1 and '' or 'en'),
+ zielTyp = 'gewahrsam',
+ }
+ end
+ if anzeigenOffen > 0 then
+ warnungen[#warnungen + 1] = {
+ prioritaet = 'normal',
+ text = ('%d Anzeige%s im Entwurf — wartet auf Bestätigung.')
+ :format(anzeigenOffen, anzeigenOffen == 1 and '' or 'n'),
+ zielTyp = 'anzeigen',
+ }
+ end
+ if akteFreigabe > 0 then
+ warnungen[#warnungen + 1] = {
+ prioritaet = 'normal',
+ text = ('%d Akte%s wartet auf Freigabe.')
+ :format(akteFreigabe, akteFreigabe == 1 and '' or 'n'),
+ zielTyp = 'akten',
+ }
+ end
+ if wartungFaellig > 0 then
+ warnungen[#warnungen + 1] = {
+ prioritaet = 'niedrig',
+ text = ('%d Dienstfahrzeug%s benötigt Wartung.')
+ :format(wartungFaellig, wartungFaellig == 1 and '' or 'e'),
+ zielTyp = 'fahrzeuge',
+ }
+ end
+
+ return {
+ kennzahlen = {
+ imDienst = #dienstliste,
+ verfuegbar = nachStatus.verfuegbar or 0,
+ imEinsatz = nachStatus.einsatz or 0,
+ pause = nachStatus.pause or 0,
+ einsaetzeOffen = #einsaetze,
+ anzeigenOffen = anzeigenOffen,
+ fahndungen = #fahndungen,
+ gefangene = #gewahrsam,
+ haftAbgelaufen = abgelaufen,
+ berichteOffen = #eigeneBerichte,
+ akteFreigabe = akteFreigabe,
+ fahrzeugeGesamt = #fahrzeuge,
+ fahrzeugeEinsatz= fahrzeugeImEinsatz,
+ },
+ beamte = dienstliste,
+ einsaetze = einsaetze,
+ fahndungen = fahndungen,
+ gewahrsam = gewahrsam,
+ berichte = eigeneBerichte,
+ fahrzeuge = fahrzeuge,
+ mitteilungen = mitteilungen,
+ warnungen = warnungen,
+ hinweise = Hinweise.Lade(charakterId, 15),
+ }
+end)
+
+--- Markiert einen Hinweis als gelesen
+Kern.RegistriereAktion('dashboard', 'hinweisGelesen', nil, function(quelle, beamter, daten)
+ if type(daten.id) ~= 'number' then return nil, 'Ungültige Kennung.' end
+ Hinweise.MarkiereGelesen(beamter.charakterId, daten.id)
+ return { ok = true }
+end)
diff --git a/resources/[aether]/aether-police/server/module/einsatz.lua b/resources/[aether]/aether-police/server/module/einsatz.lua
new file mode 100644
index 0000000..6d369ca
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/einsatz.lua
@@ -0,0 +1,495 @@
+-- =====================================================================
+-- aether-police / Modul: Einsatzverwaltung & Dispatch
+--
+-- Einsätze entstehen entweder durch einen Beamten oder durch einen
+-- eingehenden Notruf. Sie werden NIE automatisch zugewiesen,
+-- bearbeitet oder geschlossen — das entscheidet die Leitstelle
+-- beziehungsweise der jeweilige Beamte.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'einsatz', name = 'Einsätze', icon = '🚨',
+ reihenfolge = 3, recht = 'einsatz.erstellen',
+ beschreibung = 'Dispatch und Einsatzverwaltung',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Prüft, ob eine Einsatzkategorie bekannt ist
+local function KategorieGueltig(kategorie)
+ for _, eintrag in ipairs(Polizei.Config.Einsatzkategorien) do
+ if eintrag.id == kategorie then return true end
+ end
+ return false
+end
+
+--- Lädt einen Einsatz samt beteiligten Beamten und Notizen
+--- @param einsatzId number
+--- @return table|nil
+local function LadeEinsatz(einsatzId)
+ local einsatz = MySQL.query.await([[
+ SELECT e.*, c.firstname AS ersteller_vorname, c.lastname AS ersteller_nachname
+ FROM police_einsatz e
+ LEFT JOIN aether_characters c ON c.id = e.erstellt_von
+ WHERE e.id = ?
+ ]], { einsatzId })
+
+ if not einsatz or not einsatz[1] then return nil end
+ local eintrag = einsatz[1]
+
+ eintrag.beamte = MySQL.query.await([[
+ SELECT b.character, b.beigetreten, b.verlassen,
+ c.firstname, c.lastname, p.dienstnummer
+ FROM police_einsatz_beamter b
+ LEFT JOIN aether_characters c ON c.id = b.character
+ LEFT JOIN police_personal p ON p.`character` = b.character
+ WHERE b.einsatz = ?
+ ORDER BY b.id ASC
+ ]], { einsatzId }) or {}
+
+ eintrag.notizen = MySQL.query.await([[
+ SELECT n.id, n.text, n.am, c.firstname, c.lastname, p.dienstnummer
+ FROM police_einsatz_notiz n
+ LEFT JOIN aether_characters c ON c.id = n.von
+ LEFT JOIN police_personal p ON p.`character` = n.von
+ WHERE n.einsatz = ?
+ ORDER BY n.id ASC
+ ]], { einsatzId }) or {}
+
+ return eintrag
+end
+
+--- Informiert alle Beamten im Dienst über eine Änderung an den Einsätzen
+local function VerteileEinsaetze()
+ for quelle in pairs(Dienst.AlleImDienst()) do
+ TriggerClientEvent('aether:police:einsatzAenderung', quelle)
+ end
+end
+
+--- Zählt, wie viele Beamte einem Einsatz aktuell zugeordnet sind
+local function AnzahlBeamte(einsatzId)
+ return MySQL.scalar.await(
+ 'SELECT COUNT(*) FROM police_einsatz_beamter WHERE einsatz = ? AND verlassen IS NULL',
+ { einsatzId }) or 0
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'liste', nil, function(quelle, beamter, daten)
+ local nurOffene = daten.nurOffene ~= false
+
+ local einsaetze = MySQL.query.await(([[
+ SELECT e.id, e.nummer, e.titel, e.kategorie, e.prioritaet, e.ort,
+ e.pos_x, e.pos_y, e.pos_z, e.status, e.quelle, e.erstellt_am,
+ e.beschreibung,
+ (SELECT COUNT(*) FROM police_einsatz_beamter b
+ WHERE b.einsatz = e.id AND b.verlassen IS NULL) AS einheiten,
+ (SELECT COUNT(*) FROM police_einsatz_beamter b2
+ WHERE b2.einsatz = e.id AND b2.character = ? AND b2.verlassen IS NULL) AS ichDabei
+ FROM police_einsatz e
+ %s
+ ORDER BY
+ CASE WHEN e.status = 'offen' THEN 0 ELSE 1 END,
+ e.prioritaet ASC, e.id DESC
+ LIMIT 60
+ ]]):format(nurOffene and "WHERE e.status <> 'abgeschlossen'" or ''),
+ { beamter.charakterId }) or {}
+
+ return {
+ einsaetze = einsaetze,
+ kategorien = Polizei.Config.Einsatzkategorien,
+ istLeitstelle = Dienst.Leitstelle() == quelle,
+ beamteImDienst = Dienst.BaueListe(),
+ rechte = {
+ zuweisen = Rechte.Hat(beamter, 'einsatz.zuweisen'),
+ schliessen = Rechte.Hat(beamter, 'einsatz.schliessen'),
+ bericht = Rechte.Hat(beamter, 'bericht.erstellen'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('einsatz', 'oeffnen', nil, function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local einsatz = LadeEinsatz(einsatzId)
+ if not einsatz then return nil, 'Einsatz nicht gefunden.' end
+
+ -- Bin ich selbst beteiligt?
+ einsatz.ichDabei = false
+ for _, eintrag in ipairs(einsatz.beamte) do
+ if eintrag.character == beamter.charakterId and not eintrag.verlassen then
+ einsatz.ichDabei = true
+ break
+ end
+ end
+
+ return {
+ einsatz = einsatz,
+ istLeitstelle = Dienst.Leitstelle() == quelle,
+ beamteImDienst = Dienst.BaueListe(),
+ rechte = {
+ zuweisen = Rechte.Hat(beamter, 'einsatz.zuweisen'),
+ schliessen = Rechte.Hat(beamter, 'einsatz.schliessen'),
+ bericht = Rechte.Hat(beamter, 'bericht.erstellen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- ANLEGEN
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'erstellen', 'einsatz.erstellen', function(quelle, beamter, daten)
+ local titel = type(daten.titel) == 'string' and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #titel < 3 or #titel > 160 then
+ return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.'
+ end
+
+ if not KategorieGueltig(daten.kategorie) then
+ return nil, 'Unbekannte Kategorie.'
+ end
+
+ local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 3))
+ local nummer = Kern.ErzeugeKennung('EIN', 'police_einsatz', 'nummer')
+
+ -- Position: entweder mitgeschickt oder die des erstellenden Beamten
+ local position = daten.position
+ if daten.eigenePosition then
+ local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
+ position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z }
+ end
+
+ local einsatzId = MySQL.insert.await([[
+ INSERT INTO police_einsatz
+ (nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z,
+ beschreibung, status, quelle, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', 'manuell', ?)
+ ]], {
+ nummer, titel, daten.kategorie, prioritaet,
+ type(daten.ort) == 'string' and daten.ort:sub(1, 160) or nil,
+ position and position.x, position and position.y, position and position.z,
+ type(daten.beschreibung) == 'string' and daten.beschreibung or nil,
+ beamter.charakterId,
+ })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz angelegt',
+ { typ = 'einsatz', id = einsatzId }, { nummer = nummer, kategorie = daten.kategorie })
+
+ -- Alle Beamten im Dienst informieren — zugewiesen wird nichts
+ Hinweise.Erstelle(nil, {
+ kategorie = 'einsatz',
+ prioritaet = prioritaet == 1 and 'kritisch' or prioritaet == 2 and 'hoch' or 'normal',
+ titel = 'Neuer Einsatz: ' .. titel,
+ text = (daten.ort and ('Ort: ' .. daten.ort .. ' · ') or '') .. 'Noch keine Einheit zugeordnet.',
+ zielTyp = 'einsatz', zielId = einsatzId,
+ })
+
+ Kern.Loese('einsatz.erstellt', einsatzId, beamter)
+ VerteileEinsaetze()
+
+ return { id = einsatzId, nummer = nummer }
+end)
+
+-- =====================================================================
+-- BEITRETEN / VERLASSEN — immer freiwillig
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'beitreten', nil, function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local einsatz = MySQL.query.await('SELECT status FROM police_einsatz WHERE id = ?', { einsatzId })
+ if not einsatz or not einsatz[1] then return nil, 'Einsatz nicht gefunden.' end
+ if einsatz[1].status == 'abgeschlossen' then
+ return nil, 'Dieser Einsatz ist bereits abgeschlossen.'
+ end
+
+ -- Bereits dabei? Dann nichts tun
+ local vorhanden = MySQL.scalar.await([[
+ SELECT id FROM police_einsatz_beamter
+ WHERE einsatz = ? AND `character` = ? AND verlassen IS NULL
+ ]], { einsatzId, beamter.charakterId })
+ if vorhanden then return { ok = true } end
+
+ MySQL.insert.await('INSERT INTO police_einsatz_beamter (einsatz, `character`) VALUES (?, ?)',
+ { einsatzId, beamter.charakterId })
+
+ -- Sobald jemand dabei ist, gilt der Einsatz als zugewiesen
+ MySQL.update("UPDATE police_einsatz SET status = 'zugewiesen' WHERE id = ? AND status = 'offen'",
+ { einsatzId })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz beigetreten', { typ = 'einsatz', id = einsatzId })
+ Kern.Loese('einsatz.beigetreten', einsatzId, beamter)
+ VerteileEinsaetze()
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('einsatz', 'verlassen', nil, function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ MySQL.update.await([[
+ UPDATE police_einsatz_beamter SET verlassen = NOW()
+ WHERE einsatz = ? AND `character` = ? AND verlassen IS NULL
+ ]], { einsatzId, beamter.charakterId })
+
+ -- Verlässt der letzte Beamte den Einsatz, geht er zurück auf „offen"
+ if AnzahlBeamte(einsatzId) == 0 then
+ MySQL.update("UPDATE police_einsatz SET status = 'offen' WHERE id = ? AND status <> 'abgeschlossen'",
+ { einsatzId })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'einsatz', prioritaet = 'normal',
+ titel = 'Einsatz ohne Einheit',
+ text = 'Der Einsatz hat keine zugeordnete Einheit mehr.',
+ zielTyp = 'einsatz', zielId = einsatzId,
+ })
+ end
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz verlassen', { typ = 'einsatz', id = einsatzId })
+ VerteileEinsaetze()
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZUWEISUNG DURCH DIE LEITSTELLE
+--
+-- Die Leitstelle spricht eine Zuweisung AUS — sie zwingt niemanden.
+-- Der angesprochene Beamte entscheidet selbst, ob er beitritt.
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'zuweisen', 'einsatz.zuweisen', function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ local zielQuelle = tonumber(daten.quelle)
+ if not einsatzId or not zielQuelle then return nil, 'Unvollständige Angaben.' end
+
+ local ziel = Dienst.Hole(zielQuelle)
+ if not ziel then return nil, 'Diese Einheit ist nicht im Dienst.' end
+
+ local einsatz = MySQL.query.await(
+ 'SELECT nummer, titel, ort, pos_x, pos_y, pos_z FROM police_einsatz WHERE id = ?',
+ { einsatzId })
+ if not einsatz or not einsatz[1] then return nil, 'Einsatz nicht gefunden.' end
+
+ -- Aufforderung an die Einheit — kein automatischer Beitritt
+ TriggerClientEvent('aether:police:einsatzZuweisung', zielQuelle, {
+ id = einsatzId,
+ nummer = einsatz[1].nummer,
+ titel = einsatz[1].titel,
+ ort = einsatz[1].ort,
+ von = beamter.name .. ' (' .. beamter.dienstnummer .. ')',
+ position = einsatz[1].pos_x and {
+ x = einsatz[1].pos_x, y = einsatz[1].pos_y, z = einsatz[1].pos_z,
+ } or nil,
+ })
+
+ Hinweise.Erstelle({ charakterId = ziel.charakterId }, {
+ kategorie = 'einsatz', prioritaet = 'hoch',
+ titel = 'Zuweisung: ' .. einsatz[1].titel,
+ text = ('%s bittet dich, den Einsatz zu übernehmen.'):format(beamter.dienstnummer),
+ zielTyp = 'einsatz', zielId = einsatzId,
+ })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Einheit zugewiesen',
+ { typ = 'einsatz', id = einsatzId }, { einheit = ziel.dienstnummer })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- STATUS UND NOTIZEN
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'status', nil, function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ local status = tostring(daten.status or '')
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local erlaubt = { offen = true, zugewiesen = true, vorort = true }
+ if not erlaubt[status] then return nil, 'Unbekannter Status.' end
+
+ MySQL.update.await("UPDATE police_einsatz SET status = ? WHERE id = ? AND status <> 'abgeschlossen'",
+ { status, einsatzId })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Status geändert',
+ { typ = 'einsatz', id = einsatzId }, { status = status })
+ VerteileEinsaetze()
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('einsatz', 'notiz', nil, function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ local text = type(daten.text) == 'string' and daten.text:gsub('^%s+', ''):gsub('%s+$', '') or ''
+
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+ if #text < 2 or #text > 1000 then return nil, 'Die Notiz muss 2 bis 1000 Zeichen lang sein.' end
+
+ MySQL.insert.await('INSERT INTO police_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)',
+ { einsatzId, text, beamter.charakterId })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Notiz ergänzt', { typ = 'einsatz', id = einsatzId })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ABSCHLIESSEN
+--
+-- Beim Abschluss wird KEIN Bericht automatisch erzeugt. Das System
+-- bietet lediglich an, einen vorbereiteten Entwurf anzulegen.
+-- =====================================================================
+
+Kern.RegistriereAktion('einsatz', 'abschliessen', 'einsatz.schliessen', function(quelle, beamter, daten)
+ local einsatzId = tonumber(daten.id)
+ if not einsatzId then return nil, 'Ungültige Kennung.' end
+
+ local einsatz = LadeEinsatz(einsatzId)
+ if not einsatz then return nil, 'Einsatz nicht gefunden.' end
+ if einsatz.status == 'abgeschlossen' then return nil, 'Der Einsatz ist bereits abgeschlossen.' end
+
+ MySQL.update.await([[
+ UPDATE police_einsatz
+ SET status = 'abgeschlossen', geschlossen_von = ?, geschlossen_am = NOW()
+ WHERE id = ?
+ ]], { beamter.charakterId, einsatzId })
+
+ -- Alle noch offenen Beteiligungen beenden
+ MySQL.update('UPDATE police_einsatz_beamter SET verlassen = NOW() WHERE einsatz = ? AND verlassen IS NULL',
+ { einsatzId })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz abgeschlossen',
+ { typ = 'einsatz', id = einsatzId }, nil, daten.grund)
+
+ local ergebnis = { ok = true }
+
+ -- Nur auf ausdrücklichen Wunsch einen Berichtsentwurf vorbereiten
+ if daten.berichtAnlegen and Rechte.Hat(beamter, 'bericht.erstellen') then
+ local nummer = Kern.ErzeugeKennung('BER', 'police_bericht', 'nummer')
+
+ -- Beteiligte aus dem Einsatz übernehmen
+ local beteiligte = {}
+ for _, eintrag in ipairs(einsatz.beamte) do
+ beteiligte[#beteiligte + 1] = {
+ dienstnummer = eintrag.dienstnummer,
+ name = (eintrag.firstname or '') .. ' ' .. (eintrag.lastname or ''),
+ }
+ end
+
+ -- Notizen als Ausgangstext, damit nichts abgetippt werden muss
+ local zeilen = {}
+ for _, notiz in ipairs(einsatz.notizen) do
+ zeilen[#zeilen + 1] = ('[%s] %s'):format(
+ tostring(notiz.am):sub(12, 16), notiz.text)
+ end
+
+ local berichtId = MySQL.insert.await([[
+ INSERT INTO police_bericht
+ (nummer, einsatz, titel, beschreibung, beteiligte, status, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, 'entwurf', ?)
+ ]], {
+ nummer, einsatzId, einsatz.titel,
+ (einsatz.beschreibung and (einsatz.beschreibung .. '\n\n') or '') ..
+ (#zeilen > 0 and ('Einsatzverlauf:\n' .. table.concat(zeilen, '\n')) or ''),
+ json.encode(beteiligte), beamter.charakterId,
+ })
+
+ Hinweise.Erstelle({ charakterId = beamter.charakterId }, {
+ kategorie = 'bericht', prioritaet = 'normal',
+ titel = 'Berichtsentwurf angelegt',
+ text = ('%s wurde als Entwurf vorbereitet und wartet auf deine Ausarbeitung.'):format(nummer),
+ zielTyp = 'bericht', zielId = berichtId,
+ })
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Berichtsentwurf angelegt',
+ { typ = 'bericht', id = berichtId }, { einsatz = einsatzId })
+
+ ergebnis.berichtId = berichtId
+ ergebnis.berichtNummer = nummer
+ end
+
+ Kern.Loese('einsatz.abgeschlossen', einsatzId, beamter)
+ VerteileEinsaetze()
+
+ return ergebnis
+end)
+
+-- =====================================================================
+-- EINGEHENDE MELDUNGEN (Dispatch)
+-- =====================================================================
+
+--- Legt einen eingehenden Einsatz an, ohne ihn zuzuweisen
+--- @param daten table { titel, kategorie, prioritaet, ort, beschreibung, position, quelle }
+--- @return number Einsatz-ID
+function EinsatzEingang(daten)
+ local nummer = Kern.ErzeugeKennung('EIN', 'police_einsatz', 'nummer')
+ local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 2))
+
+ local einsatzId = MySQL.insert.await([[
+ INSERT INTO police_einsatz
+ (nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z,
+ beschreibung, status, quelle)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', ?)
+ ]], {
+ nummer, daten.titel, KategorieGueltig(daten.kategorie) and daten.kategorie or 'notruf',
+ prioritaet, daten.ort,
+ daten.position and daten.position.x, daten.position and daten.position.y,
+ daten.position and daten.position.z,
+ daten.beschreibung, daten.quelle or 'notruf',
+ })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'einsatz',
+ prioritaet = prioritaet == 1 and 'kritisch' or 'hoch',
+ titel = daten.titel,
+ text = (daten.ort and ('Ort: ' .. daten.ort .. ' · ') or '') ..
+ 'Eingegangen — Zuweisung durch die Leitstelle.',
+ zielTyp = 'einsatz', zielId = einsatzId,
+ })
+
+ Kern.Loese('einsatz.eingegangen', einsatzId, daten)
+ VerteileEinsaetze()
+
+ return einsatzId
+end
+
+--- Panikknopf eines Beamten (10-13)
+RegisterNetEvent('aether:police:panik', function()
+ local quelle = source
+ local beamter = Dienst.Hole(quelle)
+ if not beamter then return end
+
+ local koordinaten = GetEntityCoords(GetPlayerPed(quelle))
+
+ local einsatzId = EinsatzEingang({
+ titel = '10-13 — Beamter in Not',
+ kategorie = 'panik',
+ prioritaet = 1,
+ ort = 'Position der Einheit',
+ beschreibung = ('Panikknopf ausgelöst von %s (%s).')
+ :format(beamter.name, beamter.dienstnummer),
+ position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z },
+ quelle = 'panik',
+ })
+
+ -- Alle Beamten im Dienst mit Ton und Wegpunkt alarmieren
+ for andereQuelle in pairs(Dienst.AlleImDienst()) do
+ if andereQuelle ~= quelle then
+ TriggerClientEvent('aether:police:panikAlarm', andereQuelle, {
+ name = beamter.name, dienstnummer = beamter.dienstnummer,
+ position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z },
+ einsatzId = einsatzId,
+ })
+ end
+ end
+
+ Protokoll.Schreibe(beamter, 'einsatz', 'Panikknopf ausgelöst',
+ { typ = 'einsatz', id = einsatzId })
+end)
diff --git a/resources/[aether]/aether-police/server/module/fahndung.lua b/resources/[aether]/aether-police/server/module/fahndung.lua
new file mode 100644
index 0000000..36dfc13
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/fahndung.lua
@@ -0,0 +1,399 @@
+-- =====================================================================
+-- aether-police / Modul: Fahndungen
+--
+-- Fahndungen entstehen ausschliesslich durch einen Beamten. Es gibt
+-- keinen Automatismus, der jemanden aufgrund von Vorgängen zur Fahndung
+-- ausschreibt — und keinen, der eine Fahndung von selbst aufhebt.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'fahndung', name = 'Fahndungen', icon = '🔍',
+ reihenfolge = 9, recht = 'fahndung.erstellen',
+ beschreibung = 'Personen- und Sachfahndung',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+local GEFAHRSTUFEN = { normal = true, bewaffnet = true, gefaehrlich = true }
+
+--- Ermittelt die Bezeichnung eines Fahndungsziels
+--- @param typ string 'person' | 'fahrzeug'
+--- @param zielId string
+--- @return string|nil Bezeichnung, string|nil Fehler
+local function BezeichnungFuerZiel(typ, zielId)
+ if typ == 'person' then
+ local charakterId = tonumber(zielId)
+ if not charakterId then return nil, 'Ungültige Personenkennung.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname, dob FROM aether_characters WHERE id = ? AND deleted = 0',
+ { charakterId })
+ if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end
+
+ return ('%s %s (geb. %s)'):format(
+ person[1].firstname, person[1].lastname,
+ tostring(person[1].dob):sub(1, 10))
+ end
+
+ -- Fahrzeug
+ local kennzeichen = tostring(zielId):upper()
+ local fahrzeug = MySQL.query.await([[
+ SELECT v.plate, v.model, v.props, c.firstname, c.lastname
+ FROM aether_vehicles v
+ LEFT JOIN aether_characters c ON c.id = v.owner
+ WHERE UPPER(v.plate) = ?
+ ]], { kennzeichen })
+
+ if not fahrzeug or not fahrzeug[1] then
+ return nil, 'Kein Fahrzeug mit diesem Kennzeichen.'
+ end
+
+ local name = fahrzeug[1].model
+ if fahrzeug[1].props then
+ local erfolg, props = pcall(json.decode, fahrzeug[1].props)
+ if erfolg and type(props) == 'table' and props.name then name = props.name end
+ end
+
+ return ('%s · %s'):format(fahrzeug[1].plate, name)
+end
+
+--- Reichert eine Fahndung um die Daten ihres Ziels an
+--- @param fahndung table
+local function ZielDatenErgaenzen(fahndung)
+ if fahndung.typ == 'person' then
+ local person = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, p.lichtbild,
+ p.warnhinweis, p.gefahrenstufe
+ FROM aether_characters c
+ LEFT JOIN police_person p ON p.`character` = c.id
+ WHERE c.id = ?
+ ]], { tonumber(fahndung.ziel_id) })
+ fahndung.ziel = person and person[1] or nil
+ else
+ local fahrzeug = MySQL.query.await([[
+ SELECT v.plate, v.model, v.props, v.state, v.owner,
+ c.firstname, c.lastname
+ FROM aether_vehicles v
+ LEFT JOIN aether_characters c ON c.id = v.owner
+ WHERE v.plate = ?
+ ]], { fahndung.ziel_id })
+
+ if fahrzeug and fahrzeug[1] then
+ local eintrag = fahrzeug[1]
+ if eintrag.props then
+ local erfolg, props = pcall(json.decode, eintrag.props)
+ if erfolg and type(props) == 'table' then
+ eintrag.name = props.name
+ eintrag.farbe = props.farbe
+ end
+ end
+ eintrag.props = nil
+ fahndung.ziel = eintrag
+ end
+ end
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('fahndung', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ -- Standardmäßig nur aktive Fahndungen
+ if daten.archiv then
+ bedingungen[#bedingungen + 1] = 'f.aktiv = 0'
+ else
+ bedingungen[#bedingungen + 1] = 'f.aktiv = 1'
+ end
+
+ if daten.typ and daten.typ ~= '' then
+ bedingungen[#bedingungen + 1] = 'f.typ = ?'
+ werte[#werte + 1] = daten.typ
+ end
+ if daten.gefahr and daten.gefahr ~= '' then
+ bedingungen[#bedingungen + 1] = 'f.gefahr = ?'
+ werte[#werte + 1] = daten.gefahr
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] = '(f.bezeichnung LIKE ? OR f.grund LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local fahndungen = MySQL.query.await(([[
+ SELECT f.id, f.typ, f.ziel_id, f.bezeichnung, f.grund, f.gefahr,
+ f.prioritaet, f.hinweise, f.bild, f.aktiv, f.erstellt_am,
+ f.aufgehoben_am,
+ c.firstname, c.lastname, p.dienstnummer,
+ a.firstname AS a_vorname, a.lastname AS a_nachname
+ FROM police_fahndung f
+ LEFT JOIN aether_characters c ON c.id = f.erstellt_von
+ LEFT JOIN police_personal p ON p.`character` = f.erstellt_von
+ LEFT JOIN aether_characters a ON a.id = f.aufgehoben_von
+ WHERE %s
+ ORDER BY f.prioritaet ASC, f.id DESC
+ LIMIT 80
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ -- Zahlen für die Übersicht
+ local zahlen = MySQL.query.await([[
+ SELECT gefahr, COUNT(*) AS anzahl FROM police_fahndung WHERE aktiv = 1 GROUP BY gefahr
+ ]]) or {}
+
+ local bestand = { normal = 0, bewaffnet = 0, gefaehrlich = 0 }
+ for _, zeile in ipairs(zahlen) do bestand[zeile.gefahr] = zeile.anzahl end
+
+ return {
+ fahndungen = fahndungen,
+ bestand = bestand,
+ rechte = {
+ erstellen = Rechte.Hat(beamter, 'fahndung.erstellen'),
+ aufheben = Rechte.Hat(beamter, 'fahndung.aufheben'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('fahndung', 'oeffnen', nil, function(quelle, beamter, daten)
+ local fahndungId = tonumber(daten.id)
+ if not fahndungId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT f.*, c.firstname, c.lastname, p.dienstnummer,
+ a.firstname AS a_vorname, a.lastname AS a_nachname
+ FROM police_fahndung f
+ LEFT JOIN aether_characters c ON c.id = f.erstellt_von
+ LEFT JOIN police_personal p ON p.`character` = f.erstellt_von
+ LEFT JOIN aether_characters a ON a.id = f.aufgehoben_von
+ WHERE f.id = ?
+ ]], { fahndungId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Fahndung nicht gefunden.' end
+
+ local fahndung = zeilen[1]
+ ZielDatenErgaenzen(fahndung)
+
+ return {
+ fahndung = fahndung,
+ historie = Protokoll.Lies({ zielTyp = 'fahndung', zielId = fahndungId, limit = 20 }),
+ rechte = {
+ bearbeiten = Rechte.Hat(beamter, 'fahndung.erstellen'),
+ aufheben = Rechte.Hat(beamter, 'fahndung.aufheben'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- AUSSCHREIBEN
+-- =====================================================================
+
+Kern.RegistriereAktion('fahndung', 'erstellen', 'fahndung.erstellen', function(quelle, beamter, daten)
+ local typ = (daten.typ == 'fahrzeug') and 'fahrzeug' or 'person'
+ local zielId = tostring(daten.zielId or ''):gsub('^%s+', ''):gsub('%s+$', '')
+ if zielId == '' then return nil, 'Kein Ziel angegeben.' end
+
+ if typ == 'fahrzeug' then zielId = zielId:upper() end
+
+ -- Ziel muss existieren
+ local bezeichnung, fehler = BezeichnungFuerZiel(typ, zielId)
+ if not bezeichnung then return nil, fehler end
+
+ -- Doppelte aktive Fahndung verhindern
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT id FROM police_fahndung WHERE typ = ? AND ziel_id = ? AND aktiv = 1',
+ { typ, zielId })
+ if vorhanden then
+ return nil, 'Für dieses Ziel besteht bereits eine aktive Fahndung.'
+ end
+
+ local grund = type(daten.grund) == 'string'
+ and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #grund < 5 then
+ return nil, 'Bitte einen aussagekräftigen Fahndungsgrund angeben.'
+ end
+
+ local gefahr = GEFAHRSTUFEN[daten.gefahr] and daten.gefahr or 'normal'
+ local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 3))
+
+ local fahndungId = MySQL.insert.await([[
+ INSERT INTO police_fahndung
+ (typ, ziel_id, bezeichnung, grund, gefahr, prioritaet, hinweise, bild, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ typ, zielId, bezeichnung, grund:sub(1, 512), gefahr, prioritaet,
+ type(daten.hinweise) == 'string' and daten.hinweise or nil,
+ type(daten.bild) == 'string' and daten.bild:sub(1, 2000) or nil,
+ beamter.charakterId,
+ })
+
+ Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung ausgeschrieben',
+ { typ = 'fahndung', id = fahndungId },
+ { ziel = typ .. ':' .. zielId, gefahr = gefahr, prioritaet = prioritaet }, grund)
+
+ -- Alle Beamten im Dienst informieren; Gefahrenstufe bestimmt die Dringlichkeit
+ Hinweise.Erstelle(nil, {
+ kategorie = 'fahndung',
+ prioritaet = gefahr == 'gefaehrlich' and 'kritisch'
+ or gefahr == 'bewaffnet' and 'hoch' or 'normal',
+ titel = ('Fahndung: %s'):format(bezeichnung),
+ text = ('%s · Gefahrenstufe: %s · ausgeschrieben von %s')
+ :format(grund, gefahr, beamter.dienstnummer),
+ zielTyp = 'fahndung', zielId = fahndungId,
+ })
+
+ Kern.Loese('fahndung.erstellt', fahndungId, beamter)
+
+ return { id = fahndungId, bezeichnung = bezeichnung }
+end)
+
+-- =====================================================================
+-- BEARBEITEN
+-- =====================================================================
+
+Kern.RegistriereAktion('fahndung', 'bearbeiten', 'fahndung.erstellen', function(quelle, beamter, daten)
+ local fahndungId = tonumber(daten.id)
+ if not fahndungId then return nil, 'Ungültige Kennung.' end
+
+ local fahndung = MySQL.query.await(
+ 'SELECT gefahr, prioritaet, aktiv, bezeichnung FROM police_fahndung WHERE id = ?',
+ { fahndungId })
+ if not fahndung or not fahndung[1] then return nil, 'Fahndung nicht gefunden.' end
+ if fahndung[1].aktiv == 0 then return nil, 'Aufgehobene Fahndungen können nicht geändert werden.' end
+
+ local gefahr = GEFAHRSTUFEN[daten.gefahr] and daten.gefahr or fahndung[1].gefahr
+ local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or fahndung[1].prioritaet))
+
+ MySQL.update.await([[
+ UPDATE police_fahndung SET grund = ?, gefahr = ?, prioritaet = ?, hinweise = ?, bild = ?
+ WHERE id = ?
+ ]], {
+ type(daten.grund) == 'string' and daten.grund:sub(1, 512) or nil,
+ gefahr, prioritaet,
+ type(daten.hinweise) == 'string' and daten.hinweise or nil,
+ type(daten.bild) == 'string' and daten.bild:sub(1, 2000) or nil,
+ fahndungId,
+ })
+
+ Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung geändert',
+ { typ = 'fahndung', id = fahndungId }, { gefahr = gefahr, prioritaet = prioritaet })
+
+ -- Bei Höherstufung erneut alarmieren
+ if gefahr ~= fahndung[1].gefahr and (gefahr == 'gefaehrlich' or gefahr == 'bewaffnet') then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'fahndung',
+ prioritaet = gefahr == 'gefaehrlich' and 'kritisch' or 'hoch',
+ titel = ('Gefahrenstufe erhöht: %s'):format(fahndung[1].bezeichnung),
+ text = ('Neue Einstufung: %s'):format(gefahr),
+ zielTyp = 'fahndung', zielId = fahndungId,
+ })
+ end
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- AUFHEBEN
+--
+-- Eine Fahndung endet nie von selbst — auch nicht nach einer Festnahme.
+-- =====================================================================
+
+Kern.RegistriereAktion('fahndung', 'aufheben', 'fahndung.aufheben', function(quelle, beamter, daten)
+ local fahndungId = tonumber(daten.id)
+ if not fahndungId then return nil, 'Ungültige Kennung.' end
+
+ local grund = type(daten.grund) == 'string'
+ and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #grund < 3 then return nil, 'Bitte einen Grund für die Aufhebung angeben.' end
+
+ local fahndung = MySQL.query.await(
+ 'SELECT bezeichnung, aktiv FROM police_fahndung WHERE id = ?', { fahndungId })
+ if not fahndung or not fahndung[1] then return nil, 'Fahndung nicht gefunden.' end
+ if fahndung[1].aktiv == 0 then return nil, 'Diese Fahndung ist bereits aufgehoben.' end
+
+ MySQL.update.await([[
+ UPDATE police_fahndung
+ SET aktiv = 0, aufgehoben_von = ?, aufgehoben_am = NOW()
+ WHERE id = ?
+ ]], { beamter.charakterId, fahndungId })
+
+ Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung aufgehoben',
+ { typ = 'fahndung', id = fahndungId },
+ { bezeichnung = fahndung[1].bezeichnung }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'fahndung', prioritaet = 'niedrig',
+ titel = ('Fahndung aufgehoben: %s'):format(fahndung[1].bezeichnung),
+ text = ('%s · durch %s'):format(grund, beamter.dienstnummer),
+ })
+
+ Kern.Loese('fahndung.aufgehoben', fahndungId, beamter)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZIELSUCHE FÜR DIE OBERFLÄCHE
+-- =====================================================================
+
+Kern.RegistriereAktion('fahndung', 'zielSuche', 'fahndung.erstellen', function(quelle, beamter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {} } end
+
+ local muster = '%' .. begriff .. '%'
+
+ if daten.typ == 'fahrzeug' then
+ local fahrzeuge = MySQL.query.await([[
+ SELECT v.plate, v.model, v.props, c.firstname, c.lastname
+ FROM aether_vehicles v
+ LEFT JOIN aether_characters c ON c.id = v.owner
+ WHERE UPPER(v.plate) LIKE ? OR v.model LIKE ?
+ LIMIT 20
+ ]], { muster:upper(), muster }) or {}
+
+ local treffer = {}
+ for _, fahrzeug in ipairs(fahrzeuge) do
+ local name = fahrzeug.model
+ if fahrzeug.props then
+ local erfolg, props = pcall(json.decode, fahrzeug.props)
+ if erfolg and type(props) == 'table' and props.name then name = props.name end
+ end
+ treffer[#treffer + 1] = {
+ id = fahrzeug.plate, titel = fahrzeug.plate .. ' · ' .. name,
+ unter = 'Halter: ' .. ((fahrzeug.firstname or 'unbekannt') .. ' ' .. (fahrzeug.lastname or '')),
+ }
+ end
+ return { treffer = treffer }
+ end
+
+ local personen = MySQL.query.await([[
+ SELECT id, firstname, lastname, dob FROM aether_characters
+ WHERE deleted = 0 AND (firstname LIKE ? OR lastname LIKE ?
+ OR CONCAT(firstname, ' ', lastname) LIKE ?)
+ LIMIT 20
+ ]], { muster, muster, muster }) or {}
+
+ local treffer = {}
+ for _, person in ipairs(personen) do
+ treffer[#treffer + 1] = {
+ id = tostring(person.id),
+ titel = person.firstname .. ' ' .. person.lastname,
+ unter = 'geb. ' .. tostring(person.dob):sub(1, 10),
+ }
+ end
+ return { treffer = treffer }
+end)
+
+-- =====================================================================
+-- EXPORT — Auskunft für andere Module
+-- =====================================================================
+
+--- Besteht eine aktive Fahndung gegen ein Ziel?
+exports('BestehtFahndung', function(typ, zielId)
+ local treffer = MySQL.scalar.await(
+ 'SELECT id FROM police_fahndung WHERE typ = ? AND ziel_id = ? AND aktiv = 1',
+ { typ, tostring(zielId) })
+ return treffer ~= nil
+end)
diff --git a/resources/[aether]/aether-police/server/module/fahrzeug.lua b/resources/[aether]/aether-police/server/module/fahrzeug.lua
new file mode 100644
index 0000000..8a9c028
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/fahrzeug.lua
@@ -0,0 +1,338 @@
+-- =====================================================================
+-- aether-police / Modul: Fahrzeugverwaltung
+--
+-- Das System dokumentiert Tatsachen: Standort, Tankstand, Zustand und
+-- wer zuletzt gefahren ist. Es entscheidet nichts — es gibt keine
+-- automatische Fahrzeugvergabe und keinen automatischen Statuswechsel.
+-- Zuweisung, Freigabe und Wartungsstatus setzt immer ein Beamter.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'fahrzeug', name = 'Fuhrpark', icon = '🚓',
+ reihenfolge = 10, recht = 'dienstfahrzeug.sehen',
+ beschreibung = 'Dienstfahrzeuge und Wartung',
+})
+
+-- Zulaessige Werte
+local STATUS = {
+ verfuegbar = 'Verfügbar',
+ im_einsatz = 'Im Einsatz',
+ werkstatt = 'In der Werkstatt',
+ ausser_dienst= 'Außer Dienst',
+}
+local WARTUNG = { ok = 'In Ordnung', faellig = 'Wartung fällig', werkstatt = 'In Reparatur' }
+
+-- ---------------------------------------------------------------------
+-- Übersicht
+-- ---------------------------------------------------------------------
+
+Kern.RegistriereAktion('fahrzeug', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.abteilung and daten.abteilung ~= '' then
+ bedingungen[#bedingungen + 1] = 'f.abteilung = ?'
+ werte[#werte + 1] = daten.abteilung
+ end
+ if daten.status and daten.status ~= '' then
+ bedingungen[#bedingungen + 1] = 'f.status = ?'
+ werte[#werte + 1] = daten.status
+ end
+ if daten.wartung and daten.wartung ~= '' then
+ bedingungen[#bedingungen + 1] = 'f.wartung = ?'
+ werte[#werte + 1] = daten.wartung
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] = '(f.kennzeichen LIKE ? OR f.bezeichnung LIKE ? OR f.modell LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local fahrzeuge = MySQL.query.await(([[
+ SELECT f.*,
+ z.firstname AS z_vorname, z.lastname AS z_nachname, zp.dienstnummer AS z_nummer,
+ l.firstname AS l_vorname, l.lastname AS l_nachname, lp.dienstnummer AS l_nummer
+ FROM police_fahrzeug f
+ LEFT JOIN aether_characters z ON z.id = f.zugewiesen_an
+ LEFT JOIN police_personal zp ON zp.`character` = f.zugewiesen_an
+ LEFT JOIN aether_characters l ON l.id = f.letzter_fahrer
+ LEFT JOIN police_personal lp ON lp.`character` = f.letzter_fahrer
+ WHERE %s
+ ORDER BY f.abteilung, f.kennzeichen
+ LIMIT 100
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ -- Bestandszahlen
+ local zahlen = MySQL.query.await(
+ 'SELECT status, COUNT(*) AS anzahl FROM police_fahrzeug GROUP BY status') or {}
+ local bestand = { verfuegbar = 0, im_einsatz = 0, werkstatt = 0, ausser_dienst = 0 }
+ for _, zeile in ipairs(zahlen) do bestand[zeile.status] = zeile.anzahl end
+
+ local wartungFaellig = MySQL.scalar.await(
+ "SELECT COUNT(*) FROM police_fahrzeug WHERE wartung <> 'ok'") or 0
+
+ return {
+ fahrzeuge = fahrzeuge,
+ bestand = bestand,
+ wartungFaellig= wartungFaellig,
+ abteilungen = Polizei.Config.Abteilungen,
+ statusliste = STATUS,
+ wartungsliste = WARTUNG,
+ rechte = {
+ zuweisen = Rechte.Hat(beamter, 'dienstfahrzeug.zuweisen'),
+ wartung = Rechte.Hat(beamter, 'dienstfahrzeug.wartung'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('fahrzeug', 'oeffnen', nil, function(quelle, beamter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT f.*,
+ z.firstname AS z_vorname, z.lastname AS z_nachname, zp.dienstnummer AS z_nummer,
+ l.firstname AS l_vorname, l.lastname AS l_nachname, lp.dienstnummer AS l_nummer
+ FROM police_fahrzeug f
+ LEFT JOIN aether_characters z ON z.id = f.zugewiesen_an
+ LEFT JOIN police_personal zp ON zp.`character` = f.zugewiesen_an
+ LEFT JOIN aether_characters l ON l.id = f.letzter_fahrer
+ LEFT JOIN police_personal lp ON lp.`character` = f.letzter_fahrer
+ WHERE f.id = ?
+ ]], { fahrzeugId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Fahrzeug nicht gefunden.' end
+
+ return {
+ fahrzeug = zeilen[1],
+ historie = Protokoll.Lies({ zielTyp = 'fahrzeug', zielId = fahrzeugId, limit = 30 }),
+ beamteImDienst= Dienst.BaueListe(),
+ statusliste = STATUS,
+ wartungsliste = WARTUNG,
+ rechte = {
+ zuweisen = Rechte.Hat(beamter, 'dienstfahrzeug.zuweisen'),
+ wartung = Rechte.Hat(beamter, 'dienstfahrzeug.wartung'),
+ },
+ }
+end)
+
+-- ---------------------------------------------------------------------
+-- Zuweisung — immer manuell
+-- ---------------------------------------------------------------------
+
+Kern.RegistriereAktion('fahrzeug', 'zuweisen', 'dienstfahrzeug.zuweisen', function(quelle, beamter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local fahrzeug = MySQL.query.await(
+ 'SELECT kennzeichen, bezeichnung, status, zugewiesen_an FROM police_fahrzeug WHERE id = ?',
+ { fahrzeugId })
+ if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end
+
+ -- Freigeben
+ if daten.freigeben then
+ MySQL.update.await('UPDATE police_fahrzeug SET zugewiesen_an = NULL WHERE id = ?',
+ { fahrzeugId })
+
+ Protokoll.Schreibe(beamter, 'fahrzeug', 'Zuweisung aufgehoben',
+ { typ = 'fahrzeug', id = fahrzeugId }, { kennzeichen = fahrzeug[1].kennzeichen })
+
+ return { ok = true }
+ end
+
+ local zielCharakter = tonumber(daten.charakterId)
+ if not zielCharakter then return nil, 'Keine Person ausgewählt.' end
+
+ -- Nur Personal der Behörde
+ local personal = MySQL.query.await(
+ 'SELECT dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1',
+ { zielCharakter })
+ if not personal or not personal[1] then
+ return nil, 'Diese Person gehört nicht zum Personalbestand.'
+ end
+
+ if fahrzeug[1].status == 'werkstatt' or fahrzeug[1].status == 'ausser_dienst' then
+ return nil, ('Das Fahrzeug ist %s und kann nicht zugewiesen werden.')
+ :format(STATUS[fahrzeug[1].status])
+ end
+
+ MySQL.update.await('UPDATE police_fahrzeug SET zugewiesen_an = ? WHERE id = ?',
+ { zielCharakter, fahrzeugId })
+
+ Protokoll.Schreibe(beamter, 'fahrzeug', 'Fahrzeug zugewiesen',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = fahrzeug[1].kennzeichen, an = personal[1].dienstnummer }, daten.grund)
+
+ -- Den Betroffenen informieren, falls im Dienst
+ local zielQuelle = Dienst.QuelleVonCharakter(zielCharakter)
+ if zielQuelle then
+ Hinweise.Erstelle({ charakterId = zielCharakter }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = 'Dienstfahrzeug zugewiesen',
+ text = ('%s (%s) wurde dir von %s zugewiesen.')
+ :format(fahrzeug[1].bezeichnung, fahrzeug[1].kennzeichen, beamter.dienstnummer),
+ zielTyp = 'fahrzeug', zielId = fahrzeugId,
+ })
+ end
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Status und Wartung — ebenfalls manuell
+-- ---------------------------------------------------------------------
+
+Kern.RegistriereAktion('fahrzeug', 'status', 'dienstfahrzeug.zuweisen', function(quelle, beamter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ local status = tostring(daten.status or '')
+ if not fahrzeugId or not STATUS[status] then return nil, 'Unbekannter Status.' end
+
+ local fahrzeug = MySQL.query.await(
+ 'SELECT kennzeichen FROM police_fahrzeug WHERE id = ?', { fahrzeugId })
+ if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end
+
+ MySQL.update.await('UPDATE police_fahrzeug SET status = ? WHERE id = ?', { status, fahrzeugId })
+
+ Protokoll.Schreibe(beamter, 'fahrzeug', 'Status geändert',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = fahrzeug[1].kennzeichen, status = status }, daten.grund)
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('fahrzeug', 'wartung', 'dienstfahrzeug.wartung', function(quelle, beamter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ local wartung = tostring(daten.wartung or '')
+ if not fahrzeugId or not WARTUNG[wartung] then return nil, 'Unbekannter Wartungsstatus.' end
+
+ local fahrzeug = MySQL.query.await(
+ 'SELECT kennzeichen, wartung FROM police_fahrzeug WHERE id = ?', { fahrzeugId })
+ if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end
+
+ MySQL.update.await('UPDATE police_fahrzeug SET wartung = ? WHERE id = ?', { wartung, fahrzeugId })
+
+ Protokoll.Schreibe(beamter, 'fahrzeug', 'Wartungsstatus gesetzt',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = fahrzeug[1].kennzeichen, von = fahrzeug[1].wartung, nach = wartung },
+ daten.vermerk)
+
+ -- Bei fälliger Wartung die Fuhrparkverantwortlichen erinnern
+ if wartung ~= 'ok' then
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system', prioritaet = 'niedrig',
+ titel = 'Wartung: ' .. fahrzeug[1].kennzeichen,
+ text = ('%s — %s'):format(WARTUNG[wartung], daten.vermerk or 'ohne Vermerk'),
+ zielTyp = 'fahrzeug', zielId = fahrzeugId,
+ })
+ end
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Fuhrpark pflegen
+-- ---------------------------------------------------------------------
+
+Kern.RegistriereAktion('fahrzeug', 'anlegen', 'dienstfahrzeug.wartung', function(quelle, beamter, daten)
+ local kennzeichen = type(daten.kennzeichen) == 'string'
+ and daten.kennzeichen:gsub('%s', ''):upper() or ''
+ if #kennzeichen < 2 or #kennzeichen > 16 then
+ return nil, 'Das Kennzeichen muss 2 bis 16 Zeichen lang sein.'
+ end
+
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT id FROM police_fahrzeug WHERE kennzeichen = ?', { kennzeichen })
+ if vorhanden then return nil, 'Dieses Kennzeichen ist bereits im Fuhrpark.' end
+
+ local modell = type(daten.modell) == 'string' and daten.modell:gsub('%s', '') or ''
+ if #modell < 2 then return nil, 'Bitte das Fahrzeugmodell angeben.' end
+
+ local bezeichnung = type(daten.bezeichnung) == 'string'
+ and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #bezeichnung < 2 then return nil, 'Bitte eine Bezeichnung angeben.' end
+
+ -- Abteilung prüfen
+ local abteilung = 'streife'
+ for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
+ if eintrag.id == daten.abteilung then abteilung = daten.abteilung break end
+ end
+
+ local fahrzeugId = MySQL.insert.await([[
+ INSERT INTO police_fahrzeug (kennzeichen, modell, bezeichnung, abteilung)
+ VALUES (?, ?, ?, ?)
+ ]], { kennzeichen, modell:sub(1, 48), bezeichnung:sub(1, 64), abteilung })
+
+ Protokoll.Schreibe(beamter, 'fahrzeug', 'Fahrzeug aufgenommen',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = kennzeichen, modell = modell, abteilung = abteilung })
+
+ return { id = fahrzeugId }
+end)
+
+Kern.RegistriereAktion('fahrzeug', 'entfernen', 'dienstfahrzeug.wartung', function(quelle, beamter, daten)
+ local fahrzeugId = tonumber(daten.id)
+ if not fahrzeugId then return nil, 'Ungültige Kennung.' end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 3 then return nil, 'Bitte einen Grund für die Ausmusterung angeben.' end
+
+ local fahrzeug = MySQL.query.await(
+ 'SELECT kennzeichen, bezeichnung FROM police_fahrzeug WHERE id = ?', { fahrzeugId })
+ if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end
+
+ MySQL.update.await('DELETE FROM police_fahrzeug WHERE id = ?', { fahrzeugId })
+
+ Protokoll.Schreibe(beamter, 'fahrzeug', 'Fahrzeug ausgemustert',
+ { typ = 'fahrzeug', id = fahrzeugId },
+ { kennzeichen = fahrzeug[1].kennzeichen, bezeichnung = fahrzeug[1].bezeichnung }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- TELEMETRIE
+--
+-- Der Client meldet Tatsachen: wo das Fahrzeug steht, wie voll der Tank
+-- ist, wie es um den Zustand steht und wer zuletzt gefahren ist.
+-- Das ist Dokumentation, keine Entscheidung — Status und Wartung
+-- bleiben davon unberührt.
+-- =====================================================================
+
+RegisterNetEvent('aether:police:fahrzeugMelden', function(daten)
+ local quelle = source
+ local beamter = Dienst.Hole(quelle)
+ if not beamter then return end
+ if type(daten) ~= 'table' or type(daten.kennzeichen) ~= 'string' then return end
+
+ local kennzeichen = daten.kennzeichen:gsub('%s', ''):upper()
+
+ local fahrzeug = MySQL.scalar.await(
+ 'SELECT id FROM police_fahrzeug WHERE kennzeichen = ?', { kennzeichen })
+ if not fahrzeug then return end -- kein Dienstfahrzeug
+
+ local tank = math.max(0, math.min(100, tonumber(daten.tank) or 0))
+ local zustand = math.max(0, math.min(1000, tonumber(daten.zustand) or 1000))
+
+ MySQL.update([[
+ UPDATE police_fahrzeug
+ SET tank = ?, zustand = ?, letzter_fahrer = ?, pos_x = ?, pos_y = ?, pos_z = ?
+ WHERE id = ?
+ ]], {
+ tank, zustand, beamter.charakterId,
+ daten.x, daten.y, daten.z, fahrzeug,
+ })
+end)
+
+-- =====================================================================
+-- EXPORT
+-- =====================================================================
+
+--- Ist ein Kennzeichen ein Dienstfahrzeug?
+exports('IstDienstfahrzeug', function(kennzeichen)
+ if type(kennzeichen) ~= 'string' then return false end
+ local treffer = MySQL.scalar.await(
+ 'SELECT id FROM police_fahrzeug WHERE kennzeichen = ?',
+ { kennzeichen:gsub('%s', ''):upper() })
+ return treffer ~= nil
+end)
diff --git a/resources/[aether]/aether-police/server/module/gewahrsam.lua b/resources/[aether]/aether-police/server/module/gewahrsam.lua
new file mode 100644
index 0000000..d806ec2
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/gewahrsam.lua
@@ -0,0 +1,383 @@
+-- =====================================================================
+-- aether-police / Modul: Gewahrsamsverwaltung
+--
+-- Dieses Modul enthält BEWUSST keinen Automatismus:
+-- * Niemand wird automatisch eingewiesen.
+-- * Keine Frist entlässt jemanden.
+-- * Abgelaufene Haftzeiten bleiben offen stehen, bis ein Beamter
+-- entscheidet — sichtbar an `entlassen_von` / `entlassen_am`.
+--
+-- Das System führt die Frist mit und erinnert. Mehr nicht.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'gewahrsam', name = 'Gewahrsam', icon = '🔒',
+ reihenfolge = 5, recht = 'gewahrsam.einweisen',
+ beschreibung = 'Zellenbelegung und Fristen',
+})
+
+local Core = exports['aether-core']
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Sucht eine Zelle in der Konfiguration
+--- @param zellenId string|nil
+--- @return table|nil
+local function HoleZelle(zellenId)
+ for _, zelle in ipairs(Polizei.Config.Gewahrsam.zellen) do
+ if zelle.id == zellenId then return zelle end
+ end
+ return nil
+end
+
+--- Lädt alle offenen Gewahrsamsfälle
+--- @return table
+local function LadeOffene()
+ local eintraege = MySQL.query.await([[
+ SELECT g.id, g.character, g.anzeige, g.haft_minuten, g.beginn, g.faellig_am,
+ g.grund, g.zelle, g.eingewiesen_von,
+ TIMESTAMPDIFF(SECOND, NOW(), g.faellig_am) AS rest_sekunden,
+ c.firstname, c.lastname, c.dob,
+ e.firstname AS e_vorname, e.lastname AS e_nachname,
+ p.dienstnummer AS e_dienstnummer
+ FROM police_gewahrsam g
+ JOIN aether_characters c ON c.id = g.character
+ LEFT JOIN aether_characters e ON e.id = g.eingewiesen_von
+ LEFT JOIN police_personal p ON p.`character` = g.eingewiesen_von
+ WHERE g.entlassen_am IS NULL
+ ORDER BY g.faellig_am ASC
+ ]]) or {}
+
+ for _, eintrag in ipairs(eintraege) do
+ eintrag.rest_sekunden = tonumber(eintrag.rest_sekunden) or 0
+ eintrag.abgelaufen = eintrag.rest_sekunden <= 0
+ -- Ist die Person gerade online?
+ eintrag.online = Core:HoleQuelleNachCharakter(eintrag.character) ~= nil
+ end
+
+ return eintraege
+end
+
+--- Verbringt eine Person in eine Zelle (nur wenn sie online ist)
+--- @param charakterId number
+--- @param zelle table Zellendefinition
+local function VerbringeInZelle(charakterId, zelle)
+ if not Polizei.Config.Gewahrsam.verbringen or not zelle then return end
+
+ local zielQuelle = Core:HoleQuelleNachCharakter(charakterId)
+ if not zielQuelle then return end
+
+ TriggerClientEvent('aether:police:gewahrsamVerbringen', zielQuelle, {
+ x = zelle.pos.x, y = zelle.pos.y, z = zelle.pos.z, w = zelle.pos.w,
+ zelle = zelle.name,
+ })
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('gewahrsam', 'liste', nil, function(quelle, beamter)
+ local offene = LadeOffene()
+
+ -- Zellenbelegung ermitteln
+ local belegt = {}
+ for _, eintrag in ipairs(offene) do
+ if eintrag.zelle then belegt[eintrag.zelle] = eintrag end
+ end
+
+ local zellen = {}
+ for _, zelle in ipairs(Polizei.Config.Gewahrsam.zellen) do
+ local insasse = belegt[zelle.id]
+ zellen[#zellen + 1] = {
+ id = zelle.id, name = zelle.name,
+ frei = insasse == nil,
+ insasse = insasse and {
+ id = insasse.id,
+ name = insasse.firstname .. ' ' .. insasse.lastname,
+ rest = insasse.rest_sekunden,
+ abgelaufen = insasse.abgelaufen,
+ } or nil,
+ }
+ end
+
+ -- Kürzlich Entlassene (nur zur Nachvollziehbarkeit)
+ local verlauf = MySQL.query.await([[
+ SELECT g.id, g.haft_minuten, g.beginn, g.entlassen_am, g.entlass_grund, g.grund,
+ c.firstname, c.lastname,
+ e.firstname AS e_vorname, e.lastname AS e_nachname
+ FROM police_gewahrsam g
+ JOIN aether_characters c ON c.id = g.character
+ LEFT JOIN aether_characters e ON e.id = g.entlassen_von
+ WHERE g.entlassen_am IS NOT NULL
+ ORDER BY g.entlassen_am DESC LIMIT 20
+ ]]) or {}
+
+ return {
+ offene = offene,
+ zellen = zellen,
+ verlauf = verlauf,
+ maxDauer = Polizei.Config.Gewahrsam.maxDauer,
+ verbringen = Polizei.Config.Gewahrsam.verbringen,
+ rechte = {
+ einweisen = Rechte.Hat(beamter, 'gewahrsam.einweisen'),
+ entlassen = Rechte.Hat(beamter, 'gewahrsam.entlassen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINWEISEN
+--
+-- Geschieht nur auf ausdrückliche Bestätigung eines Beamten. Es gibt
+-- keinen Weg, hier ohne dessen Zutun hineinzukommen.
+-- =====================================================================
+
+Kern.RegistriereAktion('gewahrsam', 'einweisen', 'gewahrsam.einweisen', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Keine Person ausgewählt.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
+ { charakterId })
+ if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end
+
+ -- Doppelte Einweisung verhindern
+ local bereits = MySQL.scalar.await(
+ 'SELECT id FROM police_gewahrsam WHERE `character` = ? AND entlassen_am IS NULL',
+ { charakterId })
+ if bereits then return nil, 'Diese Person befindet sich bereits in Gewahrsam.' end
+
+ -- Haftdauer: kommt ausschliesslich vom Beamten
+ local dauer = math.floor(tonumber(daten.dauer) or 0)
+ if dauer < 1 or dauer > Polizei.Config.Gewahrsam.maxDauer then
+ return nil, ('Die Haftdauer muss zwischen 1 und %d Minuten liegen.')
+ :format(Polizei.Config.Gewahrsam.maxDauer)
+ end
+
+ local grund = type(daten.grund) == 'string'
+ and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #grund < 3 then return nil, 'Bitte einen Grund angeben.' end
+
+ -- Zelle prüfen und Belegung sicherstellen
+ local zelle = HoleZelle(daten.zelle)
+ if daten.zelle and not zelle then return nil, 'Unbekannte Zelle.' end
+
+ if zelle then
+ local besetzt = MySQL.scalar.await(
+ 'SELECT id FROM police_gewahrsam WHERE zelle = ? AND entlassen_am IS NULL',
+ { zelle.id })
+ if besetzt then return nil, ('%s ist bereits belegt.'):format(zelle.name) end
+ end
+
+ local gewahrsamId = MySQL.insert.await([[
+ INSERT INTO police_gewahrsam
+ (`character`, anzeige, haft_minuten, faellig_am, grund, zelle, eingewiesen_von)
+ VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE), ?, ?, ?)
+ ]], {
+ charakterId, tonumber(daten.anzeige), dauer, dauer, grund,
+ zelle and zelle.id or nil, beamter.charakterId,
+ })
+
+ -- Person in die Zelle bringen, sofern konfiguriert und online
+ VerbringeInZelle(charakterId, zelle)
+
+ Protokoll.Schreibe(beamter, 'gewahrsam', 'In Gewahrsam genommen',
+ { typ = 'person', id = charakterId },
+ { gewahrsam = gewahrsamId, dauer = dauer, zelle = zelle and zelle.id }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'gewahrsam', prioritaet = 'normal',
+ titel = ('%s %s in Gewahrsam'):format(person[1].firstname, person[1].lastname),
+ text = ('%d Minuten · %s · eingewiesen von %s')
+ :format(dauer, grund, beamter.dienstnummer),
+ zielTyp = 'gewahrsam', zielId = gewahrsamId,
+ })
+
+ Kern.Loese('gewahrsam.eingewiesen', gewahrsamId, beamter)
+
+ return { id = gewahrsamId }
+end)
+
+-- =====================================================================
+-- ENTLASSEN
+--
+-- Der einzige Weg aus dem Gewahrsam. Es gibt keinen Hintergrundprozess,
+-- der das übernimmt — auch nicht bei längst abgelaufener Frist.
+-- =====================================================================
+
+Kern.RegistriereAktion('gewahrsam', 'entlassen', 'gewahrsam.entlassen', function(quelle, beamter, daten)
+ local gewahrsamId = tonumber(daten.id)
+ if not gewahrsamId then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = MySQL.query.await([[
+ SELECT g.*, c.firstname, c.lastname,
+ TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest
+ FROM police_gewahrsam g
+ JOIN aether_characters c ON c.id = g.character
+ WHERE g.id = ?
+ ]], { gewahrsamId })
+
+ if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end
+ local g = eintrag[1]
+
+ if g.entlassen_am then return nil, 'Diese Person wurde bereits entlassen.' end
+
+ local grund = type(daten.grund) == 'string'
+ and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or ''
+
+ -- Vorzeitige Entlassung verlangt eine Begründung
+ local rest = tonumber(g.rest) or 0
+ local vorzeitig = rest > 0
+ if vorzeitig and #grund < 3 then
+ return nil, ('Die Frist läuft noch %d Minuten. Für eine vorzeitige Entlassung ist eine Begründung erforderlich.')
+ :format(rest)
+ end
+
+ MySQL.update.await([[
+ UPDATE police_gewahrsam
+ SET entlassen_von = ?, entlassen_am = NOW(), entlass_grund = ?
+ WHERE id = ?
+ ]], { beamter.charakterId, grund ~= '' and grund or nil, gewahrsamId })
+
+ -- Person an den Entlassungspunkt setzen, sofern online
+ if Polizei.Config.Gewahrsam.verbringen then
+ local zielQuelle = Core:HoleQuelleNachCharakter(g.character)
+ if zielQuelle then
+ local punkt = Polizei.Config.Gewahrsam.entlassung
+ TriggerClientEvent('aether:police:gewahrsamEntlassen', zielQuelle, {
+ x = punkt.x, y = punkt.y, z = punkt.z, w = punkt.w,
+ })
+ end
+ end
+
+ Protokoll.Schreibe(beamter, 'gewahrsam',
+ vorzeitig and 'Vorzeitig entlassen' or 'Entlassen',
+ { typ = 'person', id = g.character },
+ { gewahrsam = gewahrsamId, restMinuten = rest }, grund ~= '' and grund or nil)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'gewahrsam', prioritaet = 'niedrig',
+ titel = ('%s %s entlassen'):format(g.firstname, g.lastname),
+ text = ('Entlassen durch %s%s'):format(beamter.dienstnummer,
+ vorzeitig and (' · vorzeitig: ' .. grund) or ''),
+ })
+
+ Kern.Loese('gewahrsam.entlassen', gewahrsamId, beamter)
+
+ return { ok = true, vorzeitig = vorzeitig }
+end)
+
+-- =====================================================================
+-- HAFTZEIT ANPASSEN
+--
+-- Auch das ist eine bewusste Entscheidung und wird protokolliert.
+-- =====================================================================
+
+Kern.RegistriereAktion('gewahrsam', 'fristAendern', 'gewahrsam.entlassen', function(quelle, beamter, daten)
+ local gewahrsamId = tonumber(daten.id)
+ local minuten = math.floor(tonumber(daten.minuten) or 0)
+
+ if not gewahrsamId then return nil, 'Ungültige Kennung.' end
+ if minuten == 0 then return nil, 'Bitte eine Änderung angeben.' end
+ if math.abs(minuten) > Polizei.Config.Gewahrsam.maxDauer then
+ return nil, 'Die Änderung ist zu groß.'
+ end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 3 then return nil, 'Bitte einen Grund für die Änderung angeben.' end
+
+ local eintrag = MySQL.query.await(
+ 'SELECT `character`, entlassen_am FROM police_gewahrsam WHERE id = ?', { gewahrsamId })
+ if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end
+ if eintrag[1].entlassen_am then return nil, 'Der Vorgang ist bereits abgeschlossen.' end
+
+ MySQL.update.await([[
+ UPDATE police_gewahrsam
+ SET faellig_am = DATE_ADD(faellig_am, INTERVAL ? MINUTE),
+ haft_minuten = GREATEST(0, haft_minuten + ?)
+ WHERE id = ?
+ ]], { minuten, minuten, gewahrsamId })
+
+ Protokoll.Schreibe(beamter, 'gewahrsam', 'Haftzeit angepasst',
+ { typ = 'person', id = eintrag[1].character },
+ { gewahrsam = gewahrsamId, aenderung = minuten }, grund)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- ZELLE WECHSELN
+-- =====================================================================
+
+Kern.RegistriereAktion('gewahrsam', 'zelleWechseln', 'gewahrsam.einweisen', function(quelle, beamter, daten)
+ local gewahrsamId = tonumber(daten.id)
+ local zelle = HoleZelle(daten.zelle)
+
+ if not gewahrsamId or not zelle then return nil, 'Unvollständige Angaben.' end
+
+ local besetzt = MySQL.scalar.await(
+ 'SELECT id FROM police_gewahrsam WHERE zelle = ? AND entlassen_am IS NULL AND id <> ?',
+ { zelle.id, gewahrsamId })
+ if besetzt then return nil, ('%s ist bereits belegt.'):format(zelle.name) end
+
+ local eintrag = MySQL.query.await(
+ 'SELECT `character` FROM police_gewahrsam WHERE id = ? AND entlassen_am IS NULL',
+ { gewahrsamId })
+ if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end
+
+ MySQL.update.await('UPDATE police_gewahrsam SET zelle = ? WHERE id = ?', { zelle.id, gewahrsamId })
+ VerbringeInZelle(eintrag[1].character, zelle)
+
+ Protokoll.Schreibe(beamter, 'gewahrsam', 'Zelle gewechselt',
+ { typ = 'person', id = eintrag[1].character }, { zelle = zelle.id })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- BEI ANMELDUNG PRÜFEN
+--
+-- Wer während einer offenen Haft ausloggt, findet sich beim nächsten
+-- Anmelden wieder in der Zelle. Die Frist läuft derweil weiter, aber
+-- entlassen wird trotzdem nur durch einen Beamten.
+-- =====================================================================
+AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler)
+ if not Polizei.Config.Gewahrsam.verbringen then return end
+
+ local eintrag = MySQL.query.await([[
+ SELECT id, zelle, grund, TIMESTAMPDIFF(MINUTE, NOW(), faellig_am) AS rest
+ FROM police_gewahrsam
+ WHERE `character` = ? AND entlassen_am IS NULL
+ LIMIT 1
+ ]], { spieler.charakterId })
+
+ if not eintrag or not eintrag[1] then return end
+
+ local zelle = HoleZelle(eintrag[1].zelle)
+ if not zelle then return end
+
+ -- Kurz warten, bis der Spieler wirklich in der Welt ist
+ Wait(3000)
+
+ TriggerClientEvent('aether:police:gewahrsamVerbringen', quelle, {
+ x = zelle.pos.x, y = zelle.pos.y, z = zelle.pos.z, w = zelle.pos.w,
+ zelle = zelle.name,
+ rest = tonumber(eintrag[1].rest) or 0,
+ grund = eintrag[1].grund,
+ })
+end)
+
+-- =====================================================================
+-- EXPORT — Auskunft für andere Module
+-- =====================================================================
+
+--- Befindet sich eine Person in Gewahrsam?
+exports('IstInGewahrsam', function(charakterId)
+ local treffer = MySQL.scalar.await(
+ 'SELECT id FROM police_gewahrsam WHERE `character` = ? AND entlassen_am IS NULL',
+ { charakterId })
+ return treffer ~= nil
+end)
diff --git a/resources/[aether]/aether-police/server/module/kommunikation.lua b/resources/[aether]/aether-police/server/module/kommunikation.lua
new file mode 100644
index 0000000..b17d901
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/kommunikation.lua
@@ -0,0 +1,573 @@
+-- =====================================================================
+-- aether-police / Modul: Interne Kommunikation
+--
+-- Zwei Wege: Nachrichten zwischen Beamten und Aushänge der Leitung.
+--
+-- Das System stellt zu, sortiert und erinnert. Es verfasst nichts,
+-- leitet nichts weiter und bestätigt keine Kenntnisnahme im Namen
+-- eines Beamten. Ein gelesener Posteingang ist reine Anzeige — eine
+-- Kenntnisnahme dagegen ist eine Erklärung und braucht einen Klick.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'kommunikation', name = 'Kommunikation', icon = '✉',
+ reihenfolge = 12, recht = nil,
+ beschreibung = 'Nachrichten, Aushänge und Dienstanweisungen',
+})
+
+-- Erlaubte Kategorien eines Aushangs
+local AUSHANG_KATEGORIEN = {
+ anweisung = 'Dienstanweisung',
+ information = 'Information',
+ warnung = 'Warnung',
+}
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Baut die Bedingung, welche Nachrichten einen Beamten erreichen
+--- @param beamter table Empfänger
+--- @return string Bedingung, table Werte
+local function EmpfangsBedingung(beamter)
+ return [[(
+ (n.empf_typ = 'person' AND n.empfaenger = ?) OR
+ (n.empf_typ = 'abteilung' AND n.empfaenger = ?) OR
+ (n.empf_typ = 'rundmeldung')
+ )]], { tostring(beamter.charakterId), beamter.abteilung }
+end
+
+--- Zählt ungelesene Nachrichten eines Beamten
+--- @param beamter table Empfänger
+--- @return number
+local function ZaehleUngelesen(beamter)
+ local bedingung, werte = EmpfangsBedingung(beamter)
+
+ return MySQL.scalar.await(([[
+ SELECT COUNT(*) FROM police_nachricht n
+ WHERE %s
+ AND n.absender <> ?
+ AND NOT EXISTS (
+ SELECT 1 FROM police_nachricht_gelesen g
+ WHERE g.nachricht = n.id AND g.`character` = ?
+ )
+ ]]):format(bedingung),
+ { werte[1], werte[2], beamter.charakterId, beamter.charakterId }) or 0
+end
+
+--- Zählt offene Kenntnisnahmen eines Beamten
+--- @param beamter table Empfänger
+--- @return number
+local function ZaehleOffeneKenntnis(beamter)
+ return MySQL.scalar.await([[
+ SELECT COUNT(*) FROM police_aushang a
+ WHERE a.aktiv = 1 AND a.kenntnispflicht = 1
+ AND (a.abteilung IS NULL OR a.abteilung = ?)
+ AND NOT EXISTS (
+ SELECT 1 FROM police_aushang_kenntnis k
+ WHERE k.aushang = a.id AND k.`character` = ?
+ )
+ ]], { beamter.abteilung, beamter.charakterId }) or 0
+end
+
+--- Liefert den Namen einer Abteilung
+local function AbteilungsName(id)
+ for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
+ if eintrag.id == id then return eintrag.name end
+ end
+ return id
+end
+
+-- =====================================================================
+-- POSTEINGANG
+-- =====================================================================
+
+Kern.RegistriereAktion('kommunikation', 'uebersicht', nil, function(quelle, beamter, daten)
+ local ordner = tostring(daten.ordner or 'eingang')
+ local bedingung, werte = EmpfangsBedingung(beamter)
+ local nachrichten
+
+ if ordner == 'gesendet' then
+ nachrichten = MySQL.query.await([[
+ SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet,
+ n.absender, c.firstname, c.lastname, p.dienstnummer,
+ 1 AS gelesen
+ FROM police_nachricht n
+ JOIN aether_characters c ON c.id = n.absender
+ LEFT JOIN police_personal p ON p.`character` = n.absender
+ WHERE n.absender = ?
+ ORDER BY n.id DESC LIMIT 100
+ ]], { beamter.charakterId }) or {}
+ else
+ nachrichten = MySQL.query.await(([[
+ SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet,
+ n.absender, c.firstname, c.lastname, p.dienstnummer,
+ (SELECT COUNT(*) FROM police_nachricht_gelesen g
+ WHERE g.nachricht = n.id AND g.`character` = ?) AS gelesen
+ FROM police_nachricht n
+ JOIN aether_characters c ON c.id = n.absender
+ LEFT JOIN police_personal p ON p.`character` = n.absender
+ WHERE %s AND n.absender <> ?
+ ORDER BY n.id DESC LIMIT 100
+ ]]):format(bedingung),
+ { beamter.charakterId, werte[1], werte[2], beamter.charakterId }) or {}
+ end
+
+ -- Empfängernamen und Vorschau ergänzen
+ for _, eintrag in ipairs(nachrichten) do
+ eintrag.absenderName = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.gelesen = tonumber(eintrag.gelesen) > 0
+
+ if eintrag.empf_typ == 'rundmeldung' then
+ eintrag.empfaengerName = 'Alle Einheiten'
+ elseif eintrag.empf_typ == 'abteilung' then
+ eintrag.empfaengerName = AbteilungsName(eintrag.empfaenger)
+ else
+ local ziel = MySQL.query.await([[
+ SELECT c.firstname, c.lastname, p.dienstnummer
+ FROM aether_characters c
+ LEFT JOIN police_personal p ON p.`character` = c.id
+ WHERE c.id = ?
+ ]], { tonumber(eintrag.empfaenger) or 0 })
+ eintrag.empfaengerName = ziel and ziel[1]
+ and (ziel[1].firstname .. ' ' .. ziel[1].lastname) or 'Unbekannt'
+ end
+
+ -- Vorschau kurz halten, der volle Text kommt beim Öffnen
+ eintrag.vorschau = tostring(eintrag.text):gsub('%s+', ' '):sub(1, 120)
+ eintrag.text = nil
+ eintrag.firstname, eintrag.lastname = nil, nil
+ end
+
+ return {
+ ordner = ordner,
+ nachrichten = nachrichten,
+ ungelesen = ZaehleUngelesen(beamter),
+ offeneKenntnis = ZaehleOffeneKenntnis(beamter),
+ abteilungen = Polizei.Config.Abteilungen,
+ meineAbteilung = beamter.abteilung,
+ rechte = {
+ senden = Rechte.Hat(beamter, 'nachricht.senden'),
+ rundmeldung = Rechte.Hat(beamter, 'nachricht.rundmeldung'),
+ aushang = Rechte.Hat(beamter, 'aushang.schreiben'),
+ archivieren = Rechte.Hat(beamter, 'aushang.archivieren'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'nachrichtOeffnen', nil, function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT n.*, c.firstname, c.lastname, p.dienstnummer, p.rolle
+ FROM police_nachricht n
+ JOIN aether_characters c ON c.id = n.absender
+ LEFT JOIN police_personal p ON p.`character` = n.absender
+ WHERE n.id = ?
+ ]], { id })
+ if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end
+
+ local nachricht = zeilen[1]
+
+ -- Zustellprüfung: nur Absender und tatsächliche Empfänger dürfen lesen
+ local darf = nachricht.absender == beamter.charakterId
+ or nachricht.empf_typ == 'rundmeldung'
+ or (nachricht.empf_typ == 'abteilung' and nachricht.empfaenger == beamter.abteilung)
+ or (nachricht.empf_typ == 'person'
+ and tonumber(nachricht.empfaenger) == beamter.charakterId)
+ if not darf then return nil, 'Diese Nachricht ist nicht an dich gerichtet.' end
+
+ -- Lesestatus vermerken: das ist reine Anzeige, keine Erklärung
+ if nachricht.absender ~= beamter.charakterId then
+ MySQL.query([[
+ INSERT IGNORE INTO police_nachricht_gelesen (nachricht, `character`)
+ VALUES (?, ?)
+ ]], { id, beamter.charakterId })
+ end
+
+ local rolle = Polizei.HoleRolle(nachricht.rolle)
+
+ return {
+ id = nachricht.id,
+ betreff = nachricht.betreff,
+ text = nachricht.text,
+ gesendet = nachricht.gesendet,
+ absender = nachricht.absender,
+ absenderName = nachricht.firstname .. ' ' .. nachricht.lastname,
+ dienstnummer = nachricht.dienstnummer,
+ rolleName = rolle and rolle.name or nachricht.rolle,
+ empfTyp = nachricht.empf_typ,
+ empfaengerName = nachricht.empf_typ == 'rundmeldung' and 'Alle Einheiten'
+ or nachricht.empf_typ == 'abteilung' and AbteilungsName(nachricht.empfaenger)
+ or nil,
+ -- Antworten geht nur bei einer Nachricht von einer Person
+ antwortAn = nachricht.absender ~= beamter.charakterId and nachricht.absender or nil,
+ }
+end)
+
+--- Auswahlliste möglicher Empfänger
+Kern.RegistriereAktion('kommunikation', 'empfaenger', 'nachricht.senden', function(quelle, beamter)
+ local beamte = MySQL.query.await([[
+ SELECT p.`character`, p.dienstnummer, p.rolle, p.abteilung,
+ c.firstname, c.lastname
+ FROM police_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ WHERE p.aktiv = 1 AND p.`character` <> ?
+ ORDER BY p.dienstnummer
+ ]], { beamter.charakterId }) or {}
+
+ -- Wer gerade erreichbar ist, wird markiert — mehr nicht
+ local imDienst = {}
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ imDienst[eintrag.charakterId] = true
+ end
+
+ for _, eintrag in ipairs(beamte) do
+ local rolle = Polizei.HoleRolle(eintrag.rolle)
+ eintrag.rolleName = rolle and rolle.name or eintrag.rolle
+ eintrag.imDienst = imDienst[eintrag.character] == true
+ end
+
+ return {
+ beamte = beamte,
+ abteilungen = Polizei.Config.Abteilungen,
+ rundmeldung = Rechte.Hat(beamter, 'nachricht.rundmeldung'),
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'senden', 'nachricht.senden', function(quelle, beamter, daten)
+ local typ = tostring(daten.empfTyp or 'person')
+ local text = type(daten.text) == 'string' and daten.text or ''
+ local betreff = type(daten.betreff) == 'string' and daten.betreff:sub(1, 160) or ''
+
+ if #text < 2 then return nil, 'Die Nachricht ist leer.' end
+ if #text > 4000 then return nil, 'Die Nachricht ist zu lang.' end
+ if #betreff < 2 then return nil, 'Bitte einen Betreff angeben.' end
+
+ local empfaenger, hinweisText
+
+ if typ == 'rundmeldung' then
+ if not Rechte.Hat(beamter, 'nachricht.rundmeldung') then
+ return nil, 'Du darfst keine Rundmeldung absetzen.'
+ end
+ empfaenger = 'alle'
+ hinweisText = 'Rundmeldung von ' .. beamter.name
+
+ elseif typ == 'abteilung' then
+ local gefunden = false
+ for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
+ if eintrag.id == daten.empfaenger then gefunden = true break end
+ end
+ if not gefunden then return nil, 'Unbekannte Abteilung.' end
+ empfaenger = daten.empfaenger
+ hinweisText = 'Nachricht an ' .. AbteilungsName(empfaenger)
+
+ else
+ typ = 'person'
+ local zielId = tonumber(daten.empfaenger)
+ if not zielId then return nil, 'Kein Empfänger gewählt.' end
+
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT `character` FROM police_personal WHERE `character` = ? AND aktiv = 1',
+ { zielId })
+ if not vorhanden then return nil, 'Dieser Empfänger ist nicht im Dienstbestand.' end
+
+ empfaenger = tostring(zielId)
+ hinweisText = 'Nachricht von ' .. beamter.name
+ end
+
+ local id = MySQL.insert.await([[
+ INSERT INTO police_nachricht (absender, empfaenger, empf_typ, betreff, text)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { beamter.charakterId, empfaenger, typ, betreff, text })
+
+ -- Zustellung: nur benachrichtigen, nicht öffnen
+ if typ == 'person' then
+ Hinweise.Erstelle({ charakterId = tonumber(empfaenger) }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = hinweisText, text = betreff,
+ zielTyp = 'nachricht', zielId = id,
+ })
+ else
+ for _, ziel in pairs(Dienst.AlleImDienst()) do
+ local passt = typ == 'rundmeldung' or ziel.abteilung == empfaenger
+ if passt and ziel.charakterId ~= beamter.charakterId then
+ Hinweise.Erstelle({ charakterId = ziel.charakterId }, {
+ kategorie = 'system',
+ prioritaet = typ == 'rundmeldung' and 'hoch' or 'normal',
+ titel = hinweisText, text = betreff,
+ zielTyp = 'nachricht', zielId = id,
+ })
+ end
+ end
+ end
+
+ Protokoll.Schreibe(beamter, 'kommunikation', 'Nachricht gesendet',
+ { typ = 'nachricht', id = id }, { empfTyp = typ, empfaenger = empfaenger })
+
+ Kern.Loese('nachricht.gesendet', id, beamter)
+
+ return { id = id }
+end)
+
+-- =====================================================================
+-- AUSHÄNGE
+-- =====================================================================
+
+Kern.RegistriereAktion('kommunikation', 'aushaenge', nil, function(quelle, beamter, daten)
+ local archiv = daten.archiv == true
+
+ local aushaenge = MySQL.query.await([[
+ SELECT a.id, a.titel, a.text, a.kategorie, a.abteilung, a.angeheftet,
+ a.kenntnispflicht, a.frist, a.erstellt_am, a.erstellt_von, a.aktiv,
+ a.archiviert_am,
+ c.firstname, c.lastname, p.dienstnummer,
+ (SELECT COUNT(*) FROM police_aushang_kenntnis k WHERE k.aushang = a.id)
+ AS kenntnisse,
+ (SELECT COUNT(*) FROM police_aushang_kenntnis k
+ WHERE k.aushang = a.id AND k.`character` = ?) AS meineKenntnis
+ FROM police_aushang a
+ JOIN aether_characters c ON c.id = a.erstellt_von
+ LEFT JOIN police_personal p ON p.`character` = a.erstellt_von
+ WHERE a.aktiv = ?
+ AND (? = 1 OR a.abteilung IS NULL OR a.abteilung = ?)
+ ORDER BY a.angeheftet DESC, a.id DESC
+ LIMIT 100
+ ]], {
+ beamter.charakterId, archiv and 0 or 1,
+ -- Wer Aushänge veröffentlichen darf, sieht auch die anderer Abteilungen
+ Rechte.Hat(beamter, 'aushang.schreiben') and 1 or 0,
+ beamter.abteilung,
+ }) or {}
+
+ -- Wie viele Beamte kämen für eine Kenntnisnahme überhaupt in Frage?
+ for _, eintrag in ipairs(aushaenge) do
+ eintrag.autor = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.kategorieName = AUSHANG_KATEGORIEN[eintrag.kategorie] or eintrag.kategorie
+ eintrag.abteilungName = eintrag.abteilung and AbteilungsName(eintrag.abteilung) or nil
+ eintrag.bestaetigt = tonumber(eintrag.meineKenntnis) > 0
+ eintrag.angeheftet = eintrag.angeheftet == 1
+ eintrag.kenntnispflicht = eintrag.kenntnispflicht == 1
+
+ eintrag.betroffen = MySQL.scalar.await([[
+ SELECT COUNT(*) FROM police_personal
+ WHERE aktiv = 1 AND (? IS NULL OR abteilung = ?)
+ ]], { eintrag.abteilung, eintrag.abteilung }) or 0
+
+ eintrag.firstname, eintrag.lastname, eintrag.meineKenntnis = nil, nil, nil
+ end
+
+ return {
+ aushaenge = aushaenge,
+ archiv = archiv,
+ kategorien = AUSHANG_KATEGORIEN,
+ abteilungen= Polizei.Config.Abteilungen,
+ rechte = {
+ schreiben = Rechte.Hat(beamter, 'aushang.schreiben'),
+ archivieren = Rechte.Hat(beamter, 'aushang.archivieren'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'aushangErstellen', 'aushang.schreiben', function(quelle, beamter, daten)
+ local titel = type(daten.titel) == 'string' and daten.titel:sub(1, 160) or ''
+ local text = type(daten.text) == 'string' and daten.text or ''
+
+ if #titel < 3 then return nil, 'Bitte einen Titel angeben.' end
+ if #text < 5 then return nil, 'Der Aushang hat keinen Inhalt.' end
+ if #text > 8000 then return nil, 'Der Aushang ist zu lang.' end
+
+ local kategorie = AUSHANG_KATEGORIEN[daten.kategorie] and daten.kategorie or 'information'
+
+ -- Abteilung: leer bedeutet gesamte Behörde
+ local abteilung = nil
+ if daten.abteilung and daten.abteilung ~= '' then
+ for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
+ if eintrag.id == daten.abteilung then abteilung = eintrag.id break end
+ end
+ if not abteilung then return nil, 'Unbekannte Abteilung.' end
+ end
+
+ -- Frist nur sinnvoll, wenn eine Kenntnisnahme verlangt wird
+ local kenntnispflicht = daten.kenntnispflicht == true
+ local fristMinuten = tonumber(daten.fristMinuten)
+ if fristMinuten and (fristMinuten < 5 or fristMinuten > 20160) then
+ return nil, 'Die Frist muss zwischen 5 Minuten und 14 Tagen liegen.'
+ end
+ if not kenntnispflicht then fristMinuten = nil end
+
+ local id = MySQL.insert.await([[
+ INSERT INTO police_aushang
+ (titel, text, kategorie, abteilung, angeheftet, kenntnispflicht, frist, erstellt_von)
+ VALUES (?, ?, ?, ?, ?, ?,
+ CASE WHEN ? IS NULL THEN NULL ELSE DATE_ADD(NOW(), INTERVAL ? MINUTE) END, ?)
+ ]], {
+ titel, text, kategorie, abteilung,
+ daten.angeheftet == true and 1 or 0,
+ kenntnispflicht and 1 or 0,
+ fristMinuten, fristMinuten,
+ beamter.charakterId,
+ })
+
+ -- Betroffene informieren, aber nichts für sie bestätigen
+ for _, ziel in pairs(Dienst.AlleImDienst()) do
+ if (abteilung == nil or ziel.abteilung == abteilung)
+ and ziel.charakterId ~= beamter.charakterId then
+ Hinweise.Erstelle({ charakterId = ziel.charakterId }, {
+ kategorie = 'system',
+ prioritaet = kategorie == 'warnung' and 'hoch' or 'normal',
+ titel = 'Neuer Aushang: ' .. (AUSHANG_KATEGORIEN[kategorie] or kategorie),
+ text = titel,
+ zielTyp = 'aushang', zielId = id,
+ })
+ end
+ end
+
+ Protokoll.Schreibe(beamter, 'kommunikation', 'Aushang veröffentlicht',
+ { typ = 'aushang', id = id },
+ { kategorie = kategorie, abteilung = abteilung, kenntnispflicht = kenntnispflicht })
+
+ Kern.Loese('aushang.erstellt', id, beamter)
+
+ return { id = id }
+end)
+
+--- Kenntnisnahme: bestätigt ausschließlich der Beamte selbst
+Kern.RegistriereAktion('kommunikation', 'kenntnisnahme', nil, function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local aushang = MySQL.query.await(
+ 'SELECT id, titel, abteilung, aktiv FROM police_aushang WHERE id = ?', { id })
+ if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end
+ if aushang[1].aktiv ~= 1 then return nil, 'Dieser Aushang ist archiviert.' end
+
+ if aushang[1].abteilung and aushang[1].abteilung ~= beamter.abteilung then
+ return nil, 'Dieser Aushang betrifft deine Abteilung nicht.'
+ end
+
+ MySQL.query.await([[
+ INSERT IGNORE INTO police_aushang_kenntnis (aushang, `character`)
+ VALUES (?, ?)
+ ]], { id, beamter.charakterId })
+
+ Protokoll.Schreibe(beamter, 'kommunikation', 'Kenntnisnahme bestätigt',
+ { typ = 'aushang', id = id }, { titel = aushang[1].titel })
+
+ return { ok = true }
+end)
+
+--- Wer hat bestätigt, wer noch nicht — reine Auskunft für den Verfasser
+Kern.RegistriereAktion('kommunikation', 'kenntnisStand', 'aushang.schreiben', function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local aushang = MySQL.query.await(
+ 'SELECT id, titel, abteilung, frist FROM police_aushang WHERE id = ?', { id })
+ if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end
+
+ local abteilung = aushang[1].abteilung
+
+ local beamte = MySQL.query.await([[
+ SELECT p.`character`, p.dienstnummer, p.abteilung,
+ c.firstname, c.lastname,
+ k.bestaetigt_am
+ FROM police_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ LEFT JOIN police_aushang_kenntnis k
+ ON k.aushang = ? AND k.`character` = p.`character`
+ WHERE p.aktiv = 1 AND (? IS NULL OR p.abteilung = ?)
+ ORDER BY k.bestaetigt_am IS NULL DESC, p.dienstnummer
+ ]], { id, abteilung, abteilung }) or {}
+
+ local offen = 0
+ for _, eintrag in ipairs(beamte) do
+ eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname
+ eintrag.firstname, eintrag.lastname = nil, nil
+ if not eintrag.bestaetigt_am then offen = offen + 1 end
+ end
+
+ return {
+ titel = aushang[1].titel,
+ frist = aushang[1].frist,
+ beamte = beamte,
+ offen = offen,
+ gesamt = #beamte,
+ }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'aushangArchivieren', 'aushang.archivieren', function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local aushang = MySQL.query.await(
+ 'SELECT id, titel, aktiv FROM police_aushang WHERE id = ?', { id })
+ if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end
+ if aushang[1].aktiv ~= 1 then return nil, 'Dieser Aushang ist bereits archiviert.' end
+
+ MySQL.update.await([[
+ UPDATE police_aushang
+ SET aktiv = 0, archiviert_von = ?, archiviert_am = NOW()
+ WHERE id = ?
+ ]], { beamter.charakterId, id })
+
+ Protokoll.Schreibe(beamter, 'kommunikation', 'Aushang archiviert',
+ { typ = 'aushang', id = id }, { titel = aushang[1].titel }, daten.grund)
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('kommunikation', 'anheften', 'aushang.schreiben', function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local angeheftet = daten.angeheftet == true
+
+ MySQL.update.await('UPDATE police_aushang SET angeheftet = ? WHERE id = ? AND aktiv = 1',
+ { angeheftet and 1 or 0, id })
+
+ Protokoll.Schreibe(beamter, 'kommunikation',
+ angeheftet and 'Aushang angeheftet' or 'Aushang losgelöst',
+ { typ = 'aushang', id = id })
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Zählt offene Post eines Beamten im Dienst
+--- @param quelle number Server-ID
+--- @return table { nachrichten, kenntnisnahmen }
+exports('OffenePost', function(quelle)
+ local beamter = Dienst.Hole(quelle)
+ if not beamter then return { nachrichten = 0, kenntnisnahmen = 0 } end
+ return {
+ nachrichten = ZaehleUngelesen(beamter),
+ kenntnisnahmen = ZaehleOffeneKenntnis(beamter),
+ }
+end)
+
+--- Schickt eine Nachricht aus einer anderen Ressource in den Posteingang.
+--- Der Absender muss ein Beamter sein — das System schreibt nicht selbst.
+--- @param absenderCharakterId number Verfassender Beamter
+--- @param empfTyp string person/abteilung/rundmeldung
+--- @param empfaenger string|number Ziel
+--- @param betreff string Betreff
+--- @param text string Inhalt
+--- @return number|nil Nachrichten-ID
+exports('SendeNachricht', function(absenderCharakterId, empfTyp, empfaenger, betreff, text)
+ if not absenderCharakterId or not betreff or not text then return nil end
+
+ local quelle = Dienst.QuelleVonCharakter(absenderCharakterId)
+ local beamter = quelle and Dienst.Hole(quelle)
+ if not beamter then return nil end
+
+ local ergebnis = Kern.FuehreAus(quelle, beamter, 'kommunikation', 'senden', {
+ empfTyp = empfTyp, empfaenger = empfaenger,
+ betreff = betreff, text = text,
+ })
+ return ergebnis and ergebnis.id or nil
+end)
diff --git a/resources/[aether]/aether-police/server/module/mdt.lua b/resources/[aether]/aether-police/server/module/mdt.lua
new file mode 100644
index 0000000..4cf8410
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/mdt.lua
@@ -0,0 +1,635 @@
+-- =====================================================================
+-- aether-police / Modul: MDT
+--
+-- Personensuche, Fahrzeugsuche und Aktenverwaltung.
+-- Das MDT zeigt und dokumentiert — es bewertet nichts und ergreift
+-- keine Maßnahme. Jede Änderung wird protokolliert.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'mdt', name = 'MDT', icon = '🔎',
+ reihenfolge = 2, recht = 'person.suchen',
+ beschreibung = 'Personen, Fahrzeuge und Akten',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Liest den Namen eines Beamten anhand der Charakter-ID
+--- @param charakterId number|nil
+--- @return string
+local function NameVon(charakterId)
+ if not charakterId then return '—' end
+ local zeile = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId })
+ if not zeile or not zeile[1] then return '#' .. tostring(charakterId) end
+ return zeile[1].firstname .. ' ' .. zeile[1].lastname
+end
+
+--- Prüft, ob eine Zeichenkette als Suchbegriff taugt
+local function SuchbegriffGueltig(begriff)
+ return type(begriff) == 'string' and #begriff:gsub('%s', '') >= 2
+end
+
+-- =====================================================================
+-- PERSONENSUCHE
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'personSuchen', 'person.suchen', function(quelle, beamter, daten)
+ if not SuchbegriffGueltig(daten.begriff) then
+ return nil, 'Bitte mindestens zwei Zeichen eingeben.'
+ end
+
+ local muster = '%' .. daten.begriff .. '%'
+
+ -- Sucht über Name, Geburtsdatum und Telefonnummer
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, c.job,
+ p.warnhinweis, p.gefahrenstufe, p.lichtbild,
+ t.number AS telefon,
+ (SELECT COUNT(*) FROM police_fahndung f
+ WHERE f.typ = 'person' AND f.ziel_id = c.id AND f.aktiv = 1) AS fahndung
+ FROM aether_characters c
+ LEFT JOIN police_person p ON p.`character` = c.id
+ LEFT JOIN aether_phone t ON t.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ?
+ OR c.dob LIKE ? OR t.number LIKE ?)
+ ORDER BY c.lastname, c.firstname
+ LIMIT 40
+ ]], { muster, muster, muster, muster, muster }) or {}
+
+ return { treffer = treffer }
+end)
+
+-- =====================================================================
+-- PERSONENAKTE
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'personAkte', 'person.suchen', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ -- --- Stammdaten ---
+ local person = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, c.nationality, c.job,
+ c.iban, p.lichtbild, p.fingerabdruck, p.dna, p.warnhinweis, p.gefahrenstufe
+ FROM aether_characters c
+ LEFT JOIN police_person p ON p.`character` = c.id
+ WHERE c.id = ? AND c.deleted = 0
+ ]], { charakterId })
+
+ if not person or not person[1] then return nil, 'Person nicht gefunden.' end
+
+ -- --- Telefonnummer ---
+ local telefon = MySQL.scalar.await(
+ 'SELECT number FROM aether_phone WHERE `character` = ?', { charakterId })
+
+ -- --- Dokumente (Führerschein, Waffenschein ...) ---
+ local dokumente = MySQL.query.await([[
+ SELECT type, data, issued_by, issued_at, valid_until, revoked
+ FROM aether_documents
+ WHERE `character` = ?
+ ]], { charakterId }) or {}
+
+ local lizenzen = {}
+ for _, dokument in ipairs(dokumente) do
+ local zusatz = {}
+ if dokument.data then
+ local erfolg, entschluesselt = pcall(json.decode, dokument.data)
+ if erfolg and type(entschluesselt) == 'table' then zusatz = entschluesselt end
+ end
+ -- Gültig, wenn nicht entzogen und (unbefristet oder noch nicht abgelaufen)
+ local gueltig = dokument.revoked == 0
+ if gueltig and dokument.valid_until then
+ gueltig = tostring(dokument.valid_until):sub(1, 10) >= os.date('%Y-%m-%d')
+ end
+ lizenzen[#lizenzen + 1] = {
+ typ = dokument.type, nummer = zusatz.nummer, klassen = zusatz.klassen,
+ waffen = zusatz.waffen, aussteller = dokument.issued_by,
+ gueltigBis = dokument.valid_until and tostring(dokument.valid_until):sub(1, 10) or nil,
+ entzogen = dokument.revoked == 1, gueltig = gueltig,
+ }
+ end
+
+ -- --- Bekannte Fahrzeuge ---
+ local fahrzeuge = MySQL.query.await([[
+ SELECT v.plate, v.model, v.props, v.state, v.garage,
+ (SELECT COUNT(*) FROM police_fahndung f
+ WHERE f.typ = 'fahrzeug' AND f.ziel_id = v.plate AND f.aktiv = 1) AS fahndung
+ FROM aether_vehicles v WHERE v.owner = ?
+ ]], { charakterId }) or {}
+
+ for _, fahrzeug in ipairs(fahrzeuge) do
+ if fahrzeug.props then
+ local erfolg, props = pcall(json.decode, fahrzeug.props)
+ if erfolg and type(props) == 'table' then
+ fahrzeug.name = props.name
+ fahrzeug.farbe = props.farbe
+ end
+ end
+ fahrzeug.props = nil
+ end
+
+ -- --- Bekannte Kontakte (aus dem Telefonbuch) ---
+ local kontakte = MySQL.query.await([[
+ SELECT k.name, k.number,
+ (SELECT c2.id FROM aether_phone p2
+ JOIN aether_characters c2 ON c2.id = p2.`character`
+ WHERE p2.number = k.number LIMIT 1) AS charakterId
+ FROM aether_phone_contacts k
+ WHERE k.`character` = ?
+ ORDER BY k.name LIMIT 40
+ ]], { charakterId }) or {}
+
+ -- --- Notizen ---
+ local notizen = MySQL.query.await([[
+ SELECT n.id, n.text, n.am, c.firstname, c.lastname
+ FROM police_person_notiz n
+ LEFT JOIN aether_characters c ON c.id = n.von
+ WHERE n.`character` = ?
+ ORDER BY n.id DESC LIMIT 50
+ ]], { charakterId }) or {}
+
+ -- --- Verknüpfte Akten ---
+ local akten = MySQL.query.await([[
+ SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.erstellt_am, v.rolle
+ FROM police_verknuepfung v
+ JOIN police_akte a ON a.id = v.akte
+ WHERE v.typ = 'person' AND v.ziel_id = ?
+ ORDER BY a.id DESC LIMIT 50
+ ]], { tostring(charakterId) }) or {}
+
+ -- --- Anzeigen (nur bestätigte zählen als Vorgeschichte) ---
+ local anzeigen = MySQL.query.await([[
+ SELECT id, delikte, haft_final, geld_final, status, erstellt_am, bestaetigt_am
+ FROM police_anzeige
+ WHERE beschuldigter = ?
+ ORDER BY id DESC LIMIT 30
+ ]], { charakterId }) or {}
+
+ for _, anzeige in ipairs(anzeigen) do
+ local erfolg, liste = pcall(json.decode, anzeige.delikte)
+ anzeige.delikte = (erfolg and type(liste) == 'table') and liste or {}
+ end
+
+ -- --- Offene Fahndung ---
+ local fahndung = MySQL.query.await([[
+ SELECT id, grund, gefahr, prioritaet, hinweise, erstellt_am
+ FROM police_fahndung
+ WHERE typ = 'person' AND ziel_id = ? AND aktiv = 1
+ LIMIT 1
+ ]], { tostring(charakterId) })
+
+ -- --- Laufender Gewahrsam ---
+ local gewahrsam = MySQL.query.await([[
+ SELECT id, grund, zelle, faellig_am,
+ TIMESTAMPDIFF(MINUTE, NOW(), faellig_am) AS rest
+ FROM police_gewahrsam
+ WHERE `character` = ? AND entlassen_am IS NULL
+ LIMIT 1
+ ]], { charakterId })
+
+ return {
+ person = person[1],
+ telefon = telefon,
+ lizenzen = lizenzen,
+ fahrzeuge = fahrzeuge,
+ kontakte = kontakte,
+ notizen = notizen,
+ akten = akten,
+ anzeigen = anzeigen,
+ fahndung = fahndung and fahndung[1] or nil,
+ gewahrsam = gewahrsam and gewahrsam[1] or nil,
+ }
+end)
+
+-- ---------------------------------------------------------------------
+-- Notiz zu einer Person
+-- ---------------------------------------------------------------------
+Kern.RegistriereAktion('mdt', 'personNotiz', 'person.bearbeiten', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ local text = type(daten.text) == 'string' and daten.text:gsub('^%s+', ''):gsub('%s+$', '') or ''
+
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+ if #text < 3 or #text > 1000 then return nil, 'Die Notiz muss 3 bis 1000 Zeichen lang sein.' end
+
+ local id = MySQL.insert.await(
+ 'INSERT INTO police_person_notiz (`character`, text, von) VALUES (?, ?, ?)',
+ { charakterId, text, beamter.charakterId })
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Notiz hinzugefügt',
+ { typ = 'person', id = charakterId }, { notizId = id })
+
+ return { id = id }
+end)
+
+Kern.RegistriereAktion('mdt', 'personNotizLoeschen', 'person.bearbeiten', function(quelle, beamter, daten)
+ local notizId = tonumber(daten.id)
+ if not notizId then return nil, 'Ungültige Kennung.' end
+
+ -- Eigene Notizen darf jeder löschen; fremde nur ab Captain
+ local notiz = MySQL.query.await(
+ 'SELECT `character`, von FROM police_person_notiz WHERE id = ?', { notizId })
+ if not notiz or not notiz[1] then return nil, 'Notiz nicht gefunden.' end
+
+ if notiz[1].von ~= beamter.charakterId and not Rechte.Hat(beamter, 'protokoll.lesen') then
+ return nil, 'Du kannst nur eigene Notizen entfernen.'
+ end
+
+ MySQL.update.await('DELETE FROM police_person_notiz WHERE id = ?', { notizId })
+ Protokoll.Schreibe(beamter, 'mdt', 'Notiz entfernt',
+ { typ = 'person', id = notiz[1].character }, { notizId = notizId })
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Warnhinweis und Gefahrenstufe
+-- ---------------------------------------------------------------------
+Kern.RegistriereAktion('mdt', 'personWarnhinweis', 'person.warnhinweis', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ local hinweis = type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 255) or nil
+ if hinweis == '' then hinweis = nil end
+
+ local stufe = math.max(0, math.min(3, tonumber(daten.stufe) or 0))
+
+ MySQL.query.await([[
+ INSERT INTO police_person (`character`, warnhinweis, gefahrenstufe)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE warnhinweis = VALUES(warnhinweis), gefahrenstufe = VALUES(gefahrenstufe)
+ ]], { charakterId, hinweis, stufe })
+
+ Protokoll.Schreibe(beamter, 'mdt',
+ hinweis and 'Warnhinweis gesetzt' or 'Warnhinweis entfernt',
+ { typ = 'person', id = charakterId }, { hinweis = hinweis, stufe = stufe },
+ daten.grund)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Erkennungsdienstliche Daten (Fingerabdruck, DNA, Lichtbild)
+-- ---------------------------------------------------------------------
+Kern.RegistriereAktion('mdt', 'personStammdaten', 'person.bearbeiten', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ -- Vorhandene Werte lesen, damit leere Felder nichts überschreiben
+ local vorhanden = MySQL.query.await(
+ 'SELECT lichtbild, fingerabdruck, dna FROM police_person WHERE `character` = ?',
+ { charakterId })
+ local alt = (vorhanden and vorhanden[1]) or {}
+
+ local lichtbild = daten.lichtbild ~= nil and tostring(daten.lichtbild):sub(1, 2000) or alt.lichtbild
+ local finger = daten.fingerabdruck ~= nil and tostring(daten.fingerabdruck):sub(1, 64) or alt.fingerabdruck
+ local dna = daten.dna ~= nil and tostring(daten.dna):sub(1, 64) or alt.dna
+
+ MySQL.query.await([[
+ INSERT INTO police_person (`character`, lichtbild, fingerabdruck, dna)
+ VALUES (?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ lichtbild = VALUES(lichtbild), fingerabdruck = VALUES(fingerabdruck), dna = VALUES(dna)
+ ]], { charakterId, lichtbild, finger, dna })
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Erkennungsdienstliche Daten geändert',
+ { typ = 'person', id = charakterId })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- FAHRZEUGSUCHE
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'fahrzeugSuchen', 'fahrzeug.suchen', function(quelle, beamter, daten)
+ if not SuchbegriffGueltig(daten.begriff) then
+ return nil, 'Bitte mindestens zwei Zeichen eingeben.'
+ end
+
+ local muster = '%' .. daten.begriff:upper() .. '%'
+ local namensmuster = '%' .. daten.begriff .. '%'
+
+ local treffer = MySQL.query.await([[
+ SELECT v.plate, v.model, v.props, v.state, v.garage, v.owner,
+ c.firstname, c.lastname,
+ (SELECT COUNT(*) FROM police_fahndung f
+ WHERE f.typ = 'fahrzeug' AND f.ziel_id = v.plate AND f.aktiv = 1) AS fahndung
+ FROM aether_vehicles v
+ LEFT JOIN aether_characters c ON c.id = v.owner
+ WHERE UPPER(v.plate) LIKE ?
+ OR v.model LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ?
+ ORDER BY v.plate LIMIT 40
+ ]], { muster, namensmuster, namensmuster }) or {}
+
+ for _, fahrzeug in ipairs(treffer) do
+ if fahrzeug.props then
+ local erfolg, props = pcall(json.decode, fahrzeug.props)
+ if erfolg and type(props) == 'table' then
+ fahrzeug.name = props.name
+ fahrzeug.farbe = props.farbe
+ fahrzeug.klasse = props.klasse
+ end
+ end
+ fahrzeug.props = nil
+ end
+
+ return { treffer = treffer }
+end)
+
+Kern.RegistriereAktion('mdt', 'fahrzeugAkte', 'fahrzeug.suchen', function(quelle, beamter, daten)
+ local kennzeichen = type(daten.kennzeichen) == 'string' and daten.kennzeichen:upper() or nil
+ if not kennzeichen then return nil, 'Ungültiges Kennzeichen.' end
+
+ local fahrzeug = MySQL.query.await([[
+ SELECT v.*, c.firstname, c.lastname, c.dob
+ FROM aether_vehicles v
+ LEFT JOIN aether_characters c ON c.id = v.owner
+ WHERE UPPER(v.plate) = ?
+ ]], { kennzeichen })
+
+ if not fahrzeug or not fahrzeug[1] then return nil, 'Kein Fahrzeug mit diesem Kennzeichen.' end
+
+ local eintrag = fahrzeug[1]
+ if eintrag.props then
+ local erfolg, props = pcall(json.decode, eintrag.props)
+ if erfolg and type(props) == 'table' then
+ eintrag.name = props.name
+ eintrag.farbe = props.farbe
+ eintrag.klasse = props.klasse
+ end
+ eintrag.props = nil
+ end
+
+ -- Fahndungsstatus
+ local fahndung = MySQL.query.await([[
+ SELECT id, grund, gefahr, prioritaet, hinweise, erstellt_am
+ FROM police_fahndung
+ WHERE typ = 'fahrzeug' AND ziel_id = ? AND aktiv = 1 LIMIT 1
+ ]], { eintrag.plate })
+
+ -- Verknüpfte Akten
+ local akten = MySQL.query.await([[
+ SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.erstellt_am
+ FROM police_verknuepfung v
+ JOIN police_akte a ON a.id = v.akte
+ WHERE v.typ = 'fahrzeug' AND v.ziel_id = ?
+ ORDER BY a.id DESC LIMIT 30
+ ]], { eintrag.plate }) or {}
+
+ -- Protokollhistorie zu diesem Fahrzeug
+ local historie = Protokoll.Lies({ zielTyp = 'fahrzeug', zielId = eintrag.plate, limit = 30 })
+
+ return {
+ fahrzeug = eintrag,
+ fahndung = fahndung and fahndung[1] or nil,
+ akten = akten,
+ historie = historie,
+ }
+end)
+
+-- =====================================================================
+-- AKTEN
+-- =====================================================================
+
+Kern.RegistriereAktion('mdt', 'aktenListe', 'akte.lesen', function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.typ and daten.typ ~= '' then
+ bedingungen[#bedingungen + 1] = 'a.typ = ?'
+ werte[#werte + 1] = daten.typ
+ end
+ if daten.status and daten.status ~= '' then
+ bedingungen[#bedingungen + 1] = 'a.status = ?'
+ werte[#werte + 1] = daten.status
+ end
+ if SuchbegriffGueltig(daten.begriff) then
+ bedingungen[#bedingungen + 1] = '(a.titel LIKE ? OR a.aktenzeichen LIKE ?)'
+ werte[#werte + 1] = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = '%' .. daten.begriff .. '%'
+ end
+
+ local akten = MySQL.query.await(([[
+ SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.erstellt_am, a.version,
+ c.firstname, c.lastname
+ FROM police_akte a
+ LEFT JOIN aether_characters c ON c.id = a.erstellt_von
+ WHERE %s
+ ORDER BY a.id DESC LIMIT 60
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ return { akten = akten }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteOeffnen', 'akte.lesen', function(quelle, beamter, daten)
+ local akteId = tonumber(daten.id)
+ if not akteId then return nil, 'Ungültige Kennung.' end
+
+ local akte = MySQL.query.await([[
+ SELECT a.*, c.firstname, c.lastname
+ FROM police_akte a
+ LEFT JOIN aether_characters c ON c.id = a.erstellt_von
+ WHERE a.id = ?
+ ]], { akteId })
+
+ if not akte or not akte[1] then return nil, 'Akte nicht gefunden.' end
+
+ -- Verknüpfungen mit lesbaren Bezeichnungen anreichern
+ local verknuepfungen = MySQL.query.await(
+ 'SELECT * FROM police_verknuepfung WHERE akte = ? ORDER BY id ASC', { akteId }) or {}
+
+ for _, verknuepfung in ipairs(verknuepfungen) do
+ if verknuepfung.typ == 'person' then
+ verknuepfung.bezeichnung = NameVon(tonumber(verknuepfung.ziel_id))
+ elseif verknuepfung.typ == 'fahrzeug' then
+ local modell = MySQL.scalar.await(
+ 'SELECT model FROM aether_vehicles WHERE plate = ?', { verknuepfung.ziel_id })
+ verknuepfung.bezeichnung = verknuepfung.ziel_id .. (modell and (' · ' .. modell) or '')
+ else
+ verknuepfung.bezeichnung = verknuepfung.ziel_id
+ end
+ end
+
+ -- Versionshistorie
+ local versionen = MySQL.query.await([[
+ SELECT v.version, v.geaendert_am, v.grund, c.firstname, c.lastname
+ FROM police_akte_version v
+ LEFT JOIN aether_characters c ON c.id = v.geaendert_von
+ WHERE v.akte = ? ORDER BY v.version DESC
+ ]], { akteId }) or {}
+
+ return {
+ akte = akte[1],
+ verknuepfungen = verknuepfungen,
+ versionen = versionen,
+ darfBearbeiten = Rechte.Hat(beamter, 'akte.bearbeiten'),
+ darfFreigeben = Rechte.Hat(beamter, 'akte.freigeben'),
+ darfSchliessen = Rechte.Hat(beamter, 'akte.schliessen'),
+ }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteErstellen', 'akte.erstellen', function(quelle, beamter, daten)
+ local titel = type(daten.titel) == 'string' and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or ''
+ if #titel < 3 or #titel > 160 then return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.' end
+
+ -- Aktenart gegen die Konfiguration prüfen
+ local gueltig = false
+ for _, art in ipairs(Polizei.Config.Aktenarten) do
+ if art.id == daten.typ then gueltig = true break end
+ end
+ if not gueltig then return nil, 'Unbekannte Aktenart.' end
+
+ local aktenzeichen = Kern.ErzeugeKennung('AZ', 'police_akte', 'aktenzeichen')
+
+ local akteId = MySQL.insert.await([[
+ INSERT INTO police_akte (aktenzeichen, typ, titel, inhalt, erstellt_von)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { aktenzeichen, daten.typ, titel,
+ type(daten.inhalt) == 'string' and daten.inhalt or nil, beamter.charakterId })
+
+ -- Erste Version festhalten
+ MySQL.insert('INSERT INTO police_akte_version (akte, version, inhalt, geaendert_von, grund) VALUES (?, 1, ?, ?, ?)',
+ { akteId, daten.inhalt, beamter.charakterId, 'Akte angelegt' })
+
+ -- Optionale Erstverknüpfung (z. B. direkt aus einer Personenakte heraus)
+ if daten.verknuepfeTyp and daten.verknuepfeId then
+ MySQL.insert('INSERT INTO police_verknuepfung (akte, typ, ziel_id, rolle) VALUES (?, ?, ?, ?)',
+ { akteId, tostring(daten.verknuepfeTyp), tostring(daten.verknuepfeId), daten.verknuepfeRolle })
+ end
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Akte angelegt',
+ { typ = 'akte', id = akteId }, { aktenzeichen = aktenzeichen, typ = daten.typ })
+
+ Kern.Loese('akte.erstellt', akteId, beamter)
+
+ return { id = akteId, aktenzeichen = aktenzeichen }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteBearbeiten', 'akte.bearbeiten', function(quelle, beamter, daten)
+ local akteId = tonumber(daten.id)
+ if not akteId then return nil, 'Ungültige Kennung.' end
+
+ local akte = MySQL.query.await('SELECT * FROM police_akte WHERE id = ?', { akteId })
+ if not akte or not akte[1] then return nil, 'Akte nicht gefunden.' end
+
+ if akte[1].status == 'geschlossen' and not Rechte.Hat(beamter, 'akte.freigeben') then
+ return nil, 'Diese Akte ist geschlossen und kann nicht mehr bearbeitet werden.'
+ end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or nil
+ if not grund or #grund < 3 then
+ return nil, 'Bitte gib einen Grund für die Änderung an.'
+ end
+
+ local neueVersion = (akte[1].version or 1) + 1
+
+ MySQL.update.await('UPDATE police_akte SET titel = ?, inhalt = ?, version = ? WHERE id = ?',
+ { type(daten.titel) == 'string' and daten.titel:sub(1, 160) or akte[1].titel,
+ daten.inhalt, neueVersion, akteId })
+
+ MySQL.insert('INSERT INTO police_akte_version (akte, version, inhalt, geaendert_von, grund) VALUES (?, ?, ?, ?, ?)',
+ { akteId, neueVersion, daten.inhalt, beamter.charakterId, grund })
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Akte bearbeitet',
+ { typ = 'akte', id = akteId }, { version = neueVersion }, grund)
+
+ return { version = neueVersion }
+end)
+
+--- Status einer Akte ändern — jede Stufe braucht ein eigenes Recht
+Kern.RegistriereAktion('mdt', 'akteStatus', 'akte.lesen', function(quelle, beamter, daten)
+ local akteId = tonumber(daten.id)
+ local status = tostring(daten.status or '')
+ if not akteId then return nil, 'Ungültige Kennung.' end
+
+ if status ~= 'offen' and status ~= 'pruefung' and status ~= 'geschlossen' then
+ return nil, 'Unbekannter Status.'
+ end
+
+ -- Freigabe und Schließen sind gesondert geschützt
+ if status == 'geschlossen' and not Rechte.Hat(beamter, 'akte.schliessen') then
+ return nil, 'Dir fehlt die Berechtigung, Akten zu schließen.'
+ end
+ if status == 'pruefung' and not Rechte.Hat(beamter, 'akte.bearbeiten') then
+ return nil, 'Dir fehlt die Berechtigung dafür.'
+ end
+
+ -- Beim Schließen wird festgehalten, WER es entschieden hat
+ if status == 'geschlossen' then
+ MySQL.update.await(
+ 'UPDATE police_akte SET status = ?, freigabe_von = ?, freigabe_am = NOW() WHERE id = ?',
+ { status, beamter.charakterId, akteId })
+ else
+ MySQL.update.await('UPDATE police_akte SET status = ? WHERE id = ?', { status, akteId })
+ end
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Aktenstatus geändert',
+ { typ = 'akte', id = akteId }, { status = status }, daten.grund)
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Verknüpfungen
+-- ---------------------------------------------------------------------
+Kern.RegistriereAktion('mdt', 'akteVerknuepfen', 'akte.bearbeiten', function(quelle, beamter, daten)
+ local akteId = tonumber(daten.akteId)
+ local typ = tostring(daten.typ or '')
+ local zielId = tostring(daten.zielId or '')
+
+ if not akteId or zielId == '' then return nil, 'Unvollständige Angaben.' end
+
+ local erlaubt = { person = true, fahrzeug = true, beweis = true, einsatz = true, bericht = true }
+ if not erlaubt[typ] then return nil, 'Unbekannte Verknüpfungsart.' end
+
+ -- Doppelte Verknüpfungen vermeiden
+ local vorhanden = MySQL.scalar.await(
+ 'SELECT id FROM police_verknuepfung WHERE akte = ? AND typ = ? AND ziel_id = ?',
+ { akteId, typ, zielId })
+ if vorhanden then return nil, 'Diese Verknüpfung besteht bereits.' end
+
+ local id = MySQL.insert.await(
+ 'INSERT INTO police_verknuepfung (akte, typ, ziel_id, rolle) VALUES (?, ?, ?, ?)',
+ { akteId, typ, zielId, daten.rolle })
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Verknüpfung hinzugefügt',
+ { typ = 'akte', id = akteId }, { ziel = typ .. ':' .. zielId, rolle = daten.rolle })
+
+ return { id = id }
+end)
+
+Kern.RegistriereAktion('mdt', 'akteVerknuepfungLoesen', 'akte.bearbeiten', function(quelle, beamter, daten)
+ local id = tonumber(daten.id)
+ if not id then return nil, 'Ungültige Kennung.' end
+
+ local eintrag = MySQL.query.await('SELECT akte, typ, ziel_id FROM police_verknuepfung WHERE id = ?', { id })
+ if not eintrag or not eintrag[1] then return nil, 'Verknüpfung nicht gefunden.' end
+
+ MySQL.update.await('DELETE FROM police_verknuepfung WHERE id = ?', { id })
+
+ Protokoll.Schreibe(beamter, 'mdt', 'Verknüpfung entfernt',
+ { typ = 'akte', id = eintrag[1].akte },
+ { ziel = eintrag[1].typ .. ':' .. eintrag[1].ziel_id })
+
+ return { ok = true }
+end)
+
+-- ---------------------------------------------------------------------
+-- Konfiguration für die Oberfläche
+-- ---------------------------------------------------------------------
+Kern.RegistriereAktion('mdt', 'stammdaten', nil, function()
+ return {
+ aktenarten = Polizei.Config.Aktenarten,
+ rollen = {
+ { id = 'beschuldigt', name = 'Beschuldigt' },
+ { id = 'zeuge', name = 'Zeuge' },
+ { id = 'geschaedigt', name = 'Geschädigt' },
+ { id = 'beteiligt', name = 'Beteiligt' },
+ },
+ }
+end)
diff --git a/resources/[aether]/aether-police/server/module/personal.lua b/resources/[aether]/aether-police/server/module/personal.lua
new file mode 100644
index 0000000..330c758
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/personal.lua
@@ -0,0 +1,564 @@
+-- =====================================================================
+-- aether-police / Modul: Personalverwaltung
+--
+-- Rollen und Einzelrechte werden hier vergeben. Es gelten drei feste
+-- Schranken, die sich nicht umgehen lassen:
+-- 1. Niemand verwaltet Gleichrangige oder Höherstehende.
+-- 2. Niemand vergibt eine Rolle auf oder über dem eigenen Rang.
+-- 3. Niemand vergibt ein Recht, das er selbst nicht besitzt.
+-- Zusätzlich kann sich niemand selbst bearbeiten.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'personal', name = 'Personal', icon = '👮',
+ reihenfolge = 11, recht = 'personal.sehen',
+ beschreibung = 'Dienstgrade, Rechte und Personalakten',
+})
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Schlägt die nächste freie Dienstnummer vor
+--- @return string
+local function NaechsteDienstnummer()
+ local praefix = Polizei.Config.Kuerzel .. '-'
+
+ -- Alle vergebenen Nummern einmal holen, dann die erste Lücke suchen
+ local vergeben = {}
+ local zeilen = MySQL.query.await(
+ 'SELECT dienstnummer FROM police_personal WHERE dienstnummer LIKE ?',
+ { praefix .. '%' }) or {}
+ for _, zeile in ipairs(zeilen) do
+ vergeben[zeile.dienstnummer] = true
+ end
+
+ for nummer = 1, 9999 do
+ local kandidat = ('%s%04d'):format(praefix, nummer)
+ if not vergeben[kandidat] then return kandidat end
+ end
+
+ return praefix .. tostring(os.time()):sub(-4)
+end
+
+--- Wandelt eine JSON-Liste in eine Menge um
+local function ZuMenge(roh)
+ local menge = {}
+ if not roh then return menge end
+ local erfolg, liste = pcall(json.decode, roh)
+ if erfolg and type(liste) == 'table' then
+ for _, wert in ipairs(liste) do menge[wert] = true end
+ end
+ return menge
+end
+
+--- Wandelt eine Menge zurück in eine sortierte Liste
+local function ZuListe(menge)
+ local liste = {}
+ for wert in pairs(menge) do liste[#liste + 1] = wert end
+ table.sort(liste)
+ return liste
+end
+
+--- Prüft, ob ein Beamter einen anderen Personaldatensatz verwalten darf
+--- @param beamter table Handelnder
+--- @param ziel table Zeile aus police_personal
+--- @return boolean, string|nil
+local function DarfVerwalten(beamter, ziel)
+ if ziel.character == beamter.charakterId then
+ return false, 'Du kannst deinen eigenen Datensatz nicht bearbeiten.'
+ end
+ if not Rechte.StehtUeber(beamter, ziel.rolle) then
+ return false, 'Du kannst nur rangniedrigere Beamte verwalten.'
+ end
+ return true
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('personal', 'liste', nil, function(quelle, beamter, daten)
+ local bedingungen, werte = { '1=1' }, {}
+
+ if daten.ehemalige then
+ bedingungen[#bedingungen + 1] = 'p.aktiv = 0'
+ else
+ bedingungen[#bedingungen + 1] = 'p.aktiv = 1'
+ end
+
+ if daten.abteilung and daten.abteilung ~= '' then
+ bedingungen[#bedingungen + 1] = 'p.abteilung = ?'
+ werte[#werte + 1] = daten.abteilung
+ end
+ if daten.rolle and daten.rolle ~= '' then
+ bedingungen[#bedingungen + 1] = 'p.rolle = ?'
+ werte[#werte + 1] = daten.rolle
+ end
+ if type(daten.begriff) == 'string' and #daten.begriff >= 2 then
+ bedingungen[#bedingungen + 1] =
+ '(p.dienstnummer LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)'
+ local muster = '%' .. daten.begriff .. '%'
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ werte[#werte + 1] = muster
+ end
+
+ local personal = MySQL.query.await(([[
+ SELECT p.`character`, p.dienstnummer, p.rolle, p.abteilung, p.eingestellt,
+ p.aktiv, p.notiz, p.rechte_extra, p.rechte_entzug,
+ c.firstname, c.lastname,
+ (SELECT COALESCE(SUM(d.sekunden), 0) FROM police_dienst d
+ WHERE d.`character` = p.`character`) AS dienstzeit
+ FROM police_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ WHERE %s
+ ORDER BY p.dienstnummer
+ LIMIT 200
+ ]]):format(table.concat(bedingungen, ' AND ')), werte) or {}
+
+ -- Wer ist gerade im Dienst?
+ local imDienst = {}
+ for _, eintrag in pairs(Dienst.AlleImDienst()) do
+ imDienst[eintrag.charakterId] = true
+ end
+
+ for _, eintrag in ipairs(personal) do
+ local rolle = Polizei.HoleRolle(eintrag.rolle)
+ eintrag.rolleName = rolle and rolle.name or eintrag.rolle
+ eintrag.stufe = Polizei.Stufe(eintrag.rolle)
+ eintrag.imDienst = imDienst[eintrag.character] == true
+ -- Anzahl abweichender Einzelrechte für die Übersicht
+ local extra = ZuMenge(eintrag.rechte_extra)
+ local entzug = ZuMenge(eintrag.rechte_entzug)
+ local anzahl = 0
+ for _ in pairs(extra) do anzahl = anzahl + 1 end
+ for _ in pairs(entzug) do anzahl = anzahl + 1 end
+ eintrag.abweichungen = anzahl
+ eintrag.rechte_extra, eintrag.rechte_entzug = nil, nil
+ end
+
+ -- Verteilung nach Rollen
+ local verteilung = MySQL.query.await(
+ 'SELECT rolle, COUNT(*) AS anzahl FROM police_personal WHERE aktiv = 1 GROUP BY rolle') or {}
+
+ return {
+ personal = personal,
+ verteilung = verteilung,
+ rollen = Polizei.Rollen,
+ abteilungen= Polizei.Config.Abteilungen,
+ meineStufe = beamter.stufe,
+ rechte = {
+ einstellen = Rechte.Hat(beamter, 'personal.einstellen'),
+ rolle = Rechte.Hat(beamter, 'personal.rolle'),
+ rechte = Rechte.Hat(beamter, 'personal.rechte'),
+ },
+ }
+end)
+
+Kern.RegistriereAktion('personal', 'oeffnen', nil, function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ local zeilen = MySQL.query.await([[
+ SELECT p.*, c.firstname, c.lastname, c.dob
+ FROM police_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ WHERE p.`character` = ?
+ ]], { charakterId })
+
+ if not zeilen or not zeilen[1] then return nil, 'Personaldatensatz nicht gefunden.' end
+
+ local eintrag = zeilen[1]
+ local rolle = Polizei.HoleRolle(eintrag.rolle)
+
+ -- Rechte aufschlüsseln: woher kommt welches Recht?
+ local ausRolle = Polizei.RechteDerRolle(eintrag.rolle)
+ local extra = ZuMenge(eintrag.rechte_extra)
+ local entzug = ZuMenge(eintrag.rechte_entzug)
+
+ local rechteUebersicht = {}
+ for _, gruppe in ipairs(Polizei.Rechte) do
+ local eintraege = {}
+ for _, recht in ipairs(gruppe.eintraege) do
+ local herkunft = 'keins'
+ if entzug[recht.id] then herkunft = 'entzogen'
+ elseif extra[recht.id] then herkunft = 'zusaetzlich'
+ elseif ausRolle[recht.id] then herkunft = 'rolle' end
+
+ eintraege[#eintraege + 1] = {
+ id = recht.id, name = recht.name, herkunft = herkunft,
+ -- Darf der Handelnde dieses Recht überhaupt vergeben?
+ vergebbar = Rechte.Hat(beamter, recht.id),
+ }
+ end
+ rechteUebersicht[#rechteUebersicht + 1] = { gruppe = gruppe.gruppe, eintraege = eintraege }
+ end
+
+ -- Dienstzeiten
+ local dienste = MySQL.query.await([[
+ SELECT beginn, ende, sekunden, abteilung
+ FROM police_dienst WHERE `character` = ?
+ ORDER BY id DESC LIMIT 20
+ ]], { charakterId }) or {}
+
+ local gesamt = MySQL.scalar.await(
+ 'SELECT COALESCE(SUM(sekunden), 0) FROM police_dienst WHERE `character` = ?',
+ { charakterId }) or 0
+
+ local darf, grund = DarfVerwalten(beamter, eintrag)
+
+ return {
+ personal = {
+ charakterId = eintrag.character,
+ name = eintrag.firstname .. ' ' .. eintrag.lastname,
+ geburt = tostring(eintrag.dob):sub(1, 10),
+ dienstnummer= eintrag.dienstnummer,
+ rolle = eintrag.rolle,
+ rolleName = rolle and rolle.name or eintrag.rolle,
+ abteilung = eintrag.abteilung,
+ eingestellt = eintrag.eingestellt,
+ aktiv = eintrag.aktiv == 1,
+ notiz = eintrag.notiz,
+ },
+ rechte = rechteUebersicht,
+ dienste = dienste,
+ gesamtzeit = gesamt,
+ rollen = Polizei.Rollen,
+ abteilungen = Polizei.Config.Abteilungen,
+ meineStufe = beamter.stufe,
+ darfVerwalten= darf,
+ sperrgrund = grund,
+ meineRechte = {
+ rolle = Rechte.Hat(beamter, 'personal.rolle'),
+ rechte = Rechte.Hat(beamter, 'personal.rechte'),
+ einstellen = Rechte.Hat(beamter, 'personal.einstellen'),
+ },
+ }
+end)
+
+-- =====================================================================
+-- EINSTELLEN
+-- =====================================================================
+
+Kern.RegistriereAktion('personal', 'einstellen', 'personal.einstellen', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Keine Person ausgewählt.' end
+
+ local person = MySQL.query.await(
+ 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0',
+ { charakterId })
+ if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end
+
+ -- Bereits im Bestand?
+ local vorhanden = MySQL.query.await(
+ 'SELECT dienstnummer, aktiv FROM police_personal WHERE `character` = ?', { charakterId })
+ if vorhanden and vorhanden[1] and vorhanden[1].aktiv == 1 then
+ return nil, 'Diese Person ist bereits im Personalbestand.'
+ end
+
+ -- Rolle prüfen: niemals auf oder über dem eigenen Rang
+ local rollenId = tostring(daten.rolle or 'anwaerter')
+ local rolle = Polizei.HoleRolle(rollenId)
+ if not rolle then return nil, 'Unbekannte Rolle.' end
+ if rolle.stufe >= beamter.stufe then
+ return nil, 'Du kannst niemanden auf deinen Rang oder darüber einstellen.'
+ end
+
+ -- Abteilung prüfen
+ local abteilung = 'streife'
+ for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
+ if eintrag.id == daten.abteilung then abteilung = daten.abteilung break end
+ end
+
+ -- Dienstnummer: eigene Angabe oder Vorschlag
+ local dienstnummer = type(daten.dienstnummer) == 'string'
+ and daten.dienstnummer:gsub('%s', ''):upper() or ''
+ if dienstnummer == '' then dienstnummer = NaechsteDienstnummer() end
+ if #dienstnummer > 16 then return nil, 'Die Dienstnummer ist zu lang.' end
+
+ local belegt = MySQL.scalar.await(
+ 'SELECT `character` FROM police_personal WHERE dienstnummer = ? AND `character` <> ?',
+ { dienstnummer, charakterId })
+ if belegt then return nil, 'Diese Dienstnummer ist bereits vergeben.' end
+
+ -- Wiedereinstellung oder Neuanlage
+ if vorhanden and vorhanden[1] then
+ MySQL.update.await([[
+ UPDATE police_personal
+ SET dienstnummer = ?, rolle = ?, abteilung = ?, aktiv = 1,
+ rechte_extra = NULL, rechte_entzug = NULL, eingestellt = NOW()
+ WHERE `character` = ?
+ ]], { dienstnummer, rollenId, abteilung, charakterId })
+ else
+ MySQL.insert.await([[
+ INSERT INTO police_personal (`character`, dienstnummer, rolle, abteilung, notiz)
+ VALUES (?, ?, ?, ?, ?)
+ ]], { charakterId, dienstnummer, rollenId, abteilung,
+ type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil })
+ end
+
+ Protokoll.Schreibe(beamter, 'personal', 'Beamten eingestellt',
+ { typ = 'person', id = charakterId },
+ { dienstnummer = dienstnummer, rolle = rollenId, abteilung = abteilung })
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system', prioritaet = 'niedrig',
+ titel = 'Neuer Kollege',
+ text = ('%s %s (%s) wurde als %s eingestellt.')
+ :format(person[1].firstname, person[1].lastname, dienstnummer, rolle.name),
+ })
+
+ Kern.Loese('personal.eingestellt', charakterId, beamter)
+
+ return { dienstnummer = dienstnummer }
+end)
+
+--- Vorschlag für die nächste Dienstnummer
+Kern.RegistriereAktion('personal', 'nummerVorschlag', 'personal.einstellen', function()
+ return { dienstnummer = NaechsteDienstnummer() }
+end)
+
+--- Personensuche für die Einstellung
+Kern.RegistriereAktion('personal', 'personSuche', 'personal.einstellen', function(quelle, beamter, daten)
+ local begriff = type(daten.begriff) == 'string' and daten.begriff or ''
+ if #begriff < 2 then return { treffer = {} } end
+
+ local muster = '%' .. begriff .. '%'
+ local treffer = MySQL.query.await([[
+ SELECT c.id, c.firstname, c.lastname, c.dob,
+ p.dienstnummer, p.aktiv
+ FROM aether_characters c
+ LEFT JOIN police_personal p ON p.`character` = c.id
+ WHERE c.deleted = 0
+ AND (c.firstname LIKE ? OR c.lastname LIKE ?
+ OR CONCAT(c.firstname, ' ', c.lastname) LIKE ?)
+ LIMIT 20
+ ]], { muster, muster, muster }) or {}
+
+ return { treffer = treffer }
+end)
+
+-- =====================================================================
+-- ROLLE UND ABTEILUNG
+-- =====================================================================
+
+Kern.RegistriereAktion('personal', 'rolleAendern', 'personal.rolle', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ local ziel = MySQL.query.await(
+ 'SELECT `character`, rolle, dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1',
+ { charakterId })
+ if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
+
+ local darf, grund = DarfVerwalten(beamter, ziel[1])
+ if not darf then return nil, grund end
+
+ local neueRolle = Polizei.HoleRolle(tostring(daten.rolle or ''))
+ if not neueRolle then return nil, 'Unbekannte Rolle.' end
+
+ -- Schranke 2: nie auf oder über den eigenen Rang befördern
+ if neueRolle.stufe >= beamter.stufe then
+ return nil, 'Du kannst niemanden auf deinen Rang oder darüber setzen.'
+ end
+
+ local vermerk = type(daten.vermerk) == 'string' and daten.vermerk:sub(1, 255) or ''
+ if #vermerk < 3 then return nil, 'Bitte einen Vermerk zur Änderung angeben.' end
+
+ local alteStufe = Polizei.Stufe(ziel[1].rolle)
+ local richtung = neueRolle.stufe > alteStufe and 'Beförderung' or
+ neueRolle.stufe < alteStufe and 'Degradierung' or 'Rollenwechsel'
+
+ MySQL.update.await('UPDATE police_personal SET rolle = ? WHERE `character` = ?',
+ { neueRolle.id, charakterId })
+
+ -- Läuft die Person gerade im Dienst, Rechte sofort neu laden
+ local zielQuelle = Dienst.QuelleVonCharakter(charakterId)
+ if zielQuelle then
+ local neu = Dienst.LadeBeamten(zielQuelle, charakterId)
+ if neu then
+ local aktuell = Dienst.Hole(zielQuelle)
+ aktuell.rolle = neu.rolle
+ aktuell.rolleName = neu.rolleName
+ aktuell.stufe = neu.stufe
+ aktuell.rechte = neu.rechte
+ end
+
+ Hinweise.Erstelle({ charakterId = charakterId }, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = richtung,
+ text = ('Dein Dienstgrad wurde auf %s gesetzt: %s'):format(neueRolle.name, vermerk),
+ })
+ end
+
+ Protokoll.Schreibe(beamter, 'personal', richtung,
+ { typ = 'person', id = charakterId },
+ { von = ziel[1].rolle, nach = neueRolle.id, dienstnummer = ziel[1].dienstnummer }, vermerk)
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('personal', 'abteilungAendern', 'personal.rolle', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ local ziel = MySQL.query.await(
+ 'SELECT `character`, rolle FROM police_personal WHERE `character` = ? AND aktiv = 1',
+ { charakterId })
+ if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
+
+ local darf, grund = DarfVerwalten(beamter, ziel[1])
+ if not darf then return nil, grund end
+
+ local abteilung = nil
+ for _, eintrag in ipairs(Polizei.Config.Abteilungen) do
+ if eintrag.id == daten.abteilung then abteilung = eintrag break end
+ end
+ if not abteilung then return nil, 'Unbekannte Abteilung.' end
+
+ MySQL.update.await('UPDATE police_personal SET abteilung = ? WHERE `character` = ?',
+ { abteilung.id, charakterId })
+
+ Protokoll.Schreibe(beamter, 'personal', 'Abteilung geändert',
+ { typ = 'person', id = charakterId }, { abteilung = abteilung.id })
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- EINZELRECHTE
+--
+-- Schranke 3: Niemand kann ein Recht vergeben, das er selbst nicht hat.
+-- =====================================================================
+
+Kern.RegistriereAktion('personal', 'rechtSetzen', 'personal.rechte', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ local rechtId = tostring(daten.recht or '')
+ local modus = tostring(daten.modus or '') -- zusaetzlich | entzogen | rolle
+
+ if not charakterId or rechtId == '' then return nil, 'Unvollständige Angaben.' end
+ if modus ~= 'zusaetzlich' and modus ~= 'entzogen' and modus ~= 'rolle' then
+ return nil, 'Unbekannter Modus.'
+ end
+
+ -- Existiert das Recht überhaupt?
+ local bekannt = false
+ for _, id in ipairs(Polizei.AlleRechte()) do
+ if id == rechtId then bekannt = true break end
+ end
+ if not bekannt then return nil, 'Unbekanntes Recht.' end
+
+ local ziel = MySQL.query.await(
+ 'SELECT `character`, rolle, rechte_extra, rechte_entzug, dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1',
+ { charakterId })
+ if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
+
+ local darf, grund = DarfVerwalten(beamter, ziel[1])
+ if not darf then return nil, grund end
+
+ -- Schranke 3: nur eigene Rechte weitergeben
+ if modus == 'zusaetzlich' and not Rechte.Hat(beamter, rechtId) then
+ return nil, 'Du kannst nur Rechte vergeben, die du selbst besitzt.'
+ end
+
+ local extra = ZuMenge(ziel[1].rechte_extra)
+ local entzug = ZuMenge(ziel[1].rechte_entzug)
+
+ -- Erst beide Listen bereinigen, dann neu setzen
+ extra[rechtId] = nil
+ entzug[rechtId] = nil
+
+ if modus == 'zusaetzlich' then extra[rechtId] = true
+ elseif modus == 'entzogen' then entzug[rechtId] = true end
+ -- modus == 'rolle' lässt beide leer: es gilt wieder die Rolle
+
+ MySQL.update.await(
+ 'UPDATE police_personal SET rechte_extra = ?, rechte_entzug = ? WHERE `character` = ?',
+ { json.encode(ZuListe(extra)), json.encode(ZuListe(entzug)), charakterId })
+
+ -- Im Dienst? Dann sofort wirksam machen
+ local zielQuelle = Dienst.QuelleVonCharakter(charakterId)
+ if zielQuelle then
+ local neu = Dienst.LadeBeamten(zielQuelle, charakterId)
+ if neu then Dienst.Hole(zielQuelle).rechte = neu.rechte end
+ end
+
+ Protokoll.Schreibe(beamter, 'personal', 'Einzelrecht geändert',
+ { typ = 'person', id = charakterId },
+ { recht = rechtId, modus = modus, dienstnummer = ziel[1].dienstnummer }, daten.vermerk)
+
+ return { ok = true }
+end)
+
+-- =====================================================================
+-- NOTIZ UND ENTLASSUNG
+-- =====================================================================
+
+Kern.RegistriereAktion('personal', 'notiz', 'personal.rolle', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ local ziel = MySQL.query.await(
+ 'SELECT `character`, rolle FROM police_personal WHERE `character` = ?', { charakterId })
+ if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
+
+ local darf, grund = DarfVerwalten(beamter, ziel[1])
+ if not darf then return nil, grund end
+
+ MySQL.update.await('UPDATE police_personal SET notiz = ? WHERE `character` = ?',
+ { type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil, charakterId })
+
+ Protokoll.Schreibe(beamter, 'personal', 'Personalnotiz geändert',
+ { typ = 'person', id = charakterId })
+
+ return { ok = true }
+end)
+
+Kern.RegistriereAktion('personal', 'entlassen', 'personal.einstellen', function(quelle, beamter, daten)
+ local charakterId = tonumber(daten.charakterId)
+ if not charakterId then return nil, 'Ungültige Kennung.' end
+
+ local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or ''
+ if #grund < 5 then return nil, 'Eine Entlassung erfordert eine Begründung.' end
+
+ local ziel = MySQL.query.await([[
+ SELECT p.`character`, p.rolle, p.dienstnummer, c.firstname, c.lastname
+ FROM police_personal p
+ JOIN aether_characters c ON c.id = p.`character`
+ WHERE p.`character` = ? AND p.aktiv = 1
+ ]], { charakterId })
+ if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end
+
+ local darf, sperre = DarfVerwalten(beamter, ziel[1])
+ if not darf then return nil, sperre end
+
+ -- Datensatz bleibt bestehen, nur inaktiv gesetzt
+ MySQL.update.await('UPDATE police_personal SET aktiv = 0 WHERE `character` = ?', { charakterId })
+
+ -- Läuft die Person im Dienst, wird sie abgemeldet
+ local zielQuelle = Dienst.QuelleVonCharakter(charakterId)
+ if zielQuelle then
+ Dienst.Beenden(zielQuelle)
+ TriggerClientEvent('aether:police:schliessen', zielQuelle)
+ end
+
+ -- Zugewiesene Dienstfahrzeuge freigeben
+ MySQL.update('UPDATE police_fahrzeug SET zugewiesen_an = NULL WHERE zugewiesen_an = ?',
+ { charakterId })
+
+ Protokoll.Schreibe(beamter, 'personal', 'Beamten entlassen',
+ { typ = 'person', id = charakterId },
+ { dienstnummer = ziel[1].dienstnummer, rolle = ziel[1].rolle }, grund)
+
+ Hinweise.Erstelle(nil, {
+ kategorie = 'system', prioritaet = 'normal',
+ titel = 'Personalveränderung',
+ text = ('%s %s (%s) wurde aus dem Dienst entlassen.')
+ :format(ziel[1].firstname, ziel[1].lastname, ziel[1].dienstnummer),
+ })
+
+ Kern.Loese('personal.entlassen', charakterId, beamter)
+
+ return { ok = true }
+end)
diff --git a/resources/[aether]/aether-police/server/module/statistik.lua b/resources/[aether]/aether-police/server/module/statistik.lua
new file mode 100644
index 0000000..2a2802f
--- /dev/null
+++ b/resources/[aether]/aether-police/server/module/statistik.lua
@@ -0,0 +1,436 @@
+-- =====================================================================
+-- aether-police / Modul: Statistik
+--
+-- Bewusst eine Auswertung der BEHÖRDE, nicht der Beamten.
+--
+-- Es gibt hier keine Ranglisten, keine Punkte und keine Kennzahl, an
+-- der sich die Arbeit eines einzelnen Beamten messen ließe. Wer wie
+-- viel gearbeitet hat, steht in seiner Personalakte und geht seinen
+-- Vorgesetzten etwas an — nicht einer Tabelle, die jemanden bewertet.
+--
+-- Alle Zahlen sind Zählungen. Keine davon löst etwas aus.
+-- =====================================================================
+
+Kern.RegistriereModul({
+ id = 'statistik', name = 'Statistik', icon = '📊',
+ reihenfolge = 13, recht = 'statistik.sehen',
+ beschreibung = 'Auswertung der Behörde über einen Zeitraum',
+})
+
+-- Auswählbare Zeiträume in Tagen
+local ZEITRAEUME = { 7, 30, 90 }
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Begrenzt den Zeitraum auf die zugelassenen Werte
+--- @param tage any Angabe der Oberfläche
+--- @return number
+local function PruefeZeitraum(tage)
+ local wert = tonumber(tage) or 30
+ for _, erlaubt in ipairs(ZEITRAEUME) do
+ if wert == erlaubt then return wert end
+ end
+ return 30
+end
+
+--- Führt eine Zählung aus und liefert immer eine Zahl
+--- @param sql string Abfrage
+--- @param werte table Parameter
+--- @return number
+local function Zaehle(sql, werte)
+ return tonumber(MySQL.scalar.await(sql, werte)) or 0
+end
+
+--- Baut aus Zeilen { schluessel, anzahl } eine Verteilung mit Anteilen
+--- @param zeilen table Ergebniszeilen
+--- @param namen table|nil Übersetzungstabelle für die Schlüssel
+--- @return table
+local function Verteilung(zeilen, namen)
+ local gesamt = 0
+ for _, zeile in ipairs(zeilen or {}) do
+ gesamt = gesamt + (tonumber(zeile.anzahl) or 0)
+ end
+
+ local liste = {}
+ for _, zeile in ipairs(zeilen or {}) do
+ local anzahl = tonumber(zeile.anzahl) or 0
+ liste[#liste + 1] = {
+ schluessel = zeile.schluessel,
+ name = (namen and namen[zeile.schluessel]) or zeile.schluessel or 'unbekannt',
+ anzahl = anzahl,
+ anteil = gesamt > 0 and math.floor((anzahl / gesamt) * 100 + 0.5) or 0,
+ }
+ end
+
+ return { eintraege = liste, gesamt = gesamt }
+end
+
+--- Liest die Namen aus einer Konfigurationsliste { id, name }
+--- @param liste table Konfiguration
+--- @return table [id] = name
+local function NamenAus(liste)
+ local namen = {}
+ for _, eintrag in ipairs(liste or {}) do namen[eintrag.id] = eintrag.name end
+ return namen
+end
+
+-- =====================================================================
+-- ÜBERSICHT
+-- =====================================================================
+
+Kern.RegistriereAktion('statistik', 'uebersicht', nil, function(quelle, beamter, daten)
+ local tage = PruefeZeitraum(daten.tage)
+
+ -- --- Einsätze ---
+ local einsaetze = {
+ gesamt = Zaehle([[
+ SELECT COUNT(*) FROM police_einsatz
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ offen = Zaehle([[
+ SELECT COUNT(*) FROM police_einsatz WHERE geschlossen_am IS NULL
+ ]], {}),
+ }
+
+ einsaetze.nachKategorie = Verteilung(MySQL.query.await([[
+ SELECT kategorie AS schluessel, COUNT(*) AS anzahl
+ FROM police_einsatz
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ GROUP BY kategorie ORDER BY anzahl DESC
+ ]], { tage }), NamenAus(Polizei.Config.Einsatzkategorien))
+
+ einsaetze.nachPrioritaet = Verteilung(MySQL.query.await([[
+ SELECT prioritaet AS schluessel, COUNT(*) AS anzahl
+ FROM police_einsatz
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ GROUP BY prioritaet ORDER BY prioritaet
+ ]], { tage }))
+
+ -- Wie lange bleibt ein Einsatz offen? Prozessdauer, keine Bewertung.
+ einsaetze.laufzeitMinuten = tonumber(MySQL.scalar.await([[
+ SELECT AVG(TIMESTAMPDIFF(MINUTE, erstellt_am, geschlossen_am))
+ FROM police_einsatz
+ WHERE geschlossen_am IS NOT NULL
+ AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })) or 0
+
+ -- --- Anzeigen ---
+ local anzeigen = {
+ gesamt = Zaehle([[
+ SELECT COUNT(*) FROM police_anzeige
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ bestaetigt = Zaehle([[
+ SELECT COUNT(*) FROM police_anzeige
+ WHERE status = 'bestaetigt' AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ entwurf = Zaehle([[
+ SELECT COUNT(*) FROM police_anzeige WHERE status = 'entwurf'
+ ]], {}),
+ }
+
+ -- Wie oft weicht die Entscheidung vom Katalogvorschlag ab?
+ -- Das ist eine Aussage über den KATALOG, nicht über die Beamten:
+ -- viele Abweichungen heißen, dass der Vorschlag nicht passt.
+ anzeigen.abweichungen = Zaehle([[
+ SELECT COUNT(*) FROM police_anzeige
+ WHERE status = 'bestaetigt'
+ AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ AND (haft_final <> haft_empfohlen OR geld_final <> geld_empfohlen)
+ ]], { tage })
+
+ anzeigen.haftDurchschnitt = tonumber(MySQL.scalar.await([[
+ SELECT AVG(haft_final) FROM police_anzeige
+ WHERE status = 'bestaetigt' AND haft_final IS NOT NULL
+ AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })) or 0
+
+ anzeigen.geldDurchschnitt = tonumber(MySQL.scalar.await([[
+ SELECT AVG(geld_final) FROM police_anzeige
+ WHERE status = 'bestaetigt' AND geld_final IS NOT NULL
+ AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })) or 0
+
+ -- --- Gewahrsam ---
+ local gewahrsam = {
+ einweisungen = Zaehle([[
+ SELECT COUNT(*) FROM police_gewahrsam
+ WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ laufend = Zaehle('SELECT COUNT(*) FROM police_gewahrsam WHERE entlassen_am IS NULL', {}),
+ }
+
+ gewahrsam.haftDurchschnitt = tonumber(MySQL.scalar.await([[
+ SELECT AVG(haft_minuten) FROM police_gewahrsam
+ WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })) or 0
+
+ -- Wie lange nach Fristablauf wurde tatsächlich entlassen?
+ -- Reine Prozessdauer — das System entlässt niemanden von selbst,
+ -- deshalb ist der Verzug eine Frage der Erreichbarkeit, nicht der Schuld.
+ gewahrsam.verzugMinuten = tonumber(MySQL.scalar.await([[
+ SELECT AVG(TIMESTAMPDIFF(MINUTE, faellig_am, entlassen_am))
+ FROM police_gewahrsam
+ WHERE entlassen_am IS NOT NULL
+ AND beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })) or 0
+
+ gewahrsam.vorzeitig = Zaehle([[
+ SELECT COUNT(*) FROM police_gewahrsam
+ WHERE entlassen_am IS NOT NULL AND entlassen_am < faellig_am
+ AND beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })
+
+ -- --- Akten, Berichte, Fahndungen ---
+ local vorgaenge = {
+ akten = Zaehle([[
+ SELECT COUNT(*) FROM police_akte
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ aktenOffen = Zaehle("SELECT COUNT(*) FROM police_akte WHERE status <> 'geschlossen'", {}),
+ berichte = Zaehle([[
+ SELECT COUNT(*) FROM police_bericht
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ berichteEntwurf = Zaehle("SELECT COUNT(*) FROM police_bericht WHERE status = 'entwurf'", {}),
+ fahndungen = Zaehle([[
+ SELECT COUNT(*) FROM police_fahndung
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ fahndungenAktiv = Zaehle('SELECT COUNT(*) FROM police_fahndung WHERE aktiv = 1', {}),
+ beweise = Zaehle([[
+ SELECT COUNT(*) FROM police_beweis
+ WHERE erfasst_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage }),
+ asservate = Zaehle("SELECT COUNT(*) FROM police_asservat WHERE status = 'eingelagert'", {}),
+ }
+
+ vorgaenge.aktenNachTyp = Verteilung(MySQL.query.await([[
+ SELECT typ AS schluessel, COUNT(*) AS anzahl
+ FROM police_akte
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ GROUP BY typ ORDER BY anzahl DESC
+ ]], { tage }), NamenAus(Polizei.Config.Aktenarten))
+
+ -- --- Personal und Dienst ---
+ local personal = {
+ bestand = Zaehle('SELECT COUNT(*) FROM police_personal WHERE aktiv = 1', {}),
+ imDienst = 0,
+ }
+ for _ in pairs(Dienst.AlleImDienst()) do personal.imDienst = personal.imDienst + 1 end
+
+ personal.nachAbteilung = Verteilung(MySQL.query.await([[
+ SELECT abteilung AS schluessel, COUNT(*) AS anzahl
+ FROM police_personal WHERE aktiv = 1
+ GROUP BY abteilung ORDER BY anzahl DESC
+ ]], {}), NamenAus(Polizei.Config.Abteilungen))
+
+ personal.dienstStunden = math.floor((tonumber(MySQL.scalar.await([[
+ SELECT COALESCE(SUM(sekunden), 0) FROM police_dienst
+ WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { tage })) or 0) / 3600)
+
+ -- --- Verlauf: Einsätze und Anzeigen je Tag ---
+ local verlauf = MySQL.query.await([[
+ SELECT tag,
+ SUM(einsatz) AS einsaetze,
+ SUM(anzeige) AS anzeigen
+ FROM (
+ SELECT DATE(erstellt_am) AS tag, 1 AS einsatz, 0 AS anzeige
+ FROM police_einsatz
+ WHERE erstellt_am >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ UNION ALL
+ SELECT DATE(erstellt_am) AS tag, 0 AS einsatz, 1 AS anzeige
+ FROM police_anzeige
+ WHERE erstellt_am >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
+ ) AS zusammen
+ GROUP BY tag ORDER BY tag
+ ]], { tage, tage }) or {}
+
+ return {
+ tage = tage,
+ zeitraeume = ZEITRAEUME,
+ einsaetze = einsaetze,
+ anzeigen = anzeigen,
+ gewahrsam = gewahrsam,
+ vorgaenge = vorgaenge,
+ personal = personal,
+ verlauf = verlauf,
+ }
+end)
+
+-- =====================================================================
+-- DIENSTABDECKUNG
+--
+-- Wann ist wie viel Personal da? Ein Planungswerkzeug für die Leitung.
+-- Die Zahlen sagen nichts darüber aus, WER Dienst hatte.
+-- =====================================================================
+
+Kern.RegistriereAktion('statistik', 'abdeckung', nil, function(quelle, beamter, daten)
+ local tage = PruefeZeitraum(daten.tage)
+
+ local dienste = MySQL.query.await([[
+ SELECT UNIX_TIMESTAMP(beginn) AS beginn,
+ UNIX_TIMESTAMP(COALESCE(ende, NOW())) AS ende,
+ abteilung
+ FROM police_dienst
+ WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ LIMIT 5000
+ ]], { tage }) or {}
+
+ -- Dienstsekunden auf die 24 Stunden des Tages verteilen
+ local stunden = {}
+ for i = 0, 23 do stunden[i] = 0 end
+
+ for _, dienst in ipairs(dienste) do
+ local beginn = tonumber(dienst.beginn) or 0
+ local ende = tonumber(dienst.ende) or beginn
+
+ -- Schicht stundenweise durchlaufen, damit Nachtschichten korrekt zählen
+ local zeiger = beginn
+ local schutz = 0
+ while zeiger < ende and schutz < 500 do
+ schutz = schutz + 1
+
+ local stunde = tonumber(os.date('%H', zeiger)) or 0
+ -- Ende der laufenden Stunde
+ local grenze = zeiger + (3600 - (zeiger % 3600))
+ if grenze > ende then grenze = ende end
+
+ stunden[stunde] = stunden[stunde] + (grenze - zeiger)
+ zeiger = grenze
+ end
+ end
+
+ -- In durchschnittlich gleichzeitig anwesende Beamte umrechnen
+ local liste, hoechstwert = {}, 0
+ for i = 0, 23 do
+ -- Sekunden geteilt durch (Tage × 3600) = mittlere Besetzung dieser Stunde
+ local mittel = stunden[i] / (tage * 3600)
+ if mittel > hoechstwert then hoechstwert = mittel end
+ liste[#liste + 1] = {
+ stunde = i,
+ mittel = math.floor(mittel * 100 + 0.5) / 100,
+ }
+ end
+
+ return {
+ tage = tage,
+ stunden = liste,
+ hoechstwert = math.floor(hoechstwert * 100 + 0.5) / 100,
+ schichten = #dienste,
+ nachAbteilung = Verteilung(MySQL.query.await([[
+ SELECT abteilung AS schluessel, COUNT(*) AS anzahl
+ FROM police_dienst
+ WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ GROUP BY abteilung ORDER BY anzahl DESC
+ ]], { tage }), NamenAus(Polizei.Config.Abteilungen)),
+ }
+end)
+
+-- =====================================================================
+-- KATALOGAUSWERTUNG
+--
+-- Welche Delikte kommen wie oft vor und wie oft wird vom Vorschlag
+-- abgewichen? Grundlage für die Pflege des Katalogs — ausdrücklich
+-- keine Aussage über die Beamten, die entschieden haben.
+-- =====================================================================
+
+Kern.RegistriereAktion('statistik', 'katalog', nil, function(quelle, beamter, daten)
+ local tage = PruefeZeitraum(daten.tage)
+
+ local anzeigen = MySQL.query.await([[
+ SELECT delikte, haft_empfohlen, geld_empfohlen, haft_final, geld_final
+ FROM police_anzeige
+ WHERE status = 'bestaetigt'
+ AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ LIMIT 2000
+ ]], { tage }) or {}
+
+ -- Häufigkeit je Delikt und Abweichungen der Anzeigen, in denen es vorkam
+ local zaehler = {}
+
+ for _, anzeige in ipairs(anzeigen) do
+ local erfolg, liste = pcall(json.decode, anzeige.delikte)
+ if erfolg and type(liste) == 'table' then
+ -- Wich die Entscheidung dieser Anzeige vom Vorschlag ab?
+ local abweichung =
+ (tonumber(anzeige.haft_final) or 0) ~= (tonumber(anzeige.haft_empfohlen) or 0)
+ or (tonumber(anzeige.geld_final) or 0) ~= (tonumber(anzeige.geld_empfohlen) or 0)
+
+ for _, delikt in ipairs(liste) do
+ local kennung = delikt.kennung or '?'
+ zaehler[kennung] = zaehler[kennung] or {
+ kennung = kennung,
+ name = delikt.name or kennung,
+ paragraph = delikt.paragraph,
+ anzahl = 0, abweichungen = 0,
+ }
+ zaehler[kennung].anzahl = zaehler[kennung].anzahl + 1
+ if abweichung then
+ zaehler[kennung].abweichungen = zaehler[kennung].abweichungen + 1
+ end
+ end
+ end
+ end
+
+ local delikte = {}
+ for _, eintrag in pairs(zaehler) do
+ eintrag.abweichungsAnteil = eintrag.anzahl > 0
+ and math.floor((eintrag.abweichungen / eintrag.anzahl) * 100 + 0.5) or 0
+ delikte[#delikte + 1] = eintrag
+ end
+
+ table.sort(delikte, function(a, b) return a.anzahl > b.anzahl end)
+
+ -- Nur die häufigsten übertragen
+ local gekuerzt = {}
+ for i = 1, math.min(#delikte, 30) do gekuerzt[i] = delikte[i] end
+
+ -- Delikte im Katalog, die im Zeitraum nie verwendet wurden
+ local ungenutzt = {}
+ local katalog = MySQL.query.await(
+ 'SELECT kennung, bezeichnung, kategorie FROM police_katalog WHERE aktiv = 1') or {}
+ for _, eintrag in ipairs(katalog) do
+ if not zaehler[eintrag.kennung] then
+ ungenutzt[#ungenutzt + 1] = eintrag
+ end
+ end
+
+ return {
+ tage = tage,
+ delikte = gekuerzt,
+ anzeigen = #anzeigen,
+ ungenutzt = ungenutzt,
+ katalogGesamt = #katalog,
+ }
+end)
+
+-- ---------------------------------------------------------------------
+-- Schnittstelle für andere Ressourcen
+-- ---------------------------------------------------------------------
+
+--- Liefert die Kennzahlen des Zeitraums als einfache Tabelle
+--- @param tage number Zeitraum in Tagen (7, 30 oder 90)
+--- @return table
+exports('HoleKennzahlen', function(tage)
+ local geprueft = PruefeZeitraum(tage)
+ return {
+ tage = geprueft,
+ einsaetze = Zaehle([[
+ SELECT COUNT(*) FROM police_einsatz
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { geprueft }),
+ anzeigen = Zaehle([[
+ SELECT COUNT(*) FROM police_anzeige
+ WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { geprueft }),
+ einweisungen = Zaehle([[
+ SELECT COUNT(*) FROM police_gewahrsam
+ WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY)
+ ]], { geprueft }),
+ personal = Zaehle('SELECT COUNT(*) FROM police_personal WHERE aktiv = 1', {}),
+ }
+end)
diff --git a/resources/[aether]/aether-police/server/protokoll.lua b/resources/[aether]/aether-police/server/protokoll.lua
new file mode 100644
index 0000000..94a0d68
--- /dev/null
+++ b/resources/[aether]/aether-police/server/protokoll.lua
@@ -0,0 +1,66 @@
+-- =====================================================================
+-- aether-police — Protokollierung
+--
+-- Jede verändernde Aktion wird festgehalten: wer, wann, was, warum.
+-- Lesende Zugriffe werden bewusst nicht protokolliert, damit das
+-- Protokoll aussagekräftig bleibt.
+-- =====================================================================
+
+Protokoll = {}
+
+--- Schreibt einen Protokolleintrag
+--- @param beamter table|nil Handelnder Beamter
+--- @param modul string Modulkennung
+--- @param aktion string Was wurde getan
+--- @param ziel table|nil { typ, id }
+--- @param details table|nil Beliebige Zusatzdaten
+--- @param grund string|nil Begründung, falls angegeben
+function Protokoll.Schreibe(beamter, modul, aktion, ziel, details, grund)
+ MySQL.insert([[
+ INSERT INTO police_protokoll
+ (`character`, dienstnummer, modul, aktion, ziel_typ, ziel_id, details, grund)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ]], {
+ beamter and beamter.charakterId or nil,
+ beamter and beamter.dienstnummer or nil,
+ modul, aktion,
+ ziel and ziel.typ or nil,
+ ziel and tostring(ziel.id) or nil,
+ details and json.encode(details) or nil,
+ grund,
+ })
+end
+
+--- Liest Protokolleinträge mit einfachen Filtern
+--- @param filter table { modul?, charakterId?, zielTyp?, zielId?, limit? }
+--- @return table
+function Protokoll.Lies(filter)
+ filter = filter or {}
+ local bedingungen, werte = {}, {}
+
+ if filter.modul then
+ bedingungen[#bedingungen + 1] = 'modul = ?'
+ werte[#werte + 1] = filter.modul
+ end
+ if filter.charakterId then
+ bedingungen[#bedingungen + 1] = '`character` = ?'
+ werte[#werte + 1] = filter.charakterId
+ end
+ if filter.zielTyp and filter.zielId then
+ bedingungen[#bedingungen + 1] = 'ziel_typ = ? AND ziel_id = ?'
+ werte[#werte + 1] = filter.zielTyp
+ werte[#werte + 1] = tostring(filter.zielId)
+ end
+
+ local wo = #bedingungen > 0 and ('WHERE ' .. table.concat(bedingungen, ' AND ')) or ''
+ werte[#werte + 1] = math.min(filter.limit or 100, 500)
+
+ return MySQL.query.await(([[
+ SELECT p.*, c.firstname, c.lastname
+ FROM police_protokoll p
+ LEFT JOIN aether_characters c ON c.id = p.`character`
+ %s
+ ORDER BY p.id DESC
+ LIMIT ?
+ ]]):format(wo), werte) or {}
+end
diff --git a/resources/[aether]/aether-police/server/rechte.lua b/resources/[aether]/aether-police/server/rechte.lua
new file mode 100644
index 0000000..7bdc70c
--- /dev/null
+++ b/resources/[aether]/aether-police/server/rechte.lua
@@ -0,0 +1,61 @@
+-- =====================================================================
+-- aether-police — Rechteprüfung
+--
+-- Ein Beamter hat ein Recht, wenn seine Rolle es vorsieht ODER es ihm
+-- einzeln gewährt wurde — und es ihm nicht einzeln entzogen wurde.
+-- Der Entzug hat immer Vorrang.
+-- =====================================================================
+
+Rechte = {}
+
+--- Wandelt eine JSON-Liste in eine Menge um
+--- @param roh string|nil JSON-Array
+--- @return table { [wert] = true }
+local function ZuMenge(roh)
+ local menge = {}
+ if not roh then return menge end
+
+ local erfolg, liste = pcall(json.decode, roh)
+ if erfolg and type(liste) == 'table' then
+ for _, wert in ipairs(liste) do menge[wert] = true end
+ end
+ return menge
+end
+
+--- Baut die vollständige Rechtemenge eines Beamten
+--- @param personal table Zeile aus police_personal
+--- @return table { [rechtId] = true }
+function Rechte.Berechne(personal)
+ local menge = Polizei.RechteDerRolle(personal.rolle)
+
+ -- Einzeln gewährte Rechte ergänzen
+ for recht in pairs(ZuMenge(personal.rechte_extra)) do
+ menge[recht] = true
+ end
+
+ -- Einzeln entzogene Rechte haben Vorrang
+ for recht in pairs(ZuMenge(personal.rechte_entzug)) do
+ menge[recht] = nil
+ end
+
+ return menge
+end
+
+--- Prüft ein einzelnes Recht
+--- @param beamter table Beamtendaten (mit Feld `rechte`)
+--- @param recht string Recht-Kennung
+--- @return boolean
+function Rechte.Hat(beamter, recht)
+ if not beamter or not beamter.rechte then return false end
+ if not recht then return true end
+ return beamter.rechte[recht] == true
+end
+
+--- Prüft, ob ein Beamter über einem anderen steht.
+--- Verhindert, dass jemand Gleichrangige oder Höhere verwaltet.
+--- @param beamter table Handelnder
+--- @param zielRolle string Rolle der Zielperson
+--- @return boolean
+function Rechte.StehtUeber(beamter, zielRolle)
+ return Polizei.Stufe(beamter.rolle) > Polizei.Stufe(zielRolle)
+end
diff --git a/resources/[aether]/aether-police/shared/config.lua b/resources/[aether]/aether-police/shared/config.lua
new file mode 100644
index 0000000..21665cf
--- /dev/null
+++ b/resources/[aether]/aether-police/shared/config.lua
@@ -0,0 +1,127 @@
+-- =====================================================================
+-- aether-police — Grundkonfiguration
+--
+-- GRUNDPRINZIP: Das System unterstützt Entscheidungen — es trifft
+-- niemals selbst welche. Alle Zeitangaben hier dienen ausschliesslich
+-- der ERINNERUNG. Nichts davon löst eine Maßnahme aus.
+-- =====================================================================
+
+Polizei = Polizei or {}
+Polizei.Config = {}
+
+-- Anzeigename der Behörde
+Polizei.Config.Behoerde = 'Los Santos Police Department'
+Polizei.Config.Kuerzel = 'LSPD'
+
+-- Reichweite zu Terminals (Meter)
+Polizei.Config.TerminalWeite = 2.5
+
+-- Terminals an den Wachen. Dort lässt sich das Tablet aufrufen.
+Polizei.Config.Terminals = {
+ { name = 'Mission Row', pos = vector3(441.0, -982.1, 30.7) },
+ { name = 'Sandy Shores', pos = vector3(1853.1, 3689.5, 34.3) },
+ { name = 'Paleto Bay', pos = vector3(-448.6, 6012.4, 31.7) },
+}
+
+-- Abteilungen (frei erweiterbar)
+Polizei.Config.Abteilungen = {
+ { id = 'streife', name = 'Streifendienst' },
+ { id = 'kripo', name = 'Kriminalpolizei' },
+ { id = 'verkehr', name = 'Verkehrspolizei' },
+ { id = 'sek', name = 'Spezialeinsatzkommando' },
+ { id = 'leitstelle', name = 'Leitstelle' },
+}
+
+-- Dienststatus, die ein Beamter wählen kann
+Polizei.Config.Dienststatus = {
+ { id = 'verfuegbar', name = 'Verfügbar', farbe = '#34d39a' },
+ { id = 'einsatz', name = 'Im Einsatz', farbe = '#f5a742' },
+ { id = 'pause', name = 'Pause', farbe = '#7c8797' },
+ { id = 'nicht', name = 'Nicht verfügbar', farbe = '#f0506e' },
+}
+
+-- Einsatzkategorien
+Polizei.Config.Einsatzkategorien = {
+ { id = 'notruf', name = 'Notruf', farbe = '#f0506e' },
+ { id = 'verkehr', name = 'Verkehr', farbe = '#f5a742' },
+ { id = 'gewalt', name = 'Gewaltdelikt', farbe = '#e0503f' },
+ { id = 'eigentum', name = 'Eigentumsdelikt', farbe = '#7c5cf6' },
+ { id = 'unterstuetzung', name = 'Unterstützung', farbe = '#4aa8ff' },
+ { id = 'panik', name = 'Notfall Beamter', farbe = '#ff2e57' },
+ { id = 'sonstiges', name = 'Sonstiges', farbe = '#8a93a5' },
+}
+
+-- Aktenarten
+Polizei.Config.Aktenarten = {
+ { id = 'straf', name = 'Strafakte' },
+ { id = 'ermittlung', name = 'Ermittlungsakte' },
+ { id = 'einsatz', name = 'Einsatzakte' },
+ { id = 'unfall', name = 'Unfallakte' },
+ { id = 'vernehmung', name = 'Vernehmung' },
+ { id = 'beschluss', name = 'Beschluss' },
+}
+
+-- Beweisarten
+Polizei.Config.Beweisarten = {
+ { id = 'foto', name = 'Foto' },
+ { id = 'video', name = 'Video' },
+ { id = 'fingerabdruck', name = 'Fingerabdruck' },
+ { id = 'dna', name = 'DNA-Spur' },
+ { id = 'huelse', name = 'Patronenhülse' },
+ { id = 'dokument', name = 'Dokument' },
+ { id = 'aussage', name = 'Aussage' },
+ { id = 'gegenstand', name = 'Beweisstück' },
+}
+
+-- Asservat-Kategorien
+Polizei.Config.Asservatkategorien = {
+ { id = 'waffe', name = 'Waffe' },
+ { id = 'drogen', name = 'Betäubungsmittel' },
+ { id = 'bargeld', name = 'Bargeld' },
+ { id = 'schmuck', name = 'Schmuck' },
+ { id = 'datentraeger', name = 'Datenträger' },
+ { id = 'dokument', name = 'Dokument' },
+ { id = 'sonstiges', name = 'Sonstiges' },
+}
+
+-- ---------------------------------------------------------------------
+-- Gewahrsam
+--
+-- WICHTIG: Es gibt keinen Automatismus. Das System führt die Frist mit
+-- und erinnert — eingewiesen und entlassen wird ausschliesslich durch
+-- einen Beamten.
+-- ---------------------------------------------------------------------
+Polizei.Config.Gewahrsam = {
+ -- Wird die Person beim Einweisen in die Zelle verbracht?
+ -- false = rein verwaltend; das Verbringen geschieht dann im Rollenspiel.
+ verbringen = true,
+
+ -- Verfügbare Zellen
+ zellen = {
+ { id = 'A1', name = 'Zelle A1', pos = vector4(461.6, -994.4, 24.9, 90.0) },
+ { id = 'A2', name = 'Zelle A2', pos = vector4(461.6, -997.7, 24.9, 90.0) },
+ { id = 'A3', name = 'Zelle A3', pos = vector4(461.6, -1001.1, 24.9, 90.0) },
+ { id = 'B1', name = 'Zelle B1', pos = vector4(464.5, -992.7, 24.9, 270.0) },
+ { id = 'B2', name = 'Zelle B2', pos = vector4(464.5, -996.1, 24.9, 270.0) },
+ },
+
+ -- Wohin die Person nach der Entlassung gesetzt wird
+ entlassung = vector4(441.0, -978.9, 30.7, 180.0),
+
+ -- Hoechstdauer, die ein Beamter eintragen kann (Minuten)
+ maxDauer = 720,
+}
+
+-- ---------------------------------------------------------------------
+-- Erinnerungen (NUR Hinweise — keine davon führt etwas aus)
+-- ---------------------------------------------------------------------
+Polizei.Config.Erinnerungen = {
+ -- Bei welchen Restzeiten im Gewahrsam erinnert wird (Minuten)
+ gewahrsamStufen = { 15, 5, 0 },
+ -- Wie oft die Fristen geprüft werden (Millisekunden)
+ pruefIntervall = 60000,
+ -- Nach wie vielen Minuten ein Bericht als „offen" gemeldet wird
+ berichtOffenNach = 30,
+ -- Wie viele Minuten vor Ablauf an eine offene Kenntnisnahme erinnert wird
+ aushangVorwarnung = 60,
+}
diff --git a/resources/[aether]/aether-police/shared/rollen.lua b/resources/[aether]/aether-police/shared/rollen.lua
new file mode 100644
index 0000000..9fc5764
--- /dev/null
+++ b/resources/[aether]/aether-police/shared/rollen.lua
@@ -0,0 +1,208 @@
+-- =====================================================================
+-- aether-police — Rollen und Rechte
+--
+-- Rechte sind einzelne, benannte Schalter. Eine Rolle ist nur ein
+-- Vorschlagspaket — jedes Recht kann pro Beamtem zusaetzlich gewaehrt
+-- oder entzogen werden (Spalten `rechte_extra` / `rechte_entzug`).
+-- =====================================================================
+
+Polizei = Polizei or {}
+
+-- ---------------------------------------------------------------------
+-- Katalog aller Rechte
+-- Gruppiert nur fuer die Darstellung in der Administration.
+-- ---------------------------------------------------------------------
+Polizei.Rechte = {
+ { gruppe = 'Dienst', eintraege = {
+ { id = 'dienst.antreten', name = 'Dienst antreten' },
+ { id = 'dienst.leitstelle', name = 'Leitstelle übernehmen' },
+ { id = 'dienst.spezial', name = 'Spezialdienst wählen' },
+ }},
+ { gruppe = 'Personen & Fahrzeuge', eintraege = {
+ { id = 'person.suchen', name = 'Personen suchen' },
+ { id = 'person.bearbeiten', name = 'Personendaten bearbeiten' },
+ { id = 'person.warnhinweis', name = 'Warnhinweise setzen' },
+ { id = 'fahrzeug.suchen', name = 'Fahrzeuge abfragen' },
+ }},
+ { gruppe = 'Akten & Berichte', eintraege = {
+ { id = 'akte.lesen', name = 'Akten einsehen' },
+ { id = 'akte.erstellen', name = 'Akten anlegen' },
+ { id = 'akte.bearbeiten', name = 'Akten bearbeiten' },
+ { id = 'akte.freigeben', name = 'Akten freigeben' },
+ { id = 'akte.schliessen', name = 'Akten schließen' },
+ { id = 'bericht.erstellen', name = 'Berichte schreiben' },
+ { id = 'bericht.freigeben', name = 'Berichte freigeben' },
+ }},
+ { gruppe = 'Maßnahmen', eintraege = {
+ { id = 'anzeige.erstellen', name = 'Anzeige erfassen' },
+ { id = 'anzeige.bestaetigen', name = 'Anzeige bestätigen' },
+ { id = 'anzeige.abweichen', name = 'Vom Katalogvorschlag abweichen' },
+ { id = 'gewahrsam.einweisen', name = 'In Gewahrsam nehmen' },
+ { id = 'gewahrsam.entlassen', name = 'Aus Gewahrsam entlassen' },
+ { id = 'fahndung.erstellen', name = 'Fahndung ausschreiben' },
+ { id = 'fahndung.aufheben', name = 'Fahndung aufheben' },
+ }},
+ { gruppe = 'Asservate & Beweise', eintraege = {
+ { id = 'beweis.erfassen', name = 'Beweise erfassen' },
+ { id = 'beweis.loeschen', name = 'Beweise entfernen' },
+ { id = 'asservat.einlagern', name = 'Asservate einlagern' },
+ { id = 'asservat.ausgeben', name = 'Asservate ausgeben' },
+ { id = 'asservat.vernichten', name = 'Asservate vernichten' },
+ }},
+ { gruppe = 'Einsätze', eintraege = {
+ { id = 'einsatz.erstellen', name = 'Einsatz anlegen' },
+ { id = 'einsatz.zuweisen', name = 'Einheiten zuweisen' },
+ { id = 'einsatz.schliessen', name = 'Einsatz abschließen' },
+ }},
+ { gruppe = 'Fahrzeugverwaltung', eintraege = {
+ { id = 'dienstfahrzeug.sehen', name = 'Dienstfahrzeuge einsehen' },
+ { id = 'dienstfahrzeug.zuweisen', name = 'Dienstfahrzeuge zuweisen' },
+ { id = 'dienstfahrzeug.wartung', name = 'Wartungsstatus setzen' },
+ }},
+ { gruppe = 'Kommunikation', eintraege = {
+ { id = 'nachricht.senden', name = 'Nachrichten schreiben' },
+ { id = 'nachricht.rundmeldung', name = 'Rundmeldung an alle senden' },
+ { id = 'aushang.schreiben', name = 'Aushänge veröffentlichen' },
+ { id = 'aushang.archivieren', name = 'Aushänge archivieren' },
+ }},
+ { gruppe = 'Verwaltung', eintraege = {
+ { id = 'personal.sehen', name = 'Personalliste einsehen' },
+ { id = 'personal.rolle', name = 'Rollen zuweisen' },
+ { id = 'personal.rechte', name = 'Einzelrechte vergeben' },
+ { id = 'personal.einstellen', name = 'Personal einstellen/entlassen' },
+ { id = 'katalog.bearbeiten', name = 'Strafenkatalog pflegen' },
+ { id = 'protokoll.lesen', name = 'Protokoll einsehen' },
+ { id = 'statistik.sehen', name = 'Statistik einsehen' },
+ { id = 'admin.module', name = 'Module verwalten' },
+ }},
+}
+
+--- Liefert alle Rechte-IDs als flache Liste
+--- @return table
+function Polizei.AlleRechte()
+ local liste = {}
+ for _, gruppe in ipairs(Polizei.Rechte) do
+ for _, recht in ipairs(gruppe.eintraege) do
+ liste[#liste + 1] = recht.id
+ end
+ end
+ return liste
+end
+
+-- ---------------------------------------------------------------------
+-- Rollen — aufsteigend nach Verantwortung
+-- `rechte` ist ein Vorschlagspaket, kein festes Gesetz.
+-- ---------------------------------------------------------------------
+Polizei.Rollen = {
+ {
+ id = 'anwaerter', name = 'Anwärter', stufe = 0,
+ rechte = {
+ 'dienst.antreten', 'person.suchen', 'fahrzeug.suchen',
+ 'akte.lesen', 'einsatz.erstellen', 'dienstfahrzeug.sehen',
+ 'nachricht.senden',
+ },
+ },
+ {
+ id = 'officer', name = 'Officer', stufe = 1,
+ rechte = {
+ 'dienst.antreten', 'person.suchen', 'fahrzeug.suchen',
+ 'akte.lesen', 'akte.erstellen', 'bericht.erstellen',
+ 'anzeige.erstellen', 'beweis.erfassen',
+ 'einsatz.erstellen', 'dienstfahrzeug.sehen', 'nachricht.senden',
+ },
+ },
+ {
+ id = 'senior_officer', name = 'Senior Officer', stufe = 2,
+ rechte = {
+ 'dienst.antreten', 'person.suchen', 'person.bearbeiten', 'fahrzeug.suchen',
+ 'akte.lesen', 'akte.erstellen', 'akte.bearbeiten',
+ 'bericht.erstellen', 'anzeige.erstellen', 'anzeige.bestaetigen',
+ 'beweis.erfassen', 'asservat.einlagern',
+ 'einsatz.erstellen', 'einsatz.schliessen', 'dienstfahrzeug.sehen',
+ 'nachricht.senden',
+ },
+ },
+ {
+ id = 'sergeant', name = 'Sergeant', stufe = 3,
+ rechte = {
+ 'dienst.antreten', 'dienst.leitstelle', 'dienst.spezial',
+ 'person.suchen', 'person.bearbeiten', 'person.warnhinweis', 'fahrzeug.suchen',
+ 'akte.lesen', 'akte.erstellen', 'akte.bearbeiten', 'akte.schliessen',
+ 'bericht.erstellen', 'anzeige.erstellen', 'anzeige.bestaetigen',
+ 'gewahrsam.einweisen', 'gewahrsam.entlassen',
+ 'fahndung.erstellen', 'fahndung.aufheben',
+ 'beweis.erfassen', 'asservat.einlagern', 'asservat.ausgeben',
+ 'einsatz.erstellen', 'einsatz.zuweisen', 'einsatz.schliessen',
+ 'dienstfahrzeug.sehen', 'dienstfahrzeug.zuweisen',
+ 'nachricht.senden', 'nachricht.rundmeldung',
+ },
+ },
+ {
+ id = 'lieutenant', name = 'Lieutenant', stufe = 4,
+ erbt = 'sergeant',
+ rechte = {
+ 'akte.freigeben', 'bericht.freigeben', 'anzeige.abweichen',
+ 'personal.sehen', 'statistik.sehen', 'dienstfahrzeug.wartung',
+ 'aushang.schreiben',
+ },
+ },
+ {
+ id = 'captain', name = 'Captain', stufe = 5,
+ erbt = 'lieutenant',
+ rechte = {
+ 'personal.rolle', 'beweis.loeschen', 'asservat.vernichten',
+ 'protokoll.lesen', 'aushang.archivieren',
+ },
+ },
+ {
+ id = 'deputy_chief', name = 'Deputy Chief', stufe = 6,
+ erbt = 'captain',
+ rechte = { 'personal.rechte', 'personal.einstellen', 'katalog.bearbeiten' },
+ },
+ {
+ id = 'chief', name = 'Chief of Police', stufe = 7,
+ erbt = 'deputy_chief',
+ rechte = { 'admin.module' },
+ },
+}
+
+--- Sucht eine Rolle
+--- @param rollenId string Kennung
+--- @return table|nil
+function Polizei.HoleRolle(rollenId)
+ for _, rolle in ipairs(Polizei.Rollen) do
+ if rolle.id == rollenId then return rolle end
+ end
+ return nil
+end
+
+--- Loest die Rechte einer Rolle auf (inklusive Vererbung)
+--- @param rollenId string Kennung
+--- @return table Menge der Rechte { [rechtId] = true }
+function Polizei.RechteDerRolle(rollenId)
+ local menge = {}
+ local besucht = {}
+ local aktuelle = rollenId
+
+ -- Vererbungskette abarbeiten; Zyklen werden abgefangen
+ while aktuelle and not besucht[aktuelle] do
+ besucht[aktuelle] = true
+ local rolle = Polizei.HoleRolle(aktuelle)
+ if not rolle then break end
+
+ for _, recht in ipairs(rolle.rechte or {}) do
+ menge[recht] = true
+ end
+ aktuelle = rolle.erbt
+ end
+
+ return menge
+end
+
+--- Liefert die Stufe einer Rolle (fuer Vergleiche „darf ich das bei dem?")
+--- @param rollenId string Kennung
+--- @return number
+function Polizei.Stufe(rollenId)
+ local rolle = Polizei.HoleRolle(rollenId)
+ return rolle and rolle.stufe or -1
+end
diff --git a/resources/[aether]/aether-shop/client/main.lua b/resources/[aether]/aether-shop/client/main.lua
new file mode 100644
index 0000000..ac3bacb
--- /dev/null
+++ b/resources/[aether]/aether-shop/client/main.lua
@@ -0,0 +1,149 @@
+-- =====================================================================
+-- Aether Shop — Clientlogik
+-- Setzt Karten-Markierungen, erkennt Filialen und oeffnet die Oberflaeche.
+-- Gekauft wird ausschliesslich ueber den Server.
+-- =====================================================================
+
+local istOffen = false
+local istGeladen = false
+
+-- Taste E zum Betreten
+local TASTE_BENUTZEN = 38
+
+--- Schickt eine Nachricht an die Oberflaeche
+local function SendeAnNui(nachricht)
+ SendNuiMessage(json.encode(nachricht))
+end
+
+--- Zeigt eine Meldung oben links im Spiel
+local function ZeigeSpielmeldung(text)
+ if not text or text == '' then return end
+ BeginTextCommandThefeedPost('STRING')
+ AddTextComponentSubstringPlayerName(text)
+ EndTextCommandThefeedPostTicker(false, true)
+end
+
+--- Schliesst den Laden
+local function Schliessen()
+ if not istOffen then return end
+ istOffen = false
+ SetNuiFocus(false, false)
+ SendeAnNui({ aktion = 'schliessen' })
+ TriggerServerEvent('aether:shop:schliessen')
+ pcall(function() exports['aether-hud']:SetzeSichtbar(true) end)
+end
+
+-- ---------------------------------------------------------------------
+-- Karten-Markierungen setzen
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ for _, laden in pairs(Shop.Laeden) do
+ for _, standort in ipairs(laden.standorte) do
+ local blip = AddBlipForCoord(standort.x, standort.y, standort.z)
+ SetBlipSprite(blip, laden.blip.sprite)
+ SetBlipColour(blip, laden.blip.farbe)
+ SetBlipScale(blip, 0.7)
+ SetBlipAsShortRange(blip, true)
+ BeginTextCommandSetBlipName('STRING')
+ AddTextComponentString(laden.name)
+ EndTextCommandSetBlipName(blip)
+ end
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Ereignisse
+-- ---------------------------------------------------------------------
+AddEventHandler('aether:client:bereit', function()
+ istGeladen = true
+end)
+
+--- Server erlaubt das Oeffnen und schickt den Katalog
+RegisterNetEvent('aether:shop:zeige', function(katalog, geld)
+ istOffen = true
+ SetNuiFocus(true, true)
+ pcall(function() exports['aether-hud']:SetzeSichtbar(false) end)
+ SendeAnNui({ aktion = 'oeffnen', katalog = katalog, geld = geld })
+end)
+
+--- Kauf erfolgreich: Beleg und aktualisierter Katalog
+RegisterNetEvent('aether:shop:gekauft', function(ergebnis)
+ ergebnis.aktion = 'gekauft'
+ SendeAnNui(ergebnis)
+end)
+
+--- Ablehnung einer Aktion
+RegisterNetEvent('aether:shop:fehler', function(text)
+ if istOffen then
+ SendeAnNui({ aktion = 'fehler', text = text })
+ else
+ ZeigeSpielmeldung(text)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Filiale in der Naehe erkennen
+-- ---------------------------------------------------------------------
+CreateThread(function()
+ while true do
+ local pause = 800
+
+ if istGeladen and not istOffen then
+ local position = GetEntityCoords(PlayerPedId())
+
+ -- Naechstgelegene Filiale suchen
+ local trefferId, trefferLaden, trefferPos = nil, nil, nil
+ for ladenId, laden in pairs(Shop.Laeden) do
+ for _, standort in ipairs(laden.standorte) do
+ if #(position - standort) <= Shop.INTERAKTION_WEITE then
+ trefferId, trefferLaden, trefferPos = ladenId, laden, standort
+ break
+ end
+ end
+ if trefferId then break end
+ end
+
+ if trefferId then
+ pause = 0 -- fuer die Textanzeige jeden Frame laufen
+
+ SetTextFont(4)
+ SetTextScale(0.34, 0.34)
+ SetTextColour(255, 255, 255, 215)
+ SetTextCentre(true)
+ SetTextOutline()
+ SetTextEntry('STRING')
+ AddTextComponentString('[E] ' .. trefferLaden.name)
+ SetDrawOrigin(trefferPos.x, trefferPos.y, trefferPos.z + 0.9, 0)
+ DrawText(0.0, 0.0)
+ ClearDrawOrigin()
+
+ if IsControlJustReleased(0, TASTE_BENUTZEN) then
+ TriggerServerEvent('aether:shop:oeffnen', trefferId)
+ end
+ end
+ end
+
+ Wait(pause)
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- NUI-Callbacks
+-- ---------------------------------------------------------------------
+RegisterNUICallback('schliessen', function(_, rueckgabe)
+ Schliessen()
+ rueckgabe({ ok = true })
+end)
+
+RegisterNUICallback('kaufen', function(daten, rueckgabe)
+ TriggerServerEvent('aether:shop:kaufen', daten.warenkorb or {}, daten.zahlung or 'bargeld')
+ rueckgabe({ ok = true })
+end)
+
+-- ---------------------------------------------------------------------
+-- Aufraeumen
+-- ---------------------------------------------------------------------
+AddEventHandler('onResourceStop', function(ressource)
+ if ressource ~= GetCurrentResourceName() then return end
+ SetNuiFocus(false, false)
+end)
diff --git a/resources/[aether]/aether-shop/fxmanifest.lua b/resources/[aether]/aether-shop/fxmanifest.lua
new file mode 100644
index 0000000..a2f71cc
--- /dev/null
+++ b/resources/[aether]/aether-shop/fxmanifest.lua
@@ -0,0 +1,34 @@
+fx_version 'cerulean'
+game 'gta5'
+lua54 'yes'
+
+name 'aether-shop'
+author 'Jerrit Fritzsche'
+description 'Läden (24/7, Ammu-Nation) mit Warenkorb, Bestand und Beleg'
+version '0.1.0'
+
+ui_page 'html/index.html'
+
+files {
+ 'html/index.html',
+}
+
+-- Katalog teilen sich Client und Server; Core-Dateien mitladen
+shared_scripts {
+ '@aether-core/shared/config.lua',
+ '@aether-core/shared/utils.lua',
+ 'shared/shops.lua',
+}
+
+client_scripts {
+ 'client/main.lua',
+}
+
+server_scripts {
+ 'server/main.lua',
+}
+
+dependencies {
+ 'aether-core',
+ 'aether-inventory',
+}
diff --git a/resources/[aether]/aether-shop/html/index.html b/resources/[aether]/aether-shop/html/index.html
new file mode 100644
index 0000000..733c935
--- /dev/null
+++ b/resources/[aether]/aether-shop/html/index.html
@@ -0,0 +1,540 @@
+
+
+
+
+
+
+
+
+Aether — Shop
+
+
+
+
+
🛒
+
+
LS 24/7
+
● Geöffnet · Alles für den Alltag
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/[aether]/aether-shop/server/main.lua b/resources/[aether]/aether-shop/server/main.lua
new file mode 100644
index 0000000..0678b8b
--- /dev/null
+++ b/resources/[aether]/aether-shop/server/main.lua
@@ -0,0 +1,262 @@
+-- =====================================================================
+-- Aether Shop — Serverlogik
+-- Der Client schickt nur den Warenkorb. Preise, Bestand, Lizenz,
+-- Deckung und Traglast werden hier komplett neu geprueft.
+-- =====================================================================
+
+local Core = exports['aether-core']
+local Inventory = exports['aether-inventory']
+
+-- Bestaende der Laeden im Speicher: [ladenId][produktId] = Menge
+local bestaende = {}
+
+-- Merkt sich, an welchem Laden ein Spieler gerade steht
+local offeneLaeden = {}
+
+-- ---------------------------------------------------------------------
+-- Bestandsverwaltung
+-- ---------------------------------------------------------------------
+
+--- Setzt alle Bestaende auf die Ausgangswerte des Katalogs
+local function BestaendeAufbauen()
+ for ladenId, laden in pairs(Shop.Laeden) do
+ bestaende[ladenId] = {}
+ for _, produktId in ipairs(laden.produkte) do
+ local produkt = Shop.Produkte[produktId]
+ bestaende[ladenId][produktId] = produkt and produkt.bestand or 0
+ end
+ end
+end
+
+--- Fuellt die Bestaende langsam wieder auf (alle 15 Minuten um 10 %)
+CreateThread(function()
+ BestaendeAufbauen()
+ while true do
+ Wait(15 * 60 * 1000)
+ for ladenId, laden in pairs(Shop.Laeden) do
+ for _, produktId in ipairs(laden.produkte) do
+ local produkt = Shop.Produkte[produktId]
+ if produkt then
+ local maximum = produkt.bestand or 0
+ local aktuell = bestaende[ladenId][produktId] or 0
+ if aktuell < maximum then
+ local nachschub = math.max(1, math.floor(maximum * 0.1))
+ bestaende[ladenId][produktId] = math.min(maximum, aktuell + nachschub)
+ end
+ end
+ end
+ end
+ end
+end)
+
+-- ---------------------------------------------------------------------
+-- Hilfsfunktionen
+-- ---------------------------------------------------------------------
+
+--- Baut den Katalog eines Ladens fuer die Anzeige (mit aktuellem Bestand)
+--- @param ladenId string Kennung des Ladens
+--- @return table|nil
+local function BaueKatalog(ladenId)
+ local laden = Shop.Laeden[ladenId]
+ if not laden then return nil end
+
+ local produkte = {}
+ for _, produktId in ipairs(laden.produkte) do
+ local produkt = Shop.Produkte[produktId]
+ if produkt then
+ produkte[produktId] = {
+ name = produkt.name, kat = produkt.kat, icon = produkt.icon,
+ preis = produkt.preis, rabatt = produkt.rabatt, lizenz = produkt.lizenz,
+ beschreibung = produkt.beschreibung, menge = produkt.menge,
+ bestand = bestaende[ladenId] and bestaende[ladenId][produktId] or 0,
+ }
+ end
+ end
+
+ return {
+ id = ladenId, name = laden.name, logo = laden.logo, tl = laden.tl,
+ accent = laden.accent, ink = laden.ink, warnung = laden.warnung,
+ produkte = produkte,
+ reihenfolge = laden.produkte,
+ }
+end
+
+--- Prueft, ob ein Spieler einen gueltigen Waffenschein besitzt.
+--- Sobald `aether-documents` migriert ist, greift die echte Pruefung.
+--- @param quelle number Server-ID
+--- @return boolean
+local function HatWaffenschein(quelle)
+ if not Shop.LizenzPflicht then return true end
+
+ -- Optionale Abfrage beim Dokumente-Modul
+ local erfolg, ergebnis = pcall(function()
+ return exports['aether-documents']:HatWaffenschein(quelle)
+ end)
+ if erfolg and type(ergebnis) == 'boolean' then return ergebnis end
+
+ -- Modul noch nicht vorhanden: Rueckfallregel aus der Konfiguration
+ return Shop.OhneDokumenteErlauben
+end
+
+--- Lehnt eine Aktion mit Begruendung ab
+local function Ablehnen(quelle, grund)
+ TriggerClientEvent('aether:shop:fehler', quelle, grund)
+end
+
+-- ---------------------------------------------------------------------
+-- Laden oeffnen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:shop:oeffnen', function(ladenId)
+ local quelle = source
+ if not Core:HoleCharakterId(quelle) then return end
+ if type(ladenId) ~= 'string' then return end
+
+ local laden = Shop.Laeden[ladenId]
+ if not laden then return end
+
+ -- Entfernungspruefung: Der Spieler muss wirklich an einer Filiale stehen
+ local position = GetEntityCoords(GetPlayerPed(quelle))
+ local inReichweite = false
+ for _, standort in ipairs(laden.standorte) do
+ if #(position - standort) <= (Shop.INTERAKTION_WEITE + 2.0) then
+ inReichweite = true
+ break
+ end
+ end
+ if not inReichweite then
+ Ablehnen(quelle, 'Du bist nicht an diesem Laden.')
+ return
+ end
+
+ offeneLaeden[quelle] = ladenId
+
+ local daten = Core:HoleSpielerInfo(quelle)
+ TriggerClientEvent('aether:shop:zeige', quelle, BaueKatalog(ladenId), {
+ bargeld = daten and daten.bargeld or 0,
+ konto = daten and daten.konto or 0,
+ })
+end)
+
+-- ---------------------------------------------------------------------
+-- Kaufen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:shop:kaufen', function(warenkorb, zahlung)
+ local quelle = source
+ local charakterId = Core:HoleCharakterId(quelle)
+ if not charakterId then return end
+
+ local ladenId = offeneLaeden[quelle]
+ if not ladenId then Ablehnen(quelle, 'Kein Laden geöffnet.') return end
+ if type(warenkorb) ~= 'table' then return end
+
+ zahlung = (zahlung == 'karte') and 'konto' or 'bargeld'
+
+ -- ---- Warenkorb vollstaendig neu durchrechnen ----
+ local gesamt = 0
+ local posten = {}
+
+ for produktId, menge in pairs(warenkorb) do
+ if type(produktId) ~= 'string' or type(menge) ~= 'number' then return end
+ menge = math.floor(menge)
+
+ if menge < 1 or menge > Shop.MAX_MENGE then
+ Ablehnen(quelle, 'Ungültige Menge.')
+ return
+ end
+
+ -- Fuehrt dieser Laden das Produkt ueberhaupt?
+ if not Shop.FuehrtProdukt(ladenId, produktId) then
+ Ablehnen(quelle, 'Dieses Produkt gibt es hier nicht.')
+ return
+ end
+
+ local produkt = Shop.Produkte[produktId]
+ if not produkt then Ablehnen(quelle, 'Unbekanntes Produkt.') return end
+
+ -- Bestand pruefen
+ local vorrat = bestaende[ladenId][produktId] or 0
+ if menge > vorrat then
+ Ablehnen(quelle, ('%s ist nicht so oft vorrätig.'):format(produkt.name))
+ return
+ end
+
+ -- Waffenschein pruefen
+ if produkt.lizenz and not HatWaffenschein(quelle) then
+ Ablehnen(quelle, ('Für %s brauchst du einen gültigen Waffenschein.'):format(produkt.name))
+ return
+ end
+
+ -- Preis kommt IMMER vom Server, nie aus dem Warenkorb des Clients
+ local einzelpreis = Shop.Preis(produkt)
+ gesamt = gesamt + einzelpreis * menge
+
+ posten[#posten + 1] = {
+ produktId = produktId,
+ name = produkt.name,
+ menge = menge,
+ summe = einzelpreis * menge,
+ item = produkt.item,
+ stueck = (produkt.menge or 1) * menge, -- tatsaechliche Stueckzahl im Inventar
+ }
+ end
+
+ if #posten == 0 then Ablehnen(quelle, 'Der Warenkorb ist leer.') return end
+
+ -- ---- Passt alles ins Inventar? ----
+ for _, eintrag in ipairs(posten) do
+ if not Inventory:PasstNoch(quelle, eintrag.item, eintrag.stueck) then
+ Ablehnen(quelle, 'Du kannst nicht so viel tragen.')
+ return
+ end
+ end
+
+ -- ---- Bezahlen (prueft die Deckung) ----
+ if not Core:EntferneGeld(quelle, zahlung, gesamt, 'Einkauf ' .. Shop.Laeden[ladenId].name) then
+ Ablehnen(quelle, zahlung == 'bargeld' and 'Nicht genug Bargeld.' or 'Konto nicht gedeckt.')
+ return
+ end
+
+ -- ---- Waren aushaendigen ----
+ local fehlgeschlagen = {}
+ for _, eintrag in ipairs(posten) do
+ if Inventory:GebeItem(quelle, eintrag.item, eintrag.stueck) then
+ bestaende[ladenId][eintrag.produktId] =
+ (bestaende[ladenId][eintrag.produktId] or 0) - eintrag.menge
+ else
+ fehlgeschlagen[#fehlgeschlagen + 1] = eintrag
+ end
+ end
+
+ -- Konnte etwas nicht uebergeben werden, den Anteil zurueckerstatten
+ if #fehlgeschlagen > 0 then
+ local erstattung = 0
+ for _, eintrag in ipairs(fehlgeschlagen) do erstattung = erstattung + eintrag.summe end
+ Core:FuegeGeldHinzu(quelle, zahlung, erstattung, 'Erstattung Einkauf')
+ Ablehnen(quelle, ('%d Artikel passten nicht — %d € wurden erstattet.')
+ :format(#fehlgeschlagen, erstattung))
+ end
+
+ Aether.Info(('Einkauf: Charakter #%d bei %s für %d €')
+ :format(charakterId, Shop.Laeden[ladenId].name, gesamt))
+
+ -- Beleg und aktualisierten Katalog zurueckschicken
+ local daten = Core:HoleSpielerInfo(quelle)
+ TriggerClientEvent('aether:shop:gekauft', quelle, {
+ posten = posten,
+ gesamt = gesamt,
+ zahlung = zahlung,
+ katalog = BaueKatalog(ladenId),
+ geld = { bargeld = daten and daten.bargeld or 0, konto = daten and daten.konto or 0 },
+ })
+end)
+
+-- ---------------------------------------------------------------------
+-- Aufraeumen
+-- ---------------------------------------------------------------------
+RegisterNetEvent('aether:shop:schliessen', function()
+ offeneLaeden[source] = nil
+end)
+
+AddEventHandler('playerDropped', function()
+ offeneLaeden[source] = nil
+end)
diff --git a/resources/[aether]/aether-shop/shared/shops.lua b/resources/[aether]/aether-shop/shared/shops.lua
new file mode 100644
index 0000000..974f8b0
--- /dev/null
+++ b/resources/[aether]/aether-shop/shared/shops.lua
@@ -0,0 +1,140 @@
+-- =====================================================================
+-- Aether Shop — Warenkatalog, Laeden und Standorte
+-- Der Server ist die einzige Quelle fuer Preise und Bestand.
+-- Der Client bekommt den Katalog nur zum Anzeigen.
+-- =====================================================================
+
+Shop = Shop or {}
+
+-- ---------------------------------------------------------------------
+-- Grundeinstellungen
+-- ---------------------------------------------------------------------
+Shop.INTERAKTION_WEITE = 2.0 -- Reichweite zum Oeffnen (Meter)
+Shop.MAX_MENGE = 100 -- Hoechstmenge je Position im Warenkorb
+
+-- Wird ein Waffenschein verlangt?
+Shop.LizenzPflicht = true
+
+-- Rueckfallregel, falls `aether-documents` nicht laeuft: dann kann niemand
+-- einen Waffenschein nachweisen und lizenzpflichtige Waren waeren gesperrt.
+-- Seit der Migration von aether-documents greift die echte Pruefung.
+Shop.OhneDokumenteErlauben = false
+
+-- ---------------------------------------------------------------------
+-- Produkt-Registry
+-- item = Kennung im Inventar (aether-inventory)
+-- menge = wie viele Stueck ein Kauf liefert (z. B. Munitionspackung)
+-- rabatt = 0..1, macht ein Angebot
+-- lizenz = Waffenschein noetig
+-- ---------------------------------------------------------------------
+Shop.Produkte = {
+ -- --- 24/7 ---
+ wasser = { name = 'Wasserflasche', kat = 'Getränke', icon = '🥤', preis = 4, item = 'wasser', bestand = 40, beschreibung = 'Löscht den Durst.' },
+ cola = { name = 'Cola', kat = 'Getränke', icon = '🥫', preis = 6, item = 'cola', bestand = 30, beschreibung = 'Eiskalt, koffeinhaltig.' },
+ energy = { name = 'Energydrink', kat = 'Getränke', icon = '⚡', preis = 9, item = 'energy', bestand = 24, beschreibung = 'Gibt Extra-Ausdauer.' },
+ kaffee = { name = 'Kaffee to go', kat = 'Getränke', icon = '☕', preis = 5, item = 'kaffee', bestand = 18, beschreibung = 'Wach werden.' },
+ sandwich = { name = 'Sandwich', kat = 'Snacks', icon = '🥪', preis = 8, item = 'sandwich', bestand = 25, beschreibung = 'Stillt den Hunger.' },
+ chips = { name = 'Chips', kat = 'Snacks', icon = '🍟', preis = 5, item = 'chips', bestand = 32, beschreibung = 'Knuspriger Snack.' },
+ schoko = { name = 'Schokoriegel', kat = 'Snacks', icon = '🍫', preis = 3, item = 'schoko', bestand = 50, beschreibung = 'Süße Energie.' },
+ burger = { name = 'Burger', kat = 'Snacks', icon = '🍔', preis = 12, item = 'burger', bestand = 15, rabatt = 0.25, beschreibung = 'Deftige Mahlzeit.' },
+ verband = { name = 'Verband', kat = 'Gesundheit', icon = '🩹', preis = 15, item = 'verband', bestand = 20, beschreibung = 'Stoppt Blutungen.' },
+ tablette = { name = 'Schmerztabletten', kat = 'Gesundheit', icon = '💊', preis = 20, item = 'tablette', bestand = 16, beschreibung = 'Lindert Schmerzen.' },
+ medkit = { name = 'Erste-Hilfe-Kit', kat = 'Gesundheit', icon = '🧳', preis = 120, item = 'medkit', bestand = 8, beschreibung = 'Behandelt schwere Wunden.' },
+ repkit = { name = 'Reparaturkit', kat = 'Werkzeug', icon = '🧰', preis = 250, item = 'reparaturkit', bestand = 10, beschreibung = 'Repariert Fahrzeuge.' },
+ dietrich = { name = 'Dietrich', kat = 'Werkzeug', icon = '🗝️', preis = 45, item = 'dietrich', bestand = 22, beschreibung = 'Öffnet einfache Schlösser.' },
+ kabel = { name = 'Kabelbinder', kat = 'Werkzeug', icon = '🔗', preis = 30, item = 'kabel', bestand = 40, beschreibung = 'Vielseitig einsetzbar.' },
+ lampe = { name = 'Taschenlampe', kat = 'Werkzeug', icon = '🔦', preis = 35, item = 'lampe', bestand = 14, beschreibung = 'Leuchtet den Weg.' },
+ handy = { name = 'Smartphone', kat = 'Elektronik', icon = '📱', preis = 450, item = 'handy', bestand = 6, beschreibung = 'Modernes Handy.' },
+ sim = { name = 'SIM-Karte', kat = 'Elektronik', icon = '📶', preis = 60, item = 'sim', bestand = 30, beschreibung = 'Fürs Handy nötig.' },
+ akku = { name = 'Akku', kat = 'Elektronik', icon = '🔋', preis = 40, item = 'akku', bestand = 45, beschreibung = 'Ersatz-Akku fürs Handy.' },
+ funk = { name = 'Funkgerät', kat = 'Elektronik', icon = '📻', preis = 180, item = 'funk', bestand = 9, beschreibung = 'Kurzstrecken-Funk.' },
+ boerse = { name = 'Börse', kat = 'Zubehör', icon = '👛', preis = 90, item = 'boerse', bestand = 12, beschreibung = 'Fasst bis zu 10 Dokumente.' },
+ rucksack = { name = 'Rucksack', kat = 'Zubehör', icon = '🎒', preis = 300, item = 'rucksack', bestand = 7, rabatt = 0.15, beschreibung = '+ Tragekapazität.' },
+
+ -- --- Ammu-Nation ---
+ w_pistole = { name = 'Pistole P99', kat = 'Waffen', icon = '🔫', preis = 3500, item = 'pistole', bestand = 5, lizenz = true, beschreibung = '9mm Halbautomatik.' },
+ w_smg = { name = 'MP5 (MPX)', kat = 'Waffen', icon = '🔫', preis = 8500, item = 'smg', bestand = 3, lizenz = true, beschreibung = 'Maschinenpistole.' },
+ w_flinte = { name = 'Schrotflinte 870', kat = 'Waffen', icon = '🔫', preis = 5200, item = 'flinte', bestand = 4, lizenz = true, beschreibung = 'Pump-Action Shotgun.' },
+ a_9mm = { name = '9mm Munition (×30)', kat = 'Munition', icon = '🧨', preis = 120, item = 'munition9', menge = 30, bestand = 60, beschreibung = 'Patronen für 9mm.' },
+ a_12 = { name = 'Schrot 12/70 (×10)', kat = 'Munition', icon = '🧨', preis = 90, item = 'schrot12', menge = 10, bestand = 40, beschreibung = 'Schrotpatronen.' },
+ at_sd = { name = 'Schalldämpfer', kat = 'Aufsätze', icon = '🔇', preis = 900, item = 'schalldaempfer', bestand = 10, lizenz = true, beschreibung = 'Dämpft den Mündungsknall.' },
+ at_lampe = { name = 'Waffenlampe', kat = 'Aufsätze', icon = '🔦', preis = 400, item = 'waffenlampe', bestand = 12, beschreibung = 'Taktische Beleuchtung.' },
+ at_laser = { name = 'Laservisier', kat = 'Aufsätze', icon = '🎯', preis = 650, item = 'laservisier', bestand = 8, beschreibung = 'Bessere Zielerfassung.' },
+ s_weste = { name = 'Schutzweste', kat = 'Schutz', icon = '🦺', preis = 1200, item = 'weste', bestand = 6, beschreibung = 'Reduziert Schaden.' },
+ s_helm = { name = 'Ballistikhelm', kat = 'Schutz', icon = '⛑️', preis = 800, item = 'helm', bestand = 8, beschreibung = 'Schützt den Kopf.' },
+}
+
+-- ---------------------------------------------------------------------
+-- Laeden — nutzen dieselbe Oberflaeche, listen nur andere Produkte
+-- ---------------------------------------------------------------------
+Shop.Laeden = {
+ ['247'] = {
+ name = 'LS 24/7', logo = '🛒', tl = 'Alles für den Alltag',
+ accent = '#4ade80', ink = '#06210f', warnung = nil,
+ blip = { sprite = 52, farbe = 2 },
+ produkte = {
+ 'wasser', 'cola', 'energy', 'kaffee', 'sandwich', 'chips', 'schoko', 'burger',
+ 'verband', 'tablette', 'medkit', 'repkit', 'dietrich', 'kabel', 'lampe',
+ 'handy', 'sim', 'akku', 'funk', 'boerse', 'rucksack',
+ },
+ -- Standorte der 24/7-Filialen
+ standorte = {
+ vector3(25.7, -1347.3, 29.5),
+ vector3(-3038.9, 585.9, 7.9),
+ vector3(-3241.9, 1001.4, 12.8),
+ vector3(1729.2, 6414.1, 35.0),
+ vector3(1697.9, 4924.4, 42.1),
+ vector3(547.4, 2671.7, 42.1),
+ vector3(2679.0, 3280.1, 55.2),
+ vector3(1961.4, 3740.6, 32.3),
+ vector3(293.8, -1447.0, 29.9),
+ vector3(-2968.2, 390.9, 15.0),
+ },
+ },
+ ['ammu'] = {
+ name = 'Ammu-Nation', logo = '🔫', tl = 'Waffen & Ausrüstung',
+ accent = '#f0684a', ink = '#2a0c05',
+ warnung = 'Für Waffen & bestimmte Aufsätze ist ein gültiger Waffenschein erforderlich.',
+ blip = { sprite = 110, farbe = 1 },
+ produkte = {
+ 'w_pistole', 'w_smg', 'w_flinte', 'a_9mm', 'a_12',
+ 'at_sd', 'at_lampe', 'at_laser', 's_weste', 's_helm',
+ },
+ standorte = {
+ vector3(22.1, -1107.4, 29.8),
+ vector3(810.2, -2157.3, 29.6),
+ vector3(1693.4, 3760.2, 34.7),
+ vector3(-330.2, 6083.9, 31.5),
+ vector3(252.6, -50.0, 69.9),
+ vector3(-662.1, -935.3, 21.8),
+ vector3(-1305.3, -393.5, 36.7),
+ vector3(-1118.9, 2698.9, 18.6),
+ vector3(2567.7, 294.4, 108.7),
+ },
+ },
+}
+
+--- Liefert den effektiven Preis eines Produkts (mit Rabatt)
+--- @param produkt table Produktdefinition
+--- @return number Preis in Euro
+function Shop.Preis(produkt)
+ if not produkt then return 0 end
+ local preis = produkt.preis or 0
+ if produkt.rabatt then
+ preis = math.floor(preis * (1 - produkt.rabatt) + 0.5)
+ end
+ return preis
+end
+
+--- Prueft, ob ein Laden ein bestimmtes Produkt fuehrt
+--- @param ladenId string Kennung des Ladens
+--- @param produktId string Kennung des Produkts
+--- @return boolean
+function Shop.FuehrtProdukt(ladenId, produktId)
+ local laden = Shop.Laeden[ladenId]
+ if not laden then return false end
+ for _, kennung in ipairs(laden.produkte) do
+ if kennung == produktId then return true end
+ end
+ return false
+end
diff --git a/server.cfg b/server.cfg
new file mode 100644
index 0000000..f5cc5b6
--- /dev/null
+++ b/server.cfg
@@ -0,0 +1,102 @@
+# =====================================================================
+# Aether Framework — Serverkonfiguration
+# Start: FXServer.exe +exec server.cfg (bzw. ./run.sh +exec server.cfg)
+# =====================================================================
+
+# ---------------------------------------------------------------------
+# Netzwerk
+# ---------------------------------------------------------------------
+endpoint_add_tcp "0.0.0.0:30120"
+endpoint_add_udp "0.0.0.0:30120"
+
+# ---------------------------------------------------------------------
+# Serverdaten
+# ---------------------------------------------------------------------
+sv_hostname "Aether Roleplay | Los Santos"
+sv_projectName "Aether Framework"
+sv_projectDesc "Deutschsprachiges GTA-RP auf eigenem Framework"
+sv_maxclients 48
+sv_enforceGameBuild 3095 # Aktueller Spielbuild (fuer neuere DLC-Inhalte)
+sets locale "de-DE"
+sets tags "roleplay, deutsch, custom framework"
+
+# Lizenzschluessel von https://keymaster.fivem.net eintragen
+sv_licenseKey "HIER_DEINEN_LIZENZSCHLUESSEL_EINTRAGEN"
+
+# Steam-Web-API-Key (optional, fuer Steam-Identifier)
+# set steam_webApiKey "HIER_DEINEN_STEAM_KEY"
+
+# ---------------------------------------------------------------------
+# Datenbank (oxmysql)
+# Format: mysql://BENUTZER:PASSWORT@HOST/DATENBANK?charset=utf8mb4
+# ---------------------------------------------------------------------
+set mysql_connection_string "mysql://root:passwort@localhost/aether?charset=utf8mb4"
+
+# ---------------------------------------------------------------------
+# Ressourcen-Pfad
+# ---------------------------------------------------------------------
+# Nur noetig, wenn resources/ nicht neben der server.cfg liegt:
+# set resources_path "resources"
+
+# ---------------------------------------------------------------------
+# Basisressourcen (Reihenfolge ist wichtig)
+# ---------------------------------------------------------------------
+# --- System (gehoeren zum FiveM-Server, muessen laufen) ---------------
+ensure sessionmanager # Verbindungsverwaltung — ohne das kommt niemand rein
+ensure mapmanager # Kartenverwaltung
+ensure spawnmanager # Spawn-Steuerung (der Core schaltet Auto-Spawn ab)
+ensure hardcap # Begrenzung auf sv_maxclients
+ensure chat # Chat (spaeter durch eigenes Modul ersetzbar)
+
+# --- Abhaengigkeiten -------------------------------------------------
+# oxmysql muss separat heruntergeladen werden:
+# https://github.com/overextended/oxmysql/releases -> resources/[standalone]/oxmysql
+ensure oxmysql # Datenbankanbindung — muss vor aether-core laufen
+
+# --- Aether ----------------------------------------------------------
+ensure aether-core # Fundament: Spieler, Charaktere, Geld, Zustand
+ensure aether-loading # Ladebildschirm
+ensure aether-character # Charakterauswahl und -ersteller
+ensure aether-hud # In-Game-HUD (Status, Voice, Geld, Tacho)
+ensure aether-inventory # Inventar, Items, Boden-Drops (Taste I)
+ensure aether-bank # Geldautomat (PIN, Ein-/Auszahlen, Überweisen)
+ensure aether-documents # Börse: Ausweis, Führerschein, Waffenschein (F2)
+ensure aether-shop # Läden: 24/7 und Ammu-Nation
+ensure aether-garage # Garagen: Ein-/Ausparken, Fahrzeugzustand
+ensure aether-phone # AetherOS: Telefon, Nachrichten, Kontakte (F1)
+ensure aether-jobs # Jobcenter: 8 Jobs mit Level und Auftraegen (J)
+ensure aether-death # Verletzt-Zustand, Rettungsdienst, Krankenhaus
+ensure aether-police # Polizei-Managementsystem (Tablet: F5)
+ensure aether-medical # Medizin-Managementsystem (Klinik-PC: F6)
+ensure aether-admin # Admin-Panel mit Live-Kameras (Taste: F9)
+
+# Weitere Module werden hier ergaenzt, sobald sie migriert sind:
+# ensure aether-immobilien
+# ensure aether-fuel
+# ...
+
+# ---------------------------------------------------------------------
+# Rechte
+# Eigene Lizenz per "status" in der Serverkonsole herausfinden
+# ---------------------------------------------------------------------
+add_ace group.admin command allow
+add_ace group.admin command.quit deny
+# add_principal identifier.license:DEINE_LIZENZ group.admin
+
+# ---------------------------------------------------------------------
+# aether-admin — Panel & Live-Kameras
+# Die Panel-Rechte kommen aus aether_users.admin_group (user/support/
+# admin/owner), nicht aus ACE. Eigene Gruppe in der DB setzen, z. B.:
+# UPDATE aether_users SET admin_group='owner' WHERE license='DEINE_LIZENZ';
+#
+# Für die WebRTC-Live-Kameras muss der Media-Server laufen
+# (siehe resources/[aether]/aether-admin/media-server/README.md):
+# ---------------------------------------------------------------------
+set aether_admin_media_secret "" # MUSS gesetzt werden (= Media-Server-Geheimnis)
+set aether_admin_media_http "http://127.0.0.1:8788" # HTTP-Token-Endpunkt (server-zu-server)
+
+# ---------------------------------------------------------------------
+# Spielregeln
+# ---------------------------------------------------------------------
+sv_scriptHookAllowed 0 # ScriptHook verbieten (Cheat-Schutz)
+# sv_endpointPrivacy true # IPs der Spieler nicht oeffentlich zeigen
diff --git a/sql/aether.sql b/sql/aether.sql
new file mode 100644
index 0000000..6975d7e
--- /dev/null
+++ b/sql/aether.sql
@@ -0,0 +1,281 @@
+-- =====================================================================
+-- Aether Framework — Datenbank-Schema (Grundlage)
+-- Einspielen mit: mysql -u root -p aether < sql/aether.sql
+-- =====================================================================
+
+CREATE DATABASE IF NOT EXISTS `aether`
+ DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+USE `aether`;
+
+-- ---------------------------------------------------------------------
+-- Benutzer (ein Eintrag pro FiveM-Account, erkannt an der Lizenz)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_users` (
+ `license` VARCHAR(64) NOT NULL COMMENT 'Rockstar-Lizenz als eindeutige Kennung',
+ `name` VARCHAR(64) NOT NULL DEFAULT 'Unbekannt' COMMENT 'Zuletzt genutzter Spielername',
+ `discord` VARCHAR(64) DEFAULT NULL COMMENT 'Discord-ID, falls vorhanden',
+ `is_banned` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Gebannt ja/nein',
+ `ban_reason` VARCHAR(255) DEFAULT NULL COMMENT 'Grund des Banns',
+ `ban_until` DATETIME DEFAULT NULL COMMENT 'Bann-Ende (NULL = dauerhaft)',
+ `admin_group` VARCHAR(32) NOT NULL DEFAULT 'user' COMMENT 'Rechtegruppe: user/support/admin/owner',
+ `playtime` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Gesamtspielzeit in Minuten',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`license`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Charaktere (mehrere pro Benutzer — Multicharacter)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_characters` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `license` VARCHAR(64) NOT NULL COMMENT 'Besitzer des Charakters',
+ `firstname` VARCHAR(32) NOT NULL,
+ `lastname` VARCHAR(32) NOT NULL,
+ `dob` DATE NOT NULL COMMENT 'Geburtsdatum',
+ `gender` VARCHAR(16) NOT NULL DEFAULT 'm' COMMENT 'Geschlecht',
+ `nationality` VARCHAR(48) NOT NULL DEFAULT 'Los Santos' COMMENT 'Herkunft',
+ `appearance` LONGTEXT DEFAULT NULL COMMENT 'Aussehen als JSON (Charakter-Ersteller)',
+ -- Geld (passend zum HUD: Bargeld / Konto / Schwarzgeld)
+ `cash` INT NOT NULL DEFAULT 0,
+ `bank` INT NOT NULL DEFAULT 0,
+ `dirty_money` INT NOT NULL DEFAULT 0,
+ `iban` VARCHAR(24) DEFAULT NULL COMMENT 'Kontonummer fuer das Bank-Modul',
+ `bank_pin` VARCHAR(8) NOT NULL DEFAULT '1234' COMMENT 'PIN des Bankautomaten',
+ -- Arbeit und Zugehoerigkeit
+ `job` VARCHAR(32) NOT NULL DEFAULT 'unemployed',
+ `job_grade` INT NOT NULL DEFAULT 0,
+ `faction` VARCHAR(32) DEFAULT NULL COMMENT 'Fraktion/Gang, falls Mitglied',
+ `faction_rank` INT NOT NULL DEFAULT 0,
+ -- Zustand (passend zu den HUD-Statusringen)
+ `health` INT NOT NULL DEFAULT 200,
+ `hunger` INT NOT NULL DEFAULT 100,
+ `thirst` INT NOT NULL DEFAULT 100,
+ `stamina` INT NOT NULL DEFAULT 100,
+ `position` VARCHAR(128) DEFAULT NULL COMMENT 'Letzte Position als JSON {x,y,z,heading}',
+ `playtime` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Spielzeit dieses Charakters in Minuten',
+ `is_dead` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Liegt der Charakter verletzt am Boden?',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Weich geloescht (Slot freigegeben)',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `last_played` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_license` (`license`),
+ CONSTRAINT `fk_char_user` FOREIGN KEY (`license`)
+ REFERENCES `aether_users` (`license`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Inventar (ein Eintrag je Item-Stapel an einem Ort)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_inventory` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `owner_type` VARCHAR(16) NOT NULL DEFAULT 'character' COMMENT 'character/vehicle/stash/ground',
+ `owner_id` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID, Kennzeichen oder Lager-Name',
+ `slot` INT NOT NULL DEFAULT 0 COMMENT 'Platz im jeweiligen Behaelter',
+ `item` VARCHAR(48) NOT NULL COMMENT 'Item-Kennung aus der Registry',
+ `amount` INT NOT NULL DEFAULT 1,
+ `metadata` LONGTEXT DEFAULT NULL COMMENT 'Zusatzdaten als JSON (Seriennummer, Akku, Aufsaetze)',
+ PRIMARY KEY (`id`),
+ KEY `idx_owner` (`owner_type`, `owner_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fallengelassene Items (bleiben ueber Serverneustarts erhalten)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_drops` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `item` VARCHAR(48) NOT NULL COMMENT 'Item-Kennung aus der Registry',
+ `amount` INT NOT NULL DEFAULT 1,
+ `metadata` LONGTEXT DEFAULT NULL COMMENT 'Zusatzdaten als JSON',
+ `x` FLOAT NOT NULL,
+ `y` FLOAT NOT NULL,
+ `z` FLOAT NOT NULL,
+ `prop` VARCHAR(48) NOT NULL COMMENT 'Modell des sichtbaren Kartons',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fahrzeuge (Garagen-Modul)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_vehicles` (
+ `plate` VARCHAR(12) NOT NULL COMMENT 'Kennzeichen als eindeutige Kennung',
+ `owner` INT UNSIGNED NOT NULL COMMENT 'Charakter-ID des Besitzers',
+ `model` VARCHAR(48) NOT NULL COMMENT 'Fahrzeugmodell (Spawnname)',
+ `props` LONGTEXT DEFAULT NULL COMMENT 'Tuning/Farben als JSON',
+ `garage` VARCHAR(48) NOT NULL DEFAULT 'zentralgarage' COMMENT 'Aktueller Standort',
+ `state` VARCHAR(16) NOT NULL DEFAULT 'in' COMMENT 'in = eingeparkt, out = draussen, impound = beschlagnahmt',
+ `fuel` FLOAT NOT NULL DEFAULT 100 COMMENT 'Tankfuellstand in Prozent',
+ `engine` FLOAT NOT NULL DEFAULT 1000 COMMENT 'Motorzustand',
+ `body` FLOAT NOT NULL DEFAULT 1000 COMMENT 'Karosseriezustand',
+ `mileage` INT NOT NULL DEFAULT 0 COMMENT 'Kilometerstand',
+ -- Letzte bekannte Position; ausgeparkte Fahrzeuge stehen nach einem
+ -- Serverneustart wieder genau hier.
+ `pos_x` FLOAT DEFAULT NULL,
+ `pos_y` FLOAT DEFAULT NULL,
+ `pos_z` FLOAT DEFAULT NULL,
+ `heading` FLOAT DEFAULT NULL,
+ PRIMARY KEY (`plate`),
+ KEY `idx_owner` (`owner`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Dokumente (Boerse: Ausweis, Fuehrerschein, Waffenschein ...)
+-- Name und Geburtsdatum kommen live aus aether_characters, damit sie
+-- nicht doppelt gepflegt werden muessen.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_documents` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL COMMENT 'Besitzer des Dokuments',
+ `type` VARCHAR(16) NOT NULL COMMENT 'perso/fs/ws/gk/vk',
+ `wallet` INT NOT NULL DEFAULT 0 COMMENT 'In welcher Boerse es steckt (0 = erste)',
+ `data` LONGTEXT DEFAULT NULL COMMENT 'Dokumentdaten als JSON (Nummer, Klassen, Waffen ...)',
+ `issued_by` VARCHAR(64) DEFAULT NULL COMMENT 'Ausstellende Stelle',
+ `issued_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `valid_until` DATE DEFAULT NULL COMMENT 'Gueltig bis (NULL = unbefristet)',
+ `revoked` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Entzogen ja/nein',
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`),
+ UNIQUE KEY `uniq_doc` (`character`, `type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Handy: Geraetedaten je Charakter
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_phone` (
+ `character` INT UNSIGNED NOT NULL,
+ `number` VARCHAR(16) NOT NULL COMMENT 'Telefonnummer, z. B. 555-0182',
+ `pin` VARCHAR(8) DEFAULT NULL COMMENT 'Entsperrcode (NULL = Ersteinrichtung noetig)',
+ `faceid` TINYINT(1) NOT NULL DEFAULT 0,
+ `theme` VARCHAR(16) NOT NULL DEFAULT 'dark',
+ `wallpaper` VARCHAR(32) NOT NULL DEFAULT 'wp-aurora',
+ `dock` VARCHAR(128) NOT NULL DEFAULT '["phone","messages","bank","camera"]',
+ `battery` INT NOT NULL DEFAULT 100 COMMENT 'Akkustand in Prozent',
+ `unlocked` VARCHAR(255) NOT NULL DEFAULT '{}' COMMENT 'Freigeschaltete Apps als JSON',
+ PRIMARY KEY (`character`),
+ UNIQUE KEY `uniq_number` (`number`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Handy: Kontakte
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_phone_contacts` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL COMMENT 'Besitzer der Kontaktliste',
+ `name` VARCHAR(48) NOT NULL,
+ `number` VARCHAR(16) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Handy: Nachrichten (zwischen Telefonnummern, nicht Charakteren —
+-- so bleiben sie auch nach einem SIM-Wechsel nachvollziehbar)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_phone_messages` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `sender` VARCHAR(16) NOT NULL COMMENT 'Absendernummer',
+ `receiver` VARCHAR(16) NOT NULL COMMENT 'Empfaengernummer',
+ `text` VARCHAR(512) NOT NULL,
+ `gelesen` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_paar` (`sender`, `receiver`),
+ KEY `idx_empfaenger` (`receiver`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Handy: Anrufverlauf
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_phone_calls` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `caller` VARCHAR(16) NOT NULL COMMENT 'Anrufernummer',
+ `callee` VARCHAR(16) NOT NULL COMMENT 'Angerufene Nummer',
+ `verpasst` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_caller` (`caller`),
+ KEY `idx_callee` (`callee`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Banktransaktionen (Bank- und Handy-Modul)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_transactions` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL COMMENT 'Betroffener Charakter',
+ `type` VARCHAR(16) NOT NULL COMMENT 'deposit/withdraw/transfer/salary/fine',
+ `amount` INT NOT NULL COMMENT 'Betrag (positiv = Eingang)',
+ `balance` INT NOT NULL COMMENT 'Kontostand nach der Buchung',
+ `label` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Verwendungszweck',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Jobs: Level und Erfahrung je Charakter und Job
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_jobs` (
+ `character` INT UNSIGNED NOT NULL,
+ `job` VARCHAR(32) NOT NULL,
+ `level` INT NOT NULL DEFAULT 1,
+ `xp` INT NOT NULL DEFAULT 0,
+ `verdient` INT NOT NULL DEFAULT 0 COMMENT 'Gesamtverdienst in diesem Job',
+ `auftraege` INT NOT NULL DEFAULT 0 COMMENT 'Abgeschlossene Auftraege',
+ PRIMARY KEY (`character`, `job`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fraktionen: Personenakten (Straftaten, Notizen, Konfisziertes)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_records` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL COMMENT 'Betroffene Person',
+ `faction` VARCHAR(32) NOT NULL COMMENT 'Welche Fraktion hat den Eintrag erstellt',
+ `type` VARCHAR(16) NOT NULL COMMENT 'straftat/notiz/konfisziert/behandlung',
+ `text` VARCHAR(512) NOT NULL,
+ `betrag` INT NOT NULL DEFAULT 0 COMMENT 'Strafe/Rechnung, falls zutreffend',
+ `haft` INT NOT NULL DEFAULT 0 COMMENT 'Haftzeit in Minuten',
+ `created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'Name des Beamten',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fraktionen: Fahndungen
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_wanted` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL,
+ `faction` VARCHAR(32) NOT NULL,
+ `grund` VARCHAR(255) NOT NULL,
+ `stufe` VARCHAR(16) NOT NULL DEFAULT 'gesucht' COMMENT 'gesucht/bewaffnet/gefaehrlich',
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `created_by` VARCHAR(64) NOT NULL DEFAULT '',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Gefaengnis: laufende Haftstrafen
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_jail` (
+ `character` INT UNSIGNED NOT NULL,
+ `rest` INT NOT NULL DEFAULT 0 COMMENT 'Restliche Haftzeit in Sekunden',
+ `grund` VARCHAR(255) NOT NULL DEFAULT '',
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Staatskasse (eine Zeile; Strafen und Rechnungen fliessen hierher)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_treasury` (
+ `id` INT UNSIGNED NOT NULL DEFAULT 1,
+ `betrag` BIGINT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+INSERT IGNORE INTO `aether_treasury` (`id`, `betrag`) VALUES (1, 12480000);
diff --git a/sql/aether_admin.sql b/sql/aether_admin.sql
new file mode 100644
index 0000000..7c9e9c3
--- /dev/null
+++ b/sql/aether_admin.sql
@@ -0,0 +1,204 @@
+-- =====================================================================
+-- aether-admin — Datenbankschema
+--
+-- Grundsatz: Das Admin-Panel führt Buch und greift durch, aber es
+-- erfindet keine Wahrheit. Rechte und Bans leben in `aether_users`
+-- (aus aether-core) — dort prüft der Core sie bereits beim Connect.
+-- Dieses Schema ergänzt nur, was der Core nicht kennt: Verwarnungen,
+-- Reports, Notizen, das Prüfprotokoll und die verwaltbaren Datensätze
+-- einzelner Panel-Bereiche.
+--
+-- Voraussetzung: sql/aether.sql (aether_users, aether_characters) ist
+-- eingespielt. `admin_group` und die Bann-Spalten existieren dort schon.
+-- =====================================================================
+
+-- ---------------------------------------------------------------------
+-- Verwarnungen
+--
+-- Eine Verwarnung ist eine dokumentierte Ermahnung, mehr nicht. Sie
+-- sperrt nichts und läuft nicht ab — sie steht in der Akte, bis ein
+-- Admin sie entfernt.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_warns` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `license` VARCHAR(64) NOT NULL COMMENT 'Betroffener Benutzer',
+ `character` INT UNSIGNED DEFAULT NULL COMMENT 'Charakter, falls bekannt',
+ `name` VARCHAR(64) NOT NULL COMMENT 'Anzeigename zum Zeitpunkt',
+ `grund` VARCHAR(255) NOT NULL,
+ `admin_license` VARCHAR(64) DEFAULT NULL,
+ `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_license` (`license`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Bann-Verlauf
+--
+-- Die Durchsetzung liegt in `aether_users` (is_banned/ban_reason/
+-- ban_until) — der Core sperrt darüber beim Connect. Diese Tabelle ist
+-- das Gedächtnis: jeder Bann und jede Entbannung bleibt nachlesbar,
+-- auch für Identifier ohne Benutzerkonto (Offline-Ban).
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_bans` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `license` VARCHAR(64) DEFAULT NULL COMMENT 'Lizenz, falls bekannt',
+ `name` VARCHAR(64) NOT NULL,
+ `grund` VARCHAR(255) NOT NULL,
+ `admin_license` VARCHAR(64) DEFAULT NULL,
+ `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System',
+ `bis` DATETIME DEFAULT NULL COMMENT 'NULL = dauerhaft',
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '0 = wieder entbannt',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `aufgehoben_am` DATETIME DEFAULT NULL,
+ `aufgehoben_von` VARCHAR(64) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_license` (`license`),
+ KEY `idx_aktiv` (`aktiv`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Reports / Support-Tickets
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_reports` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `melder_license` VARCHAR(64) DEFAULT NULL,
+ `melder_name` VARCHAR(64) NOT NULL,
+ `betreff` VARCHAR(160) NOT NULL,
+ `nachricht` TEXT DEFAULT NULL,
+ `prioritaet` TINYINT NOT NULL DEFAULT 3 COMMENT '1 hoch … 3 niedrig',
+ `status` VARCHAR(16) NOT NULL DEFAULT 'offen'
+ COMMENT 'offen/bearbeitung/geschlossen',
+ `bearbeiter` VARCHAR(64) DEFAULT NULL COMMENT 'Admin, der übernommen hat',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `aktualisiert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Nachrichtenverlauf eines Reports (Antworten des Teams / Rückfragen)
+CREATE TABLE IF NOT EXISTS `aether_admin_report_msgs` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `report` INT UNSIGNED NOT NULL,
+ `autor` VARCHAR(64) NOT NULL,
+ `ist_admin` TINYINT(1) NOT NULL DEFAULT 1,
+ `nachricht` TEXT NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_report` (`report`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Interne Spielernotizen (nur für das Team sichtbar)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_notes` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `license` VARCHAR(64) NOT NULL,
+ `name` VARCHAR(64) NOT NULL,
+ `notiz` TEXT NOT NULL,
+ `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_license` (`license`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Prüfprotokoll (Audit-Log)
+--
+-- Jede eingreifende Aktion hinterlässt eine Spur: wer, was, an wem,
+-- mit welchem Detail. Einträge werden nur geschrieben und gelesen.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_log` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `admin_license` VARCHAR(64) DEFAULT NULL,
+ `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System',
+ `aktion` VARCHAR(64) NOT NULL,
+ `ziel` VARCHAR(96) DEFAULT NULL,
+ `detail` VARCHAR(512) DEFAULT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_admin` (`admin_license`),
+ KEY `idx_zeit` (`erstellt_am`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Ankündigungen (Verlauf der Rundmeldungen)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_broadcasts` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `typ` VARCHAR(16) NOT NULL DEFAULT 'info',
+ `nachricht` VARCHAR(512) NOT NULL,
+ `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Staatsprojekte (Abstimmungen; später koppelbar an eine Handy-App)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_projects` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `titel` VARCHAR(160) NOT NULL,
+ `beschreibung` TEXT DEFAULT NULL,
+ `kosten` BIGINT NOT NULL DEFAULT 0,
+ `ja` INT UNSIGNED NOT NULL DEFAULT 0,
+ `nein` INT UNSIGNED NOT NULL DEFAULT 0,
+ `abstimmung_offen` TINYINT(1) NOT NULL DEFAULT 1,
+ `aktiviert` TINYINT(1) NOT NULL DEFAULT 0,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Immobilien
+--
+-- Solange kein eigenständiges Immobilien-Resource läuft, verwaltet
+-- das Panel die Objekte selbst. `HoleImmobilien` ist als Export
+-- vorgesehen, damit ein späteres Modul die Hoheit übernehmen kann.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_properties` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(96) NOT NULL,
+ `ort` VARCHAR(96) DEFAULT NULL,
+ `preis` BIGINT NOT NULL DEFAULT 0,
+ `besitzer` INT UNSIGNED DEFAULT NULL COMMENT 'Charakter aus aether_characters',
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fraktions-Metadaten
+--
+-- Mitgliedschaft ergibt sich aus aether_characters.faction. Hier
+-- liegen nur die Zusatzangaben (Gehalt, Farbe), die der Charakter
+-- nicht kennt.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_factions` (
+ `name` VARCHAR(48) NOT NULL,
+ `gehalt` INT NOT NULL DEFAULT 0 COMMENT 'Payday pro Stunde',
+ `farbe` VARCHAR(9) NOT NULL DEFAULT '#34d39a',
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Persistierte Server-Einstellungen (Schlüssel/Wert)
+-- z. B. staatskasse, steuersatz, pvp, whitelist, servername …
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `aether_admin_settings` (
+ `schluessel` VARCHAR(48) NOT NULL,
+ `wert` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`schluessel`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Grundbestand (nur einmalig, wenn leer)
+-- ---------------------------------------------------------------------
+INSERT INTO `aether_admin_settings` (`schluessel`, `wert`) VALUES
+ ('staatskasse', '12480000'),
+ ('steuersatz', '8'),
+ ('pvp', '1'),
+ ('whitelist', '1'),
+ ('voice', '1'),
+ ('economy', '1')
+ON DUPLICATE KEY UPDATE `schluessel` = `schluessel`;
diff --git a/sql/aether_medical.sql b/sql/aether_medical.sql
new file mode 100644
index 0000000..1d81c8d
--- /dev/null
+++ b/sql/aether_medical.sql
@@ -0,0 +1,607 @@
+-- =====================================================================
+-- aether-medical — Datenbankschema
+--
+-- Grundsatz, der sich durch das ganze Schema zieht:
+-- Das System unterstuetzt medizinische Entscheidungen — es trifft
+-- niemals selbst welche.
+--
+-- Deshalb hat JEDER Vorgang, der eine Entscheidung darstellt, eine
+-- Spalte mit dem verantwortlichen Mediziner. Es gibt keine Spalte,
+-- die das System ohne einen Menschen dahinter fuellen wuerde.
+-- Aufnahmen, Entlassungen, Diagnosen, Medikamentengaben und
+-- Todesfeststellungen tragen immer einen Namen.
+-- =====================================================================
+
+-- ---------------------------------------------------------------------
+-- Personal
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_personal` (
+ `character` INT UNSIGNED NOT NULL COMMENT 'Charakter aus aether_characters',
+ `personalnummer` VARCHAR(16) NOT NULL COMMENT 'z. B. LSMC-0421',
+ `rolle` VARCHAR(32) NOT NULL DEFAULT 'praktikant',
+ `station` VARCHAR(32) NOT NULL DEFAULT 'notaufnahme',
+ `rechte_extra` LONGTEXT DEFAULT NULL COMMENT 'Zusaetzlich gewaehrte Rechte als JSON',
+ `rechte_entzug` LONGTEXT DEFAULT NULL COMMENT 'Einzeln entzogene Rechte als JSON',
+ `eingestellt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `notiz` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`character`),
+ UNIQUE KEY `uniq_personalnummer` (`personalnummer`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Dienstzeiten (nur Protokoll, keine Auswertung zur Bewertung)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_dienst` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL,
+ `beginn` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `ende` DATETIME DEFAULT NULL,
+ `sekunden` INT UNSIGNED NOT NULL DEFAULT 0,
+ `station` VARCHAR(32) DEFAULT NULL,
+ `funktion` VARCHAR(24) NOT NULL DEFAULT 'station'
+ COMMENT 'rtw/notarzt/station/op/leitstelle',
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Patientenstammdaten
+-- Werden ausschliesslich von Medizinern gepflegt.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_patient` (
+ `character` INT UNSIGNED NOT NULL,
+ `blutgruppe` VARCHAR(8) DEFAULT NULL COMMENT 'Erst nach Bestimmung im Labor',
+ `allergien` VARCHAR(512) DEFAULT NULL,
+ `vorerkrankungen` VARCHAR(1000) DEFAULT NULL,
+ `dauermedikation` VARCHAR(1000) DEFAULT NULL,
+ `implantate` VARCHAR(512) DEFAULT NULL,
+ `organspender` VARCHAR(16) DEFAULT NULL COMMENT 'ja/nein/unbekannt',
+ `notfallkontakt` VARCHAR(160) DEFAULT NULL,
+ `hinweis` VARCHAR(512) DEFAULT NULL COMMENT 'Warnhinweis, z. B. Gerinnungsstoerung',
+ `lichtbild` VARCHAR(512) DEFAULT NULL COMMENT 'Bildadresse fuer die Patientenakte',
+ `aktualisiert_von` INT UNSIGNED DEFAULT NULL,
+ `aktualisiert_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Kurze Vermerke zu einem Patienten ausserhalb einer Akte
+CREATE TABLE IF NOT EXISTS `medical_patient_notiz` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `patient` INT UNSIGNED NOT NULL,
+ `text` VARCHAR(1000) NOT NULL,
+ `von` INT UNSIGNED NOT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Akten und ihre Versionen
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_akte` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `aktenzeichen` VARCHAR(24) NOT NULL,
+ `patient` INT UNSIGNED DEFAULT NULL,
+ `typ` VARCHAR(24) NOT NULL DEFAULT 'behandlung',
+ `titel` VARCHAR(160) NOT NULL,
+ `status` VARCHAR(16) NOT NULL DEFAULT 'entwurf'
+ COMMENT 'entwurf/offen/freigegeben/geschlossen',
+ `inhalt` LONGTEXT DEFAULT NULL,
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `freigabe_von` INT UNSIGNED DEFAULT NULL,
+ `freigabe_am` DATETIME DEFAULT NULL,
+ `version` INT UNSIGNED NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_aktenzeichen` (`aktenzeichen`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_akte_version` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `akte` INT UNSIGNED NOT NULL,
+ `version` INT UNSIGNED NOT NULL,
+ `inhalt` LONGTEXT DEFAULT NULL,
+ `geaendert_von` INT UNSIGNED NOT NULL,
+ `geaendert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `kommentar` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_akte` (`akte`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Verknuepfungen zwischen Vorgaengen (Akte <-> Einsatz <-> OP <-> Labor)
+CREATE TABLE IF NOT EXISTS `medical_verknuepfung` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `von_typ` VARCHAR(24) NOT NULL,
+ `von_id` VARCHAR(64) NOT NULL,
+ `zu_typ` VARCHAR(24) NOT NULL,
+ `zu_id` VARCHAR(64) NOT NULL,
+ `von` INT UNSIGNED DEFAULT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_von` (`von_typ`, `von_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Aufnahme
+-- Es gibt KEINE automatische Aufnahme und KEINE automatische
+-- Entlassung. Beides traegt immer einen Mediziner als Verantwortlichen.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_aufnahme` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `patient` INT UNSIGNED NOT NULL,
+ `grund` VARCHAR(512) NOT NULL,
+ `prioritaet` TINYINT NOT NULL DEFAULT 3 COMMENT '1 = sofort … 4 = nachrangig',
+ `station` VARCHAR(32) DEFAULT NULL,
+ `zimmer` INT UNSIGNED DEFAULT NULL,
+ `status` VARCHAR(16) NOT NULL DEFAULT 'wartend'
+ COMMENT 'wartend/aufgenommen/entlassen',
+ `aufgenommen_von` INT UNSIGNED NOT NULL COMMENT 'Wer hat aufgenommen',
+ `aufgenommen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ -- Entlassung geschieht ausschliesslich manuell:
+ `entlassen_von` INT UNSIGNED DEFAULT NULL,
+ `entlassen_am` DATETIME DEFAULT NULL,
+ `entlass_grund` VARCHAR(512) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_patient` (`patient`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Behandlungsdokumentation
+-- Das System speichert AUSSCHLIESSLICH die Dokumentation. Es heilt
+-- nichts, es diagnostiziert nichts, es schlaegt keine Diagnose vor.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_behandlung` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `patient` INT UNSIGNED NOT NULL,
+ `aufnahme` INT UNSIGNED DEFAULT NULL,
+ `einsatz` INT UNSIGNED DEFAULT NULL,
+ `symptome` TEXT DEFAULT NULL COMMENT 'Was der Patient schildert',
+ `untersuchung` TEXT DEFAULT NULL COMMENT 'Was der Mediziner erhebt',
+ `diagnose` TEXT DEFAULT NULL COMMENT 'Vom Mediziner gestellt, nie berechnet',
+ `massnahmen` TEXT DEFAULT NULL,
+ `empfehlung` TEXT DEFAULT NULL,
+ `nachsorge` TEXT DEFAULT NULL,
+ `status` VARCHAR(16) NOT NULL DEFAULT 'laufend'
+ COMMENT 'laufend/abgeschlossen',
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `abgeschlossen_von` INT UNSIGNED DEFAULT NULL,
+ `abgeschlossen_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Verabreichte Medikamente — immer mit dem Mediziner, der sie gegeben hat
+CREATE TABLE IF NOT EXISTS `medical_behandlung_medikament` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `behandlung` INT UNSIGNED NOT NULL,
+ `medikament` INT UNSIGNED NOT NULL,
+ `dosierung` VARCHAR(64) DEFAULT NULL,
+ `menge` INT NOT NULL DEFAULT 1,
+ `gegeben_von` INT UNSIGNED NOT NULL,
+ `gegeben_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `hinweis` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_behandlung` (`behandlung`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Operationen
+-- Werden vollstaendig manuell dokumentiert. Keine Durchfuehrung
+-- durch das System, kein automatischer Ausgang.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_operation` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `patient` INT UNSIGNED NOT NULL,
+ `aufnahme` INT UNSIGNED DEFAULT NULL,
+ `art` VARCHAR(160) NOT NULL,
+ `saal` INT UNSIGNED DEFAULT NULL,
+ `geplant_fuer` DATETIME DEFAULT NULL COMMENT 'Ab hier erinnert das System nur',
+ `beginn` DATETIME DEFAULT NULL COMMENT 'Vom OP-Team eingetragen',
+ `ende` DATETIME DEFAULT NULL COMMENT 'Vom OP-Team eingetragen',
+ `verlauf` TEXT DEFAULT NULL,
+ `komplikationen` TEXT DEFAULT NULL,
+ `nachsorge` TEXT DEFAULT NULL,
+ `status` VARCHAR(16) NOT NULL DEFAULT 'geplant'
+ COMMENT 'geplant/laufend/abgeschlossen/abgesagt',
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_operation_team` (
+ `operation` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `funktion` VARCHAR(48) NOT NULL DEFAULT 'assistenz'
+ COMMENT 'operateur/assistenz/anaesthesie/pflege',
+ PRIMARY KEY (`operation`, `character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Medikamente
+-- Bestandsfuehrung. Eine Ausgabe bucht der Mediziner selbst —
+-- das System gibt nichts aus und verordnet nichts.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_medikament` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `name` VARCHAR(120) NOT NULL,
+ `wirkstoff` VARCHAR(120) DEFAULT NULL,
+ `kategorie` VARCHAR(48) NOT NULL DEFAULT 'sonstiges',
+ `dosierung_standard` VARCHAR(64) DEFAULT NULL COMMENT 'Nur ein Vorschlag',
+ `einheit` VARCHAR(24) NOT NULL DEFAULT 'Stück',
+ `bestand` INT NOT NULL DEFAULT 0,
+ `mindestbestand` INT NOT NULL DEFAULT 0 COMMENT 'Ab hier erinnert das System',
+ `lagerort` VARCHAR(64) DEFAULT NULL,
+ `verfaellt_am` DATE DEFAULT NULL,
+ `hinweis` VARCHAR(255) DEFAULT NULL COMMENT 'z. B. Wechselwirkungen',
+ `betaeubungsmittel` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Erfordert besondere Doku',
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `sortierung` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_medikament_buchung` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `medikament` INT UNSIGNED NOT NULL,
+ `art` VARCHAR(16) NOT NULL COMMENT 'eingang/ausgabe/ruecknahme/verfall/korrektur',
+ `menge` INT NOT NULL,
+ `bestand_danach` INT NOT NULL,
+ `patient` INT UNSIGNED DEFAULT NULL,
+ `grund` VARCHAR(255) DEFAULT NULL,
+ `gebucht_von` INT UNSIGNED NOT NULL,
+ `gebucht_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_medikament` (`medikament`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Labor
+-- Das Labor speichert Ergebnisse. Es stellt keine Diagnose —
+-- die Bewertung eines Befundes bleibt dem Mediziner ueberlassen.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_labor` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `patient` INT UNSIGNED NOT NULL,
+ `art` VARCHAR(32) NOT NULL COMMENT 'blut/dna/urin/gewebe/toxikologie',
+ `fragestellung` VARCHAR(512) DEFAULT NULL COMMENT 'Wonach wird gesucht',
+ `dringend` TINYINT(1) NOT NULL DEFAULT 0,
+ `status` VARCHAR(16) NOT NULL DEFAULT 'offen'
+ COMMENT 'offen/in_arbeit/fertig/verworfen',
+ `auftrag_von` INT UNSIGNED NOT NULL,
+ `auftrag_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `bearbeitet_von` INT UNSIGNED DEFAULT NULL,
+ `bearbeitet_am` DATETIME DEFAULT NULL,
+ `ergebnis` TEXT DEFAULT NULL COMMENT 'Messwerte, vom Labor eingetragen',
+ `befund` TEXT DEFAULT NULL COMMENT 'Bewertung durch einen Mediziner',
+ `befund_von` INT UNSIGNED DEFAULT NULL,
+ `befund_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Blutbank
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_blut` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `konserve` VARCHAR(24) NOT NULL,
+ `blutgruppe` VARCHAR(8) NOT NULL,
+ `menge_ml` INT NOT NULL DEFAULT 500,
+ `lagerort` VARCHAR(64) DEFAULT NULL,
+ `eingelagert_von` INT UNSIGNED NOT NULL,
+ `eingelagert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `verfaellt_am` DATE DEFAULT NULL COMMENT 'Ab hier erinnert das System nur',
+ `status` VARCHAR(16) NOT NULL DEFAULT 'vorraetig'
+ COMMENT 'vorraetig/reserviert/ausgegeben/verworfen',
+ `reserviert_fuer` INT UNSIGNED DEFAULT NULL,
+ `reserviert_von` INT UNSIGNED DEFAULT NULL,
+ `ausgegeben_von` INT UNSIGNED DEFAULT NULL,
+ `ausgegeben_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_konserve` (`konserve`),
+ KEY `idx_blutgruppe` (`blutgruppe`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Krankenhaus: Stationen, Zimmer, Belegung
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_station` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `name` VARCHAR(120) NOT NULL,
+ `art` VARCHAR(24) NOT NULL DEFAULT 'normal'
+ COMMENT 'notaufnahme/normal/intensiv/op/behandlung',
+ `etage` VARCHAR(24) DEFAULT NULL,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `sortierung` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_zimmer` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `station` INT UNSIGNED NOT NULL,
+ `kennung` VARCHAR(24) NOT NULL,
+ `betten` INT NOT NULL DEFAULT 1,
+ `art` VARCHAR(24) NOT NULL DEFAULT 'bett'
+ COMMENT 'bett/behandlung/op/intensiv/isolierung',
+ `status` VARCHAR(16) NOT NULL DEFAULT 'frei'
+ COMMENT 'frei/belegt/reinigung/gesperrt — immer manuell gesetzt',
+ `hinweis` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_station` (`station`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_belegung` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `zimmer` INT UNSIGNED NOT NULL,
+ `bett` INT NOT NULL DEFAULT 1,
+ `patient` INT UNSIGNED NOT NULL,
+ `aufnahme` INT UNSIGNED DEFAULT NULL,
+ `belegt_von` INT UNSIGNED NOT NULL,
+ `belegt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `frei_von` INT UNSIGNED DEFAULT NULL,
+ `frei_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_zimmer` (`zimmer`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Intensivstation
+-- Keine automatischen Statusaenderungen. Jeder Verlaufseintrag
+-- stammt von einem Menschen.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_intensiv` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `patient` INT UNSIGNED NOT NULL,
+ `aufnahme` INT UNSIGNED DEFAULT NULL,
+ `zimmer` INT UNSIGNED DEFAULT NULL,
+ `geraete` VARCHAR(512) DEFAULT NULL COMMENT 'Angeschlossene Geraete als Text',
+ `verantwortlich` INT UNSIGNED DEFAULT NULL COMMENT 'Zustaendiger Arzt',
+ `zustand` VARCHAR(24) NOT NULL DEFAULT 'stabil'
+ COMMENT 'stabil/kritisch/besserung — immer manuell gesetzt',
+ `notiz` VARCHAR(1000) DEFAULT NULL,
+ `aufgenommen_von` INT UNSIGNED NOT NULL,
+ `aufgenommen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `beendet_von` INT UNSIGNED DEFAULT NULL,
+ `beendet_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_intensiv_verlauf` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `intensiv` INT UNSIGNED NOT NULL,
+ `text` VARCHAR(1000) NOT NULL,
+ `von` INT UNSIGNED NOT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_intensiv` (`intensiv`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Einsaetze und Leitstelle
+-- Ein Notruf erzeugt einen OFFENEN Einsatz. Zugewiesen wird
+-- ausschliesslich durch die Leitstelle, nie durch das System.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_einsatz` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `nummer` VARCHAR(24) NOT NULL,
+ `titel` VARCHAR(160) NOT NULL,
+ `kategorie` VARCHAR(32) NOT NULL DEFAULT 'notruf',
+ `prioritaet` TINYINT NOT NULL DEFAULT 3,
+ `ort` VARCHAR(160) DEFAULT NULL,
+ `pos_x` FLOAT DEFAULT NULL,
+ `pos_y` FLOAT DEFAULT NULL,
+ `pos_z` FLOAT DEFAULT NULL,
+ `beschreibung` TEXT DEFAULT NULL,
+ `status` VARCHAR(16) NOT NULL DEFAULT 'offen'
+ COMMENT 'offen/laufend/abgeschlossen',
+ `quelle` VARCHAR(24) NOT NULL DEFAULT 'manuell'
+ COMMENT 'manuell/notruf/polizei/system',
+ `erstellt_von` INT UNSIGNED DEFAULT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `geschlossen_von` INT UNSIGNED DEFAULT NULL,
+ `geschlossen_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_nummer` (`nummer`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_einsatz_kraft` (
+ `einsatz` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `seit` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `bis` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`einsatz`, `character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_einsatz_notiz` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `einsatz` INT UNSIGNED NOT NULL,
+ `text` VARCHAR(1000) NOT NULL,
+ `von` INT UNSIGNED NOT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_einsatz` (`einsatz`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Berichtswesen
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_bericht` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `nummer` VARCHAR(24) NOT NULL,
+ `patient` INT UNSIGNED DEFAULT NULL,
+ `behandlung` INT UNSIGNED DEFAULT NULL,
+ `einsatz` INT UNSIGNED DEFAULT NULL,
+ `titel` VARCHAR(160) NOT NULL,
+ `symptome` TEXT DEFAULT NULL,
+ `untersuchung` TEXT DEFAULT NULL,
+ `diagnose` TEXT DEFAULT NULL,
+ `massnahmen` TEXT DEFAULT NULL,
+ `empfehlung` TEXT DEFAULT NULL,
+ `anlagen` LONGTEXT DEFAULT NULL COMMENT 'JSON: Bilder und Dokumente',
+ `status` VARCHAR(16) NOT NULL DEFAULT 'entwurf'
+ COMMENT 'entwurf/eingereicht/freigegeben',
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `unterschriften` LONGTEXT DEFAULT NULL COMMENT 'JSON: wer hat gegengezeichnet',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_nummer` (`nummer`),
+ KEY `idx_patient` (`patient`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fuhrpark
+-- Standort, Tank und Zustand sind GEMELDETE Tatsachen.
+-- Zuweisung und Wartungsstatus setzt immer ein Mensch.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_fahrzeug` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennzeichen` VARCHAR(12) NOT NULL,
+ `modell` VARCHAR(48) NOT NULL,
+ `bezeichnung` VARCHAR(64) NOT NULL COMMENT 'z. B. RTW 1',
+ `art` VARCHAR(24) NOT NULL DEFAULT 'rtw'
+ COMMENT 'rtw/nef/ktw/hubschrauber/sonstiges',
+ `station` VARCHAR(32) DEFAULT NULL,
+ `status` VARCHAR(24) NOT NULL DEFAULT 'verfuegbar'
+ COMMENT 'verfuegbar/im_einsatz/werkstatt/ausser_dienst',
+ `wartung` VARCHAR(16) NOT NULL DEFAULT 'ok' COMMENT 'ok/faellig/werkstatt',
+ `ausstattung` VARCHAR(512) DEFAULT NULL,
+ `tank` FLOAT DEFAULT NULL COMMENT 'Gemeldet',
+ `zustand` FLOAT DEFAULT NULL COMMENT 'Gemeldet',
+ `letzter_fahrer` INT UNSIGNED DEFAULT NULL COMMENT 'Gemeldet',
+ `pos_x` FLOAT DEFAULT NULL,
+ `pos_y` FLOAT DEFAULT NULL,
+ `pos_z` FLOAT DEFAULT NULL,
+ `gemeldet_am` DATETIME DEFAULT NULL,
+ `zugewiesen_an` INT UNSIGNED DEFAULT NULL COMMENT 'Manuell zugewiesen',
+ `notiz` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennzeichen` (`kennzeichen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Interne Kommunikation
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_nachricht` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `absender` INT UNSIGNED NOT NULL,
+ `empfaenger` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID, Station oder "alle"',
+ `empf_typ` VARCHAR(16) NOT NULL DEFAULT 'person'
+ COMMENT 'person/station/rundmeldung',
+ `betreff` VARCHAR(160) DEFAULT NULL,
+ `text` TEXT NOT NULL,
+ `gesendet` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_empfaenger` (`empfaenger`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_nachricht_gelesen` (
+ `nachricht` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `gelesen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`nachricht`, `character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_aushang` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `titel` VARCHAR(160) NOT NULL,
+ `text` TEXT NOT NULL,
+ `kategorie` VARCHAR(24) NOT NULL DEFAULT 'information'
+ COMMENT 'anweisung/information/warnung/hygiene',
+ `station` VARCHAR(32) DEFAULT NULL COMMENT 'NULL = gesamte Klinik',
+ `angeheftet` TINYINT(1) NOT NULL DEFAULT 0,
+ `kenntnispflicht` TINYINT(1) NOT NULL DEFAULT 0,
+ `frist` DATETIME DEFAULT NULL,
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `archiviert_von` INT UNSIGNED DEFAULT NULL,
+ `archiviert_am` DATETIME DEFAULT NULL,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ KEY `idx_aktiv` (`aktiv`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `medical_aushang_kenntnis` (
+ `aushang` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `bestaetigt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`aushang`, `character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Hinweise und Erinnerungen
+-- Sie erinnern ausschliesslich. Kein Hinweis fuehrt etwas aus.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_hinweis` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED DEFAULT NULL COMMENT 'NULL = an alle im Dienst',
+ `kategorie` VARCHAR(32) NOT NULL
+ COMMENT 'einsatz/patient/labor/medikament/blut/op/doku/system',
+ `prioritaet` VARCHAR(16) NOT NULL DEFAULT 'normal',
+ `titel` VARCHAR(160) NOT NULL,
+ `text` VARCHAR(512) DEFAULT NULL,
+ `ziel_typ` VARCHAR(24) DEFAULT NULL COMMENT 'Wohin springt ein Klick',
+ `ziel_id` VARCHAR(64) DEFAULT NULL,
+ `gelesen` TINYINT(1) NOT NULL DEFAULT 0,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`, `gelesen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Protokoll — wer, wann, was, warum
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_protokoll` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED DEFAULT NULL,
+ `personalnummer` VARCHAR(16) DEFAULT NULL,
+ `modul` VARCHAR(32) NOT NULL,
+ `aktion` VARCHAR(64) NOT NULL,
+ `ziel_typ` VARCHAR(24) DEFAULT NULL,
+ `ziel_id` VARCHAR(64) DEFAULT NULL,
+ `details` LONGTEXT DEFAULT NULL COMMENT 'JSON: was genau geaendert wurde',
+ `grund` VARCHAR(255) DEFAULT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_modul` (`modul`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Systemeinstellungen (aktive Module, Vorlagen)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_einstellung` (
+ `schluessel` VARCHAR(64) NOT NULL,
+ `wert` LONGTEXT DEFAULT NULL,
+ PRIMARY KEY (`schluessel`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Vorlagen fuer Dokumentation (Administration pflegt sie)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `medical_vorlage` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `bereich` VARCHAR(24) NOT NULL COMMENT 'behandlung/bericht/op/labor',
+ `name` VARCHAR(120) NOT NULL,
+ `inhalt` LONGTEXT DEFAULT NULL COMMENT 'JSON: vorbelegte Felder',
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `sortierung` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/sql/aether_police.sql b/sql/aether_police.sql
new file mode 100644
index 0000000..06d5e57
--- /dev/null
+++ b/sql/aether_police.sql
@@ -0,0 +1,453 @@
+-- =====================================================================
+-- aether-police — Datenbankschema
+--
+-- Einspielen: mysql -u root -p aether < sql/aether_police.sql
+--
+-- Hinweis zum Grundprinzip: Keine dieser Tabellen enthaelt Automatismen.
+-- Fristen (z. B. Haftzeit) werden nur MITGEFUEHRT, damit das System
+-- erinnern kann. Ausgefuehrt wird ausschliesslich durch einen Beamten —
+-- erkennbar an den Feldern `entlassen_von` / `entlassen_am`.
+-- =====================================================================
+
+USE `aether`;
+
+-- ---------------------------------------------------------------------
+-- Personal: Zugehoerigkeit, Rolle und individuelle Rechte
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_personal` (
+ `character` INT UNSIGNED NOT NULL COMMENT 'Charakter aus aether_characters',
+ `dienstnummer` VARCHAR(16) NOT NULL COMMENT 'Dienstnummer, z. B. LSPD-0421',
+ `rolle` VARCHAR(32) NOT NULL DEFAULT 'anwaerter',
+ `abteilung` VARCHAR(32) NOT NULL DEFAULT 'streife' COMMENT 'streife/kripo/verkehr/sek/leitstelle',
+ `rechte_extra` LONGTEXT DEFAULT NULL COMMENT 'Zusaetzlich gewaehrte Rechte als JSON-Array',
+ `rechte_entzug` LONGTEXT DEFAULT NULL COMMENT 'Einzeln entzogene Rechte als JSON-Array',
+ `eingestellt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `notiz` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`character`),
+ UNIQUE KEY `uniq_dienstnummer` (`dienstnummer`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Dienstzeiten (nur Protokoll, keine Auswertung zur Bewertung)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_dienst` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL,
+ `beginn` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `ende` DATETIME DEFAULT NULL,
+ `sekunden` INT NOT NULL DEFAULT 0,
+ `abteilung` VARCHAR(32) NOT NULL DEFAULT 'streife',
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Personenakten — Stammdaten der Polizei zu einer Person
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_person` (
+ `character` INT UNSIGNED NOT NULL,
+ `lichtbild` LONGTEXT DEFAULT NULL COMMENT 'Bild-URL oder Data-URI',
+ `fingerabdruck` VARCHAR(64) DEFAULT NULL,
+ `dna` VARCHAR(64) DEFAULT NULL,
+ `warnhinweis` VARCHAR(255) DEFAULT NULL COMMENT 'z. B. gewaltbereit, bewaffnet',
+ `gefahrenstufe` TINYINT NOT NULL DEFAULT 0 COMMENT '0 = keine, 3 = hoch',
+ `aktualisiert` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Akten (Straf-, Ermittlungs-, Einsatz-, Unfallakte, Vernehmung ...)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_akte` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `aktenzeichen` VARCHAR(32) NOT NULL,
+ `typ` VARCHAR(24) NOT NULL COMMENT 'straf/ermittlung/einsatz/unfall/vernehmung/beschluss',
+ `titel` VARCHAR(160) NOT NULL,
+ `status` VARCHAR(24) NOT NULL DEFAULT 'offen' COMMENT 'offen/pruefung/geschlossen',
+ `inhalt` LONGTEXT DEFAULT NULL,
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `freigabe_von` INT UNSIGNED DEFAULT NULL COMMENT 'Wer hat freigegeben (nie automatisch)',
+ `freigabe_am` DATETIME DEFAULT NULL,
+ `version` INT NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_az` (`aktenzeichen`),
+ KEY `idx_typ` (`typ`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Versionshistorie jeder Akte (Dokumentenverwaltung)
+CREATE TABLE IF NOT EXISTS `police_akte_version` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `akte` INT UNSIGNED NOT NULL,
+ `version` INT NOT NULL,
+ `inhalt` LONGTEXT DEFAULT NULL,
+ `geaendert_von` INT UNSIGNED NOT NULL,
+ `geaendert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `grund` VARCHAR(255) DEFAULT NULL COMMENT 'Warum wurde geaendert',
+ PRIMARY KEY (`id`),
+ KEY `idx_akte` (`akte`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- Verknuepfungen: Akte <-> Person / Fahrzeug / Beweis / Einsatz
+CREATE TABLE IF NOT EXISTS `police_verknuepfung` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `akte` INT UNSIGNED NOT NULL,
+ `typ` VARCHAR(16) NOT NULL COMMENT 'person/fahrzeug/beweis/einsatz/bericht',
+ `ziel_id` VARCHAR(64) NOT NULL COMMENT 'ID oder Kennzeichen des Ziels',
+ `rolle` VARCHAR(32) DEFAULT NULL COMMENT 'beschuldigt/zeuge/geschaedigt/beteiligt',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_akte` (`akte`),
+ KEY `idx_ziel` (`typ`, `ziel_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Anzeigen — erfasste Delikte. Immer von einem Beamten bestaetigt.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_anzeige` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `akte` INT UNSIGNED DEFAULT NULL,
+ `beschuldigter` INT UNSIGNED NOT NULL,
+ `delikte` LONGTEXT NOT NULL COMMENT 'JSON: [{id,name,haft,geld,paragraph}]',
+ `haft_empfohlen` INT NOT NULL DEFAULT 0 COMMENT 'Vorschlag des Katalogs (Minuten)',
+ `geld_empfohlen` INT NOT NULL DEFAULT 0 COMMENT 'Vorschlag des Katalogs',
+ `haft_final` INT NOT NULL DEFAULT 0 COMMENT 'Vom Beamten festgelegt',
+ `geld_final` INT NOT NULL DEFAULT 0 COMMENT 'Vom Beamten festgelegt',
+ `begruendung` VARCHAR(512) DEFAULT NULL COMMENT 'Pflicht bei Abweichung vom Vorschlag',
+ `status` VARCHAR(16) NOT NULL DEFAULT 'entwurf' COMMENT 'entwurf/bestaetigt/verworfen',
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `bestaetigt_von` INT UNSIGNED DEFAULT NULL,
+ `bestaetigt_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_beschuldigter` (`beschuldigter`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Gewahrsam — reine Fristenverwaltung, KEINE automatische Entlassung
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_gewahrsam` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL,
+ `anzeige` INT UNSIGNED DEFAULT NULL,
+ `haft_minuten` INT NOT NULL COMMENT 'Vom Beamten festgelegte Dauer',
+ `beginn` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `faellig_am` DATETIME NOT NULL COMMENT 'Ab hier erinnert das System nur',
+ `grund` VARCHAR(512) NOT NULL DEFAULT '',
+ `zelle` VARCHAR(32) DEFAULT NULL,
+ `eingewiesen_von` INT UNSIGNED NOT NULL,
+ -- Entlassung geschieht ausschliesslich manuell:
+ `entlassen_von` INT UNSIGNED DEFAULT NULL,
+ `entlassen_am` DATETIME DEFAULT NULL,
+ `entlass_grund` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`),
+ KEY `idx_offen` (`entlassen_am`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Einsaetze und Dispatch
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_einsatz` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `nummer` VARCHAR(24) NOT NULL COMMENT 'Einsatznummer',
+ `titel` VARCHAR(160) NOT NULL,
+ `kategorie` VARCHAR(32) NOT NULL DEFAULT 'sonstiges',
+ `prioritaet` TINYINT NOT NULL DEFAULT 3 COMMENT '1 = hoechste',
+ `ort` VARCHAR(160) DEFAULT NULL,
+ `pos_x` FLOAT DEFAULT NULL,
+ `pos_y` FLOAT DEFAULT NULL,
+ `pos_z` FLOAT DEFAULT NULL,
+ `beschreibung` TEXT DEFAULT NULL,
+ `status` VARCHAR(24) NOT NULL DEFAULT 'offen' COMMENT 'offen/zugewiesen/vorort/abgeschlossen',
+ `quelle` VARCHAR(32) NOT NULL DEFAULT 'manuell' COMMENT 'manuell/notruf/panik/extern',
+ `erstellt_von` INT UNSIGNED DEFAULT NULL COMMENT 'NULL bei eingehendem Notruf',
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `geschlossen_von` INT UNSIGNED DEFAULT NULL,
+ `geschlossen_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_nummer` (`nummer`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `police_einsatz_beamter` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `einsatz` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `beigetreten` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `verlassen` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_einsatz` (`einsatz`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `police_einsatz_notiz` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `einsatz` INT UNSIGNED NOT NULL,
+ `text` VARCHAR(1000) NOT NULL,
+ `von` INT UNSIGNED NOT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_einsatz` (`einsatz`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Fahndungen — immer manuell angelegt
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_fahndung` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `typ` VARCHAR(16) NOT NULL DEFAULT 'person' COMMENT 'person/fahrzeug',
+ `ziel_id` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID oder Kennzeichen',
+ `bezeichnung` VARCHAR(160) NOT NULL,
+ `grund` VARCHAR(512) NOT NULL,
+ `gefahr` VARCHAR(24) NOT NULL DEFAULT 'normal' COMMENT 'normal/bewaffnet/gefaehrlich',
+ `prioritaet` TINYINT NOT NULL DEFAULT 3,
+ `hinweise` TEXT DEFAULT NULL,
+ `bild` LONGTEXT DEFAULT NULL,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `aufgehoben_von` INT UNSIGNED DEFAULT NULL,
+ `aufgehoben_am` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_ziel` (`typ`, `ziel_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Beweise
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_beweis` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(32) NOT NULL COMMENT 'Beweismittelnummer',
+ `akte` INT UNSIGNED DEFAULT NULL,
+ `art` VARCHAR(24) NOT NULL COMMENT 'foto/video/fingerabdruck/dna/huelse/dokument/aussage/gegenstand',
+ `bezeichnung` VARCHAR(160) NOT NULL,
+ `beschreibung` TEXT DEFAULT NULL,
+ `fundort` VARCHAR(160) DEFAULT NULL,
+ `gefunden_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `datei` LONGTEXT DEFAULT NULL COMMENT 'Bild-/Video-URL',
+ `erfasst_von` INT UNSIGNED NOT NULL,
+ `erfasst_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`),
+ KEY `idx_akte` (`akte`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Asservatenkammer
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_asservat` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(32) NOT NULL,
+ `kategorie` VARCHAR(24) NOT NULL COMMENT 'waffe/drogen/bargeld/schmuck/datentraeger/dokument/sonstiges',
+ `bezeichnung` VARCHAR(160) NOT NULL,
+ `menge` VARCHAR(48) DEFAULT NULL,
+ `lagerort` VARCHAR(64) NOT NULL DEFAULT 'Asservatenkammer A',
+ `verantwortlich` INT UNSIGNED NOT NULL,
+ `akte` INT UNSIGNED DEFAULT NULL,
+ `beweis` INT UNSIGNED DEFAULT NULL,
+ `status` VARCHAR(24) NOT NULL DEFAULT 'eingelagert' COMMENT 'eingelagert/ausgegeben/vernichtet/zurueckgegeben',
+ `eingelagert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS `police_asservat_historie` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `asservat` INT UNSIGNED NOT NULL,
+ `aktion` VARCHAR(48) NOT NULL,
+ `von` INT UNSIGNED NOT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `notiz` VARCHAR(255) DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_asservat` (`asservat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Dienstfahrzeuge
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_fahrzeug` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennzeichen` VARCHAR(16) NOT NULL,
+ `modell` VARCHAR(48) NOT NULL,
+ `bezeichnung` VARCHAR(64) NOT NULL,
+ `abteilung` VARCHAR(32) NOT NULL DEFAULT 'streife',
+ `zugewiesen_an` INT UNSIGNED DEFAULT NULL COMMENT 'Manuell zugewiesen',
+ `letzter_fahrer` INT UNSIGNED DEFAULT NULL,
+ `tank` FLOAT NOT NULL DEFAULT 100,
+ `zustand` FLOAT NOT NULL DEFAULT 1000,
+ `wartung` VARCHAR(24) NOT NULL DEFAULT 'ok' COMMENT 'ok/faellig/werkstatt',
+ `status` VARCHAR(24) NOT NULL DEFAULT 'verfuegbar',
+ `pos_x` FLOAT DEFAULT NULL,
+ `pos_y` FLOAT DEFAULT NULL,
+ `pos_z` FLOAT DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennzeichen` (`kennzeichen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Berichte
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_bericht` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `nummer` VARCHAR(32) NOT NULL,
+ `einsatz` INT UNSIGNED DEFAULT NULL,
+ `akte` INT UNSIGNED DEFAULT NULL,
+ `titel` VARCHAR(160) NOT NULL,
+ `beschreibung` LONGTEXT DEFAULT NULL,
+ `massnahmen` LONGTEXT DEFAULT NULL,
+ `beteiligte` LONGTEXT DEFAULT NULL COMMENT 'JSON',
+ `anlagen` LONGTEXT DEFAULT NULL COMMENT 'JSON',
+ `status` VARCHAR(24) NOT NULL DEFAULT 'entwurf' COMMENT 'entwurf/eingereicht/freigegeben',
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `unterschriften` LONGTEXT DEFAULT NULL COMMENT 'JSON: wer hat unterschrieben',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_nummer` (`nummer`),
+ KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Interne Kommunikation
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_nachricht` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `absender` INT UNSIGNED NOT NULL,
+ `empfaenger` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID, Abteilung oder "alle"',
+ `empf_typ` VARCHAR(16) NOT NULL DEFAULT 'person' COMMENT 'person/abteilung/rundmeldung',
+ `betreff` VARCHAR(160) DEFAULT NULL,
+ `text` TEXT NOT NULL,
+ `gelesen` TINYINT(1) NOT NULL DEFAULT 0,
+ `gesendet` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_empfaenger` (`empfaenger`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Benachrichtigungen (Hinweise, Erinnerungen, Fristen)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_hinweis` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED DEFAULT NULL COMMENT 'NULL = an alle im Dienst',
+ `kategorie` VARCHAR(32) NOT NULL COMMENT 'einsatz/fahndung/gewahrsam/bericht/akte/beweis/system',
+ `prioritaet` VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT 'niedrig/normal/hoch/kritisch',
+ `titel` VARCHAR(160) NOT NULL,
+ `text` VARCHAR(512) DEFAULT NULL,
+ `ziel_typ` VARCHAR(24) DEFAULT NULL COMMENT 'Wohin springt ein Klick',
+ `ziel_id` VARCHAR(64) DEFAULT NULL,
+ `gelesen` TINYINT(1) NOT NULL DEFAULT 0,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`, `gelesen`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Protokollierung — wer, wann, was, warum
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_protokoll` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED DEFAULT NULL,
+ `dienstnummer` VARCHAR(16) DEFAULT NULL,
+ `modul` VARCHAR(32) NOT NULL,
+ `aktion` VARCHAR(64) NOT NULL,
+ `ziel_typ` VARCHAR(24) DEFAULT NULL,
+ `ziel_id` VARCHAR(64) DEFAULT NULL,
+ `details` LONGTEXT DEFAULT NULL COMMENT 'JSON: was genau geaendert wurde',
+ `grund` VARCHAR(255) DEFAULT NULL,
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_modul` (`modul`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Strafenkatalog — vom Administrator pflegbar
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_katalog` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `kennung` VARCHAR(24) NOT NULL,
+ `kategorie` VARCHAR(48) NOT NULL,
+ `bezeichnung` VARCHAR(160) NOT NULL,
+ `paragraph` VARCHAR(48) DEFAULT NULL COMMENT 'Gesetzliche Grundlage',
+ `haft` INT NOT NULL DEFAULT 0 COMMENT 'Empfehlung in Minuten',
+ `geld` INT NOT NULL DEFAULT 0 COMMENT 'Empfehlung in Euro',
+ `hinweis` VARCHAR(512) DEFAULT NULL,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ `sortierung` INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_kennung` (`kennung`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Systemeinstellungen (aktive Module, Vorlagen, Kategorien)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_einstellung` (
+ `schluessel` VARCHAR(64) NOT NULL,
+ `wert` LONGTEXT DEFAULT NULL,
+ PRIMARY KEY (`schluessel`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Notizen zu einer Person (kurze Vermerke ausserhalb einer Akte)
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_person_notiz` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `character` INT UNSIGNED NOT NULL COMMENT 'Betroffene Person',
+ `text` VARCHAR(1000) NOT NULL,
+ `von` INT UNSIGNED NOT NULL COMMENT 'Verfassender Beamter',
+ `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_character` (`character`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Lesestatus einer Nachricht je Empfaenger
+-- Noetig, weil Abteilungs- und Rundmeldungen viele Empfaenger haben.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_nachricht_gelesen` (
+ `nachricht` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `gelesen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`nachricht`, `character`),
+ CONSTRAINT `fk_nlesen_nachricht` FOREIGN KEY (`nachricht`)
+ REFERENCES `police_nachricht` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Aushaenge und Dienstanweisungen
+-- Das System veroeffentlicht nichts von selbst und setzt nichts auf
+-- gelesen — die Kenntnisnahme bestaetigt jeder Beamte eigenhaendig.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_aushang` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `titel` VARCHAR(160) NOT NULL,
+ `text` TEXT NOT NULL,
+ `kategorie` VARCHAR(24) NOT NULL DEFAULT 'information'
+ COMMENT 'anweisung/information/warnung',
+ `abteilung` VARCHAR(32) DEFAULT NULL COMMENT 'NULL = gesamte Behoerde',
+ `angeheftet` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Bleibt oben stehen',
+ `kenntnispflicht` TINYINT(1) NOT NULL DEFAULT 0
+ COMMENT 'Beamte sollen die Kenntnisnahme bestaetigen',
+ `frist` DATETIME DEFAULT NULL
+ COMMENT 'Bis wann erinnert das System an die Kenntnisnahme',
+ `erstellt_von` INT UNSIGNED NOT NULL,
+ `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ -- Archivierung geschieht ausschliesslich manuell:
+ `archiviert_von` INT UNSIGNED DEFAULT NULL,
+ `archiviert_am` DATETIME DEFAULT NULL,
+ `aktiv` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ KEY `idx_aktiv` (`aktiv`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ---------------------------------------------------------------------
+-- Bestaetigte Kenntnisnahmen zu einem Aushang
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `police_aushang_kenntnis` (
+ `aushang` INT UNSIGNED NOT NULL,
+ `character` INT UNSIGNED NOT NULL,
+ `bestaetigt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`aushang`, `character`),
+ CONSTRAINT `fk_kenntnis_aushang` FOREIGN KEY (`aushang`)
+ REFERENCES `police_aushang` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/sql/updates/001_fahrzeug_position.sql b/sql/updates/001_fahrzeug_position.sql
new file mode 100644
index 0000000..009cd72
--- /dev/null
+++ b/sql/updates/001_fahrzeug_position.sql
@@ -0,0 +1,17 @@
+-- =====================================================================
+-- Nachtrag 001 — Position ausgeparkter Fahrzeuge
+--
+-- Nur noetig, wenn die Tabelle `aether_vehicles` bereits VOR dieser
+-- Aenderung angelegt wurde. Bei einer frischen Datenbank sind die
+-- Spalten schon in sql/aether.sql enthalten.
+--
+-- Einspielen: mysql -u root -p aether < sql/updates/001_fahrzeug_position.sql
+-- Hinweis: Laeuft die Abfrage mit "Duplicate column name", sind die
+-- Spalten bereits vorhanden — dann ist nichts zu tun.
+-- =====================================================================
+
+ALTER TABLE `aether_vehicles`
+ ADD COLUMN `pos_x` FLOAT DEFAULT NULL COMMENT 'Letzte bekannte Position',
+ ADD COLUMN `pos_y` FLOAT DEFAULT NULL,
+ ADD COLUMN `pos_z` FLOAT DEFAULT NULL,
+ ADD COLUMN `heading` FLOAT DEFAULT NULL;
diff --git a/untitled.md b/untitled.md
new file mode 100644
index 0000000..e69de29