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
+350
View File
@@ -0,0 +1,350 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, Rolle, SchrittTyp, SchrittZustand, VersionStatus } from "@/generated/prisma/enums";
import { LaufBeleg } from "@/components/druck/lauf-beleg";
import { PASSWORT_IM_DRUCK } from "@/lib/passwort";
import { laufZumDrucken, type LaufZumDrucken } from "@/server/queries/druck";
import { kommentarHinzufuegen } from "@/server/services/kommentare";
import { laufAnlegen, laufZurBearbeitungOeffnen } from "@/server/services/laeufe";
import { schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die Druckansicht eines Laufs (#015).
*
* Geprüft wird am **gerenderten Markup**, nicht am Lesemodell allein: die drei
* Zusagen dieser Seite sind Aussagen darüber, was auf dem Papier steht, und
* genau dort müssen sie gelten.
*
* 1. `{{generiertes_passwort}}` erscheint nie — es wird nicht gespeichert und
* für den Ausdruck auch nicht neu erzeugt.
* 2. Vom Mitarbeiter stehen nur Name, UPN und Abteilung darauf.
* 3. Ein Lauf eines fremden Kunden ist nicht druckbar.
*/
const UMGEBUNG = { ...process.env };
/**
* Sentinel-Werte: sie tauchen in keinem Vorlagentext auf und sind auch kein Teil
* des Anzeigenamens. Steht einer davon im Markup, hat das Lesemodell ein Feld
* mitgebracht, das auf keinen Ausdruck gehört.
*/
const GEHEIM = {
vorname: "VornameNichtDrucken",
nachname: "NachnameNichtDrucken",
mail: "nichtdrucken@firma.example",
sam: "samNichtDrucken",
dn: "CN=DnNichtDrucken,OU=Benutzer,DC=firma,DC=local",
titel: "TitelNichtDrucken",
} as const;
async function grundbestand(modus: Betriebsmodus = Betriebsmodus.INTERN) {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus } });
const technikerZeile = await testPrisma.benutzer.create({
data: { email: "technik@firma.example", name: "Tina Technik", rolle: Rolle.TECHNIKER },
});
return { eigene, techniker: { id: technikerZeile.id, rolle: Rolle.TECHNIKER } };
}
/**
* Eine veröffentlichte Version mit vier Schritten: ein Haken, ein Textschritt,
* ein Befehl mit `{{generiertes_passwort}}` und ein Schritt, dessen **Titel** das
* Passwort enthält.
*
* Der Titel ist kein Wunschfall, sondern der zweite Weg ins Markup: würde nur der
* Befehl mit `{ art: "druck" }` aufgelöst, stünde das Passwort trotzdem auf dem
* Blatt.
*/
async function vorlageMitVersion() {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Onboarding", kundeId: null } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 3, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto im AD", position: 0 },
});
const haken = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto für {{mitarbeiter}} angelegt",
pflicht: true,
position: 0,
},
});
const textSchritt = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Auffälligkeiten notiert",
typ: SchrittTyp.TEXT,
pflicht: false,
position: 1,
},
});
const befehl = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto anlegen",
typ: SchrittTyp.BEFEHL,
pflicht: false,
position: 2,
konfig: {
shell: "powershell",
befehl: 'New-ADUser -Name "{{mitarbeiter}}" -AccountPassword "{{generiertes_passwort}}"',
},
},
});
const passwortImTitel = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Passwort {{generiertes_passwort}} übergeben",
pflicht: false,
position: 3,
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { version, schritte: { haken, textSchritt, befehl, passwortImTitel } };
}
async function mitarbeiterAnlegen(kundeId: string) {
return testPrisma.mitarbeiter.create({
data: {
kundeId,
anzeigename: "Maria Huber",
upn: "maria.huber@firma.example",
abteilung: "Buchhaltung",
vorname: GEHEIM.vorname,
nachname: GEHEIM.nachname,
mail: GEHEIM.mail,
sam: GEHEIM.sam,
dn: GEHEIM.dn,
titel: GEHEIM.titel,
},
});
}
function markup(daten: LaufZumDrucken): string {
// Ohne JSX, damit der Test eine .ts-Datei bleiben kann: `createElement` baut
// dieselbe Komponente, die die Seite rendert.
return renderToStaticMarkup(createElement(LaufBeleg, { daten }));
}
describe.runIf(datenbankVerfuegbar)("Druckansicht eines Laufs", () => {
beforeEach(async () => {
await datenbankLeeren();
// Ohne Kundenbindung greift die Einschraenkung nie – die Mandantentests
// setzen sie selbst.
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
afterEach(() => {
process.env = { ...UMGEBUNG };
});
describe("Das generierte Passwort", () => {
it("erscheint weder im Befehl noch im Titel", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
const daten = await laufZumDrucken(lauf.id, techniker);
expect(daten).toBeDefined();
const html = markup(daten as LaufZumDrucken);
// An beiden Stellen steht der Hinweis statt eines Passworts.
expect(html).toContain("[Passwort – nicht gespeichert]");
expect(PASSWORT_IM_DRUCK).toBe("[Passwort – nicht gespeichert]");
expect(html.match(/\[Passwort – nicht gespeichert\]/gu)).toHaveLength(2);
// Und der Platzhalter selbst bleibt nicht stehen.
expect(html).not.toContain("generiertes_passwort");
});
it("macht zwei Ausdrucke desselben Laufs gleich", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
/*
* Der eigentliche Nachweis: ein erzeugtes Passwort ist bei jedem Aufruf ein
* anderes. Sind zwei Ausdrucke Zeichen für Zeichen gleich, steckt in
* keinem von beiden eines.
*/
const ersteFassung = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken);
const zweiteFassung = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken);
expect(ersteFassung).toBe(zweiteFassung);
});
});
describe("Der Mitarbeiter", () => {
it("steht mit Name, UPN und Abteilung darauf – und mit nichts sonst", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
const html = markup((await laufZumDrucken(lauf.id, techniker)) as LaufZumDrucken);
expect(html).toContain("Maria Huber");
expect(html).toContain("maria.huber@firma.example");
expect(html).toContain("Buchhaltung");
for (const [feld, wert] of Object.entries(GEHEIM)) {
expect(html, `${feld} gehört auf kein Blatt, das herumliegt`).not.toContain(wert);
}
});
it("holt auch im Lesemodell nur diese drei Felder", async () => {
const { eigene, techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, mitarbeiterId: person.id }, techniker);
const daten = await laufZumDrucken(lauf.id, techniker);
// Nicht „alles holen und beim Anzeigen weglassen“: was nicht im Lesemodell
// steht, kann auch keine künftige Komponente versehentlich zeigen.
expect(Object.keys(daten?.kopf.person ?? {}).sort()).toEqual([
"abteilung",
"name",
"nurFreitext",
"upn",
]);
});
it("sagt bei einem Freitext-Namen, dass kein Konto dahintersteht", async () => {
const { techniker } = await grundbestand();
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterName: "Noch Nicht Im Ad" },
techniker,
);
const daten = await laufZumDrucken(lauf.id, techniker);
expect(daten?.kopf.person).toEqual({
name: "Noch Nicht Im Ad",
upn: null,
abteilung: null,
nurFreitext: true,
});
expect(markup(daten as LaufZumDrucken)).toContain("kein Konto im Verzeichnis");
});
});
describe("Der Beleg selbst", () => {
it("trägt Zustand, Wert, Notiz, Bearbeiter und Kommentare", async () => {
const { eigene, techniker } = await grundbestand();
const { version, schritte } = await vorlageMitVersion();
const person = await mitarbeiterAnlegen(eigene.id);
const lauf = await laufAnlegen(
{ vorlagenVersionId: version.id, mitarbeiterId: person.id, ticket: "INC-4711" },
techniker,
);
await laufZurBearbeitungOeffnen(lauf.id, techniker);
await schrittergebnisSetzen(
{ laufId: lauf.id, vorlagenSchrittId: schritte.haken.id, zustand: SchrittZustand.ERLEDIGT },
techniker,
);
const ergebnis = await schrittergebnisSetzen(
{
laufId: lauf.id,
vorlagenSchrittId: schritte.textSchritt.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
wert: "Lüfter läuft laut",
notiz: "Gerät geht ohnehin in die Reparatur",
},
techniker,
);
await kommentarHinzufuegen(
{ laufId: lauf.id, schrittergebnisId: ergebnis.id, rumpf: "Rückfrage beim Hersteller läuft" },
techniker,
);
await kommentarHinzufuegen({ laufId: lauf.id, rumpf: "Übergabe am Montag" }, techniker);
const daten = await laufZumDrucken(lauf.id, techniker);
const html = markup(daten as LaufZumDrucken);
expect(html).toContain("Onboarding");
expect(html).toContain("Version 3");
expect(html).toContain("INC-4711");
expect(html).toContain("Konto im AD");
// Der Titel des Hakenschritts mit aufgelöstem Platzhalter.
expect(html).toContain("Konto für Maria Huber angelegt");
expect(html).toContain("erledigt");
expect(html).toContain("nicht zutreffend");
expect(html).toContain("Lüfter läuft laut");
expect(html).toContain("Gerät geht ohnehin in die Reparatur");
expect(html).toContain("Tina Technik");
expect(html).toContain("Rückfrage beim Hersteller läuft");
expect(html).toContain("Übergabe am Montag");
// Zwei vom Tisch, zwei offen – die Zählung auf dem Blatt.
expect(html).toContain("2 von 4 erledigt oder nicht zutreffend");
expect(daten?.kommentare).toHaveLength(1);
expect(daten?.abschnitte[0]?.schritte[1]?.kommentare).toHaveLength(1);
});
it("zeigt den Kunden nur im Modus Dienstleistung", async () => {
const intern = await grundbestand(Betriebsmodus.INTERN);
const { version } = await vorlageMitVersion();
const lauf = await laufAnlegen({ vorlagenVersionId: version.id }, intern.techniker);
const daten = await laufZumDrucken(lauf.id, intern.techniker);
// Im Modus INTERN gibt es keine Kundenebene – auch nicht auf Papier (#008).
expect(daten?.kopf.kundeName).toBeNull();
expect(markup(daten as LaufZumDrucken)).not.toContain("Eigene Organisation");
});
});
describe("Die Mandantengrenze", () => {
it("macht einen Lauf eines fremden Kunden nicht druckbar", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
// Nur mit gepflegtem Präfix greift die Kundenbindung (siehe sichtbareKundenIds).
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const kundeA = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KUA" } });
const kundeB = await testPrisma.kunde.create({ data: { name: "Kunde B", kuerzel: "KUB" } });
const { version } = await vorlageMitVersion();
const zeile = await testPrisma.benutzer.create({
data: { email: "a@dienst.example", name: "Techniker A", rolle: Rolle.TECHNIKER },
});
const nurKundeA = { id: zeile.id, rolle: Rolle.TECHNIKER };
await testPrisma.benutzerKunde.create({ data: { benutzerId: zeile.id, kundeId: kundeA.id } });
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@dienst.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const admin = { id: adminZeile.id, rolle: Rolle.ADMIN };
const eigener = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeA.id }, admin);
const fremder = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kundeB.id }, admin);
expect(await laufZumDrucken(eigener.id, nurKundeA)).toBeDefined();
// „Gibt es nicht“ und „darfst du nicht“ sind dieselbe Antwort.
expect(await laufZumDrucken(fremder.id, nurKundeA)).toBeUndefined();
// Für einen ungebundenen Admin bleiben beide erreichbar.
expect(await laufZumDrucken(fremder.id, admin)).toBeDefined();
});
it("nennt im Modus Dienstleistung den Kunden auf dem Blatt", async () => {
await grundbestand(Betriebsmodus.DIENSTLEISTUNG);
const kunde = await testPrisma.kunde.create({ data: { name: "Kunde A", kuerzel: "KUA" } });
const { version } = await vorlageMitVersion();
const adminZeile = await testPrisma.benutzer.create({
data: { email: "admin@dienst.example", name: "Alex Admin", rolle: Rolle.ADMIN },
});
const admin = { id: adminZeile.id, rolle: Rolle.ADMIN };
const lauf = await laufAnlegen({ vorlagenVersionId: version.id, kundeId: kunde.id }, admin);
const daten = await laufZumDrucken(lauf.id, admin);
expect(daten?.kopf.kundeName).toBe("Kunde A");
expect(markup(daten as LaufZumDrucken)).toContain("Kunde A");
});
});
});