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