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

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

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

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

Closes #14

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent c78a6ac1af
commit 5f1b0d1435
58 changed files with 4828 additions and 54 deletions
+40
View File
@@ -200,6 +200,46 @@ hat. Bei verstecktem Tab pausiert die Abfrage.
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück.
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:
+61 -2
View File
@@ -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.
+67 -24
View File
@@ -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).
+2 -2
View File
@@ -18,10 +18,10 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
| #011 | [M6 – Mitarbeiter, Abteilungen, Geräte](011-mitarbeiter-abteilungen-geraete.md) | erledigt | v0.1.0 | 9 h | 2 h 15 min |
| #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** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB