baustelle-pwa/ROADMAP.md
Eddy 64ebbf7850 Rauswurf aus dem Auftrag behoben, wartende Fotos am Auftrag sichtbar
Gemeldet 18.09.2026: nach "Fertig" in der Kamera stand die App auf der
Auftragsliste, und im Auftrag war von den Fotos nichts zu sehen.

Ursache Rauswurf (Prod-Log 17.09., Auftrag 111): closeModal() ruft
history.back() (asynchron), direkt danach setzt router.go() den Hash.
Der Ruecksprung laeuft erst nach dem Hash-Wechsel und springt hinter
den neuen Eintrag zurueck - angezeigt wird der Auftrag, in der Adresse
steht '#/orders'. Das naechste router.navigate() (Kamera "Fertig")
zeichnet dann die Liste. Betraf nur in derselben Sitzung ueber den
Plus-Knopf angelegte Auftraege.

- closeModal(): Zaehler _pendingBacks statt Boolean (KB #1209), History-
  Eintrag wird vor dem Cleanup zurueckgenommen
- router.go() und pushModal() warten ueber historySettled() auf
  ausstehende Ruecksprunge (Schutzzeit 1,5 s)
- Kamera und Galerie-Auswahl steuern nach dem Sichern ausdruecklich
  '#/orders/<id>' an statt den Hash der Adresszeile
- Auftragsseite: Block "Warten auf Upload (n)" mit Vorschaubildern aus
  der Warteschlange und Klartext; zieht nach photo-uploaded selbst nach
- Hinweis-Toast beim Schliessen der Kamera, wenn noch Fotos warten

Verifiziert lokal in Chromium mit echtem Offline-Modus; die Kamera
selbst (getUserMedia) am Handy ist noch nicht getestet. Kein Deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:36:14 +02:00

328 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ROADMAP — Baustelle PWA
Stand: 2026-09-18 · Gegenstück: `data/bericht` (Modul-Version 1.5.1)
Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
Bericht-Moduls.
---
## 1. Nachgezogen aus Bericht 1.5.0 ✅ (2026-08-22, Modul 1.5.1)
Das Modul hatte mit 1.5.0 Infrastruktur bekommen, von der die PWA nichts hatte.
Die Beschreibungen unten schildern jeweils den Zustand davor.
### 1.1 Echte Thumbnails statt Vollbilder ✅ [API]
Bis dahin lud die PWA für **jede Kachel das komplette Foto**:
- `loadThumbs()` ruft `api.getPhotoBlobUrl(rel, 'small')` auf → `photo.php?size=small`
- `photo.php` liefert nur dann ein Thumbnail, wenn Dolibarr eines unter
`thumbs/<name>_small.<ext>` **vorgefertigt** hat
- Die PWA lädt ihre Fotos aber per `orders.php?action=upload_photo` hoch, und dort wird
**nie `vignette()` aufgerufen** → es existiert nie ein Thumb → es kommt immer das Original
- Bei 30 Baustellenfotos sind das im Mobilfunknetz 30 × 0,51 MB (bei über die
Dolibarr-Oberfläche hochgeladenen Originalen entsprechend mehr)
Das Modul kann das seit 1.5.0 längst: `bericht_attachment_thumb($full, $maxside)` in
`lib/bericht.lib.php` (GD, Cache unter `bericht/thumbs/`, EXIF-Rotation, Speicher-Notbremse).
`api/photo.php` lädt diese lib bereits.
- [x] **[API]** `photo.php` um `size=thumb&w=<px>` erweitern → `bericht_attachment_thumb()`,
mit ETag aus mtime und `Cache-Control: private, max-age=…, immutable`
- [x] `loadThumbs()` umbauen: `<img src="…api/photo.php?…&size=thumb">` **direkt** setzen statt
fetch → Blob → ObjectURL. Damit greifen HTTP-Cache und `loading="lazy"` wirklich; heute
sind beide wirkungslos, weil der Blob schon vorher komplett geladen wird.
- [x] Sequenzielle Schleife auflösen — `for (const t of thumbs) { await … }` lädt Foto für Foto
nacheinander. Beim `<img>`-Weg erledigt der Browser das parallel von selbst.
- [x] Doppel-Ladung entfernen: schlägt `size=small` fehl, wird das Original **noch einmal**
geladen.
### 1.2 Aufnahmezeit statt Dateidatum ✅ [API]
1.5.0 hat einen eigenen EXIF-Parser bekommen (`bericht_jpeg_taken_at()` /
`bericht_file_taken_at()`), weil im Prod-Container das `exif`-Modul fehlt. Der Editor sortiert
Anhänge damit nach Aufnahmezeit. `orders.php?action=photos` lieferte nur `filemtime`.
- [x] **[API]** `taken_at` je Datei ausgeben
- [x] Fotoliste nach Aufnahmezeit sortieren (Auswahl gemerkt, wie im Editor)
### 1.3 Seiten-Titel kommt nie an ✅ [API]
`api/pages.php` **kann** `title` einer Seite setzen, die PWA rendert ihn (`page-title-badge`,
`openPageActionsModal(…, title)`) — aber `api/reports.php` gibt `title` im `pages`-Array
**nicht aus**. Ein im Editor gesetzter Titel ist in der PWA also unsichtbar, und ein in der PWA
gesetzter Titel scheint nach dem Neuladen verschwunden.
- [x] **[API]** `'title' => $p->title` in `reports.php` ergänzen (eine Zeile)
### 1.4 Bearbeitete Seiten und Raster-Layouts ✅ [API]
Seit 1.5.0 hat eine Seite `composite_path` (das im Editor gebaute Seitenbild) und `layout`
(`single`, `grid_2`, `grid_4`, `before_after`, `title_only`, …). Die PWA zeigt stur
`source_path`:
- Nach Bearbeitung im Desktop-Editor zeigt die PWA weiter das **Rohbild** ohne Anmerkungen
- Bei einer Rasterseite ist `source_path` nur **eines von bis zu sechs** Bildern
- Bei `title_only` gibt es gar keine Quelle → ❌-Kachel
- [x] **[API]** `composite_path` in `reports.php` mit ausgeben
- [x] PWA zeigt `composite_path`, wenn vorhanden, sonst `source_path`
- [x] Layout-Kennzeichen an der Kachel (z. B. „⊞ 4er-Raster"), damit klar ist, warum dort
mehr drauf ist als das eine Foto
---
## 2. Verbesserungen unabhängig vom Modul
### 2.1 Browser-Dialoge raus (10 Stück) ✅
`confirm()` / `alert()` / `prompt()` widersprechen der Hausregel und sehen in der
Standalone-PWA auf Android wie ein Systemfehler aus. Ein sauberes Muster liegt schon vor:
das „Bericht löschen"-Modal in `router.on('/reports/:id')`.
| Stelle | Zeile (vor dem Umbau) |
|---|---|
| PIN stimmt nicht überein | `alert` 295 |
| Bericht finalisieren | `confirm` 1111 |
| PIN-Schutz deaktivieren | `confirm` 1264 |
| Material-Eintrag löschen | `confirm` 1432 |
| Seite aus Bericht entfernen | `confirm` 1809 |
| Foto löschen | `confirm` 2701 |
| Nicht hochgeladenes Foto löschen | `confirm` 3019 |
| Mess-Skala: Länge eingeben | `prompt` 3409 |
| Ungültiges Längenformat | `alert` 3419 |
| „Zuerst kalibrieren" | `alert` 3440 |
- [x] Eine `confirmDialog({title, text, danger})`-Hilfsfunktion (Promise) + `inputDialog()`
für die Längeneingabe, alle zehn Stellen darauf umstellen
### 2.2 Fotos offline sichtbar halten ✅
Der Service Worker reichte alles unter `/custom/bericht/api/` ungecacht durch. Auf der Baustelle
ohne Netz ist damit **kein einziges** schon angesehenes Foto verfügbar.
- [x] Eigener Runtime-Cache `baustelle-media` für `photo.php`-GETs, cache-first mit
Größenbegrenzung (LRU, z. B. 200 Einträge). Die Thumb-URLs aus 1.1 sind dafür der
richtige Kandidat — klein und unveränderlich.
- [x] Vom App-Shell-Cache getrennt halten, damit ein Deploy die Medien nicht wegräumt
---
## 3. Textnotizen ✅ (2026-08-22)
Getippte Notiz zum Auftrag — für alles, was man lieber schreibt als diktiert: Zählernummern,
Absprachen, Maße.
- [x] „📝 Notizen (n)" am Auftrag, direkt unter der Sprachnotiz
- [x] Liste mit Betreff, zweizeiliger Vorschau und Zeitstempel; Antippen öffnet zum Lesen
und Ändern, 🗑 löscht (mit Rückfrage)
- [x] Editor: Betreff-Feld + Textfeld über die volle Höhe, Speichern über ✓ in der Kopfzeile,
Rückfrage beim Verlassen mit ungespeicherten Änderungen
- [x] Ablage als `notiz_<betreff>_<datum>.txt` im Auftragsordner (`api/note.php`) — genau der
Weg der Sprachnotiz. Damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des
Bericht-Editors sichtbar.
- [x] Notizen sind aus „Weitere Dokumente" ausgefiltert — sie haben ihre eigene Sektion und
stünden dort sonst doppelt
**Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am
Handy ohne Stift ist das unbrauchbar.
## 4. Teilen aus WhatsApp ✅ (2026-08-30)
Bilder und Beschreibungstexte, die die Kundin per WhatsApp schickt, landen direkt im Auftrag.
Vorher endete jeder Teilen-Vorgang in einer Sackgasse:
- `share.html` fragte `api.getToken()` — ein JWT in IndexedDB, das es **seit der SSO-Umstellung
gar nicht mehr gibt** (`api.login()` legt nur noch `user` ab, die Sitzung steckt im
HttpOnly-Cookie `awl_sso`). Ergebnis: immer „Bitte zuerst anmelden", und der Weg „Zur App"
verwarf das Geteilte.
- Der Service Worker sicherte **nur Dateien**. Ein Text-Share (oder Bild + Bildunterschrift)
schrieb nichts in IndexedDB → „Keine geteilten Fotos gefunden".
- [x] Anmeldung über `api/verify.php` (Cookie) statt über das nicht mehr existierende Token
- [x] Anmelden **in** der Teilen-Seite (Passwort + Passkey) — das Geteilte bleibt dabei gesichert
- [x] Service Worker sichert `title`, `text` und `url` mit; Weiterleitung auch ohne Datei
- [x] Auswahl-Seite: Bildvorschau, Betreff + Text zum Nachbessern, Auftragssuche
(ohne Suchbegriff die offenen Aufträge, mit Suchbegriff alle)
- [x] Ablegen: Bilder über die normale Upload-Queue (Persist-First), Text als Notiz über
`api/note.php`. Schlägt die Notiz fehl (kein Netz), bleibt der Text gesichert und der
Vorgang ist wiederholbar
- [x] Banner in der App, wenn noch etwas Geteiltes unabgelegt bereitliegt
- [x] **Mehrere Nachrichten hintereinander**: die letzten drei Aufträge stehen unter „Zuletzt benutzt"
oben; innerhalb von 30 Minuten ist der letzte vorausgewählt (danach nur noch Schnellzugriff —
eine Dauer-Vorauswahl würde irgendwann im falschen Auftrag landen)
## 5. Offline lesbar statt „Failed to fetch" ✅ (2026-08-31)
Gemeldet von Eddy am 2026-08-31: Die App zeigte beim normalen Öffnen nur
**„⚠️ Failed to fetch"**, während das Teilen aus WhatsApp weiter funktionierte.
### Befund
- Prod-Deploy vollständig, alle Dateien liefern 200, die API antwortet korrekt
(403 ohne `X-Requested-With`, 401 ohne Anmeldung) — **kein Server- oder Deploy-Fehler.**
- Im frischen Desktop-Chromium lief der ganze Weg sauber durch
(`auth.php` → `verify.php``orders.php?open=1`, alle 200).
- „Failed to fetch" ist die *rohe Browsermeldung* eines `fetch()`, das gar nicht bis zum
Server kam. Sie stand 1:1 im UI, weil die Routen im `catch` stumpf `e.message` anzeigten.
- Warum es beim Teilen nicht auffiel: seit `eaad796` merkt sich `share.html` die letzten
Aufträge lokal und kommt ohne Netz aus. Die Hauptapp hatte für die Listen
**keinerlei Offline-Fallback**`lib/offline.js` puffert nur Foto-Uploads.
### Umgesetzt (Muster aus KB #1037 / #1041, dort im Stundenzettel verifiziert)
- [x] **Netzfehler kenntlich machen**`lib/api.js` wandelt den `TypeError` aus `fetch()`
in einen eigenen Fehler mit `offline = true` und deutschem Text
(„Keine Verbindung zum Server" / „Zeitüberschreitung"). Ein 4xx/5xx trägt die
Markierung **nicht** und wird weiterhin als Serverfehler gemeldet.
- [x] **`isTransient()` in `lib/offline.js` nachgezogen** — die Funktion erkannte Netzfehler
an `err instanceof TypeError`. Ohne Anpassung hätte ein Foto aus dem Funkloch als
*dauerhaft* fehlgeschlagen gegolten und wäre nach sechs Versuchen in der
Quarantäne gelandet, statt hochgeladen zu werden.
- [x] **Datenspiegel** (`offline.mirrorSave/mirrorLoad/mirrorClear`) — jede erfolgreiche
Antwort wird in IndexedDB gespiegelt: Auftragsliste, Kundenliste, Berichte sowie
Auftrags- und Kundendetails (letztere auf 30 Einträge begrenzt, der gerade
geschriebene Schlüssel ist beim Aufräumen geschützt — KB #1037).
- [x] **Sichtbares Band** „📴 Kein Netz — angezeigt wird der Stand von HH:MM Uhr".
Ohne den Hinweis hält man alte Zahlen für aktuell. Es gehört zur Ansicht und wird
vom Router bei jedem Routenwechsel zurückgesetzt.
- [x] **Suche ohne Netz** filtert im gespiegelten Stand statt ins Leere zu laufen —
mit einmaligem Hinweis, dass nur der gespeicherte Stand durchsucht wird.
- [x] **Beim Abmelden wird der Spiegel gelöscht** — sonst sähe der nächste Benutzer am
selben Gerät offline die Aufträge, Kunden und Preise des vorherigen (KB #1041).
- [x] **`sw.js`: echter Fallback.** Der Same-Origin-Zweig endete auf
`caches.match(e.request)`; fand der Cache nichts, wurde `respondWith(undefined)`
aufgerufen — und die Anfrage scheiterte erneut mit genau der Meldung, die der
Fallback verhindern sollte. Jetzt kommt immer eine echte Response: Seite →
App-Hülle aus dem Cache, sonst eine erklärende Seite; API → 503 mit JSON.
- [x] **Nur vollständige Antworten cachen** (`ok && status === 200 && type !== 'opaque'`) —
eine 206 (Teilinhalt beim Audio-Seek) liefert der Worker später als kaputte Datei aus.
- [x] **Precache nach Deploy** — der Cache trägt nach jedem Deploy einen neuen Namen und
startet leer, `app.js`/`app.css` inklusive. Eine feste Liste hilft nicht, weil die
URLs `?v=<mtime>` tragen. Die Seite meldet dem Worker nach dem Laden, was sie
tatsächlich geholt hat (`PRECACHE`-Nachricht), er cacht es einzeln nach (KB #1041).
### Verifiziert (2026-08-31, lokale Testinstanz, Chromium)
- Spiegel wird geschrieben (`mirror:orders:open`, `:customers`, `:reports`,
`:detail:order:<id>`, `:detail:customer:<id>`)
- `fetch` auf Fehler gesetzt: Auftragsliste zeigt 6 Aufträge aus dem Spiegel + Band
„Stand von 19:03 Uhr"; Kundenliste ohne Spiegel zeigt die verständliche Meldung
statt „Failed to fetch"
- Suche „Rolfs" ohne Netz → 2 Treffer aus dem Spiegel + Hinweis-Toast
- Band verschwindet auf Routen ohne Spiegel und nach Rückkehr des Netzes
- **Echtes Funkloch** (`context.setOffline(true)` + `reload()`): App startet aus dem
Cache, zeigt Band und 6 Aufträge; im Cache liegen `app.js`, `app.css` und alle
`lib/*.js` mit ihrem `?v=` — vorher wären sie nach einem Deploy nicht dagewesen
- Konsole beim Offline-Start: nur Netzfehler, keine JS-Fehler
### Bewusst nicht gebaut
Keine Schreib-Warteschlange für Auftragsdaten. Die bräuchte Idempotenz und
Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst
(Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz.
## 6. Rauswurf aus dem Auftrag + unsichtbare Warteschlange (gemeldet 2026-09-18)
Gemeldet von Eddy: am 17.09.2026 gegen 18 Uhr mehrere Fotos in einem Auftrag gemacht,
„Fertig" getippt — die App stand danach auf der **Auftragsliste** statt im Auftrag. Wieder
in den Auftrag gegangen: **keine Fotos zu sehen**, kein Hinweis, dass sie noch warten.
### Befund (Prod-Zugriffslog 17.09., Auftrag 111 — kein Raten)
- 17:30:56 `POST orders.php?action=create` — Auftrag 111 über den -Knopf angelegt. In
derselben Sekunde laufen **zwei** Routen gleichzeitig: `orders.php` (Liste) **und**
`orders.php?id=111` (Detail). Das ist die Signatur des Fehlers.
- 17:40:45 `verify.php` + `orders.php` (Liste) — der Rauswurf. **Kein** `index.php` davor,
also kein Reload, kein SW-Update, kein Logout (kein 401), kein Absturz.
- 17:40:47 Auftrag 111 erneut geöffnet, `action=photos` liefert eine **leere** Liste;
die Uploads kommen erst 17:40:54 und 17:57:2517:58:22 an.
**Ursache Rauswurf — History-Race in `openNewOrderModal`:**
```js
closeModal(modal); // -> history.back() (asynchron!)
router.go('#/orders/' + res.order.id); // -> location.hash = … (sofort)
```
`history.back()` wird erst nach dem Hash-Wechsel ausgeführt und springt dann **hinter** den
neuen Eintrag zurück. Ergebnis: Auf dem Bildschirm steht der Auftrag (seine Route wurde als
letzte fertig), in der Adresszeile steht aber `#/orders`. Alles, was danach
`router.navigate()` ruft — z. B. „Fertig" in der Kamera — zeichnet die **Auftragsliste**.
Nachgestellt in Chromium (18.09.2026): ohne Abwarten endet der Ablauf auf `#/orders`, mit
abgewartetem `popstate` auf `#/orders/111`.
Dieselbe Falle an zwei weiteren Stellen: „Neuen Bericht anlegen" (`#/reports/<id>`) und
Lieferschein-Unterschrift (`#/shipments/<id>`). Betroffen ist nur, wer den Auftrag **in
derselben Sitzung frisch angelegt** hat — deshalb fiel es nicht früher auf.
**Ursache „keine Fotos, keine Meldung":** Die Auftragsseite zeigt ausschließlich, was der
Server schon hat. Fotos in der Warteschlange erscheinen nirgends außer als Zahl im kleinen
Badge oben rechts (🔴 3 / 🟡 3), und die Seite zeichnet sich nach einem im Hintergrund
fertig gewordenen Upload nicht neu. Verloren ging nichts — alle Fotos kamen an.
### Umsetzung (2026-09-18)
- [x] **Race zentral behoben:** `closeModal()` zählt selbst ausgelöste `history.back()`
(`_pendingBacks`, Zähler statt Boolean, KB #1209 — der Boolean verschluckt bei zwei
schnell nacheinander geschlossenen Dialogen ein `popstate`), `router.go()` wartet über
`window.historySettled()`, bis alle durch sind, und wechselt erst dann den Hash
(Schutzzeit 1,5 s, falls ein `popstate` ausbleibt). Alle Aufrufstellen bleiben wie sie
sind. `closeModal()` nimmt den History-Eintrag jetzt **vor** dem Cleanup zurück, damit
ein navigierender Cleanup den Rücksprung sieht; `pushModal()` wartet ebenfalls.
- [x] **Sicherheitsnetz in Kamera und Galerie-Auswahl:** nach „Fertig" ausdrücklich
`router.go('#/orders/<id>')` statt `router.navigate()` — gezeichnet wird der Auftrag,
für den fotografiert wurde, nicht „was gerade in der Adresszeile steht".
- [x] **Wartende Fotos am Auftrag:** Block „⏳ Warten auf Upload (n)" über „Hochgeladene
Fotos", mit Vorschaubildern direkt aus der Warteschlange (max. 12, danach „+n") und
Klartext („📴 Kein Netz — 3 Fotos sind auf dem Gerät gesichert und werden gesendet,
sobald wieder Netz da ist."), fehlgeschlagene rot markiert; Tipp öffnet die Warteschlange.
- [x] **Auftragsseite zieht nach:** bei `photo-uploaded` für diesen Auftrag wird nach 2,5 s
Ruhe neu gezeichnet (Scrollposition bleibt) — nicht, solange ein Modal offen ist,
die Mehrfachauswahl läuft oder eine Sprachnotiz abgespielt wird.
- [x] **Hinweis beim Schließen der Kamera:** „📴 Kein Netz — n Fotos auf dem Gerät
gesichert, Upload folgt automatisch" bzw. „⏳ n Fotos warten noch auf den Upload".
### Verifiziert (2026-09-18, lokale Testinstanz, Chromium, Auftrag 62)
- Ablauf „Modal zu + `router.go`": endet auf `#/orders/62` (vorher `#/orders`)
- **Echtes Funkloch** (`context.setOffline(true)`): 3 Fotos gesichert, „Fertig" → bleibt im
Auftrag, Offline-Band, Block mit 3 Vorschaubildern und Klartext, Badge 🔴 3
- Netz zurück: Upload läuft an, Block verschwindet von selbst, „Hochgeladene Fotos" 2 → 5,
Badge 🟢, Warteschlange leer. Testfotos danach wieder entfernt.
- **Nicht** getestet: die echte Kamera (`getUserMedia`) am Handy — der Test hat den
Kamera-Cleanup nachgestellt, nicht die Kamera selbst bedient.
### Offen
- [ ] **Doppel-Uploads** (Log: mehrere Antworten mit `duplicate:true`) — Seite und Service
Worker (Background Sync) laden dasselbe Queue-Item parallel hoch. Dank md5-Abgleich
seit Bericht 1.6.0 folgenlos, kostet aber im Mobilfunk doppeltes Datenvolumen.
Lösung: Sperre je Item in IndexedDB (`uploading_since`), die der jeweils andere achtet.
- [ ] **SW-Update lädt mitten in der Arbeit neu** — beim Test beobachtet: `controllerchange`
`location.reload()` (index.php) feuerte, während drei Fotos in der Warteschlange
lagen; der Browser zeigte dazu seinen eigenen „Seite verlassen?"-Dialog (`beforeunload`).
War am 17.09. **nicht** die Ursache (kein Deploy, kein `index.php` im Log), trifft aber
jeden, der direkt nach einem Deploy fotografiert. Lösung: Reload aufschieben, solange
ein Modal (Kamera!) offen ist oder die Warteschlange nicht leer ist.
## Erledigt
### 2026-08-22
Abschnitte 1, 2 und 3 vollständig. Dazu nebenbei behoben:
- `.modal-header`, `.close-btn` und `.modal-body` hatten **überhaupt kein CSS**, obwohl das
Markup sie seit Längerem nutzt (u. a. beim Löschen eines Berichts) — der Schließen-Knopf
saß dadurch unter der Überschrift statt daneben.
- Das Laden eines Thumbnails überschrieb den kompletten Kachel-Inhalt (`t.innerHTML = …`)
und nahm die Overlays mit: Auswahl-Häkchen in der Mehrfachauswahl und Seitennummer der
Bericht-Seiten waren weg, sobald das Bild da war.
- Beim Abmelden bleiben keine Kundenfotos mehr auf dem Gerät (`CLEAR_MEDIA` an den Service
Worker + `clearPhotoCache()`).
### Davor
Siehe README (Feature-Liste) und die Commit-Historie: SSO-Migration auf awlauth,
WebAuthn-Login, Neuer-Auftrag-Formular, Live-Kamera mit Persist-First-Queue,
Lieferschein-Unterschrift.