- 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>
433 lines
17 KiB
JavaScript
433 lines
17 KiB
JavaScript
/* =====================================================================
|
||
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"));
|
||
},
|
||
});
|
||
})();
|