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
+301
View File
@@ -0,0 +1,301 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Betriebsmodus, LaufStatus } from "@/generated/prisma/enums";
import { haeufigeVorlagen, uebersicht } from "@/server/queries/uebersicht";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Die Uebersicht (#015).
*
* Geprueft wird vor allem eines: **was „ueberfaellig“ heisst.** Der Auftrag sagt
* es genau — `faelligAm` verstrichen **und** Status `OFFEN` oder `IN_ARBEIT`.
* Ein abgeschlossener Lauf mit verstrichenem Datum ist fertig, kein Rueckstand;
* ein archivierter erst recht nicht. Waere das falsch, riefe die Seite nach
* Arbeit, die niemand mehr tun kann — und der Ruf waere bald nichts mehr wert.
*/
const GESTERN = new Date("2026-09-24T09:00:00Z");
const HEUTE = new Date("2026-09-25T09:00:00Z");
const MORGEN = new Date("2026-09-26T09:00:00Z");
async function aufbau() {
const eigene = await testPrisma.kunde.create({
data: { name: "Muster", kuerzel: "MUS", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const benutzer = await testPrisma.benutzer.create({
data: { email: "t@firma.example", name: "Ali Demir", rolle: "TECHNIKER" },
});
const andere = await testPrisma.benutzer.create({
data: { email: "k@firma.example", name: "Katrin Winter", rolle: "TECHNIKER" },
});
const vorlage = await testPrisma.vorlage.create({ data: { name: "PC-Neuinstallation" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung" },
});
const schritt = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Ein Schritt", pflicht: true },
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
async function lauf(daten: {
titel: string;
status?: LaufStatus;
faelligAm?: Date | null;
zugewiesenAnId?: string | null;
}) {
const zeile = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: eigene.id,
titel: daten.titel,
status: daten.status ?? LaufStatus.OFFEN,
faelligAm: daten.faelligAm ?? null,
zugewiesenAnId: daten.zugewiesenAnId ?? null,
/*
* Die Zeitstempel gehoeren zum Status, und zwar nicht nur der Form
* halber: drei CHECK-Bedingungen in der Datenbank verlangen sie
* (`Lauf_begonnen_mit_zeitpunkt` und die beiden daneben, #006). Ein Test
* darf an ihnen nicht vorbeischreiben — sonst pruefte er eine Lage, die
* es in der Anwendung nie gibt.
*/
...(daten.status && daten.status !== LaufStatus.OFFEN ? { gestartetAm: GESTERN } : {}),
...(daten.status === LaufStatus.ABGESCHLOSSEN || daten.status === LaufStatus.ARCHIVIERT
? { abgeschlossenAm: GESTERN }
: {}),
...(daten.status === LaufStatus.ARCHIVIERT ? { archiviertAm: GESTERN } : {}),
},
});
await testPrisma.schrittergebnis.create({
data: { laufId: zeile.id, vorlagenSchrittId: schritt.id },
});
return zeile;
}
return { eigene, vorlage, version, benutzer: { id: benutzer.id, rolle: benutzer.rolle }, andere, lauf };
}
describe.runIf(datenbankVerfuegbar)("Übersicht", () => {
beforeEach(datenbankLeeren);
describe("überfällig", () => {
it("sind genau die verstrichenen, die noch laufen", async () => {
const { benutzer, lauf } = await aufbau();
await lauf({ titel: "Gestern fällig, offen", faelligAm: GESTERN });
await lauf({ titel: "Gestern fällig, in Arbeit", faelligAm: GESTERN, status: LaufStatus.IN_ARBEIT });
await lauf({ titel: "Morgen fällig", faelligAm: MORGEN });
await lauf({ titel: "Ohne Frist" });
await lauf({
titel: "Gestern fällig, aber fertig",
faelligAm: GESTERN,
status: LaufStatus.ABGESCHLOSSEN,
});
await lauf({
titel: "Gestern fällig, aber archiviert",
faelligAm: GESTERN,
status: LaufStatus.ARCHIVIERT,
});
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.ueberfaellig.map((zeile) => zeile.titel).sort()).toEqual([
"Gestern fällig, in Arbeit",
"Gestern fällig, offen",
]);
});
it("zählt einen Lauf, der heute fällig ist, nicht dazu", async () => {
/*
* Der Blocker aus dem Review: verglichen wurde mit dem Augenblick statt
* mit dem Tagesbeginn. Ein Datumsfeld im Browser speichert Mitternacht
* UTC — ein heute faelliger Lauf stand damit hierzulande ab zwei Uhr
* morgens in Warnfarbe, an genau dem Tag, an dem er erledigt werden soll.
*/
const { benutzer, lauf } = await aufbau();
await lauf({ titel: "Heute fällig", faelligAm: new Date("2026-09-25T00:00:00Z") });
await lauf({ titel: "Gestern fällig", faelligAm: GESTERN });
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.ueberfaellig.map((zeile) => zeile.titel)).toEqual(["Gestern fällig"]);
// Auch spät am Abend gilt der heutige Tag noch nicht als versäumt.
const spaet = await uebersicht(benutzer, new Date("2026-09-25T23:45:00+02:00"));
expect(spaet.ueberfaellig.map((zeile) => zeile.titel)).toEqual(["Gestern fällig"]);
});
it("zeigt das längst Fällige zuerst", async () => {
const { benutzer, lauf } = await aufbau();
await lauf({ titel: "Seit gestern", faelligAm: GESTERN });
await lauf({ titel: "Seit letzter Woche", faelligAm: new Date("2026-09-18T09:00:00Z") });
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.ueberfaellig[0]?.titel).toBe("Seit letzter Woche");
});
});
describe("mir zugewiesen", () => {
it("zeigt nur die eigenen laufenden", async () => {
const { benutzer, andere, lauf } = await aufbau();
await lauf({ titel: "Meiner", zugewiesenAnId: benutzer.id });
await lauf({ titel: "Meiner, fertig", zugewiesenAnId: benutzer.id, status: LaufStatus.ABGESCHLOSSEN });
await lauf({ titel: "Von Katrin", zugewiesenAnId: andere.id });
await lauf({ titel: "Niemandem" });
const daten = await uebersicht(benutzer, HEUTE);
expect(daten.meine.map((zeile) => zeile.titel)).toEqual(["Meiner"]);
});
});
describe("zuletzt bearbeitet", () => {
it("zeigt auch die Läufe der Kollegen, aber keine archivierten", async () => {
/*
* Bewusst nicht auf die eigene Person eingeschraenkt: ein Team teilt sich
* die Arbeit, und was der Kollege gestern angefasst hat, ist heute oft
* genau die Frage.
*/
const { benutzer, andere, lauf } = await aufbau();
await lauf({ titel: "Von Katrin", zugewiesenAnId: andere.id });
await lauf({ titel: "Archiviert", status: LaufStatus.ARCHIVIERT });
const titel = (await uebersicht(benutzer, HEUTE)).zuletzt.map((zeile) => zeile.titel);
expect(titel).toContain("Von Katrin");
expect(titel).not.toContain("Archiviert");
});
});
describe("Mandantensicht", () => {
it("zeigt keinen Lauf eines fremden Kunden", async () => {
const { benutzer, version } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.benutzerKunde.create({
data: { benutzerId: benutzer.id, kundeId: meiner.id },
});
/*
* Der sichtbare Lauf gehoert **dem gebundenen Kunden**, nicht der eigenen
* Organisation: im Modus Dienstleistung sieht die Technikerin genau die
* Kunden, an die sie gebunden ist. Ein Lauf der eigenen Organisation waere
* hier zu Recht auch weg — beim ersten Anlauf hat dieser Test genau das
* uebersehen und damit nur sich selbst geprueft.
*/
await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: meiner.id,
titel: "Eigener",
faelligAm: GESTERN,
},
});
const fremd = await testPrisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: fremder.id,
titel: "Fremder Lauf",
faelligAm: GESTERN,
zugewiesenAnId: benutzer.id,
},
});
expect(fremd.id).toBeTruthy();
const daten = await uebersicht(benutzer, HEUTE);
const alle = [...daten.ueberfaellig, ...daten.meine, ...daten.zuletzt].map((zeile) => zeile.titel);
expect(alle).not.toContain("Fremder Lauf");
// Und der eigene fehlt auch nicht – sonst filterte der Test nur sich selbst.
expect(daten.zuletzt.map((zeile) => zeile.titel)).toContain("Eigener");
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
});
describe("Schnellstart", () => {
it("zählt über alle Versionen einer Vorlage", async () => {
/*
* „PC-Neuinstallation“ ist dieselbe Arbeit, ob sie in v1 oder v7 steht.
* Eine Rangfolge je Version finge nach jedem Veroeffentlichen bei null an.
*/
const { benutzer, vorlage, version, lauf } = await aufbau();
await lauf({ titel: "Erster" });
await lauf({ titel: "Zweiter" });
const zweite = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: vorlage.id,
version: 2,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
expect(zweite.version).toBe(2);
const andere = await testPrisma.vorlage.create({ data: { name: "Offboarding" } });
const andereVersion = await testPrisma.vorlagenVersion.create({
data: {
vorlageId: andere.id,
version: 1,
status: "VEROEFFENTLICHT",
veroeffentlichtAm: new Date(),
},
});
const haeufig = await haeufigeVorlagen(benutzer);
expect(haeufig[0]?.name).toBe("PC-Neuinstallation");
expect(haeufig[0]?.anzahlLaeufe).toBe(2);
// Gestartet wird die **neueste** Version, gezaehlt werden alle.
expect(haeufig[0]?.versionId).toBe(zweite.id);
expect(haeufig[0]?.versionId).not.toBe(version.id);
expect(haeufig.map((eintrag) => eintrag.versionId)).toContain(andereVersion.id);
});
it("zählt keine Läufe fremder Kunden mit", async () => {
/*
* Der zweite Blocker: der Zaehler trug keine Mandantenbedingung. Eine
* Vorlage ohne Kundenbezug gilt fuer alle (#009) — ihr „12× gestartet“
* war damit eine Auskunft ueber fremde Betriebe, und die Reihenfolge des
* Schnellstarts richtete sich nach deren Arbeit.
*/
const { benutzer, version } = await aufbau();
await testPrisma.einstellung.update({
where: { id: "global" },
data: { modus: Betriebsmodus.DIENSTLEISTUNG },
});
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } });
for (const [nummer, kundeId] of [meiner.id, fremder.id, fremder.id, fremder.id].entries()) {
await testPrisma.lauf.create({
data: { vorlagenVersionId: version.id, kundeId, titel: `Lauf ${nummer}` },
});
}
const haeufig = await haeufigeVorlagen(benutzer);
expect(haeufig[0]?.anzahlLaeufe).toBe(1);
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
it("lässt eine Vorlage ohne veröffentlichte Version weg", async () => {
// Ein Entwurf ist noch niemandes Arbeitsanweisung (#009).
const { benutzer } = await aufbau();
const entwurf = await testPrisma.vorlage.create({ data: { name: "Nur Entwurf" } });
await testPrisma.vorlagenVersion.create({
data: { vorlageId: entwurf.id, version: 1, status: "ENTWURF" },
});
const namen = (await haeufigeVorlagen(benutzer)).map((eintrag) => eintrag.name);
expect(namen).not.toContain("Nur Entwurf");
});
});
});