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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user