- 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>
369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
/* =====================================================================
|
||
aether-police / Ansicht: Beweisverwaltung
|
||
|
||
Es gibt keine automatische Spurenerkennung — jeder Beweis wird hier
|
||
von einem Beamten erfasst und beschrieben.
|
||
===================================================================== */
|
||
"use strict";
|
||
|
||
(() => {
|
||
const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App;
|
||
|
||
const z = {
|
||
daten: null,
|
||
offen: null, // geöffneter Beweis
|
||
filterArt: "",
|
||
suche: "",
|
||
};
|
||
|
||
const artSymbol = {
|
||
foto: "📷", video: "🎬", fingerabdruck: "🖐", dna: "🧬",
|
||
huelse: "🔩", dokument: "📄", aussage: "🗣", gegenstand: "📦",
|
||
};
|
||
|
||
function artName(id) {
|
||
const treffer = (z.daten.arten || []).find((a) => a.id === id);
|
||
return treffer ? treffer.name : id;
|
||
}
|
||
|
||
/* ---- Übersicht ---------------------------------------------------- */
|
||
|
||
function uebersicht(behaelter) {
|
||
const d = z.daten;
|
||
|
||
behaelter.innerHTML = `
|
||
<div class="seiten-kopf">
|
||
<div>
|
||
<h2>Beweismittel</h2>
|
||
<p>Jeder Beweis wird von einem Beamten erfasst — es gibt keine automatische Spurenerkennung.</p>
|
||
</div>
|
||
${d.rechte.erfassen ? `<button class="knopf klein" id="bw-neu">+ Beweis erfassen</button>` : ""}
|
||
</div>
|
||
|
||
<div class="mdt-suchleiste" style="margin-bottom:14px">
|
||
<input id="bw-suche" type="search" placeholder="Kennung, Bezeichnung oder Fundort …"
|
||
value="${sicher(z.suche)}">
|
||
<select id="bw-art" class="katalog-suche">
|
||
<option value="">Alle Arten</option>
|
||
${(d.arten || []).map((a) =>
|
||
`<option value="${sicher(a.id)}" ${z.filterArt === a.id ? "selected" : ""}>${sicher(a.name)}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="karte">
|
||
${d.beweise.length ? `<div class="liste">${d.beweise.map((b) => `
|
||
<div class="liste-zeile treffer" data-beweis="${b.id}">
|
||
<span class="beweis-symbol">${artSymbol[b.art] || "•"}</span>
|
||
<div class="lz-haupt">
|
||
<div class="lz-titel">${sicher(b.bezeichnung)}</div>
|
||
<div class="lz-unter">
|
||
${sicher(b.kennung)} · ${sicher(artName(b.art))}
|
||
${b.fundort ? " · Fundort: " + sicher(b.fundort) : ""}
|
||
${b.aktenzeichen ? " · " + sicher(b.aktenzeichen) : ""}
|
||
</div>
|
||
<div class="lz-unter" style="margin-top:2px">
|
||
erfasst von ${sicher(b.dienstnummer || "")}
|
||
${sicher((b.firstname || "") + " " + (b.lastname || ""))} · ${zeit(b.erfasst_am)}
|
||
</div>
|
||
</div>
|
||
${b.datei ? '<span class="marker info">Anlage</span>' : ""}
|
||
</div>`).join("")}</div>`
|
||
: `<div class="liste-leer">Keine Beweismittel gefunden.</div>`}
|
||
</div>`;
|
||
|
||
const neu = $("bw-neu");
|
||
if (neu) neu.addEventListener("click", () => erfassenDialog(behaelter));
|
||
|
||
const suchfeld = $("bw-suche");
|
||
suchfeld.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); }
|
||
});
|
||
$("bw-art").addEventListener("change", (e) => {
|
||
z.filterArt = e.target.value; rendern(behaelter);
|
||
});
|
||
|
||
behaelter.querySelectorAll("[data-beweis]").forEach((el) =>
|
||
el.addEventListener("click", () => oeffne(behaelter, +el.dataset.beweis)));
|
||
}
|
||
|
||
/* ---- Einzelansicht ------------------------------------------------ */
|
||
|
||
function einzelAnsicht(behaelter, d) {
|
||
const b = d.beweis;
|
||
|
||
behaelter.innerHTML = `
|
||
<div class="seiten-kopf">
|
||
<div>
|
||
<h2>${artSymbol[b.art] || "•"} ${sicher(b.bezeichnung)}</h2>
|
||
<p>${sicher(b.kennung)} · ${sicher(artName(b.art))}</p>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="knopf leise klein" id="bw-zurueck">‹ Zurück</button>
|
||
<button class="knopf leise klein" id="bw-bearbeiten">✎ Ändern</button>
|
||
${d.rechte.loeschen ? `<button class="knopf leise klein" id="bw-entfernen">Entfernen</button>` : ""}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="raster k2">
|
||
<div class="karte" style="grid-column:1/-1">
|
||
<div class="karte-kopf"><span class="karte-titel">Beweismittel</span></div>
|
||
<div class="akte-zeilen">
|
||
<span>Kennung</span><b>${sicher(b.kennung)}</b>
|
||
<span>Art</span><b>${sicher(artName(b.art))}</b>
|
||
<span>Fundort</span><b>${sicher(b.fundort || "—")}</b>
|
||
<span>Aufgefunden</span><b>${zeit(b.gefunden_am)}</b>
|
||
<span>Erfasst von</span><b>${sicher(b.dienstnummer || "")}
|
||
${sicher((b.firstname || "") + " " + (b.lastname || ""))}</b>
|
||
<span>Erfasst am</span><b>${zeit(b.erfasst_am)}</b>
|
||
</div>
|
||
${b.beschreibung
|
||
? `<div class="akte-inhalt" style="margin-top:12px">${sicher(b.beschreibung).replace(/\n/g, "<br>")}</div>`
|
||
: ""}
|
||
${b.datei ? `<div style="margin-top:12px">
|
||
<a class="knopf leise klein" href="${sicher(b.datei)}" target="_blank"
|
||
style="display:inline-block;text-decoration:none">📎 Anlage öffnen</a>
|
||
</div>` : ""}
|
||
</div>
|
||
|
||
<div class="karte">
|
||
<div class="karte-kopf">
|
||
<span class="karte-titel">Verknüpfte Akten</span>
|
||
<button class="knopf leise klein" id="bw-akte">+ Akte</button>
|
||
</div>
|
||
${d.akten.length ? `<div class="liste">${d.akten.map((a) => `
|
||
<div class="liste-zeile">
|
||
<span class="marker neutral">${sicher(a.typ)}</span>
|
||
<div class="lz-haupt">
|
||
<div class="lz-titel">${sicher(a.titel)}</div>
|
||
<div class="lz-unter">${sicher(a.aktenzeichen)}</div>
|
||
</div>
|
||
<span class="marker ${a.status === "geschlossen" ? "neutral" : "warn"}">${sicher(a.status)}</span>
|
||
</div>`).join("")}</div>`
|
||
: `<div class="liste-leer">Noch keiner Akte zugeordnet.</div>`}
|
||
</div>
|
||
|
||
<div class="karte">
|
||
<div class="karte-kopf"><span class="karte-titel">Zugehörige Asservate</span></div>
|
||
${d.asservate.length ? `<div class="liste">${d.asservate.map((a) => `
|
||
<div class="liste-zeile">
|
||
<span class="marker neutral">${sicher(a.kennung)}</span>
|
||
<div class="lz-haupt">
|
||
<div class="lz-titel">${sicher(a.bezeichnung)}</div>
|
||
<div class="lz-unter">${sicher(a.lagerort)}</div>
|
||
</div>
|
||
<span class="marker ${a.status === "eingelagert" ? "gut" : "neutral"}">${sicher(a.status)}</span>
|
||
</div>`).join("")}</div>`
|
||
: `<div class="liste-leer">Keine Asservate hinterlegt.</div>`}
|
||
</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>`;
|
||
|
||
$("bw-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); });
|
||
|
||
$("bw-bearbeiten").addEventListener("click", () => {
|
||
fenster({
|
||
titel: "Beweis ändern",
|
||
inhalt: `
|
||
<div class="feld"><label>Bezeichnung</label>
|
||
<input id="be-bez" value="${sicher(b.bezeichnung)}"></div>
|
||
<div class="feld"><label>Fundort</label>
|
||
<input id="be-ort" value="${sicher(b.fundort || "")}"></div>
|
||
<div class="feld"><label>Beschreibung</label>
|
||
<textarea id="be-text">${sicher(b.beschreibung || "")}</textarea></div>
|
||
<div class="feld"><label>Anlage (Adresse)</label>
|
||
<input id="be-datei" value="${sicher(b.datei || "")}" placeholder="https://…"></div>
|
||
<div class="feld"><label>Grund der Änderung (erforderlich)</label>
|
||
<input id="be-grund" placeholder="Änderungen an Beweismitteln werden protokolliert">
|
||
</div>`,
|
||
knoepfe: [
|
||
{ text: "Abbrechen", klick: (_, zu) => zu() },
|
||
{ text: "Speichern", klick: async (el, zu) => {
|
||
try {
|
||
await aufruf("beweis", "bearbeiten", {
|
||
id: b.id,
|
||
bezeichnung: el.querySelector("#be-bez").value.trim(),
|
||
fundort: el.querySelector("#be-ort").value.trim(),
|
||
beschreibung: el.querySelector("#be-text").value,
|
||
datei: el.querySelector("#be-datei").value.trim(),
|
||
grund: el.querySelector("#be-grund").value.trim(),
|
||
});
|
||
zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, b.id);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
}},
|
||
],
|
||
});
|
||
});
|
||
|
||
const entfernen = $("bw-entfernen");
|
||
if (entfernen) entfernen.addEventListener("click", () => {
|
||
bestaetigen({
|
||
titel: "Beweismittel entfernen",
|
||
gefahr: true,
|
||
inhalt: `
|
||
<div class="folgen">
|
||
<div class="folge">
|
||
<span class="folge-symbol">⚠</span>
|
||
<div>Der Beweis <b>${sicher(b.kennung)}</b> wird aus dem Bestand entfernt.
|
||
Der Vorgang bleibt im Protokoll sichtbar und alle Beamten werden benachrichtigt.</div>
|
||
</div>
|
||
</div>
|
||
<div class="feld" style="margin-top:12px">
|
||
<label>Begründung (ausführlich, erforderlich)</label>
|
||
<textarea id="bl-grund" placeholder="Warum wird das Beweismittel entfernt?"></textarea>
|
||
</div>`,
|
||
bestaetigenText: "Entfernen",
|
||
bestaetigt: async (el, zu) => {
|
||
try {
|
||
await aufruf("beweis", "entfernen", {
|
||
id: b.id, grund: el.querySelector("#bl-grund").value.trim(),
|
||
});
|
||
zu(); meldung("Beweis entfernt."); z.offen = null; rendern(behaelter);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
},
|
||
});
|
||
});
|
||
|
||
$("bw-akte").addEventListener("click", () => akteVerknuepfenDialog(behaelter, b.id));
|
||
}
|
||
|
||
/* ---- Dialoge ------------------------------------------------------ */
|
||
|
||
function erfassenDialog(behaelter) {
|
||
const arten = (z.daten.arten || []);
|
||
|
||
fenster({
|
||
titel: "Beweis erfassen",
|
||
inhalt: `
|
||
<div class="raster k2" style="gap:10px">
|
||
<div class="feld" style="margin:0"><label>Art</label>
|
||
<select id="bn-art">
|
||
${arten.map((a) => `<option value="${sicher(a.id)}">${sicher(a.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="feld" style="margin:0"><label>Fundort</label>
|
||
<input id="bn-ort" placeholder="Wo wurde es aufgefunden?"></div>
|
||
</div>
|
||
<div class="feld"><label>Bezeichnung</label>
|
||
<input id="bn-bez" placeholder="Kurze, eindeutige Benennung"></div>
|
||
<div class="feld"><label>Beschreibung</label>
|
||
<textarea id="bn-text" placeholder="Zustand, Auffälligkeiten, Umstände der Auffindung"></textarea></div>
|
||
<div class="feld"><label>Anlage (Bild-/Videoadresse)</label>
|
||
<input id="bn-datei" placeholder="https://… (optional)"></div>
|
||
<div class="grundsatz">
|
||
<span>ℹ</span>
|
||
<div>Der <b>Fundort</b> ist Pflicht — er gehört zur Beweiskette. Das System
|
||
erkennt keine Spuren; alles hier ist deine Dokumentation.</div>
|
||
</div>`,
|
||
knoepfe: [
|
||
{ text: "Abbrechen", klick: (_, zu) => zu() },
|
||
{ text: "Erfassen", klick: async (el, zu) => {
|
||
try {
|
||
const ergebnis = await aufruf("beweis", "erfassen", {
|
||
art: el.querySelector("#bn-art").value,
|
||
bezeichnung: el.querySelector("#bn-bez").value.trim(),
|
||
beschreibung: el.querySelector("#bn-text").value,
|
||
fundort: el.querySelector("#bn-ort").value.trim(),
|
||
datei: el.querySelector("#bn-datei").value.trim(),
|
||
});
|
||
zu(); meldung("Beweis " + ergebnis.kennung + " erfasst.", "gut");
|
||
oeffne(behaelter, ergebnis.id);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
}},
|
||
],
|
||
});
|
||
}
|
||
|
||
function akteVerknuepfenDialog(behaelter, beweisId) {
|
||
fenster({
|
||
titel: "Mit Akte verknüpfen",
|
||
inhalt: `
|
||
<div class="feld">
|
||
<label>Akte suchen</label>
|
||
<div class="person-waehler">
|
||
<input id="bv-suche" placeholder="Aktenzeichen oder Titel">
|
||
<button class="knopf leise klein" id="bv-suchen">Suchen</button>
|
||
</div>
|
||
</div>
|
||
<div id="bv-treffer"></div>`,
|
||
nachOeffnen: (el) => {
|
||
const suchen = async () => {
|
||
const begriff = el.querySelector("#bv-suche").value.trim();
|
||
if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; }
|
||
try {
|
||
const d = await aufruf("beweis", "aktenSuche", { begriff });
|
||
const ziel = el.querySelector("#bv-treffer");
|
||
ziel.innerHTML = (d.akten || []).length
|
||
? `<div class="liste">${d.akten.map((a) =>
|
||
`<div class="liste-zeile treffer" data-wahl="${a.id}">
|
||
<span class="marker neutral">${sicher(a.typ)}</span>
|
||
<div class="lz-haupt"><div class="lz-titel">${sicher(a.titel)}</div>
|
||
<div class="lz-unter">${sicher(a.aktenzeichen)}</div></div>
|
||
</div>`).join("")}</div>`
|
||
: `<div class="liste-leer">Keine Treffer.</div>`;
|
||
|
||
ziel.querySelectorAll("[data-wahl]").forEach((zeile) =>
|
||
zeile.addEventListener("click", async () => {
|
||
try {
|
||
await aufruf("beweis", "akteVerknuepfen", {
|
||
beweisId, akteId: +zeile.dataset.wahl,
|
||
});
|
||
el.remove(); meldung("Verknüpft.", "gut"); oeffne(behaelter, beweisId);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
}));
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
};
|
||
el.querySelector("#bv-suchen").addEventListener("click", suchen);
|
||
el.querySelector("#bv-suche").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") { e.preventDefault(); suchen(); }
|
||
});
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ---- Laden -------------------------------------------------------- */
|
||
|
||
async function oeffne(behaelter, beweisId) {
|
||
try {
|
||
const d = await aufruf("beweis", "oeffnen", { id: beweisId });
|
||
z.offen = beweisId;
|
||
einzelAnsicht(behaelter, d);
|
||
} catch (f) { meldung(f.message, "fehler"); }
|
||
}
|
||
|
||
async function rendern(behaelter) {
|
||
try {
|
||
z.daten = await aufruf("beweis", "liste", { art: z.filterArt, begriff: z.suche });
|
||
if (z.offen) await oeffne(behaelter, z.offen);
|
||
else uebersicht(behaelter);
|
||
} catch (f) {
|
||
behaelter.innerHTML = `<div class="seiten-kopf"><div><h2>Beweise</h2>
|
||
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
|
||
}
|
||
}
|
||
|
||
App.registriereAnsicht("beweis", { rendern });
|
||
|
||
/* Globale Suche bedienen */
|
||
App.registriereSuche(async (begriff) => {
|
||
try {
|
||
const d = await aufruf("beweis", "liste", { begriff });
|
||
return (d.beweise || []).slice(0, 5).map((b) => ({
|
||
bereich: "Beweis", titel: b.bezeichnung,
|
||
unter: b.kennung + " · " + (b.fundort || ""),
|
||
}));
|
||
} catch (e) { return []; }
|
||
});
|
||
})();
|