aether-framework/resources/[aether]/aether-medical/html/js/app.js
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

629 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =====================================================================
aether-medical — Oberflächenkern
Der Kern kennt keine Medizin. Er bringt die Hülle mit (Seitenleiste,
Kopfleiste, Dialoge, Mitteilungen) und ruft Module auf, die sich
selbst über `App.registriereAnsicht(...)` anmelden.
Läuft die Datei im Browser statt in FiveM, schaltet sie in eine
Vorschau um — so bleibt die Oberfläche eigenständig ansehbar.
===================================================================== */
"use strict";
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-medical";
const App = (() => {
/* ---- Zustand ------------------------------------------------------ */
const zustand = {
imDienst: false,
mitarbeiter: null,
module: [],
aktivesModul: null,
modulZiel: null, // Sprungziel für die geöffnete Ansicht
hinweise: [],
dienstliste: [],
status: [],
funktionen: [],
stationen: [],
};
// Registrierte Modulansichten: [modulId] = { rendern, aktualisieren? }
const ansichten = {};
// Anbieter für die globale Suche
const suchAnbieter = [];
// Offene Aufrufe an den Server: [ruf] = { aufloesen, ablehnen }
const offeneRufe = {};
let rufZaehler = 0;
const $ = (id) => document.getElementById(id);
/* ---- Kleine Helfer ------------------------------------------------ */
/** Maskiert Text, damit er gefahrlos in HTML landen kann */
function sicher(text) {
if (text === null || text === undefined) return "";
return String(text).replace(/[&<>"']/g,
(z) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[z]));
}
/** Formatiert einen Zeitpunkt aus der Datenbank */
function zeit(wert) {
if (!wert) return "—";
const d = new Date(String(wert).replace(" ", "T"));
if (isNaN(d)) return sicher(wert);
return d.toLocaleString("de-DE",
{ day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
/** Formatiert eine Dauer in Sekunden */
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 Minuten als lesbare Wartezeit */
function minuten(wert) {
const m = Math.max(0, Math.floor(wert || 0));
if (m < 60) return m + " Min";
return Math.floor(m / 60) + " Std " + (m % 60) + " Min";
}
/** Formatiert einen Betrag als Euro */
function euro(betrag) {
return (Math.round(betrag || 0)).toLocaleString("de-DE",
{ style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
/** Schickt etwas an den FiveM-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 || {}),
}).then((a) => a.json()).catch(() => ({}));
}
/* ---- Aufruf an ein Servermodul ------------------------------------ */
/**
* Ruft eine Modulaktion auf und wartet auf die Antwort.
* @param {string} modul Modulkennung
* @param {string} aktion Name der Aktion
* @param {object} daten Nutzdaten
* @returns {Promise<object>}
*/
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) =>
`<button class="knopf ${k.art || "leise"}" data-k="${i}">${sicher(k.text)}</button>`
).join("");
hg.innerHTML =
`<div class="fenster">
<div class="fenster-kopf">
<h3>${sicher(o.titel || "")}</h3>
<button class="knopf leise klein" data-zu="1">✕</button>
</div>
<div class="fenster-koerper">${o.inhalt || ""}</div>
${knoepfe ? `<div class="fenster-fuss">${knoepfe}</div>` : ""}
</div>`;
ebene.appendChild(hg);
const schliessen = () => hg.remove();
hg.querySelector("[data-zu]").addEventListener("click", schliessen);
hg.addEventListener("click", (e) => { if (e.target === hg) schliessen(); });
hg.querySelectorAll("[data-k]").forEach((b) => {
b.addEventListener("click", () => {
const eintrag = o.knoepfe[+b.dataset.k];
// Der Rückruf entscheidet selbst, ob das Fenster zugeht
if (eintrag.klick) eintrag.klick(hg, schliessen);
else schliessen();
});
});
if (o.nachOeffnen) o.nachOeffnen(hg);
return { element: hg, schliessen };
}
/**
* Fenster mit Bestätigungspflicht.
* Für jede Maßnahme, die ein Mediziner selbst verantworten muss.
*/
function bestaetigen(o) {
return fenster({
titel: o.titel,
inhalt: `${o.inhalt || ""}
<div class="grundsatz">
<span>ℹ</span>
<div><b>Diese Entscheidung triffst du.</b> Das System hat den Vorgang nur
vorbereitet — dokumentiert wird er erst mit deiner Bestätigung.</div>
</div>`,
knoepfe: [
{ text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
{ text: o.bestaetigenText || "Bestätigen", art: o.gefahr ? "gefahr" : "",
klick: (el, zu) => { if (o.bestaetigt) o.bestaetigt(el, zu); } },
],
nachOeffnen: o.nachOeffnen,
});
}
/* ---- Navigation ---------------------------------------------------- */
function zeichneNavigation() {
const nav = $("navigation");
nav.innerHTML = zustand.module.map((m) =>
`<button class="${zustand.aktivesModul === m.id ? "aktiv" : ""}"
data-modul="${sicher(m.id)}" title="${sicher(m.beschreibung || m.name)}">
<span class="nav-icon">${sicher(m.icon)}</span>
<span class="nav-text">${sicher(m.name)}</span>
</button>`).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 = `<div class="laden"><div class="spinner"></div><span>Lade …</span></div>`;
try {
await ansicht.rendern(behaelter);
} catch (fehler) {
behaelter.innerHTML =
`<div class="seiten-kopf"><div><h2>Fehler</h2>
<p>${sicher(fehler.message)}</p></div></div>`;
}
}
/* ---- 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) =>
`<button data-status="${sicher(s.id)}">
<span class="status-punkt" style="background:${sicher(s.farbe)}"></span>
${sicher(s.name)}
</button>`).join("");
$("status-liste").querySelectorAll("[data-status]").forEach((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) =>
`<button data-funktion="${sicher(f.id)}">${sicher(f.name)}</button>`).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) =>
`<div class="hinweis-eintrag ${h.gelesen ? "" : "ungelesen"} prio-${sicher(h.prioritaet || "normal")}"
data-hinweis="${h.id || ""}">
<div class="he-kopf">
<span class="he-titel">${symbole[h.kategorie] || "•"} ${sicher(h.titel)}</span>
<span class="he-zeit">${zeit(h.erstellt_am)}</span>
</div>
${h.text ? `<div class="he-text">${sicher(h.text)}</div>` : ""}
</div>`
).join("")
: `<div class="liste-leer">Keine Mitteilungen.</div>`;
$("hinweise-inhalt").querySelectorAll("[data-hinweis]").forEach((el) => {
el.addEventListener("click", () => {
const id = +el.dataset.hinweis;
if (!id) return;
const treffer = zustand.hinweise.find((h) => h.id === id);
if (treffer) treffer.gelesen = 1;
aufruf("dashboard", "hinweisGelesen", { id }).catch(() => {});
zeichneHinweise();
if (treffer) springeZuHinweis(treffer);
});
});
}
/**
* Öffnet das Modul, zu dem ein Hinweis gehört.
* Nur Ziele, die eine Ansicht auch auswertet, werden mitgegeben —
* alles andere öffnet schlicht das passende Modul.
*/
function springeZuHinweis(hinweis) {
if (!hinweis.zielTyp) return;
// zielTyp → [Modul, Schlüssel des Sprungziels oder null]
const wege = {
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) =>
`<button class="an-funktion-knopf ${i === 0 ? "gewaehlt" : ""}"
data-funktion="${sicher(f.id)}">${sicher(f.name)}</button>`).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: `<p>Du meldest dich vom Dienst ab. Laufende Behandlungen und
Dokumentationen bleiben offen — sie werden nicht abgeschlossen.</p>`,
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
? `<div class="liste">${treffer.map((t) =>
`<div class="liste-zeile">
<span class="marker neutral">${sicher(t.bereich)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(t.titel)}</div>
<div class="lz-unter">${sicher(t.unter || "")}</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine Treffer.</div>`,
});
}
/* ---- Öffentliche Schnittstelle ------------------------------------------ */
return {
/** Registriert eine Modulansicht */
registriereAnsicht(modulId, ansicht) { ansichten[modulId] = ansicht; },
/** Registriert einen Anbieter für die globale Suche */
registriereSuche,
zustand, aufruf, meldung, fenster, bestaetigen,
sicher, zeit, dauer, 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();