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

433 lines
17 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: 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 `<div class="mdt-reiter">
<button data-reiter="aktuell" class="${z.reiter === "aktuell" ? "aktiv" : ""}">🔒 Aktueller Bestand</button>
<button data-reiter="verlauf" class="${z.reiter === "verlauf" ? "aktiv" : ""}">📋 Verlauf</button>
</div>`;
}
function zellenUebersicht(zellen) {
return `<div class="zellen">${zellen.map((zelle) => `
<div class="zelle ${zelle.frei ? "frei" : zelle.insasse.abgelaufen ? "abgelaufen" : "belegt"}">
<div class="zelle-name">${sicher(zelle.name)}</div>
${zelle.frei
? `<div class="zelle-status">frei</div>`
: `<div class="zelle-insasse">${sicher(zelle.insasse.name)}</div>
<div class="zelle-rest" data-rest="${zelle.insasse.rest}">
${restText(zelle.insasse.rest)}
</div>`}
</div>`).join("")}</div>`;
}
function bestandListe(offene) {
if (!offene.length) return `<div class="liste-leer">Niemand in Gewahrsam.</div>`;
return `<div class="liste">${offene.map((g) => `
<div class="liste-zeile gewahrsam-zeile ${g.abgelaufen ? "abgelaufen" : ""}">
<span class="prio ${g.abgelaufen ? "p1" : "p3"}"></span>
<div class="lz-haupt">
<div class="lz-titel">
${sicher(g.firstname + " " + g.lastname)}
${g.zelle ? `<span class="marker neutral">${sicher(g.zelle)}</span>` : ""}
${g.online ? "" : '<span class="marker neutral">offline</span>'}
</div>
<div class="lz-unter">${sicher(g.grund)}</div>
<div class="lz-unter" style="margin-top:3px">
${g.haft_minuten} Min festgelegt · eingewiesen von
${sicher(g.e_dienstnummer || "")} ${sicher((g.e_vorname || "") + " " + (g.e_nachname || ""))}
· ${zeit(g.beginn)}
</div>
</div>
<div class="gewahrsam-rechts">
<span class="rest-anzeige ${g.abgelaufen ? "abgelaufen" : ""}" data-rest="${g.rest_sekunden}">
${restText(g.rest_sekunden)}
</span>
<div style="display:flex;gap:5px">
${z.daten.rechte.entlassen
? `<button class="knopf leise klein" data-frist="${g.id}">Frist</button>` : ""}
${z.daten.rechte.einweisen
? `<button class="knopf leise klein" data-zelle="${g.id}">Zelle</button>` : ""}
${z.daten.rechte.entlassen
? `<button class="knopf klein" data-entlassen="${g.id}">Entlassen</button>` : ""}
</div>
</div>
</div>`).join("")}</div>`;
}
/* ---- Aktuelle Ansicht -------------------------------------------- */
function aktuellAnsicht(behaelter) {
const d = z.daten;
const abgelaufen = d.offene.filter((g) => g.abgelaufen).length;
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Gewahrsam</h2>
<p>Fristen laufen mit — entlassen wird ausschließlich durch einen Beamten.</p>
</div>
${d.rechte.einweisen
? `<button class="knopf klein" id="gw-neu">+ In Gewahrsam nehmen</button>` : ""}
</div>
${reiterLeiste()}
${abgelaufen > 0 ? `
<div class="warnung hoch">
<span class="w-symbol">⏰</span>
<span><b>${abgelaufen} Haftzeit${abgelaufen === 1 ? "" : "en"} abgelaufen.</b>
Die Person${abgelaufen === 1 ? " bleibt" : "en bleiben"} im Gewahrsam, bis ein Beamter
über die Entlassung entscheidet.</span>
</div>` : ""}
<div class="raster k2" style="margin-top:14px">
<div class="karte" style="grid-column:1/-1">
<div class="karte-kopf">
<span class="karte-titel">Zellenbelegung</span>
<span class="marker neutral">${d.zellen.filter((c) => !c.frei).length} / ${d.zellen.length}</span>
</div>
${zellenUebersicht(d.zellen)}
</div>
<div class="karte" style="grid-column:1/-1">
<div class="karte-kopf">
<span class="karte-titel">Aktueller Bestand</span>
<span class="marker ${abgelaufen ? "kritisch" : "neutral"}">${d.offene.length}</span>
</div>
${bestandListe(d.offene)}
<div class="grundsatz">
<span>ℹ</span>
<div>Das System <b>erinnert</b> an ablaufende Fristen — es entlässt niemanden.
Auch eine längst abgelaufene Haftzeit bleibt offen stehen, bis jemand entscheidet.</div>
</div>
</div>
</div>`;
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: `
<div class="feld">
<label>Person</label>
<div class="person-waehler">
<input id="gw-person" placeholder="Name eingeben und suchen …">
<button class="knopf leise klein" id="gw-suchen">Suchen</button>
</div>
<div class="feld-hinweis" id="gw-gewaehlt">Noch niemand ausgewählt.</div>
</div>
<div class="raster k2" style="gap:10px">
<div class="feld" style="margin:0">
<label>Haftdauer (Minuten)</label>
<input id="gw-dauer" type="number" min="1" max="${z.daten.maxDauer}" value="30">
</div>
<div class="feld" style="margin:0">
<label>Zelle</label>
<select id="gw-zelle">
<option value="">— ohne feste Zelle —</option>
${freieZellen.map((c) => `<option value="${sicher(c.id)}">${sicher(c.name)}</option>`).join("")}
</select>
</div>
</div>
<div class="feld">
<label>Grund</label>
<input id="gw-grund" placeholder="Rechtsgrundlage oder Sachverhalt">
</div>
<div class="grundsatz">
<span>ℹ</span>
<div>Die Haftdauer legst <b>du</b> fest. Ein Katalogvorschlag aus einer Anzeige
ist nur eine Empfehlung und wird hier nicht automatisch übernommen.
${z.daten.verbringen
? "<br><br>Die Person wird in die gewählte Zelle verbracht."
: "<br><br>Die Person wird <b>nicht</b> automatisch verbracht — das geschieht im Rollenspiel."}</div>
</div>`,
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 = `<div class="liste" style="margin-top:6px">${treffer.slice(0, 6).map((p) =>
`<div class="liste-zeile treffer" data-wahl="${p.id}"
data-name="${sicher(p.firstname + " " + p.lastname)}">
<div class="lz-haupt"><div class="lz-titel">${sicher(p.firstname + " " + p.lastname)}</div>
<div class="lz-unter">geb. ${sicher(String(p.dob || "").slice(0, 10))}</div></div>
</div>`).join("")}</div>`;
anzeige.querySelectorAll("[data-wahl]").forEach((zeile) =>
zeile.addEventListener("click", () => {
gewaehlt = { id: +zeile.dataset.wahl, name: zeile.dataset.name };
anzeige.innerHTML = `Ausgewählt: <b>${sicher(gewaehlt.name)}</b>`;
}));
} 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: `
<p style="margin:0 0 12px;font-size:13px;line-height:1.6">
<b>${sicher(g.firstname + " " + g.lastname)}</b><br>
${sicher(g.grund)}
</p>
${vorzeitig ? `
<div class="abweichung">
<div class="abweichung-kopf">⚠ Die Frist läuft noch ${restMinuten} Minuten</div>
<div style="font-size:12px;margin-top:6px;line-height:1.5">
Eine vorzeitige Entlassung ist möglich — sie verlangt aber eine Begründung
und wird als solche protokolliert.
</div>
</div>`
: `<div class="folgen">
<div class="folge">
<span class="folge-symbol">⏰</span>
<div>Die festgelegte Haftzeit ist abgelaufen. Das System hat lediglich
daran erinnert — die Entlassung führst <b>du</b> aus.</div>
</div>
</div>`}
<div class="feld" style="margin-top:12px">
<label>Vermerk${vorzeitig ? " (erforderlich)" : ""}</label>
<input id="ge-grund" placeholder="${vorzeitig ? "Warum wird vorzeitig entlassen?" : "optional"}">
</div>`,
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: `
<div class="feld">
<label>Änderung in Minuten</label>
<input id="gf-minuten" type="number" value="0"
placeholder="positiv verlängert, negativ verkürzt">
<div class="feld-hinweis">Beispiel: <b>-10</b> verkürzt um zehn Minuten,
<b>15</b> verlängert um fünfzehn.</div>
</div>
<div class="feld">
<label>Grund (erforderlich)</label>
<input id="gf-grund" placeholder="Warum wird die Frist geändert?">
</div>`,
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: `<div class="feld"><label>Neue Zelle</label>
<select id="gz-zelle">
${frei.map((c) => `<option value="${sicher(c.id)}">${sicher(c.name)}</option>`).join("")}
</select></div>`,
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 = `
<div class="seiten-kopf">
<div><h2>Gewahrsam — Verlauf</h2>
<p>Abgeschlossene Vorgänge, nachvollziehbar mit entscheidendem Beamten</p></div>
</div>
${reiterLeiste()}
<div class="karte">
${v.length ? `<div class="liste">${v.map((g) => `
<div class="liste-zeile">
<div class="lz-haupt">
<div class="lz-titel">${sicher(g.firstname + " " + g.lastname)}</div>
<div class="lz-unter">${sicher(g.grund)}</div>
<div class="lz-unter" style="margin-top:3px">
${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)}"` : ""}
</div>
</div>
</div>`).join("")}</div>`
: `<div class="liste-leer">Keine abgeschlossenen Vorgänge.</div>`}
</div>`;
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 = `<div class="seiten-kopf"><div><h2>Gewahrsam</h2>
<p>Konnte nicht geladen werden: ${sicher(fehler.message)}</p></div></div>`;
}
}
App.registriereAnsicht("gewahrsam", {
rendern,
aktualisieren: () => {
if (App.zustand.aktivesModul === "gewahrsam") rendern($("inhalt"));
},
});
})();