feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.

Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.

Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.

Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.

Closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 22:59:02 +02:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit 8146e33f48
32 changed files with 4202 additions and 38 deletions
+127
View File
@@ -0,0 +1,127 @@
import { Archive } from "lucide-react";
import { ArchivFilter, type ArchivFilterfelder } from "@/components/archiv/archiv-filter";
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import {
ARCHIV_HOECHSTENS,
SUCHE_MINDESTZEICHEN,
archivFilterwerte,
archivSuche,
datumAusText,
type ArchivFilter as ArchivFilterwerte,
} from "@/server/queries/archiv";
/**
* Das Archiv (#015).
*
* Das Gedaechtnis der Anwendung: durchsucht werden abgeschlossene und
* archivierte Laeufe, und zwar in `Lauf.titel`, `Schrittergebnis.notiz`,
* `Schrittergebnis.wert` und `Kommentar.rumpf`. Was genau dabei geschieht, steht
* in `src/server/queries/archiv.ts`; diese Seite liest die Adresszeile, prueft
* ihr Recht und stellt dar.
*
* **Sie prueft selbst.** Die Huelle unter `(app)/layout.tsx` ist das zweite Netz
* und nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben
* Layouts nicht neu (#007).
*/
export default async function ArchivSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/archiv");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const felder: ArchivFilterfelder = {
suche: einzeln("suche"),
vorlage: einzeln("vorlage"),
mitarbeiter: einzeln("mitarbeiter"),
abteilung: einzeln("abteilung"),
geraet: einzeln("geraet"),
bearbeiter: einzeln("bearbeiter"),
kunde: einzeln("kunde"),
von: einzeln("von"),
bis: einzeln("bis"),
laufende: einzeln("laufende") === "1",
};
/*
* Ein vertippter Parameter filtert nicht, statt eine Fehlerseite zu zeigen –
* dieselbe Entscheidung wie bei der Geraeteliste (#011). Eine erfundene
* Kennung bleibt dagegen stehen: sie ist kein Formfehler, sondern eine Suche,
* die nichts findet. Ueber die Mandantengrenze kommt sie nicht (siehe
* archivSuche).
*/
const von = datumAusText(felder.von);
const bis = datumAusText(felder.bis);
const filter: ArchivFilterwerte = {
...(felder.suche ? { suche: felder.suche } : {}),
...(felder.vorlage ? { vorlageId: felder.vorlage } : {}),
...(felder.mitarbeiter ? { mitarbeiterId: felder.mitarbeiter } : {}),
...(felder.abteilung ? { abteilungId: felder.abteilung } : {}),
...(felder.geraet ? { geraetId: felder.geraet } : {}),
...(felder.bearbeiter ? { bearbeiterId: felder.bearbeiter } : {}),
...(felder.kunde ? { kundeId: felder.kunde } : {}),
...(von ? { von } : {}),
...(bis ? { bis } : {}),
...(felder.laufende ? { auchLaufende: true } : {}),
};
const [ergebnis, werte, kundenEbene] = await Promise.all([
archivSuche(benutzer, filter),
archivFilterwerte(benutzer),
kundenEbeneSichtbar(),
]);
// Hervorgehoben wird nur, wonach auch gesucht wurde: ein verworfener Begriff
// hat keine Fundstellen und darf keine markieren.
const begriff = ergebnis.sucheZuKurz ? "" : felder.suche;
return (
<>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Archive className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Archiv
</h1>
<p className="mt-2 max-w-prose text-nebentext">
Durchsucht werden abgeschlossene und archivierte Läufe — ihr Titel, die Notizen und Werte der Schritte
und die Kommentare. Ein archivierter Lauf ändert sich nicht mehr; zurückholen kann ihn nur ein
Administrator.
</p>
<ArchivFilter felder={felder} werte={werte} kundenEbene={kundenEbene} />
{ergebnis.sucheZuKurz ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
„{felder.suche}“ ist als Suchbegriff zu kurz — es braucht mindestens {SUCHE_MINDESTZEICHEN} Zeichen.
Gezeigt werden deshalb die Treffer der übrigen Filter.
</p>
) : null}
{ergebnis.zeilen.length === 0 ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Lauf passt dazu. Laufende Läufe stehen erst mit dem Häkchen „auch offene und laufende Läufe“
dabei — bis zum Abschluss gehören sie in die Laufliste.
</p>
) : (
<>
<p className="mt-6 max-w-prose text-sm text-nebentext">
{ergebnis.zeilen.length === 1 ? "Ein Lauf gefunden" : `${ergebnis.zeilen.length} Läufe gefunden`}
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{ergebnis.gekappt
? `. Es sind mehr als ${ARCHIV_HOECHSTENS}; gezeigt werden die zuletzt abgelegten. Ein engerer Zeitraum oder ein genauerer Begriff holt die übrigen hervor.`
: "."}
</p>
<ul className="mt-3 flex flex-col gap-3">
{ergebnis.zeilen.map((zeile) => (
<ArchivTreffer key={zeile.id} zeile={zeile} begriff={begriff} kundenEbene={kundenEbene} />
))}
</ul>
</>
)}
</>
);
}