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
+129
View File
@@ -0,0 +1,129 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Das Archiv am gebauten Stand (#015).
*
* Die Suche selbst prueft `tests/archiv.test.ts` gegen die Datenbank, bis in die
* Mandantengrenze hinein. Hier geht es um den Weg, den ein Mensch nimmt: Wort
* eintippen, Treffer sehen, **erkennen warum**, in den Lauf springen. Ein
* Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
*/
const STEMPEL = Date.now();
const NOTIZ = `Restposten ${STEMPEL} aus dem Lager`;
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
/**
* Ein archivierter Lauf mit einer Notiz, die es sonst nirgends gibt.
*
* Direkt ueber Prisma und nicht ueber die Dienste: hier soll **das Archiv**
* geprueft werden, nicht der Weg dorthin — den prueft `tests/e2e/laeufe.spec.ts`
* vom Anlegen bis zum Archivieren. Die Zeitstempel gehoeren trotzdem dazu, drei
* CHECK-Bedingungen in der Datenbank verlangen sie.
*/
async function archivierterLauf(): Promise<{ id: string; titel: string }> {
const version = await prisma.vorlagenVersion.findFirstOrThrow({
where: { status: "VEROEFFENTLICHT" },
orderBy: { veroeffentlichtAm: "desc" },
select: { id: true, abschnitte: { select: { schritte: { select: { id: true } } } } },
});
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
const schrittId = version.abschnitte.flatMap((abschnitt) => abschnitt.schritte)[0]?.id;
if (!schrittId) throw new Error("Die Vorlage hat keinen Schritt.");
const jetzt = new Date();
const titel = `ZZ E2E Archiv ${STEMPEL}`;
const lauf = await prisma.lauf.create({
data: {
vorlagenVersionId: version.id,
kundeId: kunde.id,
titel,
status: "ARCHIVIERT",
gestartetAm: jetzt,
abgeschlossenAm: jetzt,
archiviertAm: jetzt,
},
});
await prisma.schrittergebnis.create({
data: {
laufId: lauf.id,
vorlagenSchrittId: schrittId,
zustand: "NICHT_ZUTREFFEND",
notiz: NOTIZ,
},
});
return { id: lauf.id, titel };
}
test.describe("Archiv", () => {
test("findet einen Lauf über ein Wort aus einer Schritt-Notiz", async ({ page }) => {
const lauf = await archivierterLauf();
await anmelden(page);
// Am Handy steckt die Navigation im Menü – der Weg dorthin gehört zum Test.
if (test.info().project.name === "handy") {
await page.getByLabel("Menü").click();
}
await page.getByRole("link", { name: "Archiv" }).first().click();
await page.waitForURL("**/archiv");
await page.getByLabel("Suchbegriff").fill(`Restposten ${STEMPEL}`);
await page.getByRole("button", { name: "Suchen" }).click();
const treffer = page.getByRole("listitem").filter({ hasText: lauf.titel });
await expect(treffer).toHaveCount(1);
// Und der Grund steht dabei: Fundstelle und Ausschnitt.
await expect(treffer.getByText("Notiz")).toBeVisible();
await expect(treffer.getByText(/Restposten/)).toBeVisible();
await treffer.getByRole("link").first().click();
await page.waitForURL(`**/laeufe/${lauf.id}`);
await expect(page.getByText(/Dieser Lauf ist archiviert/)).toBeVisible();
});
test("sagt, dass ein einzelnes Zeichen nicht sucht", async ({ page }) => {
// Ein stilles Nichtstun wäre die schlechtere Antwort: der Suchende wartet.
await anmelden(page);
await page.goto("/archiv?suche=a");
// Die Meldung **zum Ergebnis**, nicht der Hinweis am Formular: beide nennen
// die zwei Zeichen, und nur die erste sagt, dass gerade nichts gesucht wurde.
await expect(page.getByText(/ist als Suchbegriff zu kurz/)).toBeVisible();
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
await anmelden(page);
await page.goto("/archiv?suche=Restposten");
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Archiv" } },
select: { id: true },
});
const kennungen = laeufe.map((lauf) => lauf.id);
if (kennungen.length > 0) {
await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: kennungen } } });
await prisma.protokoll.deleteMany({
where: { OR: [{ laufId: { in: kennungen } }, { entitaetId: { in: kennungen } }] },
});
await prisma.lauf.deleteMany({ where: { id: { in: kennungen } } });
}
await prisma.$disconnect();
});