Files
Laufzettel/src/app/(app)/archiv/page.tsx
T
Florian WildeandClaude Opus 5 8146e33f48
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
feat(archiv): Uebersicht, Volltextsuche und Druckansicht
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>
2026-09-25 22:59:02 +02:00

128 lines
5.2 KiB
TypeScript
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.
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>
</>
)}
</>
);
}