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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Printer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Der Knopf, der den Druckdialog oeffnet (#015).
|
||||
*
|
||||
* Eine eigene Client-Komponente, weil `window.print()` nur im Browser existiert.
|
||||
* Sie traegt `nicht-drucken`, damit sie nicht selbst auf dem Blatt landet.
|
||||
*
|
||||
* Der Etikettendruck hat mit `EtikettenDrucken` (#014) denselben Knopf. Bewusst
|
||||
* nicht von dort importiert: der Name gehoert zum Etikettenbogen, und ein
|
||||
* gemeinsamer Knopf gehoerte nach `src/components/ui` — eine Datei, die dieser
|
||||
* Meilenstein nicht anfasst.
|
||||
*/
|
||||
export function DruckenKnopf() {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => window.print()}
|
||||
className="nicht-drucken h-11 rounded-pille px-5 text-sm font-semibold md:h-10"
|
||||
>
|
||||
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
|
||||
Jetzt drucken
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
|
||||
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { Wortmarke } from "@/components/wortmarke";
|
||||
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
|
||||
import type { KommentarZeile } from "@/server/queries/laeufe";
|
||||
|
||||
/**
|
||||
* Der Lauf als Blatt Papier (#015).
|
||||
*
|
||||
* Reine Darstellung: alles, was hier steht, kommt fertig aus
|
||||
* `src/server/queries/druck.ts` — Reihenfolge, Nummerierung, aufgeloeste
|
||||
* Platzhalter, Zuordnung der Kommentare. Diese Datei entscheidet nur, wo es
|
||||
* steht.
|
||||
*
|
||||
* **Auf dem Blatt traegt keine Farbe eine Bedeutung.** Die Zustaende stehen als
|
||||
* Wort und als Lucide-Symbol da, nicht als gruene und rote Pille wie am
|
||||
* Bildschirm. Zwei Gruende: ein Ausdruck wird kopiert, gefaxt und in Graustufen
|
||||
* gedruckt, und die Token `--color-erledigt`, `--color-faellig` und
|
||||
* `--color-fehler` stellt `.druck-hell` gar nicht auf eine helle Fassung um —
|
||||
* das Fehlerrot des dunklen Themas ist auf Weiss ein blasses Rosa. Genutzt
|
||||
* werden deshalb nur `text`, `nebentext` und `linie`, und die sind dort hell
|
||||
* gesetzt.
|
||||
*/
|
||||
|
||||
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const TAG = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
|
||||
const STATUS_TEXT: Readonly<Record<LaufStatus, string>> = {
|
||||
OFFEN: "offen",
|
||||
IN_ARBEIT: "in Arbeit",
|
||||
ABGESCHLOSSEN: "abgeschlossen",
|
||||
ARCHIVIERT: "archiviert",
|
||||
};
|
||||
|
||||
const ZUSTAND_TEXT: Readonly<Record<SchrittZustand, string>> = {
|
||||
OFFEN: "offen",
|
||||
ERLEDIGT: "erledigt",
|
||||
NICHT_ZUTREFFEND: "nicht zutreffend",
|
||||
FEHLGESCHLAGEN: "fehlgeschlagen",
|
||||
};
|
||||
|
||||
const ZUSTAND_SYMBOL: Readonly<Record<SchrittZustand, LucideIcon>> = {
|
||||
// Ein leeres Kaestchen ist auf Papier eine Aussage: hier ist nichts geschehen,
|
||||
// und wer will, hakt es mit dem Stift ab.
|
||||
OFFEN: Square,
|
||||
ERLEDIGT: Check,
|
||||
NICHT_ZUTREFFEND: CircleSlash,
|
||||
FEHLGESCHLAGEN: TriangleAlert,
|
||||
};
|
||||
|
||||
/** Eine Zeile der Kopfdaten – nur, wenn es etwas zu zeigen gibt. */
|
||||
function Zeile({ beschriftung, wert }: { beschriftung: string; wert: React.ReactNode }) {
|
||||
if (wert === null || wert === undefined || wert === "") return null;
|
||||
return (
|
||||
<>
|
||||
<dt className="text-nebentext">{beschriftung}</dt>
|
||||
<dd className="min-w-0">{wert}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** „12.03.2026, 08:15, Tina Technik“ – ohne Namen, wenn keiner hinterlegt ist. */
|
||||
function zeitpunktMitName(zeitpunkt: Date | null, name: string | null): string | null {
|
||||
if (!zeitpunkt) return null;
|
||||
return name ? `${ZEITPUNKT.format(zeitpunkt)}, ${name}` : ZEITPUNKT.format(zeitpunkt);
|
||||
}
|
||||
|
||||
export function BelegKopf({
|
||||
kopf,
|
||||
anzahlSchritte,
|
||||
anzahlVomTisch,
|
||||
}: {
|
||||
kopf: DruckKopf;
|
||||
anzahlSchritte: number;
|
||||
anzahlVomTisch: number;
|
||||
}) {
|
||||
return (
|
||||
<header className="border-b border-linie pb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-nebentext">
|
||||
{kopf.vorlagenName} · Version {kopf.version}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-bold">{kopf.titel}</h1>
|
||||
</div>
|
||||
<Wortmarke className="text-sm" />
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<Zeile beschriftung="Status" wert={STATUS_TEXT[kopf.status]} />
|
||||
<Zeile
|
||||
beschriftung="Schritte"
|
||||
wert={`${anzahlVomTisch} von ${anzahlSchritte} erledigt oder nicht zutreffend`}
|
||||
/>
|
||||
{/* Im Modus INTERN ist `kundeName` leer – dort gibt es keine Kundenebene (#008). */}
|
||||
<Zeile beschriftung="Kunde" wert={kopf.kundeName} />
|
||||
<Zeile
|
||||
beschriftung="Gerät"
|
||||
wert={
|
||||
kopf.geraet ? (
|
||||
<>
|
||||
{kopf.geraet}
|
||||
{kopf.tagCode && kopf.tagCode !== kopf.geraet ? (
|
||||
<span className="mono text-nebentext"> · {kopf.tagCode}</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Zeile
|
||||
beschriftung="Person"
|
||||
wert={
|
||||
kopf.person ? (
|
||||
<>
|
||||
{kopf.person.name}
|
||||
{kopf.person.nurFreitext ? (
|
||||
<span className="text-nebentext"> · als Freitext erfasst, kein Konto im Verzeichnis</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
* Vom Mitarbeiter stehen hier nur Name, UPN und Abteilung — mehr holt das
|
||||
* Lesemodell nicht. Ein Ausdruck liegt herum; was darauf steht, ist aus
|
||||
* der Hand gegeben.
|
||||
*/}
|
||||
<Zeile beschriftung="UPN" wert={kopf.person?.upn ?? null} />
|
||||
<Zeile beschriftung="Abteilung" wert={kopf.person?.abteilung ?? null} />
|
||||
{/*
|
||||
* Zwei verschiedene Angaben, deshalb zwei Zeilen: „Abteilung“ ist der
|
||||
* Wert am Verzeichniseintrag, „Abteilung im Lauf“ die beim Start
|
||||
* gewaehlte. Sie koennen auseinanderfallen, und dann sagt genau das
|
||||
* etwas.
|
||||
*/}
|
||||
<Zeile beschriftung="Abteilung im Lauf" wert={kopf.abteilungImLauf} />
|
||||
<Zeile beschriftung="Ticket" wert={kopf.ticket} />
|
||||
<Zeile beschriftung="Zugewiesen an" wert={kopf.zugewiesenAn} />
|
||||
<Zeile beschriftung="Fällig" wert={kopf.faelligAm ? TAG.format(kopf.faelligAm) : null} />
|
||||
<Zeile beschriftung="Angelegt" wert={zeitpunktMitName(kopf.erstelltAm, kopf.erstelltVon)} />
|
||||
<Zeile beschriftung="Begonnen" wert={kopf.gestartetAm ? ZEITPUNKT.format(kopf.gestartetAm) : null} />
|
||||
<Zeile
|
||||
beschriftung="Abgeschlossen"
|
||||
wert={zeitpunktMitName(kopf.abgeschlossenAm, kopf.abgeschlossenVon)}
|
||||
/>
|
||||
<Zeile beschriftung="Archiviert" wert={zeitpunktMitName(kopf.archiviertAm, kopf.archiviertVon)} />
|
||||
</dl>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
|
||||
if (kommentare.length === 0) return null;
|
||||
return (
|
||||
<ul className="mt-2 flex flex-col gap-1 border-l border-linie pl-3">
|
||||
{kommentare.map((kommentar) => (
|
||||
<li key={kommentar.id} className="text-sm">
|
||||
{kommentar.rumpf}
|
||||
<span className="text-nebentext">
|
||||
{" "}
|
||||
— {kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Schritt({ schritt }: { schritt: DruckSchritt }) {
|
||||
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
|
||||
return (
|
||||
<li data-schritt className="flex gap-3 border-t border-linie py-2">
|
||||
<span className="mono mt-0.5 w-5 shrink-0 text-xs text-nebentext">
|
||||
{String(schritt.nummer).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-medium">{schritt.titel}</span>
|
||||
{schritt.pflicht ? <span className="text-xs text-nebentext">Pflicht</span> : null}
|
||||
</div>
|
||||
|
||||
{schritt.befehl ? (
|
||||
// Der Befehl mit eingesetzten Werten. `{{generiertes_passwort}}` steht
|
||||
// hier als „[Passwort – nicht gespeichert]“: es wird nie gespeichert
|
||||
// und fuer den Ausdruck auch nicht neu erzeugt.
|
||||
<pre className="mono mt-1 text-xs whitespace-pre-wrap break-words text-nebentext">
|
||||
{schritt.befehl}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{schritt.wert ? <p className="mono mt-1 text-sm break-words">{schritt.wert}</p> : null}
|
||||
{schritt.notiz ? <p className="mt-1 text-sm text-nebentext">{schritt.notiz}</p> : null}
|
||||
|
||||
{schritt.bearbeitetVon && schritt.bearbeitetAm ? (
|
||||
<p className="mt-1 text-xs text-nebentext">
|
||||
{schritt.bearbeitetVon}, {ZEITPUNKT.format(schritt.bearbeitetAm)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Kommentare kommentare={schritt.kommentare} />
|
||||
</div>
|
||||
|
||||
<span className="flex shrink-0 items-baseline gap-1 text-xs text-nebentext">
|
||||
<Symbol className="size-3.5 shrink-0 translate-y-0.5" strokeWidth={2} aria-hidden="true" />
|
||||
{ZUSTAND_TEXT[schritt.zustand]}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function BelegAbschnitt({ abschnitt }: { abschnitt: DruckAbschnitt }) {
|
||||
return (
|
||||
<section className="mt-5">
|
||||
<h2 className="text-base font-semibold">
|
||||
<span className="mono text-nebentext">{abschnitt.nummer}</span> · {abschnitt.titel}
|
||||
</h2>
|
||||
{abschnitt.schritte.length === 0 ? (
|
||||
<p className="mt-1 text-sm text-nebentext">Dieser Abschnitt hat keine Schritte.</p>
|
||||
) : (
|
||||
<ul className="mt-1 flex flex-col">
|
||||
{abschnitt.schritte.map((schritt) => (
|
||||
<Schritt key={schritt.nummer} schritt={schritt} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BelegKommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
|
||||
return (
|
||||
<section className="mt-6 border-t border-linie pt-4">
|
||||
<h2 className="text-base font-semibold">Kommentare zum Lauf</h2>
|
||||
{kommentare.length === 0 ? (
|
||||
<p className="mt-1 text-sm text-nebentext">Kein Kommentar.</p>
|
||||
) : (
|
||||
<Kommentare kommentare={kommentare} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Der ganze Beleg – Kopf, Abschnitte, Kommentare. */
|
||||
export function LaufBeleg({ daten }: { daten: LaufZumDrucken }) {
|
||||
return (
|
||||
<article>
|
||||
<BelegKopf
|
||||
kopf={daten.kopf}
|
||||
anzahlSchritte={daten.anzahlSchritte}
|
||||
anzahlVomTisch={daten.anzahlVomTisch}
|
||||
/>
|
||||
{daten.abschnitte.map((abschnitt) => (
|
||||
<BelegAbschnitt key={abschnitt.id} abschnitt={abschnitt} />
|
||||
))}
|
||||
<BelegKommentare kommentare={daten.kommentare} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user