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
@@ -0,0 +1,88 @@
import Link from "next/link";
import { Search } from "lucide-react";
import { FELD } from "@/lib/formular";
/**
* Filter des Protokolls (#023).
*
* Ein GET-Formular, kein React-State: eine gesetzte Suche ist verlinkbar, der
* Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine Server-Komponente.
* Dieselbe Entscheidung wie beim Archiv (#015).
*/
export type ProtokollFilterfelder = {
readonly suche: string;
readonly urheber: string;
readonly von: string;
readonly bis: string;
};
export function ProtokollFilter({
felder,
urheber,
}: {
felder: ProtokollFilterfelder;
urheber: readonly { readonly id: string; readonly name: string }[];
}) {
const gesetzt = Boolean(felder.suche || felder.urheber || felder.von || felder.bis);
return (
<form method="get" className="mt-6 flex flex-col gap-3">
{/* Beim Filtern immer auf Seite 1: die alte Seitenzahl zeigte sonst ins
Leere, wenn die neue Auswahl kuerzer ist. */}
<div className="flex flex-wrap items-end gap-3">
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Aktion oder Lauf</span>
<div className="flex items-center gap-2">
<Search className="size-4 shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
<input
type="search"
name="suche"
defaultValue={felder.suche}
placeholder="Rolle geändert, archiviert …"
className={`${FELD} w-full`}
/>
</div>
</label>
<label className="flex min-w-0 basis-56 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Urheber</span>
<select name="urheber" defaultValue={felder.urheber} className={`${FELD} w-full`}>
<option value="">alle</option>
{urheber.map((eintrag) => (
<option key={eintrag.id} value={eintrag.id}>
{eintrag.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
<span className="text-nebentext">von</span>
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
</label>
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
<span className="text-nebentext">bis</span>
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
</label>
<button
type="submit"
className="min-h-11 shrink-0 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
>
Filtern
</button>
{gesetzt ? (
<Link
href="/admin/protokoll"
className="flex min-h-11 shrink-0 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ücksetzen
</Link>
) : null}
</div>
</form>
);
}
@@ -0,0 +1,115 @@
import Link from "next/link";
import { Bot } from "lucide-react";
import type { ProtokollZeile } from "@/server/queries/protokoll";
/**
* Die Einträge des Protokolls (#023).
*
* Keine Tabelle, sondern eine Liste aus Karten. Eine Tabelle mit sieben Spalten
* ist bei 390 px nicht darstellbar, ohne horizontal zu scrollen — und genau das
* ist ausgeschlossen (CLAUDE.md).
*
* `vorher`/`nachher` stehen in einem `details`: sie sind Json und koennen lang
* werden. Zugeklappt bleibt die Liste lesbar, aufgeklappt steht alles da.
*/
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
/** Json lesbar, ohne Anführungszeichen-Wald für den häufigen Fall eines Objekts. */
function alsText(wert: unknown): string {
if (wert === null || wert === undefined) return "";
if (typeof wert === "object" && !Array.isArray(wert)) {
const paare = Object.entries(wert as Record<string, unknown>).map(
([schluessel, inhalt]) =>
`${schluessel}: ${typeof inhalt === "object" ? JSON.stringify(inhalt) : String(inhalt)}`,
);
return paare.join(" · ");
}
return JSON.stringify(wert);
}
export function ProtokollListe({
zeilen,
kundenEbene,
}: {
zeilen: readonly ProtokollZeile[];
kundenEbene: boolean;
}) {
if (zeilen.length === 0) {
return (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Eintrag passt zu diesen Filtern.
</p>
);
}
return (
<ul className="mt-4 flex flex-col gap-2">
{zeilen.map((zeile) => {
const vorher = alsText(zeile.vorher);
const nachher = alsText(zeile.nachher);
return (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="mono shrink-0 text-xs text-nebentext">
{ZEITPUNKT.format(zeile.erstelltAm)}
</span>
<span className="min-w-0 font-semibold break-words">{zeile.aktion}</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-nebentext">
{zeile.urheber ? (
<span>{zeile.urheber.name}</span>
) : (
/* Ein Systemlauf ist keine Lücke – der Zeitplan des
Verzeichnisabgleichs hat keinen Menschen hinter sich (#023). */
<span className="inline-flex items-center gap-1.5">
<Bot className="size-4" strokeWidth={1.75} aria-hidden="true" />
Systemlauf
</span>
)}
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs">{zeile.entitaet}</span>
{zeile.laufId && zeile.laufTitel ? (
<Link
href={`/laeufe/${zeile.laufId}`}
className="min-w-0 break-words text-akzent underline underline-offset-2"
>
{zeile.laufTitel}
</Link>
) : null}
{kundenEbene && zeile.kundeName ? <span>· {zeile.kundeName}</span> : null}
</div>
{vorher || nachher ? (
<details className="mt-2">
<summary className="inline-flex min-h-11 cursor-pointer items-center text-sm text-nebentext hover:text-text md:min-h-0">
Werte
</summary>
<dl className="mt-1 flex flex-col gap-1 text-sm">
{vorher ? (
<div className="flex flex-wrap gap-x-2">
<dt className="shrink-0 text-nebentext">vorher</dt>
<dd className="mono min-w-0 break-words">{vorher}</dd>
</div>
) : null}
{nachher ? (
<div className="flex flex-wrap gap-x-2">
<dt className="shrink-0 text-nebentext">nachher</dt>
<dd className="mono min-w-0 break-words">{nachher}</dd>
</div>
) : null}
</dl>
</details>
) : null}
</li>
);
})}
</ul>
);
}