aether-framework/resources/[aether]/aether-police/html/js/module/einsatz.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

484 lines
20 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-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 `
<div class="einsatz-karte ${e.status === "offen" ? "offen" : ""}" data-einsatz="${e.id}">
<span class="prio p${Math.min(4, e.prioritaet || 3)}"></span>
<div class="ek-haupt">
<div class="ek-titel">
${sicher(e.titel)}
${e.ichDabei > 0 ? '<span class="marker gut">Du bist dabei</span>' : ""}
</div>
<div class="ek-unter">
<span class="ek-kategorie" style="--kf:${kategorieFarbe(e.kategorie)}">
${sicher(kategorieName(e.kategorie))}
</span>
${sicher(e.nummer)} · ${sicher(e.ort || "Ort unbekannt")} · ${zeit(e.erstellt_am)}
${e.quelle !== "manuell" ? " · " + sicher(quelleText[e.quelle] || e.quelle) : ""}
</div>
</div>
<div class="ek-rechts">
<span class="marker ${statusArt[e.status]}">${sicher(statusText[e.status] || e.status)}</span>
<span class="ek-einheiten">${e.einheiten || 0} 🚓</span>
</div>
</div>`;
}
function einheitenListe(beamte) {
if (!beamte.length) return `<div class="liste-leer">Niemand im Dienst.</div>`;
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 `<div class="liste">${beamte.map((b) =>
`<div class="liste-zeile">
<span class="marker ${farbe[b.status] || "neutral"}">${sicher(b.dienstnummer)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(b.name)}
${b.leitstelle ? '<span class="marker info">Leitstelle</span>' : ""}</div>
<div class="lz-unter">${sicher(text[b.status] || b.status)} · ${sicher(b.abteilung)}</div>
</div>
</div>`).join("")}</div>`;
}
/* ---- Ü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 = `
<div class="seiten-kopf">
<div>
<h2>Einsätze & Dispatch</h2>
<p>${d.istLeitstelle
? "Du führst die Leitstelle — du entscheidest über die Zuweisung."
: "Einsätze werden nicht automatisch verteilt. Tritt bei, wenn du übernimmst."}</p>
</div>
<div style="display:flex;gap:8px">
<button class="knopf leise klein" id="e-filter">
${z.nurOffene ? "Auch abgeschlossene" : "Nur laufende"}
</button>
<button class="knopf klein" id="e-neu">+ Einsatz anlegen</button>
</div>
</div>
<div class="dispatch">
<div class="dispatch-spalte">
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Offen — keine Einheit zugeordnet</span>
<span class="marker ${offene.length ? "kritisch" : "neutral"}">${offene.length}</span>
</div>
${offene.length ? offene.map(einsatzKarte).join("")
: `<div class="liste-leer">Keine offenen Einsätze.</div>`}
</div>
<div class="karte" style="margin-top:14px">
<div class="karte-kopf">
<span class="karte-titel">In Bearbeitung</span>
<span class="marker neutral">${laufende.length}</span>
</div>
${laufende.length ? laufende.map(einsatzKarte).join("")
: `<div class="liste-leer">Keine laufenden Einsätze.</div>`}
</div>
${!z.nurOffene ? `
<div class="karte" style="margin-top:14px">
<div class="karte-kopf">
<span class="karte-titel">Abgeschlossen</span>
<span class="marker neutral">${erledigt.length}</span>
</div>
${erledigt.length ? erledigt.map(einsatzKarte).join("")
: `<div class="liste-leer">Nichts abgeschlossen.</div>`}
</div>` : ""}
</div>
<div class="dispatch-spalte schmal">
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Einheiten im Dienst</span>
<span class="marker neutral">${d.beamteImDienst.length}</span>
</div>
${einheitenListe(d.beamteImDienst)}
<div class="grundsatz">
<span>ℹ</span>
<div>Eine Zuweisung ist eine <b>Aufforderung</b>, kein Befehl an das System.
Die Einheit entscheidet selbst, ob sie den Einsatz übernimmt.</div>
</div>
</div>
</div>
</div>`;
$("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 = `
<div class="seiten-kopf">
<div>
<h2>${sicher(e.titel)}</h2>
<p>${sicher(e.nummer)} · ${sicher(kategorieName(e.kategorie))} ·
${sicher(quelleText[e.quelle] || e.quelle)} · ${zeit(e.erstellt_am)}</p>
</div>
<button class="knopf leise klein" id="e-zurueck">‹ Zurück</button>
</div>
<div class="dispatch">
<div class="dispatch-spalte">
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Lage</span>
<span class="marker ${statusArt[e.status]}">${sicher(statusText[e.status] || e.status)}</span>
</div>
<div class="akte-zeilen" style="grid-template-columns:auto 1fr">
<span>Priorität</span><b>${sicher(e.prioritaet)} ${e.prioritaet === 1 ? "(höchste)" : ""}</b>
<span>Ort</span><b>${sicher(e.ort || "unbekannt")}</b>
<span>Gemeldet von</span><b>${e.erstellt_von
? sicher((e.ersteller_vorname || "") + " " + (e.ersteller_nachname || ""))
: sicher(quelleText[e.quelle] || "extern")}</b>
</div>
${e.beschreibung ? `<div class="akte-inhalt" style="margin-top:12px">${sicher(e.beschreibung).replace(/\n/g, "<br>")}</div>` : ""}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px">
${e.pos_x ? `<button class="knopf leise klein" id="e-route">📍 Route setzen</button>` : ""}
${aktiv && !e.ichDabei ? `<button class="knopf klein" id="e-beitreten">Einsatz übernehmen</button>` : ""}
${aktiv && e.ichDabei ? `<button class="knopf leise klein" id="e-verlassen">Einsatz verlassen</button>` : ""}
${aktiv && e.ichDabei ? `<button class="knopf leise klein" id="e-vorort">Status: Vor Ort</button>` : ""}
${aktiv && d.rechte.schliessen ? `<button class="knopf leise klein" id="e-schliessen">Abschließen</button>` : ""}
</div>
</div>
<div class="karte" style="margin-top:14px">
<div class="karte-kopf">
<span class="karte-titel">Einsatzverlauf</span>
${aktiv ? `<button class="knopf leise klein" id="e-notiz">+ Notiz</button>` : ""}
</div>
${(e.notizen || []).length ? `<div class="liste">${e.notizen.map((n) =>
`<div class="liste-zeile">
<span class="marker neutral">${zeit(n.am).slice(-5)}</span>
<div class="lz-haupt">
<div class="lz-titel" style="font-weight:600">${sicher(n.text)}</div>
<div class="lz-unter">${sicher(n.dienstnummer || "")}
${sicher((n.firstname || "") + " " + (n.lastname || ""))}</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Noch keine Einträge.</div>`}
</div>
</div>
<div class="dispatch-spalte schmal">
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">Beteiligte Einheiten</span>
<span class="marker neutral">${beteiligt.length}</span>
</div>
${beteiligt.length ? `<div class="liste">${beteiligt.map((b) =>
`<div class="liste-zeile">
<span class="marker info">${sicher(b.dienstnummer || "—")}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher((b.firstname || "") + " " + (b.lastname || ""))}</div>
<div class="lz-unter">seit ${zeit(b.beigetreten)}</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine Einheit vor Ort.</div>`}
</div>
${aktiv && d.rechte.zuweisen ? `
<div class="karte" style="margin-top:14px">
<div class="karte-kopf"><span class="karte-titel">Einheit anfordern</span></div>
<div class="liste">${d.beamteImDienst.map((b) =>
`<div class="liste-zeile">
<span class="marker neutral">${sicher(b.dienstnummer)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(b.name)}</div>
<div class="lz-unter">${sicher(b.status)}</div>
</div>
<button class="knopf leise klein" data-zuweisen="${b.quelle}">Anfordern</button>
</div>`).join("")}</div>
<div class="grundsatz">
<span>ℹ</span>
<div>Die Einheit erhält eine <b>Anfrage</b> mit Wegpunkt. Ob sie übernimmt,
entscheidet sie selbst.</div>
</div>
</div>` : ""}
</div>
</div>`;
/* ---- 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: `<div class="feld"><label>Eintrag</label>
<textarea id="en-text" placeholder="Was ist geschehen?"></textarea>
<div class="feld-hinweis">Der Verlauf hilft später beim Bericht.</div></div>`,
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: `
<p style="margin:0 0 12px;font-size:13px;line-height:1.6">
<b>${sicher(e.nummer)} — ${sicher(e.titel)}</b><br>
Alle Beteiligungen werden beendet. Es wird festgehalten, dass du den Einsatz
abgeschlossen hast.
</p>
<div class="feld"><label>Abschlussvermerk</label>
<input id="es-grund" placeholder="Ergebnis in einem Satz"></div>
${d.rechte.bericht ? `
<label style="display:flex;gap:9px;align-items:flex-start;font-size:12.5px;cursor:pointer">
<input type="checkbox" id="es-bericht" checked style="margin-top:2px">
<span>Berichtsentwurf vorbereiten — Titel, Beteiligte und der Einsatzverlauf
werden übernommen. Der Bericht bleibt ein <b>Entwurf</b>, bis du ihn ausarbeitest.</span>
</label>` : ""}`,
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: `
<div class="feld"><label>Titel</label>
<input id="ne-titel" placeholder="Kurz und eindeutig"></div>
<div class="feld"><label>Kategorie</label>
<select id="ne-kategorie">
${kategorien.map((k) => `<option value="${sicher(k.id)}">${sicher(k.name)}</option>`).join("")}
</select></div>
<div class="feld"><label>Priorität</label>
<select id="ne-prio">
<option value="1">1 — höchste (sofort)</option>
<option value="2">2 — hoch</option>
<option value="3" selected>3 — normal</option>
<option value="4">4 — niedrig</option>
</select></div>
<div class="feld"><label>Ort</label>
<input id="ne-ort" placeholder="Straße, Bezirk oder Objekt"></div>
<div class="feld"><label>Beschreibung</label>
<textarea id="ne-text" placeholder="Was ist bekannt?"></textarea></div>
<label style="display:flex;gap:9px;align-items:center;font-size:12.5px;cursor:pointer">
<input type="checkbox" id="ne-hier" checked>
<span>Meine aktuelle Position als Einsatzort übernehmen</span>
</label>`,
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 =
`<div class="seiten-kopf"><div><h2>Einsätze</h2>
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
}
}
/* ---- 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: `<p style="margin:0;font-size:13px;line-height:1.6">
<b>${sicher(d.von)}</b> bittet dich um Übernahme:<br><br>
<b>${sicher(d.nummer)} — ${sicher(d.titel)}</b><br>
${d.ort ? "Ort: " + sicher(d.ort) : "Ort unbekannt"}<br><br>
Ein Wegpunkt wurde bereits gesetzt.</p>`,
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"));
},
});
})();