feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user