/* ===================================================================== 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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} */ 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) => `` ).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) => ``).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 = `
Lade …
`; 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) => ``).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) => ``).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) => ``).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();