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
+312
View File
@@ -0,0 +1,312 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
/**
* Das Lesemodell der Druckansicht (#015).
*
* **Warum das kein zweites `laufZumAbarbeiten` ist** — drei Unterschiede, und
* jeder einzelne waere Grund genug:
*
* 1. **Hier entsteht kein Passwort.** `laufZumAbarbeiten` ruft
* `passwortErzeugen()` und fuellt die Befehle mit `{ art: "befehl" }`. Diese
* Abfrage loest die Vorlagentexte — Schritt-Titel und Befehl — mit
* `{ art: "druck" }` auf; dort steht statt des Passworts `PASSWORT_IM_DRUCK`.
* Ein Schalter an der bestehenden Funktion haette den Zweig, der **nie** ein
* Passwort erzeugen darf, mitten in die Funktion gelegt, die eines erzeugt —
* ein Ausdruck liegt herum und wird abgeheftet, und
* `{{generiertes_passwort}}` ist nirgends gespeichert, also auf dem Papier
* ohnehin nicht nachvollziehbar.
* 2. **Vom Mitarbeiter werden nur drei Felder geholt:** Anzeigename, UPN und
* Abteilung. Nicht „alles lesen und beim Anzeigen weglassen“ — was nicht
* geladen wird, kann auch kein spaeteres Feld in die Vorlage rutschen lassen.
* 3. **Der Ausdruck ist ein Beleg, keine Arbeitsflaeche.** Er traegt keine
* Skripte, keine Ergebnis-Kennungen zum Schreiben, keine Hilfetexte und keine
* Doku-Links — dafuer Zeitpunkte und Namen, die `LaufZeile` nicht fuehrt:
* begonnen am, abgeschlossen von wem, archiviert am und von wem.
*
* Gelesen wird wie ueberall ueber `Lauf.vorlagenVersionId`, also die Version, mit
* der der Lauf gestartet wurde (#009). Die Mandantensicht wendet diese Datei
* selbst an: ein Lauf eines fremden Kunden ist `undefined` — „gibt es nicht“ und
* „darfst du nicht“ sind dieselbe Antwort.
*/
export type DruckPerson = {
readonly name: string;
readonly upn: string | null;
readonly abteilung: string | null;
/**
* Der Name steht nur als Freitext im Lauf; im Verzeichnis gab es die Person
* beim Start noch nicht. Dann gibt es weder UPN noch Abteilung, und der
* Ausdruck sagt das, statt ein leeres Feld zu zeigen.
*/
readonly nurFreitext: boolean;
};
export type DruckKopf = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
/** Hostname, sonst Bezeichnung, sonst der Tag-Code – wie in den Listen. */
readonly geraet: string | null;
readonly tagCode: string | null;
readonly person: DruckPerson | null;
/** Die beim Start gewaehlte Abteilung – nicht die des Verzeichniseintrags. */
readonly abteilungImLauf: string | null;
readonly ticket: string | null;
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN gibt es keine Kundenebene (#008). */
readonly kundeName: string | null;
readonly faelligAm: Date | null;
readonly erstelltAm: Date;
readonly erstelltVon: string | null;
readonly gestartetAm: Date | null;
readonly zugewiesenAn: string | null;
readonly abgeschlossenAm: Date | null;
readonly abgeschlossenVon: string | null;
readonly archiviertAm: Date | null;
readonly archiviertVon: string | null;
};
export type DruckSchritt = {
readonly nummer: number;
readonly titel: string;
readonly pflicht: boolean;
readonly typ: SchrittTyp;
readonly zustand: SchrittZustand;
/** Der erfasste Wert als Text, oder `null`, wenn keiner erfasst wurde. */
readonly wert: string | null;
readonly notiz: string | null;
/** Nur bei `BEFEHL`: der Befehlstext mit eingesetzten Werten, ohne Passwort. */
readonly befehl: string | null;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
readonly kommentare: readonly KommentarZeile[];
};
export type DruckAbschnitt = {
readonly id: string;
readonly titel: string;
/** Zweistellig aus der Position, nie gespeichert (Gestaltungsregel). */
readonly nummer: string;
readonly schritte: readonly DruckSchritt[];
};
export type LaufZumDrucken = {
readonly kopf: DruckKopf;
readonly abschnitte: readonly DruckAbschnitt[];
/** Die Kommentare am Lauf selbst; die an einem Schritt stehen beim Schritt. */
readonly kommentare: readonly KommentarZeile[];
readonly anzahlSchritte: number;
/** Erledigt oder nicht zutreffend – beides ist vom Tisch. */
readonly anzahlVomTisch: number;
};
/**
* Der erfasste Wert als eine Zeile Text.
*
* `wert` ist Json, weil ein Schritt vom Typ `AKTION` (Phase 4) dort auch ein
* Protokoll oder erzeugte Kennungen ablegen soll (CLAUDE.md, „Erweiterbarkeit“).
* Deshalb faellt hier nichts weg, was kein String ist: ein Objekt wird als JSON
* gedruckt. Lesbar genug fuer Papier — und in jedem Fall besser als das
* `[object Object]`, das ein `String(wert)` daraus machte.
*/
function wertAlsText(wert: unknown): string | null {
if (wert === null || wert === undefined) return null;
if (typeof wert === "string") return wert.trim() === "" ? null : wert;
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return JSON.stringify(wert);
}
function befehlAusKonfig(konfig: unknown): string {
if (typeof konfig !== "object" || konfig === null) return "";
const befehl = (konfig as Record<string, unknown>)["befehl"];
return typeof befehl === "string" ? befehl : "";
}
/**
* Der ganze Lauf als Beleg. `undefined` heisst „gibt es nicht oder darfst du
* nicht sehen“.
*/
export async function laufZumDrucken(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZumDrucken | undefined> {
const sichtbar = await sichtbareKundenIds(benutzer);
const mandant = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...mandant] },
select: {
id: true,
titel: true,
status: true,
ticket: true,
faelligAm: true,
erstelltAm: true,
gestartetAm: true,
abgeschlossenAm: true,
archiviertAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
// Genau drei Felder. Kein Geburtsdatum, kein `dn`, kein `sam`, keine
// Kennung aus dem Verzeichnis: was nicht geladen wird, steht auch auf
// keinem Blatt, das irgendwo liegen bleibt.
mitarbeiter: { select: { anzeigename: true, upn: true, abteilung: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
abteilung: { select: { name: true } },
erstelltVon: { select: { name: true } },
zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } },
archiviertVon: { select: { name: true } },
vorlagenVersion: {
select: {
version: true,
vorlage: { select: { name: true } },
abschnitte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
schritte: {
orderBy: { position: "asc" },
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true },
},
},
},
},
},
ergebnisse: {
select: {
id: true,
vorlagenSchrittId: true,
zustand: true,
wert: true,
notiz: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
},
},
},
});
if (!lauf) return undefined;
const kundenEbene = await kundenEbeneSichtbar();
/*
* Die Platzhalterwerte **ohne** Technikernamen und mit dem Anlegezeitpunkt als
* „heute“:
*
* - `{{techniker}}` bliebe sonst der Name dessen, der gerade druckt — und das
* ist nicht unbedingt der, der gearbeitet hat. Wer welchen Schritt erledigt
* hat, steht am Schritt und muss nicht in einem Titel behauptet werden.
* - `{{datum}}` waere sonst der Tag des Ausdrucks. Ein Lauf von vorletztem
* Jahr trueg dann im Schritt-Titel das heutige Datum, und zwei Ausdrucke
* desselben Laufs waeren verschieden.
*/
const werte = await platzhalterWerteDesLaufs({ laufId, technikerName: null, jetzt: lauf.erstelltAm });
// Die Mandantensicht steckt schon in `kommentareDesLaufs`; ein zweites
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
// kann.
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
let nummer = 0;
let anzahlSchritte = 0;
let anzahlVomTisch = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1;
anzahlSchritte += 1;
const ergebnis = ergebnisse.get(schritt.id);
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
if (zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND) {
anzahlVomTisch += 1;
}
const roherBefehl = schritt.typ === SchrittTyp.BEFEHL ? befehlAusKonfig(schritt.konfig) : "";
return {
nummer,
titel: platzhalterErsetzen(schritt.titel, werte, { art: "druck" }).text,
pflicht: schritt.pflicht,
typ: schritt.typ,
zustand,
wert: wertAlsText(ergebnis?.wert),
notiz: ergebnis?.notiz ?? null,
/*
* Der Befehl **unmaskiert**: `{ art: "druck" }` maskiert nicht, und das
* ist hier richtig. Der Beleg haelt fest, was zu tun war; Maskierzeichen
* waeren auf Papier Rauschen, und kopieren kann man von dort ohnehin
* nichts. Wer den Befehl **ausfuehren** will, nimmt die Laufseite — dort
* ist er fuer die gewaehlte Shell maskiert (#012). Das Passwort steht
* hier als `PASSWORT_IM_DRUCK`; erzeugt wird keines.
*/
befehl: roherBefehl === "" ? null : platzhalterErsetzen(roherBefehl, werte, { art: "druck" }).text,
/*
* „Wer wann“ nur, wenn der Schritt nicht mehr offen ist: die
* Ergebniszeilen entstehen beim Anlegen des Laufs, `aktualisiertAm`
* traegt also auch an einem unberuehrten Schritt einen Zeitstempel, der
* nichts sagt. Am Zustand und nicht am Urheber, weil `aktualisiertVonId`
* leer bleiben darf — dieselbe Regel wie in `laufZumAbarbeiten` (#013).
*/
bearbeitetVon:
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
};
});
return {
id: abschnitt.id,
titel: abschnitt.titel,
nummer: String(index + 1).padStart(2, "0"),
schritte,
};
});
const person: DruckPerson | null = lauf.mitarbeiter
? {
name: lauf.mitarbeiter.anzeigename,
upn: lauf.mitarbeiter.upn,
abteilung: lauf.mitarbeiter.abteilung,
nurFreitext: false,
}
: lauf.mitarbeiterName
? { name: lauf.mitarbeiterName, upn: null, abteilung: null, nurFreitext: true }
: null;
return {
kopf: {
id: lauf.id,
titel: lauf.titel,
status: lauf.status,
vorlagenName: lauf.vorlagenVersion.vorlage.name,
version: lauf.vorlagenVersion.version,
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
tagCode: lauf.geraet?.tagCode ?? null,
person,
abteilungImLauf: lauf.abteilung?.name ?? null,
ticket: lauf.ticket,
kundeName: kundenEbene ? lauf.kunde.name : null,
faelligAm: lauf.faelligAm,
erstelltAm: lauf.erstelltAm,
erstelltVon: lauf.erstelltVon?.name ?? null,
gestartetAm: lauf.gestartetAm,
zugewiesenAn: lauf.zugewiesenAn?.name ?? null,
abgeschlossenAm: lauf.abgeschlossenAm,
abgeschlossenVon: lauf.abgeschlossenVon?.name ?? null,
archiviertAm: lauf.archiviertAm,
archiviertVon: lauf.archiviertVon?.name ?? null,
},
abschnitte,
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === null),
anzahlSchritte,
anzahlVomTisch,
};
}