feat(tags): QR-Aufkleber, Geraeteseite, Etikettendruck und Scanner #28
@@ -25,3 +25,4 @@ blob-report/
|
||||
# Werkzeuge
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
/public/zxing/
|
||||
|
||||
@@ -117,6 +117,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
- Abschnitte klappen zu, sobald in ihnen nichts mehr zu tun ist — wer einen wieder aufklappt,
|
||||
behält ihn offen, auch über das Speichern hinweg (#013)
|
||||
|
||||
- QR-Aufkleber: Stapel vorgedruckter Tags, Etikettendruck als Einzeletikett (62 × 29 mm) und als
|
||||
A4-Bogen (3 × 8 à 70 × 37 mm), je mit QR, lesbarem Code, Hostname und Besitzer (#014)
|
||||
- `/t/<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
|
||||
|
||||
- Der Compose-Stand bricht ab, wenn `APP_SECRET`, `POSTGRES_PASSWORD` oder `APP_URL` fehlen,
|
||||
@@ -202,3 +212,13 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
Bearbeiter fremder Kunden mehr (#013)
|
||||
- „Nicht zutreffend“ und „fehlgeschlagen“ lassen sich mit einem Klick zurücknehmen. Vorher sah es
|
||||
danach aus, als wäre nichts gewählt, während in der Datenbank weiter der alte Zustand stand (#013)
|
||||
- Im QR steht ausschließlich `{APP_URL}/t/{tagCode}` — kein Name, keine Seriennummer, kein
|
||||
Besitzer. Ein Aufkleber hängt jahrelang sichtbar am Gerät und wird von jedem Telefon gelesen;
|
||||
was darauf steht, ist veröffentlicht (#014)
|
||||
- Der QR wird lokal erzeugt, nie über eine externe API: die bekäme mit jedem Aufruf den Bestand an
|
||||
Tag-Codes geliefert (#014)
|
||||
- Das WebAssembly des QR-Lesers kommt aus dem eigenen Haus statt aus einem CDN — die
|
||||
Content-Security-Policy erlaubt nur `connect-src 'self'`, und die Anwendung soll ohne Internet
|
||||
laufen (#014)
|
||||
- Ein Tag, ein Etikett und eine Geräteseite eines fremden Kunden sind nicht erreichbar; „gibt es
|
||||
nicht“ und „darfst du nicht“ sind dieselbe Antwort (#014)
|
||||
|
||||
@@ -200,6 +200,46 @@ hat. Bei verstecktem Tab pausiert die Abfrage.
|
||||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück.
|
||||
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
||||
|
||||
## 5e. Der Aufkleber ist die Schnittstelle
|
||||
|
||||
Jedes Objekt traegt einen QR-Aufkleber mit einem `tagCode`, der **nie geaendert
|
||||
und nie wiederverwendet** wird — auch nicht, nachdem das Geraet geloescht wurde
|
||||
(`TagCodeVergabe`, #006). Der Aufkleber ist damit die einzige Angabe am Geraet,
|
||||
die Jahre haelt: Hostnamen aendern sich, Besitzer wechseln, Seriennummern werden
|
||||
abgeschrieben.
|
||||
|
||||
**Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`.** Kein Name, keine
|
||||
Seriennummer, kein Besitzer. Ein Aufkleber haengt sichtbar am Geraet und wird von
|
||||
jedem Telefon gelesen, auch von einem fremden; was darauf steht, ist
|
||||
veroeffentlicht. Die Adresse selbst verraet nichts — dahinter verlangt
|
||||
`/t/[tagCode]` eine Anmeldung und zeigt einem fremden Kunden dieselbe Antwort wie
|
||||
bei einem Code, den es nicht gibt.
|
||||
|
||||
Drei Dinge daran sind nicht mehr zu korrigieren, sobald gedruckt ist, und stehen
|
||||
deshalb fest:
|
||||
|
||||
1. **Die Basisadresse kommt aus `APP_URL`, mit ihrem Pfad.** Liegt die Anwendung
|
||||
unter `https://firma.de/laufzettel`, muss genau das im QR stehen;
|
||||
`basisAdresse()` in `src/lib/adressen.ts` haelt den Pfad fest, `tagAdresse()`
|
||||
haengt relativ an. Ohne gesetzte `APP_URL` warnt die Druckseite, statt still
|
||||
`localhost` zu drucken.
|
||||
2. **Der QR entsteht lokal** (`qrcode`, Fehlerkorrektur M, Ruhezone 4). Eine
|
||||
externe API bekaeme mit jedem Aufruf den Bestand an Tag-Codes geliefert.
|
||||
3. **Die Masse sind geprueft, nicht behauptet.** `tests/e2e/etiketten.spec.ts`
|
||||
misst die Seitengroesse an einem echten PDF (62 × 29 mm bzw. A4) und liest
|
||||
ein bei 300 dpi gerastertes Etikett mit demselben Decoder zurueck, den auch
|
||||
der Scanner benutzt.
|
||||
|
||||
Der Scanner laedt `barcode-detector/polyfill`: wo der Browser eine
|
||||
`BarcodeDetector`-API mitbringt, laeuft sie, sonst springt WebAssembly ein. Dessen
|
||||
`.wasm` kommt aus dem eigenen Haus (`scripts/wasm-bereitstellen.mjs` legt es unter
|
||||
`public/zxing/`), weil die Content-Security-Policy nur `connect-src 'self'`
|
||||
erlaubt und die Anwendung ohne Internet laufen soll; dafuer traegt `script-src`
|
||||
ein `'wasm-unsafe-eval'` — die schmale Erlaubnis, WebAssembly zu uebersetzen, und
|
||||
**nicht** `unsafe-eval`. Laeuft die Kamera nicht oder faellt der Decoder aus, steht
|
||||
an derselben Stelle ein Feld fuer den abgetippten Code: der steht lesbar unter
|
||||
jedem QR, und ein Scanner, der nicht startet, darf keine Sackgasse sein.
|
||||
|
||||
## 6. Betrieb: zwei Wege, eine Regel
|
||||
|
||||
Die Datenbank ist auf beiden Wegen von außen nicht erreichbar:
|
||||
|
||||
@@ -16,11 +16,12 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #011 | M6 – Mitarbeiter, Abteilungen, Geräte | 2 h 15 min |
|
||||
| #012 | M7 – Platzhalter, Namensregeln, Escaping | 2 h 30 min |
|
||||
| #013 | M8 – Läufe: Service und Abarbeitung | 3 h 05 min |
|
||||
| | **Summe** | **16 h 50 min** |
|
||||
| #014 | M9 – QR-Tags, Geräteseite, Etiketten, Scanner | 3 h 35 min |
|
||||
| | **Summe** | **20 h 25 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 16 h 50 min | in Arbeit |
|
||||
| v0.1.0 | 20 h 25 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -566,3 +567,61 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
|
||||
- #014 QR-Tags, Geräteseite, Etiketten und Scanner — der Einstieg, für den die ganze Anwendung
|
||||
gebaut ist: Code scannen, Geräteseite öffnen, loslegen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-25 · Sitzung 10 · 18:50–21:30
|
||||
|
||||
**Issue:** #014 (M9)
|
||||
**Version:** 0.1.0
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Zwei Sub-Agenten parallel**, auf getrennten Dateien: QR, Etiketten und Tag-Stapel der eine,
|
||||
Geräteseite und `/t/<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.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# #014 M9 – QR-Tags, Geräteseite, Etiketten, Scanner
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** phase-1, backend, frontend
|
||||
**Geschätzt:** 12 h **Tatsächlich:** –
|
||||
**Geschätzt:** 12 h **Tatsächlich:** 3 h 35 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -11,28 +11,71 @@ Der Einstieg für den Techniker, der am Gerät steht: scannen, sehen was zu tun
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber
|
||||
- [ ] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag“ mit Link zur Suche
|
||||
- [ ] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite
|
||||
- [ ] Geräteseite `/geraete/[id]`, handyoptimiert: Kopf mit Hostname bzw. Bezeichnung, Typ, Besitzer mit Abteilung, Seriennummer, `tagCode` in JetBrains Mono
|
||||
- [ ] Abschnitt „Zugewiesen“: alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten“ bzw. „Weiter“
|
||||
- [ ] Abschnitt „Schnellstart“: Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer
|
||||
- [ ] Abschnitt „Vorlage zuweisen“: Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN`
|
||||
- [ ] Abschnitt „Historie“: abgeschlossene und archivierte Läufe chronologisch
|
||||
- [ ] QR-Erzeugung serverseitig als SVG mit `qrcode`, Fehlerkorrektur M, Inhalt ausschließlich `{APP_URL}/t/{tagCode}`, keine Personendaten, keine externe API
|
||||
- [ ] Etikettendruck mit Print-CSS in zwei Layouts: Etikettendrucker 62 × 29 mm (ein Etikett pro Seite) und A4-Bogen 3 × 8 à 70 × 37 mm; je Etikett QR, `tagCode` lesbar darunter, optional Hostname und Besitzer, kleines Laufzettel-Logo; immer hell
|
||||
- [ ] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie
|
||||
- [ ] Knopf „Etikett drucken“ auf der Geräteseite, Stapeldruck für unregistrierte Tags
|
||||
- [ ] „Scannen“ in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite
|
||||
- [ ] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/`
|
||||
- [x] Stapel-Erzeugung von N unregistrierten Tags (Standard 50) für vorgedruckte Aufkleber
|
||||
- [x] Route `/t/[tagCode]` mit Anmeldepflicht und Rücksprung; drei Fälle: registriert → Geräteseite; existiert unregistriert → Registrierformular; unbekannt → Hinweis „Kein Laufzettel-Tag“ mit Link zur Suche
|
||||
- [x] Registrierformular: Typ, Hostname oder Bezeichnung, Seriennummer, Inventarnummer, optional Besitzer per Mitarbeitersuche; danach Geräteseite
|
||||
- [x] Geräteseite `/geraete/[id]`, handyoptimiert: Kopf mit Hostname bzw. Bezeichnung, Typ, Besitzer mit Abteilung, Seriennummer, `tagCode` in JetBrains Mono
|
||||
- [x] Abschnitt „Zugewiesen“: alle Läufe zu diesem Gerät in `OFFEN` und `IN_ARBEIT` mit Fortschritt, Zuweisung und Fälligkeit, Knopf „Starten“ bzw. „Weiter“
|
||||
- [x] Abschnitt „Schnellstart“: Vorlagen aus `GeraetetypVorlage` für diesen Gerätetyp, ein Tipp startet den Lauf mit vorbelegtem Gerät und Besitzer
|
||||
- [x] Abschnitt „Vorlage zuweisen“: Vorlage wählen, optional Techniker und Fälligkeit, erzeugt einen Lauf in `OFFEN`
|
||||
- [x] Abschnitt „Historie“: abgeschlossene und archivierte Läufe chronologisch
|
||||
- [x] QR-Erzeugung serverseitig als SVG mit `qrcode`, Fehlerkorrektur M, Inhalt ausschließlich `{APP_URL}/t/{tagCode}`, keine Personendaten, keine externe API
|
||||
- [x] Etikettendruck mit Print-CSS in zwei Layouts: Etikettendrucker 62 × 29 mm (ein Etikett pro Seite) und A4-Bogen 3 × 8 à 70 × 37 mm; je Etikett QR, `tagCode` lesbar darunter, optional Hostname und Besitzer, kleines Laufzettel-Logo; immer hell
|
||||
- [x] Im Modus `DIENSTLEISTUNG` zeigt das Etikett zusätzlich das Kundenkürzel, im Modus `INTERN` nie
|
||||
- [x] Knopf „Etikett drucken“ auf der Geräteseite, Stapeldruck für unregistrierte Tags
|
||||
- [x] „Scannen“ in der App: `barcode-detector` (native BarcodeDetector-API mit Polyfill-Fallback), Kamera im Browser, direkter Sprung zur Geräteseite
|
||||
- [x] Screenshots Geräteseite Handy, Registrierformular, Etikett 62 × 29, A4-Bogen, Scanner in `docs/screenshots/m9/`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001`
|
||||
- [ ] Test: „Vorlage zuweisen“ erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen“ erscheint; „Starten“ öffnet ihn und setzt `IN_ARBEIT`
|
||||
- [ ] Test: unregistrierter Tag führt zum Registrierformular, danach zur Geräteseite; unbekannter Tag zeigt den Hinweis
|
||||
- [ ] Test: mehrere Zuweisungen je Gerät sind gleichzeitig möglich
|
||||
- [ ] Test: der QR-Inhalt ist genau `{APP_URL}/t/{tagCode}` und enthält keine Personendaten
|
||||
- [ ] Test: ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem QR-Decoder korrekt zurückgelesen
|
||||
- [ ] Beide Druckbilder sind sauber, ohne Sidebar, ohne dunkle Flächen, ohne abgeschnittene Etiketten
|
||||
- [ ] Die Geräteseite ist bei 390 px ohne horizontales Scrollen bedienbar
|
||||
- [x] Test: `/t/LZ-7K3F9Q` ohne Anmeldung leitet zum Login und danach auf die Geräteseite von `PC-001`
|
||||
- [x] Test: „Vorlage zuweisen“ erzeugt einen Lauf in `OFFEN`, der unter „Zugewiesen“ erscheint; „Starten“ öffnet ihn und setzt `IN_ARBEIT`
|
||||
- [x] Test: unregistrierter Tag führt zum Registrierformular, danach zur Geräteseite; unbekannter Tag zeigt den Hinweis
|
||||
- [x] Test: mehrere Zuweisungen je Gerät sind gleichzeitig möglich
|
||||
- [x] Test: der QR-Inhalt ist genau `{APP_URL}/t/{tagCode}` und enthält keine Personendaten
|
||||
- [x] Test: ein bei 300 dpi gerastertes 62 × 29-mm-Etikett wird von einem QR-Decoder korrekt zurückgelesen
|
||||
- [x] Beide Druckbilder sind sauber, ohne Sidebar, ohne dunkle Flächen, ohne abgeschnittene Etiketten
|
||||
- [x] Die Geräteseite ist bei 390 px ohne horizontales Scrollen bedienbar
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Erledigt am 2026-09-25. **930 Tests** (vorher 879) und **54 Ende-zu-Ende-Tests** grün, Screenshots in
|
||||
`docs/screenshots/m9/`.
|
||||
|
||||
Zwei Teile entstanden parallel (QR/Etiketten und Geräteseite/Tag-Einstieg), der Scanner kam von
|
||||
Hand dazu. Die Fehler saßen erwartungsgemäß an den Nähten — und fielen erst im **gebauten** Stand
|
||||
auf:
|
||||
|
||||
- **„Starten“ startete, sprang aber nicht.** `revalidatePath` baut die Geräteseite neu, dabei
|
||||
verschwindet der Knopf aus dem Baum — und der Effekt, der in den Lauf springen sollte, kam nie
|
||||
dazu. Der Sprung passiert jetzt im Dienst (`laufStartenAktion`).
|
||||
- **„Vorlage zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte.** Vorlagen mit
|
||||
Pflichtangaben führen jetzt in den Start-Dialog — dieselbe Entscheidung, die der Schnellstart
|
||||
schon traf.
|
||||
- **Die Geräteliste verlinkte nichts**, weil es die Detailseite bis dahin nicht gab.
|
||||
|
||||
### Der unabhängige Review
|
||||
|
||||
Drei Blocker, elf Befunde, mehrere Anmerkungen — alle abgearbeitet. Die wichtigsten:
|
||||
|
||||
- **Der Scanner benutzte nie die native `BarcodeDetector`-API.** Geladen war die Ponyfill-Fassung, die
|
||||
**immer** ihre eigene Klasse zurückgibt; jedes Gerät holte 1,1 MB WebAssembly, auch wo der
|
||||
Browser einen Decoder mitbringt. Jetzt der Polyfill, der nur einspringt, wenn es keinen gibt.
|
||||
- **Ein ausgefallener Decoder blieb unsichtbar:** dreimal je Sekunde ein Fehler, und auf dem
|
||||
Bildschirm stand unverändert „Halte den Aufkleber ins Bild“. Nach drei Fehlschlägen hört die
|
||||
Abfrage auf und sagt es.
|
||||
- **Ein Unterpfad in `APP_URL` fiel aus dem QR heraus** — `https://firma.de/laufzettel` wurde zu
|
||||
`https://firma.de/t/…`. Auf Papier ist das nicht mehr zu berichtigen; `basisAdresse()` hält den
|
||||
Pfad jetzt fest.
|
||||
- **Ohne `APP_URL` druckte jedes Etikett stillschweigend `localhost`.** Die Druckseite warnt.
|
||||
- **Der Stapeldruck war die offene Hintertür zu einer Seite mit Schloss:** `/tags` verlangt
|
||||
`verwaltung`, der Bogen ohne Codes zeigte dieselbe Liste jedem Leser. Jetzt gilt dort dasselbe
|
||||
Recht.
|
||||
- **`/api/qr/[tagCode]` hatte keinen Aufrufer und keinen Test** und faltete Groß/Klein anders als
|
||||
`/t/<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).
|
||||
|
||||
@@ -18,10 +18,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min |
|
||||
| #012 | [M7 – Platzhalter, Namensregeln, Escaping](012-platzhalter-namensregeln.md) | erledigt | v0.1.0 | 8 h | 2 h 30 min |
|
||||
| #013 | [M8 – Läufe: Service und Abarbeitung](013-laeufe-abarbeitung.md) | erledigt | v0.1.0 | 14 h | 3 h 05 min |
|
||||
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | offen | v0.1.0 | 12 h | – |
|
||||
| #014 | [M9 – QR-Tags, Geräteseite, Etiketten, Scanner](014-qr-tags-geraeteseite.md) | erledigt | v0.1.0 | 12 h | 3 h 35 min |
|
||||
| #015 | [M10 – Dashboard und Archiv](015-dashboard-archiv.md) | offen | v0.1.0 | 8 h | – |
|
||||
| #016 | [M11 – Verzeichnis und LDAP-Abgleich](016-verzeichnis-ldap.md) | offen | v0.1.0 | 12 h | – |
|
||||
| #017 | [M12 – Seed der sieben Vorlagen](017-seed-sieben-vorlagen.md) | offen | v0.1.0 | 8 h | – |
|
||||
| #018 | [M13 – E2E-Tests, Anleitung, PDF](018-e2e-anleitung-pdf.md) | offen | v0.1.0 | 14 h | – |
|
||||
| #019 | [M14 – Betriebswege und Release v0.1.0](019-betrieb-release.md) | offen | v0.1.0 | 10 h | – |
|
||||
| | **Summe** | | | **136 h** | **16 h 50 min** |
|
||||
| | **Summe** | | | **136 h** | **20 h 25 min** |
|
||||
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -12,14 +12,14 @@
|
||||
"beispiel:stammdaten": "tsx scripts/beispiel-stammdaten.ts",
|
||||
"beispiel:vorlage": "tsx scripts/beispiel-vorlage.ts",
|
||||
"beispiel:lauf": "tsx scripts/beispiel-lauf.ts",
|
||||
"build": "next build",
|
||||
"build": "node scripts/wasm-bereitstellen.mjs && next build",
|
||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:studio": "prisma studio",
|
||||
"db:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||
"dev": "next dev",
|
||||
"dev": "node scripts/wasm-bereitstellen.mjs && next dev",
|
||||
"docs:pdf": "tsx scripts/anleitung-pdf.ts",
|
||||
"docs:screenshots": "tsx scripts/screenshots.ts",
|
||||
"format": "prettier --write .",
|
||||
|
||||
@@ -42,6 +42,12 @@ type Seite = {
|
||||
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
|
||||
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
|
||||
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
|
||||
* ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild
|
||||
@@ -161,6 +167,31 @@ const SEITEN: readonly Seite[] = [
|
||||
ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }),
|
||||
},
|
||||
{ name: "abteilungen", pfad: "/admin/abteilungen" },
|
||||
{ name: "scannen", pfad: "/scannen" },
|
||||
{ name: "tags", pfad: "/tags" },
|
||||
{
|
||||
name: "geraeteseite",
|
||||
pfad: "/geraete",
|
||||
vorbereiten: async (tab) => {
|
||||
await tab.getByRole("link", { name: "NB-001" }).first().click();
|
||||
await tab.waitForURL(/\/geraete\/[^/]+$/);
|
||||
},
|
||||
},
|
||||
{
|
||||
/*
|
||||
* Das Registrierformular gibt es nur zu einem Tag ohne Zuordnung. Welcher
|
||||
* das ist, weiss nur die Datenbank — also den Weg gehen, den auch ein
|
||||
* Techniker geht: von der Tag-Liste aus.
|
||||
*/
|
||||
name: "tag-registrieren",
|
||||
pfad: "/tags",
|
||||
vorbereiten: async (tab) => {
|
||||
await tab.getByRole("link", { name: /^LZ-/ }).first().click();
|
||||
await tab.waitForURL(/\/t\/LZ-/);
|
||||
},
|
||||
},
|
||||
{ name: "etikett-62x29", pfad: "/drucken/etiketten?layout=etikett", hell: true },
|
||||
{ name: "etiketten-a4", pfad: "/drucken/etiketten?layout=bogen", hell: true },
|
||||
];
|
||||
|
||||
function argument(name: string): string | undefined {
|
||||
@@ -248,7 +279,7 @@ try {
|
||||
for (const ansicht of ANSICHTEN) {
|
||||
const kontext = await browser.newContext({
|
||||
viewport: { width: ansicht.breite, height: ansicht.hoehe },
|
||||
colorScheme: "dark",
|
||||
colorScheme: seite.hell ? "light" : "dark",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
deviceScaleFactor: 2,
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -84,9 +84,26 @@ function Karte({
|
||||
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
|
||||
{zeile.tagCode}
|
||||
</span>
|
||||
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
|
||||
{zeile.anzeige}
|
||||
</p>
|
||||
{/*
|
||||
* 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}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Kennzeichen zeile={zeile} kundenEbene={kundenEbene} />
|
||||
@@ -275,6 +292,21 @@ export default async function GeraeteSeite({
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -38,6 +38,18 @@ export default async function LaufStartenSeite({
|
||||
startAuswahl(benutzer),
|
||||
kundenEbeneSichtbar(),
|
||||
]);
|
||||
|
||||
/*
|
||||
* Kommt ein Gerät mit, aber keine Person, dann ist der Besitzer gemeint.
|
||||
* Dieselbe Regel gilt im Dialog schon beim Wechsel des Geräts — sie darf nicht
|
||||
* daran hängen, dass jemand das Feld **anfasst**: von der Geräteseite und vom
|
||||
* gescannten Aufkleber kommt die Wahl fertig an, und der Techniker sähe sonst
|
||||
* „keine Person aus dem Verzeichnis“ (Reviewbefund zu #014).
|
||||
*/
|
||||
const geraetAusAdresse = einzeln("geraet");
|
||||
const besitzerDesGeraets = geraetAusAdresse
|
||||
? (auswahl.geraete.find((geraet) => geraet.id === geraetAusAdresse)?.besitzerId ?? undefined)
|
||||
: undefined;
|
||||
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
|
||||
|
||||
return (
|
||||
@@ -63,7 +75,11 @@ export default async function LaufStartenSeite({
|
||||
vorbelegung={{
|
||||
...(einzeln("vorlage") ? { versionId: einzeln("vorlage") as string } : {}),
|
||||
...(einzeln("geraet") ? { geraetId: einzeln("geraet") as string } : {}),
|
||||
...(einzeln("person") ? { mitarbeiterId: einzeln("person") as string } : {}),
|
||||
...(einzeln("person")
|
||||
? { mitarbeiterId: einzeln("person") as string }
|
||||
: besitzerDesGeraets
|
||||
? { mitarbeiterId: besitzerDesGeraets }
|
||||
: {}),
|
||||
}}
|
||||
kundenEbene={kundenEbene}
|
||||
kunden={kunden.map(({ id, name }) => ({ id, name }))}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -144,3 +144,29 @@ samp,
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Druckansichten und Etiketten sind immer hell (#014).
|
||||
*
|
||||
* `.druck-hell` stellt die Farbtoken eines Teilbaums auf die helle Fassung um.
|
||||
* Dadurch bleiben die Komponenten darin bei `bg-flaeche`, `text-text` und
|
||||
* `border-linie`, statt feste Hexwerte mitzubringen: der helle Modus oben greift
|
||||
* nur auf der Wurzel, und die gehoert dem Wurzel-Layout — eine Druckseite unter
|
||||
* `src/app/drucken/` kann sie nicht umschalten.
|
||||
*
|
||||
* Reinweiss statt des gebrochenen Weiss des hellen Modus: Papier ist weiss, und
|
||||
* ein Grauschleier hinter dem QR kostet Toner und Kontrast.
|
||||
*/
|
||||
.druck-hell {
|
||||
--color-grund: #ffffff;
|
||||
--color-flaeche: #ffffff;
|
||||
--color-flaeche-hoch: #f6f7f4;
|
||||
--color-linie: #d9ddd4;
|
||||
--color-text: #0b0d10;
|
||||
--color-nebentext: #5b6472;
|
||||
--color-akzent: #5e8a00;
|
||||
--color-auf-akzent: #ffffff;
|
||||
--color-akzent-flaeche: rgb(94 138 0 / 0.1);
|
||||
background-color: var(--color-grund);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import { darf } from "@/server/auth/rollen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { versionText } from "@/lib/version";
|
||||
|
||||
/** Wird in #014 auf `true` gestellt, zusammen mit der Seite. */
|
||||
const SCANNEN_VERFUEGBAR = false;
|
||||
/** Seit #014 gibt es `/scannen` – der Knopf fuehrt jetzt irgendwohin. */
|
||||
const SCANNEN_VERFUEGBAR = true;
|
||||
|
||||
const SYMBOLE: Record<string, LucideIcon> = {
|
||||
LayoutDashboard,
|
||||
@@ -96,10 +96,10 @@ function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
|
||||
/**
|
||||
* Der Einstieg des Technikers, an beiden Stellen ganz oben.
|
||||
*
|
||||
* Solange es `/scannen` nicht gibt (#014), wird **nicht** verlinkt: ein Knopf,
|
||||
* der so prominent sitzt, darf nicht auf eine Fehlerseite fuehren. Next holt den
|
||||
* Verweis beim Darueberfahren sogar vorab — jede Seite erzeugte damit eine 404
|
||||
* im Hintergrund. Dieselbe Regel gilt schon fuer die Navigation.
|
||||
* Der gedaempfte Zustand bleibt im Code: er gilt, solange eine Seite fehlt, und
|
||||
* genau diese Lage gab es hier bis #014. Ein Knopf, der so prominent sitzt, darf
|
||||
* nicht auf eine Fehlerseite fuehren — Next holt den Verweis beim Darueberfahren
|
||||
* sogar vorab, jede Seite erzeugte damit eine 404 im Hintergrund.
|
||||
*/
|
||||
function ScannenKnopf({ className }: { className?: string }) {
|
||||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,3 +21,31 @@ export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||
}
|
||||
return new URL(pfad, ersatzHerkunft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Basisadresse der Anwendung – **mit** ihrem Pfad (#014).
|
||||
*
|
||||
* `oeffentlicheAdresse` loest einen absoluten Pfad gegen die Basis auf, und
|
||||
* dabei faellt ein Unterpfad heraus: aus `https://firma.de/laufzettel` und
|
||||
* `/t/LZ-…` wird `https://firma.de/t/LZ-…`. Fuer eine Umleitung im laufenden
|
||||
* Betrieb faellt das niemandem auf, weil der Reverse Proxy den Pfad ohnehin
|
||||
* wieder anhaengt. Auf einem **gedruckten Aufkleber** ist es das Ende: er zeigt
|
||||
* fuer immer ins Leere.
|
||||
*
|
||||
* Wer eine Adresse baut, die jemand behaelt, nimmt deshalb diese Basis und
|
||||
* haengt relativ an. Der Schraegstrich am Ende entscheidet darueber, ob das
|
||||
* letzte Pfadstueck ersetzt oder ergaenzt wird — er wird hier gesetzt, statt
|
||||
* darauf zu hoffen.
|
||||
*/
|
||||
export function basisAdresse(ersatzHerkunft: string): URL {
|
||||
const roh = process.env["APP_URL"]?.trim();
|
||||
let basis: URL;
|
||||
try {
|
||||
basis = new URL(roh || ersatzHerkunft);
|
||||
} catch {
|
||||
// Eine unbrauchbare APP_URL soll den Druck nicht blockieren.
|
||||
basis = new URL(ersatzHerkunft);
|
||||
}
|
||||
if (!basis.pathname.endsWith("/")) basis.pathname += "/";
|
||||
return basis;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -77,3 +77,45 @@ export async function tagCodeEindeutigErzeugen(
|
||||
"Das deutet auf eine defekte Vergabepruefung hin, nicht auf einen erschoepften Vorrat.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Tag-Code aus dem, was der Scanner gefunden hat (#014).
|
||||
*
|
||||
* Auf dem Aufkleber steht ein QR mit `{APP_URL}/t/{tagCode}`. Gescannt wird aber
|
||||
* nicht nur der eigene Aufkleber: in der Kamera landen auch fremde QR-Codes,
|
||||
* alte Etiketten einer anderen Anwendung oder ein abgetippter Code aus dem
|
||||
* Ersatzfeld. Deshalb wird hier entschieden, **was** ein Fund bedeutet, und
|
||||
* nicht in der Kamerakomponente.
|
||||
*
|
||||
* Angenommen wird dreierlei, und zwar bewusst grosszuegig:
|
||||
*
|
||||
* 1. eine Adresse, deren Pfad auf `/t/<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,14 @@ export function istOeffentlich(pfad: string): boolean {
|
||||
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
|
||||
return [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
/*
|
||||
* `wasm-unsafe-eval` ist der schmale Bruder von `unsafe-eval`: er erlaubt
|
||||
* ausschliesslich das Uebersetzen von WebAssembly, sonst nichts. Der Scanner
|
||||
* (#014) liest QR-Codes ueber `zxing-wasm`, und ohne diese Erlaubnis lehnt
|
||||
* Chrome das Modul ab — die Kamera laeuft, erkennt aber nie etwas. Die
|
||||
* strenge Fassung von `script-src` bleibt davon unberuehrt.
|
||||
*/
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
// Tailwind erzeugt Stilangaben zur Laufzeit, deshalb unsafe-inline fuer Stile.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob:`,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Recht } from "@/server/auth/rollen";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import {
|
||||
@@ -12,8 +14,10 @@ import {
|
||||
geraetArchivieren,
|
||||
geraetBearbeiten,
|
||||
geraetLoeschen,
|
||||
laufFuerGeraetAnlegen,
|
||||
type ImportAbweisung,
|
||||
} from "@/server/services/geraete";
|
||||
import { LaufFehler, laufZurBearbeitungOeffnen } from "@/server/services/laeufe";
|
||||
|
||||
/**
|
||||
* Server Actions der Geraete (#011).
|
||||
@@ -44,7 +48,7 @@ const RECHTE_MELDUNG =
|
||||
* src/server/actions/vorlagen.ts).
|
||||
*/
|
||||
async function angemeldetOderMeldung(
|
||||
recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten",
|
||||
recht: Recht = "geraeteBearbeiten",
|
||||
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich(recht);
|
||||
@@ -58,7 +62,10 @@ async function angemeldetOderMeldung(
|
||||
|
||||
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
|
||||
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||
if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) {
|
||||
// `LaufFehler` steht mit dabei, seit die Geraeteseite eine Vorlage zuweist
|
||||
// (#014): dass eine Version noch ein Entwurf ist oder eine Pflichtangabe fehlt,
|
||||
// formuliert der Laufdienst — und zwar so, wie es der Techniker lesen soll.
|
||||
if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler || fehler instanceof LaufFehler) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
if (fehler instanceof RechteFehler) {
|
||||
@@ -287,3 +294,206 @@ export async function geraeteImportierenAktion(
|
||||
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Geraeteseite und Tag-Einstieg (#014) ----------------------------------
|
||||
|
||||
const RegistrierenSchema = z.object({
|
||||
geraetId: z.string().min(1),
|
||||
typ: TypSchema,
|
||||
hostname: z.string().trim().optional(),
|
||||
bezeichnung: z.string().trim().optional(),
|
||||
seriennummer: z.string().trim().optional(),
|
||||
inventarnummer: z.string().trim().optional(),
|
||||
mitarbeiterId: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Macht aus einem vorgedruckten Tag ein Gerät (#014).
|
||||
*
|
||||
* Keine eigene Regel: das Registrieren ist eine Änderung mit
|
||||
* `registrieren: true`, und ob dabei ein Name vorliegt, entscheidet der Dienst an
|
||||
* der einen Stelle, an der diese Regel steht. Der Erfolg endet auf der
|
||||
* Geräteseite — wer am Gerät steht und es gerade benannt hat, will als nächstes
|
||||
* loslegen, nicht ein Formular mit einer Erfolgsmeldung sehen.
|
||||
*/
|
||||
export async function geraetRegistrierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<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}`);
|
||||
}
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { LaufStatus, SchrittZustand, VersionStatus, type GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
@@ -34,6 +34,8 @@ export type GeraeteZeile = {
|
||||
readonly besitzerId: string | null;
|
||||
readonly besitzerName: string | null;
|
||||
readonly besitzerAktiv: boolean | null;
|
||||
/** Der rohe `department`-Wert der Person – die Geraeteseite nennt ihn im Kopf (#014). */
|
||||
readonly besitzerAbteilung: string | null;
|
||||
readonly anzahlLaeufe: number;
|
||||
};
|
||||
|
||||
@@ -76,7 +78,7 @@ const ZEILEN_AUSWAHL = {
|
||||
registriertAm: true,
|
||||
kundeId: true,
|
||||
kunde: { select: { name: true } },
|
||||
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true } },
|
||||
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true, abteilung: true } },
|
||||
_count: { select: { laeufe: true } },
|
||||
} satisfies Prisma.GeraetSelect;
|
||||
|
||||
@@ -101,6 +103,7 @@ function zeileBauen(zeile: Rohzeile): GeraeteZeile {
|
||||
besitzerId: zeile.mitarbeiter?.id ?? null,
|
||||
besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
|
||||
besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null,
|
||||
besitzerAbteilung: zeile.mitarbeiter?.abteilung ?? null,
|
||||
anzahlLaeufe: zeile._count.laeufe,
|
||||
};
|
||||
}
|
||||
@@ -167,6 +170,258 @@ export async function geraetHolen(
|
||||
return zeile ? zeileBauen(zeile) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Geraet zu einem Tag-Code – der Einstieg von `/t/<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 = {
|
||||
readonly id: string;
|
||||
readonly anzeigename: string;
|
||||
@@ -195,11 +450,25 @@ export type BesitzerAuswahl = {
|
||||
export async function besitzerKandidaten(
|
||||
benutzer: HandelnderBenutzer,
|
||||
grenze = 500,
|
||||
/**
|
||||
* Nur Personen dieses Kunden.
|
||||
*
|
||||
* Gehoert in die **Abfrage** und nicht hinter sie: wer erst 500 Personen aller
|
||||
* sichtbaren Kunden holt und danach filtert, hat bei einem grossen
|
||||
* Dienstleister womoeglich keine einzige passende in der Hand — und die
|
||||
* Meldung „nur die ersten 500“ stimmt dann auch nicht mehr (Reviewbefund zu
|
||||
* #014).
|
||||
*/
|
||||
kundeId?: string,
|
||||
): Promise<BesitzerAuswahl> {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
const zeilen = await prisma.mitarbeiter.findMany({
|
||||
where: {
|
||||
AND: [{ aktiv: true }, ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }])],
|
||||
AND: [
|
||||
{ aktiv: true },
|
||||
...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }]),
|
||||
...(kundeId === undefined ? [] : [{ kundeId }]),
|
||||
],
|
||||
},
|
||||
select: { id: true, anzeigename: true, aktiv: true, kundeId: true },
|
||||
orderBy: { anzeigename: "asc" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Prisma, type Geraet } from "@/generated/prisma/client";
|
||||
import { Prisma, type Geraet, type Lauf } from "@/generated/prisma/client";
|
||||
import { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
|
||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { laufAnlegen } from "@/server/services/laeufe";
|
||||
|
||||
/**
|
||||
* Geraete und ihre Stammdaten (#011).
|
||||
@@ -445,6 +446,61 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ein Lauf am Geraet ----------------------------------------------------
|
||||
|
||||
export type LaufAmGeraet = {
|
||||
readonly geraetId: string;
|
||||
readonly vorlagenVersionId: string;
|
||||
readonly zugewiesenAnId?: string | null;
|
||||
readonly faelligAm?: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt einen Lauf fuer dieses Geraet an — „Vorlage zuweisen“ auf der
|
||||
* Geraeteseite (#014).
|
||||
*
|
||||
* Die Regeln des Laufs bleiben in `services/laeufe.ts`: die Version muss
|
||||
* veroeffentlicht sein, das Geraet darf nicht archiviert sein, die Zuweisung
|
||||
* muss jemanden treffen, der den Lauf auch oeffnen kann. Nichts davon steht hier
|
||||
* ein zweites Mal — diese Funktion beantwortet nur die zwei Fragen, die das
|
||||
* **Geraet** beantwortet:
|
||||
*
|
||||
* 1. **Welcher Kunde?** Der des Geraets. Eine von aussen mitgeschickte `kundeId`
|
||||
* gibt es hier deshalb nicht; im Modus INTERN verwirft `kundeErmitteln` sie
|
||||
* ohnehin (#008).
|
||||
* 2. **Welche Person?** Der Besitzer, wenn es einen gibt. Wer an einem Notebook
|
||||
* einen Lauf anlegt, meint die Person, der es gehoert; ein Server und ein
|
||||
* Raum haben keine, und dann bleibt das Feld leer.
|
||||
*
|
||||
* Der Lauf entsteht als `OFFEN` und damit als Zuweisung — gestartet wird er
|
||||
* erst, wenn jemand ihn zur Bearbeitung oeffnet.
|
||||
*/
|
||||
export async function laufFuerGeraetAnlegen(
|
||||
eingabe: LaufAmGeraet,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<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 ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }[]>;
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,15 @@ describe("wertMaskieren fuer PowerShell", () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => {
|
||||
it("kommt als genau ein Argument an und fuehrt nichts aus", () => {
|
||||
/*
|
||||
* Eine Minute statt der fuenf Sekunden, die Vitest sonst gibt: liegt kein
|
||||
* `pwsh` auf der Maschine, laeuft dieser Test in einem Container, und der
|
||||
* startet erst. Unter Last — ein Ende-zu-Ende-Lauf nebenher genuegt — reichte
|
||||
* die Vorgabe nicht, und der Test kippte um, ohne dass an der Maskierung
|
||||
* etwas falsch war. Ein Test, der gelegentlich grundlos rot ist, wird bald
|
||||
* uebersehen.
|
||||
*/
|
||||
it("kommt als genau ein Argument an und fuehrt nichts aus", { timeout: 90_000 }, () => {
|
||||
for (const wert of [BOESARTIG, TYPOGRAFISCH]) {
|
||||
const maskiert = wertMaskieren(wert, "powershell");
|
||||
const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`;
|
||||
|
||||
@@ -29,8 +29,16 @@ function teile(richtlinie: string): Map<string, string> {
|
||||
describe("Ausgelieferter Stand", () => {
|
||||
const richtlinie = sicherheitsrichtlinie(NONCE, false);
|
||||
|
||||
it("erlaubt kein unsafe-eval", () => {
|
||||
expect(richtlinie).not.toContain("unsafe-eval");
|
||||
it("erlaubt kein unsafe-eval – wohl aber WebAssembly", () => {
|
||||
/*
|
||||
* `wasm-unsafe-eval` enthaelt die Zeichenkette „unsafe-eval“, ist aber etwas
|
||||
* ganz anderes: es erlaubt nur das Uebersetzen von WebAssembly, kein `eval`
|
||||
* auf JavaScript. Der Scanner (#014) braucht es, sonst lehnt Chrome den
|
||||
* QR-Leser ab. Geprueft wird deshalb auf die **Marke**, nicht auf das Wort.
|
||||
*/
|
||||
const script = teile(richtlinie).get("script-src") ?? "";
|
||||
expect(script.split(" ")).not.toContain("'unsafe-eval'");
|
||||
expect(script.split(" ")).toContain("'wasm-unsafe-eval'");
|
||||
});
|
||||
|
||||
it("erlaubt keine WebSocket-Verbindungen", () => {
|
||||
@@ -38,7 +46,9 @@ describe("Ausgelieferter Stand", () => {
|
||||
});
|
||||
|
||||
it("bindet Skripte an die Nonce dieser Anfrage", () => {
|
||||
expect(teile(richtlinie).get("script-src")).toBe(`'self' 'nonce-${NONCE}' 'strict-dynamic'`);
|
||||
expect(teile(richtlinie).get("script-src")).toBe(
|
||||
`'self' 'nonce-${NONCE}' 'strict-dynamic' 'wasm-unsafe-eval'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("verbietet das Einbetten in fremde Seiten und Objekte", () => {
|
||||
@@ -58,7 +68,7 @@ describe("Entwicklungsmodus", () => {
|
||||
const richtlinie = sicherheitsrichtlinie(NONCE, true);
|
||||
|
||||
it("erlaubt unsafe-eval, sonst hydriert React nicht", () => {
|
||||
expect(teile(richtlinie).get("script-src")).toContain("'unsafe-eval'");
|
||||
expect(teile(richtlinie).get("script-src")?.split(" ")).toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => {
|
||||
@@ -130,7 +140,10 @@ describe("Die Entscheidung im Proxy selbst", () => {
|
||||
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
|
||||
);
|
||||
expect(richtlinie).toBeTruthy();
|
||||
expect(richtlinie).not.toContain("unsafe-eval");
|
||||
// Auf die Marke geprueft, nicht auf das Wort: `wasm-unsafe-eval` enthaelt es
|
||||
// und ist trotzdem keine Lockerung fuer JavaScript (#014).
|
||||
expect(richtlinie).not.toContain(" 'unsafe-eval'");
|
||||
expect(richtlinie).toContain("'wasm-unsafe-eval'");
|
||||
expect(richtlinie).toContain("connect-src 'self';");
|
||||
expect(richtlinie).toContain("'strict-dynamic'");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||