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

569 lines
22 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: Interne Kommunikation
Posteingang und Aushänge. Der Posteingang merkt sich, was du gelesen
hast — das ist Anzeige. Eine Kenntnisnahme ist dagegen eine Erklärung
und wird nur durch deinen ausdrücklichen Klick gesetzt.
===================================================================== */
"use strict";
(() => {
const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App;
const z = {
reiter: "eingang", // eingang | gesendet | aushang | archiv
daten: null,
aushangDaten: null,
offeneNachricht: null,
offenerAushang: null,
};
// Farbgebung der Aushangkategorien
const kategorieArt = {
anweisung: "info", information: "neutral", warnung: "kritisch",
};
const kategorieSymbol = {
anweisung: "📋", information: "ℹ", warnung: "⚠",
};
/* ---- Gerüst -------------------------------------------------------- */
function reiterleiste(d) {
const eintraege = [
{ id: "eingang", text: "Posteingang", zaehler: d.ungelesen },
{ id: "gesendet", text: "Gesendet" },
{ id: "aushang", text: "Aushänge", zaehler: d.offeneKenntnis },
{ id: "archiv", text: "Archiv" },
];
return `<div class="mdt-reiter">
${eintraege.map((e) => `
<button class="${z.reiter === e.id ? "aktiv" : ""}" data-reiter="${e.id}">
${sicher(e.text)}
${e.zaehler ? `<span class="komm-zaehler">${e.zaehler}</span>` : ""}
</button>`).join("")}
</div>`;
}
/* ---- Posteingang --------------------------------------------------- */
function postliste(behaelter) {
const d = z.daten;
const gesendet = z.reiter === "gesendet";
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>Kommunikation</h2>
<p>Nachrichten und Aushänge der Behörde. Zugestellt wird automatisch —
gelesen, beantwortet und zur Kenntnis genommen wird von Hand.</p>
</div>
${d.rechte.senden
? `<button class="knopf" data-neu="1">✉ Nachricht verfassen</button>` : ""}
</div>
${reiterleiste(d)}
<div class="liste">
${(d.nachrichten || []).length
? d.nachrichten.map((n) => postzeile(n, gesendet)).join("")
: `<div class="liste-leer">${gesendet
? "Du hast noch nichts gesendet."
: "Keine Nachrichten."}</div>`}
</div>
`;
verdrahtePost(behaelter);
}
function postzeile(n, gesendet) {
const typMarker = n.empf_typ === "rundmeldung"
? `<span class="marker kritisch">Rundmeldung</span>`
: n.empf_typ === "abteilung"
? `<span class="marker info">Abteilung</span>` : "";
return `<div class="liste-zeile komm-zeile ${n.gelesen ? "" : "ungelesen"}"
data-nachricht="${n.id}">
<span class="komm-punkt ${n.gelesen ? "" : "neu"}"></span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(n.betreff || "(kein Betreff)")}</div>
<div class="lz-unter">
${gesendet ? "an " + sicher(n.empfaengerName)
: "von " + sicher(n.absenderName)
+ (n.dienstnummer ? " · " + sicher(n.dienstnummer) : "")}
— ${sicher(n.vorschau)}
</div>
</div>
${typMarker}
<span class="komm-zeit">${zeit(n.gesendet)}</span>
</div>`;
}
function verdrahtePost(behaelter) {
verdrahteReiter(behaelter);
behaelter.querySelectorAll("[data-nachricht]").forEach((el) => {
el.addEventListener("click", () => nachrichtOeffnen(behaelter, +el.dataset.nachricht));
});
const neu = behaelter.querySelector("[data-neu]");
if (neu) neu.addEventListener("click", () => verfassen(behaelter));
}
function verdrahteReiter(behaelter) {
behaelter.querySelectorAll("[data-reiter]").forEach((el) => {
el.addEventListener("click", () => {
z.reiter = el.dataset.reiter;
rendern(behaelter);
});
});
}
/* ---- Nachricht lesen ------------------------------------------------ */
async function nachrichtOeffnen(behaelter, id) {
try {
const n = await aufruf("kommunikation", "nachrichtOeffnen", { id });
fenster({
titel: n.betreff || "(kein Betreff)",
inhalt: `
<div class="komm-kopf">
<div>
<b>${sicher(n.absenderName)}</b>
<span class="komm-kopf-unter">
${sicher(n.dienstnummer || "")} ${n.rolleName ? "· " + sicher(n.rolleName) : ""}
</span>
</div>
<span class="komm-zeit">${zeit(n.gesendet)}</span>
</div>
${n.empfaengerName
? `<p class="feld-hinweis">Gerichtet an: ${sicher(n.empfaengerName)}</p>` : ""}
<div class="komm-text">${sicher(n.text).replace(/\n/g, "<br>")}</div>`,
knoepfe: n.antwortAn
? [{ text: "Antworten", klick: (_, zu) => {
zu();
verfassen(behaelter, {
empfTyp: "person", empfaenger: n.antwortAn,
empfaengerName: n.absenderName,
betreff: n.betreff && n.betreff.startsWith("Re:")
? n.betreff : "Re: " + (n.betreff || ""),
});
} },
{ text: "Schließen", art: "leise", klick: (_, zu) => zu() }]
: [{ text: "Schließen", art: "leise", klick: (_, zu) => zu() }],
});
// Der Lesestatus hat sich geändert — Liste auffrischen
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
}
/* ---- Nachricht verfassen -------------------------------------------- */
async function verfassen(behaelter, vorgabe) {
vorgabe = vorgabe || {};
let empfaengerDaten;
try {
empfaengerDaten = await aufruf("kommunikation", "empfaenger", {});
} catch (f) { return meldung(f.message, "fehler"); }
let gewaehlt = vorgabe.empfaenger
? { typ: vorgabe.empfTyp, id: vorgabe.empfaenger, name: vorgabe.empfaengerName }
: null;
fenster({
titel: "Nachricht verfassen",
inhalt: `
<div class="feld"><label>Empfänger</label>
<div class="komm-empf-arten">
<button class="pv-schalter gewaehlt" data-art="person">Beamter</button>
<button class="pv-schalter" data-art="abteilung">Abteilung</button>
${empfaengerDaten.rundmeldung
? `<button class="pv-schalter" data-art="rundmeldung">Alle Einheiten</button>` : ""}
</div>
<div data-empfbereich="1"></div>
</div>
<div class="feld"><label>Betreff</label>
<input name="betreff" maxlength="160"
value="${sicher(vorgabe.betreff || "")}" placeholder="Worum geht es?"></div>
<div class="feld"><label>Nachricht</label>
<textarea name="text" rows="7" placeholder="Text der Nachricht…"></textarea></div>`,
knoepfe: [
{ text: "Abbrechen", art: "leise", klick: (_, zu) => zu() },
{ text: "Senden", klick: async (el, zu) => {
if (!gewaehlt) return meldung("Bitte einen Empfänger wählen.", "fehler");
try {
await aufruf("kommunikation", "senden", {
empfTyp: gewaehlt.typ,
empfaenger: gewaehlt.id,
betreff: el.querySelector("[name=betreff]").value.trim(),
text: el.querySelector("[name=text]").value.trim(),
});
zu();
meldung("Nachricht gesendet.", "gut");
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
} },
],
nachOeffnen: (hg) => {
const bereich = hg.querySelector("[data-empfbereich]");
/** Zeichnet den Empfängerbereich passend zur gewählten Art */
function zeichne(art) {
hg.querySelectorAll("[data-art]").forEach((b) =>
b.classList.toggle("gewaehlt", b.dataset.art === art));
if (art === "rundmeldung") {
gewaehlt = { typ: "rundmeldung", id: "alle", name: "Alle Einheiten" };
bereich.innerHTML = `<div class="warnung">
<span>📣</span>
<div>Diese Nachricht erreicht <b>jeden Beamten</b> der Behörde.</div>
</div>`;
return;
}
if (art === "abteilung") {
gewaehlt = null;
bereich.innerHTML = `<select name="abt">
<option value="">— Abteilung wählen —</option>
${empfaengerDaten.abteilungen.map((a) =>
`<option value="${sicher(a.id)}">${sicher(a.name)}</option>`).join("")}
</select>`;
bereich.querySelector("[name=abt]").addEventListener("change", (e) => {
gewaehlt = e.target.value
? { typ: "abteilung", id: e.target.value, name: e.target.value } : null;
});
return;
}
// Einzelner Beamter: Liste mit Suchfeld
bereich.innerHTML = `
<input name="suche" placeholder="Beamten suchen…">
<div class="liste komm-empfliste" data-liste="1"></div>`;
const liste = bereich.querySelector("[data-liste]");
const suchfeld = bereich.querySelector("[name=suche]");
function zeichneListe(begriff) {
const gefiltert = empfaengerDaten.beamte.filter((b) => {
if (!begriff) return true;
const text = (b.firstname + " " + b.lastname + " " + b.dienstnummer).toLowerCase();
return text.includes(begriff.toLowerCase());
}).slice(0, 40);
liste.innerHTML = gefiltert.length
? gefiltert.map((b) => `
<div class="liste-zeile ${gewaehlt && gewaehlt.id === b.character ? "gewaehlt" : ""}"
data-beamter="${b.character}">
<span class="pv-punkt ${b.imDienst ? "an" : ""}"
title="${b.imDienst ? "Im Dienst" : "Außer Dienst"}"></span>
<span class="pv-nummer">${sicher(b.dienstnummer)}</span>
<div class="lz-haupt">
<div class="lz-titel">${sicher(b.firstname)} ${sicher(b.lastname)}</div>
<div class="lz-unter">${sicher(b.rolleName)}</div>
</div>
</div>`).join("")
: `<div class="liste-leer">Kein Beamter gefunden.</div>`;
liste.querySelectorAll("[data-beamter]").forEach((el) => {
el.addEventListener("click", () => {
const b = empfaengerDaten.beamte.find((x) => x.character === +el.dataset.beamter);
gewaehlt = { typ: "person", id: b.character,
name: b.firstname + " " + b.lastname };
liste.querySelectorAll("[data-beamter]")
.forEach((zeile) => zeile.classList.remove("gewaehlt"));
el.classList.add("gewaehlt");
});
});
}
zeichneListe(gewaehlt && gewaehlt.name ? gewaehlt.name : "");
suchfeld.addEventListener("input", () => zeichneListe(suchfeld.value.trim()));
}
hg.querySelectorAll("[data-art]").forEach((b) =>
b.addEventListener("click", () => zeichne(b.dataset.art)));
zeichne(gewaehlt ? gewaehlt.typ : "person");
},
});
}
/* ---- Aushänge -------------------------------------------------------- */
function aushangListe(behaelter) {
const d = z.aushangDaten;
behaelter.innerHTML = `
<div class="seiten-kopf">
<div>
<h2>${d.archiv ? "Archivierte Aushänge" : "Aushänge"}</h2>
<p>Dienstanweisungen und Mitteilungen der Leitung. Eine Kenntnisnahme
bestätigst ausschließlich du selbst.</p>
</div>
${d.rechte.schreiben && !d.archiv
? `<button class="knopf" data-neu-aushang="1">+ Aushang verfassen</button>` : ""}
</div>
${reiterleiste(z.daten || { ungelesen: 0, offeneKenntnis: 0 })}
<div class="komm-aushaenge">
${(d.aushaenge || []).length
? d.aushaenge.map((a) => aushangKarte(a, d)).join("")
: `<div class="liste-leer">${d.archiv
? "Das Archiv ist leer." : "Zurzeit hängt nichts aus."}</div>`}
</div>
`;
verdrahteAushaenge(behaelter);
}
function aushangKarte(a, d) {
const art = kategorieArt[a.kategorie] || "neutral";
const offen = a.betroffen - a.kenntnisse;
return `<div class="karte komm-aushang ${a.angeheftet ? "angeheftet" : ""}">
<div class="karte-kopf">
<span class="karte-titel">
${kategorieSymbol[a.kategorie] || ""} ${sicher(a.titel)}
</span>
<div class="komm-aushang-marker">
${a.angeheftet ? `<span class="marker warn">angeheftet</span>` : ""}
<span class="marker ${art}">${sicher(a.kategorieName)}</span>
${a.abteilungName
? `<span class="marker neutral">${sicher(a.abteilungName)}</span>` : ""}
</div>
</div>
<div class="komm-text">${sicher(a.text).replace(/\n/g, "<br>")}</div>
<div class="komm-aushang-fuss">
<span class="feld-hinweis">
${sicher(a.autor)}${a.dienstnummer ? " (" + sicher(a.dienstnummer) + ")" : ""}
· ${zeit(a.erstellt_am)}
${a.frist ? ` · Kenntnisnahme bis ${zeit(a.frist)}` : ""}
</span>
<div class="komm-aushang-aktionen">
${a.kenntnispflicht ? (a.bestaetigt
? `<span class="marker gut">✓ zur Kenntnis genommen</span>`
: (a.aktiv === 1
? `<button class="knopf klein" data-kenntnis="${a.id}">
Kenntnis bestätigen</button>` : "")) : ""}
${a.kenntnispflicht && d.rechte.schreiben
? `<button class="knopf leise klein" data-stand="${a.id}">
${a.kenntnisse}/${a.betroffen} bestätigt${offen > 0 ? " · " + offen + " offen" : ""}
</button>` : ""}
${d.rechte.schreiben && a.aktiv === 1
? `<button class="knopf leise klein" data-anheften="${a.id}"
data-wert="${a.angeheftet ? "0" : "1"}">
${a.angeheftet ? "Loslösen" : "Anheften"}</button>` : ""}
${d.rechte.archivieren && a.aktiv === 1
? `<button class="knopf leise klein" data-archiv="${a.id}">Archivieren</button>` : ""}
</div>
</div>
</div>`;
}
function verdrahteAushaenge(behaelter) {
verdrahteReiter(behaelter);
const neu = behaelter.querySelector("[data-neu-aushang]");
if (neu) neu.addEventListener("click", () => aushangVerfassen(behaelter));
behaelter.querySelectorAll("[data-kenntnis]").forEach((el) => {
el.addEventListener("click", () => kenntnisBestaetigen(behaelter, +el.dataset.kenntnis));
});
behaelter.querySelectorAll("[data-stand]").forEach((el) => {
el.addEventListener("click", () => kenntnisStand(+el.dataset.stand));
});
behaelter.querySelectorAll("[data-anheften]").forEach((el) => {
el.addEventListener("click", async () => {
try {
await aufruf("kommunikation", "anheften", {
id: +el.dataset.anheften, angeheftet: el.dataset.wert === "1",
});
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
});
});
behaelter.querySelectorAll("[data-archiv]").forEach((el) => {
el.addEventListener("click", () => aushangArchivieren(behaelter, +el.dataset.archiv));
});
}
/* ---- Kenntnisnahme ---------------------------------------------------- */
function kenntnisBestaetigen(behaelter, id) {
// Bewusst über den Bestätigungsdialog: das ist eine Erklärung, kein Klick
bestaetigen({
titel: "Kenntnisnahme bestätigen",
inhalt: `<p>Du erklärst damit, den Aushang gelesen und verstanden zu haben.
Die Bestätigung wird mit deinem Namen und der Uhrzeit vermerkt.</p>`,
bestaetigenText: "Zur Kenntnis genommen",
bestaetigt: async (_, zu) => {
try {
await aufruf("kommunikation", "kenntnisnahme", { id });
zu();
meldung("Kenntnisnahme vermerkt.", "gut");
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
},
});
}
async function kenntnisStand(id) {
try {
const d = await aufruf("kommunikation", "kenntnisStand", { id });
fenster({
titel: "Stand der Kenntnisnahmen",
inhalt: `
<p><b>${sicher(d.titel)}</b></p>
<p class="feld-hinweis">${d.gesamt - d.offen} von ${d.gesamt} Beamten haben bestätigt.
${d.frist ? "Frist: " + zeit(d.frist) + "." : ""}
Das System erinnert nur — es bestätigt für niemanden.</p>
<div class="liste komm-empfliste">
${d.beamte.map((b) => `
<div class="liste-zeile">
<span class="pv-nummer">${sicher(b.dienstnummer)}</span>
<div class="lz-haupt"><div class="lz-titel">${sicher(b.name)}</div></div>
${b.bestaetigt_am
? `<span class="marker gut">${zeit(b.bestaetigt_am)}</span>`
: `<span class="marker warn">offen</span>`}
</div>`).join("")}
</div>`,
});
} catch (f) { meldung(f.message, "fehler"); }
}
/* ---- Aushang verfassen und archivieren -------------------------------- */
function aushangVerfassen(behaelter) {
const d = z.aushangDaten;
bestaetigen({
titel: "Aushang verfassen",
inhalt: `
<div class="feld"><label>Titel</label>
<input name="titel" maxlength="160" placeholder="Überschrift des Aushangs"></div>
<div class="feld"><label>Art</label>
<select name="kategorie">
${Object.keys(d.kategorien).map((k) =>
`<option value="${sicher(k)}">${sicher(d.kategorien[k])}</option>`).join("")}
</select></div>
<div class="feld"><label>Gilt für</label>
<select name="abteilung">
<option value="">Gesamte Behörde</option>
${d.abteilungen.map((a) =>
`<option value="${sicher(a.id)}">${sicher(a.name)}</option>`).join("")}
</select></div>
<div class="feld"><label>Inhalt</label>
<textarea name="text" rows="8" placeholder="Text des Aushangs…"></textarea></div>
<div class="feld">
<label><input type="checkbox" name="angeheftet" class="komm-haken"> Oben anheften</label>
</div>
<div class="feld">
<label><input type="checkbox" name="kenntnispflicht" class="komm-haken">
Kenntnisnahme verlangen</label>
<div class="feld-hinweis">Beamte bestätigen selbst. Das System erinnert nur
an offene Bestätigungen und setzt nie eine.</div>
</div>
<div class="feld" data-fristfeld="1" style="display:none">
<label>Frist für die Kenntnisnahme (Minuten)</label>
<input name="frist" type="number" min="5" max="20160" value="1440">
</div>`,
bestaetigenText: "Veröffentlichen",
bestaetigt: async (el, zu) => {
const kenntnispflicht = el.querySelector("[name=kenntnispflicht]").checked;
const frist = +el.querySelector("[name=frist]").value;
try {
await aufruf("kommunikation", "aushangErstellen", {
titel: el.querySelector("[name=titel]").value.trim(),
text: el.querySelector("[name=text]").value.trim(),
kategorie: el.querySelector("[name=kategorie]").value,
abteilung: el.querySelector("[name=abteilung]").value,
angeheftet: el.querySelector("[name=angeheftet]").checked,
kenntnispflicht,
fristMinuten: kenntnispflicht && frist > 0 ? frist : null,
});
zu();
meldung("Aushang veröffentlicht.", "gut");
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
},
nachOeffnen: (hg) => {
// Das Fristfeld ist nur sinnvoll, wenn eine Kenntnisnahme verlangt wird
const haken = hg.querySelector("[name=kenntnispflicht]");
const fristfeld = hg.querySelector("[data-fristfeld]");
haken.addEventListener("change", () => {
fristfeld.style.display = haken.checked ? "" : "none";
});
},
});
}
function aushangArchivieren(behaelter, id) {
bestaetigen({
titel: "Aushang archivieren",
gefahr: true,
inhalt: `<p>Der Aushang verschwindet vom Brett und wandert ins Archiv.
Bereits bestätigte Kenntnisnahmen bleiben erhalten.</p>
<div class="feld"><label>Grund (freiwillig)</label>
<input name="grund" placeholder="Warum wird der Aushang abgenommen?"></div>`,
bestaetigenText: "Archivieren",
bestaetigt: async (el, zu) => {
try {
await aufruf("kommunikation", "aushangArchivieren", {
id, grund: el.querySelector("[name=grund]").value.trim(),
});
zu();
meldung("Aushang archiviert.", "gut");
rendern(behaelter);
} catch (f) { meldung(f.message, "fehler"); }
},
});
}
/* ---- Laden ------------------------------------------------------------- */
async function rendern(behaelter) {
try {
// Die Zähler der Reiterleiste kommen immer aus der Übersicht
z.daten = await aufruf("kommunikation", "uebersicht", {
ordner: z.reiter === "gesendet" ? "gesendet" : "eingang",
});
// Sprungziel aus einem Hinweis, z. B. „Neuer Aushang"
const ziel = App.zustand.modulZiel;
if (ziel && ziel.aushang) {
App.zustand.modulZiel = null;
z.reiter = "aushang";
} else if (ziel && ziel.nachricht) {
App.zustand.modulZiel = null;
z.reiter = "eingang";
postliste(behaelter);
return nachrichtOeffnen(behaelter, ziel.nachricht);
}
if (z.reiter === "aushang" || z.reiter === "archiv") {
z.aushangDaten = await aufruf("kommunikation", "aushaenge", {
archiv: z.reiter === "archiv",
});
aushangListe(behaelter);
} else {
postliste(behaelter);
}
} catch (f) {
behaelter.innerHTML = `<div class="seiten-kopf"><div><h2>Kommunikation</h2>
<p>Konnte nicht geladen werden: ${sicher(f.message)}</p></div></div>`;
}
}
App.registriereAnsicht("kommunikation", { rendern });
})();