feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #45.
This commit is contained in:
2026-10-04 16:06:26 +00:00
parent f5936327a0
commit e1f244e4e8
23 changed files with 1103 additions and 22 deletions
+1
View File
@@ -14,6 +14,7 @@ const REITER = [
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
/*
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
+115
View File
@@ -0,0 +1,115 @@
import Link from "next/link";
import { ScrollText } from "lucide-react";
import { ProtokollFilter, type ProtokollFilterfelder } from "@/components/protokoll/protokoll-filter";
import { ProtokollListe } from "@/components/protokoll/protokoll-liste";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { PRO_SEITE, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
/**
* Das Protokoll ansehen (#023).
*
* Geschrieben wurde es seit #006 — Rollenänderungen, veröffentlichte Vorlagen,
* archivierte und zurückgeholte Läufe, der Moduswechsel. Ansehen konnte es
* niemand: wer wissen wollte, wer wann eine Rolle geändert hat, brauchte `psql`.
*
* `verwaltung` und nicht `lesen`: das Protokoll sagt, wer was getan hat, und ist
* damit selbst eine Auskunft über Menschen.
*
* **Die Seite prüft selbst.** Das Admin-Layout darüber ist das zweite Netz und
* nicht das erste (siehe CLAUDE.md).
*/
export default async function ProtokollSeiteAnsicht({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/protokoll");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const felder: ProtokollFilterfelder = {
suche: einzeln("suche"),
urheber: einzeln("urheber"),
von: einzeln("von"),
bis: einzeln("bis"),
};
const gewuenschteSeite = Number.parseInt(einzeln("seite"), 10);
const [ergebnis, urheber, kundenEbene] = await Promise.all([
protokollSeite(
{
suche: felder.suche,
...(felder.urheber ? { urheberId: felder.urheber } : {}),
...(felder.von ? { vonDatum: felder.von } : {}),
...(felder.bis ? { bisDatum: felder.bis } : {}),
seite: Number.isNaN(gewuenschteSeite) ? 1 : gewuenschteSeite,
},
benutzer,
),
protokollUrheber(benutzer),
kundenEbeneSichtbar(),
]);
/** Dieselben Filter mitnehmen, nur die Seite wechseln. */
function blaetternZu(seite: number): string {
const suchteil = new URLSearchParams();
if (felder.suche) suchteil.set("suche", felder.suche);
if (felder.urheber) suchteil.set("urheber", felder.urheber);
if (felder.von) suchteil.set("von", felder.von);
if (felder.bis) suchteil.set("bis", felder.bis);
suchteil.set("seite", String(seite));
return `/admin/protokoll?${suchteil.toString()}`;
}
const erster = (ergebnis.seite - 1) * PRO_SEITE + 1;
const letzter = Math.min(ergebnis.seite * PRO_SEITE, ergebnis.gesamt);
return (
<>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<ScrollText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Protokoll
</h1>
<p className="mt-2 max-w-prose text-nebentext">
Wer hat was wann getan. Festgehalten wird, was nachvollziehbar bleiben muss — Rollenänderungen,
veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, der Moduswechsel.
Einträge werden nie geändert und nie gelöscht.
</p>
<ProtokollFilter felder={felder} urheber={urheber} />
<p className="mt-4 text-sm text-nebentext">
{ergebnis.gesamt === 0 ? "Keine Einträge." : `${erster}–${letzter} von ${ergebnis.gesamt} Einträgen`}
</p>
<ProtokollListe zeilen={ergebnis.zeilen} kundenEbene={kundenEbene} />
{ergebnis.seiten > 1 ? (
<nav aria-label="Seiten" className="mt-6 flex flex-wrap items-center gap-3">
{ergebnis.seite > 1 ? (
<Link
href={blaetternZu(ergebnis.seite - 1)}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
zurück
</Link>
) : null}
<span className="text-sm text-nebentext">
Seite {ergebnis.seite} von {ergebnis.seiten}
</span>
{ergebnis.seite < ergebnis.seiten ? (
<Link
href={blaetternZu(ergebnis.seite + 1)}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
weiter
</Link>
) : null}
</nav>
) : null}
</>
);
}