feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) 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 is contained in:
Florian Wilde
2026-09-25 22:59:02 +02:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit 8146e33f48
32 changed files with 4202 additions and 38 deletions
+17
View File
@@ -127,6 +127,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
keine Kamera läuft, steht ein Feld für den abgetippten Code (#014)
- Der Tag-Code in der Geräteliste und in der Tag-Liste führt dorthin, wo auch der Scan landet (#014)
- Übersicht: überfällige Läufe, die eigenen Zuweisungen, zuletzt Bearbeitetes und ein Schnellstart
aus den Vorlagen, aus denen im Haus am häufigsten Läufe entstehen (#015)
- Archiv mit Volltextsuche über Titel, Notizen, erfasste Werte und Kommentare in einem gemeinsamen
Lesemodell; die Trefferliste zeigt, **wo** der Begriff steht, und hebt ihn hervor (#015)
- Filter im Archiv nach Vorlage, Person, Abteilung, Gerät, Zeitraum und Bearbeiter — im Modus
Dienstleistung zusätzlich nach Kunde (#015)
- Druckansicht eines Laufs: hell, ohne Navigation, auf A4, mit allen Schritten, Werten, Notizen,
Namen und Zeitstempeln. Sie ersetzt im MVP den PDF-Export (#015)
### Sicherheit
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
@@ -222,3 +231,11 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
laufen (#014)
- Ein Tag, ein Etikett und eine Geräteseite eines fremden Kunden sind nicht erreichbar; „gibt es
nicht“ und „darfst du nicht“ sind dieselbe Antwort (#014)
- In der Druckansicht erscheint vom Mitarbeiter nur Name, UPN und Abteilung — das Lesemodell holt
gar nichts anderes erst. Ein Ausdruck liegt herum; was darauf steht, ist aus der Hand gegeben
(#015)
- Für den Ausdruck entsteht **kein** generiertes Passwort: wo in einem Vorlagentext
`{{generiertes_passwort}}` steht, erscheint `[Passwort – nicht gespeichert]`. Zwei Ausdrucke
desselben Laufs sind deshalb Zeichen für Zeichen gleich — der Test prüft genau das (#015)
- Die Archivsuche setzt jeden Wert als Parameter und maskiert `%`, `_` und `\` für `ILIKE`: die
Suche nach „50 %“ trifft nicht „5099“ (#015)
+36
View File
@@ -240,6 +240,42 @@ ein `'wasm-unsafe-eval'` — die schmale Erlaubnis, WebAssembly zu uebersetzen,
an derselben Stelle ein Feld fuer den abgetippten Code: der steht lesbar unter
jedem QR, und ein Scanner, der nicht startet, darf keine Sackgasse sein.
## 5f. Archiv und Ausdruck: das Gedaechtnis
Die **Archivsuche** geht ueber drei Tabellen — `Lauf.titel`, `Schrittergebnis.notiz`,
`Schrittergebnis.wert` und `Kommentar.rumpf` — und steht deshalb als **eine**
SQL-Anweisung in `src/server/queries/archiv.ts` statt als vier Prisma-Abfragen.
Zwei Gruende, beide handfest:
1. `Schrittergebnis.wert` ist ein Json. Nur `("wert")::text` trifft den
`pg_trgm`-GIN-Index aus #006; eine ganze Json-Spalte als Text zu durchsuchen
kann Prisma nicht.
2. Die Fundstelle gehoert zum Treffer. Wer im Archiv sucht, will nicht wissen
**dass** etwas passt, sondern **wo** — Titel, Notiz, Wert oder Kommentar, mit
dem Ausschnitt drumherum. Vier getrennte Abfragen muessten das hinterher
wieder zusammenlegen.
Jeder Wert geht als Parameter in die Anweisung (`Prisma.sql`), und `%`, `_` und
`\` werden fuer `ILIKE` maskiert: die Suche nach „50 %“ trifft sonst „5099“.
`tsvector` bleibt die spaetere Optimierung, so wie in §5a festgehalten.
**Abgelegt heisst `COALESCE(archiviertAm, abgeschlossenAm, erstelltAm)`** — derselbe
Ausdruck fuer die Sortierung **und** den Zeitraumfilter. Zwei verschiedene
Ausdruecke hiessen: ein Lauf steht ausserhalb des Zeitraums, den die Liste an
seiner Position anzeigt.
Die **Druckansicht** (`/drucken/lauf/<id>`) ist ein eigenes Lesemodell und kein
Schalter an `laufZumAbarbeiten`. Dort entsteht beim Fuellen der Befehle ein
`{{generiertes_passwort}}`; auf einem Blatt, das jemand abheftet, darf keines
stehen — dort erscheint `[Passwort – nicht gespeichert]`, und erzeugt wird
keines. Der Beweis dafuer ist ein Vergleich: zwei Ausdrucke desselben Laufs sind
Zeichen fuer Zeichen gleich, ein erzeugtes Passwort waere bei jedem Aufbau ein
anderes.
Vom Mitarbeiter holt dieses Lesemodell **nur Name, UPN und Abteilung** — nicht
alles und dann gefiltert. Ein Ausdruck liegt herum, wird kopiert und weitergegeben;
was darauf steht, ist aus der Hand gegeben.
## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
+57 -2
View File
@@ -17,11 +17,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min |
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min |
| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min |
| | **Summe** | **20 h 25 min** |
| #015 | M10 – Dashboard und Archiv | 2 h 20 min |
| | **Summe** | **22 h 45 min** |
| Meilenstein | Tatsächlich | Status |
|---|---|---|
| v0.1.0 | 20 h 25 min | in Arbeit |
| v0.1.0 | 22 h 45 min | in Arbeit |
---
@@ -625,3 +626,57 @@ als Unterpunkte mit Aufgabe und Dauer.
- #015 Dashboard und Archiv — die Übersicht für den Alltag und die Volltextsuche über alles, was
abgearbeitet wurde.
---
## 2026-09-25 · Sitzung 11 · 21:35–23:00
**Issue:** #015 (M10)
**Version:** 0.1.0
### Erledigt
- **Drei Teile parallel**: Archiv (Sub-Agent), Druckansicht (Sub-Agent), Übersicht (selbst)
- Archiv mit Volltextsuche über Titel, Notizen, Werte und Kommentare, Fundstellen im Treffer,
sechs Filtern und Mandantensicht in jeder Abfrage
- Druckansicht eines Laufs: hell, ohne Navigation, A4 — ersetzt im MVP den PDF-Export
- Übersicht mit Überfälligem, eigenen Zuweisungen, zuletzt Bearbeitetem und Schnellstart
- Unabhängiger Review: **zwei Blocker**, fünf Befunde, neun Anmerkungen — alle abgearbeitet
- **980 Tests** (vorher 930) und **67 Ende-zu-Ende-Tests** grün
### Entscheidungen
- **`ABGESCHLOSSEN` gehört ins Archiv.** „Abgeschlossen ist nicht archiviert" betrifft den
Schreibschutz, nicht das Suchen: wer die Seriennummer von letzter Woche sucht, weiß nicht, ob
schon jemand archiviert hat.
- **Eine SQL-Anweisung für drei Tabellen.** `Schrittergebnis.wert` ist ein Json — nur
`("wert")::text` trifft den Trigramm-Index, und über Prisma lässt sich eine Json-Spalte nicht als
Text durchsuchen. Vor allem aber gehört die **Fundstelle** zum Treffer: ein Treffer ohne
sichtbaren Grund ist für den Suchenden wertlos.
- **Die Druckansicht ist ein eigenes Lesemodell**, kein Schalter an `laufZumAbarbeiten`. Dort
entsteht beim Füllen der Befehle ein Passwort; auf einem Blatt, das jemand abheftet, darf keines
stehen.
- **Was „überfällig" heißt, steht jetzt in `src/lib/fristen.ts`** — eine Regel, zwei Benutzer
(Lesemodell und Anzeige). Zwei Fassungen derselben Regel laufen auseinander.
### Probleme
- **Beide Blocker steckten in meinem eigenen Teil.** „Überfällig" verglich mit dem Augenblick
statt mit dem Tagesbeginn — ein heute fälliger Lauf stand ab zwei Uhr morgens in Warnfarbe. Und
der Schnellstart zählte Läufe fremder Kunden mit: eine Vorlage ohne Kundenbezug gilt für alle,
ihr Zähler nicht.
- **Mein Test ließ genau die Grenze aus, an der der Fehler saß.** Er kannte „gestern" und
„morgen", aber nicht „heute". Seitdem steht die Regel in einer reinen Funktion mit eigenen
Grenztests.
- **Ein E2E-Test, der nicht scheitern konnte:** „trägt kein Passwort" lief gegen eine Vorlage ohne
Passwortschritt. Er hätte auch bestanden, wenn die Druckansicht Passwörter erzeugte.
- **Der Trigramm-Index auf `Lauf.titel` war strukturell unerreichbar**, weil der Titeltreffer aus
der sechsmal gelesenen und deshalb materialisierten CTE las. Der Review hat das mit 40 000
Läufen gemessen, nicht vermutet — das ist der Unterschied zwischen „sollte passen" und „passt".
- **Siebter Meilenstein in Folge mit einer Zusage ohne Deckung:** „kein
`{{generiertes_passwort}}` im Druck" galt nur für Vorlagentexte.
### Nächster Schritt
- #016 Verzeichnis und LDAP-Abgleich — der größte verbleibende Brocken: `ldapts`, Attribut-Mapping,
Zeitplan, verschlüsseltes Bind-Passwort und ein Integrationstest gegen einen OpenLDAP-Container.
+47 -14
View File
@@ -1,9 +1,9 @@
# #015 M10 – Dashboard und Archiv
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.1.0
**Labels:** phase-1, frontend, backend
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 2 h 20 min
## Ziel
@@ -11,18 +11,51 @@ Der Einstieg am Schreibtisch und das Gedächtnis der Anwendung.
## Aufgaben
- [ ] Dashboard: meine offenen Läufe, überfällige Läufe, zuletzt bearbeitet, Schnellstart der häufigsten Vorlagen
- [ ] Archiv-Volltextsuche über Titel, Notizen, Werte und Kommentare in einem gemeinsamen Query-Modul mit `pg_trgm` und `ILIKE`
- [ ] Filter nach Vorlage, Mitarbeiter, Abteilung, Gerät, Zeitraum und Bearbeiter; im Modus `DIENSTLEISTUNG` zusätzlich nach Kunde
- [ ] Druckansicht eines Laufs: Print-CSS, Kopfdaten, alle Abschnitte und Schritte, Werte, Notizen, Namen, Zeitstempel; ersetzt im MVP den PDF-Export
- [ ] In der Druckansicht erscheinen vom Mitarbeiter nur Name, UPN und Abteilung, sonst nichts
- [ ] Screenshots Übersicht, Archiv, Druckansicht in `docs/screenshots/m10/`
- [x] Dashboard: meine offenen Läufe, überfällige Läufe, zuletzt bearbeitet, Schnellstart der häufigsten Vorlagen
- [x] Archiv-Volltextsuche über Titel, Notizen, Werte und Kommentare in einem gemeinsamen Query-Modul mit `pg_trgm` und `ILIKE`
- [x] Filter nach Vorlage, Mitarbeiter, Abteilung, Gerät, Zeitraum und Bearbeiter; im Modus `DIENSTLEISTUNG` zusätzlich nach Kunde
- [x] Druckansicht eines Laufs: Print-CSS, Kopfdaten, alle Abschnitte und Schritte, Werte, Notizen, Namen, Zeitstempel; ersetzt im MVP den PDF-Export
- [x] In der Druckansicht erscheinen vom Mitarbeiter nur Name, UPN und Abteilung, sonst nichts
- [x] Screenshots Übersicht, Archiv, Druckansicht in `docs/screenshots/m10/`
## Akzeptanzkriterien
- [ ] Test: ein archivierter Lauf wird über ein Wort aus einer Schritt-Notiz gefunden
- [ ] Test: ein archivierter Lauf wird über ein Wort aus einem Kommentar gefunden
- [ ] Test: die Filter kombinieren sich (Vorlage und Zeitraum und Bearbeiter)
- [ ] Test: `{{generiertes_passwort}}` erscheint nicht in der Druckansicht
- [ ] Test: überfällige Läufe sind genau die mit `faelligAm < heute` und Status `OFFEN` oder `IN_ARBEIT`
- [ ] Die Druckansicht ist hell, ohne Navigation, und passt auf A4
- [x] Test: ein archivierter Lauf wird über ein Wort aus einer Schritt-Notiz gefunden
- [x] Test: ein archivierter Lauf wird über ein Wort aus einem Kommentar gefunden
- [x] Test: die Filter kombinieren sich (Vorlage und Zeitraum und Bearbeiter)
- [x] Test: `{{generiertes_passwort}}` erscheint nicht in der Druckansicht
- [x] Test: überfällige Läufe sind genau die mit `faelligAm < heute` und Status `OFFEN` oder `IN_ARBEIT`
- [x] Die Druckansicht ist hell, ohne Navigation, und passt auf A4
## Ergebnis
Erledigt am 2026-09-25. **980 Tests** (vorher 930) und **67 Ende-zu-Ende-Tests** grün, Screenshots in
`docs/screenshots/m10/`.
Drei Teile entstanden parallel: Archiv, Druckansicht und Übersicht. Eine Entscheidung des
Archiv-Teils ist nicht offensichtlich und darum hier festgehalten: **auch `ABGESCHLOSSEN` gehört
ins Archiv.** „Abgeschlossen ist nicht archiviert" (architektur.md §4) betrifft den Schreibschutz,
nicht das Suchen — wer die Seriennummer von letzter Woche sucht, weiß nicht, ob schon jemand
archiviert hat.
### Der unabhängige Review
Zwei Blocker, fünf Befunde, neun Anmerkungen — beide Blocker steckten im Dashboard, also in meinem
eigenen Teil:
- **„Überfällig" traf auch, was heute fällig ist.** Verglichen wurde mit dem Augenblick statt mit
dem Tagesbeginn; ein Datumsfeld speichert Mitternacht UTC, und damit stand ein heute fälliger
Lauf hierzulande ab zwei Uhr morgens in Warnfarbe — an genau dem Tag, an dem er erledigt werden
soll. Die Regel steht jetzt als `fristVerstrichen()` in `src/lib/fristen.ts`, Lesemodell und
Anzeige benutzen dieselbe, und ein Test prüft die Grenze selbst. Der alte Test kannte nur
„gestern" und „morgen".
- **Der Schnellstart zählte Läufe fremder Kunden mit.** Eine Vorlage ohne Kundenbezug gilt für alle
(#009); ihr Zähler sammelte die Läufe sämtlicher Kunden, und „4× gestartet" war damit eine
Auskunft über fremde Betriebe — dazu eine, die die Reihenfolge nach fremder Arbeit sortierte.
Dazu die Befunde: der E2E-Test „trägt kein Passwort" lief gegen eine Vorlage **ohne** Passwortschritt
und konnte gar nicht scheitern; der Trigramm-Index auf `Lauf.titel` war strukturell unerreichbar,
weil der Titeltreffer aus der materialisierten CTE las; `pflichtangaben` wurde berechnet,
dokumentiert und nie benutzt; „N weitere in der Liste" führte bei Überfälligen in die ungefilterte
Liste; und die Zusage „kein `{{generiertes_passwort}}` im Druck" galt nur für Vorlagentexte —
wörtlich in eine Notiz geschriebene Zeichenfolgen gibt der Beleg wieder, wie es ein Beleg tun muss.
+2 -2
View File
@@ -19,9 +19,9 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | erledigt | v0.1.0 | 8 h | 2 h 30 min |
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | erledigt | v0.1.0 | 14 h | 3 h 05 min |
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | erledigt | v0.1.0 | 12 h | 3 h 35 min |
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – |
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | erledigt | v0.1.0 | 8 h | 2 h 20 min |
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | offen | v0.1.0 | 12 h | – |
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
| | **Summe** | | | **136 h** | **20 h 25 min** |
| | **Summe** | | | **136 h** | **22 h 45 min** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+20
View File
@@ -190,6 +190,26 @@ const SEITEN: readonly Seite[] = [
await tab.waitForURL(/\/t\/LZ-/);
},
},
{ name: "archiv", pfad: "/archiv?suche=Inventar" },
{
/*
* Die Druckansicht braucht einen Lauf, und welcher das ist, weiss nur die
* Datenbank. Also den Weg gehen, den auch ein Mensch geht: von der Laufliste
* aus zum Lauf und von dort auf „Drucken“.
*/
name: "druckansicht",
pfad: "/laeufe",
hell: true,
vorbereiten: async (tab) => {
await tab
.getByRole("link", { name: /NB-001/ })
.first()
.click();
await tab.waitForURL(/\/laeufe\/[^/]+$/);
await tab.getByRole("link", { name: "Drucken" }).click();
await tab.waitForURL(/\/drucken\/lauf\//);
},
},
{ name: "etikett-62x29", pfad: "/drucken/etiketten?layout=etikett", hell: true },
{ name: "etiketten-a4", pfad: "/drucken/etiketten?layout=bogen", hell: true },
];
+127
View File
@@ -0,0 +1,127 @@
import { Archive } from "lucide-react";
import { ArchivFilter, type ArchivFilterfelder } from "@/components/archiv/archiv-filter";
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import {
ARCHIV_HOECHSTENS,
SUCHE_MINDESTZEICHEN,
archivFilterwerte,
archivSuche,
datumAusText,
type ArchivFilter as ArchivFilterwerte,
} from "@/server/queries/archiv";
/**
* Das Archiv (#015).
*
* Das Gedaechtnis der Anwendung: durchsucht werden abgeschlossene und
* archivierte Laeufe, und zwar in `Lauf.titel`, `Schrittergebnis.notiz`,
* `Schrittergebnis.wert` und `Kommentar.rumpf`. Was genau dabei geschieht, steht
* in `src/server/queries/archiv.ts`; diese Seite liest die Adresszeile, prueft
* ihr Recht und stellt dar.
*
* **Sie prueft selbst.** Die Huelle unter `(app)/layout.tsx` ist das zweite Netz
* und nicht das erste: Next rendert Layouts bei Navigation innerhalb desselben
* Layouts nicht neu (#007).
*/
export default async function ArchivSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/archiv");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const felder: ArchivFilterfelder = {
suche: einzeln("suche"),
vorlage: einzeln("vorlage"),
mitarbeiter: einzeln("mitarbeiter"),
abteilung: einzeln("abteilung"),
geraet: einzeln("geraet"),
bearbeiter: einzeln("bearbeiter"),
kunde: einzeln("kunde"),
von: einzeln("von"),
bis: einzeln("bis"),
laufende: einzeln("laufende") === "1",
};
/*
* Ein vertippter Parameter filtert nicht, statt eine Fehlerseite zu zeigen –
* dieselbe Entscheidung wie bei der Geraeteliste (#011). Eine erfundene
* Kennung bleibt dagegen stehen: sie ist kein Formfehler, sondern eine Suche,
* die nichts findet. Ueber die Mandantengrenze kommt sie nicht (siehe
* archivSuche).
*/
const von = datumAusText(felder.von);
const bis = datumAusText(felder.bis);
const filter: ArchivFilterwerte = {
...(felder.suche ? { suche: felder.suche } : {}),
...(felder.vorlage ? { vorlageId: felder.vorlage } : {}),
...(felder.mitarbeiter ? { mitarbeiterId: felder.mitarbeiter } : {}),
...(felder.abteilung ? { abteilungId: felder.abteilung } : {}),
...(felder.geraet ? { geraetId: felder.geraet } : {}),
...(felder.bearbeiter ? { bearbeiterId: felder.bearbeiter } : {}),
...(felder.kunde ? { kundeId: felder.kunde } : {}),
...(von ? { von } : {}),
...(bis ? { bis } : {}),
...(felder.laufende ? { auchLaufende: true } : {}),
};
const [ergebnis, werte, kundenEbene] = await Promise.all([
archivSuche(benutzer, filter),
archivFilterwerte(benutzer),
kundenEbeneSichtbar(),
]);
// Hervorgehoben wird nur, wonach auch gesucht wurde: ein verworfener Begriff
// hat keine Fundstellen und darf keine markieren.
const begriff = ergebnis.sucheZuKurz ? "" : felder.suche;
return (
<>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Archive className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Archiv
</h1>
<p className="mt-2 max-w-prose text-nebentext">
Durchsucht werden abgeschlossene und archivierte Läufe — ihr Titel, die Notizen und Werte der Schritte
und die Kommentare. Ein archivierter Lauf ändert sich nicht mehr; zurückholen kann ihn nur ein
Administrator.
</p>
<ArchivFilter felder={felder} werte={werte} kundenEbene={kundenEbene} />
{ergebnis.sucheZuKurz ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
„{felder.suche}“ ist als Suchbegriff zu kurz — es braucht mindestens {SUCHE_MINDESTZEICHEN} Zeichen.
Gezeigt werden deshalb die Treffer der übrigen Filter.
</p>
) : null}
{ergebnis.zeilen.length === 0 ? (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Lauf passt dazu. Laufende Läufe stehen erst mit dem Häkchen „auch offene und laufende Läufe“
dabei — bis zum Abschluss gehören sie in die Laufliste.
</p>
) : (
<>
<p className="mt-6 max-w-prose text-sm text-nebentext">
{ergebnis.zeilen.length === 1 ? "Ein Lauf gefunden" : `${ergebnis.zeilen.length} Läufe gefunden`}
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{ergebnis.gekappt
? `. Es sind mehr als ${ARCHIV_HOECHSTENS}; gezeigt werden die zuletzt abgelegten. Ein engerer Zeitraum oder ein genauerer Begriff holt die übrigen hervor.`
: "."}
</p>
<ul className="mt-3 flex flex-col gap-3">
{ergebnis.zeilen.map((zeile) => (
<ArchivTreffer key={zeile.id} zeile={zeile} begriff={begriff} kundenEbene={kundenEbene} />
))}
</ul>
</>
)}
</>
);
}
+16 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, MessageSquare, Star } from "lucide-react";
import { ArrowLeft, MessageSquare, Printer, Star } from "lucide-react";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
@@ -364,6 +364,21 @@ export default async function LaufSeite({
>
{ausfuehrlich ? "Kompakt" : "Ausführlich"}
</Link>
{/*
* Die Druckansicht liegt ausserhalb der Huelle und traegt deshalb kein
* `Link`-Vorabladen: sie ist hell, ohne Navigation und fuer Papier
* gemacht (#015). Auf dem Blatt steht **kein** Passwort — auch dort
* nicht, wo im Lauf eines stuende.
*/}
<Link
href={`/drucken/lauf/${lauf.id}`}
prefetch={false}
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
Drucken
</Link>
</div>
<div className="mt-6 flex flex-col gap-4">
+283 -17
View File
@@ -1,27 +1,293 @@
export default function Startseite() {
import Link from "next/link";
import { ArrowRight, Clock, ListChecks, Rocket, TriangleAlert } from "lucide-react";
import { LaufStatus } from "@/generated/prisma/enums";
import { fristVerstrichen } from "@/lib/fristen";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import {
UEBERSICHT_JE_BLOCK,
haeufigeVorlagen,
uebersicht,
type HaeufigeVorlage,
type UebersichtsLauf,
} from "@/server/queries/uebersicht";
/**
* Die Uebersicht (#015).
*
* Der Einstieg am Schreibtisch — das Gegenstueck zum Aufkleber am Geraet. Sie
* zeigt vier Dinge und sonst nichts: was ueberfaellig ist, was bei mir liegt,
* woran zuletzt gearbeitet wurde, und was man haeufig anfaengt.
*
* **Ueberfaellig steht oben und ist das Einzige in Warnfarbe.** Alles andere auf
* dieser Seite ist Auskunft; das hier ist eine Aufforderung. Wuerde jeder Block
* rufen, riefe keiner.
*
* Leere Bloecke verschwinden nicht, sie sagen es: eine Seite, deren Abschnitte
* je nach Datenlage kommen und gehen, laesst sich nicht lesen.
*/
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
const STATUS_TEXT: Record<LaufStatus, string> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
function Fortschritt({ lauf }: { lauf: UebersichtsLauf }) {
const anteil = lauf.anzahlSchritte === 0 ? 0 : lauf.anzahlErledigt / lauf.anzahlSchritte;
return (
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 w-24 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={lauf.anzahlSchritte}
aria-valuenow={lauf.anzahlErledigt}
aria-label="Fortschritt"
>
<div className="h-full bg-akzent" style={{ width: `${Math.round(anteil * 100)}%` }} />
</div>
<span className="text-xs text-nebentext">
{lauf.anzahlErledigt} von {lauf.anzahlSchritte}
</span>
</div>
);
}
function LaufZeile({
lauf,
zeigeFaellig,
jetzt,
}: {
lauf: UebersichtsLauf;
zeigeFaellig?: boolean;
jetzt: Date;
}) {
/*
* Rot nur fuer **verstrichene** Fristen, und verstrichen heisst „vor heute“.
* „Mir zugewiesen“ zeigt auch Laeufe, die erst naechste Woche faellig sind;
* ein „seit 26.09. faellig“ in Warnfarbe waere dort schlicht falsch — und wer
* eine Warnung zweimal umsonst sieht, liest die dritte nicht mehr. Dieselbe
* Regel wie im Lesemodell, aus `src/lib/fristen.ts`.
*/
const ueberfaellig = fristVerstrichen(lauf.faelligAm, jetzt);
const untertitel = [
lauf.vorlagenName,
lauf.geraetName,
lauf.mitarbeiterName,
lauf.zugewiesenAn ? `für ${lauf.zugewiesenAn}` : null,
]
.filter((stueck): stueck is string => Boolean(stueck))
.join(" · ");
return (
<li className="border-t border-linie first:border-t-0">
<Link href={`/laeufe/${lauf.id}`} className="block py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words">{lauf.titel}</span>
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{STATUS_TEXT[lauf.status]}
</span>
{zeigeFaellig && lauf.faelligAm ? (
<span
className={`flex items-center gap-1 rounded-pille px-2.5 py-1 text-xs ${
ueberfaellig ? "bg-fehler/15 text-fehler" : "bg-flaeche-hoch text-nebentext"
}`}
>
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
{ueberfaellig ? "seit" : "fällig am"} {DATUM.format(lauf.faelligAm)}
{ueberfaellig ? " fällig" : ""}
</span>
) : null}
</div>
<p className="mt-1 text-xs break-words text-nebentext">{untertitel}</p>
<Fortschritt lauf={lauf} />
</Link>
</li>
);
}
function Block({
titel,
symbol: Symbol,
laeufe,
leer,
mehr,
zeigeFaellig,
warnend,
jetzt,
}: {
titel: string;
symbol: typeof ListChecks;
laeufe: readonly UebersichtsLauf[];
/** Was dasteht, wenn nichts dasteht. Ein leerer Kasten erklärt nichts. */
leer: string;
/**
* Der Weg zum Rest.
*
* Die Beschriftung sagt, wohin es geht — die Laufliste kennt keinen Filter
* „ueberfaellig“, und „weitere in der Liste“ haette Genaueres versprochen, als
* dort zu finden ist (Reviewbefund zu #015).
*/
mehr?: { readonly anzahl: number; readonly pfad: string; readonly beschriftung: string };
zeigeFaellig?: boolean;
warnend?: boolean;
jetzt: Date;
}) {
return (
<section
className={`rounded-karte border bg-flaeche p-5 ${warnend && laeufe.length > 0 ? "border-fehler/40" : "border-linie"}`}
>
<h2 className="flex items-center gap-2 text-lg font-semibold">
{/*
* Ein leerer Block ruft nicht: das Warndreieck ueber „nichts ist
* ueberfaellig“ in Signal-Lime waere ein Ausrufezeichen ohne Satz.
*/}
<Symbol
className={`size-5 ${
laeufe.length === 0 ? "text-nebentext" : warnend ? "text-fehler" : "text-akzent"
}`}
strokeWidth={2}
aria-hidden="true"
/>
{titel}
</h2>
{laeufe.length === 0 ? (
<p className="mt-3 max-w-prose text-sm text-nebentext">{leer}</p>
) : (
<>
<ul className="mt-2 flex flex-col">
{laeufe.map((lauf) => (
<LaufZeile key={lauf.id} lauf={lauf} zeigeFaellig={zeigeFaellig} jetzt={jetzt} />
))}
</ul>
{mehr && mehr.anzahl > 0 ? (
<Link
href={mehr.pfad}
className="mt-3 flex min-h-11 w-fit items-center gap-2 text-sm text-akzent underline underline-offset-2 md:min-h-10"
>
{mehr.anzahl} {mehr.anzahl === 1 ? "weiterer" : "weitere"} — {mehr.beschriftung}
<ArrowRight className="size-4" strokeWidth={1.75} aria-hidden="true" />
</Link>
) : null}
</>
)}
</section>
);
}
function Schnellstart({ vorlagen }: { vorlagen: readonly HaeufigeVorlage[] }) {
if (vorlagen.length === 0) return null;
return (
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Rocket className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Schnellstart
</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Die {vorlagen.length === 1 ? "Vorlage" : `${vorlagen.length} Vorlagen`}, aus denen hier am häufigsten
Läufe entstehen.{" "}
<Link href="/vorlagen" className="underline underline-offset-2 hover:text-text">
Alle Vorlagen
</Link>
</p>
<ul className="mt-4 grid gap-3 sm:grid-cols-2">
{vorlagen.map((vorlage) => (
<li key={vorlage.versionId}>
<Link
href={`/laeufe/starten?vorlage=${vorlage.versionId}`}
className="flex min-h-11 flex-col justify-center rounded-karte border border-linie px-4 py-3 hover:bg-flaeche-hoch md:min-h-10"
>
<span className="font-medium break-words">{vorlage.name}</span>
<span className="mt-0.5 text-xs text-nebentext">
{[
vorlage.kategorie,
`v${vorlage.version}`,
vorlage.anzahlLaeufe === 0 ? "noch nie gestartet" : `${vorlage.anzahlLaeufe}× gestartet`,
]
.filter(Boolean)
.join(" · ")}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
export default async function Uebersichtsseite() {
const { benutzer } = await sitzungErforderlich("lesen", "/");
const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten");
/*
* **Ein** Zeitpunkt fuer die ganze Seite: die Abfrage und die Anzeige muessen
* dieselbe Grenze benutzen, sonst stuende ein Lauf im Block „Ueberfaellig“ und
* traege daneben eine unauffaellige Frist.
*/
const jetzt = new Date();
const [daten, vorlagen] = await Promise.all([
uebersicht(benutzer, jetzt),
// Schnellstart nur für die, die auch starten dürfen.
darfArbeiten ? haeufigeVorlagen(benutzer) : Promise.resolve([]),
]);
return (
<>
<h1 className="text-2xl font-bold">Übersicht</h1>
<p className="mt-2 max-w-prose text-nebentext">
Datenmodell und Anmeldung stehen. Als Nächstes kommen die Betriebsmodi und darauf die Vorlagen —
danach lässt sich der erste Lauf wirklich abarbeiten.
Guten Tag, {benutzer.name}. Was hier steht, ist nach Dringlichkeit sortiert — überfällig zuerst.
</p>
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[
{ nummer: "01", titel: "Betriebsmodus und Kunden", issue: "#008" },
{ nummer: "02", titel: "Vorlagen-Versionierung", issue: "#009" },
{ nummer: "03", titel: "Vorlagen-Editor", issue: "#010" },
].map((karte) => (
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
<p className="mono text-xs text-nebentext">
<span className="text-akzent">{karte.nummer}</span> · als Nächstes
<div className="mt-6 flex flex-col gap-4">
<Block
titel="Überfällig"
symbol={TriangleAlert}
laeufe={daten.ueberfaellig}
leer="Nichts ist überfällig. Ein Lauf gilt als überfällig, sobald sein Fälligkeitsdatum verstrichen ist und er noch offen oder in Arbeit ist."
mehr={{
anzahl: daten.mehrUeberfaellig,
pfad: "/laeufe",
beschriftung: "die Laufliste sortiert nach Fälligkeit",
}}
zeigeFaellig
warnend
jetzt={jetzt}
/>
<Block
titel="Mir zugewiesen"
symbol={ListChecks}
laeufe={daten.meine}
leer="Dir ist gerade nichts zugewiesen. Zugewiesen wird beim Anlegen eines Laufs oder auf der Geräteseite."
mehr={{ anzahl: daten.mehrMeine, pfad: "/laeufe?meine=1", beschriftung: "alle meine Läufe" }}
zeigeFaellig
jetzt={jetzt}
/>
<Block
titel="Zuletzt bearbeitet"
symbol={Clock}
laeufe={daten.zuletzt}
leer="Hier steht, woran zuletzt gearbeitet wurde — auch von Kolleginnen und Kollegen. Noch gibt es keinen Lauf."
jetzt={jetzt}
/>
{darfArbeiten ? <Schnellstart vorlagen={vorlagen} /> : null}
</div>
<p className="mt-4 text-xs text-nebentext">
Je Block höchstens {UEBERSICHT_JE_BLOCK} Läufe.{" "}
<Link href="/laeufe" className="underline underline-offset-2 hover:text-text">
Alle Läufe
</Link>
</p>
<h2 className="mt-2 text-lg font-semibold">{karte.titel}</h2>
<p className="mono mt-1 text-xs text-nebentext">{karte.issue}</p>
</article>
))}
</section>
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { Metadata } from "next";
import { DruckenKnopf } from "@/components/druck/drucken-knopf";
import { LaufBeleg } from "@/components/druck/lauf-beleg";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZumDrucken } from "@/server/queries/druck";
/**
* Die Druckansicht eines Laufs (#015).
*
* Sie ersetzt im MVP den PDF-Export: hell, ohne Navigation, auf A4 — ein
* Ausdruck, den jemand abheftet. Wer ein PDF braucht, druckt in eine Datei.
*
* **Die Seite prueft ihr Recht selbst.** Sie liegt ausserhalb von `(app)`, also
* deckt die Huelle dort sie nicht ab. `lesen` genuegt: die Laufseite zeigt
* denselben Lauf mit demselben Recht, und UPN und Abteilung der Person stehen
* unter `lesen` auch in den Stammdaten (`/mitarbeiter/[id]`). Die Mandantensicht
* wendet das Lesemodell an — ein Lauf eines fremden Kunden ist hier nicht
* erreichbar, und „gibt es nicht“ ist dieselbe Antwort wie „darfst du nicht“.
*
* **Es entsteht kein Passwort.** `{{generiertes_passwort}}` in einem
* Vorlagentext — Schritt-Titel oder Befehl — wird zu `PASSWORT_IM_DRUCK`, weil
* `laufZumDrucken` mit `{ art: "druck" }` aufloest; erzeugt wird keines, und
* gespeichert war ohnehin nie eines. Der Beleg dafuer ist ein Vergleich: zwei
* Ausdrucke desselben Laufs sind Zeichen fuer Zeichen gleich
* (`tests/druckansicht.test.ts`).
*
* Genau formuliert, weil die Zusage sonst mehr behauptet, als sie haelt: hat
* jemand die **Zeichenfolge** `{{generiertes_passwort}}` von Hand in eine Notiz
* oder einen Kommentar geschrieben, steht sie dort auch auf dem Blatt. Das ist
* kein Geheimnis, sondern ein Platzhalter, den niemand aufgeloest hat — und der
* Ausdruck gibt Notizen und Kommentare wortgetreu wieder, wie es ein Beleg tun
* muss (Reviewbefund zu #015).
*/
export const metadata: Metadata = {
// Bewusst ohne Lauftitel: ein Titel aus den Daten verlangte eine zweite
// Abfrage in `generateMetadata`, also den ganzen Lauf ein zweites Mal.
title: "Lauf drucken",
};
/**
* Das Seitenformat.
*
* `@page` laesst sich nicht auf eine Klasse einschraenken und kann deshalb nicht
* in globals.css stehen — dort galte es fuer jede Seite, auch fuer die
* Etikettenbogen mit ihren eigenen Massen (#014). Also schreibt diese Seite ihre
* eigene Regel.
*
* Links mehr Rand als rechts: das Blatt wird gelocht und abgeheftet, und ein
* Locher frisst die ersten Millimeter.
*/
const DRUCKSTIL = `
@page { size: A4; margin: 14mm 14mm 14mm 20mm; }
@media print {
/*
* Der dunkle Grund kommt vom Wurzel-Layout und liegt **hinter** der hellen
* Huelle. Am Bildschirm deckt min-h-screen ihn ab; auf Papier gibt es
* keinen Bildschirm, und ein Browser, der den Hintergrund des body
* mitdruckt, faerbte das ganze Blatt (#015).
*/
html, body { background: #ffffff; }
/* Ein Schritt gehoert zusammen – Zustand, Wert und Notiz auf einer Seite. */
[data-schritt] { break-inside: avoid; }
/* Eine Abschnittsueberschrift allein am Seitenfuss zeigt auf nichts. */
h2 { break-after: avoid; }
/*
* Voreingestellt ist "economy": dort darf der Browser Farben und Flaechen
* anpassen, um Toner zu sparen. "exact" verlangt, sie zu drucken. Betroffen
* sind auf diesem Blatt die Haarlinien zwischen den Schritten und der
* Akzentpunkt der Wortmarke — sonst ist nichts eingefaerbt.
*/
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
`;
const GEDRUCKT_AM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export default async function LaufDruckSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/drucken/lauf/${id}`);
const daten = await laufZumDrucken(id, { id: benutzer.id, rolle: benutzer.rolle });
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort.
if (!daten) notFound();
return (
<>
<style>{DRUCKSTIL}</style>
<div className="mx-auto w-full max-w-[210mm] px-4 py-6 print:max-w-none print:px-0 print:py-0">
<div className="nicht-drucken mb-6 flex flex-wrap items-center gap-3">
<Link
href={`/laeufe/${daten.kopf.id}`}
className="flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text md:min-h-10"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Zum Lauf
</Link>
<DruckenKnopf />
<p className="text-xs text-nebentext">
Im Druckdialog Kopf- und Fußzeilen abschalten — das Blatt bringt beides selbst mit.
</p>
</div>
<LaufBeleg daten={daten} />
{/*
* „Gedruckt am“ steht bewusst darauf: ein Lauf in Arbeit kann sich nach
* dem Ausdruck weiter aendern, und dann sagt dieses Datum, wie alt das
* Blatt in der Mappe ist. Die Kennung steht dabei, weil man mit ihr den
* Lauf wiederfindet — der Lauftitel ist nicht eindeutig.
*/}
<footer className="mt-6 border-t border-linie pt-3 text-xs text-nebentext">
Gedruckt am {GEDRUCKT_AM.format(new Date())} · Lauf <span className="mono">{daten.kopf.id}</span>
</footer>
</div>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Eigene, helle Huelle fuer die Druckansicht eines Laufs (#015).
*
* Bewusst **ausserhalb** von `src/app/(app)`: die Huelle dort bringt Seitenleiste,
* Kopfzeile und den dunklen Grund mit, und das gehoert auf kein Blatt. Die
* Rechtepruefung uebernimmt diese Huelle deshalb **nicht** — die Seite darunter
* prueft selbst, so wie es CLAUDE.md fuer alle Seiten verlangt. Denselben Aufbau
* hat der Etikettendruck (#014).
*
* `druck-hell` stellt die Farbtoken dieses Teilbaums auf die helle Fassung um
* (globals.css); `min-h-screen` deckt den dunklen Grund des Wurzel-Layouts auch
* am Bildschirm ab, damit die Vorschau aussieht wie das Papier.
*/
export default function LaufDruckLayout({ children }: { children: React.ReactNode }) {
return <div className="druck-hell min-h-screen">{children}</div>;
}
+205
View File
@@ -0,0 +1,205 @@
import Link from "next/link";
import { Search, SlidersHorizontal } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { AUSWAHL_HOECHSTENS, SUCHE_MINDESTZEICHEN, type ArchivAuswahl } from "@/server/queries/archiv";
/**
* Suchfeld und Filter des Archivs (#015).
*
* Ein GET-Formular, kein React-State: eine gesetzte Suche ist damit verlinkbar,
* der Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine
* Server-Komponente. Dieselbe Entscheidung wie bei der Geraeteliste (#011).
*
* Die sieben zusaetzlichen Filter stehen in einem `details`, das sich von selbst
* oeffnet, sobald einer davon gesetzt ist. Am Handy schoeben neun aufgeklappte
* Felder die Trefferliste sonst aus dem Bild — geschlossen bleiben sie
* trotzdem Teil des Formulars und werden mitgeschickt.
*/
export type ArchivFilterfelder = {
readonly suche: string;
readonly vorlage: string;
readonly mitarbeiter: string;
readonly abteilung: string;
readonly geraet: string;
readonly bearbeiter: string;
readonly kunde: string;
readonly von: string;
readonly bis: string;
readonly laufende: boolean;
};
function Auswahlfeld({
name,
beschriftung,
wert,
leer,
eintraege,
}: {
name: string;
beschriftung: string;
wert: string;
leer: string;
eintraege: readonly { readonly id: string; readonly name: string }[];
}) {
return (
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">{beschriftung}</span>
<select name={name} defaultValue={wert} className={`${FELD} w-full`}>
<option value="">{leer}</option>
{eintraege.map((eintrag) => (
<option key={eintrag.id} value={eintrag.id}>
{eintrag.name}
</option>
))}
</select>
</label>
);
}
export function ArchivFilter({
felder,
werte,
kundenEbene,
}: {
felder: ArchivFilterfelder;
werte: ArchivAuswahl;
kundenEbene: boolean;
}) {
const weitereGesetzt = Boolean(
felder.mitarbeiter ||
felder.abteilung ||
felder.geraet ||
felder.bearbeiter ||
felder.kunde ||
felder.von ||
felder.bis ||
felder.laufende,
);
const etwasGesetzt = weitereGesetzt || Boolean(felder.suche || felder.vorlage);
return (
<form method="get" action="/archiv" className="mt-6 flex flex-col gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Suchbegriff</span>
<input
name="suche"
defaultValue={felder.suche}
placeholder="Titel, Notiz, Wert oder Kommentar"
className={`${FELD} w-full`}
/>
</label>
<div className="min-w-0 md:w-64">
<Auswahlfeld
name="vorlage"
beschriftung="Vorlage"
wert={felder.vorlage}
leer="alle Vorlagen"
eintraege={werte.vorlagen}
/>
</div>
</div>
<details open={weitereGesetzt} className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 text-sm text-nebentext hover:text-text">
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
Weitere Filter
</summary>
<div className="grid grid-cols-1 gap-3 border-t border-linie p-4 md:grid-cols-2 lg:grid-cols-3">
<Auswahlfeld
name="mitarbeiter"
beschriftung={kundenEbene ? "Person" : "Mitarbeiter"}
wert={felder.mitarbeiter}
leer="alle"
eintraege={werte.mitarbeiter}
/>
<Auswahlfeld
name="abteilung"
beschriftung="Abteilung"
wert={felder.abteilung}
leer="alle"
eintraege={werte.abteilungen}
/>
<Auswahlfeld
name="geraet"
beschriftung="Gerät"
wert={felder.geraet}
leer="alle"
eintraege={werte.geraete}
/>
<Auswahlfeld
name="bearbeiter"
beschriftung="Bearbeiter"
wert={felder.bearbeiter}
leer="alle"
eintraege={werte.bearbeiter}
/>
{kundenEbene ? (
<Auswahlfeld
name="kunde"
beschriftung="Kunde"
wert={felder.kunde}
leer="alle"
eintraege={werte.kunden}
/>
) : null}
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">abgelegt ab</span>
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
</label>
<label className="flex min-w-0 flex-col gap-1.5 text-sm">
<span className="text-nebentext">abgelegt bis</span>
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="laufende"
value="1"
defaultChecked={felder.laufende}
className={KAESTCHEN}
/>
auch offene und laufende Läufe
</label>
{/* „Abgelegt“ und „Bearbeiter“ sind beide zusammengesetzt – das muss
dort stehen, wo jemand sie auswählt, sonst filtert er nach etwas
anderem als er denkt. */}
<p className="text-xs text-nebentext md:col-span-2 lg:col-span-3">
Als Ablage gilt der Zeitpunkt der Archivierung, sonst der des Abschlusses, sonst der des Anlegens.
Danach ist die Liste auch sortiert; der Bis-Tag gehört vollständig dazu. Als Bearbeiter zählt, wem
der Lauf zugewiesen war, wer ihn abgeschlossen hat oder wer an einem seiner Schritte gearbeitet
hat.
</p>
{werte.gekappt ? (
<p className="text-xs text-nebentext md:col-span-2 lg:col-span-3">
Zur Auswahl stehen höchstens {AUSWAHL_HOECHSTENS} Einträge je Liste. Steht der gesuchte nicht
dabei, fehlt er in dieser Liste — nicht im Archiv.
</p>
) : null}
</div>
</details>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
className="flex min-h-11 items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent md:min-h-10"
>
<Search className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Suchen
</button>
{etwasGesetzt ? (
<Link
href="/archiv"
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
<span className="text-xs text-nebentext">
Der Suchbegriff braucht mindestens {SUCHE_MINDESTZEICHEN} Zeichen; ohne Begriff wird nur gefiltert.
</span>
</div>
</form>
);
}
+142
View File
@@ -0,0 +1,142 @@
import Link from "next/link";
import { Archive, CalendarDays, CheckCircle2 } from "lucide-react";
import type { LaufStatus } from "@/generated/prisma/enums";
import type { ArchivZeile, TrefferFeld } from "@/server/queries/archiv";
/**
* Eine Zeile der Trefferliste (#015).
*
* Sie zeigt **warum** sie da ist: bei jeder Fundstelle steht, in welchem Feld
* der Begriff steht, und der Ausschnitt zeigt ihn im Satz. Ein Treffer ohne
* sichtbaren Grund laesst den Suchenden jeden Lauf einzeln aufklappen.
*
* Karte statt Tabellenzeile: ein Treffer bringt bis zu vier Ausschnitte mit, die
* am Handy umbrechen muessen — in einer Tabelle waere davon nichts lesbar.
*/
const STATUS_TEXT: Readonly<Record<LaufStatus, string>> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
const FELD_TEXT: Readonly<Record<TrefferFeld, string>> = {
TITEL: "Titel",
NOTIZ: "Notiz",
WERT: "Wert",
KOMMENTAR: "Kommentar",
};
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
/**
* Hebt den Suchbegriff im Ausschnitt hervor.
*
* Verglichen wird ueber `indexOf` und nicht ueber einen regulaeren Ausdruck: der
* Begriff kommt aus der Adresszeile, und `.` oder `(` waeren dort sonst
* Sonderzeichen. Ohne Begriff — die Liste zeigt dann ohnehin keine Fundstellen —
* bleibt der Text unveraendert.
*/
function Hervorgehoben({ text, begriff }: { text: string; begriff: string }) {
if (!begriff) return <>{text}</>;
const teile: React.ReactNode[] = [];
const klein = text.toLowerCase();
const gesucht = begriff.toLowerCase();
let stelle = 0;
for (let fund = klein.indexOf(gesucht); fund !== -1; fund = klein.indexOf(gesucht, stelle)) {
if (fund > stelle) teile.push(text.slice(stelle, fund));
teile.push(
<mark key={fund} className="bg-akzent-flaeche text-akzent">
{text.slice(fund, fund + begriff.length)}
</mark>,
);
stelle = fund + begriff.length;
}
if (stelle < text.length) teile.push(text.slice(stelle));
return <>{teile}</>;
}
export function ArchivTreffer({
zeile,
begriff,
kundenEbene,
}: {
zeile: ArchivZeile;
begriff: string;
kundenEbene: boolean;
}) {
const angaben = [
`${zeile.vorlagenName} v${zeile.version}`,
zeile.geraetName,
zeile.mitarbeiterName,
zeile.abteilungName,
zeile.ticket,
zeile.abgeschlossenVon ? `abgeschlossen von ${zeile.abgeschlossenVon}` : null,
zeile.abgeschlossenVon === null && zeile.zugewiesenAn ? `zugewiesen an ${zeile.zugewiesenAn}` : null,
].filter((angabe): angabe is string => Boolean(angabe));
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="p-4">
<div className="flex flex-wrap items-center gap-2">
{zeile.archiviertAm ? (
<Archive className="size-[18px] shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
) : (
<CheckCircle2
className="size-[18px] shrink-0 text-erledigt"
strokeWidth={1.75}
aria-hidden="true"
/>
)}
<Link
href={`/laeufe/${zeile.id}`}
className="font-medium break-words underline-offset-4 hover:underline"
>
<Hervorgehoben text={zeile.titel} begriff={begriff} />
</Link>
<span className={PILLE}>{STATUS_TEXT[zeile.status]}</span>
{kundenEbene ? <span className={PILLE}>{zeile.kundeName}</span> : null}
<span className={`${PILLE} flex items-center gap-1`}>
<CalendarDays className="size-3" strokeWidth={2} aria-hidden="true" />
{DATUM.format(zeile.abgelegtAm)}
</span>
</div>
<p className="mt-2 text-xs break-words text-nebentext">{angaben.join(" · ")}</p>
{zeile.fundstellen.length > 0 ? (
<ul className="mt-3 flex flex-col gap-2">
{zeile.fundstellen.map((stelle, nummer) => (
<li
// Zwei Fundstellen koennen im selben Feld liegen und denselben
// Ausschnitt tragen; die Position ist hier der einzige
// verlaessliche Schluessel.
key={`${stelle.feld}-${nummer}`}
className="flex flex-col gap-1 border-l-2 border-linie pl-3 sm:flex-row sm:items-baseline sm:gap-2"
>
<span className={`${PILLE} w-fit shrink-0`}>{FELD_TEXT[stelle.feld]}</span>
<span className="min-w-0 text-sm break-words text-nebentext">
<Hervorgehoben text={stelle.ausschnitt} begriff={begriff} />
</span>
</li>
))}
</ul>
) : null}
{zeile.weitereFundstellen > 0 ? (
<p className="mt-2 pl-3 text-xs text-nebentext">
{zeile.weitereFundstellen === 1
? "eine weitere Fundstelle in diesem Lauf"
: `${zeile.weitereFundstellen} weitere Fundstellen in diesem Lauf`}
</p>
) : null}
</div>
</li>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Der Knopf, der den Druckdialog oeffnet (#015).
*
* Eine eigene Client-Komponente, weil `window.print()` nur im Browser existiert.
* Sie traegt `nicht-drucken`, damit sie nicht selbst auf dem Blatt landet.
*
* Der Etikettendruck hat mit `EtikettenDrucken` (#014) denselben Knopf. Bewusst
* nicht von dort importiert: der Name gehoert zum Etikettenbogen, und ein
* gemeinsamer Knopf gehoerte nach `src/components/ui` — eine Datei, die dieser
* Meilenstein nicht anfasst.
*/
export function DruckenKnopf() {
return (
<Button
type="button"
onClick={() => window.print()}
className="nicht-drucken h-11 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Jetzt drucken
</Button>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { Check, CircleSlash, Square, TriangleAlert, type LucideIcon } from "lucide-react";
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
import { Wortmarke } from "@/components/wortmarke";
import type { DruckAbschnitt, DruckKopf, DruckSchritt, LaufZumDrucken } from "@/server/queries/druck";
import type { KommentarZeile } from "@/server/queries/laeufe";
/**
* Der Lauf als Blatt Papier (#015).
*
* Reine Darstellung: alles, was hier steht, kommt fertig aus
* `src/server/queries/druck.ts` — Reihenfolge, Nummerierung, aufgeloeste
* Platzhalter, Zuordnung der Kommentare. Diese Datei entscheidet nur, wo es
* steht.
*
* **Auf dem Blatt traegt keine Farbe eine Bedeutung.** Die Zustaende stehen als
* Wort und als Lucide-Symbol da, nicht als gruene und rote Pille wie am
* Bildschirm. Zwei Gruende: ein Ausdruck wird kopiert, gefaxt und in Graustufen
* gedruckt, und die Token `--color-erledigt`, `--color-faellig` und
* `--color-fehler` stellt `.druck-hell` gar nicht auf eine helle Fassung um —
* das Fehlerrot des dunklen Themas ist auf Weiss ein blasses Rosa. Genutzt
* werden deshalb nur `text`, `nebentext` und `linie`, und die sind dort hell
* gesetzt.
*/
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const TAG = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
const STATUS_TEXT: Readonly<Record<LaufStatus, string>> = {
OFFEN: "offen",
IN_ARBEIT: "in Arbeit",
ABGESCHLOSSEN: "abgeschlossen",
ARCHIVIERT: "archiviert",
};
const ZUSTAND_TEXT: Readonly<Record<SchrittZustand, string>> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
NICHT_ZUTREFFEND: "nicht zutreffend",
FEHLGESCHLAGEN: "fehlgeschlagen",
};
const ZUSTAND_SYMBOL: Readonly<Record<SchrittZustand, LucideIcon>> = {
// Ein leeres Kaestchen ist auf Papier eine Aussage: hier ist nichts geschehen,
// und wer will, hakt es mit dem Stift ab.
OFFEN: Square,
ERLEDIGT: Check,
NICHT_ZUTREFFEND: CircleSlash,
FEHLGESCHLAGEN: TriangleAlert,
};
/** Eine Zeile der Kopfdaten – nur, wenn es etwas zu zeigen gibt. */
function Zeile({ beschriftung, wert }: { beschriftung: string; wert: React.ReactNode }) {
if (wert === null || wert === undefined || wert === "") return null;
return (
<>
<dt className="text-nebentext">{beschriftung}</dt>
<dd className="min-w-0">{wert}</dd>
</>
);
}
/** „12.03.2026, 08:15, Tina Technik“ – ohne Namen, wenn keiner hinterlegt ist. */
function zeitpunktMitName(zeitpunkt: Date | null, name: string | null): string | null {
if (!zeitpunkt) return null;
return name ? `${ZEITPUNKT.format(zeitpunkt)}, ${name}` : ZEITPUNKT.format(zeitpunkt);
}
export function BelegKopf({
kopf,
anzahlSchritte,
anzahlVomTisch,
}: {
kopf: DruckKopf;
anzahlSchritte: number;
anzahlVomTisch: number;
}) {
return (
<header className="border-b border-linie pb-4">
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div className="min-w-0">
<p className="text-xs text-nebentext">
{kopf.vorlagenName} · Version {kopf.version}
</p>
<h1 className="mt-1 text-2xl font-bold">{kopf.titel}</h1>
</div>
<Wortmarke className="text-sm" />
</div>
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
<Zeile beschriftung="Status" wert={STATUS_TEXT[kopf.status]} />
<Zeile
beschriftung="Schritte"
wert={`${anzahlVomTisch} von ${anzahlSchritte} erledigt oder nicht zutreffend`}
/>
{/* Im Modus INTERN ist `kundeName` leer – dort gibt es keine Kundenebene (#008). */}
<Zeile beschriftung="Kunde" wert={kopf.kundeName} />
<Zeile
beschriftung="Gerät"
wert={
kopf.geraet ? (
<>
{kopf.geraet}
{kopf.tagCode && kopf.tagCode !== kopf.geraet ? (
<span className="mono text-nebentext"> · {kopf.tagCode}</span>
) : null}
</>
) : null
}
/>
<Zeile
beschriftung="Person"
wert={
kopf.person ? (
<>
{kopf.person.name}
{kopf.person.nurFreitext ? (
<span className="text-nebentext"> · als Freitext erfasst, kein Konto im Verzeichnis</span>
) : null}
</>
) : null
}
/>
{/*
* Vom Mitarbeiter stehen hier nur Name, UPN und Abteilung — mehr holt das
* Lesemodell nicht. Ein Ausdruck liegt herum; was darauf steht, ist aus
* der Hand gegeben.
*/}
<Zeile beschriftung="UPN" wert={kopf.person?.upn ?? null} />
<Zeile beschriftung="Abteilung" wert={kopf.person?.abteilung ?? null} />
{/*
* Zwei verschiedene Angaben, deshalb zwei Zeilen: „Abteilung“ ist der
* Wert am Verzeichniseintrag, „Abteilung im Lauf“ die beim Start
* gewaehlte. Sie koennen auseinanderfallen, und dann sagt genau das
* etwas.
*/}
<Zeile beschriftung="Abteilung im Lauf" wert={kopf.abteilungImLauf} />
<Zeile beschriftung="Ticket" wert={kopf.ticket} />
<Zeile beschriftung="Zugewiesen an" wert={kopf.zugewiesenAn} />
<Zeile beschriftung="Fällig" wert={kopf.faelligAm ? TAG.format(kopf.faelligAm) : null} />
<Zeile beschriftung="Angelegt" wert={zeitpunktMitName(kopf.erstelltAm, kopf.erstelltVon)} />
<Zeile beschriftung="Begonnen" wert={kopf.gestartetAm ? ZEITPUNKT.format(kopf.gestartetAm) : null} />
<Zeile
beschriftung="Abgeschlossen"
wert={zeitpunktMitName(kopf.abgeschlossenAm, kopf.abgeschlossenVon)}
/>
<Zeile beschriftung="Archiviert" wert={zeitpunktMitName(kopf.archiviertAm, kopf.archiviertVon)} />
</dl>
</header>
);
}
function Kommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
if (kommentare.length === 0) return null;
return (
<ul className="mt-2 flex flex-col gap-1 border-l border-linie pl-3">
{kommentare.map((kommentar) => (
<li key={kommentar.id} className="text-sm">
{kommentar.rumpf}
<span className="text-nebentext">
{" "}
— {kommentar.benutzer}, {ZEITPUNKT.format(kommentar.erstelltAm)}
</span>
</li>
))}
</ul>
);
}
function Schritt({ schritt }: { schritt: DruckSchritt }) {
const Symbol = ZUSTAND_SYMBOL[schritt.zustand];
return (
<li data-schritt className="flex gap-3 border-t border-linie py-2">
<span className="mono mt-0.5 w-5 shrink-0 text-xs text-nebentext">
{String(schritt.nummer).padStart(2, "0")}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-sm font-medium">{schritt.titel}</span>
{schritt.pflicht ? <span className="text-xs text-nebentext">Pflicht</span> : null}
</div>
{schritt.befehl ? (
// Der Befehl mit eingesetzten Werten. `{{generiertes_passwort}}` steht
// hier als „[Passwort – nicht gespeichert]“: es wird nie gespeichert
// und fuer den Ausdruck auch nicht neu erzeugt.
<pre className="mono mt-1 text-xs whitespace-pre-wrap break-words text-nebentext">
{schritt.befehl}
</pre>
) : null}
{schritt.wert ? <p className="mono mt-1 text-sm break-words">{schritt.wert}</p> : null}
{schritt.notiz ? <p className="mt-1 text-sm text-nebentext">{schritt.notiz}</p> : null}
{schritt.bearbeitetVon && schritt.bearbeitetAm ? (
<p className="mt-1 text-xs text-nebentext">
{schritt.bearbeitetVon}, {ZEITPUNKT.format(schritt.bearbeitetAm)}
</p>
) : null}
<Kommentare kommentare={schritt.kommentare} />
</div>
<span className="flex shrink-0 items-baseline gap-1 text-xs text-nebentext">
<Symbol className="size-3.5 shrink-0 translate-y-0.5" strokeWidth={2} aria-hidden="true" />
{ZUSTAND_TEXT[schritt.zustand]}
</span>
</li>
);
}
export function BelegAbschnitt({ abschnitt }: { abschnitt: DruckAbschnitt }) {
return (
<section className="mt-5">
<h2 className="text-base font-semibold">
<span className="mono text-nebentext">{abschnitt.nummer}</span> · {abschnitt.titel}
</h2>
{abschnitt.schritte.length === 0 ? (
<p className="mt-1 text-sm text-nebentext">Dieser Abschnitt hat keine Schritte.</p>
) : (
<ul className="mt-1 flex flex-col">
{abschnitt.schritte.map((schritt) => (
<Schritt key={schritt.nummer} schritt={schritt} />
))}
</ul>
)}
</section>
);
}
export function BelegKommentare({ kommentare }: { kommentare: readonly KommentarZeile[] }) {
return (
<section className="mt-6 border-t border-linie pt-4">
<h2 className="text-base font-semibold">Kommentare zum Lauf</h2>
{kommentare.length === 0 ? (
<p className="mt-1 text-sm text-nebentext">Kein Kommentar.</p>
) : (
<Kommentare kommentare={kommentare} />
)}
</section>
);
}
/** Der ganze Beleg – Kopf, Abschnitte, Kommentare. */
export function LaufBeleg({ daten }: { daten: LaufZumDrucken }) {
return (
<article>
<BelegKopf
kopf={daten.kopf}
anzahlSchritte={daten.anzahlSchritte}
anzahlVomTisch={daten.anzahlVomTisch}
/>
{daten.abschnitte.map((abschnitt) => (
<BelegAbschnitt key={abschnitt.id} abschnitt={abschnitt} />
))}
<BelegKommentare kommentare={daten.kommentare} />
</article>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Was „ueberfaellig“ heisst — an einer Stelle (#015).
*
* Die Regel steht im Auftrag: **`faelligAm` verstrichen und der Lauf laeuft
* noch.** Zwei Feinheiten entscheiden darueber, ob sie stimmt:
*
* 1. **Verstrichen heisst „vor heute“, nicht „vor jetzt“.** Ein Datumsfeld im
* Browser liefert einen Tag, keine Uhrzeit; gespeichert wird daraus
* Mitternacht UTC. Verglichen mit dem Augenblick waere ein Lauf, der *heute*
* faellig ist, hierzulande ab zwei Uhr morgens „ueberfaellig“ — an dem Tag,
* an dem er erledigt werden soll. Wer eine Warnung zweimal umsonst sieht,
* liest die dritte nicht mehr (Reviewbefund zu #015).
* 2. **Fertig ist nicht ueberfaellig.** Ein abgeschlossener Lauf mit
* verstrichenem Datum ist fertig, kein Rueckstand; ein archivierter erst
* recht nicht.
*
* Framework- und datenbankfrei, damit Lesemodell und Anzeige **dieselbe** Grenze
* benutzen. Zwei Fassungen derselben Regel laufen auseinander, und dann steht
* ein Lauf im Block „Ueberfaellig“ und traegt daneben eine unauffaellige Frist.
*/
/** Mitternacht des Tages, in dem dieser Zeitpunkt liegt – in **lokaler** Zeit. */
export function tagesbeginn(zeitpunkt: Date): Date {
const beginn = new Date(zeitpunkt);
beginn.setHours(0, 0, 0, 0);
return beginn;
}
/**
* Ist diese Frist verstrichen?
*
* Ohne Frist gibt es nichts zu versaeumen. Der heutige Tag gilt **nicht** als
* verstrichen.
*/
export function fristVerstrichen(faelligAm: Date | null | undefined, jetzt: Date): boolean {
return faelligAm !== null && faelligAm !== undefined && faelligAm < tagesbeginn(jetzt);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export const NAVIGATION: readonly NavEintrag[] = [
symbol: "Building2",
verfuegbar: true,
},
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: true },
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", verfuegbar: true },
];
+548
View File
@@ -0,0 +1,548 @@
import { Prisma } from "@/generated/prisma/client";
import { LaufStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Das Archiv: Volltextsuche und Filter ueber abgelegte Laeufe (#015).
*
* Vier Entscheidungen tragen das Modul:
*
* 1. **Ein gemeinsames Lesemodell fuer drei Tabellen.** Gesucht wird in
* `Lauf.titel`, in `Schrittergebnis.notiz`, im Json `Schrittergebnis.wert`
* und in `Kommentar.rumpf` — mit `ILIKE '%…%'`, wie im Auftrag festgelegt.
* Die `pg_trgm`-GIN-Indizes aus #006 stehen genau fuer diese vier Ausdruecke
* in der Datenbank (`Lauf_titel_trgm_idx`, `Schrittergebnis_notiz_trgm_idx`,
* `Schrittergebnis_wert_trgm_idx` auf `(("wert")::text)`,
* `Kommentar_rumpf_trgm_idx`). Damit sie erreichbar bleiben, lesen **alle
* vier** Zweige aus den Basistabellen und schraenken ueber den Verbund mit
* `basis` ein; ueber die sechsmal gelesene und deshalb materialisierte CTE
* gaebe es keinen Index. Ob der Planer ihn dann waehlt, entscheidet er
* selbst — bei wenigen Zeilen ist ein Durchlauf schneller. Ein `tsvector`
* ist ausdruecklich spaetere Optimierung und steht hier nicht.
*
* **Ohne Suchbegriff** kostet die Anweisung trotzdem einen Durchlauf ueber
* die gefilterten Laeufe: sortiert wird nach
* `COALESCE(archiviertAm, abgeschlossenAm, erstelltAm)`, und dafuer gibt es
* keinen Index. Das ist der Preis dafuer, dass Sortierung und Zeitraumfilter
* denselben Ausdruck benutzen — ein Archiv, das ein Haus in Jahren fuellt,
* vertraegt ihn.
* 2. **Die Suche laeuft als eine Anweisung in SQL**, weil `wert` ein Json ist:
* ueber Prisma laesst sich eine ganze Json-Spalte nicht als Text
* durchsuchen, und nur `("wert")::text` trifft den Index. Derselbe Weg
* liefert gleich mit, **wo** der Begriff steht und mit welchem Ausschnitt —
* ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
* 3. **Die Mandantensicht steht zweimal drin**, und das ist Absicht: einmal in
* der handgeschriebenen SQL-Bedingung, einmal beim Nachladen der Anzeigedaten
* ueber Prisma. Die Teilbedingungen liegen dabei alle in **einem** `AND`, wo
* keine die andere ueberschreiben kann — in #009 hat genau das die
* Kundengrenze gekostet: ein zweites `OR` im selben Objekt, und ein Buchstabe
* im Suchfeld genuegte.
* 4. **Auch abgeschlossene Laeufe gehoeren ins Archiv.** „Abgeschlossen ist
* nicht archiviert“ (docs/architektur.md, Abschnitt 4) beschreibt den
* *Schreibschutz*: ein abgeschlossener Lauf darf noch eine nachgetragene
* Notiz bekommen, ein archivierter nicht. Fuer das **Suchen** ist der
* Unterschied bedeutungslos — wer die Seriennummer von letzter Woche sucht,
* weiss nicht, ob jemand den Lauf schon archiviert hat. Das Archiv sucht
* deshalb ueber `ABGESCHLOSSEN` **und** `ARCHIVIERT`; laufende Laeufe kommen
* nur auf ausdruecklichen Wunsch dazu (`auchLaufende`), weil sie in der
* Laufliste stehen, wo man sie auch anfassen kann.
*/
/** So viele Laeufe liefert eine Suche hoechstens; die Seite sagt es, wenn es mehr sind. */
export const ARCHIV_HOECHSTENS = 100;
/**
* Kuerzere Begriffe werden verworfen.
*
* Ein einzelner Buchstabe traefe fast jede Notiz des Hauses und waere damit
* keine Auskunft. Unter drei Zeichen kann der Trigramm-Index ohnehin nicht
* greifen, ein zweistelliger Begriff („AD“, „PC“) ist also schon ein
* vollstaendiger Durchlauf — dafuer aber einer, der eine sinnvolle Frage
* beantwortet.
*/
export const SUCHE_MINDESTZEICHEN = 2;
/** So viele Fundstellen zeigt ein Treffer; die Zeile nennt die Zahl der uebrigen. */
export const FUNDSTELLEN_JE_LAUF = 4;
/** So viele Eintraege stehen je Filterliste zur Auswahl. */
export const AUSWAHL_HOECHSTENS = 500;
/** Zeichen vor dem Fund, die der Ausschnitt mitnimmt. */
const AUSSCHNITT_VORLAUF = 60;
/** Laenge des Ausschnitts, den die Datenbank liefert. */
const AUSSCHNITT_LAENGE = 240;
/** Die Zustaende, die das Archiv immer durchsucht. */
export const ARCHIV_STATUS = [LaufStatus.ABGESCHLOSSEN, LaufStatus.ARCHIVIERT] as const;
/** Nur mit `auchLaufende` dabei. */
const LAUFENDE_STATUS = [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] as const;
/**
* Der Zeitpunkt, an dem ein Lauf zur Ruhe kam.
*
* Archiviert, sonst abgeschlossen, sonst angelegt. Sortierung und
* Zeitraumfilter benutzen **denselben** Ausdruck: sonst stuende ein Lauf
* ausserhalb des Zeitraums, den die Liste an seiner Position anzeigt.
*/
const ABGELEGT = Prisma.sql`COALESCE(l."archiviertAm", l."abgeschlossenAm", l."erstelltAm")`;
export type TrefferFeld = "TITEL" | "NOTIZ" | "WERT" | "KOMMENTAR";
export type Fundstelle = {
readonly feld: TrefferFeld;
/** Der Text um den Fund herum; „…“ steht dort, wo abgeschnitten wurde. */
readonly ausschnitt: string;
};
export type ArchivZeile = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
readonly abteilungName: string | null;
readonly ticket: string | null;
readonly kundeName: string;
/** Siehe `ABGELEGT`: archiviert, sonst abgeschlossen, sonst angelegt. */
readonly abgelegtAm: Date;
readonly abgeschlossenAm: Date | null;
readonly archiviertAm: Date | null;
readonly abgeschlossenVon: string | null;
readonly zugewiesenAn: string | null;
/** Leer, wenn ohne Suchbegriff nur gefiltert wurde. */
readonly fundstellen: readonly Fundstelle[];
/** Weitere Stellen im selben Lauf, die nicht mehr angezeigt werden. */
readonly weitereFundstellen: number;
};
export type ArchivFilter = {
readonly suche?: string;
readonly vorlageId?: string;
readonly mitarbeiterId?: string;
readonly abteilungId?: string;
readonly geraetId?: string;
/**
* Wer an dem Lauf gearbeitet hat: ihm zugewiesen, von ihm abgeschlossen oder
* an einem Schritt beteiligt. Alle drei, weil keines allein genuegt — ein Lauf
* wird oft ohne Zuweisung abgearbeitet, und wer abschliesst, muss nicht der
* sein, der die Arbeit gemacht hat.
*/
readonly bearbeiterId?: string;
/** Nur im Modus DIENSTLEISTUNG beachtet; im Modus INTERN verworfen (#008). */
readonly kundeId?: string;
/** Ab diesem Zeitpunkt abgelegt (einschliesslich). */
readonly von?: Date;
/** An diesem Tag abgelegt oder davor – der Bis-Tag gehoert vollstaendig dazu. */
readonly bis?: Date;
/** Auch offene und laufende Laeufe durchsuchen. */
readonly auchLaufende?: boolean;
};
export type ArchivErgebnis = {
readonly zeilen: readonly ArchivZeile[];
/** Es gibt mehr Treffer als `ARCHIV_HOECHSTENS` – die Seite sagt es. */
readonly gekappt: boolean;
/** Der Begriff war zu kurz und wurde verworfen; gefiltert wurde trotzdem. */
readonly sucheZuKurz: boolean;
};
/**
* `%`, `_` und `\` sind Sonderzeichen in `ILIKE`.
*
* Ohne Maskierung waere die Suche nach „50%“ eine Suche nach „50 und dann
* irgendetwas“ und traefe jede Notiz, in der eine 50 vorkommt.
*/
function musterMaskieren(begriff: string): string {
return begriff.replace(/[\\%_]/g, (zeichen) => `\\${zeichen}`);
}
type RoheFundstelle = {
readonly feld: string;
readonly roh: string;
readonly beginn: number;
readonly gesamt: number;
};
type Rohzeile = {
readonly id: string;
readonly abgelegtAm: Date;
readonly anzahlFundstellen: number;
readonly fundstellen: readonly RoheFundstelle[] | null;
};
const FELDER: readonly TrefferFeld[] = ["TITEL", "NOTIZ", "WERT", "KOMMENTAR"];
function feldPruefen(wert: string): TrefferFeld | undefined {
return FELDER.find((feld) => feld === wert);
}
/**
* Aus dem Fenster, das die Datenbank geliefert hat, wird ein einzeiliger
* Ausschnitt.
*
* Die „…“ stehen nur dort, wo wirklich etwas fehlt: `beginn` und `gesamt`
* kommen aus derselben Abfrage, die den Text zugeschnitten hat.
*/
function ausschnittBauen(stelle: RoheFundstelle): string {
const text = stelle.roh.replace(/\s+/g, " ").trim();
const vorne = stelle.beginn > 1 ? "…" : "";
const hinten = stelle.beginn - 1 + stelle.roh.length < stelle.gesamt ? "…" : "";
return `${vorne}${text}${hinten}`;
}
function fundstellenBauen(zeile: Rohzeile): readonly Fundstelle[] {
const stellen: Fundstelle[] = [];
for (const stelle of zeile.fundstellen ?? []) {
const feld = feldPruefen(stelle.feld);
if (!feld) continue;
stellen.push({ feld, ausschnitt: ausschnittBauen(stelle) });
}
return stellen;
}
const ZEILEN_AUSWAHL = {
id: true,
titel: true,
status: true,
ticket: true,
erstelltAm: true,
abgeschlossenAm: true,
archiviertAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
abteilung: { select: { name: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } },
} satisfies Prisma.LaufSelect;
type Anzeigezeile = Prisma.LaufGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
/**
* Volltextsuche und Filter ueber das Archiv.
*
* Ohne Suchbegriff wird nur gefiltert; die Trefferliste hat dann keine
* Fundstellen, weil es keine gibt.
*/
export async function archivSuche(
benutzer: HandelnderBenutzer,
filter: ArchivFilter = {},
): Promise<ArchivErgebnis> {
const eingegeben = filter.suche?.trim() ?? "";
const sucheZuKurz = eingegeben.length > 0 && eingegeben.length < SUCHE_MINDESTZEICHEN;
const begriff = sucheZuKurz ? "" : eingegeben;
const muster = begriff ? `%${musterMaskieren(begriff)}%` : null;
const [sichtbar, kundenEbene] = await Promise.all([sichtbareKundenIds(benutzer), kundenEbeneSichtbar()]);
const status: string[] = [...ARCHIV_STATUS, ...(filter.auchLaufende ? LAUFENDE_STATUS : [])];
const bedingungen: Prisma.Sql[] = [Prisma.sql`l."status"::text = ANY(${status}::text[])`];
// Die Mandantengrenze. `undefined` heisst „keine Einschraenkung“, eine leere
// Liste heisst „sieht nichts“ – und `= ANY('{}')` ist genau das.
if (sichtbar !== undefined) {
bedingungen.push(Prisma.sql`l."kundeId" = ANY(${[...sichtbar]}::text[])`);
}
// Im Modus INTERN gibt es keine Kundenwahl, also auch nichts zu entscheiden:
// eine mitgeschickte kundeId wird verworfen, nicht abgelehnt (#008).
if (kundenEbene && filter.kundeId) {
bedingungen.push(Prisma.sql`l."kundeId" = ${filter.kundeId}`);
}
if (filter.vorlageId) {
// Gefiltert wird ueber die Vorlage, nicht ueber eine einzelne Version: der
// Lauf haengt an der Version, mit der er gestartet wurde, und wer im Archiv
// nach „Onboarding“ sucht, meint alle Fassungen davon.
bedingungen.push(
Prisma.sql`EXISTS (
SELECT 1 FROM "VorlagenVersion" v
WHERE v."id" = l."vorlagenVersionId" AND v."vorlageId" = ${filter.vorlageId}
)`,
);
}
if (filter.mitarbeiterId) {
bedingungen.push(Prisma.sql`l."mitarbeiterId" = ${filter.mitarbeiterId}`);
}
if (filter.abteilungId) {
bedingungen.push(Prisma.sql`l."abteilungId" = ${filter.abteilungId}`);
}
if (filter.geraetId) {
bedingungen.push(Prisma.sql`l."geraetId" = ${filter.geraetId}`);
}
if (filter.bearbeiterId) {
bedingungen.push(
Prisma.sql`(
l."zugewiesenAnId" = ${filter.bearbeiterId}
OR l."abgeschlossenVonId" = ${filter.bearbeiterId}
OR EXISTS (
SELECT 1 FROM "Schrittergebnis" e
WHERE e."laufId" = l."id" AND e."aktualisiertVonId" = ${filter.bearbeiterId}
)
)`,
);
}
if (filter.von) {
bedingungen.push(Prisma.sql`${ABGELEGT} >= ${filter.von}`);
}
if (filter.bis) {
// Der Bis-Tag gehoert dazu. Gerechnet wird mit dem Kalendertag und nicht mit
// „plus 24 Stunden“: an den beiden Umstelltagen des Jahres hat ein Tag 23
// oder 25 Stunden.
const grenze = new Date(filter.bis);
grenze.setDate(grenze.getDate() + 1);
bedingungen.push(Prisma.sql`${ABGELEGT} < ${grenze}`);
}
/*
* Eine Anweisung, drei Tabellen. `basis` sind die Laeufe, die die Filter
* bestehen; `treffer` sammelt je Fundstelle eine Zeile mit Feld und Text.
* Ohne Suchbegriff bleibt `treffer` leer (`FALSE`), und die Liste zeigt
* einfach die gefilterten Laeufe.
*
* `rang` ordnet die Fundstellen: Titel vor Notiz vor Wert vor Kommentar. Ohne
* die Ordnung waere bei mehr als `FUNDSTELLEN_JE_LAUF` Stellen zufaellig,
* welche der Suchende sieht.
*/
const suchbar = muster !== null;
const roh = await prisma.$queryRaw<Rohzeile[]>(Prisma.sql`
WITH basis AS (
SELECT l."id", l."titel", ${ABGELEGT} AS "abgelegtAm"
FROM "Lauf" l
WHERE ${Prisma.join(bedingungen, " AND ")}
),
treffer AS (
SELECT b."id" AS "laufId", 0::int AS "rang", 'TITEL'::text AS "feld", b."titel" AS "fundstelle"
FROM basis b
WHERE ${suchbar ? Prisma.sql`b."titel" ILIKE ${muster}` : Prisma.sql`FALSE`}
UNION ALL
SELECT e."laufId", 1::int, 'NOTIZ'::text, e."notiz"
FROM "Schrittergebnis" e JOIN basis b ON b."id" = e."laufId"
WHERE ${suchbar ? Prisma.sql`e."notiz" ILIKE ${muster}` : Prisma.sql`FALSE`}
UNION ALL
SELECT e."laufId", 2::int, 'WERT'::text, e."wert"::text
FROM "Schrittergebnis" e JOIN basis b ON b."id" = e."laufId"
WHERE ${suchbar ? Prisma.sql`e."wert"::text ILIKE ${muster}` : Prisma.sql`FALSE`}
UNION ALL
SELECT k."laufId", 3::int, 'KOMMENTAR'::text, k."rumpf"
FROM "Kommentar" k JOIN basis b ON b."id" = k."laufId"
WHERE ${suchbar ? Prisma.sql`k."rumpf" ILIKE ${muster}` : Prisma.sql`FALSE`}
)
SELECT
b."id",
b."abgelegtAm",
(SELECT count(*)::int FROM treffer t WHERE t."laufId" = b."id") AS "anzahlFundstellen",
(
SELECT json_agg(
json_build_object(
'feld', x."feld",
'roh', substr(x."fundstelle", x."beginn", ${AUSSCHNITT_LAENGE}),
'beginn', x."beginn",
'gesamt', length(x."fundstelle")
)
)
FROM (
SELECT
t."feld",
t."fundstelle",
-- strpos gibt 0, wenn die Gross-/Kleinschreibung von ILIKE und lower()
-- einmal auseinandergehen; dann beginnt der Ausschnitt eben vorn.
greatest(strpos(lower(t."fundstelle"), lower(${begriff})) - ${AUSSCHNITT_VORLAUF}, 1) AS "beginn"
FROM treffer t
WHERE t."laufId" = b."id"
ORDER BY t."rang"
LIMIT ${FUNDSTELLEN_JE_LAUF}
) x
) AS "fundstellen"
FROM basis b
${suchbar ? Prisma.sql`WHERE EXISTS (SELECT 1 FROM treffer t WHERE t."laufId" = b."id")` : Prisma.empty}
ORDER BY b."abgelegtAm" DESC, b."id" DESC
LIMIT ${ARCHIV_HOECHSTENS + 1}
`);
const gekappt = roh.length > ARCHIV_HOECHSTENS;
const gefunden = roh.slice(0, ARCHIV_HOECHSTENS);
if (gefunden.length === 0) {
return { zeilen: [], gekappt: false, sucheZuKurz };
}
/*
* Die Anzeigedaten kommen ueber Prisma nach – und die Kundenbedingung steht
* dabei **noch einmal** davor. Die Suche oben ist handgeschriebenes SQL; ein
* Fehler darin darf nicht damit enden, dass der Name eines fremden Kunden
* ausgeliefert wird. Zwei unabhaengige Sperren an derselben Grenze.
*/
const zeilen = await prisma.lauf.findMany({
where: {
AND: [
{ id: { in: gefunden.map((zeile) => zeile.id) } },
...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }]),
],
},
select: ZEILEN_AUSWAHL,
});
const nachId = new Map<string, Anzeigezeile>(zeilen.map((zeile) => [zeile.id, zeile]));
const fertig: ArchivZeile[] = [];
// Die Reihenfolge kommt aus der Suche, nicht aus dem Nachladen.
for (const treffer of gefunden) {
const zeile = nachId.get(treffer.id);
if (!zeile) continue;
const stellen = fundstellenBauen(treffer);
fertig.push({
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
version: zeile.vorlagenVersion.version,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
// Entweder eine Person aus dem Verzeichnis oder ein Freitextname – die
// Datenbank schliesst beides zugleich aus.
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
abteilungName: zeile.abteilung?.name ?? null,
ticket: zeile.ticket,
kundeName: zeile.kunde.name,
abgelegtAm: treffer.abgelegtAm,
abgeschlossenAm: zeile.abgeschlossenAm,
archiviertAm: zeile.archiviertAm,
abgeschlossenVon: zeile.abgeschlossenVon?.name ?? null,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
fundstellen: stellen,
weitereFundstellen: Math.max(treffer.anzahlFundstellen - stellen.length, 0),
});
}
return { zeilen: fertig, gekappt, sucheZuKurz };
}
export type ArchivAuswahl = {
readonly vorlagen: readonly { readonly id: string; readonly name: string }[];
readonly mitarbeiter: readonly { readonly id: string; readonly name: string }[];
readonly abteilungen: readonly { readonly id: string; readonly name: string }[];
readonly geraete: readonly { readonly id: string; readonly name: string }[];
readonly bearbeiter: readonly { readonly id: string; readonly name: string }[];
/** Leer im Modus INTERN – dort gibt es keine Kundenebene (#008). */
readonly kunden: readonly { readonly id: string; readonly name: string }[];
/** Mindestens eine Liste ist voll; die Seite sagt es, statt still zu kappen. */
readonly gekappt: boolean;
};
/**
* Was in den Filterlisten des Archivs steht.
*
* **Archiviertes und Deaktiviertes gehoert dazu.** Das Archiv sieht nach hinten:
* ein Lauf von 2024 haengt oft an einer archivierten Vorlage, einem
* ausgemusterten Geraet und einer Person, die das Haus verlassen hat. Wuerden
* diese Eintraege fehlen, waere der Lauf ueber keinen Filter mehr erreichbar.
*
* Gekappt bei `AUSWAHL_HOECHSTENS` je Liste, wie im Start-Dialog (#013). Die
* Listen kommen aus den Stammdaten und nicht aus den Laeufen selbst: dafuer
* muessten alle Laeufe des Archivs gelesen werden, und genau das soll eine
* Auswahlliste nicht kosten. Ein Eintrag ohne Lauf im Archiv fuehrt deshalb zu
* einer leeren Trefferliste — das ist die richtige Antwort, nur keine schoene.
*/
export async function archivFilterwerte(benutzer: HandelnderBenutzer): Promise<ArchivAuswahl> {
const [sichtbar, kundenEbene] = await Promise.all([sichtbareKundenIds(benutzer), kundenEbeneSichtbar()]);
const mandant = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
const [vorlagen, mitarbeiter, abteilungen, geraete, bearbeiter, kunden] = await Promise.all([
prisma.vorlage.findMany({
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
where: sichtbar === undefined ? {} : { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
}),
prisma.mitarbeiter.findMany({
where: { AND: mandant },
orderBy: { anzeigename: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, anzeigename: true },
}),
prisma.abteilung.findMany({
where: { AND: mandant },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
}),
prisma.geraet.findMany({
where: { AND: mandant },
orderBy: [{ hostname: "asc" }, { bezeichnung: "asc" }],
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, hostname: true, bezeichnung: true, tagCode: true },
}),
/*
* Zur Auswahl steht, wer einen Lauf dieses Kunden bearbeitet haben **kann**.
* Zwei Unterschiede zum Start-Dialog (#013), beide gewollt:
*
* - **Deaktivierte Konten sind dabei.** Ihre Laeufe stehen im Archiv, und
* wer nach „wer hat das damals gemacht“ sucht, sucht oft genau nach
* jemandem, der das Haus verlassen hat.
* - **Kein Rollenfilter.** Der Start-Dialog zeigt nur, wem sich ein Lauf
* *zuweisen* laesst; hier geht es darum, wer in der Vergangenheit einer
* war. Eine Rolle kann sich geaendert haben — die abgelegte Arbeit nicht.
*/
prisma.benutzer.findMany({
where:
mandant.length === 0
? {}
: { OR: [{ kundenbindungen: { none: {} } }, { kundenbindungen: { some: mandant[0] } }] },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
}),
kundenEbene
? // Auch die eigene Organisation: im Modus DIENSTLEISTUNG traegt sie die
// internen Laeufe des Hauses, und die stehen genauso im Archiv.
prisma.kunde.findMany({
where: sichtbar === undefined ? {} : { id: { in: [...sichtbar] } },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
})
: Promise.resolve([]),
]);
const listen = [vorlagen, mitarbeiter, abteilungen, geraete, bearbeiter, kunden];
/** Die eine Zeile ueber der Grenze diente nur dem Zaehlen. */
const zeigen = <Zeile>(liste: readonly Zeile[]): Zeile[] => liste.slice(0, AUSWAHL_HOECHSTENS);
return {
vorlagen: zeigen(vorlagen),
mitarbeiter: zeigen(mitarbeiter).map((person) => ({ id: person.id, name: person.anzeigename })),
abteilungen: zeigen(abteilungen),
geraete: zeigen(geraete).map((geraet) => {
const name = geraet.hostname ?? geraet.bezeichnung;
// Ein vorgedrucktes Tag hat keinen Namen – dann steht der Code einmal da
// und nicht zweimal (Reviewbefund zu #015).
return { id: geraet.id, name: name ? `${name} · ${geraet.tagCode}` : geraet.tagCode };
}),
bearbeiter: zeigen(bearbeiter),
kunden: zeigen(kunden),
/*
* Geholt wird eine Zeile mehr als gezeigt: sonst meldete die Oberflaeche
* „gekappt“ auch dann, wenn es **genau** 500 Eintraege gibt und keiner
* fehlt (Reviewbefund zu #015).
*/
gekappt: listen.some((liste) => liste.length > AUSWAHL_HOECHSTENS),
};
}
/**
* Ein Datum aus dem Formular, oder `undefined`.
*
* Mitternacht **lokal**, nicht in UTC: wer „ab 1. März“ eingibt, meint den
* Tagesbeginn dort, wo er sitzt. Ein vertippter Parameter filtert nicht, statt
* eine Fehlerseite zu zeigen.
*/
export function datumAusText(text: string | undefined): Date | undefined {
if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) return undefined;
const datum = new Date(`${text}T00:00:00`);
return Number.isNaN(datum.getTime()) ? undefined : datum;
}
+312
View File
@@ -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,
};
}
+234
View File
@@ -0,0 +1,234 @@
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { tagesbeginn } from "@/lib/fristen";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Die Uebersicht: was heute ansteht (#015).
*
* Das ist die Seite, die ein Techniker morgens aufschlaegt — und die einzige,
* die ihn **anspricht** statt nur Daten zu zeigen. Sie beantwortet vier Fragen,
* in dieser Reihenfolge:
*
* 1. Was ist ueberfaellig? (Das Einzige, was sofort etwas bedeutet.)
* 2. Was liegt bei mir?
* 3. Woran war ich zuletzt?
* 4. Was faengt man haeufig an?
*
* Alle vier Abfragen laufen nebeneinander und tragen die Mandantensicht selbst.
* Sie sind bewusst **gekappt**: eine Uebersicht, die alles zeigt, ist keine.
*/
/** So viele Zeilen zeigt jeder Block hoechstens. */
export const UEBERSICHT_JE_BLOCK = 5;
export type UebersichtsLauf = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
readonly faelligAm: Date | null;
readonly zugewiesenAn: string | null;
readonly anzahlSchritte: number;
readonly anzahlErledigt: number;
/** Wann zuletzt an diesem Lauf etwas geschah – fuer „zuletzt bearbeitet“. */
readonly zuletztAm: Date;
};
export type Uebersicht = {
readonly ueberfaellig: readonly UebersichtsLauf[];
readonly meine: readonly UebersichtsLauf[];
readonly zuletzt: readonly UebersichtsLauf[];
/** Mehr, als der Block zeigt? Dann fuehrt die Seite in die Liste. */
readonly mehrUeberfaellig: number;
readonly mehrMeine: number;
};
const ZEILE = {
id: true,
titel: true,
status: true,
faelligAm: true,
geaendertAm: true,
zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
mitarbeiterName: true,
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect;
type Rohzeile = Prisma.LaufGetPayload<{ select: typeof ZEILE }>;
function zeileBauen(zeile: Rohzeile): UebersichtsLauf {
return {
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
faelligAm: zeile.faelligAm,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
anzahlSchritte: zeile.ergebnisse.length,
// „Vom Tisch“ heisst erledigt **oder** nicht zutreffend – wie ueberall sonst.
anzahlErledigt: zeile.ergebnisse.filter(
(ergebnis) =>
ergebnis.zustand === SchrittZustand.ERLEDIGT || ergebnis.zustand === SchrittZustand.NICHT_ZUTREFFEND,
).length,
zuletztAm: zeile.geaendertAm,
};
}
/**
* Laufend heisst: `OFFEN` oder `IN_ARBEIT`.
*
* Ein abgeschlossener Lauf kann nicht ueberfaellig sein — er ist fertig, auch
* wenn das Datum verstrichen ist. Ein archivierter erst recht nicht.
*/
const LAEUFT: Prisma.LaufWhereInput = { status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] } };
async function mandantenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.LaufWhereInput[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
/**
* Alles, was die Uebersicht zeigt – in einem Aufruf.
*
* `jetzt` kommt von aussen, damit „ueberfaellig“ pruefbar bleibt: eine Grenze,
* die sich aus `new Date()` im Inneren ergibt, laesst sich nur mit Warten
* testen.
*/
export async function uebersicht(
benutzer: HandelnderBenutzer,
jetzt: Date = new Date(),
): Promise<Uebersicht> {
const mandant = await mandantenBedingung(benutzer);
/*
* **Vor heute**, nicht vor jetzt. Ein Lauf, der heute faellig ist, soll heute
* erledigt werden und ist kein Rueckstand; verglichen mit dem Augenblick
* stuende er hierzulande ab zwei Uhr morgens in Warnfarbe, weil das
* Datumsfeld Mitternacht UTC speichert (Reviewbefund zu #015). Die Regel
* steht in `src/lib/fristen.ts` — Lesemodell und Anzeige benutzen dieselbe.
*/
const ueberfaelligWo: Prisma.LaufWhereInput = {
AND: [LAEUFT, { faelligAm: { lt: tagesbeginn(jetzt) } }, ...mandant],
};
const meineWo: Prisma.LaufWhereInput = {
AND: [LAEUFT, { zugewiesenAnId: benutzer.id }, ...mandant],
};
const [ueberfaellig, meine, zuletzt, mehrUeberfaellig, mehrMeine] = await Promise.all([
prisma.lauf.findMany({
where: ueberfaelligWo,
// Das laengst Faellige zuerst: je aelter, desto dringender.
orderBy: { faelligAm: "asc" },
take: UEBERSICHT_JE_BLOCK,
select: ZEILE,
}),
prisma.lauf.findMany({
where: meineWo,
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { geaendertAm: "desc" }],
take: UEBERSICHT_JE_BLOCK,
select: ZEILE,
}),
/*
* „Zuletzt bearbeitet“ ist 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. Archivierte bleiben
* draussen — die stehen im Archiv.
*/
prisma.lauf.findMany({
where: { AND: [{ status: { not: LaufStatus.ARCHIVIERT } }, ...mandant] },
orderBy: { geaendertAm: "desc" },
take: UEBERSICHT_JE_BLOCK,
select: ZEILE,
}),
prisma.lauf.count({ where: ueberfaelligWo }),
prisma.lauf.count({ where: meineWo }),
]);
return {
ueberfaellig: ueberfaellig.map(zeileBauen),
meine: meine.map(zeileBauen),
zuletzt: zuletzt.map(zeileBauen),
mehrUeberfaellig: Math.max(0, mehrUeberfaellig - ueberfaellig.length),
mehrMeine: Math.max(0, mehrMeine - meine.length),
};
}
export type HaeufigeVorlage = {
readonly versionId: string;
readonly name: string;
readonly kategorie: string | null;
readonly version: number;
/** Wie oft daraus schon ein Lauf entstand – die Reihenfolge des Schnellstarts. */
readonly anzahlLaeufe: number;
};
/**
* Die Vorlagen, aus denen hier am haeufigsten Laeufe entstehen (#015).
*
* Gezaehlt wird ueber **alle** Versionen einer Vorlage, gestartet wird die
* zuletzt veroeffentlichte: „PC-Neuinstallation“ ist dieselbe Arbeit, ob sie in
* v3 oder v7 steht. Eine Rangfolge je Version haette nach jedem
* Veroeffentlichen wieder bei null angefangen.
*/
export async function haeufigeVorlagen(
benutzer: HandelnderBenutzer,
anzahl = 4,
): Promise<readonly HaeufigeVorlage[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
/*
* Gezaehlt wird **nur, was der Benutzer sehen darf**. Eine Vorlage ohne
* Kundenbezug gilt fuer alle (#009); ihr Zaehler sammelte sonst die Laeufe
* saemtlicher Kunden, und „12× gestartet“ waere eine Auskunft ueber fremde
* Betriebe — dazu noch eine, die die Reihenfolge des Schnellstarts nach
* fremder Arbeit sortiert (Reviewbefund zu #015).
*/
const laeufeBedingung = sichtbar === undefined ? {} : { where: { kundeId: { in: [...sichtbar] } } };
const vorlagen = await prisma.vorlage.findMany({
where: {
AND: [
{ archiviert: false },
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
...(sichtbar === undefined ? [] : [{ OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] }]),
],
},
select: {
name: true,
kategorie: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
select: {
id: true,
version: true,
_count: { select: { laeufe: laeufeBedingung } },
},
},
},
});
return vorlagen
.flatMap((vorlage) => {
const neueste = vorlage.versionen[0];
// Ohne veroeffentlichte Version laesst sich nichts starten (#009).
if (!neueste) return [];
return [
{
versionId: neueste.id,
name: vorlage.name,
kategorie: vorlage.kategorie,
version: neueste.version,
anzahlLaeufe: vorlage.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
},
];
})
.sort((a, b) => b.anzahlLaeufe - a.anzahlLaeufe || a.name.localeCompare(b.name, "de"))
.slice(0, anzahl);
}
+692
View File
@@ -0,0 +1,692 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it } from "vitest";
import {
Betriebsmodus,
LaufStatus,
Rolle,
SchrittTyp,
SchrittZustand,
VersionStatus,
} from "@/generated/prisma/enums";
import { ArchivTreffer } from "@/components/archiv/archiv-treffer";
import {
ARCHIV_HOECHSTENS,
FUNDSTELLEN_JE_LAUF,
archivFilterwerte,
archivSuche,
datumAusText,
type ArchivFilter,
type ArchivZeile,
} from "@/server/queries/archiv";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* Das Archiv: Volltextsuche und Filter (#015).
*
* Zwei Zusagen hängen an dieser Datei. Die erste ist die Suche selbst: ein Lauf,
* dessen Kennzeichen nur in einer Schritt-Notiz oder in einem Kommentar steht,
* wird gefunden — sonst ist das Archiv nur ein Stapel.
*
* Die zweite ist die Mandantengrenze, und sie ist der Grund, warum hier so viele
* Fälle stehen. In #009 hat ein zweites `OR` im selben Objekt die
* Kundenbedingung überschrieben; ein Buchstabe im Suchfeld genügte, um fremde
* Daten zu sehen. Die Suche im Archiv liest drei Tabellen und nimmt sieben
* Filter — jeder davon ist eine Gelegenheit, dieselbe Lücke neu aufzureißen.
*/
const WORT_NOTIZ = "Kaltgeraetestecker";
const WORT_KOMMENTAR = "Ruecksendeschein";
const WORT_WERT = "Zuckerhut";
async function benutzerAnlegen(name: string, email: string, rolle: Rolle, aktiv = true) {
return testPrisma.benutzer.create({ data: { email, name, rolle, aktiv } });
}
/**
* Eine Vorlage mit einer veröffentlichten Version 1 und zwei Schritten.
*
* Der Aufbau entsteht **vor** dem Veröffentlichen: die Trigger aus #009 lassen
* an einer veröffentlichten Version nichts mehr einfügen.
*/
async function vorlageMitVersion(name: string, kundeId: string | null = null, archiviert = false) {
const vorlage = await testPrisma.vorlage.create({ data: { name, kundeId, archiviert } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Vorbereitung", position: 0 },
});
const haken = await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Datensicherung geprüft", pflicht: true, position: 0 },
});
const text = await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Auffälligkeiten notiert",
typ: SchrittTyp.TEXT,
position: 1,
},
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
});
return { vorlageId: vorlage.id, versionId: version.id, haken: haken.id, text: text.id };
}
type LaufVorgabe = {
readonly titel: string;
readonly versionId: string;
readonly kundeId: string;
readonly status?: LaufStatus;
readonly abgelegtAm?: Date;
readonly zugewiesenAnId?: string;
readonly abgeschlossenVonId?: string;
readonly mitarbeiterId?: string;
readonly geraetId?: string;
readonly abteilungId?: string;
};
/**
* Ein Lauf direkt in der Datenbank – ohne den Dienst.
*
* Die Zeitstempel müssen zum Status passen, das erzwingen drei CHECK-Bedingungen
* aus #006. Genau deshalb steht hier auch kein `OFFEN` mit `archiviertAm`.
*/
async function laufAnlegen(vorgabe: LaufVorgabe) {
const status = vorgabe.status ?? LaufStatus.ARCHIVIERT;
const zeitpunkt = vorgabe.abgelegtAm ?? new Date("2026-03-10T09:00:00");
const fertig = status === LaufStatus.ABGESCHLOSSEN || status === LaufStatus.ARCHIVIERT;
return testPrisma.lauf.create({
data: {
titel: vorgabe.titel,
vorlagenVersionId: vorgabe.versionId,
kundeId: vorgabe.kundeId,
status,
erstelltAm: zeitpunkt,
...(status === LaufStatus.OFFEN ? {} : { gestartetAm: zeitpunkt }),
...(fertig ? { abgeschlossenAm: zeitpunkt } : {}),
...(status === LaufStatus.ARCHIVIERT ? { archiviertAm: zeitpunkt } : {}),
...(vorgabe.zugewiesenAnId ? { zugewiesenAnId: vorgabe.zugewiesenAnId } : {}),
...(vorgabe.abgeschlossenVonId ? { abgeschlossenVonId: vorgabe.abgeschlossenVonId } : {}),
...(vorgabe.mitarbeiterId ? { mitarbeiterId: vorgabe.mitarbeiterId } : {}),
...(vorgabe.geraetId ? { geraetId: vorgabe.geraetId } : {}),
...(vorgabe.abteilungId ? { abteilungId: vorgabe.abteilungId } : {}),
},
});
}
/**
* Der Bestand für die Suchtests: ein archiviertes Notebook mit Notiz, Wert und
* Kommentar, ein abgeschlossenes Offboarding und ein laufender Lauf.
*/
async function bestand() {
const eigene = await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } });
const tina = await benutzerAnlegen("Tina Technik", "tina@firma.example", Rolle.TECHNIKER);
const tom = await benutzerAnlegen("Tom Technik", "tom@firma.example", Rolle.TECHNIKER);
const leser = await benutzerAnlegen("Lars Leser", "lars@firma.example", Rolle.LESER);
const abteilung = await testPrisma.abteilung.create({
data: { name: "Buchhaltung", kundeId: eigene.id },
});
const person = await testPrisma.mitarbeiter.create({
data: {
vorname: "Mara",
nachname: "Meier",
anzeigename: "Mara Meier",
upn: "mara@firma.example",
kundeId: eigene.id,
},
});
const geraet = await testPrisma.geraet.create({
data: { tagCode: "LZ-AAAAAA", hostname: "nb-meier", kundeId: eigene.id, registriertAm: new Date() },
});
const neuinstallation = await vorlageMitVersion("PC-Neuinstallation");
const offboarding = await vorlageMitVersion("Offboarding");
const notebook = await laufAnlegen({
titel: "Neuinstallation nb-meier",
versionId: neuinstallation.versionId,
kundeId: eigene.id,
abgelegtAm: new Date("2026-03-10T09:00:00"),
zugewiesenAnId: tina.id,
mitarbeiterId: person.id,
geraetId: geraet.id,
abteilungId: abteilung.id,
});
await testPrisma.schrittergebnis.create({
data: {
laufId: notebook.id,
vorlagenSchrittId: neuinstallation.haken,
zustand: SchrittZustand.ERLEDIGT,
notiz: `Netzteil fehlte, ${WORT_NOTIZ} nachgelegt.`,
aktualisiertVonId: tina.id,
},
});
await testPrisma.schrittergebnis.create({
data: {
laufId: notebook.id,
vorlagenSchrittId: neuinstallation.text,
zustand: SchrittZustand.ERLEDIGT,
wert: WORT_WERT,
aktualisiertVonId: tom.id,
},
});
await testPrisma.kommentar.create({
data: {
laufId: notebook.id,
benutzerId: tina.id,
rumpf: `${WORT_KOMMENTAR} liegt beim Hersteller vor.`,
},
});
const austritt = await laufAnlegen({
titel: "Offboarding Meier",
versionId: offboarding.versionId,
kundeId: eigene.id,
status: LaufStatus.ABGESCHLOSSEN,
abgelegtAm: new Date("2026-01-15T14:00:00"),
zugewiesenAnId: tom.id,
});
const laufender = await laufAnlegen({
titel: "Neuinstallation nb-schmidt",
versionId: neuinstallation.versionId,
kundeId: eigene.id,
status: LaufStatus.IN_ARBEIT,
abgelegtAm: new Date("2026-04-01T08:00:00"),
});
await testPrisma.schrittergebnis.create({
data: {
laufId: laufender.id,
vorlagenSchrittId: neuinstallation.haken,
zustand: SchrittZustand.ERLEDIGT,
notiz: `Auch hier fehlt ein ${WORT_NOTIZ}.`,
aktualisiertVonId: tina.id,
},
});
return {
eigene,
tina,
tom,
leser: { id: leser.id, rolle: Rolle.LESER },
admin: { id: "admin-ohne-zeile", rolle: Rolle.ADMIN },
technikerin: { id: tina.id, rolle: Rolle.TECHNIKER },
neuinstallation,
offboarding,
notebook,
austritt,
laufender,
person,
geraet,
abteilung,
};
}
async function titel(benutzer: { id: string; rolle: Rolle }, filter: ArchivFilter) {
const ergebnis = await archivSuche(benutzer, filter);
return ergebnis.zeilen.map((zeile) => zeile.titel);
}
describe.runIf(datenbankVerfuegbar)("Archivsuche", () => {
beforeEach(async () => {
await datenbankLeeren();
delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
});
describe("Volltext über drei Tabellen", () => {
it("findet einen archivierten Lauf über ein Wort aus einer Schritt-Notiz", async () => {
const { technikerin } = await bestand();
const ergebnis = await archivSuche(technikerin, { suche: WORT_NOTIZ });
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
const stellen = ergebnis.zeilen[0]?.fundstellen ?? [];
expect(stellen.map((stelle) => stelle.feld)).toEqual(["NOTIZ"]);
// Der Ausschnitt trägt den Grund des Treffers, nicht nur die Zusage, es
// gäbe einen.
expect(stellen[0]?.ausschnitt).toContain(WORT_NOTIZ);
expect(stellen[0]?.ausschnitt).toContain("Netzteil fehlte");
});
it("findet einen archivierten Lauf über ein Wort aus einem Kommentar", async () => {
const { technikerin } = await bestand();
const ergebnis = await archivSuche(technikerin, { suche: WORT_KOMMENTAR });
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual(["KOMMENTAR"]);
expect(ergebnis.zeilen[0]?.fundstellen[0]?.ausschnitt).toContain(WORT_KOMMENTAR);
});
it("findet einen Lauf über den Json-Wert eines Schritts", async () => {
const { technikerin } = await bestand();
const ergebnis = await archivSuche(technikerin, { suche: WORT_WERT });
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual(["WERT"]);
});
it("findet einen Lauf über seinen Titel und sucht ohne Rücksicht auf Groß- und Kleinschreibung", async () => {
const { technikerin } = await bestand();
const ergebnis = await archivSuche(technikerin, { suche: "NB-MEIER" });
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Neuinstallation nb-meier"]);
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual(["TITEL"]);
});
it("nennt jede Fundstelle mit ihrem Feld, Titel zuerst", async () => {
const { technikerin, notebook, tina } = await bestand();
await testPrisma.lauf.update({
where: { id: notebook.id },
data: { titel: `Neuinstallation ${WORT_NOTIZ}` },
});
await testPrisma.kommentar.create({
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: `${WORT_NOTIZ} besorgt.` },
});
const ergebnis = await archivSuche(technikerin, { suche: WORT_NOTIZ });
expect(ergebnis.zeilen[0]?.fundstellen.map((stelle) => stelle.feld)).toEqual([
"TITEL",
"NOTIZ",
"KOMMENTAR",
]);
});
it("zeigt höchstens vier Fundstellen und zählt die übrigen", async () => {
const { technikerin, notebook, tina } = await bestand();
for (let nummer = 0; nummer < 5; nummer += 1) {
await testPrisma.kommentar.create({
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: `${WORT_NOTIZ} Nummer ${nummer}` },
});
}
const ergebnis = await archivSuche(technikerin, { suche: WORT_NOTIZ });
// Eine Notiz und fünf Kommentare tragen das Wort: sechs Stellen.
expect(ergebnis.zeilen[0]?.fundstellen).toHaveLength(FUNDSTELLEN_JE_LAUF);
expect(ergebnis.zeilen[0]?.weitereFundstellen).toBe(6 - FUNDSTELLEN_JE_LAUF);
});
it("schneidet einen langen Ausschnitt zu und markiert die Schnittstellen mit Auslassung", async () => {
const { technikerin, notebook, tina } = await bestand();
await testPrisma.kommentar.create({
data: {
laufId: notebook.id,
benutzerId: tina.id,
rumpf: `${"A".repeat(400)} ${WORT_KOMMENTAR} ${"B".repeat(400)}`,
},
});
const ergebnis = await archivSuche(technikerin, { suche: WORT_KOMMENTAR });
const kommentare = (ergebnis.zeilen[0]?.fundstellen ?? []).filter(
(stelle) => stelle.feld === "KOMMENTAR",
);
const lang = kommentare.find((stelle) => stelle.ausschnitt.startsWith("…"));
expect(lang?.ausschnitt).toContain(WORT_KOMMENTAR);
expect(lang?.ausschnitt.endsWith("…")).toBe(true);
expect(lang?.ausschnitt.length).toBeLessThan(300);
});
it("behandelt ein Prozentzeichen als Zeichen und nicht als Platzhalter", async () => {
const { technikerin, notebook, tina } = await bestand();
await testPrisma.kommentar.create({
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: "Platte zu 50% belegt." },
});
await testPrisma.kommentar.create({
data: { laufId: notebook.id, benutzerId: tina.id, rumpf: "Platte zu 5099 Blöcken belegt." },
});
const ergebnis = await archivSuche(technikerin, { suche: "50%" });
const ausschnitte = (ergebnis.zeilen[0]?.fundstellen ?? []).map((stelle) => stelle.ausschnitt);
expect(ausschnitte.some((text) => text.includes("50%"))).toBe(true);
expect(ausschnitte.some((text) => text.includes("5099"))).toBe(false);
});
it("verwirft einen zu kurzen Begriff, filtert aber weiter", async () => {
const { technikerin, offboarding } = await bestand();
const ergebnis = await archivSuche(technikerin, { suche: "a", vorlageId: offboarding.vorlageId });
expect(ergebnis.sucheZuKurz).toBe(true);
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual(["Offboarding Meier"]);
});
it("liefert ohne Begriff und ohne Filter das Archiv, neueste Ablage zuerst", async () => {
const { technikerin } = await bestand();
const ergebnis = await archivSuche(technikerin, {});
expect(ergebnis.zeilen.map((zeile) => zeile.titel)).toEqual([
"Neuinstallation nb-meier",
"Offboarding Meier",
]);
expect(ergebnis.zeilen.every((zeile) => zeile.fundstellen.length === 0)).toBe(true);
});
});
describe("Was im Archiv steht", () => {
it("sucht über archivierte und abgeschlossene Läufe, aber nicht über laufende", async () => {
const { technikerin } = await bestand();
const gefunden = await titel(technikerin, { suche: WORT_NOTIZ });
expect(gefunden).toEqual(["Neuinstallation nb-meier"]);
expect(gefunden).not.toContain("Neuinstallation nb-schmidt");
});
it("nimmt laufende Läufe auf ausdrücklichen Wunsch dazu", async () => {
const { technikerin } = await bestand();
const gefunden = await titel(technikerin, { suche: WORT_NOTIZ, auchLaufende: true });
expect(gefunden).toContain("Neuinstallation nb-schmidt");
});
it("sagt, dass die Liste gekappt ist", async () => {
const { technikerin, eigene, neuinstallation } = await bestand();
for (let nummer = 0; nummer < ARCHIV_HOECHSTENS; nummer += 1) {
await laufAnlegen({
titel: `Massenlauf ${nummer}`,
versionId: neuinstallation.versionId,
kundeId: eigene.id,
});
}
const ergebnis = await archivSuche(technikerin, {});
expect(ergebnis.zeilen).toHaveLength(ARCHIV_HOECHSTENS);
expect(ergebnis.gekappt).toBe(true);
});
});
describe("Filter", () => {
it("kombiniert Vorlage, Zeitraum und Bearbeiter", async () => {
const { technikerin, neuinstallation, tina } = await bestand();
const filter: ArchivFilter = {
vorlageId: neuinstallation.vorlageId,
von: datumAusText("2026-03-01"),
bis: datumAusText("2026-03-31"),
bearbeiterId: tina.id,
};
expect(await titel(technikerin, filter)).toEqual(["Neuinstallation nb-meier"]);
});
it("lässt einen Lauf weg, sobald einer der drei Filter nicht passt", async () => {
const { technikerin, neuinstallation, offboarding, tina, leser } = await bestand();
const passend: ArchivFilter = {
vorlageId: neuinstallation.vorlageId,
von: datumAusText("2026-03-01"),
bis: datumAusText("2026-03-31"),
bearbeiterId: tina.id,
};
expect(await titel(technikerin, { ...passend, vorlageId: offboarding.vorlageId })).toEqual([]);
expect(await titel(technikerin, { ...passend, von: datumAusText("2026-04-01") })).toEqual([]);
expect(await titel(technikerin, { ...passend, bis: datumAusText("2026-03-09") })).toEqual([]);
// Lars hat an diesem Lauf nichts getan – weder zugewiesen noch
// abgeschlossen noch an einem Schritt.
expect(await titel(technikerin, { ...passend, bearbeiterId: leser.id })).toEqual([]);
});
it("nimmt den Bis-Tag vollständig mit", async () => {
const { technikerin } = await bestand();
// Abgelegt am 10. März um 9 Uhr – ein „bis 10. März“ muss ihn finden.
expect(await titel(technikerin, { bis: datumAusText("2026-03-10") })).toContain(
"Neuinstallation nb-meier",
);
expect(await titel(technikerin, { von: datumAusText("2026-03-10") })).toContain(
"Neuinstallation nb-meier",
);
});
it("zählt als Bearbeiter auch, wer nur an einem Schritt gearbeitet hat", async () => {
const { technikerin, tom } = await bestand();
// Tom ist dem Notebook nicht zugewiesen, hat aber einen Wert eingetragen.
expect(await titel(technikerin, { bearbeiterId: tom.id })).toEqual([
"Neuinstallation nb-meier",
"Offboarding Meier",
]);
});
it("filtert nach Mitarbeiter, Abteilung und Gerät", async () => {
const { technikerin, person, abteilung, geraet } = await bestand();
expect(await titel(technikerin, { mitarbeiterId: person.id })).toEqual(["Neuinstallation nb-meier"]);
expect(await titel(technikerin, { abteilungId: abteilung.id })).toEqual(["Neuinstallation nb-meier"]);
expect(await titel(technikerin, { geraetId: geraet.id })).toEqual(["Neuinstallation nb-meier"]);
});
it("verwirft im Modus INTERN eine mitgeschickte Kundenkennung", async () => {
const { technikerin } = await bestand();
// Es gibt keine Kundenwahl in diesem Modus – also auch nichts zu
// entscheiden (#008). Ein erfundener Wert darf die Liste nicht leeren.
expect(await titel(technikerin, { kundeId: "erfunden" })).toEqual([
"Neuinstallation nb-meier",
"Offboarding Meier",
]);
});
});
describe("Filterlisten", () => {
it("nimmt archivierte Vorlagen und deaktivierte Konten mit", async () => {
const { eigene, technikerin } = await bestand();
await vorlageMitVersion("Alte Vorlage", null, true);
await benutzerAnlegen("Erik Ehemalig", "erik@firma.example", Rolle.TECHNIKER, false);
await testPrisma.mitarbeiter.create({
data: {
vorname: "Peter",
nachname: "Pensioniert",
anzeigename: "Peter Pensioniert",
upn: "peter@firma.example",
kundeId: eigene.id,
aktiv: false,
},
});
const werte = await archivFilterwerte(technikerin);
expect(werte.vorlagen.map((eintrag) => eintrag.name)).toContain("Alte Vorlage");
expect(werte.bearbeiter.map((eintrag) => eintrag.name)).toContain("Erik Ehemalig");
expect(werte.mitarbeiter.map((eintrag) => eintrag.name)).toContain("Peter Pensioniert");
expect(werte.gekappt).toBe(false);
// Im Modus INTERN gibt es keine Kundenebene (#008).
expect(werte.kunden).toEqual([]);
});
});
});
/**
* Die Mandantengrenze – der Teil, der in #009 der Blocker war.
*
* Geprüft wird nicht „filtert die Liste“, sondern „kommt der fremde Lauf über
* **irgendeinen** Weg herein“: über den Titel, über eine Notiz, über einen
* Kommentar, über jeden einzelnen Filter und über eine mitgeschickte
* Kundenkennung.
*/
describe.runIf(datenbankVerfuegbar)("Archiv und Mandantengrenze", () => {
beforeEach(async () => {
await datenbankLeeren();
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "lz-kunde-";
});
async function zweiKunden() {
await testPrisma.kunde.create({
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
});
await testPrisma.einstellung.create({
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
});
const meiner = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } });
const tina = await benutzerAnlegen("Tina Technik", "tina@firma.example", Rolle.TECHNIKER);
await testPrisma.benutzerKunde.create({ data: { benutzerId: tina.id, kundeId: meiner.id } });
async function laufMit(name: string, kundeId: string, tagCode: string) {
const vorlage = await vorlageMitVersion(`Vorlage ${name}`, kundeId);
const person = await testPrisma.mitarbeiter.create({
data: {
vorname: "Person",
nachname: name,
anzeigename: `Person ${name}`,
upn: `${name}@example.test`,
kundeId,
},
});
const abteilung = await testPrisma.abteilung.create({ data: { name: `Abteilung ${name}`, kundeId } });
const geraet = await testPrisma.geraet.create({
data: { tagCode, hostname: `pc-${name}`, kundeId, registriertAm: new Date() },
});
const lauf = await laufAnlegen({
titel: `Geheimvorgang ${name}`,
versionId: vorlage.versionId,
kundeId,
zugewiesenAnId: tina.id,
mitarbeiterId: person.id,
geraetId: geraet.id,
abteilungId: abteilung.id,
});
await testPrisma.schrittergebnis.create({
data: {
laufId: lauf.id,
vorlagenSchrittId: vorlage.haken,
zustand: SchrittZustand.ERLEDIGT,
notiz: `Geheimnotiz ${WORT_NOTIZ}`,
aktualisiertVonId: tina.id,
},
});
await testPrisma.schrittergebnis.create({
data: {
laufId: lauf.id,
vorlagenSchrittId: vorlage.text,
zustand: SchrittZustand.ERLEDIGT,
wert: WORT_WERT,
aktualisiertVonId: tina.id,
},
});
await testPrisma.kommentar.create({
data: { laufId: lauf.id, benutzerId: tina.id, rumpf: `Geheimkommentar ${WORT_KOMMENTAR}` },
});
return { vorlage, person, abteilung, geraet, lauf, kundeId };
}
return {
meiner,
fremder,
meins: await laufMit("Meiner", meiner.id, "LZ-AAAAAA"),
fremdes: await laufMit("Fremder", fremder.id, "LZ-BBBBBB"),
technikerin: { id: tina.id, rolle: Rolle.TECHNIKER },
admin: { id: tina.id, rolle: Rolle.ADMIN },
};
}
it("hält bei jedem Suchbegriff, der über eine andere Tabelle trifft", async () => {
const { technikerin } = await zweiKunden();
for (const begriff of ["Geheimvorgang", WORT_NOTIZ, WORT_WERT, WORT_KOMMENTAR, "Geheim"]) {
const gefunden = await titel(technikerin, { suche: begriff });
expect(gefunden, begriff).toEqual(["Geheimvorgang Meiner"]);
}
});
it("hält bei jedem einzelnen Filter", async () => {
const { technikerin, fremdes, fremder } = await zweiKunden();
const filter: ArchivFilter[] = [
{ vorlageId: fremdes.vorlage.vorlageId },
{ mitarbeiterId: fremdes.person.id },
{ abteilungId: fremdes.abteilung.id },
{ geraetId: fremdes.geraet.id },
{ kundeId: fremder.id },
];
for (const einzeln of filter) {
expect(await titel(technikerin, einzeln), JSON.stringify(einzeln)).toEqual([]);
}
});
it("hält, wenn Suche und Filter zusammenkommen", async () => {
const { technikerin, fremdes, fremder } = await zweiKunden();
const gefunden = await titel(technikerin, {
suche: "Geheim",
vorlageId: fremdes.vorlage.vorlageId,
kundeId: fremder.id,
bearbeiterId: fremdes.lauf.zugewiesenAnId ?? undefined,
auchLaufende: true,
});
expect(gefunden).toEqual([]);
});
it("zeigt einem Administrator beide Läufe", async () => {
const { admin } = await zweiKunden();
expect((await titel(admin, { suche: "Geheim" })).sort()).toEqual([
"Geheimvorgang Fremder",
"Geheimvorgang Meiner",
]);
});
it("filtert die Auswahllisten mit", async () => {
const { technikerin } = await zweiKunden();
const werte = await archivFilterwerte(technikerin);
expect(werte.vorlagen.map((eintrag) => eintrag.name)).toEqual(["Vorlage Meiner"]);
expect(werte.mitarbeiter.map((eintrag) => eintrag.name)).toEqual(["Person Meiner"]);
expect(werte.abteilungen.map((eintrag) => eintrag.name)).toEqual(["Abteilung Meiner"]);
expect(werte.geraete.map((eintrag) => eintrag.name)).toEqual(["pc-Meiner · LZ-AAAAAA"]);
expect(werte.kunden.map((eintrag) => eintrag.name)).toEqual(["Meiner"]);
});
});
/**
* Die Trefferzeile im gerenderten HTML.
*
* Hier steht der einzige eigene Darstellungscode des Archivs: das Hervorheben
* des Begriffs im Ausschnitt. Es laeuft ueber `indexOf` statt ueber einen
* regulaeren Ausdruck, weil der Begriff aus der Adresszeile kommt — und es soll
* jedes Vorkommen treffen, nicht nur das erste.
*/
describe("Trefferzeile", () => {
const zeile: ArchivZeile = {
id: "lauf-1",
titel: "Neuinstallation nb-meier",
status: LaufStatus.ARCHIVIERT,
vorlagenName: "PC-Neuinstallation",
version: 3,
geraetName: "nb-meier",
mitarbeiterName: "Mara Meier",
abteilungName: "Buchhaltung",
ticket: "INC-4711",
kundeName: "Eigene Organisation",
abgelegtAm: new Date("2026-03-10T09:00:00"),
abgeschlossenAm: new Date("2026-03-10T09:00:00"),
archiviertAm: new Date("2026-03-10T09:00:00"),
abgeschlossenVon: "Tina Technik",
zugewiesenAn: "Tina Technik",
fundstellen: [{ feld: "NOTIZ", ausschnitt: "Meier gemeldet, Meier bestätigt" }],
weitereFundstellen: 2,
};
it("führt in den Lauf und hebt jedes Vorkommen hervor", () => {
const html = renderToStaticMarkup(
createElement(ArchivTreffer, { zeile, begriff: "meier", kundenEbene: false }),
);
expect(html).toContain('href="/laeufe/lauf-1"');
// Zwei im Ausschnitt, eines im Titel.
expect(html.match(/<mark/g)).toHaveLength(3);
expect(html).toContain("Notiz");
expect(html).toContain("2 weitere Fundstellen");
// Der Kundenname erscheint nur, wo es eine Kundenebene gibt (#008).
expect(html).not.toContain("Eigene Organisation");
});
it("markiert ohne Begriff nichts", () => {
const html = renderToStaticMarkup(
createElement(ArchivTreffer, { zeile: { ...zeile, fundstellen: [] }, begriff: "", kundenEbene: true }),
);
expect(html).not.toContain("<mark");
expect(html).toContain("Eigene Organisation");
});
});
+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");
});
});
});
+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();
});
+162
View File
@@ -0,0 +1,162 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
/**
* Uebersicht und Druckansicht am gebauten Stand (#015).
*
* Beide Seiten leben von ihrer Darstellung: die Uebersicht sortiert nach
* Dringlichkeit, die Druckansicht gehoert auf Papier. Was davon stimmt, zeigt
* erst der Browser — die Lesemodelle dahinter pruefen `tests/uebersicht.test.ts`
* und `tests/druckansicht.test.ts`.
*/
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 Lauf aus einer Vorlage, die `{{generiertes_passwort}}` wirklich enthaelt.
*
* Direkt ueber Prisma: geprueft werden soll die **Druckansicht**, nicht der Weg
* zum Lauf — den prueft `tests/e2e/laeufe.spec.ts` vom Anlegen bis zum Archiv.
*/
async function laufMitPasswortschritt(): Promise<string> {
const stempel = Date.now();
const kunde = await prisma.kunde.findFirstOrThrow({ select: { id: true } });
const vorlage = await prisma.vorlage.create({
data: { name: `ZZ E2E Druck ${stempel}`, kategorie: "ZZ Test" },
});
const version = await prisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await prisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Konto" },
});
await prisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Konto angelegt",
typ: "BEFEHL",
konfig: { shell: "powershell", befehl: 'New-ADUser -Password "{{generiertes_passwort}}"' },
},
});
await prisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
const lauf = await prisma.lauf.create({
data: { vorlagenVersionId: version.id, kundeId: kunde.id, titel: `ZZ E2E Druck ${stempel}` },
});
return lauf.id;
}
test.describe("Übersicht", () => {
test("zeigt die vier Blöcke in ihrer Reihenfolge", async ({ page }) => {
await anmelden(page);
const ueberschriften = await page.getByRole("heading", { level: 2 }).allInnerTexts();
expect(ueberschriften).toEqual(["Überfällig", "Mir zugewiesen", "Zuletzt bearbeitet", "Schnellstart"]);
});
test("führt von einer Zeile in den Lauf", async ({ page }) => {
await anmelden(page);
const zuletzt = page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "Zuletzt bearbeitet" }) });
await zuletzt.getByRole("link").first().click();
await page.waitForURL(/\/laeufe\/[^/]+$/);
});
test("trägt am Handy ohne horizontales Scrollen", async ({ page, isMobile }) => {
test.skip(!isMobile, "Nur im Projekt „handy“.");
await anmelden(page);
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
});
});
test.describe("Druckansicht", () => {
test("ist hell, ohne Navigation und passt auf A4", async ({ page }) => {
test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät.");
await anmelden(page);
const lauf = await prisma.lauf.findFirstOrThrow({ select: { id: true } });
await page.goto(`/drucken/lauf/${lauf.id}`);
// Keine Hülle: weder Seitenleiste noch der Scannen-Knopf gehören aufs Blatt.
await expect(page.getByRole("navigation", { name: "Hauptnavigation" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Scannen" })).toHaveCount(0);
// Hell heisst hell: der Grund des Belegs ist keine dunkle Fläche.
const grund = await page
.locator(".druck-hell")
.first()
.evaluate((element) => getComputedStyle(element).backgroundColor);
expect(grund).toBe("rgb(255, 255, 255)");
/*
* A4 kommt aus `@page`, und nur ein echtes PDF zeigt, ob die Regel greift:
* 210 × 297 mm sind 595 × 842 Punkte.
*/
const pdf = await page.pdf({ preferCSSPageSize: true });
const text = pdf.toString("latin1");
const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text);
expect(treffer).not.toBeNull();
expect(Number(treffer?.[3]) - Number(treffer?.[1])).toBeCloseTo(595, 0);
expect(Number(treffer?.[4]) - Number(treffer?.[2])).toBeCloseTo(842, 0);
});
test("ist vom Lauf aus erreichbar und erzeugt kein Passwort", async ({ page }) => {
/*
* Der Lauf bringt seine eigene Vorlage mit — eine **mit**
* `{{generiertes_passwort}}` im Befehl. Vorher lief dieser Test gegen
* NB-001, dessen Vorlage den Platzhalter gar nicht enthält: er hätte auch
* dann bestanden, wenn die Druckansicht Passwörter erzeugte (Reviewbefund
* zu #015).
*/
const laufId = await laufMitPasswortschritt();
await anmelden(page);
await page.goto(`/laeufe/${laufId}`);
await page.getByRole("link", { name: "Drucken" }).click();
await page.waitForURL(/\/drucken\/lauf\//);
const blatt = await page.locator("body").innerText();
expect(blatt).toContain("[Passwort – nicht gespeichert]");
expect(blatt).not.toContain("generiertes_passwort");
/*
* Und der zweite Beweis, der ohne Kenntnis des Textes auskommt: ein
* erzeugtes Passwort wäre bei jedem Aufbau ein anderes. Sind beide Blätter
* Zeichen für Zeichen gleich, kann keines darin stecken.
*/
await page.reload();
const nochmal = await page.locator("body").innerText();
expect(nochmal.replace(/Gedruckt am[^\n]*/u, "")).toBe(blatt.replace(/Gedruckt am[^\n]*/u, ""));
});
});
test.afterAll(async () => {
const laeufe = await prisma.lauf.findMany({
where: { titel: { startsWith: "ZZ E2E Druck" } },
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.vorlage.deleteMany({ where: { name: { startsWith: "ZZ E2E Druck" } } });
await prisma.$disconnect();
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { fristVerstrichen, tagesbeginn } from "@/lib/fristen";
/**
* Die Grenze zwischen „heute faellig“ und „ueberfaellig“ (#015).
*
* Der Review hat genau hier zugeschlagen: verglichen wurde mit dem Augenblick
* statt mit dem Tagesbeginn, und ein heute faelliger Lauf stand ab zwei Uhr
* morgens in Warnfarbe. Der alte Test kannte nur „gestern“ und „morgen“ — die
* Grenze selbst blieb ungeprueft, und genau dort sass der Fehler.
*/
describe("Fristen", () => {
const HEUTE_FRUEH = new Date(2026, 8, 25, 2, 0, 0);
const HEUTE_SPAET = new Date(2026, 8, 25, 23, 30, 0);
it("zählt den heutigen Tag nicht als verstrichen", () => {
const heuteMitternacht = new Date(2026, 8, 25, 0, 0, 0);
expect(fristVerstrichen(heuteMitternacht, HEUTE_FRUEH)).toBe(false);
expect(fristVerstrichen(heuteMitternacht, HEUTE_SPAET)).toBe(false);
});
it("zählt gestern als verstrichen, egal zu welcher Uhrzeit", () => {
const gestern = new Date(2026, 8, 24, 23, 59, 0);
expect(fristVerstrichen(gestern, HEUTE_FRUEH)).toBe(true);
expect(fristVerstrichen(gestern, HEUTE_SPAET)).toBe(true);
});
it("zählt morgen nicht als verstrichen", () => {
expect(fristVerstrichen(new Date(2026, 8, 26, 0, 0, 0), HEUTE_SPAET)).toBe(false);
});
it("nimmt eine fehlende Frist als nicht verstrichen", () => {
expect(fristVerstrichen(null, HEUTE_FRUEH)).toBe(false);
expect(fristVerstrichen(undefined, HEUTE_FRUEH)).toBe(false);
});
it("gibt den Tagesbeginn in lokaler Zeit zurück", () => {
const beginn = tagesbeginn(HEUTE_SPAET);
expect(beginn.getFullYear()).toBe(2026);
expect(beginn.getMonth()).toBe(8);
expect(beginn.getDate()).toBe(25);
expect(beginn.getHours()).toBe(0);
expect(beginn.getMinutes()).toBe(0);
});
});
+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");
});
});
});