feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.

Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.

Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent c78a6ac1af
commit 5f1b0d1435
58 changed files with 4828 additions and 54 deletions
+1
View File
@@ -25,3 +25,4 @@ blob-report/
# Werkzeuge # Werkzeuge
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
/public/zxing/
+20
View File
@@ -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, - 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) 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/<code>` 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 ### Sicherheit
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen, - 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) Bearbeiter fremder Kunden mehr (#013)
- „Nicht zutreffend“ und „fehlgeschlagen“ lassen sich mit einem Klick zurücknehmen. Vorher sah es - „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) 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)
+40
View File
@@ -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. 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. 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 ## 6. Betrieb: zwei Wege, eine Regel
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar: Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
+61 -2
View File
@@ -16,11 +16,12 @@ als Unterpunkte mit Aufgabe und Dauer.
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min | | #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
| #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min | | #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min |
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 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 | | 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 - #014 QR-Tags, Geräteseite, Etiketten und Scanner — der Einstieg, für den die ganze Anwendung
gebaut ist: Code scannen, Geräteseite öffnen, loslegen. 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/<code>` der andere. Der Scanner kam von Hand dazu.
- `/t/<tagCode>` 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.
+67 -24
View File
@@ -1,9 +1,9 @@
# #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner # #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** phase-1, backend, frontend **Labels:** phase-1, backend, frontend
**Geschätzt:** 12 h **Tatsächlich:** – **Geschätzt:** 12 h **Tatsächlich:** 3 h 35 min
## Ziel ## Ziel
@@ -11,28 +11,71 @@ Der Einstieg für den Techniker, der am Gerät steht: scannen, sehen was zu tun
## Aufgaben ## Aufgaben
- [ ] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber - [x] 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 - [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
- [ ] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite - [x] 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 - [x] 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“ - [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“
- [ ] Abschnitt „Schnellstart“: Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer - [x] 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` - [x] Abschnitt „Vorlage zuweisen“: Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN`
- [ ] Abschnitt „Historie“: abgeschlossene und archivierte Läufe chronologisch - [x] 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 - [x] 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 - [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
- [ ] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie - [x] 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 - [x] 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 - [x] „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] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/`
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001` - [x] 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` - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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 - [x] 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] 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/<code>`. 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).
+2 -2
View File
@@ -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 | | #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 | | #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 | | #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 | – | | #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 | – | | #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 | – | | #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 | – | | #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 | – | | #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** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+2 -2
View File
@@ -12,14 +12,14 @@
"beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts", "beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts",
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts", "beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
"beispiel:lauf": "tsx scripts/beispiel-lauf.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:down": "docker compose -f docker-compose.dev.yml down",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:up": "docker compose -f docker-compose.dev.yml up -d", "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:pdf": "tsx scripts/anleitung-pdf.ts",
"docs:screenshots": "tsx scripts/screenshots.ts", "docs:screenshots": "tsx scripts/screenshots.ts",
"format": "prettier --write .", "format": "prettier --write .",
+32 -1
View File
@@ -42,6 +42,12 @@ type Seite = {
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG"; readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */ /** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
readonly vorbereiten?: (tab: Page) => Promise<void>; readonly vorbereiten?: (tab: Page) => Promise<void>;
/**
* 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 * Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. Fuer Bilder, die
* ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild * 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" }), ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }),
}, },
{ name: "abteilungen", pfad: "/admin/abteilungen" }, { 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 { function argument(name: string): string | undefined {
@@ -248,7 +279,7 @@ try {
for (const ansicht of ANSICHTEN) { for (const ansicht of ANSICHTEN) {
const kontext = await browser.newContext({ const kontext = await browser.newContext({
viewport: { width: ansicht.breite, height: ansicht.hoehe }, viewport: { width: ansicht.breite, height: ansicht.hoehe },
colorScheme: "dark", colorScheme: seite.hell ? "light" : "dark",
locale: "de-DE", locale: "de-DE",
timezoneId: "Europe/Berlin", timezoneId: "Europe/Berlin",
deviceScaleFactor: 2, deviceScaleFactor: 2,
+46
View File
@@ -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/<name>@<version>` 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}`);
+427
View File
@@ -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<LaufStatus, string> = {
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 (
<div className="mt-2 flex items-center gap-2">
<div
className="h-1.5 w-28 overflow-hidden rounded-pille bg-flaeche-hoch"
role="progressbar"
aria-valuemin={0}
aria-valuemax={zeile.anzahlSchritte}
aria-valuenow={zeile.anzahlErledigt}
aria-label="Fortschritt"
>
<div className="h-full bg-akzent" style={{ width: `${Math.round(anteil * 100)}%` }} />
</div>
<span className="text-xs text-nebentext">
{zeile.anzahlErledigt} von {zeile.anzahlSchritte}
</span>
</div>
);
}
/** 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 (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<CircleDot
className={`size-[18px] shrink-0 ${
zeile.status === LaufStatus.IN_ARBEIT ? "text-akzent" : "text-nebentext"
}`}
strokeWidth={1.75}
aria-hidden="true"
/>
<Link
href={`/laeufe/${zeile.id}`}
className="min-w-0 font-medium break-words underline-offset-2 hover:underline"
>
{zeile.titel}
</Link>
<span className={PILLE}>{STATUS_TEXT[zeile.status]}</span>
{ueberfaellig ? (
<span className="flex items-center gap-1 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-faellig">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
seit {DATUM.format(zeile.faelligAm as Date)} fällig
</span>
) : null}
</div>
<p className="mt-1 text-xs break-words text-nebentext">
{[
`${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(" · ")}
</p>
<Fortschritt zeile={zeile} />
</div>
{/*
* „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 ? (
<LaufStartenKnopf laufId={zeile.id} geraetId={geraetId} />
) : (
<Link href={`/laeufe/${zeile.id}`} className={KNOPF}>
<ArrowRight className="size-4" strokeWidth={1.75} aria-hidden="true" />
{zeile.status === LaufStatus.IN_ARBEIT ? "Weiter" : "Ansehen"}
</Link>
)}
</div>
</li>
);
}
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 (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<dl className="grid gap-4 sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-xs text-nebentext">Besitzer</dt>
<dd className="mt-1 text-sm break-words">
{geraet.besitzerId ? (
<>
<Link
href={`/mitarbeiter/${geraet.besitzerId}`}
className="text-akzent underline underline-offset-2"
>
{geraet.besitzerName}
</Link>
{geraet.besitzerAbteilung ? (
<span className="text-nebentext"> · {geraet.besitzerAbteilung}</span>
) : null}
{geraet.besitzerAktiv === false ? <span className="text-faellig"> · deaktiviert</span> : null}
</>
) : (
<span className="text-nebentext">niemand — Server, Raum oder Poolgerät</span>
)}
</dd>
</div>
{angaben.map((angabe) => (
<div key={angabe.name} className="min-w-0">
<dt className="text-xs text-nebentext">{angabe.name}</dt>
<dd className={`mt-1 text-sm break-words${angabe.mono ? " mono" : ""}`}>{angabe.wert}</dd>
</div>
))}
{kundenEbene ? (
<div className="min-w-0">
<dt className="text-xs text-nebentext">Kunde</dt>
<dd className="mt-1 text-sm break-words">{geraet.kundeName}</dd>
</div>
) : null}
</dl>
{geraet.notizen ? (
<p className="mt-4 border-t border-linie pt-4 text-sm break-words text-nebentext">{geraet.notizen}</p>
) : null}
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 text-sm text-nebentext hover:text-text md:min-h-10">
<QrCode className="size-4" strokeWidth={1.75} aria-hidden="true" />
QR-Code anzeigen
</summary>
{/*
* 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.
*/}
<div
role="img"
aria-label={`QR-Code zu ${geraet.tagCode}`}
className="mt-3 w-40 rounded-karte bg-white p-2"
dangerouslySetInnerHTML={{ __html: qr }}
/>
</details>
</section>
);
}
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 (
<>
<Link
href="/geraete"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Geräte
</Link>
<div className="mt-2 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<h1
className={`text-2xl font-bold break-words ${geraet.archiviert ? "text-nebentext line-through" : ""}`}
>
{geraet.anzeige}
</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="mono flex items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{geraet.tagCode}
</span>
<span className={PILLE}>{GERAETE_TYP_TEXT[geraet.typ]}</span>
{geraet.registriertAm === null ? <span className={PILLE}>Tag ohne Zuordnung</span> : null}
{geraet.archiviert ? <span className={PILLE}>archiviert</span> : null}
</div>
</div>
{/*
* Die Adresse baut der Etikettendruck selbst (#014) — sie haengt am
* Tag-Code, nicht an der Kennung des Geraets: gedruckt wird der
* Aufkleber, und der kennt nur seinen Code.
*/}
<Link href={etikettenPfad([geraet.tagCode])} className={KNOPF}>
<Printer className="size-4" strokeWidth={1.75} aria-hidden="true" />
Etikett drucken
</Link>
</div>
<Kopf geraet={geraet} kundenEbene={kundenEbene} qr={qr} />
{geraet.registriertAm === null ? (
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Noch keinem Gerät zugeordnet</h2>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Dieser Aufkleber ist vorgedruckt und wartet auf ein Gerät. Wer davor steht, trägt Typ und Namen
ein — danach läuft hier alles Weitere.
</p>
<Link href={`/t/${geraet.tagCode}`} className={`${KNOPF} mt-4`}>
<QrCode className="size-4" strokeWidth={1.75} aria-hidden="true" />
Gerät registrieren
</Link>
</section>
) : null}
<section className="mt-8">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<ListChecks className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Zugewiesen
</h2>
{laeufe.zugewiesen.length === 0 ? (
<p className="mt-3 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
An diesem Gerät wartet keine Arbeit.
</p>
) : (
<ul className="mt-3 flex flex-col gap-3">
{laeufe.zugewiesen.map((zeile) => (
<Zuweisung key={zeile.id} zeile={zeile} geraetId={geraet.id} darfArbeiten={darfArbeiten} />
))}
</ul>
)}
</section>
{nimmtArbeitAuf && vorlagen.schnellstart.length > 0 ? (
<section className="mt-8">
<h2 className="text-lg font-semibold">Schnellstart</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
Für {GERAETE_TYP_TEXT[geraet.typ]} hinterlegte Vorlagen. Ein Tipp legt den Lauf mit diesem Gerät
{geraet.besitzerName ? ` und ${geraet.besitzerName}` : ""} an und beginnt ihn.
</p>
<Schnellstart geraetId={geraet.id} vorlagen={vorlagen.schnellstart} personId={besitzerFuerStart} />
</section>
) : null}
{nimmtArbeitAuf ? (
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Vorlage zuweisen</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Für später oder für jemand anderen: der Lauf entsteht als <strong>offen</strong> und erscheint
oben unter „Zugewiesen“.
</p>
<VorlageZuweisen
geraetId={geraet.id}
vorlagen={vorlagen.alle}
bearbeiter={bearbeiter}
besitzerName={geraet.besitzerName}
/>
</section>
) : null}
<section className="mt-8">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<History className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Historie
</h2>
{laeufe.historie.length === 0 ? (
<p className="mt-3 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
An diesem Gerät ist noch kein Lauf abgeschlossen.
</p>
) : (
<ul className="mt-3 rounded-karte border border-linie bg-flaeche px-5">
{laeufe.historie.map((zeile) => (
<li key={zeile.id} className="border-t border-linie py-4 first:border-t-0">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/laeufe/${zeile.id}`}
className="min-w-0 text-sm break-words underline-offset-2 hover:underline"
>
{zeile.titel}
</Link>
<span className={PILLE}>{STATUS_TEXT[zeile.status]}</span>
</div>
<p className="mt-1 text-xs break-words text-nebentext">
{[
`${zeile.vorlagenName} v${zeile.version}`,
zeile.abgeschlossenAm
? `abgeschlossen am ${DATUM.format(zeile.abgeschlossenAm)}`
: `angelegt am ${DATUM.format(zeile.erstelltAm)}`,
`${zeile.anzahlErledigt} von ${zeile.anzahlSchritte} Schritten`,
].join(" · ")}
</p>
</li>
))}
</ul>
)}
{/* Eine gekappte Liste sagt das – sonst fehlte ein Lauf stillschweigend. */}
{laeufe.historieGekuerzt ? (
<p className="mt-3 max-w-prose text-sm text-nebentext">
Es werden die letzten {HISTORIE_HOECHSTENS} Läufe gezeigt.{" "}
<Link
href={`/laeufe?suche=${encodeURIComponent(geraet.tagCode)}`}
className="text-akzent underline underline-offset-2"
>
In den Läufen nach diesem Tag-Code suchen
</Link>
{/*
* Ehrlich benannt: die Laufliste zeigt keine archivierten Läufe,
* und gerade die alten sind archiviert. Die vollständige Suche
* darüber kommt mit dem Archiv (#015) — bis dahin ist dieser Weg
* die halbe Antwort, und das soll er auch sagen (Reviewbefund zu
* #014).
*/}{" "}
— archivierte Läufe stehen dort noch nicht, die findet erst das Archiv
</p>
) : null}
</section>
</>
);
}
+34 -2
View File
@@ -84,9 +84,26 @@ function Karte({
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" /> <QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{zeile.tagCode} {zeile.tagCode}
</span> </span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}> {/*
* Seit #014 fuehrt der Name auf die Geraeteseite — dorthin, wo auch
* der gescannte Aufkleber landet. Ein vorgedruckter Tag ohne
* Zuordnung bleibt Text: dort gaebe es nichts zu sehen, sein Weg
* ist `/t/<code>` und das Registrierformular.
*/}
{zeile.registriertAm === null ? (
<p className="text-nebentext">{zeile.anzeige}</p>
) : (
<Link
href={`/geraete/${zeile.id}`}
className={
zeile.archiviert
? "text-nebentext line-through hover:text-text"
: "font-medium underline-offset-4 hover:underline"
}
>
{zeile.anzeige} {zeile.anzeige}
</p> </Link>
)}
</div> </div>
<div className="mt-2"> <div className="mt-2">
<Kennzeichen zeile={zeile} kundenEbene={kundenEbene} /> <Kennzeichen zeile={zeile} kundenEbene={kundenEbene} />
@@ -275,6 +292,21 @@ export default async function GeraeteSeite({
/> />
</div> </div>
</details> </details>
{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).
*/
<p className="text-sm text-nebentext">
Aufkleber auf Vorrat drucken?{" "}
<Link href="/tags" className="text-akzent underline underline-offset-2">
Tag-Stapel anlegen
</Link>
</p>
) : null}
</div> </div>
) : null} ) : null}
</> </>
+17 -1
View File
@@ -38,6 +38,18 @@ export default async function LaufStartenSeite({
startAuswahl(benutzer), startAuswahl(benutzer),
kundenEbeneSichtbar(), 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) : []; const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return ( return (
@@ -63,7 +75,11 @@ export default async function LaufStartenSeite({
vorbelegung={{ vorbelegung={{
...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}), ...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}),
...(einzeln("geraet") ? { geraetId: einzeln("geraet") 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} kundenEbene={kundenEbene}
kunden={kunden.map(({ id, name }) => ({ id, name }))} kunden={kunden.map(({ id, name }) => ({ id, name }))}
+30
View File
@@ -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 (
<>
<h1 className="text-2xl font-bold">Scannen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Halte den Aufkleber ins Bild. Laufzettel springt von selbst auf die Geräteseite, sobald der Code
erkannt ist.
</p>
<div className="mt-6 max-w-xl">
<KameraScanner />
</div>
</>
);
}
+115
View File
@@ -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/<tagCode>` (#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/<code>`.**
* 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/<code>` 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 (
<div className="mx-auto max-w-lg">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<QrCode className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Kein Laufzettel-Tag
</h1>
<p className="mt-3 text-nebentext">
Zu <span className="mono break-all text-text">{code}</span> gehört kein Gerät. Ein Laufzettel-Code
beginnt mit <span className="mono">{TAGCODE_PRAEFIX}</span> und hat sechs Zeichen dahinter — vom
Aufkleber abgelesen, ohne die verwechselbaren Zeichen 0, 1, I und O.
</p>
<Link
href="/geraete"
className="mt-6 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"
>
<Search className="size-4" strokeWidth={1.75} aria-hidden="true" />
Gerät suchen
</Link>
</div>
);
}
if (!darf(benutzer.rolle, "geraeteBearbeiten")) {
return (
<div className="mx-auto max-w-lg">
<h1 className="text-2xl font-bold">Noch keinem Gerät zugeordnet</h1>
<p className="mt-3 text-nebentext">
Der Aufkleber <span className="mono text-text">{geraet.tagCode}</span> ist vorgedruckt und wartet
auf ein Gerät. Zum Registrieren fehlt dir die Berechtigung — ein Techniker oder Administrator
übernimmt das.
</p>
</div>
);
}
/*
* 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 (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-bold">Gerät registrieren</h1>
<p className="mt-2 max-w-prose text-nebentext">
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.
</p>
<div className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<GeraetRegistrieren geraetId={geraet.id} tagCode={geraet.tagCode} besitzer={besitzer} />
</div>
</div>
);
}
+116
View File
@@ -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 (
<>
<h1 className="text-2xl font-bold">Tags</h1>
<p className="mt-2 max-w-prose text-nebentext">
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.
</p>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Tags className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Stapel anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
{TAGS_STAPEL_STANDARD} Tags sind gut zwei A4-Bogen und der übliche Vorrat.
</p>
<TagStapel standard={TAGS_STAPEL_STANDARD} hoechstens={TAGS_STAPEL_HOECHSTENS} kunden={kundenWahl} />
</section>
<div className="mt-8 flex flex-wrap items-end justify-between gap-3">
<h2 className="text-lg font-semibold">
Noch nicht zugeordnet
<span className="ml-2 text-sm font-normal text-nebentext">
{tags.length} {tags.length === 1 ? "Tag" : "Tags"}
</span>
</h2>
{tags.length > 0 ? (
<Link
href={etikettenPfad([], "bogen")}
className="flex min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Etiketten drucken
</Link>
) : null}
</div>
{tags.length === 0 ? (
<p className="mt-4 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Tag wartet auf ein Gerät. Alles, was angelegt wurde, ist registriert.
</p>
) : (
<ul className="mt-4 flex flex-col gap-3">
{tags.map((tag) => (
<li
key={tag.tagCode}
className="flex flex-col gap-3 rounded-karte border border-linie bg-flaeche p-4 md:flex-row md:items-center md:justify-between"
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{/*
* 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).
*/}
<Link
href={`/t/${tag.tagCode}`}
className="mono flex min-h-11 items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 text-xs text-nebentext hover:text-text md:min-h-9"
>
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{tag.tagCode}
</Link>
{/* Im Modus Intern gibt es genau eine Organisation, und die
erscheint nirgends (#008). */}
{kundenEbene ? <span className={PILLE}>{tag.kundeName}</span> : null}
<span className={PILLE}>angelegt {DATUM.format(tag.erstelltAm)}</span>
</div>
<Link
href={etikettenPfad([tag.tagCode], "etikett")}
className="flex min-h-11 shrink-0 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Einzeletikett
</Link>
</li>
))}
</ul>
)}
</>
);
}
+15
View File
@@ -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 <div className="druck-hell min-h-screen">{children}</div>;
}
+229
View File
@@ -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<Record<EtikettLayout, string>> = {
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<Record<string, string | string[] | undefined>>;
}) {
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 (
<>
<style>{`${SEITENFORMAT[layout]}${DRUCKSTIL}`}</style>
<div className="nicht-drucken mx-auto flex max-w-3xl flex-col gap-4 p-4">
<h1 className="text-xl font-bold">Etiketten drucken</h1>
{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).
*/
<p role="alert" className="max-w-prose rounded-karte border border-fehler p-4 text-sm text-fehler">
<strong>APP_URL ist nicht gesetzt.</strong> Die QR-Codes zeigen dann auf{" "}
<span className="mono">{QR_ERSATZ_HERKUNFT}</span> und führen von keinem anderen Gerät
irgendwohin. Erst die Adresse eintragen, dann drucken — ein geklebter Aufkleber lässt sich nicht
berichtigen.
</p>
) : null}
<p className="text-sm text-nebentext">
{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." : "."}
</p>
<div className="flex flex-wrap items-center gap-3">
<EtikettenDrucken />
<Link
href={etikettenPfad(gewuenschteCodes, layout === "etikett" ? "bogen" : "etikett")}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
{layout === "etikett" ? "Als A4-Bogen" : "Als Einzeletikett 62 × 29 mm"}
</Link>
</div>
{fehlend > 0 ? (
<p className="text-sm text-faellig">
{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.
</p>
) : null}
<p className="text-xs text-nebentext">
Im Druckdialog Skalierung auf 100 % stellen und Kopf- und Fußzeilen abschalten — sonst sitzen die
Etiketten neben ihrer Stanzung.
</p>
</div>
{etiketten.length === 0 ? (
<p className="nicht-drucken mx-auto max-w-3xl rounded-karte border border-linie p-5 text-sm text-nebentext">
Kein Etikett zu drucken. Einen Stapel neuer Tags legt die Seite{" "}
<Link href="/tags" className="underline underline-offset-2">
Tags
</Link>{" "}
an.
</p>
) : (
<div
className={
layout === "bogen"
? // Am Bildschirm umbrechend, damit bei 390 px nichts seitwaerts
// laeuft; im Druck das feste Raster des Stanzbogens.
"flex flex-wrap justify-center gap-2 p-2 print:grid print:grid-cols-3 print:gap-0 print:p-0"
: "flex flex-col items-center gap-2 p-2 print:gap-0 print:p-0"
}
>
{etiketten.map((daten, stelle) => (
<div
key={daten.tagCode}
className={
layout === "etikett" && stelle < etiketten.length - 1
? // Ein Etikett je Seite – aber kein Seitenumbruch hinter dem
// letzten, sonst faellt ein leeres Etikett aus dem Drucker.
"border border-dashed border-linie print:border-0 print:break-after-page"
: "border border-dashed border-linie print:border-0"
}
>
<Etikett daten={daten} layout={layout} />
</div>
))}
</div>
)}
</>
);
}
+26
View File
@@ -144,3 +144,29 @@ samp,
display: none !important; 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);
}
@@ -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<AktionsErgebnis, FormData>(geraetRegistrierenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="geraetId" value={geraetId} />
<p className="text-sm text-nebentext">
Tag-Code <span className="mono text-text">{tagCode}</span> — er bleibt, wie er ist.
</p>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue="SONSTIGES" className={FELD}>
{GERAETE_TYP_LISTE.map((typ) => (
<option key={typ} value={typ}>
{GERAETE_TYP_TEXT[typ]}
</option>
))}
</select>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Hostname</span>
<input name="hostname" className={`${FELD} mono`} placeholder="PC-VERTRIEB-07" autoFocus />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Bezeichnung</span>
<input name="bezeichnung" className={FELD} placeholder="Serverschrank Keller" />
</label>
</div>
<p className="text-xs text-nebentext">
Eines von beiden genügt. Ein Raum oder ein Serverschrank hat keinen Hostname, ein Notebook keine
Bezeichnung.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Seriennummer</span>
<input name="seriennummer" className={`${FELD} mono`} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Inventarnummer</span>
<input name="inventarnummer" className={`${FELD} mono`} />
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Besitzer (optional)</span>
<select name="mitarbeiterId" defaultValue="" className={FELD}>
<option value="">niemand — Server, Raum oder Poolgerät</option>
{besitzer.kandidaten.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
{besitzer.gekuerzt ? (
<p className="text-xs text-faellig">
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.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
{laeuft ? "Wird registriert …" : "Gerät registrieren"}
</button>
</form>
);
}
+44
View File
@@ -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<AktionsErgebnis, FormData>(laufStartenAktion, {});
return (
<div className="flex flex-col gap-2">
<form action={aktion}>
<input type="hidden" name="laufId" value={laufId} />
<input type="hidden" name="geraetId" value={geraetId} />
<button
type="submit"
disabled={laeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<PlayCircle className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird geöffnet …" : "Starten"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+93
View File
@@ -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<AktionsErgebnis, FormData>(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 (
<div className="flex flex-col gap-3">
<div className="grid gap-3 sm:grid-cols-2">
{vorlagen.map((vorlage) =>
vorlage.pflichtangaben ? (
<Link
key={vorlage.versionId}
href={`/laeufe/starten?vorlage=${encodeURIComponent(vorlage.versionId)}&geraet=${encodeURIComponent(geraetId)}${
personId ? `&person=${encodeURIComponent(personId)}` : ""
}`}
className={kachel}
>
<span className="flex items-center gap-2 font-medium">
<SlidersHorizontal
className="size-4 shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span className="min-w-0 break-words">{vorlage.name}</span>
</span>
<span className="text-xs text-nebentext">
v{vorlage.version}
{vorlage.kategorie ? ` · ${vorlage.kategorie}` : ""} · fragt noch nach Angaben
</span>
</Link>
) : (
<form key={vorlage.versionId} action={aktion}>
<input type="hidden" name="geraetId" value={geraetId} />
<input type="hidden" name="vorlagenVersionId" value={vorlage.versionId} />
<button type="submit" disabled={laeuft} className={kachel}>
<span className="flex items-center gap-2 font-medium">
<Rocket className="size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span className="min-w-0 break-words">{vorlage.name}</span>
</span>
<span className="text-xs text-nebentext">
v{vorlage.version}
{vorlage.kategorie ? ` · ${vorlage.kategorie}` : ""}
{laeuft ? " · wird gestartet …" : ""}
</span>
</button>
</form>
),
)}
</div>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+138
View File
@@ -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<AktionsErgebnis, FormData>(vorlageZuweisenAktion, {});
const [versionId, setzeVersion] = useState("");
const gewaehlt = vorlagen.find((vorlage) => vorlage.versionId === versionId);
if (vorlagen.length === 0) {
return (
<p className="max-w-prose text-sm text-nebentext">
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.
</p>
);
}
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="geraetId" value={geraetId} />
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorlage</span>
<select
name="vorlagenVersionId"
required
value={versionId}
onChange={(ereignis) => setzeVersion(ereignis.target.value)}
className={FELD}
>
<option value="">bitte wählen</option>
{vorlagen.map((vorlage) => (
<option key={vorlage.versionId} value={vorlage.versionId}>
{vorlage.kategorie ? `${vorlage.kategorie} · ` : ""}
{vorlage.name} (v{vorlage.version}){vorlage.pflichtangaben ? " — fragt noch nach Angaben" : ""}
</option>
))}
</select>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Techniker (optional)</span>
<select name="zugewiesenAnId" defaultValue="" className={FELD}>
<option value="">niemandem</option>
{bearbeiter.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Fällig am (optional)</span>
<input name="faelligAm" type="date" className={FELD} />
</label>
</div>
{besitzerName ? (
<p className="max-w-prose text-xs text-nebentext">
Der Lauf wird für {besitzerName} geführt — die Person, der dieses Gerät gehört.
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{gewaehlt?.pflichtangaben ? (
<div className="flex flex-col gap-2">
<Link
href={`/laeufe/starten?vorlage=${gewaehlt.versionId}&geraet=${geraetId}`}
className="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"
>
<ArrowRight className="size-4" strokeWidth={1.75} aria-hidden="true" />
Im Start-Dialog anlegen
</Link>
<p className="max-w-prose text-xs text-nebentext">
„{gewaehlt.name}“ verlangt Angaben, die hier nicht abgefragt werden. Der Start-Dialog fragt danach
— Gerät und, wenn es einen Besitzer hat, auch die Person sind dort schon vorbelegt.
</p>
</div>
) : (
<button
type="submit"
disabled={laeuft}
className="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 disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ClipboardPlus className="size-4" strokeWidth={1.75} aria-hidden="true" />
{laeuft ? "Wird angelegt …" : "Lauf zuweisen"}
</button>
)}
</form>
);
}
+6 -6
View File
@@ -22,8 +22,8 @@ import { darf } from "@/server/auth/rollen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { versionText } from "@/lib/version"; import { versionText } from "@/lib/version";
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */ /** Seit #014 gibt es `/scannen` – der Knopf fuehrt jetzt irgendwohin. */
const SCANNEN_VERFUEGBAR = false; const SCANNEN_VERFUEGBAR = true;
const SYMBOLE: Record<string, LucideIcon> = { const SYMBOLE: Record<string, LucideIcon> = {
LayoutDashboard, LayoutDashboard,
@@ -96,10 +96,10 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
/** /**
* Der Einstieg des Technikers, an beiden Stellen ganz oben. * Der Einstieg des Technikers, an beiden Stellen ganz oben.
* *
* Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf, * Der gedaempfte Zustand bleibt im Code: er gilt, solange eine Seite fehlt, und
* der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den * genau diese Lage gab es hier bis #014. Ein Knopf, der so prominent sitzt, darf
* Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404 * nicht auf eine Fehlerseite fuehren — Next holt den Verweis beim Darueberfahren
* im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation. * sogar vorab, jede Seite erzeugte damit eine 404 im Hintergrund.
*/ */
function ScannenKnopf({ className }: { className?: string }) { function ScannenKnopf({ className }: { className?: string }) {
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am // Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
+248
View File
@@ -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<HTMLVideoElement>(null);
const gesprungen = useRef(false);
const [fehler, setzeFehler] = useState<string | null>(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<typeof setInterval> | undefined;
const video = videoRef.current;
async function starten(): Promise<void> {
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 (
<div className="flex flex-col gap-5">
{fehler ? (
<p
role="status"
className="flex max-w-prose items-start gap-2 rounded-karte border border-linie bg-flaeche p-4 text-sm text-nebentext"
>
<CameraOff className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} aria-hidden="true" />
{fehler}
</p>
) : (
<div className="relative overflow-hidden rounded-karte border border-linie bg-grund">
{/* `muted` und `playsInline`: ohne sie startet iOS das Bild gar nicht erst. */}
<video
ref={videoRef}
muted
playsInline
aria-label="Kamerabild zum Scannen"
className="aspect-[3/4] w-full object-cover sm:aspect-video"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="size-48 rounded-karte border-2 border-akzent/70" />
</div>
{!laeuft ? (
<p className="absolute inset-x-0 bottom-0 bg-grund/80 p-3 text-center text-sm text-nebentext">
Kamera wird gestartet …
</p>
) : null}
</div>
)}
{laeuft && fremderFund ? (
<p role="status" className="max-w-prose text-sm text-faellig">
Das ist kein Laufzettel-Tag. Halte den Aufkleber mit dem Code <span className="mono">LZ-…</span> ins
Bild.
</p>
) : null}
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
if (ausEingabe) springen(ausEingabe);
}}
className="flex flex-col gap-2"
>
<label className="flex flex-col gap-1.5 text-sm">
<span className="flex items-center gap-2 text-nebentext">
<Keyboard className="size-4" strokeWidth={1.75} aria-hidden="true" />
Code eintippen
</span>
<input
value={eingetippt}
onChange={(ereignis) => setzeEingetippt(ereignis.target.value)}
placeholder="LZ-7K3F9Q"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
className={`${FELD} mono max-w-xs`}
/>
</label>
<button
type="submit"
disabled={!ausEingabe}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
Öffnen
</button>
{eingetippt.trim() !== "" && !ausEingabe ? (
<p className="max-w-prose text-xs text-nebentext">
Ein Code sieht aus wie <span className="mono">LZ-7K3F9Q</span>. Die Zeichen 0, O, 1 und I gibt es
darin nicht — sie sind zu leicht zu verwechseln.
</p>
) : null}
</form>
</div>
);
}
+117
View File
@@ -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<Record<EtikettLayout, string>> = {
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<Record<EtikettLayout, string>> = {
etikett: "size-[25mm]",
bogen: "size-[31mm]",
};
const CODE_GROESSE: Readonly<Record<EtikettLayout, string>> = {
etikett: "text-[3.4mm]",
bogen: "text-[3.8mm]",
};
const TEXT_GROESSE: Readonly<Record<EtikettLayout, string>> = {
etikett: "text-[2.6mm]",
bogen: "text-[2.9mm]",
};
export function Etikett({ daten, layout }: { daten: EtikettDaten; layout: EtikettLayout }) {
return (
<div
className={`flex items-center overflow-hidden bg-flaeche text-text ${ETIKETT_RAHMEN[layout]}`}
// `data-`-Haken fuer die Screenshots und die Pruefung des Druckbilds.
data-etikett={daten.tagCode}
>
{/*
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.
*/}
<div
className={`shrink-0 [&>svg]:block [&>svg]:size-full ${ETIKETT_QR_FLAECHE[layout]}`}
aria-label={`QR-Code ${daten.tagCode}`}
role="img"
dangerouslySetInnerHTML={{ __html: daten.qrSvg }}
/>
<div className="flex min-w-0 flex-1 flex-col justify-center">
<p className={`mono leading-tight font-bold ${CODE_GROESSE[layout]}`}>{daten.tagCode}</p>
{daten.anzeige ? (
<p className={`mt-[1mm] leading-tight break-words ${TEXT_GROESSE[layout]}`}>{daten.anzeige}</p>
) : null}
{daten.besitzerName ? (
<p className={`text-nebentext leading-tight break-words ${TEXT_GROESSE[layout]}`}>
{daten.besitzerName}
</p>
) : null}
<div className={`mt-auto flex items-center gap-[1.2mm] text-nebentext ${TEXT_GROESSE[layout]}`}>
<Bildmarke className="size-[3.2mm] shrink-0" />
<span>laufzettel</span>
{/* Im Modus Intern gibt es genau eine Organisation, und die erscheint
auf keinem Etikett (#008). */}
{daten.kundeKuerzel ? <span className="mono ml-auto">{daten.kundeKuerzel}</span> : null}
</div>
</div>
</div>
);
}
+23
View File
@@ -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 (
<Button
type="button"
onClick={() => window.print()}
className="nicht-drucken h-11 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
{beschriftung}
</Button>
);
}
+101
View File
@@ -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<TagsErgebnis, FormData>(tagsErzeugenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anzahl</span>
<input
name="anzahl"
type="number"
inputMode="numeric"
min={1}
max={hoechstens}
defaultValue={standard}
className={`${FELD} mono`}
/>
</label>
{/* Die Kundenwahl erscheint nur im Modus Dienstleistung; im Modus Intern
verwirft der Server eine mitgeschickte Kennung ohnehin (#008). */}
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" defaultValue="" className={FELD} required>
<option value="">bitte wählen</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
</div>
<p className="text-xs text-nebentext">
Höchstens {hoechstens} auf einmal. Jeder Tag verbraucht einen Tag-Code für immer — auch einer, dessen
Aufkleber nie geklebt wird.
</p>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
disabled={laeuft}
className="h-11 rounded-pille px-5 text-sm font-semibold md:h-10"
>
<Tags className="size-4" strokeWidth={2} aria-hidden="true" />
{laeuft ? "Wird angelegt …" : "Stapel anlegen"}
</Button>
{ergebnis.tagCodes && ergebnis.tagCodes.length > 0 ? (
<Link
href={etikettenPfad(ergebnis.tagCodes, "bogen")}
className="flex min-h-11 items-center gap-2 rounded-pille border border-akzent px-4 text-sm text-akzent hover:bg-akzent-flaeche md:min-h-10"
>
<Printer className="size-4" strokeWidth={2} aria-hidden="true" />
Etiketten für diesen Stapel drucken
</Link>
) : null}
</div>
</form>
);
}
+28
View File
@@ -21,3 +21,31 @@ export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
} }
return new URL(pfad, ersatzHerkunft); 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;
}
+116
View File
@@ -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<string> {
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" },
});
}
+42
View File
@@ -77,3 +77,45 @@ export async function tagCodeEindeutigErzeugen(
"Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.", "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/<code>` 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;
}
}
+8 -1
View File
@@ -52,7 +52,14 @@ export function istOeffentlich(pfad: string): boolean {
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string { export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
return [ return [
`default-src 'self'`, `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. // Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
`style-src 'self' 'unsafe-inline'`, `style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: blob:`, `img-src 'self' data: blob:`,
+212 -2
View File
@@ -1,8 +1,10 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { GeraeteTyp } from "@/generated/prisma/enums"; import { GeraeteTyp } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung"; import { z } from "@/lib/validierung";
import type { Recht } from "@/server/auth/rollen";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant"; import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant";
import { import {
@@ -12,8 +14,10 @@ import {
geraetArchivieren, geraetArchivieren,
geraetBearbeiten, geraetBearbeiten,
geraetLoeschen, geraetLoeschen,
laufFuerGeraetAnlegen,
type ImportAbweisung, type ImportAbweisung,
} from "@/server/services/geraete"; } from "@/server/services/geraete";
import { LaufFehler, laufZurBearbeitungOeffnen } from "@/server/services/laeufe";
/** /**
* Server Actions der Geraete (#011). * Server Actions der Geraete (#011).
@@ -44,7 +48,7 @@ const RECHTE_MELDUNG =
* src/server/actions/vorlagen.ts). * src/server/actions/vorlagen.ts).
*/ */
async function angemeldetOderMeldung( async function angemeldetOderMeldung(
recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten", recht: Recht = "geraeteBearbeiten",
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> { ): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try { try {
const { benutzer } = await rechtErforderlich(recht); 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. */ /** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis { 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 }; return { fehler: fehler.message };
} }
if (fehler instanceof RechteFehler) { if (fehler instanceof RechteFehler) {
@@ -287,3 +294,206 @@ export async function geraeteImportierenAktion(
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden."); 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<AktionsErgebnis> {
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<AktionsErgebnis> {
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<AktionsErgebnis> {
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<AktionsErgebnis> {
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}`);
}
+90
View File
@@ -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<TagsErgebnis> {
let benutzerId: string;
let rolle: Parameters<typeof tagsErzeugen>[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." };
}
}
+272 -3
View File
@@ -1,5 +1,5 @@
import type { Prisma } from "@/generated/prisma/client"; 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 { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
@@ -34,6 +34,8 @@ export type GeraeteZeile = {
readonly besitzerId: string | null; readonly besitzerId: string | null;
readonly besitzerName: string | null; readonly besitzerName: string | null;
readonly besitzerAktiv: boolean | 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; readonly anzahlLaeufe: number;
}; };
@@ -76,7 +78,7 @@ const ZEILEN_AUSWAHL = {
registriertAm: true, registriertAm: true,
kundeId: true, kundeId: true,
kunde: { select: { name: 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 } }, _count: { select: { laeufe: true } },
} satisfies Prisma.GeraetSelect; } satisfies Prisma.GeraetSelect;
@@ -101,6 +103,7 @@ function zeileBauen(zeile: Rohzeile): GeraeteZeile {
besitzerId: zeile.mitarbeiter?.id ?? null, besitzerId: zeile.mitarbeiter?.id ?? null,
besitzerName: zeile.mitarbeiter?.anzeigename ?? null, besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null, besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null,
besitzerAbteilung: zeile.mitarbeiter?.abteilung ?? null,
anzahlLaeufe: zeile._count.laeufe, anzahlLaeufe: zeile._count.laeufe,
}; };
} }
@@ -167,6 +170,258 @@ export async function geraetHolen(
return zeile ? zeileBauen(zeile) : undefined; return zeile ? zeileBauen(zeile) : undefined;
} }
/**
* Das Geraet zu einem Tag-Code – der Einstieg von `/t/<code>` (#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<GeraeteZeile | undefined> {
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<GeraetLaeufe> {
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<VorlagenFuerGeraet> {
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 = { export type BesitzerKandidat = {
readonly id: string; readonly id: string;
readonly anzeigename: string; readonly anzeigename: string;
@@ -195,11 +450,25 @@ export type BesitzerAuswahl = {
export async function besitzerKandidaten( export async function besitzerKandidaten(
benutzer: HandelnderBenutzer, benutzer: HandelnderBenutzer,
grenze = 500, 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<BesitzerAuswahl> { ): Promise<BesitzerAuswahl> {
const sichtbar = await sichtbareKundenIds(benutzer); const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.mitarbeiter.findMany({ const zeilen = await prisma.mitarbeiter.findMany({
where: { 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 }, select: { id: true, anzeigename: true, aktiv: true, kundeId: true },
orderBy: { anzeigename: "asc" }, orderBy: { anzeigename: "asc" },
+135
View File
@@ -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<Prisma.GeraetWhereInput | undefined> {
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<readonly EtikettZeile[]> {
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<readonly EtikettZeile[]> {
// 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);
}
+57 -1
View File
@@ -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 { GeraeteTyp } from "@/generated/prisma/enums";
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv"; import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode"; import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant"; import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
import { laufAnlegen } from "@/server/services/laeufe";
/** /**
* Geraete und ihre Stammdaten (#011). * 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<Lauf> {
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 ------------------------------------------------------------ // ---- CSV-Import ------------------------------------------------------------
/** /**
+97
View File
@@ -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<TagStapel> {
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 };
}
+163
View File
@@ -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<string> {
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<string[]> {
const { readFile } = await import("node:fs/promises");
const { BarcodeDetector, setZXingModuleOverrides } = await import("barcode-detector/ponyfill");
const global = globalThis as unknown as Record<string, unknown>;
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();
});
+215
View File
@@ -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/<code>`, 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<string> {
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<string> {
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();
});
+65
View File
@@ -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);
});
});
+567
View File
@@ -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);
});
});
});
+240
View File
@@ -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(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg" /);
// Ein QR besteht aus zwei Pfaden: weisser Grund und schwarze Module.
expect(svg.match(/<path /g)).toHaveLength(2);
expect(svg).not.toContain("<text");
expect(svg).not.toContain("<script");
expect(svg).not.toContain("<image");
// Der Klartext steht nirgends im SVG – auch nicht der tagCode selbst.
expect(svg).not.toContain(TAGCODE);
expect(svg).not.toContain(APP_URL);
});
it("nutzt Fehlerkorrektur M", async () => {
/*
* 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(`<!doctype html>
<html><body style="margin:0;background:#ffffff">
<div id="etikett" style="box-sizing:border-box;width:${breiteMm}mm;height:${hoeheMm}mm;
padding:${randMm}mm;background:#ffffff;display:flex;align-items:center;gap:2mm">
<div style="width:${qrMm}mm;height:${qrMm}mm;flex:none">${svg}</div>
<div style="font-family:monospace;font-size:3.4mm;font-weight:700">${TAGCODE}</div>
</div>
</body></html>`);
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<readonly { rawValue: string }[]>;
};
};
+51
View File
@@ -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");
});
});
+9 -1
View File
@@ -143,7 +143,15 @@ describe("wertMaskieren fuer PowerShell", () => {
}); });
describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => { 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]) { for (const wert of [BOESARTIG, TYPOGRAFISCH]) {
const maskiert = wertMaskieren(wert, "powershell"); const maskiert = wertMaskieren(wert, "powershell");
const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`; const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`;
+18 -5
View File
@@ -29,8 +29,16 @@ function teile(richtlinie: string): Map<string, string> {
describe("Ausgelieferter Stand", () => { describe("Ausgelieferter Stand", () => {
const richtlinie = sicherheitsrichtlinie(NONCE, false); const richtlinie = sicherheitsrichtlinie(NONCE, false);
it("erlaubt kein unsafe-eval", () => { it("erlaubt kein unsafe-eval – wohl aber WebAssembly", () => {
expect(richtlinie).not.toContain("unsafe-eval"); /*
* `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", () => { it("erlaubt keine WebSocket-Verbindungen", () => {
@@ -38,7 +46,9 @@ describe("Ausgelieferter Stand", () => {
}); });
it("bindet Skripte an die Nonce dieser Anfrage", () => { 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", () => { it("verbietet das Einbetten in fremde Seiten und Objekte", () => {
@@ -58,7 +68,7 @@ describe("Entwicklungsmodus", () => {
const richtlinie = sicherheitsrichtlinie(NONCE, true); const richtlinie = sicherheitsrichtlinie(NONCE, true);
it("erlaubt unsafe-eval, sonst hydriert React nicht", () => { 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", () => { 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"), proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
); );
expect(richtlinie).toBeTruthy(); 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("connect-src 'self';");
expect(richtlinie).toContain("'strict-dynamic'"); expect(richtlinie).toContain("'strict-dynamic'");
}); });
+280
View File
@@ -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: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37 37"><path d="M0 0h1"/></svg>',
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");
});
});