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