Doppel-Uploads (Prod-Log 17.09.: reihenweise duplicate:true): Seite
(syncQueue) und Service Worker (Background Sync) arbeiteten dieselbe
IndexedDB-Queue ab, ohne voneinander zu wissen. Dank md5-Abgleich
folgenlos, aber doppeltes Datenvolumen. Mit eingestelltem Verkleinern
sogar zwei verschiedene Dateien im Auftrag: der Worker lud das
Original, die Seite danach die kleine Fassung.
- idb.js: queueKeys/queuePatch/queueClaim/queueRelease - Lesen-Aendern-
Schreiben in einer readwrite-Transaktion, ueber Kontexte atomar
- syncQueue (Seite) und drainQueue (Worker) belegen jedes Foto vor dem
Upload (Lease 90 s > laengster Upload-Timeout); belegt = ueberspringen,
abgelaufen = uebernehmen. Nur noch ein Foto gleichzeitig im Speicher
- enqueuePhoto({hold:true}) + releaseHold: waehrend des Verkleinerns
bleibt das Foto belegt
- Versuchszaehler, retryFailed und replaceQueuedBlob per Patch statt
ganzes Item zurueckschreiben
Reload nach App-Update: controllerchange lud sofort neu - offene Kamera,
getippte Notiz, halbe Unterschrift weg; mit Fotos in der Warteschlange
kam dazu der Browserdialog "Seite verlassen?".
- window.appBusy(): Modal, Auswahl, laufender Upload, Eingabe im Fokus,
laufende Sprachnotiz. PIN-Sperre zaehlt bewusst nicht
- index.php holt den Reload nach, sobald nichts mehr offen ist; eigener
Reload setzt __selfReload, beforeunload warnt dann nicht
Verifiziert lokal in Chromium: echte Kamera-Oberflaeche mit Canvas-
Stream offline, paralleler Zugriff Seite+Worker (3 Fotos = 3 POSTs im
Serverlog, kein duplicate), Verkleinern (eine Datei, kleine Fassung),
fremde Lease frisch/abgelaufen, aufgeschobener Reload ohne Dialog.
Nicht getestet: Kamera-Hardware und Background Sync bei Display aus am
Handy. Kein Deploy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
207 lines
14 KiB
Markdown
207 lines
14 KiB
Markdown
# Baustelle PWA
|
|
|
|
Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und Textnotizen, Skizzen, offline-fähig. Spricht die REST-API des Dolibarr-Bericht-Moduls.
|
|
|
|
## Stack
|
|
|
|
- **Vanilla JavaScript** — kein Build, keine Framework-Abhängigkeit
|
|
- **Service Worker** für App-Shell-Cache (Workbox-frei, eigene Implementierung)
|
|
- **IndexedDB** für die Offline-Upload-Queue (Auth-Session steckt im HttpOnly-Cookie, nicht in IndexedDB)
|
|
- **REST API** des `bericht`-Dolibarr-Moduls unter `/custom/bericht/api/`
|
|
|
|
## Features
|
|
|
|
**Aufträge**
|
|
- ✅ Login mit Dolibarr-Credentials → zentrales SSO (HttpOnly-Cookie `awl_sso`, same-origin; Single-Sign-On/-Logout über alle AWL-Apps). WebAuthn/Passkey-Login möglich
|
|
- ✅ **„Heute"-Ansicht**: offene Aufträge des Tages, gefiltert aus der normalen Auftragsliste, mit Route
|
|
für alle Termine auf einen Blick (öffnet Google Maps mit allen Adressen als Wegpunkten)
|
|
- ✅ Auftragsliste mit Suche, gefiltert auf eigene Aufträge (Multi-User)
|
|
- ✅ Toggle "Auch abgeschlossene" (Filter in localStorage persistiert)
|
|
- ✅ Auftragsdetail mit Kunde, Adresse, Telefon (Click-to-Call)
|
|
- ✅ **Neuer Auftrag** (FAB): Kunde per Suche/Zuletzt-verwendet wählen, dann **Ihr Zeichen** (Pflicht), **Auftragsbeschreibung** (optional), **Geplanter Liefertermin** (Datum + Uhrzeit) und Checkbox **Auftrag direkt freigeben**. Kunden-Defaults (Zahlungsbedingung/Zahlart, Ansprechpartner) werden übernommen. Bei „direkt freigeben" wird der Auftrag **ohne Position** validiert — die Leistungen kommen aus dem Stundenzettel. (Bis Bericht 1.3.1 hängte das Backend hier eine Std-Lohn-Zeile mit Menge 1 an; die verfälschte den Auftragswert, stand als offene Restmenge in der Lieferauflistung und im Kunden-PDF.)
|
|
|
|
**Foto-Upload**
|
|
- ✅ Live-Kamera-Modal (getUserMedia): bleibt für Serien-Aufnahmen offen — Auslöser + Filmstreifen mit Upload-Status je Foto (💾 gesichert → ⏳ lädt hoch → ✓ fertig), Front-/Rückkamera-Umschaltung, Fehlschuss löschbar
|
|
- ✅ Alternativ Aufnahme aus der Galerie
|
|
- ✅ Clientseitige Bild-Verkleinerung auf 2000px (JPEG q=0.85)
|
|
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
|
|
- ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen)
|
|
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
|
|
- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease, 90 s); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern
|
|
- ✅ **App-Update lädt nicht mitten in der Arbeit neu**: nach einem Deploy wartet der Reload, bis kein Dialog (Kamera, Notiz, Unterschrift) offen ist, nichts getippt wird und kein Upload läuft (`window.appBusy()`); der eigene Reload löst keinen Browser-Dialog aus
|
|
- ✅ **Wartende Fotos stehen am Auftrag**: Block „⏳ Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext (gesichert auf dem Gerät, geht raus sobald Netz da ist); nach dem Upload zieht die Auftragsseite von selbst nach. Hinweis-Toast beim Schließen der Kamera, wenn noch etwas wartet
|
|
- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304
|
|
- ✅ Foto-Viewer mit Zoom + Swipe
|
|
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
|
|
|
**Notizen**
|
|
- ✅ Getippte Notiz zum Auftrag: Betreff + Text, Liste mit Vorschau und Zeitstempel, jederzeit wieder les- und änderbar
|
|
- ✅ Ablage als `notiz_<betreff>_<datum>.txt` im Auftragsordner — damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors sichtbar
|
|
- ✅ Löschen mit Rückfrage; in „Weitere Dokumente" tauchen Notizen nicht doppelt auf
|
|
|
|
**Sprachnotizen**
|
|
- ✅ Sprachaufnahme via MediaRecorder (webm)
|
|
- ✅ Whisper-Transkription (serverseitig, API-Aufruf)
|
|
|
|
**Berichte**
|
|
- ✅ Berichte pro Auftrag anzeigen
|
|
- ✅ Seitenkacheln zeigen das im Editor gebaute Seitenbild (`composite_path`) samt Anmerkungen — nicht mehr nur das Rohbild; Raster-Layouts sind mit „⊞ 4" gekennzeichnet
|
|
- ✅ Bericht erstellen mit Template-Auswahl
|
|
- ✅ Seiten umordnen per Drag&Drop
|
|
- ✅ Seite löschen, Notiz bearbeiten
|
|
- ✅ Touch-Unterschrift abnehmen (mit GPS + Name)
|
|
- ✅ Bericht finalisieren → PDF-Vorschau
|
|
- ✅ PDF-Download
|
|
|
|
**Lieferungen + Kundenunterschrift**
|
|
- ✅ Lieferungen-Liste pro Auftrag mit Status + Signed-Badge
|
|
- ✅ Lieferschein-PDF inline (PDF.js, alle Seiten)
|
|
- ✅ Vollbild-Querformat-Unterschrift mit HiDPI-Canvas, transparent (kein Hintergrund)
|
|
- ✅ Trimmt Canvas automatisch auf bemalte Fläche
|
|
- ✅ Name des Unterzeichners aus Kundendaten vorausgefüllt
|
|
- ✅ GPS-Koordinaten (optional, timeout 3s)
|
|
- ✅ Unterschrift wird via ODT-Hook in das Lieferschein-PDF gestempelt
|
|
- ✅ Expedition wird nach Unterschrift automatisch validiert + geschlossen
|
|
- ✅ Signed-PDF direkt nach Bestätigung als Vorschau
|
|
|
|
**Materialliste**
|
|
- ✅ Material pro Auftrag erfassen (Label, Menge, Einheit, Notiz)
|
|
- ✅ Material löschen
|
|
|
|
**PWA**
|
|
- ✅ Service Worker für Offline-Start
|
|
- ✅ Installierbar als PWA (Home-Screen-Icon)
|
|
- ✅ **Web Share Target** — aus WhatsApp & Co. Bilder **und** Beschreibungstext an „Baustelle" teilen:
|
|
anmelden (falls nötig, direkt in der Teilen-Seite), Auftrag suchen und wählen, ablegen. Bilder gehen
|
|
über die normale Upload-Queue, der Text wird als Notiz (`notiz_*.txt`) im Auftrag gespeichert
|
|
- ✅ **Serie ohne Sucherei**: die Teilen-Seite merkt sich die letzten drei benutzten Aufträge und zeigt
|
|
sie unter „Zuletzt benutzt" ganz oben. Wurde einer davon in den letzten 30 Minuten benutzt, ist er
|
|
**vorausgewählt** — die zweite und dritte Nachricht der Kundin kosten dann nur noch einen Tipp.
|
|
Danach steht er nur noch als Schnellzugriff da, damit nichts versehentlich im Auftrag von vorgestern landet
|
|
- ✅ Nativer Android-Share-Sheet für Fotos, PDFs und Dokumente (`navigator.share`) — direkt an WhatsApp / Signal / Mail
|
|
- ✅ Mehrfachauswahl in Foto- und Dokument-Liste mit Batch-Teilen
|
|
- ✅ Android-Hardware-Back-Button schließt Modale/Ansichten sauber (History-Modal-Stack), bricht Mehrfachauswahl ab statt App zu verlassen
|
|
- ✅ „Nochmal drücken zum Beenden"-Toast auf Top-Level-Routen
|
|
- ✅ PIN-Schutz (optional)
|
|
- ✅ **Push-Benachrichtigungen via ntfy** (statt klassischem Web-Push): eigener Server/Topic in den
|
|
Einstellungen, Browser-Notification-Permission, Reconnect bei Verbindungsabbruch
|
|
- ✅ **Bild-Cache** `baustelle-media` (cache-first, LRU 300): schon angesehene Fotos sind auch ohne Netz da; überlebt Deploys, wird beim Abmelden geleert
|
|
- ✅ **Keine Browser-Dialoge** — `confirm`/`alert`/`prompt` sind durch eigene Modale ersetzt (`confirmDialog`, `alertDialog`, `inputDialog`); der Hardware-Zurück-Button schließt sie als Abbruch
|
|
|
|
**Ohne Netz** (Keller, Neubau, Funkloch — auf der Baustelle der Normalfall)
|
|
- ✅ **Datenspiegel**: Auftragsliste, Kundenliste, Berichte sowie geöffnete Auftrags- und
|
|
Kundendetails werden nach jedem erfolgreichen Abruf in IndexedDB gespiegelt und ohne Netz
|
|
von dort angezeigt (Details: die letzten 30). Zusammen mit dem Bild-Cache steht ein einmal
|
|
geöffneter Auftrag samt Fotos auch im Funkloch
|
|
- ✅ **Sichtbares Band** „📴 Kein Netz — angezeigt wird der Stand von HH:MM Uhr" — ohne den
|
|
Hinweis hält man alte Zahlen für aktuell. Kommt das Netz zurück, lädt die Ansicht neu und
|
|
das Band verschwindet
|
|
- ✅ **Suche greift auf den gespiegelten Stand zu**, statt ins Leere zu laufen (mit Hinweis)
|
|
- ✅ **Verständliche Meldungen**: ein `fetch()`, das nicht bis zum Server kommt, wird zu
|
|
„Keine Verbindung zum Server" — nicht mehr die rohe Browsermeldung „Failed to fetch".
|
|
Ein echter Serverfehler (401/403/500) wird weiterhin als solcher gemeldet
|
|
- ✅ **Start ohne Netz**: der Service Worker liefert die App-Hülle aus dem Cache; die Seite
|
|
meldet ihm nach dem Laden ihre tatsächlichen Asset-URLs zum Nachcachen, sonst wäre der
|
|
Cache direkt nach einem Deploy leer (`?v=<mtime>` verhindert eine feste Dateiliste)
|
|
- ✅ **Beim Abmelden wird der Spiegel gelöscht** — am geteilten Gerät sieht sonst der nächste
|
|
Benutzer offline die Kunden und Preise des vorherigen
|
|
- ⛔ **Ändern braucht Netz** — bewusst keine Schreib-Warteschlange für Auftragsdaten
|
|
(bräuchte Idempotenz + Konfliktbehandlung). Fotos sind die Ausnahme: die gehen über die
|
|
Persist-First-Queue und werden nachgeliefert
|
|
|
|
## Hosting
|
|
|
|
Deploy per Forgejo-Pipeline (Commit-Tag `[deploy]`) nach `/mnt/appdata/firma/dolibarr-202509/modules/baustelle/`. Erreichbar unter `https://awl.data-it-solution.de/custom/baustelle/`. Cache-Busting automatisch via `filemtime()` in `index.php` (`?v=<mtime>` an JS/CSS) — kein manuelles Versions-Hochzählen nötig, die PWA aktualisiert sich beim nächsten Laden.
|
|
|
|
## Verzeichnis-Struktur
|
|
|
|
```
|
|
baustelle-pwa/
|
|
├── index.php App-Shell (Cache-Busting via filemtime → ?v=<mtime>)
|
|
├── share.html Web-Share-Target-Empfänger
|
|
├── manifest.webmanifest PWA Manifest
|
|
├── sw.js Service Worker (Network-First für Assets, API pass-through,
|
|
│ cache-first für photo.php?size=thumb → Cache baustelle-media)
|
|
├── app.js Hauptlogik (Routes, Views, Dialoge, Notizen)
|
|
├── app.css Mobile-first Styling
|
|
├── lib/
|
|
│ ├── idb.js IndexedDB-Wrapper (Foto-Queue)
|
|
│ ├── api.js REST-API-Client (SSO-Cookie same-origin, JSON-Guard, Timeout)
|
|
│ ├── offline.js Offline-Queue + Sync (Persist-First)
|
|
│ ├── router.js Hash-Router
|
|
│ ├── pdf.min.js PDF.js (Lieferschein-Vorschau)
|
|
│ └── pdf.worker.min.js PDF.js Worker
|
|
└── icons/
|
|
├── icon-192.png
|
|
└── icon-512.png
|
|
```
|
|
|
|
## API
|
|
|
|
Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
|
|
|
**Auth**
|
|
- `POST /auth.php` — Login mit Dolibarr-Credentials → setzt HttpOnly-Cookie `awl_sso` (zentrales SSO via awlauth). Folge-Requests authentisieren same-origin über das Cookie (kein Bearer-Token)
|
|
- `GET /verify.php` — prüft das Cookie und verlängert die Session (sliding renewal); läuft bei jedem Routenwechsel (`ensureAuth()`) und ist der Weg, wie `share.html` prüft, ob eine Anmeldung nötig ist
|
|
- `POST /logout.php` — löscht das `awl_sso`-Cookie (Single-Logout über alle AWL-Apps)
|
|
|
|
**Aufträge**
|
|
- `GET /orders.php` — Aufträge des Users (Filter: `?open=1`, `?q=suche`)
|
|
- `GET /orders.php?id=X` — Auftrag-Detail inkl. verknüpfte Berichte
|
|
- `GET /orders.php?id=X&action=photos` — Anhang-Liste (je Datei `taken_at` = EXIF-Aufnahmezeit, danach sortiert)
|
|
- `POST /orders.php?id=X&action=upload_photo` — Foto-Upload (multipart)
|
|
- `POST /orders.php?action=create` — Neuen Auftrag anlegen (Kunde, Ihr Zeichen, Beschreibung, Liefertermin, optional direkt freigeben)
|
|
|
|
**Kunden**
|
|
- `GET /customers.php` — Kundenliste (Filter: `?q=suche`)
|
|
- `GET /customers.php?id=X` — Kundendetail
|
|
|
|
**Berichte**
|
|
- `GET /reports.php` — Alle Berichte des Users
|
|
- `GET /reports.php?id=X` — Bericht-Detail mit Seiten (je Seite `composite_path`, `title`, `layout` — `composite_path` anzeigen, wenn gesetzt)
|
|
- `POST /reports.php?action=create` — Neuen Bericht anlegen
|
|
- `POST /reports.php?id=X&action=finalize` — Bericht finalisieren
|
|
- `DELETE /reports.php?id=X` — Bericht löschen
|
|
|
|
**Seiten**
|
|
- `DELETE /pages.php?id=X` — Seite löschen
|
|
- `POST /pages.php?id=X` — Seite aktualisieren (note, rotation)
|
|
- `POST /pages.php?action=signature&bericht_id=X` — Unterschrift hinzufügen
|
|
- `POST /pages.php?action=reorder` — Seiten umordnen
|
|
|
|
**Material**
|
|
- `GET /materials.php?element_type=X&element_id=Y` — Materialliste
|
|
- `POST /materials.php?element_type=X&element_id=Y` — Material hinzufügen
|
|
- `POST /materials.php?id=X&delete=1` — Material löschen
|
|
|
|
**Lieferungen**
|
|
- `GET /shipments.php?order_id=X` — Lieferungen zu einem Auftrag
|
|
- `GET /shipments.php?id=X` — Lieferung-Detail (inkl. bericht_id falls Unterschrift vorhanden)
|
|
- `GET /shipments.php?id=X&action=pdf[&variant=auto|signed|unsigned]` — Lieferschein-PDF
|
|
- `POST /shipments.php?id=X&action=confirm` — Kunden-Unterschrift einstempeln (FormData mit signature_png, signer_name, gps_lat, gps_lon, signed_at). Backend setzt signed_status=1 und schließt die Expedition
|
|
|
|
**Notizen**
|
|
- `GET /note.php?order_id=X` — Liste (Betreff, Vorschau, Zeitstempel)
|
|
- `GET /note.php?order_id=X&file=<name>` — eine Notiz mit vollem Text
|
|
- `POST /note.php?order_id=X` — anlegen `{ subject, text }`
|
|
- `POST /note.php?order_id=X&file=<name>` — ändern
|
|
- `POST /note.php?order_id=X&file=<name>&delete=1` — löschen
|
|
|
|
**Medien**
|
|
- `GET /photo.php?relpath=X` — Datei abrufen; Auth über `awl_sso`-Cookie (same-origin, auch für `<img>`-Tags — kein `?jwt=` mehr nötig)
|
|
- `GET /photo.php?relpath=X&size=thumb&w=<px>` — serverseitiges Thumbnail (GD, gecacht, ETag → 304). **Der Weg für Kachel-Ansichten.** `size=small` ist etwas anderes: das liefert nur ein von Dolibarr vorgefertigtes Thumb, das es für PWA-Uploads gar nicht gibt — dort kam bis Modul-Version 1.5.1 immer das Original zurück
|
|
- `POST /delete_photo.php` — Foto löschen
|
|
- `POST /voice.php?order_id=X` — Sprachnotiz hochladen
|
|
- `POST /transcribe.php` — Whisper-Transkription anfordern
|
|
- `GET /pdf.php?id=X` — PDF abrufen (Final oder Preview); Auth über `awl_sso`-Cookie (same-origin)
|
|
|
|
**Templates**
|
|
- `GET /templates.php` — Bericht-Vorlagen
|
|
- `GET /odt_templates.php` — ODT-Deckblatt-Vorlagen
|
|
|
|
**Konfiguration**
|
|
- `GET /config.php` — Admin-Vorgaben zur Fotoqualität (`photo_maxside`, `photo_quality`); wird beim Start und nach jedem Login neu geladen, zwischengespeichert in `localStorage` für den Offline-Fall
|
|
|
|
## Lizenz
|
|
|
|
GPL v3+
|