diff --git a/.gitignore b/.gitignore index 629671c..7f2b7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ blob-report/ # Werkzeuge *.tsbuildinfo .DS_Store +/public/zxing/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a37d9e..4e33e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). - Abschnitte klappen zu, sobald in ihnen nichts mehr zu tun ist — wer einen wieder aufklappt, behält ihn offen, auch über das Speichern hinweg (#013) +- QR-Aufkleber: Stapel vorgedruckter Tags, Etikettendruck als Einzeletikett (62 × 29 mm) und als + A4-Bogen (3 × 8 à 70 × 37 mm), je mit QR, lesbarem Code, Hostname und Besitzer (#014) +- `/t/` als Einstieg vom Aufkleber: registriertes Gerät → Geräteseite, vorgedruckter Tag → + Registrierformular, unbekannter Code → Hinweis mit Weg zur Suche (#014) +- Geräteseite mit Kopf, „Zugewiesen“, Schnellstart aus den Gerätetyp-Vorgaben, „Vorlage zuweisen“ + und Historie — am Handy bedienbar, weil sie dort benutzt wird (#014) +- „Scannen“ mit der BarcodeDetector-API des Browsers, wo es sie gibt, sonst mit WebAssembly; wo + 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) + ### Sicherheit - Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen, @@ -202,3 +212,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). Bearbeiter fremder Kunden mehr (#013) - „Nicht zutreffend“ und „fehlgeschlagen“ lassen sich mit einem Klick zurücknehmen. Vorher sah es danach aus, als wäre nichts gewählt, während in der Datenbank weiter der alte Zustand stand (#013) +- Im QR steht ausschließlich `{APP_URL}/t/{tagCode}` — kein Name, keine Seriennummer, kein + Besitzer. Ein Aufkleber hängt jahrelang sichtbar am Gerät und wird von jedem Telefon gelesen; + was darauf steht, ist veröffentlicht (#014) +- Der QR wird lokal erzeugt, nie über eine externe API: die bekäme mit jedem Aufruf den Bestand an + Tag-Codes geliefert (#014) +- Das WebAssembly des QR-Lesers kommt aus dem eigenen Haus statt aus einem CDN — die + Content-Security-Policy erlaubt nur `connect-src 'self'`, und die Anwendung soll ohne Internet + 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) diff --git a/docs/architektur.md b/docs/architektur.md index ddb5fe8..6467fbb 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -200,6 +200,46 @@ hat. Bei verstecktem Tab pausiert die Abfrage. Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück. Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab. +## 5e. Der Aufkleber ist die Schnittstelle + +Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert +und nie wiederverwendet** wird — auch nicht, nachdem das Geraet geloescht wurde +(`TagCodeVergabe`, #006). Der Aufkleber ist damit die einzige Angabe am Geraet, +die Jahre haelt: Hostnamen aendern sich, Besitzer wechseln, Seriennummern werden +abgeschrieben. + +**Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`.** Kein Name, keine +Seriennummer, kein Besitzer. Ein Aufkleber haengt sichtbar am Geraet und wird von +jedem Telefon gelesen, auch von einem fremden; was darauf steht, ist +veroeffentlicht. Die Adresse selbst verraet nichts — dahinter verlangt +`/t/[tagCode]` eine Anmeldung und zeigt einem fremden Kunden dieselbe Antwort wie +bei einem Code, den es nicht gibt. + +Drei Dinge daran sind nicht mehr zu korrigieren, sobald gedruckt ist, und stehen +deshalb fest: + +1. **Die Basisadresse kommt aus `APP_URL`, mit ihrem Pfad.** Liegt die Anwendung + unter `https://firma.de/laufzettel`, muss genau das im QR stehen; + `basisAdresse()` in `src/lib/adressen.ts` haelt den Pfad fest, `tagAdresse()` + haengt relativ an. Ohne gesetzte `APP_URL` warnt die Druckseite, statt still + `localhost` zu drucken. +2. **Der QR entsteht lokal** (`qrcode`, Fehlerkorrektur M, Ruhezone 4). Eine + externe API bekaeme mit jedem Aufruf den Bestand an Tag-Codes geliefert. +3. **Die Masse sind geprueft, nicht behauptet.** `tests/e2e/etiketten.spec.ts` + misst die Seitengroesse an einem echten PDF (62 × 29 mm bzw. A4) und liest + ein bei 300 dpi gerastertes Etikett mit demselben Decoder zurueck, den auch + der Scanner benutzt. + +Der Scanner laedt `barcode-detector/polyfill`: wo der Browser eine +`BarcodeDetector`-API mitbringt, laeuft sie, sonst springt WebAssembly ein. Dessen +`.wasm` kommt aus dem eigenen Haus (`scripts/wasm-bereitstellen.mjs` legt es unter +`public/zxing/`), weil die Content-Security-Policy nur `connect-src 'self'` +erlaubt und die Anwendung ohne Internet laufen soll; dafuer traegt `script-src` +ein `'wasm-unsafe-eval'` — die schmale Erlaubnis, WebAssembly zu uebersetzen, und +**nicht** `unsafe-eval`. Laeuft die Kamera nicht oder faellt der Decoder aus, steht +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. + ## 6. Betrieb: zwei Wege, eine Regel Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: diff --git a/docs/devlog.md b/docs/devlog.md index 0fad002..703ae67 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -16,11 +16,12 @@ als Unterpunkte mit Aufgabe und Dauer. | #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min | | #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min | | #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min | -| | **Summe** | **16 h 50 min** | +| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min | +| | **Summe** | **20 h 25 min** | | Meilenstein | Tatsächlich | Status | |---|---|---| -| v0.1.0 | 16 h 50 min | in Arbeit | +| v0.1.0 | 20 h 25 min | in Arbeit | --- @@ -566,3 +567,61 @@ als Unterpunkte mit Aufgabe und Dauer. - #014 QR-Tags, Geräteseite, Etiketten und Scanner — der Einstieg, für den die ganze Anwendung gebaut ist: Code scannen, Geräteseite öffnen, loslegen. + +--- + +## 2026-09-25 · Sitzung 10 · 18:50–21:30 + +**Issue:** #014 (M9) +**Version:** 0.1.0 + +### Erledigt + +- **Zwei Sub-Agenten parallel**, auf getrennten Dateien: QR, Etiketten und Tag-Stapel der eine, + Geräteseite und `/t/` der andere. Der Scanner kam von Hand dazu. +- `/t/` als Weiche, Registrierformular, Geräteseite mit Zuweisungen, Schnellstart, + „Vorlage zuweisen“ und Historie +- Etikettendruck in zwei Formaten, QR ausschließlich mit `{APP_URL}/t/{tagCode}` +- Scanner mit der BarcodeDetector-API des Browsers, WebAssembly als Rückfall, Eingabefeld als + Ersatzweg +- Unabhängiger Review: **drei Blocker**, elf Befunde, mehrere Anmerkungen — alle abgearbeitet +- **930 Tests** (vorher 879) und **54 Ende-zu-Ende-Tests** grün + +### Entscheidungen + +- **Parallel arbeiten heißt Dateien trennen, nicht Aufgaben.** Beide Agenten bekamen eine Liste + der Dateien, die ihnen gehören, und je eine eigene Testdatenbank. Es gab keinen einzigen + Schreibkonflikt — die Fehler saßen stattdessen an den **Nähten**, und zwar alle drei. +- **Das WebAssembly des QR-Lesers wird kopiert, nicht eingecheckt.** `zxing-wasm` lädt es sonst + aus einem CDN: die Content-Security-Policy erlaubt `connect-src 'self'`, und eine selbst + gehostete Anwendung soll ohne Internet laufen. 1,1 MB gehören nicht ins Repository, also legt + `scripts/wasm-bereitstellen.mjs` sie vor jedem Bau bereit. +- **`'wasm-unsafe-eval'` statt `unsafe-eval`.** Die schmale Erlaubnis, WebAssembly zu übersetzen — + ohne sie läuft die Kamera und erkennt nie etwas. Der Test prüft jetzt auf die **Marke**, nicht + auf das Wort: `wasm-unsafe-eval` enthält „unsafe-eval“ und ist doch etwas anderes. +- **Eine Route ohne Aufrufer wird entfernt, nicht dokumentiert.** `/api/qr/[tagCode]` war sauber + gebaut, hatte aber keinen Nutzer, keinen Test und eine abweichende Groß-/Kleinschreibung. Die + Geräteseite setzt das SVG jetzt direkt ein. + +### Probleme + +- **Drei Defekte, die nur der gebaute Stand zeigt.** „Starten“ setzte `IN_ARBEIT` und sprang + nicht (der Knopf verschwindet beim Neuaufbau, bevor sein Effekt läuft); „Vorlage zuweisen“ bot + Vorlagen an, die der Dienst danach ablehnte; die Geräteliste verlinkte nichts. Kein einziger + davon wäre durch Lesen aufgefallen. +- **Der Scanner benutzte nie die native API.** `barcode-detector/ponyfill` gibt **immer** die + eigene Klasse zurück — der Kommentar behauptete das Gegenteil, und die Auftragszeile „native + BarcodeDetector-API mit Polyfill-Fallback“ war damit unerfüllt. Ein Blick in `node_modules` + hätte genügt; ich habe der Paketbeschreibung geglaubt. +- **Ein Unterpfad in `APP_URL` fiel aus dem QR heraus.** `new URL("/t/…", "https://firma.de/app")` + verschluckt `/app`. Für eine Umleitung fällt das nie auf, weil der Reverse Proxy den Pfad wieder + anhängt — auf einem geklebten Aufkleber ist es das Ende. Der Test dazu hieß „nimmt APP_URL mit + Pfad ernst“ und prüfte nur einen Schrägstrich. +- **Sechster Meilenstein in Folge mit Zusagen ohne Deckung:** archivierte Geräte im Druck, „1 von + 2 fehlt“ bei doppelten Codes, eine Vorbelegung, die nicht kam, ein Kommentarblock über der + falschen Funktion. + +### Nächster Schritt + +- #015 Dashboard und Archiv — die Übersicht für den Alltag und die Volltextsuche über alles, was + abgearbeitet wurde. diff --git a/docs/issues/014-qr-tags-geraeteseite.md b/docs/issues/014-qr-tags-geraeteseite.md index 2fed549..0b660e9 100644 --- a/docs/issues/014-qr-tags-geraeteseite.md +++ b/docs/issues/014-qr-tags-geraeteseite.md @@ -1,9 +1,9 @@ # #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** phase-1, backend, frontend -**Geschätzt:** 12 h **Tatsächlich:** – +**Geschätzt:** 12 h **Tatsächlich:** 3 h 35 min ## Ziel @@ -11,28 +11,71 @@ Der Einstieg für den Techniker, der am Gerät steht: scannen, sehen was zu tun ## Aufgaben -- [ ] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber -- [ ] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag“ mit Link zur Suche -- [ ] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite -- [ ] Geräteseite `/geraete/[id]`, handyoptimiert: Kopf mit Hostname bzw. Bezeichnung, Typ, Besitzer mit Abteilung, Seriennummer, `tagCode` in JetBrains Mono -- [ ] Abschnitt „Zugewiesen“: alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten“ bzw. „Weiter“ -- [ ] Abschnitt „Schnellstart“: Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer -- [ ] Abschnitt „Vorlage zuweisen“: Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN` -- [ ] Abschnitt „Historie“: abgeschlossene und archivierte Läufe chronologisch -- [ ] QR-Erzeugung serverseitig als SVG mit `qrcode`, Fehlerkorrektur M, Inhalt ausschließlich `{APP_URL}/t/{tagCode}`, keine Personendaten, keine externe API -- [ ] Etikettendruck mit Print-CSS in zwei Layouts: Etikettendrucker 62 × 29 mm (ein Etikett pro Seite) und A4-Bogen 3 × 8 à 70 × 37 mm; je Etikett QR, `tagCode` lesbar darunter, optional Hostname und Besitzer, kleines Laufzettel-Logo; immer hell -- [ ] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie -- [ ] Knopf „Etikett drucken“ auf der Geräteseite, Stapeldruck für unregistrierte Tags -- [ ] „Scannen“ in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite -- [ ] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/` +- [x] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber +- [x] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag“ mit Link zur Suche +- [x] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite +- [x] Geräteseite `/geraete/[id]`, handyoptimiert: Kopf mit Hostname bzw. Bezeichnung, Typ, Besitzer mit Abteilung, Seriennummer, `tagCode` in JetBrains Mono +- [x] Abschnitt „Zugewiesen“: alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten“ bzw. „Weiter“ +- [x] Abschnitt „Schnellstart“: Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer +- [x] Abschnitt „Vorlage zuweisen“: Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN` +- [x] Abschnitt „Historie“: abgeschlossene und archivierte Läufe chronologisch +- [x] QR-Erzeugung serverseitig als SVG mit `qrcode`, Fehlerkorrektur M, Inhalt ausschließlich `{APP_URL}/t/{tagCode}`, keine Personendaten, keine externe API +- [x] Etikettendruck mit Print-CSS in zwei Layouts: Etikettendrucker 62 × 29 mm (ein Etikett pro Seite) und A4-Bogen 3 × 8 à 70 × 37 mm; je Etikett QR, `tagCode` lesbar darunter, optional Hostname und Besitzer, kleines Laufzettel-Logo; immer hell +- [x] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie +- [x] Knopf „Etikett drucken“ auf der Geräteseite, Stapeldruck für unregistrierte Tags +- [x] „Scannen“ in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite +- [x] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/` ## Akzeptanzkriterien -- [ ] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001` -- [ ] Test: „Vorlage zuweisen“ erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen“ erscheint; „Starten“ öffnet ihn und setzt `IN_ARBEIT` -- [ ] Test: unregistrierter Tag führt zum Registrierformular, danach zur Geräteseite; unbekannter Tag zeigt den Hinweis -- [ ] Test: mehrere Zuweisungen je Gerät sind gleichzeitig möglich -- [ ] Test: der QR-Inhalt ist genau `{APP_URL}/t/{tagCode}` und enthält keine Personendaten -- [ ] Test: ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem QR-Decoder korrekt zurückgelesen -- [ ] Beide Druckbilder sind sauber, ohne Sidebar, ohne dunkle Flächen, ohne abgeschnittene Etiketten -- [ ] Die Geräteseite ist bei 390 px ohne horizontales Scrollen bedienbar +- [x] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001` +- [x] Test: „Vorlage zuweisen“ erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen“ erscheint; „Starten“ öffnet ihn und setzt `IN_ARBEIT` +- [x] Test: unregistrierter Tag führt zum Registrierformular, danach zur Geräteseite; unbekannter Tag zeigt den Hinweis +- [x] Test: mehrere Zuweisungen je Gerät sind gleichzeitig möglich +- [x] Test: der QR-Inhalt ist genau `{APP_URL}/t/{tagCode}` und enthält keine Personendaten +- [x] Test: ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem QR-Decoder korrekt zurückgelesen +- [x] Beide Druckbilder sind sauber, ohne Sidebar, ohne dunkle Flächen, ohne abgeschnittene Etiketten +- [x] Die Geräteseite ist bei 390 px ohne horizontales Scrollen bedienbar + +## Ergebnis + +Erledigt am 2026-09-25. **930 Tests** (vorher 879) und **54 Ende-zu-Ende-Tests** grün, Screenshots in +`docs/screenshots/m9/`. + +Zwei Teile entstanden parallel (QR/Etiketten und Geräteseite/Tag-Einstieg), der Scanner kam von +Hand dazu. Die Fehler saßen erwartungsgemäß an den Nähten — und fielen erst im **gebauten** Stand +auf: + +- **„Starten“ startete, sprang aber nicht.** `revalidatePath` baut die Geräteseite neu, dabei + verschwindet der Knopf aus dem Baum — und der Effekt, der in den Lauf springen sollte, kam nie + dazu. Der Sprung passiert jetzt im Dienst (`laufStartenAktion`). +- **„Vorlage zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte.** Vorlagen mit + Pflichtangaben führen jetzt in den Start-Dialog — dieselbe Entscheidung, die der Schnellstart + schon traf. +- **Die Geräteliste verlinkte nichts**, weil es die Detailseite bis dahin nicht gab. + +### Der unabhängige Review + +Drei Blocker, elf Befunde, mehrere Anmerkungen — alle abgearbeitet. Die wichtigsten: + +- **Der Scanner benutzte nie die native `BarcodeDetector`-API.** Geladen war die Ponyfill-Fassung, die + **immer** ihre eigene Klasse zurückgibt; jedes Gerät holte 1,1 MB WebAssembly, auch wo der + Browser einen Decoder mitbringt. Jetzt der Polyfill, der nur einspringt, wenn es keinen gibt. +- **Ein ausgefallener Decoder blieb unsichtbar:** dreimal je Sekunde ein Fehler, und auf dem + Bildschirm stand unverändert „Halte den Aufkleber ins Bild“. Nach drei Fehlschlägen hört die + Abfrage auf und sagt es. +- **Ein Unterpfad in `APP_URL` fiel aus dem QR heraus** — `https://firma.de/laufzettel` wurde zu + `https://firma.de/t/…`. Auf Papier ist das nicht mehr zu berichtigen; `basisAdresse()` hält den + Pfad jetzt fest. +- **Ohne `APP_URL` druckte jedes Etikett stillschweigend `localhost`.** Die Druckseite warnt. +- **Der Stapeldruck war die offene Hintertür zu einer Seite mit Schloss:** `/tags` verlangt + `verwaltung`, der Bogen ohne Codes zeigte dieselbe Liste jedem Leser. Jetzt gilt dort dasselbe + Recht. +- **`/api/qr/[tagCode]` hatte keinen Aufrufer und keinen Test** und faltete Groß/Klein anders als + `/t/`. Entfernt; die Geräteseite setzt das SVG direkt ein — dieselbe Quelle wie das + Etikett, eine Anfrage weniger. +- **Die Besitzerliste beim Registrieren filterte erst nach der Abfrage** auf den Kunden: bei mehr + als 500 Personen wäre keine passende übrig geblieben. +- Dazu vier Texte und Kommentare, die mehr versprachen als der Code hielt (archivierte Geräte im + Druck, „1 von 2 fehlt“ bei doppelten Codes, eine Vorbelegung, die nicht kam, ein verrutschter + Kommentarblock). diff --git a/docs/issues/README.md b/docs/issues/README.md index e3dd167..7abfbcb 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -18,10 +18,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min | | #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) | offen | v0.1.0 | 12 h | – | +| #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 | – | | #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** | **16 h 50 min** | +| | **Summe** | | | **136 h** | **20 h 25 min** | diff --git a/docs/screenshots/m9/etikett-62x29-desktop.png b/docs/screenshots/m9/etikett-62x29-desktop.png new file mode 100644 index 0000000..4292c9d Binary files /dev/null and b/docs/screenshots/m9/etikett-62x29-desktop.png differ diff --git a/docs/screenshots/m9/etikett-62x29-handy.png b/docs/screenshots/m9/etikett-62x29-handy.png new file mode 100644 index 0000000..302cc90 Binary files /dev/null and b/docs/screenshots/m9/etikett-62x29-handy.png differ diff --git a/docs/screenshots/m9/etiketten-a4-desktop.png b/docs/screenshots/m9/etiketten-a4-desktop.png new file mode 100644 index 0000000..2310e24 Binary files /dev/null and b/docs/screenshots/m9/etiketten-a4-desktop.png differ diff --git a/docs/screenshots/m9/etiketten-a4-handy.png b/docs/screenshots/m9/etiketten-a4-handy.png new file mode 100644 index 0000000..fc17294 Binary files /dev/null and b/docs/screenshots/m9/etiketten-a4-handy.png differ diff --git a/docs/screenshots/m9/geraeteseite-desktop.png b/docs/screenshots/m9/geraeteseite-desktop.png new file mode 100644 index 0000000..28f6331 Binary files /dev/null and b/docs/screenshots/m9/geraeteseite-desktop.png differ diff --git a/docs/screenshots/m9/geraeteseite-handy.png b/docs/screenshots/m9/geraeteseite-handy.png new file mode 100644 index 0000000..51a030f Binary files /dev/null and b/docs/screenshots/m9/geraeteseite-handy.png differ diff --git a/docs/screenshots/m9/scannen-desktop.png b/docs/screenshots/m9/scannen-desktop.png new file mode 100644 index 0000000..e3f1a86 Binary files /dev/null and b/docs/screenshots/m9/scannen-desktop.png differ diff --git a/docs/screenshots/m9/scannen-handy.png b/docs/screenshots/m9/scannen-handy.png new file mode 100644 index 0000000..db173d2 Binary files /dev/null and b/docs/screenshots/m9/scannen-handy.png differ diff --git a/docs/screenshots/m9/tag-registrieren-desktop.png b/docs/screenshots/m9/tag-registrieren-desktop.png new file mode 100644 index 0000000..6b73021 Binary files /dev/null and b/docs/screenshots/m9/tag-registrieren-desktop.png differ diff --git a/docs/screenshots/m9/tag-registrieren-handy.png b/docs/screenshots/m9/tag-registrieren-handy.png new file mode 100644 index 0000000..e467383 Binary files /dev/null and b/docs/screenshots/m9/tag-registrieren-handy.png differ diff --git a/docs/screenshots/m9/tags-desktop.png b/docs/screenshots/m9/tags-desktop.png new file mode 100644 index 0000000..f2736e4 Binary files /dev/null and b/docs/screenshots/m9/tags-desktop.png differ diff --git a/docs/screenshots/m9/tags-handy.png b/docs/screenshots/m9/tags-handy.png new file mode 100644 index 0000000..5a15767 Binary files /dev/null and b/docs/screenshots/m9/tags-handy.png differ diff --git a/package.json b/package.json index 02622c6..0433d4f 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,14 @@ "beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts", "beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts", "beispiel:lauf": "tsx scripts/beispiel-lauf.ts", - "build": "next build", + "build": "node scripts/wasm-bereitstellen.mjs && next build", "db:down": "docker compose -f docker-compose.dev.yml down", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:seed": "prisma db seed", "db:studio": "prisma studio", "db:up": "docker compose -f docker-compose.dev.yml up -d", - "dev": "next dev", + "dev": "node scripts/wasm-bereitstellen.mjs && next dev", "docs:pdf": "tsx scripts/anleitung-pdf.ts", "docs:screenshots": "tsx scripts/screenshots.ts", "format": "prettier --write .", diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index e99f5ec..090b78f 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -42,6 +42,12 @@ type Seite = { readonly nurModus?: "INTERN" | "DIENSTLEISTUNG"; /** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */ readonly vorbereiten?: (tab: Page) => Promise; + /** + * Hell aufnehmen statt dunkel. Fuer Druckseiten: ein Etikett ist immer hell, + * und ein Bild im dunklen Farbschema zeigte etwas, das so nie aus einem + * Drucker kommt (#014). + */ + readonly hell?: boolean; /** * Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. Fuer Bilder, die * ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild @@ -161,6 +167,31 @@ const SEITEN: readonly Seite[] = [ ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }), }, { name: "abteilungen", pfad: "/admin/abteilungen" }, + { name: "scannen", pfad: "/scannen" }, + { name: "tags", pfad: "/tags" }, + { + name: "geraeteseite", + pfad: "/geraete", + vorbereiten: async (tab) => { + await tab.getByRole("link", { name: "NB-001" }).first().click(); + await tab.waitForURL(/\/geraete\/[^/]+$/); + }, + }, + { + /* + * Das Registrierformular gibt es nur zu einem Tag ohne Zuordnung. Welcher + * das ist, weiss nur die Datenbank — also den Weg gehen, den auch ein + * Techniker geht: von der Tag-Liste aus. + */ + name: "tag-registrieren", + pfad: "/tags", + vorbereiten: async (tab) => { + await tab.getByRole("link", { name: /^LZ-/ }).first().click(); + await tab.waitForURL(/\/t\/LZ-/); + }, + }, + { name: "etikett-62x29", pfad: "/drucken/etiketten?layout=etikett", hell: true }, + { name: "etiketten-a4", pfad: "/drucken/etiketten?layout=bogen", hell: true }, ]; function argument(name: string): string | undefined { @@ -248,7 +279,7 @@ try { for (const ansicht of ANSICHTEN) { const kontext = await browser.newContext({ viewport: { width: ansicht.breite, height: ansicht.hoehe }, - colorScheme: "dark", + colorScheme: seite.hell ? "light" : "dark", locale: "de-DE", timezoneId: "Europe/Berlin", deviceScaleFactor: 2, diff --git a/scripts/wasm-bereitstellen.mjs b/scripts/wasm-bereitstellen.mjs new file mode 100644 index 0000000..e0cf627 --- /dev/null +++ b/scripts/wasm-bereitstellen.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Legt das WebAssembly des QR-Lesers neben die statischen Dateien (#014). + * + * Der Scanner benutzt `barcode-detector` als Polyfill der BarcodeDetector-API. + * Dessen Unterbau `zxing-wasm` laedt sein `.wasm` von sich aus **aus einem CDN** + * — das verbietet hier zweierlei: die Content-Security-Policy erlaubt + * `connect-src 'self'`, und eine selbst gehostete Anwendung soll ohne Internet + * laufen. Deshalb wird die Datei aus `node_modules` unter `public/zxing/` + * bereitgelegt und von dort geladen. + * + * Kopiert statt eingecheckt: 1,1 MB Binaerdatei gehoeren nicht ins Repository, + * und die Fassung muss zur installierten Paketversion passen. Laeuft vor jedem + * `pnpm build` und `pnpm dev`. + */ +import { copyFile, mkdir } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); + +let wasm; +try { + /* + * Aufgeloest **von barcode-detector aus**, nicht von hier: pnpm legt jedes + * Paket unter `.pnpm/@` ab und verlinkt nur die direkten + * Abhaengigkeiten nach `node_modules`. `zxing-wasm` ist keine davon — es + * haengt an `barcode-detector`, und nur von dort ist es auffindbar. Ein fest + * geschriebener Pfad ginge beim naechsten Versionssprung kaputt. + */ + const vonBarcodeDetector = createRequire(require.resolve("barcode-detector")); + // Die Datei steht selbst in der Exportkarte – kein Raten am Verzeichnis. + wasm = vonBarcodeDetector.resolve("zxing-wasm/reader/zxing_reader.wasm"); +} catch (fehler) { + console.error( + "Das WebAssembly des QR-Lesers ist nicht auffindbar. Es kommt über barcode-detector — " + + "erst `pnpm install` laufen lassen.", + ); + console.error(fehler instanceof Error ? fehler.message : fehler); + process.exit(1); +} +const ziel = join(process.cwd(), "public", "zxing", "zxing_reader.wasm"); + +await mkdir(dirname(ziel), { recursive: true }); +await copyFile(wasm, ziel); +console.log(`QR-Leser bereitgelegt: ${ziel}`); diff --git a/src/app/(app)/geraete/[id]/page.tsx b/src/app/(app)/geraete/[id]/page.tsx new file mode 100644 index 0000000..949fcb1 --- /dev/null +++ b/src/app/(app)/geraete/[id]/page.tsx @@ -0,0 +1,427 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ArrowRight, CircleDot, Clock, History, ListChecks, Printer, QrCode } from "lucide-react"; +import { LaufStatus } from "@/generated/prisma/enums"; +import { LaufStartenKnopf } from "@/components/geraete/lauf-starten"; +import { etikettenPfad } from "@/components/tags/etikett"; +import { Schnellstart } from "@/components/geraete/schnellstart"; +import { VorlageZuweisen } from "@/components/geraete/vorlage-zuweisen"; +import { GERAETE_TYP_TEXT } from "@/lib/geraete-text"; +import { qrSvg } from "@/lib/qr"; +import { darf } from "@/server/auth/rollen"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kundenEbeneSichtbar } from "@/server/mandant"; +import { + HISTORIE_HOECHSTENS, + geraetHolen, + laeufeDesGeraets, + vorlagenFuerGeraet, + type GeraetLaufZeile, + type GeraeteZeile, +} from "@/server/queries/geraete"; +import { startAuswahl } from "@/server/queries/laeufe"; + +/** + * Die Geraeteseite (#014). + * + * Das Ziel jedes gescannten Aufklebers und damit die Seite, die ein Techniker am + * Handy vor dem Geraet aufschlaegt. Sie beantwortet in dieser Reihenfolge: was + * ist das hier, was wartet daran, was kann ich sofort anfangen, was war schon. + * + * Deshalb die Anordnung: erst der Kopf mit `tagCode` und Besitzer, dann die + * Zuweisungen, dann der Schnellstart. Das Vorbereiten fuer jemand anderen + * („Vorlage zuweisen“) steht weiter unten — es passiert am Schreibtisch, nicht + * im Serverraum. + * + * Nummerierte Abschnitte gibt es hier bewusst nicht: die zweistellige Nummer + * gehoert zur Abarbeitung, wo sie eine Reihenfolge ausdrueckt. Hier waere sie + * Schmuck. + */ + +const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); + +const STATUS_TEXT: Record = { + OFFEN: "offen", + IN_ARBEIT: "in Arbeit", + ABGESCHLOSSEN: "abgeschlossen", + ARCHIVIERT: "archiviert", +}; + +const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"; + +const KNOPF = + "flex min-h-11 w-fit 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"; + +function Fortschritt({ zeile }: { zeile: GeraetLaufZeile }) { + const anteil = zeile.anzahlSchritte === 0 ? 0 : zeile.anzahlErledigt / zeile.anzahlSchritte; + return ( +
+
+
+
+ + {zeile.anzahlErledigt} von {zeile.anzahlSchritte} + +
+ ); +} + +/** Eine Zuweisung: was ist es, wie weit ist es, und der Weg hinein. */ +function Zuweisung({ + zeile, + geraetId, + darfArbeiten, +}: { + zeile: GeraetLaufZeile; + geraetId: string; + darfArbeiten: boolean; +}) { + const ueberfaellig = zeile.faelligAm !== null && zeile.faelligAm < new Date(); + + return ( +
  • +
    +
    +
    +
    + +

    + {[ + `${zeile.vorlagenName} v${zeile.version}`, + zeile.zugewiesenAn ? `für ${zeile.zugewiesenAn}` : "niemandem zugewiesen", + zeile.faelligAm && !ueberfaellig ? `fällig am ${DATUM.format(zeile.faelligAm)}` : null, + ] + .filter((stueck) => stueck !== null) + .join(" · ")} +

    + + +
    + + {/* + * „Starten“ setzt `IN_ARBEIT`, „Weiter“ ist nur der Weg zurück in einen + * Lauf, der schon läuft. Wer Läufe nicht bearbeiten darf, bekommt nur den + * Weg zum Ansehen. + */} + {darfArbeiten && zeile.status === LaufStatus.OFFEN ? ( + + ) : ( + +
    +
  • + ); +} + +function Kopf({ + geraet, + kundenEbene, + qr, +}: { + geraet: GeraeteZeile; + kundenEbene: boolean; + /** Das fertige QR-SVG – erzeugt von der Seite, nicht von dieser Komponente. */ + qr: string; +}) { + const angaben: readonly { readonly name: string; readonly wert: string; readonly mono?: boolean }[] = [ + { name: "Typ", wert: GERAETE_TYP_TEXT[geraet.typ] }, + ...(geraet.modell ? [{ name: "Modell", wert: geraet.modell }] : []), + ...(geraet.seriennummer ? [{ name: "Seriennummer", wert: geraet.seriennummer, mono: true }] : []), + ...(geraet.inventarnummer ? [{ name: "Inventarnummer", wert: geraet.inventarnummer, mono: true }] : []), + ]; + + return ( +
    +
    +
    +
    Besitzer
    +
    + {geraet.besitzerId ? ( + <> + + {geraet.besitzerName} + + {geraet.besitzerAbteilung ? ( + · {geraet.besitzerAbteilung} + ) : null} + {geraet.besitzerAktiv === false ? · deaktiviert : null} + + ) : ( + niemand — Server, Raum oder Poolgerät + )} +
    +
    + + {angaben.map((angabe) => ( +
    +
    {angabe.name}
    +
    {angabe.wert}
    +
    + ))} + + {kundenEbene ? ( +
    +
    Kunde
    +
    {geraet.kundeName}
    +
    + ) : null} +
    + + {geraet.notizen ? ( +

    {geraet.notizen}

    + ) : null} + +
    + + + {/* + * Fuer den Fall, dass der Aufkleber abgerissen ist oder das Geraet vor + * einem steht und das Handy in der Hand: vom Bildschirm gescannt fuehrt + * der Code genau dorthin, wohin auch der geklebte fuehrt. + * + * Das SVG steht eingesetzt hier und kommt nicht ueber einen eigenen + * Endpunkt — dieselbe Quelle wie das gedruckte Etikett, eine Anfrage + * weniger, und kein Weg, der ohne Aufrufer im Baum steht. + */} +
    +
    +
    + ); +} + +export default async function GeraeteSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { benutzer } = await sitzungErforderlich("lesen", `/geraete/${id}`); + + const geraet = await geraetHolen(id, benutzer); + // Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die Unterscheidung + // waere schon eine Auskunft ueber fremde Kunden (#009). + if (!geraet) notFound(); + + const darfArbeiten = darf(benutzer.rolle, "laeufeBearbeiten"); + const [laeufe, kundenEbene, qr] = await Promise.all([ + laeufeDesGeraets(geraet.id, benutzer), + kundenEbeneSichtbar(), + // Dasselbe SVG wie auf dem Etikett – eine Quelle, ein Ergebnis. + qrSvg(geraet.tagCode), + ]); + + /* + * Neue Arbeit nimmt nur ein Geraet auf, das es auch wirklich gibt: ein + * archiviertes ist aus dem Bestand genommen (`laufAnlegen` weist es ab), und + * ein vorgedruckter Tag ohne Zuordnung beschreibt noch kein Objekt. Statt + * Formulare anzubieten, die der Dienst danach ablehnt, steht dort dann der + * naechste Schritt. + */ + const nimmtArbeitAuf = darfArbeiten && !geraet.archiviert && geraet.registriertAm !== null; + const vorlagen = nimmtArbeitAuf + ? await vorlagenFuerGeraet(geraet, benutzer) + : { schnellstart: [], alle: [] }; + /* + * Die Techniker kommen aus `startAuswahl` und nicht aus einer eigenen Abfrage. + * Zuweisen laesst sich nur an jemanden, der den Lauf auch oeffnen koennte — + * eine zweite, aehnlich gebaute Liste waere genau der Fehler, den der Review + * zu #013 gefunden hat: ein Lauf, zugewiesen an jemanden, der ihn nie sieht. + */ + const bearbeiter = nimmtArbeitAuf ? (await startAuswahl(benutzer)).bearbeiter : []; + const besitzerFuerStart = geraet.besitzerAktiv === true ? geraet.besitzerId : null; + + return ( + <> + +
    @@ -275,6 +292,21 @@ export default async function GeraeteSeite({ />
    + + {darfLoeschen ? ( + /* + * Vorgedruckte Aufkleber sind Beschaffung, nicht Geraetepflege — + * deshalb `verwaltung`, dasselbe Recht wie beim Loeschen. Der Weg + * dorthin steht hier, weil niemand eine Seite findet, die nur in der + * Adresszeile existiert (#014). + */ +

    + Aufkleber auf Vorrat drucken?{" "} + + Tag-Stapel anlegen + +

    + ) : null} ) : null} diff --git a/src/app/(app)/laeufe/starten/page.tsx b/src/app/(app)/laeufe/starten/page.tsx index 62ca49c..2766b35 100644 --- a/src/app/(app)/laeufe/starten/page.tsx +++ b/src/app/(app)/laeufe/starten/page.tsx @@ -38,6 +38,18 @@ export default async function LaufStartenSeite({ startAuswahl(benutzer), kundenEbeneSichtbar(), ]); + + /* + * Kommt ein Gerät mit, aber keine Person, dann ist der Besitzer gemeint. + * Dieselbe Regel gilt im Dialog schon beim Wechsel des Geräts — sie darf nicht + * daran hängen, dass jemand das Feld **anfasst**: von der Geräteseite und vom + * gescannten Aufkleber kommt die Wahl fertig an, und der Techniker sähe sonst + * „keine Person aus dem Verzeichnis“ (Reviewbefund zu #014). + */ + const geraetAusAdresse = einzeln("geraet"); + const besitzerDesGeraets = geraetAusAdresse + ? (auswahl.geraete.find((geraet) => geraet.id === geraetAusAdresse)?.besitzerId ?? undefined) + : undefined; const kunden = kundenEbene ? await kundenListe(benutzer) : []; return ( @@ -63,7 +75,11 @@ export default async function LaufStartenSeite({ vorbelegung={{ ...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}), ...(einzeln("geraet") ? { geraetId: einzeln("geraet") as string } : {}), - ...(einzeln("person") ? { mitarbeiterId: einzeln("person") as string } : {}), + ...(einzeln("person") + ? { mitarbeiterId: einzeln("person") as string } + : besitzerDesGeraets + ? { mitarbeiterId: besitzerDesGeraets } + : {}), }} kundenEbene={kundenEbene} kunden={kunden.map(({ id, name }) => ({ id, name }))} diff --git a/src/app/(app)/scannen/page.tsx b/src/app/(app)/scannen/page.tsx new file mode 100644 index 0000000..3d47b80 --- /dev/null +++ b/src/app/(app)/scannen/page.tsx @@ -0,0 +1,30 @@ +import { KameraScanner } from "@/components/scanner/kamera-scanner"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +/** + * Scannen (#014). + * + * Die Seite selbst ist duenn: anmelden, Ueberschrift, Kamera. Was der Scanner + * tut, ist Sache des Browsers; was ein Fund **bedeutet**, entscheidet + * `tagCodeAusFund` in `src/lib/tagcode.ts`, und was hinter dem Code steht, + * entscheidet `/t/[tagCode]`. + */ +export const metadata = { title: "Scannen" }; + +export default async function ScannenSeite() { + await sitzungErforderlich("lesen", "/scannen"); + + return ( + <> +

    Scannen

    +

    + Halte den Aufkleber ins Bild. Laufzettel springt von selbst auf die Geräteseite, sobald der Code + erkannt ist. +

    + +
    + +
    + + ); +} diff --git a/src/app/(app)/t/[tagCode]/page.tsx b/src/app/(app)/t/[tagCode]/page.tsx new file mode 100644 index 0000000..d9742c0 --- /dev/null +++ b/src/app/(app)/t/[tagCode]/page.tsx @@ -0,0 +1,115 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { QrCode, Search } from "lucide-react"; +import { GeraetRegistrieren } from "@/components/geraete/geraet-registrieren"; +import { TAGCODE_PRAEFIX, istTagCode } from "@/lib/tagcode"; +import { darf } from "@/server/auth/rollen"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { besitzerKandidaten, geraetNachTagCode } from "@/server/queries/geraete"; + +/** + * Der Einstieg ueber den Aufkleber: `/t/` (#014). + * + * Auf dem Etikett steht ein QR mit `{APP_URL}/t/{tagCode}`, darunter der Code zum + * Abtippen. Diese Seite ist der Weichensteller und kennt drei Faelle: + * + * 1. Der Code gehoert zu einem registrierten Geraet → weiter zur Geraeteseite. + * 2. Der Code gehoert zu einem vorgedruckten Tag ohne Zuordnung → das + * Registrierformular. Danach dieselbe Geraeteseite. + * 3. Der Code ist unbekannt oder formal falsch → „Kein Laufzettel-Tag“, mit dem + * Weg zur Suche. Beides ist **dieselbe** Antwort, und zwar mit Absicht: eine + * Unterscheidung wuerde verraten, welche Codes es gibt, und einem fremden + * Kunden gegenueber ist schon das eine Auskunft. + * + * **Die Datei liegt in der Gruppe `(app)`, die Adresse bleibt `/t/`.** + * Eine Routengruppe aendert den Pfad nicht — der QR-Inhalt bleibt also so kurz + * wie gedruckt. Dafuer tragen die beiden Faelle, die hier wirklich etwas + * anzeigen, die gewohnte Huelle mit Navigation: wer einen fremden Aufkleber + * scannt, steht sonst auf einer Seite ohne jeden Weg weiter. Der Ruecksprung nach + * der Anmeldung haengt nicht daran: das Cookie-Tor im Proxy leitet mit + * `?ziel=/t/` um, und diese Seite prueft ihr Recht ohnehin selbst — die + * Huelle ist auch hier das zweite Netz, nicht das erste. + */ +export default async function TagSeite({ params }: { params: Promise<{ tagCode: string }> }) { + const { tagCode } = await params; + /* + * Gross- und Kleinschreibung sind unerheblich. Ein Handy schreibt von sich aus + * klein, und wer `lz-7k3f9q` eintippt, meint den Aufkleber mit `LZ-7K3F9Q`. + * Weiter geht die Nachsicht nicht: ein `I` auf ein `L` zu biegen koennte auf ein + * anderes, vorhandenes Geraet fuehren — und wer davor steht, saehe den Irrtum + * nie. Gross zu Klein kann das nicht, weil beide dasselbe Zeichen bezeichnen. + */ + const code = tagCode.trim().toUpperCase(); + const gueltig = istTagCode(code); + // Erst die Anmeldung, dann die Datenbank: das Ziel ist die kanonische Adresse + // dieses Aufklebers, damit der Scan nach dem Anmelden dort ankommt, wo er + // hinwollte. + const { benutzer } = await sitzungErforderlich( + "lesen", + `/t/${encodeURIComponent(gueltig ? code : tagCode)}`, + ); + + const geraet = gueltig ? await geraetNachTagCode(code, benutzer) : undefined; + + if (geraet && geraet.registriertAm !== null) { + redirect(`/geraete/${geraet.id}`); + } + + if (!geraet) { + return ( +
    +

    +

    +

    + Zu {code} gehört kein Gerät. Ein Laufzettel-Code + beginnt mit {TAGCODE_PRAEFIX} und hat sechs Zeichen dahinter — vom + Aufkleber abgelesen, ohne die verwechselbaren Zeichen 0, 1, I und O. +

    + +
    + ); + } + + if (!darf(benutzer.rolle, "geraeteBearbeiten")) { + return ( +
    +

    Noch keinem Gerät zugeordnet

    +

    + Der Aufkleber {geraet.tagCode} ist vorgedruckt und wartet + auf ein Gerät. Zum Registrieren fehlt dir die Berechtigung — ein Techniker oder Administrator + übernimmt das. +

    +
    + ); + } + + /* + * Nur Personen **dieses** Kunden: ein Geraet kann nur jemandem desselben Kunden + * gehoeren (zusammengesetzter Fremdschluessel, siehe services/geraete.ts). Der + * Kunde geht in die Abfrage; nachtraeglich zu filtern liesse bei mehr als 500 + * Personen genau die passenden herausfallen. + */ + const besitzer = await besitzerKandidaten(benutzer, 500, geraet.kundeId); + + return ( +
    +

    Gerät registrieren

    +

    + Dieser Aufkleber ist vorgedruckt und gehört noch zu keinem Gerät. Trag ein, wovor du stehst — danach + geht es direkt auf die Geräteseite. +

    + +
    + +
    +
    + ); +} diff --git a/src/app/(app)/tags/page.tsx b/src/app/(app)/tags/page.tsx new file mode 100644 index 0000000..b13ad69 --- /dev/null +++ b/src/app/(app)/tags/page.tsx @@ -0,0 +1,116 @@ +import Link from "next/link"; +import { Printer, QrCode, Tags } from "lucide-react"; +import { etikettenPfad } from "@/components/tags/etikett"; +import { TagStapel } from "@/components/tags/tag-stapel"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kundenEbeneSichtbar } from "@/server/mandant"; +import { kundenListe } from "@/server/queries/kunden"; +import { unregistrierteTags } from "@/server/queries/tags"; +import { TAGS_STAPEL_HOECHSTENS, TAGS_STAPEL_STANDARD } from "@/server/services/tags"; + +/** + * Vorgedruckte Tags (#014). + * + * Hier entsteht der Stapel, bevor es die Geraete gibt: Codes ziehen, Bogen + * drucken, aufkleben, spaeter am Geraet scannen und benennen. Ein Tag ist ein + * `Geraet` mit `registriertAm = null` und steht deshalb auch in der Geraeteliste + * — dort als „Tag ohne Zuordnung“. + * + * **Die Seite prueft ihr Recht selbst** (CLAUDE.md): `verwaltung`, wie die + * Aktion. Ein Stapel verbrennt N Tag-Codes unwiderruflich. + */ + +const DATUM = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); + +const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"; + +export default async function TagsSeite() { + const { benutzer } = await sitzungErforderlich("verwaltung", "/tags"); + const handelnder = { id: benutzer.id, rolle: benutzer.rolle }; + + const [tags, kundenEbene] = await Promise.all([unregistrierteTags(handelnder), kundenEbeneSichtbar()]); + const kunden = kundenEbene ? await kundenListe(handelnder) : []; + const kundenWahl = kunden.filter((kunde) => !kunde.archiviert).map(({ id, name }) => ({ id, name })); + + return ( + <> +

    Tags

    +

    + Vorgedruckte Aufkleber ohne Gerät. Der Tag-Code entsteht hier, der Aufkleber kommt aufs Objekt, und + wer ihn später scannt, füllt die Stammdaten aus. Ein Tag-Code wird nie geändert und nie neu vergeben — + ein Stapel, den niemand klebt, ist verbraucht. +

    + +
    +

    +

    +

    + {TAGS_STAPEL_STANDARD} Tags sind gut zwei A4-Bogen und der übliche Vorrat. +

    + +
    + +
    +

    + Noch nicht zugeordnet + + {tags.length} {tags.length === 1 ? "Tag" : "Tags"} + +

    + {tags.length > 0 ? ( + +
    + + {tags.length === 0 ? ( +

    + Kein Tag wartet auf ein Gerät. Alles, was angelegt wurde, ist registriert. +

    + ) : ( +
      + {tags.map((tag) => ( +
    • +
      + {/* + * Der Code führt dorthin, wo auch der Scan landet: zum + * Registrierformular. Im Regal wird gescannt, am Schreibtisch + * geklickt — beides derselbe Weg, und niemand muss den Code + * abtippen (#014). + */} + +
      + +
    • + ))} +
    + )} + + ); +} diff --git a/src/app/drucken/etiketten/layout.tsx b/src/app/drucken/etiketten/layout.tsx new file mode 100644 index 0000000..0193386 --- /dev/null +++ b/src/app/drucken/etiketten/layout.tsx @@ -0,0 +1,15 @@ +/** + * Eigene, helle Huelle fuer den Etikettendruck (#014). + * + * Bewusst **ausserhalb** von `src/app/(app)`: die Huelle dort bringt Seitenleiste, + * Kopfzeile und den dunklen Grund mit, und beides gehoert auf keinen Bogen. Die + * Rechtepruefung uebernimmt diese Huelle deshalb **nicht** — jede Seite darunter + * prueft selbst, so wie es CLAUDE.md fuer alle Seiten verlangt. + * + * `druck-hell` stellt die Farbtoken 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 EtikettenDruckLayout({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} diff --git a/src/app/drucken/etiketten/page.tsx b/src/app/drucken/etiketten/page.tsx new file mode 100644 index 0000000..40326cf --- /dev/null +++ b/src/app/drucken/etiketten/page.tsx @@ -0,0 +1,229 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { Etikett, etikettenPfad, type EtikettDaten, type EtikettLayout } from "@/components/tags/etikett"; +import { EtikettenDrucken } from "@/components/tags/etiketten-drucken"; +import { QR_ERSATZ_HERKUNFT, qrSvg } from "@/lib/qr"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kundenEbeneSichtbar } from "@/server/mandant"; +import { etikettenZuCodes, unregistrierteTags, type EtikettZeile } from "@/server/queries/tags"; + +/** + * Etikettendruck (#014). + * + * Zwei Formate, ueber `?layout=` waehlbar: + * + * `etikett` 62 × 29 mm, ein Stueck je Seite — fuer den Etikettendrucker + * `bogen` A4 mit 3 × 8 Stueck à 70 × 37 mm — fuer den Laserdrucker + * + * Ohne `?codes=` stehen alle noch nicht zugeordneten Tags auf dem Bogen; das ist + * der Stapeldruck nach `/tags`. + * + * **Die Seite prueft ihr Recht selbst.** Sie liegt ausserhalb von `(app)`, also + * deckt die Huelle dort sie nicht ab — und ein Bogen nennt Hostnamen und + * Besitzer. `lesen` genuegt: dieselben Angaben stehen in der Geraeteliste, und + * ein Techniker muss einen abgerissenen Aufkleber nachdrucken koennen, ohne den + * Administrator zu fragen. Die Mandantensicht wenden die Lesemodelle an: ein + * Etikett eines fremden Kunden erscheint hier nicht. + */ + +export const metadata: Metadata = { + title: "Etiketten drucken", +}; + +/** + * Das Seitenformat je Layout. + * + * `@page` laesst sich nicht auf eine Klasse einschraenken und kann deshalb nicht + * in globals.css stehen — dort gaebe es nur eines von beiden Formaten. Also + * schreibt die Seite die Regel, die zum gewaehlten Layout gehoert. + * + * `margin: 0`, weil die Masse den Bogen genau ausfuellen (3 · 70 = 210 mm, + * 8 · 37 = 296 mm auf 210 × 297 mm) und der Stanzbogen keinen Rand hat. Ein + * Seitenrand verschoebe jedes Etikett gegen seine Stanzung. + * + * `print-color-adjust: exact` haelt Druckern das Sparen ab: ohne die Angabe + * lassen manche Flaechen weg, und ein QR ohne Flaeche ist kein QR. + */ +const SEITENFORMAT: Readonly> = { + etikett: "@page { size: 62mm 29mm; margin: 0; }", + bogen: "@page { size: A4; margin: 0; }", +}; + +const DRUCKSTIL = ` + @media print { + html, body { margin: 0; padding: 0; background: #ffffff; } + /* Ein Etikett darf nie ueber zwei Seiten fallen. */ + [data-etikett] { break-inside: avoid; } + /* Farbflaechen und der QR muessen mitkommen, nicht "gespart" werden. */ + * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + } +`; + +function layoutLesen(wert: string): EtikettLayout { + // Ein unbekannter Wert in der Adresse fuehrt auf den Bogen und nicht auf eine + // Fehlerseite — ein vertippter Parameter ist kein Grund, nichts zu drucken. + return wert === "etikett" ? "etikett" : "bogen"; +} + +/** Komma, Leerzeichen und Zeilenumbruch trennen gleichermassen. */ +function codesLesen(wert: string): readonly string[] { + /* + * Entdoppelt, weil das Lesemodell entdoppelt: sonst meldete `?codes=X,X` + * „1 von 2 fehlt“, obwohl alles da ist (Reviewbefund zu #014). Gezaehlt wird + * gegen dieselbe Liste, die auch gesucht wird. + */ + return [ + ...new Set( + wert + .split(/[\s,;]+/) + .map((code) => code.trim().toUpperCase()) + .filter((code) => code !== ""), + ), + ]; +} + +export default async function EtikettenSeite({ + searchParams, +}: { + searchParams: Promise>; +}) { + const parameter = await searchParams; + const einzeln = (name: string): string => + (Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? ""; + + // Nicht ueber `basisAdresse` geprueft, sondern an der Quelle: hier geht es um + // die **Einstellung**, nicht um die Adresse, die daraus faellt. + const ohneAppUrl = !process.env["APP_URL"]?.trim(); + const layout = layoutLesen(einzeln("layout")); + const gewuenschteCodes = codesLesen(einzeln("codes")); + const eigenePfad = etikettenPfad(gewuenschteCodes, layout); + + /* + * Zwei Rechte, weil es zwei verschiedene Dinge sind (Reviewbefund zu #014): + * + * - **Bestimmte Codes drucken** braucht `lesen`. Ein Techniker muss einen + * abgerissenen Aufkleber nachdrucken koennen, und dieselben Angaben stehen + * in der Geraeteliste. + * - **Ohne Codes** steht hier der ganze Vorrat an noch nicht zugeordneten + * Tags — dieselbe Liste wie auf `/tags`, und die verlangt `verwaltung`. + * Vorher war der Bogen die offene Hintertuer zu einer Seite mit Schloss. + */ + const { benutzer } = await sitzungErforderlich( + gewuenschteCodes.length > 0 ? "lesen" : "verwaltung", + eigenePfad, + ); + const handelnder = { id: benutzer.id, rolle: benutzer.rolle }; + + const [zeilen, kundenEbene] = await Promise.all([ + gewuenschteCodes.length > 0 + ? etikettenZuCodes(gewuenschteCodes, handelnder) + : unregistrierteTags(handelnder), + kundenEbeneSichtbar(), + ]); + + // Die QR-Codes entstehen alle in einem Zug; jedes Etikett traegt sein SVG + // eingesetzt bei sich: 24 einzeln geholte Bilder je Bogen waeren 24 Anfragen + // mitten im Druckdialog. Denselben Weg geht die Geraeteseite (#014). + const etiketten: readonly EtikettDaten[] = await Promise.all( + zeilen.map(async (zeile: EtikettZeile) => ({ + tagCode: zeile.tagCode, + qrSvg: await qrSvg(zeile.tagCode), + anzeige: zeile.anzeige, + besitzerName: zeile.besitzerName, + // Im Modus INTERN **nie** (#008): dort gibt es genau eine Organisation. + kundeKuerzel: kundenEbene ? zeile.kundeKuerzel : null, + })), + ); + + const fehlend = gewuenschteCodes.length > 0 ? gewuenschteCodes.length - etiketten.length : 0; + + return ( + <> + + +
    +

    Etiketten drucken

    + + {ohneAppUrl ? ( + /* + * Ohne `APP_URL` zeigt jeder QR auf `http://localhost:3000` — auf dem + * Bildschirm sieht das Etikett trotzdem richtig aus, und der Fehler + * faellt erst auf, wenn jemand den geklebten Aufkleber scannt. Dann + * ist der Bogen gedruckt (Reviewbefund zu #014). + */ +

    + APP_URL ist nicht gesetzt. Die QR-Codes zeigen dann auf{" "} + {QR_ERSATZ_HERKUNFT} und führen von keinem anderen Gerät + irgendwohin. Erst die Adresse eintragen, dann drucken — ein geklebter Aufkleber lässt sich nicht + berichtigen. +

    + ) : null} +

    + {layout === "etikett" + ? "Format 62 × 29 mm, ein Etikett je Seite — für den Etikettendrucker." + : "A4-Bogen mit 3 × 8 Etiketten à 70 × 37 mm — für den Stanzbogen im Laserdrucker."}{" "} + {etiketten.length} {etiketten.length === 1 ? "Etikett" : "Etiketten"} + {gewuenschteCodes.length === 0 ? " — alle noch nicht zugeordneten Tags." : "."} +

    + +
    + + + {layout === "etikett" ? "Als A4-Bogen" : "Als Einzeletikett 62 × 29 mm"} + +
    + + {fehlend > 0 ? ( +

    + {fehlend} der angefragten {gewuenschteCodes.length} Codes {fehlend === 1 ? "ist" : "sind"} nicht + dabei: unbekannt oder zu einem anderen Kunden gehörend. Ein archiviertes Gerät lässt sich + weiterhin drucken — der Aufkleber am Karton im Lager soll lesbar bleiben. +

    + ) : null} + +

    + Im Druckdialog Skalierung auf 100 % stellen und Kopf- und Fußzeilen abschalten — sonst sitzen die + Etiketten neben ihrer Stanzung. +

    +
    + + {etiketten.length === 0 ? ( +

    + Kein Etikett zu drucken. Einen Stapel neuer Tags legt die Seite{" "} + + Tags + {" "} + an. +

    + ) : ( +
    + {etiketten.map((daten, stelle) => ( +
    + +
    + ))} +
    + )} + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 52ae3a2..98888de 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -144,3 +144,29 @@ samp, display: none !important; } } + +/* + * Druckansichten und Etiketten sind immer hell (#014). + * + * `.druck-hell` stellt die Farbtoken eines Teilbaums auf die helle Fassung um. + * Dadurch bleiben die Komponenten darin bei `bg-flaeche`, `text-text` und + * `border-linie`, statt feste Hexwerte mitzubringen: der helle Modus oben greift + * nur auf der Wurzel, und die gehoert dem Wurzel-Layout — eine Druckseite unter + * `src/app/drucken/` kann sie nicht umschalten. + * + * Reinweiss statt des gebrochenen Weiss des hellen Modus: Papier ist weiss, und + * ein Grauschleier hinter dem QR kostet Toner und Kontrast. + */ +.druck-hell { + --color-grund: #ffffff; + --color-flaeche: #ffffff; + --color-flaeche-hoch: #f6f7f4; + --color-linie: #d9ddd4; + --color-text: #0b0d10; + --color-nebentext: #5b6472; + --color-akzent: #5e8a00; + --color-auf-akzent: #ffffff; + --color-akzent-flaeche: rgb(94 138 0 / 0.1); + background-color: var(--color-grund); + color: var(--color-text); +} diff --git a/src/components/geraete/geraet-registrieren.tsx b/src/components/geraete/geraet-registrieren.tsx new file mode 100644 index 0000000..b5d7730 --- /dev/null +++ b/src/components/geraete/geraet-registrieren.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useActionState } from "react"; +import { FELD } from "@/lib/formular"; +import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text"; +import { geraetRegistrierenAktion, type AktionsErgebnis } from "@/server/actions/geraete"; + +/** + * Das Registrierformular hinter einem vorgedruckten Aufkleber (#014). + * + * Es erscheint, wenn jemand einen Tag scannt, der noch keinem Gerät gehört. Der + * Techniker steht dann davor, hat das Handy in der Hand und liest den Hostname + * vom Anmeldebildschirm ab — deshalb genau die fünf Felder aus dem Auftrag und + * keines mehr. Modell und Notizen lassen sich danach in der Geräteliste + * nachtragen. + * + * **Der Tag-Code steht hier, aber nicht als Feld.** Er ist schon gedruckt und + * geklebt; der Dienst weist jede Änderung daran ab. + */ +export function GeraetRegistrieren({ + geraetId, + tagCode, + besitzer, +}: { + geraetId: string; + tagCode: string; + besitzer: { + readonly kandidaten: readonly { readonly id: string; readonly anzeigename: string }[]; + readonly gekuerzt: boolean; + }; +}) { + const [ergebnis, aktion, laeuft] = useActionState(geraetRegistrierenAktion, {}); + + return ( +
    + + +

    + Tag-Code {tagCode} — er bleibt, wie er ist. +

    + + + +
    + + +
    +

    + Eines von beiden genügt. Ein Raum oder ein Serverschrank hat keinen Hostname, ein Notebook keine + Bezeichnung. +

    + +
    + + +
    + + + {besitzer.gekuerzt ? ( +

    + Es stehen nur die ersten {besitzer.kandidaten.length} aktiven Personen zur Auswahl. Wer nicht dabei + ist, lässt sich nach dem Registrieren über die Geräteliste zuordnen. +

    + ) : null} + + {ergebnis.fehler ? ( +

    + {ergebnis.fehler} +

    + ) : null} + + +
    + ); +} diff --git a/src/components/geraete/lauf-starten.tsx b/src/components/geraete/lauf-starten.tsx new file mode 100644 index 0000000..a49cde9 --- /dev/null +++ b/src/components/geraete/lauf-starten.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useActionState } from "react"; +import { PlayCircle } from "lucide-react"; +import { laufStartenAktion, type AktionsErgebnis } from "@/server/actions/geraete"; + +/** + * „Starten“ an einer Zuweisung auf der Geräteseite (#014). + * + * Aus `OFFEN` wird `IN_ARBEIT`, `gestartetAm` entsteht — und danach steht der + * Techniker **im Lauf**. Ein bloßer Link hätte ihn nur geöffnet und nicht + * gestartet; der Unterschied ist der ganze Sinn von `OFFEN`: die Liste soll + * nicht nach Arbeit aussehen, die niemand tut. + * + * Der Sprung passiert im Dienst, nicht hier. Ein `router.push` in einem Effekt + * sah richtig aus und war es nicht: `revalidatePath` baut die Geräteseite neu, + * dabei verschwindet die Zuweisung mitsamt diesem Knopf, und der Effekt kam nie + * dazu — der Lauf lief, der Techniker blieb stehen. + */ +export function LaufStartenKnopf({ laufId, geraetId }: { laufId: string; geraetId: string }) { + const [ergebnis, aktion, laeuft] = useActionState(laufStartenAktion, {}); + + return ( +
    +
    + + + +
    + {ergebnis.fehler ? ( +

    + {ergebnis.fehler} +

    + ) : null} +
    + ); +} diff --git a/src/components/geraete/schnellstart.tsx b/src/components/geraete/schnellstart.tsx new file mode 100644 index 0000000..abecce7 --- /dev/null +++ b/src/components/geraete/schnellstart.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useActionState } from "react"; +import Link from "next/link"; +import { Rocket, SlidersHorizontal } from "lucide-react"; +import { laufSchnellstartAktion, type AktionsErgebnis } from "@/server/actions/geraete"; +import type { GeraeteVorlage } from "@/server/queries/geraete"; + +/** + * Der Schnellstart auf der Geräteseite (#014). + * + * Eine Kachel je Vorlage, die für diesen Gerätetyp hinterlegt ist. Ein Tipp legt + * den Lauf an, startet ihn und springt hinein — mit Gerät und Besitzer, ohne eine + * einzige Eingabe. + * + * **Zwei Sorten Kacheln, und der Unterschied steht darauf.** Eine Vorlage mit + * Pflichtangaben kann so nicht starten, weil hier niemand gefragt wird; sie führt + * in den Start-Dialog, der fragt. Das verschweigt die Kachel nicht — ein Tipp, + * der einmal sofort startet und einmal ein Formular öffnet, ohne dass man es + * vorher sieht, wäre die unangenehmere Lösung. + * + * Alle Kacheln teilen einen Zustand: es wird ein Lauf gestartet, nicht drei. Nach + * dem Tippen sind die übrigen deshalb gesperrt, bis die Seite in den Lauf + * wechselt. + */ +export function Schnellstart({ + geraetId, + vorlagen, + personId, +}: { + geraetId: string; + vorlagen: readonly GeraeteVorlage[]; + /** Der Besitzer für den Start-Dialog. Leer, wenn das Gerät niemandem gehört. */ + personId: string | null; +}) { + const [ergebnis, aktion, laeuft] = useActionState(laufSchnellstartAktion, {}); + + const kachel = + "flex min-h-11 w-full flex-col items-start gap-1 rounded-karte border border-linie bg-flaeche-hoch p-4 text-left hover:border-akzent disabled:pointer-events-none disabled:opacity-40"; + + return ( +
    +
    + {vorlagen.map((vorlage) => + vorlage.pflichtangaben ? ( + + + + + v{vorlage.version} + {vorlage.kategorie ? ` · ${vorlage.kategorie}` : ""} · fragt noch nach Angaben + + + ) : ( +
    + + + +
    + ), + )} +
    + + {ergebnis.fehler ? ( +

    + {ergebnis.fehler} +

    + ) : null} +
    + ); +} diff --git a/src/components/geraete/vorlage-zuweisen.tsx b/src/components/geraete/vorlage-zuweisen.tsx new file mode 100644 index 0000000..39ae8f9 --- /dev/null +++ b/src/components/geraete/vorlage-zuweisen.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useActionState, useState } from "react"; +import Link from "next/link"; +import { ArrowRight, ClipboardPlus } from "lucide-react"; +import { FELD } from "@/lib/formular"; +import { vorlageZuweisenAktion, type AktionsErgebnis } from "@/server/actions/geraete"; +import type { GeraeteVorlage } from "@/server/queries/geraete"; + +/** + * „Vorlage zuweisen“ auf der Geräteseite (#014). + * + * Der Weg für die Vorbereitung: eine Vorlage wählen, sie jemandem geben, ein + * Datum setzen — fertig. Der Lauf entsteht als `OFFEN` und wartet unter + * „Zugewiesen“, bis ihn jemand öffnet. Für „jetzt sofort selbst machen“ gibt es + * den Schnellstart darüber. + * + * Gerät und Besitzer kommen nicht aus diesem Formular: sie stehen fest, weil die + * Seite zu genau einem Gerät gehört. Der Dienst liest beides selbst am Gerät — + * ein verstecktes Feld mit der Personenkennung wäre eine Angabe, an die sich + * niemand hält. + * + * **Eine Vorlage mit Pflichtangaben führt in den Start-Dialog**, statt hier + * abgeschickt zu werden. Dieses Formular fragt Titel, Ticket und Variablen nicht + * ab; `laufAnlegen` würde den Lauf deshalb zu Recht abweisen („Diese Angaben + * fehlen: …“) — eine Auswahl anzubieten, die danach scheitert, ist keine + * Auswahl. Dieselbe Entscheidung trifft der Schnellstart darüber. + */ +export function VorlageZuweisen({ + geraetId, + vorlagen, + bearbeiter, + besitzerName, +}: { + geraetId: string; + vorlagen: readonly GeraeteVorlage[]; + bearbeiter: readonly { readonly id: string; readonly name: string }[]; + /** Nur für den Hinweis: an wen der Lauf geht, wenn das Gerät jemandem gehört. */ + besitzerName: string | null; +}) { + const [ergebnis, aktion, laeuft] = useActionState(vorlageZuweisenAktion, {}); + const [versionId, setzeVersion] = useState(""); + const gewaehlt = vorlagen.find((vorlage) => vorlage.versionId === versionId); + + if (vorlagen.length === 0) { + return ( +

    + Es gibt keine veröffentlichte Vorlage, die zu diesem Gerät passt. Ein Entwurf ist noch niemandes + Arbeitsanweisung — erst das Veröffentlichen macht ihn dazu. +

    + ); + } + + return ( +
    + + + + +
    + + +
    + + {besitzerName ? ( +

    + Der Lauf wird für {besitzerName} geführt — die Person, der dieses Gerät gehört. +

    + ) : null} + + {ergebnis.fehler ? ( +

    + {ergebnis.fehler} +

    + ) : null} + {ergebnis.erfolg ? ( +

    + {ergebnis.erfolg} +

    + ) : null} + + {gewaehlt?.pflichtangaben ? ( +
    + +
    + ) : ( + + )} +
    + ); +} diff --git a/src/components/huelle.tsx b/src/components/huelle.tsx index 58dff42..ac634fc 100644 --- a/src/components/huelle.tsx +++ b/src/components/huelle.tsx @@ -22,8 +22,8 @@ import { darf } from "@/server/auth/rollen"; import { cn } from "@/lib/utils"; import { versionText } from "@/lib/version"; -/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */ -const SCANNEN_VERFUEGBAR = false; +/** Seit #014 gibt es `/scannen` – der Knopf fuehrt jetzt irgendwohin. */ +const SCANNEN_VERFUEGBAR = true; const SYMBOLE: Record = { LayoutDashboard, @@ -96,10 +96,10 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) { /** * Der Einstieg des Technikers, an beiden Stellen ganz oben. * - * Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf, - * der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den - * Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404 - * im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation. + * Der gedaempfte Zustand bleibt im Code: er gilt, solange eine Seite fehlt, und + * genau diese Lage gab es hier bis #014. Ein Knopf, der so prominent sitzt, darf + * nicht auf eine Fehlerseite fuehren — Next holt den Verweis beim Darueberfahren + * sogar vorab, jede Seite erzeugte damit eine 404 im Hintergrund. */ function ScannenKnopf({ className }: { className?: string }) { // Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am diff --git a/src/components/scanner/kamera-scanner.tsx b/src/components/scanner/kamera-scanner.tsx new file mode 100644 index 0000000..e994e16 --- /dev/null +++ b/src/components/scanner/kamera-scanner.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { CameraOff, Keyboard, ScanLine } from "lucide-react"; +import { FELD } from "@/lib/formular"; +import { tagCodeAusFund } from "@/lib/tagcode"; + +/** + * Der Scanner (#014). + * + * Das ist der Einstieg, fuer den die ganze Anwendung gebaut ist: der Techniker + * steht am Geraet, scannt den Aufkleber und ist auf der Geraeteseite. Vier + * Entscheidungen stecken darin: + * + * 1. **Ein Codepfad, native API wenn vorhanden.** Geladen wird + * `barcode-detector/polyfill`: der setzt `globalThis.BarcodeDetector` nur, + * **wenn** der Browser keine mitbringt. Auf Android laeuft damit die + * eingebaute, sonst WebAssembly — und der Aufrufcode ist derselbe. + * (`barcode-detector/ponyfill` waere das Gegenteil: er gibt **immer** die + * eigene Klasse zurueck, und jedes Geraet laedt 1,1 MB WebAssembly, auch wo + * es das gar nicht braucht. Reviewbefund zu #014.) + * 2. **Das WebAssembly kommt aus dem eigenen Haus.** `zxing-wasm` laedt es sonst + * aus einem CDN — das verbietet die Content-Security-Policy (`connect-src + * 'self'`), und eine selbst gehostete Anwendung soll ohne Internet laufen. + * `scripts/wasm-bereitstellen.mjs` legt die Datei unter `public/zxing/`. + * 3. **Es gibt immer einen Weg ohne Kamera.** Kein HTTPS, verweigerte + * Berechtigung, Rechner ohne Kamera: dann steht hier ein Eingabefeld fuer den + * Code, der auf dem Aufkleber lesbar unter dem QR steht. Ein Scanner, der + * nicht startet, darf keine Sackgasse sein. + * 4. **Erkannt wird laufend, gesprungen genau einmal.** Sonst schoebe die + * Kamera bei jedem Bild eine weitere Navigation nach. + */ +export function KameraScanner() { + const router = useRouter(); + const videoRef = useRef(null); + const gesprungen = useRef(false); + const [fehler, setzeFehler] = useState(null); + const [laeuft, setzeLaeuft] = useState(false); + const [fremderFund, setzeFremderFund] = useState(false); + const [eingetippt, setzeEingetippt] = useState(""); + + const springen = useCallback( + (tagCode: string) => { + if (gesprungen.current) return; + gesprungen.current = true; + router.push(`/t/${tagCode}`); + }, + [router], + ); + + useEffect(() => { + let abgebrochen = false; + let strom: MediaStream | undefined; + let zeitgeber: ReturnType | undefined; + const video = videoRef.current; + + async function starten(): Promise { + if (!globalThis.isSecureContext) { + setzeFehler( + "Die Kamera gibt es nur über HTTPS. Trag den Code unten ein — er steht lesbar unter dem QR.", + ); + return; + } + if (!navigator.mediaDevices?.getUserMedia) { + setzeFehler("Dieser Browser gibt keine Kamera frei. Trag den Code unten ein."); + return; + } + + try { + /* + * Der Polyfill wird erst hier geladen: er bringt ein WebAssembly von + * gut einem Megabyte mit, und das soll nicht in jedem Seitenaufbau der + * Anwendung stecken, sondern nur auf dieser einen Seite. + */ + /* + * Der Polyfill setzt `globalThis.BarcodeDetector`, falls der Browser + * keine hat; wo es eine gibt, bleibt sie stehen. Der Pfad zum + * WebAssembly wird trotzdem immer gesetzt — er kostet nichts und gilt + * fuer den Fall, dass der Polyfill einspringt. + */ + const { setZXingModuleOverrides } = await import("barcode-detector/polyfill"); + setZXingModuleOverrides({ locateFile: () => "/zxing/zxing_reader.wasm" }); + const BarcodeDetector = globalThis.BarcodeDetector; + + strom = await navigator.mediaDevices.getUserMedia({ + // Die rueckwaertige Kamera, sonst filmt das Handy den Techniker. + video: { facingMode: { ideal: "environment" } }, + audio: false, + }); + if (abgebrochen) { + for (const spur of strom.getTracks()) spur.stop(); + return; + } + + if (!video) return; + video.srcObject = strom; + await video.play(); + setzeLaeuft(true); + + const leser = new BarcodeDetector({ formats: ["qr_code"] }); + /* + * Ein einzelnes Bild, das sich nicht lesen laesst, ist normal — ein + * Decoder, der **gar nicht** laeuft, ist es nicht. Der Unterschied war + * vorher unsichtbar: fehlte das WebAssembly, warf jeder Versuch, dreimal + * je Sekunde, und auf dem Bildschirm stand unveraendert „Halte den + * Aufkleber ins Bild“ (Reviewbefund zu #014). Drei Fehlschlaege + * hintereinander heissen deshalb: aufhoeren und es sagen. + */ + let fehlschlaege = 0; + zeitgeber = setInterval(() => { + void (async () => { + if (abgebrochen || gesprungen.current || video.readyState < 2) return; + try { + const funde = await leser.detect(video); + fehlschlaege = 0; + if (funde.length === 0) { + setzeFremderFund(false); + return; + } + for (const fund of funde) { + const code = tagCodeAusFund(fund.rawValue); + if (code) { + springen(code); + return; + } + } + // Etwas gefunden, aber keinen unserer Codes: das sagen wir, statt + // den Techniker rätseln zu lassen, warum nichts passiert. + setzeFremderFund(true); + } catch { + fehlschlaege += 1; + if (fehlschlaege < 3) return; + if (zeitgeber) clearInterval(zeitgeber); + setzeLaeuft(false); + setzeFehler( + "Der QR-Leser lässt sich nicht starten. Trag den Code unten ein — er steht lesbar unter dem QR.", + ); + } + })(); + }, 300); + } catch (ausnahme) { + const name = ausnahme instanceof Error ? ausnahme.name : ""; + setzeFehler( + name === "NotAllowedError" + ? "Die Kamera ist nicht freigegeben. Gib sie im Browser frei oder trag den Code unten ein." + : "Die Kamera ließ sich nicht starten. Trag den Code unten ein.", + ); + } + } + + void starten(); + + return () => { + abgebrochen = true; + if (zeitgeber) clearInterval(zeitgeber); + if (strom) for (const spur of strom.getTracks()) spur.stop(); + // Auch die Verbindung zum Element loesen: gestoppte Spuren geben die + // Kamera frei, ein haengendes `srcObject` haelt das letzte Bild. Das + // Element kommt aus der Schliessung, nicht aus der Referenz — bis der + // Effekt aufraeumt, kann dort laengst ein anderes stehen. + if (video) video.srcObject = null; + }; + }, [springen]); + + const ausEingabe = tagCodeAusFund(eingetippt); + + return ( +
    + {fehler ? ( +

    +

    + ) : ( +
    + {/* `muted` und `playsInline`: ohne sie startet iOS das Bild gar nicht erst. */} +
    + ); +} diff --git a/src/components/tags/etikett.tsx b/src/components/tags/etikett.tsx new file mode 100644 index 0000000..2ccbdbf --- /dev/null +++ b/src/components/tags/etikett.tsx @@ -0,0 +1,117 @@ +import { Bildmarke } from "@/components/bildmarke"; + +/** + * Ein Etikett (#014). + * + * Zwei Formate, beide in Millimetern und beide **immer hell**: die Masse stehen + * in `mm`, weil ein Etikett auf Papier eine physische Groesse hat und ein Klebeband + * keine Pixel kennt. Die Farben kommen aus den Token von `.druck-hell` (siehe + * `src/app/globals.css`), nicht aus rohen Hexwerten — nur das QR-SVG selbst + * bringt Schwarz und Weiss fest mit, weil der Decoder den Kontrast braucht + * (`src/lib/qr.ts`). + * + * Keine Geschaeftslogik hier: was auf dem Etikett stehen darf, hat die Seite + * entschieden — insbesondere, ob das Kundenkuerzel erscheint (`INTERN`: nie). + */ + +export type EtikettLayout = "etikett" | "bogen"; + +/** + * Die Adresse der Druckansicht. + * + * Steht hier, damit niemand die Parameternamen abtippt: die Geraeteseite braucht + * genau einen Aufruf (`etikettenPfad([geraet.tagCode])`), die Tag-Liste den + * Stapel als Bogen. Ohne Codes zeigt die Seite alle noch nicht zugeordneten + * Tags — das ist der Stapeldruck. + */ +export function etikettenPfad(tagCodes: readonly string[] = [], layout: EtikettLayout = "etikett"): string { + const parameter = new URLSearchParams({ layout }); + if (tagCodes.length > 0) parameter.set("codes", tagCodes.join(",")); + return `/drucken/etiketten?${parameter.toString()}`; +} + +export type EtikettDaten = { + readonly tagCode: string; + /** Das fertige QR-SVG aus `qrSvg` – die Seite erzeugt es, nicht diese Komponente. */ + readonly qrSvg: string; + /** Hostname, sonst Bezeichnung. Ein Tag ohne Zuordnung hat nichts davon. */ + readonly anzeige?: string | null; + readonly besitzerName?: string | null; + /** Nur im Modus DIENSTLEISTUNG gesetzt (#008). */ + readonly kundeKuerzel?: string | null; +}; + +/* + * Die Masse als feste Klassennamen und nicht als Zeichenkette zusammengesetzt: + * Tailwind liest den Quelltext, ein `w-[${wert}]` findet es nicht und die Klasse + * fehlt im Stylesheet. Das faellt erst am leeren Bogen auf. + * + * etikett 62 × 29 mm, ein Stueck je Seite (Etikettendrucker) + * bogen 70 × 37 mm, 3 × 8 auf A4 (3 · 70 = 210, 8 · 37 = 296 mm) + * + * Exportiert, weil `tests/qr.test.ts` daraus die Pixelmasse fuer die + * 300-dpi-Rasterung rechnet. Stuenden die Millimeter dort zweitens, koennte das + * Etikett schrumpfen, ohne dass der Test es merkt. + */ +export const ETIKETT_RAHMEN: Readonly> = { + etikett: "h-[29mm] w-[62mm] p-[2mm] gap-[2mm]", + bogen: "h-[37mm] w-[70mm] p-[3mm] gap-[2.5mm]", +}; + +/** Der QR nimmt die Hoehe minus Rand – quadratisch, sonst verzerrt er. */ +export const ETIKETT_QR_FLAECHE: Readonly> = { + etikett: "size-[25mm]", + bogen: "size-[31mm]", +}; + +const CODE_GROESSE: Readonly> = { + etikett: "text-[3.4mm]", + bogen: "text-[3.8mm]", +}; + +const TEXT_GROESSE: Readonly> = { + etikett: "text-[2.6mm]", + bogen: "text-[2.9mm]", +}; + +export function Etikett({ daten, layout }: { daten: EtikettDaten; layout: EtikettLayout }) { + return ( +
    + {/* + Das SVG kommt aus `qrSvg` und damit aus dem eigenen Code – kein + Benutzertext. Eingesetzt statt als `img`, damit der Drucker Vektoren + bekommt und keine zweite Anfrage nötig ist; ein Bogen mit 24 Etiketten + wären sonst 24 Abrufe. + */} +
    svg]:block [&>svg]:size-full ${ETIKETT_QR_FLAECHE[layout]}`} + aria-label={`QR-Code ${daten.tagCode}`} + role="img" + dangerouslySetInnerHTML={{ __html: daten.qrSvg }} + /> + +
    +

    {daten.tagCode}

    + {daten.anzeige ? ( +

    {daten.anzeige}

    + ) : null} + {daten.besitzerName ? ( +

    + {daten.besitzerName} +

    + ) : null} +
    + + laufzettel + {/* Im Modus Intern gibt es genau eine Organisation, und die erscheint + auf keinem Etikett (#008). */} + {daten.kundeKuerzel ? {daten.kundeKuerzel} : null} +
    +
    +
    + ); +} diff --git a/src/components/tags/etiketten-drucken.tsx b/src/components/tags/etiketten-drucken.tsx new file mode 100644 index 0000000..3218e18 --- /dev/null +++ b/src/components/tags/etiketten-drucken.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Printer } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +/** + * Der Knopf, der den Druckdialog oeffnet (#014). + * + * Eine eigene Client-Komponente, weil `window.print()` nur im Browser existiert. + * Sie traegt `nicht-drucken`, damit sie nicht selbst auf dem Bogen landet. + */ +export function EtikettenDrucken({ beschriftung = "Jetzt drucken" }: { beschriftung?: string }) { + return ( + + ); +} diff --git a/src/components/tags/tag-stapel.tsx b/src/components/tags/tag-stapel.tsx new file mode 100644 index 0000000..03bc538 --- /dev/null +++ b/src/components/tags/tag-stapel.tsx @@ -0,0 +1,101 @@ +"use client"; + +import Link from "next/link"; +import { useActionState } from "react"; +import { Printer, Tags } from "lucide-react"; +import { etikettenPfad } from "@/components/tags/etikett"; +import { Button } from "@/components/ui/button"; +import { FELD } from "@/lib/formular"; +import { tagsErzeugenAktion, type TagsErgebnis } from "@/server/actions/tags"; + +/** + * Einen Stapel vorgedruckter Tags anlegen (#014). + * + * Die erzeugten Codes stehen anschliessend **hier** und nicht nur in der Liste: + * gleich nach dem Anlegen will man sie drucken, und zwar genau diesen Stapel und + * nicht alles, was noch offen ist. Deshalb fuehrt der Knopf daneben mit den + * Codes im Gepaeck auf die Druckansicht. + */ +export function TagStapel({ + standard, + hoechstens, + kunden, +}: { + readonly standard: number; + readonly hoechstens: number; + readonly kunden: readonly { readonly id: string; readonly name: string }[]; +}) { + const [ergebnis, aktion, laeuft] = useActionState(tagsErzeugenAktion, {}); + + return ( +
    +
    + + + {/* Die Kundenwahl erscheint nur im Modus Dienstleistung; im Modus Intern + verwirft der Server eine mitgeschickte Kennung ohnehin (#008). */} + {kunden.length > 0 ? ( + + ) : null} +
    + +

    + Höchstens {hoechstens} auf einmal. Jeder Tag verbraucht einen Tag-Code für immer — auch einer, dessen + Aufkleber nie geklebt wird. +

    + + {ergebnis.fehler ? ( +

    + {ergebnis.fehler} +

    + ) : null} + {ergebnis.erfolg ? ( +

    + {ergebnis.erfolg} +

    + ) : null} + +
    + + + {ergebnis.tagCodes && ergebnis.tagCodes.length > 0 ? ( + +
    +
    + ); +} diff --git a/src/lib/adressen.ts b/src/lib/adressen.ts index 1513a59..11baaa7 100644 --- a/src/lib/adressen.ts +++ b/src/lib/adressen.ts @@ -21,3 +21,31 @@ export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL { } return new URL(pfad, ersatzHerkunft); } + +/** + * Die Basisadresse der Anwendung – **mit** ihrem Pfad (#014). + * + * `oeffentlicheAdresse` loest einen absoluten Pfad gegen die Basis auf, und + * dabei faellt ein Unterpfad heraus: aus `https://firma.de/laufzettel` und + * `/t/LZ-…` wird `https://firma.de/t/LZ-…`. Fuer eine Umleitung im laufenden + * Betrieb faellt das niemandem auf, weil der Reverse Proxy den Pfad ohnehin + * wieder anhaengt. Auf einem **gedruckten Aufkleber** ist es das Ende: er zeigt + * fuer immer ins Leere. + * + * Wer eine Adresse baut, die jemand behaelt, nimmt deshalb diese Basis und + * haengt relativ an. Der Schraegstrich am Ende entscheidet darueber, ob das + * letzte Pfadstueck ersetzt oder ergaenzt wird — er wird hier gesetzt, statt + * darauf zu hoffen. + */ +export function basisAdresse(ersatzHerkunft: string): URL { + const roh = process.env["APP_URL"]?.trim(); + let basis: URL; + try { + basis = new URL(roh || ersatzHerkunft); + } catch { + // Eine unbrauchbare APP_URL soll den Druck nicht blockieren. + basis = new URL(ersatzHerkunft); + } + if (!basis.pathname.endsWith("/")) basis.pathname += "/"; + return basis; +} diff --git a/src/lib/qr.ts b/src/lib/qr.ts new file mode 100644 index 0000000..bcf0743 --- /dev/null +++ b/src/lib/qr.ts @@ -0,0 +1,116 @@ +import QRCode from "qrcode"; +import { basisAdresse } from "@/lib/adressen"; +import { istTagCode } from "@/lib/tagcode"; + +/** + * QR-Codes fuer die Aufkleber (#014). + * + * Framework- und datenbankfrei, wie jedes Modul in `src/lib`: hier stehen nur + * die Rechnung von `tagCode` zu Adresse und die Erzeugung des SVG. Wer den Code + * kennen darf, entscheiden Route Handler und Seiten. + * + * **Im QR steht ausschliesslich die Adresse `{APP_URL}/t/{tagCode}`.** Kein + * Name, keine Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang + * sichtbar am Geraet und wird von jedem Telefon gelesen, auch von einem fremden; + * was darauf steht, ist damit veroeffentlicht. Die Adresse selbst verraet + * nichts — dahinter verlangt `/t/[tagCode]` eine Anmeldung. + */ + +export class QrFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "QrFehler"; + } +} + +/** + * Fehlerkorrektur M wie im Auftrag: rund 15 % der Module duerfen fehlen. + * + * Das passt zu einem Aufkleber, der verkratzt und verstaubt, ohne den Code so + * gross zu machen, dass er auf ein 62 × 29 mm grosses Etikett nicht mehr passt. + */ +export const QR_FEHLERKORREKTUR = "M" as const; + +/** + * Ruhezone in Modulen. + * + * Vier ist das Mass aus ISO/IEC 18004 und zugleich der Standard von `qrcode`. + * Die Zone wird hier ausdruecklich gesetzt und nicht dem Paket ueberlassen, + * damit niemand sie am Etikett „einspart“: ohne weissen Rand findet der Decoder + * das Suchmuster nicht mehr, und das faellt erst am gedruckten Bogen auf. + */ +export const QR_RUHEZONE = 4; + +/** + * Basisadresse, wenn weder `APP_URL` noch eine Anfrage eine liefert. + * + * `oeffentlicheAdresse` verlangt eine Ersatzherkunft, weil im Betrieb die + * laufende Anfrage sie stellt. Beim Etikettendruck gibt es keine — eine + * Server-Komponente hat keine Anfrageadresse zur Hand, und ein Etikett wird + * gedruckt, nicht angeklickt. Ohne gesetzte `APP_URL` waere jeder Aufkleber + * ohnehin falsch, egal welche Adresse hier stuende; der Entwicklungsstand ist + * dann die Adresse, unter der die Anwendung ueberhaupt laeuft. + */ +export const QR_ERSATZ_HERKUNFT = "http://localhost:3000"; + +/** + * Die Adresse, die im QR steht. + * + * Die Basis kommt aus `APP_URL` ueber `oeffentlicheAdresse` (#007) und nicht aus + * der eingehenden Anfrage: hinter einem Reverse Proxy traegt die Anfrage den + * internen Host, und ein Aufkleber mit `http://laufzettel-app:3000/t/…` ist + * wertlos — er klebt Jahre am Geraet und laesst sich nicht nachtraeglich + * korrigieren. Wer eine Ersatzherkunft hat (ein Route Handler etwa), gibt sie + * mit; sie greift nur, wenn `APP_URL` fehlt. + */ +export function tagAdresse(tagCode: string, ersatzHerkunft: string = QR_ERSATZ_HERKUNFT): string { + if (!istTagCode(tagCode)) { + // Der Code wird in die Adresse eingesetzt. Ein nicht geprueftes Zeichen + // stuende damit im QR jedes gedruckten Aufklebers. + throw new QrFehler(`„${tagCode}“ hat nicht die Form eines Tag-Codes.`); + } + + /* + * **Relativ angehaengt, nicht als absoluter Pfad.** Liegt die Anwendung unter + * einem Unterpfad (`https://firma.de/laufzettel`), verschluckt `new + * URL("/t/…", basis)` genau diesen Unterpfad — der Aufkleber zeigte dann auf + * `https://firma.de/t/…`, und dort steht nichts. Auf Papier ist das nicht + * mehr zu korrigieren (Reviewbefund zu #014). `basisAdresse` haelt den Pfad + * fest, und hier wird relativ angehaengt. + */ + return new URL(`t/${tagCode}`, basisAdresse(ersatzHerkunft)).toString(); +} + +export type QrOptionen = { + /** Greift nur ohne gesetzte `APP_URL`; siehe `tagAdresse`. */ + readonly ersatzHerkunft?: string; + /** + * Feste Kantenlaenge in Pixeln fuer das `svg`-Element. Ohne Angabe traegt das + * SVG nur eine `viewBox` und fuellt seinen Platz — genau das, was ein Etikett + * in Millimetern braucht. + */ + readonly breite?: number; + /** Ruhezone in Modulen. Standard `QR_RUHEZONE`. */ + readonly rand?: number; +}; + +/** + * Das QR-SVG zu einem `tagCode`. + * + * SVG und nicht PNG: es wird in einen Druck mit Millimeterangaben gesetzt, und + * nur ein Vektor kommt auf jedem Drucker scharf heraus. Erzeugt wird es lokal + * mit `qrcode`; eine externe API kaeme nicht in Frage, sie bekaeme mit jedem + * Aufruf den Bestand an Tag-Codes geliefert. + */ +export async function qrSvg(tagCode: string, optionen: QrOptionen = {}): Promise { + const adresse = tagAdresse(tagCode, optionen.ersatzHerkunft ?? QR_ERSATZ_HERKUNFT); + return QRCode.toString(adresse, { + type: "svg", + errorCorrectionLevel: QR_FEHLERKORREKTUR, + margin: optionen.rand ?? QR_RUHEZONE, + ...(optionen.breite === undefined ? {} : { width: optionen.breite }), + // Schwarz auf Weiss, feste Werte: ein Etikett ist immer hell, und der + // Decoder braucht den Kontrast. Die Token der Oberflaeche gelten hier nicht. + color: { dark: "#000000ff", light: "#ffffffff" }, + }); +} diff --git a/src/lib/tagcode.ts b/src/lib/tagcode.ts index b7cd85c..0683caa 100644 --- a/src/lib/tagcode.ts +++ b/src/lib/tagcode.ts @@ -77,3 +77,45 @@ export async function tagCodeEindeutigErzeugen( "Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.", ); } + +/** + * Liest den Tag-Code aus dem, was der Scanner gefunden hat (#014). + * + * Auf dem Aufkleber steht ein QR mit `{APP_URL}/t/{tagCode}`. Gescannt wird aber + * nicht nur der eigene Aufkleber: in der Kamera landen auch fremde QR-Codes, + * alte Etiketten einer anderen Anwendung oder ein abgetippter Code aus dem + * Ersatzfeld. Deshalb wird hier entschieden, **was** ein Fund bedeutet, und + * nicht in der Kamerakomponente. + * + * Angenommen wird dreierlei, und zwar bewusst grosszuegig: + * + * 1. eine Adresse, deren Pfad auf `/t/` endet — **egal von welchem Host**. + * Ein Haus zieht um, ein zweiter Name zeigt auf dieselbe Anwendung, ein + * Reverse Proxy schreibt um: an all dem soll der Aufkleber im Regal nicht + * scheitern. Der Code selbst ist die Auskunft, nicht der Host. + * 2. der blanke Code `LZ-XXXXXX` + * 3. derselbe in Kleinbuchstaben — abgetippt wird klein, und das Alphabet des + * Codes ist ohnehin nur gross. + * + * Alles andere ergibt `null`; der Scanner sagt dann „kein Laufzettel-Tag“ und + * sucht weiter, statt auf eine Fehlerseite zu springen. + */ +export function tagCodeAusFund(fund: string): string | null { + const roh = fund.trim(); + if (!roh) return null; + + const alsCode = roh.toUpperCase(); + if (istTagCode(alsCode)) return alsCode; + + try { + // `new URL` scheitert bei allem, was keine absolute Adresse ist – genau die + // Unterscheidung, die hier gebraucht wird. + const adresse = new URL(roh); + const teile = adresse.pathname.split("/").filter(Boolean); + if (teile.length !== 2 || teile[0] !== "t") return null; + const code = decodeURIComponent(teile[1] ?? "").toUpperCase(); + return istTagCode(code) ? code : null; + } catch { + return null; + } +} diff --git a/src/proxy.ts b/src/proxy.ts index c8a27c9..0b7d07e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -52,7 +52,14 @@ export function istOeffentlich(pfad: string): boolean { export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string { return [ `default-src 'self'`, - `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`, + /* + * `wasm-unsafe-eval` ist der schmale Bruder von `unsafe-eval`: er erlaubt + * ausschliesslich das Uebersetzen von WebAssembly, sonst nichts. Der Scanner + * (#014) liest QR-Codes ueber `zxing-wasm`, und ohne diese Erlaubnis lehnt + * Chrome das Modul ab — die Kamera laeuft, erkennt aber nie etwas. Die + * strenge Fassung von `script-src` bleibt davon unberuehrt. + */ + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'${entwicklung ? " 'unsafe-eval'" : ""}`, // Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile. `style-src 'self' 'unsafe-inline'`, `img-src 'self' data: blob:`, diff --git a/src/server/actions/geraete.ts b/src/server/actions/geraete.ts index 82f3da5..99c8979 100644 --- a/src/server/actions/geraete.ts +++ b/src/server/actions/geraete.ts @@ -1,8 +1,10 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { GeraeteTyp } from "@/generated/prisma/enums"; import { z } from "@/lib/validierung"; +import type { Recht } from "@/server/auth/rollen"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant"; import { @@ -12,8 +14,10 @@ import { geraetArchivieren, geraetBearbeiten, geraetLoeschen, + laufFuerGeraetAnlegen, type ImportAbweisung, } from "@/server/services/geraete"; +import { LaufFehler, laufZurBearbeitungOeffnen } from "@/server/services/laeufe"; /** * Server Actions der Geraete (#011). @@ -44,7 +48,7 @@ const RECHTE_MELDUNG = * src/server/actions/vorlagen.ts). */ async function angemeldetOderMeldung( - recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten", + recht: Recht = "geraeteBearbeiten", ): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> { try { const { benutzer } = await rechtErforderlich(recht); @@ -58,7 +62,10 @@ async function angemeldetOderMeldung( /** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */ function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis { - if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) { + // `LaufFehler` steht mit dabei, seit die Geraeteseite eine Vorlage zuweist + // (#014): dass eine Version noch ein Entwurf ist oder eine Pflichtangabe fehlt, + // formuliert der Laufdienst — und zwar so, wie es der Techniker lesen soll. + if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler || fehler instanceof LaufFehler) { return { fehler: fehler.message }; } if (fehler instanceof RechteFehler) { @@ -287,3 +294,206 @@ export async function geraeteImportierenAktion( return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden."); } } + +// ---- Geraeteseite und Tag-Einstieg (#014) ---------------------------------- + +const RegistrierenSchema = z.object({ + geraetId: z.string().min(1), + typ: TypSchema, + hostname: z.string().trim().optional(), + bezeichnung: z.string().trim().optional(), + seriennummer: z.string().trim().optional(), + inventarnummer: z.string().trim().optional(), + mitarbeiterId: z.string().trim().optional(), +}); + +/** + * Macht aus einem vorgedruckten Tag ein Gerät (#014). + * + * Keine eigene Regel: das Registrieren ist eine Änderung mit + * `registrieren: true`, und ob dabei ein Name vorliegt, entscheidet der Dienst an + * der einen Stelle, an der diese Regel steht. Der Erfolg endet auf der + * Geräteseite — wer am Gerät steht und es gerade benannt hat, will als nächstes + * loslegen, nicht ein Formular mit einer Erfolgsmeldung sehen. + */ +export async function geraetRegistrierenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const zugang = await angemeldetOderMeldung(); + if ("fehler" in zugang) return zugang; + + const eingabe = RegistrierenSchema.safeParse({ + geraetId: feld(formular, "geraetId"), + typ: feld(formular, "typ"), + hostname: feld(formular, "hostname"), + bezeichnung: feld(formular, "bezeichnung"), + seriennummer: feld(formular, "seriennummer"), + inventarnummer: feld(formular, "inventarnummer"), + mitarbeiterId: feld(formular, "mitarbeiterId"), + }); + if (!eingabe.success) { + return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; + } + + try { + await geraetBearbeiten( + eingabe.data.geraetId, + { + registrieren: true, + typ: eingabe.data.typ, + hostname: eingabe.data.hostname ?? null, + bezeichnung: eingabe.data.bezeichnung ?? null, + seriennummer: eingabe.data.seriennummer ?? null, + inventarnummer: eingabe.data.inventarnummer ?? null, + mitarbeiterId: eingabe.data.mitarbeiterId ?? null, + }, + zugang.benutzer, + ); + } catch (fehler) { + return alsErgebnis(fehler, "Das Gerät konnte nicht registriert werden."); + } + + revalidatePath("/geraete"); + revalidatePath(`/geraete/${eingabe.data.geraetId}`); + // Nach dem `try`: `redirect` arbeitet mit einem geworfenen Fehler, den ein + // `catch` darüber verschluckt hätte — das Gerät wäre registriert, und die + // Oberfläche zeigte trotzdem eine Störung. + redirect(`/geraete/${eingabe.data.geraetId}`); +} + +const ZuweisenSchema = z.object({ + geraetId: z.string().min(1), + vorlagenVersionId: z.string().min(1, "Bitte eine Vorlage wählen."), + zugewiesenAnId: z.string().trim().optional(), + faelligAm: z.string().trim().optional(), +}); + +/** + * Ein Datumsfeld des Browsers liefert `2026-09-30`; ein leeres Feld und ein + * unbrauchbarer Wert heissen beide „keine Faelligkeit“. + */ +function datumLesen(roh: string | undefined): Date | null { + if (!roh) return null; + const datum = new Date(roh); + return Number.isNaN(datum.getTime()) ? null : datum; +} + +/** + * „Vorlage zuweisen“ auf der Geräteseite (#014). + * + * Das Recht ist `laeufeBearbeiten` und nicht `geraeteBearbeiten`: hier entsteht + * ein Lauf, kein Stammdatensatz. Der Lauf beginnt als `OFFEN` und erscheint + * damit unter „Zugewiesen“ — gestartet wird er erst, wenn jemand ihn öffnet. + */ +export async function vorlageZuweisenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const zugang = await angemeldetOderMeldung("laeufeBearbeiten"); + if ("fehler" in zugang) return zugang; + + const eingabe = ZuweisenSchema.safeParse({ + geraetId: feld(formular, "geraetId"), + vorlagenVersionId: feld(formular, "vorlagenVersionId"), + zugewiesenAnId: feld(formular, "zugewiesenAnId"), + faelligAm: feld(formular, "faelligAm"), + }); + if (!eingabe.success) { + return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; + } + + try { + const lauf = await laufFuerGeraetAnlegen( + { + geraetId: eingabe.data.geraetId, + vorlagenVersionId: eingabe.data.vorlagenVersionId, + zugewiesenAnId: eingabe.data.zugewiesenAnId || null, + faelligAm: datumLesen(eingabe.data.faelligAm), + }, + zugang.benutzer, + ); + revalidatePath(`/geraete/${eingabe.data.geraetId}`); + revalidatePath("/laeufe"); + return { erfolg: `„${lauf.titel}“ steht jetzt unter „Zugewiesen“.` }; + } catch (fehler) { + return alsErgebnis(fehler, "Der Lauf konnte nicht angelegt werden."); + } +} + +/** + * „Starten“ an einer Zuweisung auf der Geräteseite (#014). + * + * Dasselbe Öffnen wie in der Abarbeitungsansicht — aber **mit Sprung in den + * Lauf**, und zwar vom Server aus. Zuerst stand hier `laufOeffnenAktion` aus + * `actions/laeufe.ts`, und die Komponente sprang danach selbst über einen + * Effekt. Das ging nicht auf: `revalidatePath` baut die Geräteseite neu, dabei + * verschwindet die Zuweisung samt Knopf aus dem Baum, und der Effekt kam nie + * dazu. Der Lauf stand auf `IN_ARBEIT`, der Techniker blieb am Gerät — und + * nichts sagte ihm, dass er schon angefangen hat. + */ +export async function laufStartenAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const zugang = await angemeldetOderMeldung("laeufeBearbeiten"); + if ("fehler" in zugang) return zugang; + + const eingabe = z + .object({ laufId: z.string().min(1), geraetId: z.string().min(1) }) + .safeParse({ laufId: feld(formular, "laufId"), geraetId: feld(formular, "geraetId") }); + if (!eingabe.success) return { fehler: "Es fehlt der Lauf." }; + + try { + await laufZurBearbeitungOeffnen(eingabe.data.laufId, zugang.benutzer); + } catch (fehler) { + return alsErgebnis(fehler, "Der Lauf konnte nicht geöffnet werden."); + } + + revalidatePath(`/geraete/${eingabe.data.geraetId}`); + revalidatePath("/laeufe"); + // Außerhalb des `try`, weil `redirect` über einen geworfenen Fehler arbeitet. + redirect(`/laeufe/${eingabe.data.laufId}`); +} + +/** + * Der Schnellstart auf der Geräteseite (#014). + * + * Ein Tipp auf eine Vorlage des Gerätetyps legt den Lauf an **und** startet ihn: + * wer am Gerät steht und hier tippt, fängt jetzt an. Deshalb zwei Dienste + * hintereinander — `laufFuerGeraetAnlegen` erzeugt den `OFFEN`en Lauf mit Gerät + * und Besitzer, `laufZurBearbeitungOeffnen` macht daraus `IN_ARBEIT` mit + * `gestartetAm`. Beide Übergänge stehen weiter je an ihrer einen Stelle. + * + * Vorlagen mit Pflichtangaben kommen hier nicht an: die Geräteseite schickt sie + * in den Start-Dialog, weil dieser Weg nichts erfragen kann. Käme eine trotzdem, + * weist `laufAnlegen` sie mit der Liste der fehlenden Angaben ab — sichtbar, und + * ohne einen halben Lauf zu hinterlassen. + */ +export async function laufSchnellstartAktion( + _vorher: AktionsErgebnis, + formular: FormData, +): Promise { + const zugang = await angemeldetOderMeldung("laeufeBearbeiten"); + if ("fehler" in zugang) return zugang; + + const eingabe = z.object({ geraetId: z.string().min(1), vorlagenVersionId: z.string().min(1) }).safeParse({ + geraetId: feld(formular, "geraetId"), + vorlagenVersionId: feld(formular, "vorlagenVersionId"), + }); + if (!eingabe.success) return { fehler: "Es fehlt die Vorlage." }; + + let laufId: string; + try { + const lauf = await laufFuerGeraetAnlegen(eingabe.data, zugang.benutzer); + await laufZurBearbeitungOeffnen(lauf.id, zugang.benutzer); + laufId = lauf.id; + } catch (fehler) { + return alsErgebnis(fehler, "Der Lauf konnte nicht gestartet werden."); + } + + revalidatePath(`/geraete/${eingabe.data.geraetId}`); + revalidatePath("/laeufe"); + // Außerhalb des `try`, weil `redirect` über einen geworfenen Fehler arbeitet. + redirect(`/laeufe/${laufId}`); +} diff --git a/src/server/actions/tags.ts b/src/server/actions/tags.ts new file mode 100644 index 0000000..b522a9c --- /dev/null +++ b/src/server/actions/tags.ts @@ -0,0 +1,90 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "@/lib/validierung"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { MandantFehler, kundenEbeneSichtbar } from "@/server/mandant"; +import { GeraeteFehler } from "@/server/services/geraete"; +import { TAGS_STAPEL_HOECHSTENS, TagsFehler, tagsErzeugen } from "@/server/services/tags"; + +/** + * Server Action der Tag-Stapel (#014). + * + * Duenn: ein Recht pruefen, Zod anwenden, den Dienst rufen. Das Recht ist + * `verwaltung` — ein Stapel verbrennt N Tag-Codes unwiderruflich (siehe + * `services/tags.ts`). + */ + +export type TagsErgebnis = { + readonly fehler?: string; + readonly erfolg?: string; + /** Die Codes des gerade erzeugten Stapels – fuer den Knopf „Etiketten drucken“. */ + readonly tagCodes?: readonly string[]; +}; + +const RECHTE_MELDUNG = + "Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " + + "noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite."; + +const StapelSchema = z.object({ + /* + * `z.coerce`, weil ein Formularfeld immer Text schickt. Die Obergrenze steht + * hier **und** im Dienst: hier fuer die Meldung vor dem ersten Schreibvorgang, + * dort, weil sich kein Dienst darauf verlassen darf, dass vor ihm jemand + * geprueft hat. + */ + anzahl: z.coerce + .number() + .int("Bitte eine ganze Zahl angeben.") + .min(1, "Mindestens ein Tag.") + .max(TAGS_STAPEL_HOECHSTENS, `Höchstens ${TAGS_STAPEL_HOECHSTENS} Tags auf einmal.`), + kundeId: z.string().trim().optional(), +}); + +export async function tagsErzeugenAktion(_vorher: TagsErgebnis, formular: FormData): Promise { + let benutzerId: string; + let rolle: Parameters[2]["rolle"]; + try { + const { benutzer } = await rechtErforderlich("verwaltung"); + benutzerId = benutzer.id; + rolle = benutzer.rolle; + } catch (fehler) { + // Ein geworfener Rechtefehler wuerde die Seite aushaengen; bei einem Formular + // mit halb eingetippter Anzahl ist das die Arbeit, die weg ist (Befund aus + // dem Review von #010). + if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG }; + throw fehler; + } + + const roh = formular.get("anzahl"); + const eingabe = StapelSchema.safeParse({ + anzahl: typeof roh === "string" && roh.trim() !== "" ? roh.trim() : undefined, + kundeId: typeof formular.get("kundeId") === "string" ? String(formular.get("kundeId")) : undefined, + }); + if (!eingabe.success) { + return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." }; + } + + // Eine Kundenwahl gibt es nur im Modus Dienstleistung; `kundeErmitteln` + // verwirft eine mitgeschickte Kennung im Modus Intern ohnehin (#008). + const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null; + + try { + const stapel = await tagsErzeugen(eingabe.data.anzahl, kundeId, { id: benutzerId, rolle }); + revalidatePath("/tags"); + return { + erfolg: `${stapel.tagCodes.length} ${stapel.tagCodes.length === 1 ? "Tag" : "Tags"} angelegt.`, + tagCodes: stapel.tagCodes, + }; + } catch (fehler) { + if (fehler instanceof TagsFehler || fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) { + return { fehler: fehler.message }; + } + if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG }; + console.error( + "Der Tag-Stapel konnte nicht angelegt werden:", + fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler, + ); + return { fehler: "Der Tag-Stapel konnte nicht angelegt werden." }; + } +} diff --git a/src/server/queries/geraete.ts b/src/server/queries/geraete.ts index 4543e50..1ea40c9 100644 --- a/src/server/queries/geraete.ts +++ b/src/server/queries/geraete.ts @@ -1,5 +1,5 @@ import type { Prisma } from "@/generated/prisma/client"; -import type { GeraeteTyp } from "@/generated/prisma/enums"; +import { LaufStatus, SchrittZustand, VersionStatus, type GeraeteTyp } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; @@ -34,6 +34,8 @@ export type GeraeteZeile = { readonly besitzerId: string | null; readonly besitzerName: string | null; readonly besitzerAktiv: boolean | null; + /** Der rohe `department`-Wert der Person – die Geraeteseite nennt ihn im Kopf (#014). */ + readonly besitzerAbteilung: string | null; readonly anzahlLaeufe: number; }; @@ -76,7 +78,7 @@ const ZEILEN_AUSWAHL = { registriertAm: true, kundeId: true, kunde: { select: { name: true } }, - mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true } }, + mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true, abteilung: true } }, _count: { select: { laeufe: true } }, } satisfies Prisma.GeraetSelect; @@ -101,6 +103,7 @@ function zeileBauen(zeile: Rohzeile): GeraeteZeile { besitzerId: zeile.mitarbeiter?.id ?? null, besitzerName: zeile.mitarbeiter?.anzeigename ?? null, besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null, + besitzerAbteilung: zeile.mitarbeiter?.abteilung ?? null, anzahlLaeufe: zeile._count.laeufe, }; } @@ -167,6 +170,258 @@ export async function geraetHolen( return zeile ? zeileBauen(zeile) : undefined; } +/** + * Das Geraet zu einem Tag-Code – der Einstieg von `/t/` (#014). + * + * **Gross- und Kleinschreibung sind unerheblich.** Der Code wird abgetippt, und + * ein Handy schreibt von sich aus klein; wer `lz-7k3f9q` eingibt, meint den + * Aufkleber, auf dem `LZ-7K3F9Q` steht. Weiter geht die Nachsicht bewusst + * nicht: verwechselbare Zeichen bleiben stehen, wie sie eingegeben wurden. Ein + * `I` auf ein `L` zu biegen koennte auf ein **anderes, vorhandenes** Geraet + * fuehren, und wer vor dem Geraet steht, saehe den Irrtum nie. + * + * `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ – dieselbe + * Antwort, aus demselben Grund wie in `geraetHolen`. + */ +export async function geraetNachTagCode( + tagCode: string, + benutzer: HandelnderBenutzer, +): Promise { + const code = tagCode.trim().toUpperCase(); + if (code === "") return undefined; + const mandant = await kundenBedingung(benutzer); + const zeile = await prisma.geraet.findFirst({ + where: { AND: [{ tagCode: code }, ...(mandant ? [mandant] : [])] }, + select: ZEILEN_AUSWAHL, + }); + return zeile ? zeileBauen(zeile) : undefined; +} + +export type GeraetLaufZeile = { + readonly id: string; + readonly titel: string; + readonly status: LaufStatus; + readonly vorlagenName: string; + readonly version: number; + readonly zugewiesenAn: string | null; + readonly faelligAm: Date | null; + readonly erstelltAm: Date; + readonly abgeschlossenAm: Date | null; + readonly anzahlSchritte: number; + readonly anzahlErledigt: number; +}; + +export type GeraetLaeufe = { + /** `OFFEN` und `IN_ARBEIT` – die Arbeit, die an diesem Geraet wartet. */ + readonly zugewiesen: readonly GeraetLaufZeile[]; + /** `ABGESCHLOSSEN` und `ARCHIVIERT`, das Jüngste zuerst. */ + readonly historie: readonly GeraetLaufZeile[]; + /** Waren es mehr als `HISTORIE_HOECHSTENS`? Dann sagt die Seite das auch. */ + readonly historieGekuerzt: boolean; +}; + +/** + * So viele abgeschlossene Laeufe zeigt die Geraeteseite. + * + * Die Zuweisungen bleiben ungekappt: was offen ist, muss vollstaendig sichtbar + * sein, und mehr als eine Handvoll gleichzeitig hat ein Geraet nie. Die Historie + * dagegen waechst ueber Jahre; ungekappt waere die Seite am Handy irgendwann eine + * Chronik statt eines Einstiegs. + */ +export const HISTORIE_HOECHSTENS = 20; + +const LAUF_AUSWAHL = { + id: true, + titel: true, + status: true, + faelligAm: true, + erstelltAm: true, + abgeschlossenAm: true, + zugewiesenAn: { select: { name: true } }, + vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } }, + ergebnisse: { select: { zustand: true } }, +} satisfies Prisma.LaufSelect; + +type RohLauf = Prisma.LaufGetPayload<{ select: typeof LAUF_AUSWAHL }>; + +function laufZeileBauen(zeile: RohLauf): GeraetLaufZeile { + return { + id: zeile.id, + titel: zeile.titel, + status: zeile.status, + vorlagenName: zeile.vorlagenVersion.vorlage.name, + version: zeile.vorlagenVersion.version, + zugewiesenAn: zeile.zugewiesenAn?.name ?? null, + faelligAm: zeile.faelligAm, + erstelltAm: zeile.erstelltAm, + abgeschlossenAm: zeile.abgeschlossenAm, + anzahlSchritte: zeile.ergebnisse.length, + // Erledigt heisst hier „vom Tisch“: `NICHT_ZUTREFFEND` zaehlt mit, + // `FEHLGESCHLAGEN` nicht – genau wie in der Laufliste und beim Abschluss. + anzahlErledigt: zeile.ergebnisse.filter( + (ergebnis) => + ergebnis.zustand === SchrittZustand.ERLEDIGT || ergebnis.zustand === SchrittZustand.NICHT_ZUTREFFEND, + ).length, + }; +} + +/** + * Die Laeufe eines Geraets, getrennt in Zuweisungen und Historie (#014). + * + * Zwei Abfragen statt einer, weil beide Haelften verschieden sortiert und + * verschieden gekappt sind: das Offene nach Faelligkeit und vollstaendig, das + * Erledigte nach Abschluss und begrenzt. + * + * Die Mandantensicht steht auch hier im Lesemodell selbst – ein Lauf traegt seine + * eigene `kundeId`, und die Bedingung greift auf ihr. Die `geraetId` allein + * duerfte nichts oeffnen. + */ +export async function laeufeDesGeraets( + geraetId: string, + benutzer: HandelnderBenutzer, +): Promise { + const sichtbar = await sichtbareKundenIds(benutzer); + const mandant: Prisma.LaufWhereInput[] = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }]; + + const [zugewiesen, historie] = await Promise.all([ + prisma.lauf.findMany({ + where: { + AND: [{ geraetId }, { status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] } }, ...mandant], + }, + orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { erstelltAm: "desc" }], + select: LAUF_AUSWAHL, + }), + prisma.lauf.findMany({ + where: { + AND: [ + { geraetId }, + { status: { in: [LaufStatus.ABGESCHLOSSEN, LaufStatus.ARCHIVIERT] } }, + ...mandant, + ], + }, + // Abgeschlossen zuerst, und wo das fehlt, nach Anlage – ein archivierter + // Lauf ohne `abgeschlossenAm` faellt sonst ans Ende der Chronik. + orderBy: [{ abgeschlossenAm: { sort: "desc", nulls: "last" } }, { erstelltAm: "desc" }], + take: HISTORIE_HOECHSTENS + 1, + select: LAUF_AUSWAHL, + }), + ]); + + return { + zugewiesen: zugewiesen.map(laufZeileBauen), + historie: historie.slice(0, HISTORIE_HOECHSTENS).map(laufZeileBauen), + historieGekuerzt: historie.length > HISTORIE_HOECHSTENS, + }; +} + +export type GeraeteVorlage = { + readonly versionId: string; + readonly vorlageId: string; + readonly name: string; + readonly version: number; + readonly kategorie: string | null; + /** + * Hat diese Version Pflichtvariablen? + * + * Der Schnellstart legt einen Lauf mit einem Tipp an und kann dabei nichts + * erfragen. Eine Vorlage, die eine Pflichtangabe verlangt, wuerde `laufAnlegen` + * deshalb abweisen — sie fuehrt statt dessen in den Start-Dialog, der fragen + * kann. Die Regel selbst bleibt im Dienst; dieses Feld entscheidet nur, welcher + * der beiden Wege angeboten wird. + */ + readonly pflichtangaben: boolean; +}; + +export type VorlagenFuerGeraet = { + /** Die Vorgaben aus `GeraetetypVorlage` fuer diesen Typ, in ihrer Reihenfolge. */ + readonly schnellstart: readonly GeraeteVorlage[]; + /** Alles, was sich an diesem Geraet starten laesst – fuer „Vorlage zuweisen“. */ + readonly alle: readonly GeraeteVorlage[]; +}; + +/** + * Die Vorlagen, die an diesem Geraet in Frage kommen (#014). + * + * Je Vorlage genau die **zuletzt veroeffentlichte** Version: wer an einem Geraet + * losgeht, will die geltende Fassung, und ein Entwurf ist noch niemandes + * Arbeitsanweisung. Eine Vorlage ohne veroeffentlichte Version steht deshalb + * nicht dabei, eine archivierte auch nicht. + * + * Gefiltert wird auf den Kunden **des Geraets** und nicht nur auf die Sicht des + * Benutzers: ein Administrator sieht im Modus Dienstleistung alle Kunden, und + * eine kundenspezifische Vorlage von Kunde B liesse sich an einem Geraet von + * Kunde A nicht starten — `laufAnlegen` weist das ab. Eine Auswahl anzubieten, + * die der Dienst danach ablehnt, ist keine Auswahl. + */ +export async function vorlagenFuerGeraet( + geraet: { readonly typ: GeraeteTyp; readonly kundeId: string }, + benutzer: HandelnderBenutzer, +): Promise { + const sichtbar = await sichtbareKundenIds(benutzer); + // Die Mandantensicht auch hier selbst: ohne sie liesse sich mit der `kundeId` + // eines fremden Geraets erfragen, welche Vorlagen dieser Kunde hat. + if (sichtbar !== undefined && !sichtbar.includes(geraet.kundeId)) { + return { schnellstart: [], alle: [] }; + } + + const vorlagen = await prisma.vorlage.findMany({ + where: { + AND: [ + { archiviert: false }, + // Eine Vorlage ohne Kundenbezug gilt fuer alle (#009). + { OR: [{ kundeId: null }, { kundeId: geraet.kundeId }] }, + ], + }, + orderBy: [{ kategorie: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + kategorie: true, + versionen: { + where: { status: VersionStatus.VEROEFFENTLICHT }, + orderBy: { version: "desc" }, + take: 1, + select: { + id: true, + version: true, + // Nur ob es welche gibt, nicht welche: mehr braucht die Entscheidung + // zwischen Schnellstart und Start-Dialog nicht. + variablen: { where: { pflicht: true }, select: { schluessel: true }, take: 1 }, + }, + }, + typVorgaben: { where: { geraeteTyp: geraet.typ }, select: { position: true } }, + }, + }); + + const alle: GeraeteVorlage[] = []; + const schnellstart: { eintrag: GeraeteVorlage; position: number }[] = []; + + for (const vorlage of vorlagen) { + const version = vorlage.versionen[0]; + if (!version) continue; + const eintrag: GeraeteVorlage = { + versionId: version.id, + vorlageId: vorlage.id, + name: vorlage.name, + version: version.version, + kategorie: vorlage.kategorie, + pflichtangaben: version.variablen.length > 0, + }; + alle.push(eintrag); + const vorgabe = vorlage.typVorgaben[0]; + if (vorgabe) schnellstart.push({ eintrag, position: vorgabe.position }); + } + + return { + schnellstart: schnellstart + // Die Position ist die gepflegte Reihenfolge; bei gleicher Position bleibt + // die Sortierung der Abfrage (Kategorie, Name) erhalten. + .sort((links, rechts) => links.position - rechts.position) + .map((eintrag) => eintrag.eintrag), + alle, + }; +} + export type BesitzerKandidat = { readonly id: string; readonly anzeigename: string; @@ -195,11 +450,25 @@ export type BesitzerAuswahl = { export async function besitzerKandidaten( benutzer: HandelnderBenutzer, grenze = 500, + /** + * Nur Personen dieses Kunden. + * + * Gehoert in die **Abfrage** und nicht hinter sie: wer erst 500 Personen aller + * sichtbaren Kunden holt und danach filtert, hat bei einem grossen + * Dienstleister womoeglich keine einzige passende in der Hand — und die + * Meldung „nur die ersten 500“ stimmt dann auch nicht mehr (Reviewbefund zu + * #014). + */ + kundeId?: string, ): Promise { const sichtbar = await sichtbareKundenIds(benutzer); const zeilen = await prisma.mitarbeiter.findMany({ where: { - AND: [{ aktiv: true }, ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }])], + AND: [ + { aktiv: true }, + ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }]), + ...(kundeId === undefined ? [] : [{ kundeId }]), + ], }, select: { id: true, anzeigename: true, aktiv: true, kundeId: true }, orderBy: { anzeigename: "asc" }, diff --git a/src/server/queries/tags.ts b/src/server/queries/tags.ts new file mode 100644 index 0000000..5d04be5 --- /dev/null +++ b/src/server/queries/tags.ts @@ -0,0 +1,135 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { istTagCode } from "@/lib/tagcode"; +import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; + +/** + * Lesemodelle der Tags und Etiketten (#014). + * + * Ein „Tag“ ist kein eigenes Modell: es ist ein `Geraet` mit + * `registriertAm = null`, also ein vorgedruckter Aufkleber, dem noch kein Objekt + * zugeordnet ist. Deshalb liest dieses Modul aus `Geraet` — nur mit den Feldern, + * die auf ein Etikett passen, und nicht mit denen der Geraeteliste. + * + * **Die Mandantensicht wendet jede Funktion hier selbst an**, in einem + * `AND`-Array, in dem keine Teilbedingung eine andere ueberschreiben kann (der + * Befund aus dem Review von #009). Ein Etikett nennt Hostname und Besitzer; ein + * Bogen mit fremden Kunden darauf waere ein Datenleck auf Papier. + */ + +export type EtikettZeile = { + readonly id: string; + readonly tagCode: string; + readonly hostname: string | null; + readonly bezeichnung: string | null; + /** Hostname, sonst Bezeichnung, sonst leer — ein Tag ohne Zuordnung hat keinen Namen. */ + readonly anzeige: string | null; + readonly besitzerName: string | null; + /** Steht nur im Modus DIENSTLEISTUNG auf dem Etikett (#008). */ + readonly kundeKuerzel: string; + readonly kundeName: string; + /** Leer heisst: vorgedruckter Tag, noch keinem Objekt zugeordnet. */ + readonly registriertAm: Date | null; + readonly erstelltAm: Date; +}; + +/** + * Die Mandantenbedingung als eigener Ausdruck. `undefined` heisst „keine + * Einschraenkung“ und ist etwas anderes als eine leere Liste (siehe + * `sichtbareKundenIds`). + */ +async function kundenBedingung(benutzer: HandelnderBenutzer): Promise { + const sichtbar = await sichtbareKundenIds(benutzer); + if (sichtbar === undefined) return undefined; + return { kundeId: { in: [...sichtbar] } }; +} + +const ETIKETT_AUSWAHL = { + id: true, + tagCode: true, + hostname: true, + bezeichnung: true, + registriertAm: true, + erstelltAm: true, + kunde: { select: { name: true, kuerzel: true } }, + mitarbeiter: { select: { anzeigename: true } }, +} satisfies Prisma.GeraetSelect; + +type Rohzeile = Prisma.GeraetGetPayload<{ select: typeof ETIKETT_AUSWAHL }>; + +function zeileBauen(zeile: Rohzeile): EtikettZeile { + return { + id: zeile.id, + tagCode: zeile.tagCode, + hostname: zeile.hostname, + bezeichnung: zeile.bezeichnung, + anzeige: zeile.hostname ?? zeile.bezeichnung, + besitzerName: zeile.mitarbeiter?.anzeigename ?? null, + kundeKuerzel: zeile.kunde.kuerzel, + kundeName: zeile.kunde.name, + registriertAm: zeile.registriertAm, + erstelltAm: zeile.erstelltAm, + }; +} + +/** + * Wie viele Zeilen ein Aufruf hoechstens liefert. + * + * Ein Bogen fasst 24 Etiketten; 500 sind gut 20 Bogen und damit mehr, als jemand + * in einem Zug druckt. Die Grenze steht hier, damit die Seite nach dem dritten + * Stapel nicht tausende Zeilen in ein Dokument gieszt. + */ +export const TAGS_LISTE_GRENZE = 500; + +/** + * Die noch nicht zugeordneten Tags. + * + * Neueste zuerst: gefragt ist immer der Stapel von gerade, nicht der vom letzten + * Jahr. Archivierte bleiben aussen vor — ein archivierter Aufkleber soll nicht + * noch einmal gedruckt werden. + */ +export async function unregistrierteTags( + benutzer: HandelnderBenutzer, + grenze: number = TAGS_LISTE_GRENZE, +): Promise { + const mandant = await kundenBedingung(benutzer); + const zeilen = await prisma.geraet.findMany({ + where: { AND: [{ registriertAm: null }, { archiviert: false }, ...(mandant ? [mandant] : [])] }, + orderBy: [{ erstelltAm: "desc" }, { tagCode: "asc" }], + take: grenze, + select: ETIKETT_AUSWAHL, + }); + return zeilen.map(zeileBauen); +} + +/** + * Die Etikettenzeilen zu bestimmten Tag-Codes, in genau der uebergebenen + * Reihenfolge. + * + * Die Reihenfolge ist keine Kosmetik: ein Bogen wird ausgeschnitten und der + * Reihe nach aufgeklebt, und wer zwei Bogen desselben Stapels nacheinander + * druckt, erwartet dieselbe Anordnung. Codes, die es nicht gibt oder die zu + * einem fremden Kunden gehoeren, fehlen still — die Unterscheidung waere schon + * die Auskunft, die wir nicht geben wollen. + */ +export async function etikettenZuCodes( + codes: readonly string[], + benutzer: HandelnderBenutzer, +): Promise { + // Ein formal falscher Code kann keine Zeile treffen. Ihn gar nicht erst zu + // fragen haelt die Abfrage klein, wenn jemand die Adresse von Hand baut. + const gewuenscht = [...new Set(codes.map((code) => code.trim().toUpperCase()))].filter(istTagCode); + if (gewuenscht.length === 0) return []; + + const mandant = await kundenBedingung(benutzer); + const zeilen = await prisma.geraet.findMany({ + where: { AND: [{ tagCode: { in: gewuenscht } }, ...(mandant ? [mandant] : [])] }, + take: TAGS_LISTE_GRENZE, + select: ETIKETT_AUSWAHL, + }); + + const nachCode = new Map(zeilen.map((zeile) => [zeile.tagCode, zeileBauen(zeile)])); + return gewuenscht + .map((code) => nachCode.get(code)) + .filter((zeile): zeile is EtikettZeile => zeile !== undefined); +} diff --git a/src/server/services/geraete.ts b/src/server/services/geraete.ts index f084675..41e6ca4 100644 --- a/src/server/services/geraete.ts +++ b/src/server/services/geraete.ts @@ -1,9 +1,10 @@ -import { Prisma, type Geraet } from "@/generated/prisma/client"; +import { Prisma, type Geraet, type Lauf } from "@/generated/prisma/client"; import { GeraeteTyp } from "@/generated/prisma/enums"; import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv"; import { prisma } from "@/lib/db"; import { tagCodeEindeutigErzeugen } from "@/lib/tagcode"; import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant"; +import { laufAnlegen } from "@/server/services/laeufe"; /** * Geraete und ihre Stammdaten (#011). @@ -445,6 +446,61 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut } } +// ---- Ein Lauf am Geraet ---------------------------------------------------- + +export type LaufAmGeraet = { + readonly geraetId: string; + readonly vorlagenVersionId: string; + readonly zugewiesenAnId?: string | null; + readonly faelligAm?: Date | null; +}; + +/** + * Legt einen Lauf fuer dieses Geraet an — „Vorlage zuweisen“ auf der + * Geraeteseite (#014). + * + * Die Regeln des Laufs bleiben in `services/laeufe.ts`: die Version muss + * veroeffentlicht sein, das Geraet darf nicht archiviert sein, die Zuweisung + * muss jemanden treffen, der den Lauf auch oeffnen kann. Nichts davon steht hier + * ein zweites Mal — diese Funktion beantwortet nur die zwei Fragen, die das + * **Geraet** beantwortet: + * + * 1. **Welcher Kunde?** Der des Geraets. Eine von aussen mitgeschickte `kundeId` + * gibt es hier deshalb nicht; im Modus INTERN verwirft `kundeErmitteln` sie + * ohnehin (#008). + * 2. **Welche Person?** Der Besitzer, wenn es einen gibt. Wer an einem Notebook + * einen Lauf anlegt, meint die Person, der es gehoert; ein Server und ein + * Raum haben keine, und dann bleibt das Feld leer. + * + * Der Lauf entsteht als `OFFEN` und damit als Zuweisung — gestartet wird er + * erst, wenn jemand ihn zur Bearbeitung oeffnet. + */ +export async function laufFuerGeraetAnlegen( + eingabe: LaufAmGeraet, + benutzer: HandelnderBenutzer, +): Promise { + const geraet = await prisma.geraet.findUnique({ + where: { id: eingabe.geraetId }, + select: { id: true, kundeId: true, mitarbeiterId: true }, + }); + if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht."); + // Die `geraetId` kommt von aussen: ohne diese Pruefung liesse sich mit einer + // fremden Kennung Arbeit im Mandanten eines anderen Kunden anlegen. + await kundeZugriffPruefen(geraet.kundeId, benutzer); + + return laufAnlegen( + { + vorlagenVersionId: eingabe.vorlagenVersionId, + geraetId: geraet.id, + mitarbeiterId: geraet.mitarbeiterId, + zugewiesenAnId: eingabe.zugewiesenAnId ?? null, + faelligAm: eingabe.faelligAm ?? null, + kundeId: geraet.kundeId, + }, + benutzer, + ); +} + // ---- CSV-Import ------------------------------------------------------------ /** diff --git a/src/server/services/tags.ts b/src/server/services/tags.ts new file mode 100644 index 0000000..c619db3 --- /dev/null +++ b/src/server/services/tags.ts @@ -0,0 +1,97 @@ +import { geraetAnlegen } from "@/server/services/geraete"; +import type { HandelnderBenutzer } from "@/server/mandant"; + +/** + * Stapel vorgedruckter Tags (#014). + * + * Wer Aufkleber drucken lassen will, braucht die Codes, **bevor** die Geraete + * existieren: der Bogen kommt aus dem Drucker, wird aufgeklebt und erst am Geraet + * gescannt und benannt. Ein solcher Tag ist ein `Geraet` mit + * `registriertAm = null` — kein zweites Modell, kein zweiter Codepfad. + * + * Die Regeln der Vergabe stehen **nicht** hier, sondern in + * `services/geraete.ts`: dort wird der `tagCode` gezogen, in derselben + * Transaktion ins Vergabebuch geschrieben und protokolliert. Dieses Modul ruft + * `geraetAnlegen({ registrieren: false })` in einer Schleife und sonst nichts. + * Die Regeln nachzuschreiben waere der sichere Weg, dass eine von ihnen nur in + * einem der beiden Wege landet — und ein Tag ohne Eintrag im Vergabebuch ist ein + * Aufkleber, dessen Code irgendwann ein zweites Mal vergeben wird. + */ + +export class TagsFehler extends Error { + constructor(nachricht: string) { + super(nachricht); + this.name = "TagsFehler"; + } +} + +/** Ein A4-Bogen fasst 24 Etiketten; 50 sind gut zwei Bogen und der uebliche Vorrat. */ +export const TAGS_STAPEL_STANDARD = 50; + +/** + * Obergrenze je Stapel. + * + * Jeder Tag zieht einen `tagCode`, der nie zurueckkommt — ein vertippter Stapel + * ist also nicht ruecknehmbar, sondern nur Papier, das man nicht bedruckt. Dazu + * kommt die Laufzeit: jeder Tag ist eine eigene Transaktion, weil das die + * Vergabe verlangt. 500 sind gut 20 Bogen, mehr druckt niemand in einem Zug, und + * die Oberflaeche nennt die Zahl, **bevor** jemand dagegen laeuft. + */ +export const TAGS_STAPEL_HOECHSTENS = 500; + +export type TagStapel = { + /** Der Kunde, dem die Tags gehoeren – im Modus INTERN die eigene Organisation. */ + readonly kundeId: string; + /** In der Reihenfolge der Erzeugung, also in der Reihenfolge des Bogens. */ + readonly tagCodes: readonly string[]; +}; + +/** + * Legt `anzahl` Tags ohne Zuordnung an. + * + * Die Rechtepruefung sitzt nicht hier, sondern in der Server Action und auf der + * Seite — wie in jedem Dienst dieses Projekts. Verlangt wird dort `verwaltung`: + * ein Stapel verbrennt N Codes unwiderruflich und ist eine Beschaffung, keine + * Geraetepflege. Aus demselben Grund verlangt auch das endgueltige Loeschen eines + * Geraets `verwaltung` (#011). + * + * Es gibt **keine** gemeinsame Transaktion ueber den Stapel. Die Vergabe eines + * `tagCode` ist je Tag eine eigene, und ein Abbruch in der Mitte laesst 30 + * brauchbare Tags stehen statt 50 verlorene — Tags ohne Zuordnung stoeren + * niemanden, sie warten in der Liste auf ihren Aufkleber. + */ +export async function tagsErzeugen( + anzahl: number, + kundeIdAusEingabe: string | null | undefined, + benutzer: HandelnderBenutzer, +): Promise { + if (!Number.isInteger(anzahl) || anzahl < 1) { + throw new TagsFehler("Die Anzahl muss eine ganze Zahl ab 1 sein."); + } + if (anzahl > TAGS_STAPEL_HOECHSTENS) { + throw new TagsFehler( + `Höchstens ${TAGS_STAPEL_HOECHSTENS} Tags auf einmal. Jeder davon verbraucht einen ` + + "Tag-Code für immer — lieber zweimal drucken als einmal zu viel.", + ); + } + + const tagCodes: string[] = []; + let kundeId: string | undefined; + for (let nummer = 0; nummer < anzahl; nummer += 1) { + // Nacheinander und nicht mit `Promise.all`: `geraetAnlegen` prueft vor dem + // Schreiben, ob der gezogene Code frei ist. Parallel gezogene Codes koennten + // sich gegenseitig ueberholen, und der Stapel schluege mit einem Indexfehler + // fehl, statt fertig zu werden. + const geraet = await geraetAnlegen({ registrieren: false, kundeId: kundeIdAusEingabe }, benutzer); + tagCodes.push(geraet.tagCode); + kundeId ??= geraet.kundeId; + } + + // `anzahl >= 1` ist oben geprueft, die Schleife laeuft also mindestens einmal. + // Der Compiler weiss das nicht, und ein `!` waere eine Behauptung statt einer + // Pruefung. + if (kundeId === undefined) { + throw new TagsFehler("Es wurde kein Tag angelegt."); + } + return { kundeId, tagCodes }; +} diff --git a/tests/e2e/etiketten.spec.ts b/tests/e2e/etiketten.spec.ts new file mode 100644 index 0000000..93ec78b --- /dev/null +++ b/tests/e2e/etiketten.spec.ts @@ -0,0 +1,163 @@ +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; + +/** + * Der Etikettendruck am gebauten Stand (#014). + * + * Diese Pruefungen gibt es nur hier, weil sie ohne Browser nicht ehrlich zu + * fuehren sind: ob Tailwind die Millimeterklassen wirklich erzeugt, ob `@page` + * das Blatt auf 62 × 29 mm stellt und ob der gedruckte QR **zurueckgelesen** + * werden kann, entscheidet sich erst im gerenderten Dokument. `tests/qr.test.ts` + * prueft die Geometrie rechnerisch; hier steht das Ergebnis. + * + * Die Aufloesung: 1 mm sind 96/25,4 px, ein `deviceScaleFactor` von 300/96 = + * 3,125 macht daraus 300 dpi. 62 mm werden so zu 62 · 96/25,4 · 3,125 ≈ 732 + * Geraetepixeln — genau die Aufloesung, mit der ein Etikettendrucker arbeitet. + */ + +const DPI = 300; +const PX_JE_MM = 96 / 25.4; + +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"]')]); +} + +/** Irgendein Tag-Code, den es in dieser Datenbank wirklich gibt. */ +async function einTagCode(): Promise { + const geraet = await prisma.geraet.findFirst({ select: { tagCode: true } }); + if (!geraet) throw new Error("Kein Gerät in der Datenbank — erst pnpm db:seed."); + return geraet.tagCode; +} + +test.describe("Etikettendruck", () => { + test("druckt ein Einzeletikett auf 62 × 29 mm", async ({ page }) => { + test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); + await anmelden(page); + const code = await einTagCode(); + await page.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); + + /* + * `page.pdf()` gilt in Chromium und beachtet `@page`. Gemessen wird die + * Seitengroesse in Punkten (1 pt = 1/72 Zoll): 62 mm sind 175,7 pt, 29 mm + * sind 82,2 pt. Ein Etikettendrucker schneidet, was darueber hinausgeht — + * eine falsche Seitengroesse faellt deshalb erst auf der Rolle auf. + */ + const pdf = await page.pdf({ preferCSSPageSize: true }); + const masse = seitenmasseAusPdf(pdf); + expect(masse.breite).toBeCloseTo((62 / 25.4) * 72, 0); + expect(masse.hoehe).toBeCloseTo((29 / 25.4) * 72, 0); + }); + + test("druckt den A4-Bogen auf A4", async ({ page }) => { + test.skip(test.info().project.name !== "desktop", "Das Seitenformat hängt nicht am Gerät."); + await anmelden(page); + await page.goto("/drucken/etiketten?layout=bogen"); + + const pdf = await page.pdf({ preferCSSPageSize: true }); + const masse = seitenmasseAusPdf(pdf); + // A4 sind 210 × 297 mm, in Punkten 595 × 842. + expect(masse.breite).toBeCloseTo(595, 0); + expect(masse.hoehe).toBeCloseTo(842, 0); + }); + + test("ein bei 300 dpi gerastertes Etikett lässt sich zurücklesen", async ({ browser }) => { + test.skip(test.info().project.name !== "desktop", "Der Decoder hängt nicht am Gerät."); + const code = await einTagCode(); + + /* + * Eigener Kontext: der `deviceScaleFactor` ist die Aufloesung, und die + * gehoert zu diesem einen Test. Die Breite entspricht dem Etikett, damit + * gerastert wird, was auch gedruckt wird. + */ + const kontext = await browser.newContext({ + baseURL: process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000", + viewport: { width: Math.round(62 * PX_JE_MM), height: Math.round(29 * PX_JE_MM) }, + deviceScaleFactor: DPI / 96, + }); + try { + const tab = await kontext.newPage(); + await anmelden(tab); + await tab.goto(`/drucken/etiketten?layout=etikett&codes=${code}`); + const etikett = tab.locator("[data-etikett]").first(); + await expect(etikett).toBeVisible(); + const bild = await etikett.screenshot(); + + const gelesen = await zurueckgelesen(bild); + + expect(gelesen).toHaveLength(1); + /* + * Der ganze Inhalt, nicht nur das Ende: „enthaelt keinen Hostnamen“ waere + * keine Pruefung — dieser Aufkleber koennte zu einem Geraet ohne Hostname + * gehoeren, und der Test waere gruen, ohne etwas zu wissen. Ein Vergleich + * auf Gleichheit schliesst alles andere aus, was jemand spaeter + * dazulegen koennte. + */ + const adresse = new URL(gelesen[0] ?? ""); + expect(adresse.pathname).toBe(`/t/${code}`); + expect(adresse.search).toBe(""); + expect(adresse.hash).toBe(""); + } finally { + await kontext.close(); + } + }); +}); + +/** + * Liest die QR-Codes aus einem PNG zurueck. + * + * Mit **demselben** Decoder, den der Scanner im Browser benutzt + * (`barcode-detector` ueber `zxing-wasm`): ein anderer Leser bewiese etwas + * anderes als „unsere Kamera erkennt das“. Das WebAssembly kommt aus `public/`, + * wohin `scripts/wasm-bereitstellen.mjs` es legt — nicht aus dem Netz. + * + * Der kleine Behelf ist noetig, weil die Ponyfill ihre Treffer in DOM-Typen + * verpackt, die es in Node nicht gibt. Gelesen wird trotzdem im WebAssembly, + * also genau wie im Browser; ersetzt wird nur die Huelle um das Ergebnis. + */ +async function zurueckgelesen(png: Buffer): Promise { + const { readFile } = await import("node:fs/promises"); + const { BarcodeDetector, setZXingModuleOverrides } = await import("barcode-detector/ponyfill"); + + const global = globalThis as unknown as Record; + global["DOMRectReadOnly"] ??= class { + constructor( + readonly x = 0, + readonly y = 0, + readonly width = 0, + readonly height = 0, + ) {} + }; + + setZXingModuleOverrides({ wasmBinary: await readFile("public/zxing/zxing_reader.wasm") }); + const funde = await new BarcodeDetector({ formats: ["qr_code"] }).detect( + new Blob([new Uint8Array(png)], { type: "image/png" }), + ); + return funde.map((fund) => fund.rawValue); +} + +/** + * Breite und Hoehe der ersten Seite eines PDF, in Punkten. + * + * Reicht fuer diesen Zweck und spart eine Abhaengigkeit: das `/MediaBox`-Feld + * steht als Klartext im Dokument, auch wenn die Inhalte komprimiert sind. + */ +function seitenmasseAusPdf(pdf: Buffer): { breite: number; hoehe: number } { + const text = pdf.toString("latin1"); + const treffer = /\/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]/u.exec(text); + if (!treffer) throw new Error("Im PDF steht keine MediaBox — das Dokument ist unbrauchbar."); + return { + breite: Number(treffer[3]) - Number(treffer[1]), + hoehe: Number(treffer[4]) - Number(treffer[2]), + }; +} + +test.afterAll(async () => { + await prisma.$disconnect(); +}); diff --git a/tests/e2e/geraeteseite.spec.ts b/tests/e2e/geraeteseite.spec.ts new file mode 100644 index 0000000..468caf3 --- /dev/null +++ b/tests/e2e/geraeteseite.spec.ts @@ -0,0 +1,215 @@ +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; +import { prisma } from "@/lib/db"; + +/** + * Der Weg vom Aufkleber zur Arbeit (#014). + * + * Das ist der Ablauf, fuer den die Anwendung gebaut ist: scannen, sehen was zu + * tun ist, loslegen. Geprueft wird er am **gebauten** Stand, weil genau hier die + * Teile zusammenkommen, die einzeln schon funktionierten — die Weiche unter + * `/t/`, das Registrieren, die Geraeteseite und der Lauf. + */ + +const STEMPEL = Date.now(); + +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"]')]); +} + +/** + * Eine schlichte Vorlage **ohne** Pflichtangaben. + * + * Die Beispielvorlage verlangt Betriebssystem und Einbindung; „Vorlage zuweisen“ + * fragt beides nicht ab und fuehrt dafuer in den Start-Dialog. Dieser Test will + * aber den kurzen Weg pruefen, also bringt er seine eigene Vorlage mit. + */ +async function vorlageAnlegen(): Promise { + const name = `ZZ E2E Geraetelauf ${STEMPEL}`; + const vorlage = await prisma.vorlage.create({ data: { name, 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: "Prüfen" }, + }); + await prisma.vorlagenSchritt.create({ + data: { abschnittId: abschnitt.id, titel: "Gerät angesehen", pflicht: true }, + }); + await prisma.vorlagenVersion.update({ + where: { id: version.id }, + data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() }, + }); + return name; +} + +/** Die Tags, die dieser Lauf angelegt hat – nur die raeumt er hinterher weg. */ +const eigeneTags: string[] = []; + +/** Legt einen vorgedruckten Tag an – ueber den Dienst, nicht ueber die Oberflaeche. */ +async function tagAnlegen(): Promise { + const { tagsErzeugen } = await import("@/server/services/tags"); + const admin = await prisma.benutzer.findFirstOrThrow({ + where: { rolle: "ADMIN" }, + select: { id: true, rolle: true }, + }); + const stapel = await tagsErzeugen(1, null, { id: admin.id, rolle: admin.rolle }); + const code = stapel.tagCodes[0]; + if (!code) throw new Error("Der Stapel kam leer zurück."); + eigeneTags.push(code); + return code; +} + +test.describe("Vom Aufkleber zur Arbeit", () => { + test("führt einen unbekannten Code nicht ins Leere", async ({ page }) => { + await anmelden(page); + await page.goto("/t/LZ-ZZZZZZ"); + await expect(page.getByRole("heading", { name: "Kein Laufzettel-Tag" })).toBeVisible(); + await page.getByRole("link", { name: "Gerät suchen" }).click(); + await page.waitForURL("**/geraete"); + }); + + test("schickt einen nicht angemeldeten Scan über die Anmeldung ans Ziel", async ({ page }) => { + /* + * Das erste Akzeptanzkriterium des Auftrags, und der Fall, den ein Techniker + * am häufigsten hat: das Handy scannt, die Sitzung ist abgelaufen. Danach + * muss er auf **der Geräteseite** stehen und nicht auf der Übersicht — sonst + * sucht er das Gerät noch einmal, das er gerade in der Hand hält. + */ + const geraet = await prisma.geraet.findFirstOrThrow({ + where: { hostname: "PC-001" }, + select: { id: true, tagCode: true }, + }); + + await page.context().clearCookies(); + await page.goto(`/t/${geraet.tagCode}`); + await page.waitForURL(/\/anmelden\?ziel=/); + + 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 page.fill('input[name="email"]', email); + await page.fill('input[name="passwort"]', passwort); + await page.click('button[type="submit"]'); + + await page.waitForURL(`**/geraete/${geraet.id}`); + await expect(page.getByRole("heading", { name: "PC-001" })).toBeVisible(); + }); + + test("registriert einen vorgedruckten Tag und arbeitet daran weiter", async ({ page }) => { + test.slow(); + const [code, vorlagenName] = await Promise.all([tagAnlegen(), vorlageAnlegen()]); + await anmelden(page); + + // Klein geschrieben, so wie ein Handy es eintippt. + await page.goto(`/t/${code.toLowerCase()}`); + await expect(page.getByRole("heading", { name: "Gerät registrieren" })).toBeVisible(); + + const hostname = `ZZ-E2E-${STEMPEL}`; + await page.getByLabel("Hostname").fill(hostname); + await page.getByLabel("Typ").selectOption("NOTEBOOK"); + await page.getByRole("button", { name: "Gerät registrieren" }).click(); + + // Danach steht der Techniker auf der Geräteseite – nicht auf einer + // Erfolgsmeldung, die er wegklicken muss. + await page.waitForURL(/\/geraete\/[^/]+$/); + await expect(page.getByRole("heading", { name: hostname })).toBeVisible(); + await expect(page.getByText(code)).toBeVisible(); + + // Derselbe Aufkleber führt von jetzt an direkt hierher. + const adresse = page.url(); + await page.goto(`/t/${code}`); + await page.waitForURL(adresse); + + // Eine Vorlage zuweisen: der Lauf erscheint als „offen“ unter Zugewiesen. + /* + * Ueber die Ueberschrift und nicht ueber den Text: der Abschnitt „Vorlage + * zuweisen“ erklaert sich mit dem Satz „… erscheint oben unter + * Zugewiesen“ — ein Textfilter faende ihn damit unter beiden Namen. + */ + const zuweisen = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Vorlage zuweisen" }) }); + // Ueber den Wert, nicht ueber die Beschriftung: die traegt Kategorie und + // Version und liesse sich mit jeder Feinheit am Text brechen. + const wert = await zuweisen.locator("option", { hasText: vorlagenName }).getAttribute("value"); + if (!wert) throw new Error(`Die Vorlage „${vorlagenName}“ steht nicht zur Auswahl.`); + await zuweisen.getByLabel("Vorlage").selectOption(wert); + await zuweisen.getByRole("button", { name: "Lauf zuweisen" }).click(); + + const zugewiesen = page + .locator("section") + .filter({ has: page.getByRole("heading", { name: "Zugewiesen" }) }); + await expect(zugewiesen.getByText("offen")).toBeVisible(); + + // Und „Starten“ öffnet ihn wirklich – danach ist er in Arbeit. + await zugewiesen.getByRole("button", { name: "Starten" }).click(); + await page.waitForURL(/\/laeufe\/[^/]+$/); + await expect(page.getByRole("button", { name: "Lauf abschließen" })).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("/geraete"); + await page.getByRole("link", { name: "NB-001" }).first().click(); + await page.waitForURL(/\/geraete\/[^/]+$/); + + const masse = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + sichtbar: document.documentElement.clientWidth, + })); + expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); + }); +}); + +test.afterAll(async () => { + const vorlagen = await prisma.vorlage.findMany({ + where: { name: { startsWith: "ZZ E2E Geraetelauf" } }, + select: { id: true }, + }); + + /* + * Die Testgeraete und ihre Laeufe raeumen sich nicht von selbst weg: ein + * Tag-Code bleibt absichtlich fuer immer vergeben, das Geraet dahinter muss + * aber nicht stehen bleiben. + */ + /* + * Nur die eigenen: ein „alle Tags ohne Zuordnung“ raeumte auch den Vorrat + * weg, den jemand fuer echte Aufkleber angelegt hat — in einer + * Entwicklungsdatenbank ist das derselbe Bestand. + */ + const geraete = await prisma.geraet.findMany({ + where: { OR: [{ hostname: { startsWith: "ZZ-E2E-" } }, { tagCode: { in: eigeneTags } }] }, + select: { id: true }, + }); + const kennungen = geraete.map((geraet) => geraet.id); + if (kennungen.length > 0) { + const laeufe = await prisma.lauf.findMany({ + where: { geraetId: { in: kennungen } }, + select: { id: true }, + }); + const laufIds = laeufe.map((lauf) => lauf.id); + if (laufIds.length > 0) { + await prisma.kommentar.deleteMany({ where: { laufId: { in: laufIds } } }); + await prisma.schrittergebnis.deleteMany({ where: { laufId: { in: laufIds } } }); + await prisma.protokoll.deleteMany({ + where: { OR: [{ laufId: { in: laufIds } }, { entitaetId: { in: laufIds } }] }, + }); + await prisma.lauf.deleteMany({ where: { id: { in: laufIds } } }); + } + await prisma.protokoll.deleteMany({ where: { entitaetId: { in: kennungen } } }); + await prisma.geraet.deleteMany({ where: { id: { in: kennungen } } }); + } + // Die Testvorlage zuletzt: an ihr hingen gerade noch die Laeufe. + if (vorlagen.length > 0) { + await prisma.vorlage.deleteMany({ where: { id: { in: vorlagen.map((zeile) => zeile.id) } } }); + } + await prisma.$disconnect(); +}); diff --git a/tests/e2e/scannen.spec.ts b/tests/e2e/scannen.spec.ts new file mode 100644 index 0000000..e10c9c9 --- /dev/null +++ b/tests/e2e/scannen.spec.ts @@ -0,0 +1,65 @@ +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; + +/** + * Der Einstieg ueber den Aufkleber (#014). + * + * Die Kamera laesst sich hier nicht pruefen — Chromium bekommt im Testlauf kein + * Bild, und ein gefaelschter Videostrom prueft am Ende die Attrappe. Geprueft + * wird deshalb das, was ohne Kamera trotzdem gelten muss: die Seite ist + * erreichbar, der Knopf in der Huelle fuehrt hin, und der **abgetippte** Code + * bringt den Techniker genauso ans Ziel wie der gescannte. Das ist kein + * Ersatzweg fuer den Notfall, sondern der Weg, den jeder geht, dessen Browser + * keine Kamera freigibt. + */ + +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"]')]); +} + +test.describe("Scannen", () => { + test("ist über den Knopf in der Hülle erreichbar", async ({ page }) => { + await anmelden(page); + await page.getByRole("link", { name: "Scannen" }).first().click(); + await page.waitForURL("**/scannen"); + await expect(page.getByRole("heading", { name: "Scannen" })).toBeVisible(); + }); + + test("öffnet einen abgetippten Code", async ({ page }) => { + await anmelden(page); + await page.goto("/scannen"); + + // Ohne Kamera bleibt das Feld – und es nimmt auch Kleinschreibung an. + await page.getByLabel("Code eintippen").fill("lz-7k3f9q"); + await page.getByRole("button", { name: "Öffnen" }).click(); + + await page.waitForURL("**/t/LZ-7K3F9Q"); + }); + + test("lässt einen unsinnigen Code gar nicht erst abschicken", async ({ page }) => { + await anmelden(page); + await page.goto("/scannen"); + + await page.getByLabel("Code eintippen").fill("PC-001"); + await expect(page.getByRole("button", { name: "Öffnen" })).toBeDisabled(); + await expect(page.getByText(/Die Zeichen 0, O, 1 und I gibt es darin nicht/)).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("/scannen"); + + const masse = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + sichtbar: document.documentElement.clientWidth, + })); + expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar); + }); +}); diff --git a/tests/geraeteseite.test.ts b/tests/geraeteseite.test.ts new file mode 100644 index 0000000..ebaa20b --- /dev/null +++ b/tests/geraeteseite.test.ts @@ -0,0 +1,567 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + Betriebsmodus, + GeraeteTyp, + LaufStatus, + Rolle, + SchrittZustand, + VersionStatus, +} from "@/generated/prisma/enums"; +import { MandantFehler } from "@/server/mandant"; +import { + GeraeteFehler, + geraetAnlegen, + geraetArchivieren, + geraetBearbeiten, + laufFuerGeraetAnlegen, +} from "@/server/services/geraete"; +import { + LaufFehler, + laufAbschliessen, + laufArchivieren, + laufZurBearbeitungOeffnen, +} from "@/server/services/laeufe"; +import { + HISTORIE_HOECHSTENS, + geraetHolen, + geraetNachTagCode, + laeufeDesGeraets, + vorlagenFuerGeraet, +} from "@/server/queries/geraete"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Die Geräteseite und der Einstieg über den Aufkleber (#014). + * + * Festgehalten wird, was ein Techniker vor dem Gerät erlebt: ein Code führt zum + * Gerät oder zum Registrierformular, mehrere Zuweisungen stehen gleichzeitig an + * einem Gerät, „Vorlage zuweisen“ erzeugt einen `OFFEN`en Lauf, und „Starten“ + * macht `IN_ARBEIT` daraus, ohne dass der Lauf aus der Liste verschwindet. + * + * Dazu die Mandantengrenze, und zwar an jeder Tür: ein Gerät eines fremden Kunden + * ist über den Tag-Code genauso unerreichbar wie über seine Kennung, und beide + * antworten wie „gibt es nicht“. + */ + +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 benutzer = await testPrisma.benutzer.create({ + data: { email: "admin@firma.example", name: "Administrator", rolle: Rolle.ADMIN }, + }); + return { eigene, admin: { id: benutzer.id, rolle: Rolle.ADMIN } }; +} + +type VorlagenWunsch = { + readonly name?: string; + readonly kategorie?: string | null; + readonly kundeId?: string | null; + readonly status?: VersionStatus; + readonly archiviert?: boolean; + readonly schritte?: number; + /** Standard sind Pflichtschritte – ohne sie lässt sich ein Lauf sofort abschließen. */ + readonly pflicht?: boolean; + readonly pflichtVariable?: boolean; + /** Legt die Vorgabe in `GeraetetypVorlage` an – damit taucht sie im Schnellstart auf. */ + readonly fuerTyp?: GeraeteTyp; + readonly position?: number; +}; + +/** Eine Vorlage mit einer Version und ein paar Pflichtschritten. */ +async function vorlageAnlegen(wunsch: VorlagenWunsch = {}) { + const vorlage = await testPrisma.vorlage.create({ + data: { + name: wunsch.name ?? "PC-Neuinstallation", + kategorie: wunsch.kategorie ?? null, + kundeId: wunsch.kundeId ?? null, + archiviert: wunsch.archiviert ?? false, + }, + }); + const status = wunsch.status ?? VersionStatus.VEROEFFENTLICHT; + /* + * Erst als Entwurf mit Abschnitten und Schritten, dann veroeffentlichen: eine + * veroeffentlichte Version ist in der Datenbank unveraenderlich, ein Trigger + * weist den nachtraeglichen Abschnitt ab. Genau die Regel, die #009 zugesagt hat. + */ + const version = await testPrisma.vorlagenVersion.create({ + data: { + vorlageId: vorlage.id, + version: 1, + status: VersionStatus.ENTWURF, + abschnitte: { + create: { + titel: "Vorbereitung", + position: 0, + schritte: { + create: Array.from({ length: wunsch.schritte ?? 2 }, (_, index) => ({ + titel: `Schritt ${index + 1}`, + position: index, + pflicht: wunsch.pflicht ?? true, + })), + }, + }, + }, + ...(wunsch.pflichtVariable + ? { + variablen: { + create: { schluessel: "upn", beschriftung: "Anmeldename", pflicht: true, position: 0 }, + }, + } + : {}), + }, + }); + if (status === VersionStatus.VEROEFFENTLICHT) { + await testPrisma.vorlagenVersion.update({ + where: { id: version.id }, + data: { status, veroeffentlichtAm: new Date() }, + }); + } + if (wunsch.fuerTyp) { + await testPrisma.geraetetypVorlage.create({ + data: { geraeteTyp: wunsch.fuerTyp, vorlageId: vorlage.id, position: wunsch.position ?? 0 }, + }); + } + return { vorlage, versionId: version.id }; +} + +describe.runIf(datenbankVerfuegbar)("Geräteseite", () => { + beforeEach(async () => { + await datenbankLeeren(); + delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + }); + + describe("Einstieg über den Tag", () => { + it("findet das Gerät zum Tag-Code, gleich wie er geschrieben steht", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin); + + // Abgetippt wird klein, und ein Handy schreibt von sich aus klein. + expect((await geraetNachTagCode(geraet.tagCode, admin))?.id).toBe(geraet.id); + expect((await geraetNachTagCode(geraet.tagCode.toLowerCase(), admin))?.id).toBe(geraet.id); + expect((await geraetNachTagCode(` ${geraet.tagCode}\n`, admin))?.id).toBe(geraet.id); + }); + + it("liefert einen vorgedruckten Tag mit leerer Registrierung", async () => { + const { admin } = await grundbestand(); + const tag = await geraetAnlegen({ registrieren: false }, admin); + + const zeile = await geraetNachTagCode(tag.tagCode, admin); + expect(zeile?.registriertAm).toBeNull(); + // Ohne Namen bleibt der Code selbst die Anzeige – leer ist die Zeile nie. + expect(zeile?.anzeige).toBe(tag.tagCode); + }); + + it("antwortet auf einen unbekannten Code wie auf einen falschen: mit nichts", async () => { + const { admin } = await grundbestand(); + await geraetAnlegen({ hostname: "PC-001" }, admin); + + expect(await geraetNachTagCode("LZ-ZZZZZZ", admin)).toBeUndefined(); + expect(await geraetNachTagCode("Unsinn", admin)).toBeUndefined(); + expect(await geraetNachTagCode("", admin)).toBeUndefined(); + }); + + it("biegt verwechselbare Zeichen nicht auf ein vorhandenes Gerät", async () => { + const { admin, eigene } = await grundbestand(); + await testPrisma.geraet.create({ + data: { + kundeId: eigene.id, + tagCode: "LZ-LLLLLL", + hostname: "PC-LLL", + registriertAm: new Date(), + }, + }); + + // Das Alphabet kennt kein I. Ein getipptes „I“ auf das „L“ zu biegen führte + // auf ein anderes, vorhandenes Gerät – und wer davor steht, sähe es nie. + expect(await geraetNachTagCode("LZ-IIIIII", admin)).toBeUndefined(); + expect((await geraetNachTagCode("lz-llllll", admin))?.hostname).toBe("PC-LLL"); + }); + }); + + describe("Registrieren", () => { + it("macht aus dem Aufkleber ein Gerät mit Typ, Namen und Besitzer", async () => { + const { admin, eigene } = await grundbestand(); + const tag = await geraetAnlegen({ registrieren: false }, admin); + const person = await testPrisma.mitarbeiter.create({ + data: { + kundeId: eigene.id, + vorname: "Ada", + nachname: "Lovelace", + anzeigename: "Ada Lovelace", + abteilung: "Vertrieb", + }, + }); + + await geraetBearbeiten( + tag.id, + { + registrieren: true, + typ: GeraeteTyp.NOTEBOOK, + hostname: "NB-ADA", + seriennummer: "SN-4711", + mitarbeiterId: person.id, + }, + admin, + ); + + const zeile = await geraetNachTagCode(tag.tagCode, admin); + expect(zeile?.registriertAm).not.toBeNull(); + expect(zeile?.anzeige).toBe("NB-ADA"); + expect(zeile?.typ).toBe(GeraeteTyp.NOTEBOOK); + expect(zeile?.besitzerName).toBe("Ada Lovelace"); + // Die Abteilung steht im Kopf der Geräteseite neben dem Besitzer. + expect(zeile?.besitzerAbteilung).toBe("Vertrieb"); + // Der Tag-Code ändert sich dabei nicht. + expect(zeile?.tagCode).toBe(tag.tagCode); + }); + + it("lässt einen Tag unregistriert, wenn kein Name eingetragen wurde", async () => { + const { admin } = await grundbestand(); + const tag = await geraetAnlegen({ registrieren: false }, admin); + + await expect( + geraetBearbeiten(tag.id, { registrieren: true, typ: GeraeteTyp.PC }, admin), + ).rejects.toThrow(GeraeteFehler); + expect((await geraetNachTagCode(tag.tagCode, admin))?.registriertAm).toBeNull(); + }); + }); + + describe("Zugewiesen und Historie", () => { + it("zeigt mehrere Zuweisungen an einem Gerät gleichzeitig", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin); + const ersteVorlage = await vorlageAnlegen({ name: "Patchday" }); + const zweiteVorlage = await vorlageAnlegen({ name: "Rechnerwechsel" }); + + await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: ersteVorlage.versionId }, admin); + await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: zweiteVorlage.versionId }, admin); + + const laeufe = await laeufeDesGeraets(geraet.id, admin); + expect(laeufe.zugewiesen).toHaveLength(2); + expect(laeufe.zugewiesen.every((zeile) => zeile.status === LaufStatus.OFFEN)).toBe(true); + expect(laeufe.historie).toHaveLength(0); + }); + + it("zeigt den Fortschritt: erledigt und nicht zutreffend zählen, fehlgeschlagen nicht", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + const { versionId } = await vorlageAnlegen({ schritte: 4 }); + const lauf = await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin); + + const ergebnisse = await testPrisma.schrittergebnis.findMany({ + where: { laufId: lauf.id }, + orderBy: { vorlagenSchrittId: "asc" }, + }); + const zustaende = [ + SchrittZustand.ERLEDIGT, + SchrittZustand.NICHT_ZUTREFFEND, + SchrittZustand.FEHLGESCHLAGEN, + SchrittZustand.OFFEN, + ]; + for (const [index, ergebnis] of ergebnisse.entries()) { + await testPrisma.schrittergebnis.update({ + where: { id: ergebnis.id }, + data: { zustand: zustaende[index] ?? SchrittZustand.OFFEN, notiz: "Grund" }, + }); + } + + const laeufe = await laeufeDesGeraets(geraet.id, admin); + expect(laeufe.zugewiesen[0]?.anzahlSchritte).toBe(4); + expect(laeufe.zugewiesen[0]?.anzahlErledigt).toBe(2); + }); + + it("trennt Abgeschlossenes und Archiviertes von der Arbeit, die wartet", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + // Ohne Pflichtschritte lässt sich ein Lauf sofort abschließen; geprüft wird + // hier die Aufteilung des Lesemodells, nicht die Abschlussbedingung. + const { versionId } = await vorlageAnlegen({ pflicht: false }); + + const wartend = await laufFuerGeraetAnlegen( + { geraetId: geraet.id, vorlagenVersionId: versionId }, + admin, + ); + const fertig = await laufFuerGeraetAnlegen( + { geraetId: geraet.id, vorlagenVersionId: versionId }, + admin, + ); + const weggelegt = await laufFuerGeraetAnlegen( + { geraetId: geraet.id, vorlagenVersionId: versionId }, + admin, + ); + // Über die Dienste, nicht an ihnen vorbei: Abschluss und Archiv setzen + // Zeitstempel, die die Datenbank auch verlangt. + await laufAbschliessen(fertig.id, admin); + await laufAbschliessen(weggelegt.id, admin); + await laufArchivieren(weggelegt.id, admin); + + const laeufe = await laeufeDesGeraets(geraet.id, admin); + expect(laeufe.zugewiesen.map((zeile) => zeile.id)).toEqual([wartend.id]); + expect(laeufe.historie.map((zeile) => zeile.id).sort()).toEqual([fertig.id, weggelegt.id].sort()); + expect(laeufe.historieGekuerzt).toBe(false); + }); + + it("kappt die Historie und sagt, dass sie gekappt ist", async () => { + const { admin, eigene } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + const { versionId } = await vorlageAnlegen(); + + // Direkt geschrieben: geprüft wird die Kappung des Lesemodells, nicht das + // Anlegen – und Zuweisungen bleiben bewusst ungekappt. + await testPrisma.lauf.createMany({ + data: Array.from({ length: HISTORIE_HOECHSTENS + 3 }, (_, index) => ({ + vorlagenVersionId: versionId, + titel: `Alter Lauf ${index + 1}`, + kundeId: eigene.id, + geraetId: geraet.id, + status: LaufStatus.ABGESCHLOSSEN, + // Die Datenbank verlangt beide Zeitstempel, sobald ein Lauf nicht mehr + // offen ist (CHECK `Lauf_begonnen_mit_zeitpunkt`). + gestartetAm: new Date(2026, 0, index + 1), + abgeschlossenAm: new Date(2026, 0, index + 1), + })), + }); + + const laeufe = await laeufeDesGeraets(geraet.id, admin); + expect(laeufe.historie).toHaveLength(HISTORIE_HOECHSTENS); + expect(laeufe.historieGekuerzt).toBe(true); + // Das Jüngste zuerst – die Historie liest sich von oben. + expect(laeufe.historie[0]?.titel).toBe(`Alter Lauf ${HISTORIE_HOECHSTENS + 3}`); + }); + }); + + describe("Vorlage zuweisen", () => { + it("erzeugt einen Lauf in OFFEN, der unter „Zugewiesen“ erscheint", async () => { + const { admin, eigene } = await grundbestand(); + const person = await testPrisma.mitarbeiter.create({ + data: { kundeId: eigene.id, vorname: "Ada", nachname: "Lovelace", anzeigename: "Ada Lovelace" }, + }); + const geraet = await geraetAnlegen( + { hostname: "NB-ADA", typ: GeraeteTyp.NOTEBOOK, mitarbeiterId: person.id }, + admin, + ); + const { versionId } = await vorlageAnlegen({ name: "Rechnerwechsel" }); + const faelligAm = new Date(2026, 9, 30); + + const lauf = await laufFuerGeraetAnlegen( + { geraetId: geraet.id, vorlagenVersionId: versionId, zugewiesenAnId: admin.id, faelligAm }, + admin, + ); + + expect(lauf.status).toBe(LaufStatus.OFFEN); + expect(lauf.gestartetAm).toBeNull(); + expect(lauf.geraetId).toBe(geraet.id); + // Kunde und Besitzer kommen vom Gerät, nicht aus dem Formular. + expect(lauf.kundeId).toBe(eigene.id); + expect(lauf.mitarbeiterId).toBe(person.id); + expect(lauf.zugewiesenAnId).toBe(admin.id); + expect(lauf.faelligAm?.getTime()).toBe(faelligAm.getTime()); + // Der Titel kommt aus Vorlage, Gerät und Datum – nicht aus der Oberfläche. + expect(lauf.titel).toContain("Rechnerwechsel"); + expect(lauf.titel).toContain("NB-ADA"); + + const laeufe = await laeufeDesGeraets(geraet.id, admin); + expect(laeufe.zugewiesen.map((zeile) => zeile.id)).toEqual([lauf.id]); + expect(laeufe.zugewiesen[0]?.zugewiesenAn).toBe("Administrator"); + expect(laeufe.zugewiesen[0]?.anzahlSchritte).toBe(2); + }); + + it("„Starten“ setzt IN_ARBEIT, ohne den Lauf aus der Liste zu nehmen", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + const { versionId } = await vorlageAnlegen(); + const lauf = await laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin); + + const gestartet = await laufZurBearbeitungOeffnen(lauf.id, admin); + expect(gestartet.status).toBe(LaufStatus.IN_ARBEIT); + expect(gestartet.gestartetAm).not.toBeNull(); + + const laeufe = await laeufeDesGeraets(geraet.id, admin); + expect(laeufe.zugewiesen.map((zeile) => zeile.status)).toEqual([LaufStatus.IN_ARBEIT]); + expect(laeufe.historie).toHaveLength(0); + }); + + it("weist eine Vorlage mit Pflichtangaben ab, statt sie halb zu starten", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + const { versionId } = await vorlageAnlegen({ pflichtVariable: true }); + + // Genau deshalb führt der Schnellstart solche Vorlagen in den Start-Dialog: + // dort kann jemand die Angabe machen. + await expect( + laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin), + ).rejects.toThrow(/Diese Angaben fehlen/u); + expect(await testPrisma.lauf.count()).toBe(0); + }); + + it("nimmt an einem archivierten Gerät keine neue Arbeit auf", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + const { versionId } = await vorlageAnlegen(); + await geraetArchivieren(geraet.id, true, admin); + + await expect( + laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin), + ).rejects.toThrow(LaufFehler); + }); + + it("weist einen Entwurf ab – ein Lauf hängt nur an einer veröffentlichten Version", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001" }, admin); + const { versionId } = await vorlageAnlegen({ status: VersionStatus.ENTWURF }); + + await expect( + laufFuerGeraetAnlegen({ geraetId: geraet.id, vorlagenVersionId: versionId }, admin), + ).rejects.toThrow(LaufFehler); + }); + }); + + describe("Schnellstart und Vorlagenauswahl", () => { + it("zeigt im Schnellstart nur die Vorgaben dieses Gerätetyps, in ihrer Reihenfolge", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin); + await vorlageAnlegen({ name: "Zweiter Griff", fuerTyp: GeraeteTyp.PC, position: 1 }); + await vorlageAnlegen({ name: "Erster Griff", fuerTyp: GeraeteTyp.PC, position: 0 }); + await vorlageAnlegen({ name: "Nur für Server", fuerTyp: GeraeteTyp.SERVER }); + await vorlageAnlegen({ name: "Ohne Vorgabe" }); + + const vorlagen = await vorlagenFuerGeraet(geraet, admin); + expect(vorlagen.schnellstart.map((eintrag) => eintrag.name)).toEqual(["Erster Griff", "Zweiter Griff"]); + // „Vorlage zuweisen“ bietet alles an, was startbar ist. + expect(vorlagen.alle.map((eintrag) => eintrag.name).sort()).toEqual([ + "Erster Griff", + "Nur für Server", + "Ohne Vorgabe", + "Zweiter Griff", + ]); + }); + + it("nimmt weder Entwürfe noch archivierte Vorlagen", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin); + await vorlageAnlegen({ name: "Fertig", fuerTyp: GeraeteTyp.PC }); + await vorlageAnlegen({ name: "Noch Entwurf", fuerTyp: GeraeteTyp.PC, status: VersionStatus.ENTWURF }); + await vorlageAnlegen({ name: "Weggelegt", fuerTyp: GeraeteTyp.PC, archiviert: true }); + + const vorlagen = await vorlagenFuerGeraet(geraet, admin); + expect(vorlagen.schnellstart.map((eintrag) => eintrag.name)).toEqual(["Fertig"]); + expect(vorlagen.alle.map((eintrag) => eintrag.name)).toEqual(["Fertig"]); + }); + + it("merkt an einer Vorlage, dass sie Pflichtangaben verlangt", async () => { + const { admin } = await grundbestand(); + const geraet = await geraetAnlegen({ hostname: "PC-001", typ: GeraeteTyp.PC }, admin); + await vorlageAnlegen({ name: "Mit Frage", fuerTyp: GeraeteTyp.PC, pflichtVariable: true }); + await vorlageAnlegen({ name: "Ohne Frage", fuerTyp: GeraeteTyp.PC, position: 1 }); + + const vorlagen = await vorlagenFuerGeraet(geraet, admin); + const mitFrage = vorlagen.schnellstart.find((eintrag) => eintrag.name === "Mit Frage"); + const ohneFrage = vorlagen.schnellstart.find((eintrag) => eintrag.name === "Ohne Frage"); + expect(mitFrage?.pflichtangaben).toBe(true); + expect(ohneFrage?.pflichtangaben).toBe(false); + }); + }); + + /** + * Die Mandantengrenze an jeder Tür der Geräteseite. Ein fremdes Gerät ist über + * den Tag-Code genauso unerreichbar wie über seine Kennung — und beide + * antworten, als gäbe es es nicht. + */ + describe("Mandantensicht", () => { + async function zweiKunden() { + await grundbestand(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" } }); + + const benutzer = await testPrisma.benutzer.create({ + data: { email: "t@firma.example", name: "Technikerin", rolle: Rolle.TECHNIKER }, + }); + await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } }); + + const meinGeraet = await testPrisma.geraet.create({ + data: { + kundeId: meiner.id, + tagCode: "LZ-AAAAAA", + hostname: "PC-MEIN", + typ: GeraeteTyp.PC, + registriertAm: new Date(), + }, + }); + const fremdesGeraet = await testPrisma.geraet.create({ + data: { + kundeId: fremder.id, + tagCode: "LZ-BBBBBB", + hostname: "PC-FREMD", + typ: GeraeteTyp.PC, + registriertAm: new Date(), + }, + }); + + return { + meiner, + fremder, + meinGeraet, + fremdesGeraet, + technikerin: { id: benutzer.id, rolle: Rolle.TECHNIKER }, + }; + } + + it("beantwortet ein fremdes Gerät über Tag-Code und Kennung wie ein unbekanntes", async () => { + const { fremdesGeraet, meinGeraet, technikerin } = await zweiKunden(); + + expect(await geraetNachTagCode("LZ-BBBBBB", technikerin)).toBeUndefined(); + expect(await geraetHolen(fremdesGeraet.id, technikerin)).toBeUndefined(); + // Das eigene bleibt erreichbar – sonst prüfte der Test nur eine leere Sicht. + expect((await geraetNachTagCode("lz-aaaaaa", technikerin))?.id).toBe(meinGeraet.id); + }); + + it("zeigt die Läufe eines fremden Geräts nicht", async () => { + const { fremder, fremdesGeraet, technikerin } = await zweiKunden(); + const { versionId } = await vorlageAnlegen(); + await testPrisma.lauf.create({ + data: { + vorlagenVersionId: versionId, + titel: "Geheime Arbeit", + kundeId: fremder.id, + geraetId: fremdesGeraet.id, + }, + }); + + const laeufe = await laeufeDesGeraets(fremdesGeraet.id, technikerin); + expect(laeufe.zugewiesen).toHaveLength(0); + expect(laeufe.historie).toHaveLength(0); + }); + + it("bietet zu einem fremden Gerät keine Vorlagen an", async () => { + const { fremder, technikerin } = await zweiKunden(); + await vorlageAnlegen({ name: "Für alle", fuerTyp: GeraeteTyp.PC }); + + const vorlagen = await vorlagenFuerGeraet({ typ: GeraeteTyp.PC, kundeId: fremder.id }, technikerin); + expect(vorlagen.schnellstart).toHaveLength(0); + expect(vorlagen.alle).toHaveLength(0); + }); + + it("bietet an einem Gerät nur Vorlagen an, die zu seinem Kunden gehören", async () => { + const { meiner, fremder, meinGeraet, technikerin } = await zweiKunden(); + await vorlageAnlegen({ name: "Für alle" }); + await vorlageAnlegen({ name: "Nur für meinen", kundeId: meiner.id }); + await vorlageAnlegen({ name: "Nur für fremde", kundeId: fremder.id }); + + const vorlagen = await vorlagenFuerGeraet(meinGeraet, technikerin); + expect(vorlagen.alle.map((eintrag) => eintrag.name).sort()).toEqual(["Für alle", "Nur für meinen"]); + }); + + it("lässt an einem fremden Gerät keinen Lauf anlegen", async () => { + const { fremdesGeraet, technikerin } = await zweiKunden(); + const { versionId } = await vorlageAnlegen(); + + await expect( + laufFuerGeraetAnlegen({ geraetId: fremdesGeraet.id, vorlagenVersionId: versionId }, technikerin), + ).rejects.toThrow(MandantFehler); + expect(await testPrisma.lauf.count()).toBe(0); + }); + }); +}); diff --git a/tests/qr.test.ts b/tests/qr.test.ts new file mode 100644 index 0000000..9c7377b --- /dev/null +++ b/tests/qr.test.ts @@ -0,0 +1,240 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { chromium } from "@playwright/test"; +import { ETIKETT_QR_FLAECHE, ETIKETT_RAHMEN } from "@/components/tags/etikett"; +import { QR_ERSATZ_HERKUNFT, QR_FEHLERKORREKTUR, QrFehler, qrSvg, tagAdresse } from "@/lib/qr"; + +/** + * QR-Codes und Etiketten (#014). + * + * Zwei Zusagen aus dem Auftrag stehen hier fest: + * + * 1. Im QR steht **genau** `{APP_URL}/t/{tagCode}` und nichts weiter — kein + * Hostname, kein Besitzer, keine Seriennummer. + * 2. Ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem Decoder wieder gelesen. + * Das ist der Test, der zaehlt: ein QR, der auf dem Bildschirm huebsch + * aussieht und am Etikettendrucker nicht mehr lesbar ist, waere genau der + * Fehler, den niemand vor dem ersten Klebeband merkt. + */ + +const APP_URL = "https://laufzettel.example.com"; +const TAGCODE = "LZ-7K3F9Q"; + +let vorherigeAppUrl: string | undefined; + +beforeEach(() => { + vorherigeAppUrl = process.env["APP_URL"]; + process.env["APP_URL"] = APP_URL; +}); + +afterEach(() => { + if (vorherigeAppUrl === undefined) delete process.env["APP_URL"]; + else process.env["APP_URL"] = vorherigeAppUrl; +}); + +describe("Adresse im QR", () => { + it("ist genau {APP_URL}/t/{tagCode}", () => { + expect(tagAdresse(TAGCODE)).toBe(`${APP_URL}/t/${TAGCODE}`); + }); + + it("verschluckt den Unterpfad aus APP_URL nicht", () => { + /* + * Der Reviewbefund zu #014, und der teuerste von allen: steht die Anwendung + * unter `https://firma.de/laufzettel`, dann zeigte der gedruckte Aufkleber + * auf `https://firma.de/t/…` — und dort steht nichts. Ein Etikett laesst + * sich nicht nachtraeglich berichtigen; es klebt. + */ + process.env["APP_URL"] = "https://firma.de/laufzettel"; + expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`); + + // Mit Schrägstrich am Ende derselbe Weg, dasselbe Ergebnis. + process.env["APP_URL"] = "https://firma.de/laufzettel/"; + expect(tagAdresse(TAGCODE)).toBe(`https://firma.de/laufzettel/t/${TAGCODE}`); + + process.env["APP_URL"] = "https://intern.example.com/"; + expect(tagAdresse(TAGCODE)).toBe(`https://intern.example.com/t/${TAGCODE}`); + }); + + it("fällt ohne APP_URL auf die mitgegebene Ersatzherkunft zurück", () => { + delete process.env["APP_URL"]; + expect(tagAdresse(TAGCODE, "http://127.0.0.1:3300")).toBe(`http://127.0.0.1:3300/t/${TAGCODE}`); + expect(tagAdresse(TAGCODE)).toBe(`${QR_ERSATZ_HERKUNFT}/t/${TAGCODE}`); + }); + + it("weist alles ab, was nicht die Form eines Tag-Codes hat", () => { + // Der Code landet in der Adresse und damit im QR jedes gedruckten + // Aufklebers. Ein ungeprueftes Zeichen waere dort nicht mehr einzufangen. + expect(() => tagAdresse("LZ-0O1I")).toThrow(QrFehler); + expect(() => tagAdresse("../../etc/passwd")).toThrow(QrFehler); + expect(() => tagAdresse("lz-7k3f9q")).toThrow(QrFehler); + }); +}); + +describe("QR-SVG", () => { + it("enthält nur Geometrie – keine Personendaten, keine Schrift, kein Skript", async () => { + const svg = await qrSvg(TAGCODE); + expect(svg).toMatch(/^ { + /* + * Geprueft an der Kantenlaenge in Modulen: dieselbe Nutzlast braucht mit + * hoeherer Fehlerkorrektur eine groessere Version. 29 Module (Version 3) plus + * zweimal die Ruhezone von 4 ergeben 37 — mit "Q" oder "H" waeren es mehr, + * mit "L" weniger. + * + * Die Adresse ist hier 42 Zeichen lang und damit **genau** die Grenze von + * Version 3 bei M. Eine laengere APP_URL ist deshalb kein Fehler, macht den + * Code aber eine Version groesser und die Module kleiner — bei 300 dpi bleibt + * selbst Version 6 noch weit ueber der Lesbarkeitsgrenze (siehe unten). + */ + expect(QR_FEHLERKORREKTUR).toBe("M"); + expect(`${APP_URL}/t/${TAGCODE}`).toHaveLength(42); + const svg = await qrSvg(TAGCODE); + expect(svg).toContain('viewBox="0 0 37 37"'); + }); + + it("trägt eine feste Kantenlänge nur, wenn eine verlangt ist", async () => { + // Ohne Angabe fuellt das SVG seinen Platz – genau das braucht ein Etikett, + // das in Millimetern gesetzt ist. + expect(await qrSvg(TAGCODE)).not.toContain("width="); + expect(await qrSvg(TAGCODE, { breite: 256 })).toContain('width="256" height="256"'); + }); +}); + +/* + * Die Rasterung, ausgerechnet. + * + * CSS rechnet 1 mm = 96 / 25.4 px. Ein Etikett von 62 × 29 mm ist im Browser + * also 62 · 96 / 25.4 = 234,33 × 109,61 CSS-Pixel gross. + * + * 300 dpi bedeutet 300 / 96 = 3,125 Geraetepixel je CSS-Pixel. Mit diesem + * `deviceScaleFactor` kommt der Screenshot mit + * 62 / 25,4 · 300 = 732,28 × 342,52 Geraetepixeln heraus — also genau das + * Bild, das ein 300-dpi-Etikettendrucker aufs Papier bringt. + * + * Der QR selbst ist 25 mm breit, das sind 295,3 Geraetepixel fuer 37 Module, + * also knapp 8 Pixel je Modul. Kein Decoder hat damit Muehe — und genau + * deshalb steht die Zahl hier: faellt sie unter drei Pixel je Modul, wird es + * knapp, und dieser Test ist die Stelle, an der das auffaellt. + */ +const MM_JE_ZOLL = 25.4; +const CSS_PX_JE_ZOLL = 96; +const DPI = 300; + +/** Liest `62` aus `w-[62mm]` – die Masse stehen nur in `etikett.tsx`. */ +function mmAus(klassen: string, praefix: string): number { + const treffer = klassen.match(new RegExp(`(?:^| )${praefix}-\\[([0-9.]+)mm\\]`)); + if (!treffer?.[1]) throw new Error(`In „${klassen}“ steht kein ${praefix}-[…mm].`); + return Number(treffer[1]); +} + +describe("Gerastertes Etikett", () => { + it("wird bei 300 dpi im Format 62 × 29 mm von einem Decoder zurückgelesen", async () => { + const breiteMm = mmAus(ETIKETT_RAHMEN.etikett, "w"); + const hoeheMm = mmAus(ETIKETT_RAHMEN.etikett, "h"); + const randMm = mmAus(ETIKETT_RAHMEN.etikett, "p"); + const qrMm = mmAus(ETIKETT_QR_FLAECHE.etikett, "size"); + expect([breiteMm, hoeheMm]).toEqual([62, 29]); + + const svg = await qrSvg(TAGCODE); + const skalierung = DPI / CSS_PX_JE_ZOLL; + + const browser = await chromium.launch(); + try { + const seite = await browser.newPage({ + viewport: { + width: Math.ceil((breiteMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL), + height: Math.ceil((hoeheMm / MM_JE_ZOLL) * CSS_PX_JE_ZOLL), + }, + deviceScaleFactor: skalierung, + }); + + /* + * Nachgebaut und nicht die Komponente gerendert: `Etikett` traegt seine + * Masse in Tailwind-Klassen, und ohne den Build gibt es kein Stylesheet + * dazu. Die Millimeter kommen deshalb aus denselben Klassen + * (`mmAus` oben) — schrumpft das Etikett dort, schrumpft es hier mit. + */ + await seite.setContent(` + +
    +
    ${svg}
    +
    ${TAGCODE}
    +
    + `); + + const etikett = seite.locator("#etikett"); + const bild = await etikett.screenshot({ type: "png" }); + + // Die Rechnung von oben, gegen das tatsaechliche Bild geprueft. + const erwarteteBreite = Math.round((breiteMm / MM_JE_ZOLL) * DPI); + const masse = await seite.evaluate( + (datenAdresse) => + new Promise<{ breite: number; hoehe: number }>((fertig, gescheitert) => { + const bildElement = new Image(); + bildElement.onload = () => + fertig({ breite: bildElement.naturalWidth, hoehe: bildElement.naturalHeight }); + bildElement.onerror = () => gescheitert(new Error("Das Bild ließ sich nicht laden.")); + bildElement.src = datenAdresse; + }), + `data:image/png;base64,${bild.toString("base64")}`, + ); + // Ein Pixel Spiel: der Screenshot rundet auf ganze Geraetepixel. + expect(Math.abs(masse.breite - erwarteteBreite)).toBeLessThanOrEqual(2); + + /* + * Zurueckgelesen mit `barcode-detector` im Seitenkontext. Das WASM kommt + * als `wasmBinary` aus `node_modules` mit — ohne diese Uebergabe holte + * es sich das Modul von einem CDN, und ein Test, der das Netz braucht, + * ist kein Test. + */ + const require = createRequire(import.meta.url); + const ponyfill = require.resolve("barcode-detector/ponyfill"); + const wasm = readFileSync( + require.resolve("zxing-wasm/reader/zxing_reader.wasm", { paths: [ponyfill] }), + ); + await seite.addScriptTag({ + path: ponyfill.replace(/dist[/\\](es|cjs)[/\\]ponyfill\.js$/, "dist/iife/ponyfill.js"), + }); + + const gelesen = await seite.evaluate( + async ({ pngBase64, wasmBase64 }) => { + const bytes = (text: string): Uint8Array => + Uint8Array.from(atob(text), (zeichen) => zeichen.charCodeAt(0)); + const api = (globalThis as unknown as { BarcodeDetectionAPI: BarcodeApi }).BarcodeDetectionAPI; + api.prepareZXingModule({ overrides: { wasmBinary: bytes(wasmBase64) } }); + const decoder = new api.BarcodeDetector({ formats: ["qr_code"] }); + const treffer = await decoder.detect( + new Blob([bytes(pngBase64) as BlobPart], { type: "image/png" }), + ); + return treffer.map((eintrag) => eintrag.rawValue); + }, + { pngBase64: bild.toString("base64"), wasmBase64: wasm.toString("base64") }, + ); + + // Genau ein Code, und genau die Adresse – nichts sonst steht darin. + expect(gelesen).toEqual([`${APP_URL}/t/${TAGCODE}`]); + } finally { + await browser.close(); + } + }, 120_000); +}); + +/** Nur das, was der Test vom Ponyfill im Seitenkontext braucht. */ +type BarcodeApi = { + prepareZXingModule: (optionen: { overrides: { wasmBinary: Uint8Array } }) => void; + BarcodeDetector: new (optionen?: { formats?: readonly string[] }) => { + detect: (bild: Blob) => Promise; + }; +}; diff --git a/tests/scanner.test.ts b/tests/scanner.test.ts new file mode 100644 index 0000000..a80b027 --- /dev/null +++ b/tests/scanner.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { tagCodeAusFund } from "@/lib/tagcode"; + +/** + * Was ein gescannter Code bedeutet (#014). + * + * Die Kamera findet alles Moegliche: den eigenen Aufkleber, den QR-Code auf + * einer Paketmarke, einen Link ins Intranet. Diese Entscheidung liegt deshalb in + * einer reinen Funktion und nicht in der Kamerakomponente — dort waere sie nur + * von Hand pruefbar, und zwar genau einmal. + */ +describe("Ein Fund des Scanners", () => { + it("nimmt die Adresse vom Aufkleber", () => { + expect(tagCodeAusFund("https://laufzettel.firma.de/t/LZ-7K3F9Q")).toBe("LZ-7K3F9Q"); + }); + + it("nimmt sie auch von einem anderen Host", () => { + /* + * Der Aufkleber klebt jahrelang. Zieht die Anwendung um oder steht ein + * zweiter Name davor, waere ein Vergleich mit APP_URL der Grund, warum der + * Scanner plötzlich nichts mehr erkennt — obwohl der Code stimmt. + */ + expect(tagCodeAusFund("http://10.0.0.5:3000/t/LZ-7K3F9Q")).toBe("LZ-7K3F9Q"); + }); + + it("nimmt den blanken Code, auch klein geschrieben", () => { + expect(tagCodeAusFund("LZ-7K3F9Q")).toBe("LZ-7K3F9Q"); + expect(tagCodeAusFund(" lz-7k3f9q ")).toBe("LZ-7K3F9Q"); + }); + + it("weist alles andere ab, statt zu raten", () => { + for (const fund of [ + "", + " ", + "https://laufzettel.firma.de/geraete/abc", + "https://laufzettel.firma.de/t/", + "https://laufzettel.firma.de/t/LZ-7K3F9Q/extra", + // Verwechselbare Zeichen gibt es im Alphabet nicht – ein Code mit O, I + // oder 1 ist keiner, auch wenn er wie einer aussieht. + "LZ-7K3F9O", + "LZ-ABCDE", + "nur text", + ]) { + expect(tagCodeAusFund(fund), fund).toBeNull(); + } + }); + + it("verkraftet eine prozentkodierte Adresse", () => { + expect(tagCodeAusFund("https://laufzettel.firma.de/t/LZ%2D7K3F9Q")).toBe("LZ-7K3F9Q"); + }); +}); diff --git a/tests/shell.test.ts b/tests/shell.test.ts index ddc5315..2127635 100644 --- a/tests/shell.test.ts +++ b/tests/shell.test.ts @@ -143,7 +143,15 @@ describe("wertMaskieren fuer PowerShell", () => { }); describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => { - it("kommt als genau ein Argument an und fuehrt nichts aus", () => { + /* + * Eine Minute statt der fuenf Sekunden, die Vitest sonst gibt: liegt kein + * `pwsh` auf der Maschine, laeuft dieser Test in einem Container, und der + * startet erst. Unter Last — ein Ende-zu-Ende-Lauf nebenher genuegt — reichte + * die Vorgabe nicht, und der Test kippte um, ohne dass an der Maskierung + * etwas falsch war. Ein Test, der gelegentlich grundlos rot ist, wird bald + * uebersehen. + */ + it("kommt als genau ein Argument an und fuehrt nichts aus", { timeout: 90_000 }, () => { for (const wert of [BOESARTIG, TYPOGRAFISCH]) { const maskiert = wertMaskieren(wert, "powershell"); const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`; diff --git a/tests/sicherheitsrichtlinie.test.ts b/tests/sicherheitsrichtlinie.test.ts index 72d5bcf..14487e1 100644 --- a/tests/sicherheitsrichtlinie.test.ts +++ b/tests/sicherheitsrichtlinie.test.ts @@ -29,8 +29,16 @@ function teile(richtlinie: string): Map { describe("Ausgelieferter Stand", () => { const richtlinie = sicherheitsrichtlinie(NONCE, false); - it("erlaubt kein unsafe-eval", () => { - expect(richtlinie).not.toContain("unsafe-eval"); + it("erlaubt kein unsafe-eval – wohl aber WebAssembly", () => { + /* + * `wasm-unsafe-eval` enthaelt die Zeichenkette „unsafe-eval“, ist aber etwas + * ganz anderes: es erlaubt nur das Uebersetzen von WebAssembly, kein `eval` + * auf JavaScript. Der Scanner (#014) braucht es, sonst lehnt Chrome den + * QR-Leser ab. Geprueft wird deshalb auf die **Marke**, nicht auf das Wort. + */ + const script = teile(richtlinie).get("script-src") ?? ""; + expect(script.split(" ")).not.toContain("'unsafe-eval'"); + expect(script.split(" ")).toContain("'wasm-unsafe-eval'"); }); it("erlaubt keine WebSocket-Verbindungen", () => { @@ -38,7 +46,9 @@ describe("Ausgelieferter Stand", () => { }); it("bindet Skripte an die Nonce dieser Anfrage", () => { - expect(teile(richtlinie).get("script-src")).toBe(`'self' 'nonce-${NONCE}' 'strict-dynamic'`); + expect(teile(richtlinie).get("script-src")).toBe( + `'self' 'nonce-${NONCE}' 'strict-dynamic' 'wasm-unsafe-eval'`, + ); }); it("verbietet das Einbetten in fremde Seiten und Objekte", () => { @@ -58,7 +68,7 @@ describe("Entwicklungsmodus", () => { const richtlinie = sicherheitsrichtlinie(NONCE, true); it("erlaubt unsafe-eval, sonst hydriert React nicht", () => { - expect(teile(richtlinie).get("script-src")).toContain("'unsafe-eval'"); + expect(teile(richtlinie).get("script-src")?.split(" ")).toContain("'unsafe-eval'"); }); it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => { @@ -130,7 +140,10 @@ describe("Die Entscheidung im Proxy selbst", () => { proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"), ); expect(richtlinie).toBeTruthy(); - expect(richtlinie).not.toContain("unsafe-eval"); + // Auf die Marke geprueft, nicht auf das Wort: `wasm-unsafe-eval` enthaelt es + // und ist trotzdem keine Lockerung fuer JavaScript (#014). + expect(richtlinie).not.toContain(" 'unsafe-eval'"); + expect(richtlinie).toContain("'wasm-unsafe-eval'"); expect(richtlinie).toContain("connect-src 'self';"); expect(richtlinie).toContain("'strict-dynamic'"); }); diff --git a/tests/tags.test.ts b/tests/tags.test.ts new file mode 100644 index 0000000..9236d03 --- /dev/null +++ b/tests/tags.test.ts @@ -0,0 +1,280 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Betriebsmodus, Rolle } from "@/generated/prisma/enums"; +import { Etikett, etikettenPfad } from "@/components/tags/etikett"; +import { istTagCode } from "@/lib/tagcode"; +import { etikettenZuCodes, unregistrierteTags } from "@/server/queries/tags"; +import { + TAGS_STAPEL_HOECHSTENS, + TAGS_STAPEL_STANDARD, + TagsFehler, + tagsErzeugen, +} from "@/server/services/tags"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; + +/** + * Tag-Stapel und Etikettenzeilen (#014). + * + * Festgehalten wird, was der Auftrag verspricht: ein Stapel legt N Tags an, alle + * unregistriert, alle mit verschiedenen Codes, alle im Vergabebuch. Dazu die + * Mandantensicht der Lesemodelle — ein Etikett eines fremden Kunden darf + * niemand drucken, und ein Bogen ist Papier, das man nicht zurueckrufen kann. + */ + +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 benutzer = await testPrisma.benutzer.create({ + data: { email: "admin@firma.example", name: "Administrator", rolle: Rolle.ADMIN }, + }); + return { eigene, admin: { id: benutzer.id, rolle: Rolle.ADMIN } }; +} + +describe.runIf(datenbankVerfuegbar)("Tags", () => { + beforeEach(async () => { + await datenbankLeeren(); + delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; + }); + + describe("Stapel erzeugen", () => { + it("legt N unregistrierte Tags mit verschiedenen Codes im Vergabebuch an", async () => { + const { admin, eigene } = await grundbestand(); + const stapel = await tagsErzeugen(7, null, admin); + + expect(stapel.tagCodes).toHaveLength(7); + expect(stapel.kundeId).toBe(eigene.id); + // Jeder Code ist ein Code, und keiner kommt zweimal. + expect(stapel.tagCodes.every(istTagCode)).toBe(true); + expect(new Set(stapel.tagCodes).size).toBe(7); + + const geraete = await testPrisma.geraet.findMany({ + where: { tagCode: { in: [...stapel.tagCodes] } }, + }); + expect(geraete).toHaveLength(7); + for (const geraet of geraete) { + // Ein Tag ohne Zuordnung: kein Name, kein Besitzer, nicht registriert. + expect(geraet.registriertAm).toBeNull(); + expect(geraet.hostname).toBeNull(); + expect(geraet.bezeichnung).toBeNull(); + expect(geraet.mitarbeiterId).toBeNull(); + expect(geraet.kundeId).toBe(eigene.id); + } + + // Das Vergabebuch ist der Grund, dass ein Code nie zurueckkommt. + const vergaben = await testPrisma.tagCodeVergabe.findMany({ + where: { tagCode: { in: [...stapel.tagCodes] } }, + }); + expect(vergaben).toHaveLength(7); + + // Jeder Tag steht mit seinem Erzeuger im Protokoll. + const protokoll = await testPrisma.protokoll.findMany({ where: { entitaet: "Geraet" } }); + expect(protokoll).toHaveLength(7); + expect(protokoll.every((zeile) => zeile.benutzerId === admin.id)).toBe(true); + }); + + it("hält die Obergrenze und nimmt keine krumme Anzahl", async () => { + const { admin } = await grundbestand(); + await expect(tagsErzeugen(0, null, admin)).rejects.toThrow(TagsFehler); + await expect(tagsErzeugen(-3, null, admin)).rejects.toThrow(TagsFehler); + await expect(tagsErzeugen(2.5, null, admin)).rejects.toThrow(TagsFehler); + await expect(tagsErzeugen(TAGS_STAPEL_HOECHSTENS + 1, null, admin)).rejects.toThrow( + /Höchstens 500 Tags/, + ); + // Und kein einziger Code ist dabei verbraucht worden. + expect(await testPrisma.tagCodeVergabe.count()).toBe(0); + }); + + it("nennt 50 als Standardstapel", () => { + // Die Zahl steht im Auftrag. Sie hier festzuhalten heisst, dass eine + // Aenderung eine Entscheidung ist und kein Versehen. + expect(TAGS_STAPEL_STANDARD).toBe(50); + }); + + it("verlangt im Modus Dienstleistung einen Kunden", async () => { + const { admin } = await grundbestand(Betriebsmodus.DIENSTLEISTUNG); + // Die Regel steht in `kundeErmitteln` und gilt damit fuer jeden Dienst + // gleich — auch fuer diesen, der sie nicht selbst kennt (#008). + await expect(tagsErzeugen(3, null, admin)).rejects.toThrow(/Kunde/); + expect(await testPrisma.tagCodeVergabe.count()).toBe(0); + }); + + it("verwirft im Modus Intern eine mitgeschickte Kundenkennung", async () => { + const { admin, eigene } = await grundbestand(); + const fremder = await testPrisma.kunde.create({ data: { name: "Fremder", kuerzel: "FREMD" } }); + const stapel = await tagsErzeugen(2, fremder.id, admin); + expect(stapel.kundeId).toBe(eigene.id); + }); + }); + + describe("Liste der unregistrierten Tags", () => { + it("zeigt nur Tags ohne Zuordnung und keine archivierten", async () => { + const { admin, eigene } = await grundbestand(); + const stapel = await tagsErzeugen(3, null, admin); + await testPrisma.geraet.create({ + data: { + kundeId: eigene.id, + tagCode: "LZ-AAAAAA", + hostname: "PC-VERTRIEB-07", + registriertAm: new Date(), + }, + }); + const archivierter = stapel.tagCodes[0]; + await testPrisma.geraet.update({ + where: { tagCode: archivierter }, + data: { archiviert: true }, + }); + + const tags = await unregistrierteTags(admin); + expect(tags.map((tag) => tag.tagCode).sort()).toEqual([...stapel.tagCodes].slice(1).sort()); + // Ein Tag hat noch keinen Namen – das Etikett zeigt dann nur den Code. + expect(tags.every((tag) => tag.anzeige === null)).toBe(true); + expect(tags.every((tag) => tag.kundeKuerzel === "EIGEN")).toBe(true); + }); + + it("zeigt einem Techniker nur die Tags seiner Kunden", async () => { + const { techniker, meinCode, fremdCode } = await zweiKunden(); + const tags = await unregistrierteTags(techniker); + expect(tags.map((tag) => tag.tagCode)).toEqual([meinCode]); + expect(tags.map((tag) => tag.tagCode)).not.toContain(fremdCode); + }); + }); + + describe("Etiketten zu bestimmten Codes", () => { + it("liefert sie in der angefragten Reihenfolge", async () => { + const { admin } = await grundbestand(); + const stapel = await tagsErzeugen(3, null, admin); + const gewuenscht = [stapel.tagCodes[2], stapel.tagCodes[0], stapel.tagCodes[1]] as string[]; + + const zeilen = await etikettenZuCodes(gewuenscht, admin); + // Ein Bogen wird der Reihe nach ausgeschnitten und aufgeklebt. + expect(zeilen.map((zeile) => zeile.tagCode)).toEqual(gewuenscht); + }); + + it("lässt unbekannte, doppelte und formal falsche Codes still weg", async () => { + const { admin } = await grundbestand(); + const stapel = await tagsErzeugen(1, null, admin); + const code = stapel.tagCodes[0] as string; + + const zeilen = await etikettenZuCodes([code, code, "LZ-ZZZZZZ", "quatsch", "", " "], admin); + // „Gibt es nicht“ und „darfst du nicht sehen“ sind dieselbe Antwort, und + // ein doppelter Code soll nicht zweimal auf den Bogen. + expect(zeilen.map((zeile) => zeile.tagCode)).toEqual([code]); + }); + + it("nimmt einen kleingeschriebenen Code an", async () => { + const { admin } = await grundbestand(); + const stapel = await tagsErzeugen(1, null, admin); + const code = stapel.tagCodes[0] as string; + // Abgetippt kommt der Code auch mal klein – gemeint ist derselbe Aufkleber. + const zeilen = await etikettenZuCodes([code.toLowerCase()], admin); + expect(zeilen.map((zeile) => zeile.tagCode)).toEqual([code]); + }); + + it("gibt einem Techniker kein Etikett eines fremden Kunden", async () => { + const { techniker, meinCode, fremdCode } = await zweiKunden(); + const zeilen = await etikettenZuCodes([fremdCode, meinCode], techniker); + expect(zeilen.map((zeile) => zeile.tagCode)).toEqual([meinCode]); + }); + + it("führt Hostname, Besitzer und Kundenkürzel mit – die Seite entscheidet über das Kürzel", async () => { + const { admin, eigene } = await grundbestand(); + const person = await testPrisma.mitarbeiter.create({ + data: { + kundeId: eigene.id, + vorname: "Anna", + nachname: "Musterfrau", + anzeigename: "Anna Musterfrau", + }, + }); + await testPrisma.geraet.create({ + data: { + kundeId: eigene.id, + tagCode: "LZ-BBBBBB", + hostname: "PC-VERTRIEB-07", + mitarbeiterId: person.id, + registriertAm: new Date(), + }, + }); + + const [zeile] = await etikettenZuCodes(["LZ-BBBBBB"], admin); + expect(zeile?.anzeige).toBe("PC-VERTRIEB-07"); + expect(zeile?.besitzerName).toBe("Anna Musterfrau"); + // Das Lesemodell liefert das Kuerzel immer; ob es aufs Etikett kommt, + // haengt am Betriebsmodus und entscheidet die Druckseite (#008). + expect(zeile?.kundeKuerzel).toBe("EIGEN"); + }); + }); +}); + +/** + * Zwei Kunden, ein Techniker mit Bindung an genau einen davon – der Aufbau fuer + * jede Pruefung der Mandantengrenze. + */ +async function zweiKunden() { + await grundbestand(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" } }); + + const benutzer = await testPrisma.benutzer.create({ + data: { email: "t@firma.example", name: "Techniker", rolle: Rolle.TECHNIKER }, + }); + await testPrisma.benutzerKunde.create({ data: { benutzerId: benutzer.id, kundeId: meiner.id } }); + + await testPrisma.geraet.create({ data: { kundeId: meiner.id, tagCode: "LZ-CCCCCC" } }); + await testPrisma.geraet.create({ data: { kundeId: fremder.id, tagCode: "LZ-DDDDDD" } }); + + return { + techniker: { id: benutzer.id, rolle: Rolle.TECHNIKER }, + meinCode: "LZ-CCCCCC", + fremdCode: "LZ-DDDDDD", + }; +} + +/** + * Das Etikett selbst. Braucht keine Datenbank: die Komponente bekommt fertige + * Daten und entscheidet nichts — insbesondere nicht, ob das Kundenkuerzel + * erscheint. Genau das wird hier festgehalten. + */ +describe("Etikett", () => { + const daten = { + tagCode: "LZ-7K3F9Q", + qrSvg: '', + anzeige: "PC-VERTRIEB-07", + besitzerName: "Anna Musterfrau", + }; + + it("zeigt Code, Name, Besitzer und das eingesetzte QR-SVG", () => { + const markup = renderToStaticMarkup(createElement(Etikett, { daten, layout: "etikett" })); + expect(markup).toContain("LZ-7K3F9Q"); + expect(markup).toContain("PC-VERTRIEB-07"); + expect(markup).toContain("Anna Musterfrau"); + expect(markup).toContain('viewBox="0 0 37 37"'); + // Die Masse stehen in Millimetern und nicht in Pixeln. + expect(markup).toContain("w-[62mm]"); + }); + + it("nennt das Kundenkürzel nur, wenn eines mitgegeben wird", () => { + // Im Modus INTERN gibt die Seite `null` – dort erscheint die eine eigene + // Organisation nirgends, auch nicht auf einem Etikett (#008). + expect(renderToStaticMarkup(createElement(Etikett, { daten, layout: "bogen" }))).not.toContain("MEIN"); + expect( + renderToStaticMarkup( + createElement(Etikett, { daten: { ...daten, kundeKuerzel: "MEIN" }, layout: "bogen" }), + ), + ).toContain("MEIN"); + }); + + it("baut die Adresse der Druckansicht mit Layout und Codes", () => { + // Der Knopf „Etikett drucken“ auf der Geraeteseite ruft genau das auf. + expect(etikettenPfad(["LZ-7K3F9Q"])).toBe("/drucken/etiketten?layout=etikett&codes=LZ-7K3F9Q"); + expect(etikettenPfad(["LZ-AAAAAA", "LZ-BBBBBB"], "bogen")).toBe( + "/drucken/etiketten?layout=bogen&codes=LZ-AAAAAA%2CLZ-BBBBBB", + ); + // Ohne Codes zeigt die Seite alle noch nicht zugeordneten Tags. + expect(etikettenPfad([], "bogen")).toBe("/drucken/etiketten?layout=bogen"); + }); +});