feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (push) 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 was merged in pull request #29.
This commit is contained in:
2026-09-25 20:59:38 +00:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit ab5777a57e
32 changed files with 4202 additions and 38 deletions
+205
View File
@@ -0,0 +1,205 @@
import Link from "next/link";
import { Search, SlidersHorizontal } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { AUSWAHL_HOECHSTENS, SUCHE_MINDESTZEICHEN, type ArchivAuswahl } from "@/server/queries/archiv";
/**
* Suchfeld und Filter des Archivs (#015).
*
* Ein GET-Formular, kein React-State: eine gesetzte Suche ist damit verlinkbar,
* der Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine
* Server-Komponente. Dieselbe Entscheidung wie bei der Geraeteliste (#011).
*
* Die sieben zusaetzlichen Filter stehen in einem `details`, das sich von selbst
* oeffnet, sobald einer davon gesetzt ist. Am Handy schoeben neun aufgeklappte
* Felder die Trefferliste sonst aus dem Bild — geschlossen bleiben sie
* trotzdem Teil des Formulars und werden mitgeschickt.
*/
export type ArchivFilterfelder = {
readonly suche: string;
readonly vorlage: string;
readonly mitarbeiter: string;
readonly abteilung: string;
readonly geraet: string;
readonly bearbeiter: string;
readonly kunde: string;
readonly von: string;
readonly bis: string;
readonly laufende: boolean;
};
function Auswahlfeld({
name,
beschriftung,
wert,
leer,
eintraege,
}: {
name: string;
beschriftung: string;
wert: string;
leer: string;
eintraege: readonly { readonly id: string; readonly name: string }[];
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<select name={name} defaultValue={wert} className={`${FELD} w-full`}>
<option value="">{leer}</option>
{eintraege.map((eintrag) => (
<option key={eintrag.id} value={eintrag.id}>
{eintrag.name}
</option>
))}
</select>
</label>
);
}
export function ArchivFilter({
felder,
werte,
kundenEbene,
}: {
felder: ArchivFilterfelder;
werte: ArchivAuswahl;
kundenEbene: boolean;
}) {
const weitereGesetzt = Boolean(
felder.mitarbeiter ||
felder.abteilung ||
felder.geraet ||
felder.bearbeiter ||
felder.kunde ||
felder.von ||
felder.bis ||
felder.laufende,
);
const etwasGesetzt = weitereGesetzt || Boolean(felder.suche || felder.vorlage);
return (
<form method="get" action="/archiv" className="mt-6 flex flex-col gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Suchbegriff</span>
<input
name="suche"
defaultValue={felder.suche}
placeholder="Titel, Notiz, Wert oder Kommentar"
className={`${FELD} w-full`}
/>
</label>
<div className="min-w-0 md:w-64">
<Auswahlfeld
name="vorlage"
beschriftung="Vorlage"
wert={felder.vorlage}
leer="alle Vorlagen"
eintraege={werte.vorlagen}
/>
</div>
</div>
<details open={weitereGesetzt} className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 text-sm text-nebentext hover:text-text">
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
Weitere Filter
</summary>
<div className="grid grid-cols-1 gap-3 border-t border-linie p-4 md:grid-cols-2 lg:grid-cols-3">
<Auswahlfeld
name="mitarbeiter"
beschriftung={kundenEbene ? "Person" : "Mitarbeiter"}
wert={felder.mitarbeiter}
leer="alle"
eintraege={werte.mitarbeiter}
/>
<Auswahlfeld
name="abteilung"
beschriftung="Abteilung"
wert={felder.abteilung}
leer="alle"
eintraege={werte.abteilungen}
/>
<Auswahlfeld
name="geraet"
beschriftung="Gerät"
wert={felder.geraet}
leer="alle"
eintraege={werte.geraete}
/>
<Auswahlfeld
name="bearbeiter"
beschriftung="Bearbeiter"
wert={felder.bearbeiter}
leer="alle"
eintraege={werte.bearbeiter}
/>
{kundenEbene ? (
<Auswahlfeld
name="kunde"
beschriftung="Kunde"
wert={felder.kunde}
leer="alle"
eintraege={werte.kunden}
/>
) : null}
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">abgelegt ab</span>
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
</label>
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">abgelegt bis</span>
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="laufende"
value="1"
defaultChecked={felder.laufende}
className={KAESTCHEN}
/>
auch offene und laufende Läufe
</label>
{/* „Abgelegt“ und „Bearbeiter“ sind beide zusammengesetzt – das muss
dort stehen, wo jemand sie auswählt, sonst filtert er nach etwas
anderem als er denkt. */}
<p className="text-xs text-nebentext md:col-span-2 lg:col-span-3">
Als Ablage gilt der Zeitpunkt der Archivierung, sonst der des Abschlusses, sonst der des Anlegens.
Danach ist die Liste auch sortiert; der Bis-Tag gehört vollständig dazu. Als Bearbeiter zählt, wem
der Lauf zugewiesen war, wer ihn abgeschlossen hat oder wer an einem seiner Schritte gearbeitet
hat.
</p>
{werte.gekappt ? (
<p className="text-xs text-nebentext md:col-span-2 lg:col-span-3">
Zur Auswahl stehen höchstens {AUSWAHL_HOECHSTENS} Einträge je Liste. Steht der gesuchte nicht
dabei, fehlt er in dieser Liste — nicht im Archiv.
</p>
) : null}
</div>
</details>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
<Search className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Suchen
</button>
{etwasGesetzt ? (
<Link
href="/archiv"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
<span className="text-xs text-nebentext">
Der Suchbegriff braucht mindestens {SUCHE_MINDESTZEICHEN} Zeichen; ohne Begriff wird nur gefiltert.
</span>
</div>
</form>
);
}
+142
View File
@@ -0,0 +1,142 @@
import Link from "next/link";
import { Archive, CalendarDays, CheckCircle2 } from "lucide-react";
import type { LaufStatus } from "@/generated/prisma/enums";
import type { ArchivZeile, TrefferFeld } from "@/server/queries/archiv";
/**
* Eine Zeile der Trefferliste (#015).
*
* Sie zeigt **warum** sie da ist: bei jeder Fundstelle steht, in welchem Feld
* der Begriff steht, und der Ausschnitt zeigt ihn im Satz. Ein Treffer ohne
* sichtbaren Grund laesst den Suchenden jeden Lauf einzeln aufklappen.
*
* Karte statt Tabellenzeile: ein Treffer bringt bis zu vier Ausschnitte mit, die
* am Handy umbrechen muessen — in einer Tabelle waere davon nichts lesbar.
*/
const STATUS_TEXT: Readonly<Record<LaufStatus, string>> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
const FELD_TEXT: Readonly<Record<TrefferFeld, string>> = {
TITEL: "Titel",
NOTIZ: "Notiz",
WERT: "Wert",
KOMMENTAR: "Kommentar",
};
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
/**
* Hebt den Suchbegriff im Ausschnitt hervor.
*
* Verglichen wird ueber `indexOf` und nicht ueber einen regulaeren Ausdruck: der
* Begriff kommt aus der Adresszeile, und `.` oder `(` waeren dort sonst
* Sonderzeichen. Ohne Begriff — die Liste zeigt dann ohnehin keine Fundstellen —
* bleibt der Text unveraendert.
*/
function Hervorgehoben({ text, begriff }: { text: string; begriff: string }) {
if (!begriff) return <>{text}</>;
const teile: React.ReactNode[] = [];
const klein = text.toLowerCase();
const gesucht = begriff.toLowerCase();
let stelle = 0;
for (let fund = klein.indexOf(gesucht); fund !== -1; fund = klein.indexOf(gesucht, stelle)) {
if (fund > stelle) teile.push(text.slice(stelle, fund));
teile.push(
<mark key={fund} className="bg-akzent-flaeche text-akzent">
{text.slice(fund, fund + begriff.length)}
</mark>,
);
stelle = fund + begriff.length;
}
if (stelle < text.length) teile.push(text.slice(stelle));
return <>{teile}</>;
}
export function ArchivTreffer({
zeile,
begriff,
kundenEbene,
}: {
zeile: ArchivZeile;
begriff: string;
kundenEbene: boolean;
}) {
const angaben = [
`${zeile.vorlagenName} v${zeile.version}`,
zeile.geraetName,
zeile.mitarbeiterName,
zeile.abteilungName,
zeile.ticket,
zeile.abgeschlossenVon ? `abgeschlossen von ${zeile.abgeschlossenVon}` : null,
zeile.abgeschlossenVon === null && zeile.zugewiesenAn ? `zugewiesen an ${zeile.zugewiesenAn}` : null,
].filter((angabe): angabe is string => Boolean(angabe));
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="p-4">
<div className="flex flex-wrap items-center gap-2">
{zeile.archiviertAm ? (
<Archive className="size-[18px] shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
) : (
<CheckCircle2
className="size-[18px] shrink-0 text-erledigt"
strokeWidth={1.75}
aria-hidden="true"
/>
)}
<Link
href={`/laeufe/${zeile.id}`}
className="font-medium break-words underline-offset-4 hover:underline"
>
<Hervorgehoben text={zeile.titel} begriff={begriff} />
</Link>
<span className={PILLE}>{STATUS_TEXT[zeile.status]}</span>
{kundenEbene ? <span className={PILLE}>{zeile.kundeName}</span> : null}
<span className={`${PILLE} flex items-center gap-1`}>
<CalendarDays className="size-3" strokeWidth={2} aria-hidden="true" />
{DATUM.format(zeile.abgelegtAm)}
</span>
</div>
<p className="mt-2 text-xs break-words text-nebentext">{angaben.join(" · ")}</p>
{zeile.fundstellen.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2">
{zeile.fundstellen.map((stelle, nummer) => (
<li
// Zwei Fundstellen koennen im selben Feld liegen und denselben
// Ausschnitt tragen; die Position ist hier der einzige
// verlaessliche Schluessel.
key={`${stelle.feld}-${nummer}`}
className="flex flex-col gap-1 border-l-2 border-linie pl-3 sm:flex-row sm:items-baseline sm:gap-2"
>
<span className={`${PILLE} w-fit shrink-0`}>{FELD_TEXT[stelle.feld]}</span>
<span className="min-w-0 text-sm break-words text-nebentext">
<Hervorgehoben text={stelle.ausschnitt} begriff={begriff} />
</span>
</li>
))}
</ul>
) : null}
{zeile.weitereFundstellen > 0 ? (
<p className="mt-2 pl-3 text-xs text-nebentext">
{zeile.weitereFundstellen === 1
? "eine weitere Fundstelle in diesem Lauf"
: `${zeile.weitereFundstellen} weitere Fundstellen in diesem Lauf`}
</p>
) : null}
</div>
</li>
);
}