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

459 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: 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 `
<div class="fahndung-karte gefahr-${sicher(f.gefahr)}" data-fahndung="${f.id}">
<div class="fk-bild">
${f.bild ? `<img src="${sicher(f.bild)}" alt="">`
: `<span class="fk-symbol">${f.typ === "person" ? "👤" : "🚗"}</span>`}
</div>
<div class="fk-haupt">
<div class="fk-titel">${sicher(f.bezeichnung)}</div>
<div class="fk-grund">${sicher(f.grund)}</div>
<div class="fk-meta">
${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 || ""))}` : ""}
</div>
</div>
<div class="fk-rechts">
<span class="marker ${gefahrArt[f.gefahr]}">${sicher(gefahrText[f.gefahr] || f.gefahr)}</span>
<span class="fk-prio">Prio ${sicher(prioText[f.prioritaet] || f.prioritaet)}</span>
</div>
</div>`;
}
function uebersicht(behaelter) {
const d = z.daten;
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Fahndungen</h2>
<p>Werden von Beamten ausgeschrieben — und nur von Beamten wieder aufgehoben.</p>
</div>
${d.rechte.erstellen ? `<button class="knopf klein" id="fa-neu">+ Fahndung ausschreiben</button>` : ""}
</div>
<div class="raster k3" style="margin-bottom:14px">
<div class="kennzahl"><div class="kz-titel">Normal</div>
<div class="kz-wert">${d.bestand.normal || 0}</div></div>
<div class="kennzahl warn"><div class="kz-titel">Bewaffnet</div>
<div class="kz-wert">${d.bestand.bewaffnet || 0}</div></div>
<div class="kennzahl hoch"><div class="kz-titel">Gefährlich</div>
<div class="kz-wert">${d.bestand.gefaehrlich || 0}</div></div>
</div>
<div class="mdt-suchleiste" style="margin-bottom:14px">
<input id="fa-suche" type="search" placeholder="Bezeichnung oder Grund …"
value="${sicher(z.suche)}">
<select id="fa-typ" class="katalog-suche">
<option value="">Alle Arten</option>
<option value="person" ${z.filterTyp === "person" ? "selected" : ""}>Personen</option>
<option value="fahrzeug" ${z.filterTyp === "fahrzeug" ? "selected" : ""}>Fahrzeuge</option>
</select>
<select id="fa-gefahr" class="katalog-suche">
<option value="">Alle Stufen</option>
<option value="normal" ${z.filterGefahr === "normal" ? "selected" : ""}>Normal</option>
<option value="bewaffnet" ${z.filterGefahr === "bewaffnet" ? "selected" : ""}>Bewaffnet</option>
<option value="gefaehrlich" ${z.filterGefahr === "gefaehrlich" ? "selected" : ""}>Gefährlich</option>
</select>
<button class="knopf leise klein" id="fa-archiv">
${z.archiv ? "Aktive zeigen" : "Archiv"}
</button>
</div>
<div class="karte">
${d.fahndungen.length
? `<div class="fahndungen">${d.fahndungen.map(fahndungKarte).join("")}</div>`
: `<div class="liste-leer">${z.archiv ? "Keine aufgehobenen Fahndungen." : "Keine aktiven Fahndungen."}</div>`}
</div>`;
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 = `
<div class="seiten-kopf">
<div>
<h2>${f.typ === "person" ? "👤" : "🚗"} ${sicher(f.bezeichnung)}</h2>
<p>Fahndung #${f.id} · ${sicher(gefahrText[f.gefahr] || f.gefahr)}
· Priorität ${sicher(prioText[f.prioritaet] || f.prioritaet)}</p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="knopf leise klein" id="fa-zurueck">‹ Zurück</button>
${aktiv && d.rechte.bearbeiten
? `<button class="knopf leise klein" id="fa-bearbeiten">✎ Bearbeiten</button>` : ""}
${aktiv && d.rechte.aufheben
? `<button class="knopf klein" id="fa-aufheben">Fahndung aufheben</button>` : ""}
</div>
</div>
${!aktiv ? `
<div class="warnung niedrig">
<span class="w-symbol">✓</span>
<span>Diese Fahndung wurde am ${zeit(f.aufgehoben_am)} von
<b>${sicher((f.a_vorname || "") + " " + (f.a_nachname || ""))}</b> aufgehoben.</span>
</div>`
: f.gefahr !== "normal" ? `
<div class="warnung hoch">
<span class="w-symbol">⚠</span>
<span><b>Gefahrenstufe ${sicher(gefahrText[f.gefahr])}.</b>
${f.gefahr === "gefaehrlich"
? "Zugriff nur mit ausreichender Verstärkung."
: "Es ist mit Bewaffnung zu rechnen."}</span>
</div>` : ""}
<div class="raster k2" style="margin-top:14px">
<div class="karte">
<div class="karte-kopf"><span class="karte-titel">Fahndungsgrund</span></div>
<div class="akte-inhalt">${sicher(f.grund).replace(/\n/g, "<br>")}</div>
${f.hinweise ? `
<div class="karte-titel" style="margin-top:16px">Hinweise für die Streife</div>
<div class="akte-inhalt" style="margin-top:8px">${sicher(f.hinweise).replace(/\n/g, "<br>")}</div>
` : ""}
<div class="akte-zeilen" style="grid-template-columns:auto 1fr;margin-top:16px">
<span>Ausgeschrieben</span><b>${sicher(f.dienstnummer || "")}
${sicher((f.firstname || "") + " " + (f.lastname || ""))}</b>
<span>Am</span><b>${zeit(f.erstellt_am)}</b>
</div>
</div>
<div class="karte">
<div class="karte-kopf">
<span class="karte-titel">${f.typ === "person" ? "Gesuchte Person" : "Gesuchtes Fahrzeug"}</span>
${ziel && f.typ === "person"
? `<button class="knopf leise klein" data-person="${sicher(f.ziel_id)}">Akte öffnen</button>` : ""}
${ziel && f.typ === "fahrzeug"
? `<button class="knopf leise klein" data-fahrzeug="${sicher(f.ziel_id)}">Halterdaten</button>` : ""}
</div>
${!ziel ? `<div class="liste-leer">Das Ziel ist nicht mehr im System auffindbar.</div>`
: f.typ === "person" ? `
<div style="display:flex;gap:14px">
<div class="akte-bild" style="width:90px;height:112px">
${ziel.lichtbild ? `<img src="${sicher(ziel.lichtbild)}" alt="">`
: `<span class="kein-bild">Kein Lichtbild</span>`}
</div>
<div class="akte-zeilen" style="grid-template-columns:auto 1fr;flex:1">
<span>Name</span><b>${sicher(ziel.firstname + " " + ziel.lastname)}</b>
<span>Geboren</span><b>${sicher(String(ziel.dob || "").slice(0, 10))}</b>
<span>Warnhinweis</span><b>${sicher(ziel.warnhinweis || "—")}</b>
</div>
</div>`
: `<div class="akte-zeilen" style="grid-template-columns:auto 1fr">
<span>Kennzeichen</span><b>${sicher(ziel.plate)}</b>
<span>Modell</span><b>${sicher(ziel.name || ziel.model)}</b>
<span>Farbe</span><b>${ziel.farbe
? `<span class="farbtupfer" style="background:${sicher(ziel.farbe)}"></span> ${sicher(ziel.farbe)}`
: "—"}</b>
<span>Halter</span><b>${sicher((ziel.firstname || "unbekannt") + " " + (ziel.lastname || ""))}</b>
<span>Status</span><b>${sicher(ziel.state === "impound" ? "sichergestellt"
: ziel.state === "out" ? "unterwegs" : "eingeparkt")}</b>
</div>`}
</div>
${f.bild ? `
<div class="karte" style="grid-column:1/-1">
<div class="karte-kopf"><span class="karte-titel">Fahndungsbild</span></div>
<img src="${sicher(f.bild)}" alt="Fahndungsbild" class="fahndung-bild">
</div>` : ""}
<div class="karte" style="grid-column:1/-1">
<div class="karte-kopf"><span class="karte-titel">Vorgangshistorie</span></div>
${d.historie.length ? `<div class="liste">${d.historie.map((h) => `
<div class="liste-zeile">
<div class="lz-haupt">
<div class="lz-titel">${sicher(h.aktion)}</div>
<div class="lz-unter">${sicher(h.dienstnummer || "")}
${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)}
${h.grund ? " · " + sicher(h.grund) : ""}</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine Einträge.</div>`}
</div>
</div>`;
$("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: `
<p style="margin:0 0 12px;font-size:13px;line-height:1.6">
<b>${sicher(f.bezeichnung)}</b><br>${sicher(f.grund)}
</p>
<div class="folgen">
<div class="folge">
<span class="folge-symbol">📄</span>
<div>Die Fahndung wandert ins Archiv und bleibt dort nachvollziehbar.
Es wird festgehalten, dass <b>du</b> sie aufgehoben hast.</div>
</div>
</div>
<div class="feld" style="margin-top:12px">
<label>Grund der Aufhebung</label>
<input id="fh-grund" placeholder="z. B. Person gestellt, Sachverhalt geklärt">
</div>`,
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: `
<div class="feld"><label>Art der Fahndung</label>
<select id="fn-typ">
<option value="person">Person</option>
<option value="fahrzeug">Fahrzeug</option>
</select></div>
<div class="feld">
<label>Ziel suchen</label>
<div class="person-waehler">
<input id="fn-suche" placeholder="Name oder Kennzeichen">
<button class="knopf leise klein" id="fn-suchen">Suchen</button>
</div>
<div class="feld-hinweis" id="fn-gewaehlt">Noch kein Ziel ausgewählt.</div>
</div>
<div class="raster k2" style="gap:10px">
<div class="feld" style="margin:0"><label>Gefahrenstufe</label>
<select id="fn-gefahr">
<option value="normal">Normal</option>
<option value="bewaffnet">Bewaffnet</option>
<option value="gefaehrlich">Gefährlich</option>
</select></div>
<div class="feld" style="margin:0"><label>Priorität</label>
<select id="fn-prio">
<option value="1">1 — Sofort</option>
<option value="2">2 — Hoch</option>
<option value="3" selected>3 — Normal</option>
<option value="4">4 — Niedrig</option>
</select></div>
</div>
<div class="feld"><label>Fahndungsgrund</label>
<textarea id="fn-grund" placeholder="Weshalb wird gefahndet?"></textarea></div>
<div class="feld"><label>Hinweise für die Streife</label>
<textarea id="fn-hinweise" placeholder="Verhalten, letzte Sichtung, Vorgehensweise"></textarea></div>
<div class="feld"><label>Fahndungsbild (Adresse)</label>
<input id="fn-bild" placeholder="https://… (optional)"></div>
<div class="grundsatz">
<span>ℹ</span>
<div>Die Gefahrenstufe bestimmt, wie dringlich alle Beamten benachrichtigt werden.
Bei <b>gefährlich</b> geht ein kritischer Hinweis an alle im Dienst.</div>
</div>`,
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
? `<div class="liste" style="margin-top:6px">${d.treffer.slice(0, 6).map((t) =>
`<div class="liste-zeile treffer" data-wahl="${sicher(t.id)}"
data-titel="${sicher(t.titel)}">
<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>`;
anzeige.querySelectorAll("[data-wahl]").forEach((zeile) =>
zeile.addEventListener("click", () => {
gewaehlt = zeile.dataset.wahl;
anzeige.innerHTML = `Ausgewählt: <b>${sicher(zeile.dataset.titel)}</b>`;
}));
} 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: `
<div class="raster k2" style="gap:10px">
<div class="feld" style="margin:0"><label>Gefahrenstufe</label>
<select id="fb-gefahr">
${["normal", "bewaffnet", "gefaehrlich"].map((g) =>
`<option value="${g}" ${f.gefahr === g ? "selected" : ""}>${gefahrText[g]}</option>`).join("")}
</select></div>
<div class="feld" style="margin:0"><label>Priorität</label>
<select id="fb-prio">
${[1, 2, 3, 4].map((p) =>
`<option value="${p}" ${f.prioritaet === p ? "selected" : ""}>${p} — ${prioText[p]}</option>`).join("")}
</select></div>
</div>
<div class="feld"><label>Fahndungsgrund</label>
<textarea id="fb-grund">${sicher(f.grund)}</textarea></div>
<div class="feld"><label>Hinweise</label>
<textarea id="fb-hinweise">${sicher(f.hinweise || "")}</textarea></div>
<div class="feld"><label>Fahndungsbild (Adresse)</label>
<input id="fb-bild" value="${sicher(f.bild || "")}"></div>`,
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 = `<div class="seiten-kopf"><div><h2>Fahndungen</h2>
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
}
}
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 []; }
});
})();