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>
This commit was merged in pull request #29.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user