Compare commits
No commits in common. "10d5f915df9c36ab4ef7a2a984c583e6a3cc8eae" and "7c7de5846070e38a9d2dde4cba09438739c80cd1" have entirely different histories.
10d5f915df
...
7c7de58460
7 changed files with 44 additions and 825 deletions
30
README.md
30
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Baustelle PWA
|
# 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.
|
Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotizen, Skizzen, offline-fähig. Spricht die REST-API des Dolibarr-Bericht-Moduls.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|
@ -25,22 +25,15 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
|
||||||
- ✅ **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
|
- ✅ **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)
|
- ✅ 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
|
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
|
||||||
- ✅ 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-Viewer mit Zoom + Swipe
|
||||||
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
- ✅ 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**
|
**Sprachnotizen**
|
||||||
- ✅ Sprachaufnahme via MediaRecorder (webm)
|
- ✅ Sprachaufnahme via MediaRecorder (webm)
|
||||||
- ✅ Whisper-Transkription (serverseitig, API-Aufruf)
|
- ✅ Whisper-Transkription (serverseitig, API-Aufruf)
|
||||||
|
|
||||||
**Berichte**
|
**Berichte**
|
||||||
- ✅ Berichte pro Auftrag anzeigen
|
- ✅ 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
|
- ✅ Bericht erstellen mit Template-Auswahl
|
||||||
- ✅ Seiten umordnen per Drag&Drop
|
- ✅ Seiten umordnen per Drag&Drop
|
||||||
- ✅ Seite löschen, Notiz bearbeiten
|
- ✅ Seite löschen, Notiz bearbeiten
|
||||||
|
|
@ -72,8 +65,6 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
|
||||||
- ✅ Android-Hardware-Back-Button schließt Modale/Ansichten sauber (History-Modal-Stack), bricht Mehrfachauswahl ab statt App zu verlassen
|
- ✅ 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
|
- ✅ „Nochmal drücken zum Beenden"-Toast auf Top-Level-Routen
|
||||||
- ✅ PIN-Schutz (optional)
|
- ✅ PIN-Schutz (optional)
|
||||||
- ✅ **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
|
|
||||||
|
|
||||||
## Geplant
|
## Geplant
|
||||||
|
|
||||||
|
|
@ -90,9 +81,8 @@ baustelle-pwa/
|
||||||
├── index.php App-Shell (Cache-Busting via filemtime → ?v=<mtime>)
|
├── index.php App-Shell (Cache-Busting via filemtime → ?v=<mtime>)
|
||||||
├── share.html Web-Share-Target-Empfänger
|
├── share.html Web-Share-Target-Empfänger
|
||||||
├── manifest.webmanifest PWA Manifest
|
├── manifest.webmanifest PWA Manifest
|
||||||
├── sw.js Service Worker (Network-First für Assets, API pass-through,
|
├── sw.js Service Worker (Network-First, API pass-through)
|
||||||
│ cache-first für photo.php?size=thumb → Cache baustelle-media)
|
├── app.js Hauptlogik (Routes, Views)
|
||||||
├── app.js Hauptlogik (Routes, Views, Dialoge, Notizen)
|
|
||||||
├── app.css Mobile-first Styling
|
├── app.css Mobile-first Styling
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── idb.js IndexedDB-Wrapper (Foto-Queue)
|
│ ├── idb.js IndexedDB-Wrapper (Foto-Queue)
|
||||||
|
|
@ -116,7 +106,7 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
||||||
**Aufträge**
|
**Aufträge**
|
||||||
- `GET /orders.php` — Aufträge des Users (Filter: `?open=1`, `?q=suche`)
|
- `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` — Auftrag-Detail inkl. verknüpfte Berichte
|
||||||
- `GET /orders.php?id=X&action=photos` — Anhang-Liste (je Datei `taken_at` = EXIF-Aufnahmezeit, danach sortiert)
|
- `GET /orders.php?id=X&action=photos` — Anhang-Liste
|
||||||
- `POST /orders.php?id=X&action=upload_photo` — Foto-Upload (multipart)
|
- `POST /orders.php?id=X&action=upload_photo` — Foto-Upload (multipart)
|
||||||
|
|
||||||
**Kunden**
|
**Kunden**
|
||||||
|
|
@ -125,7 +115,7 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
||||||
|
|
||||||
**Berichte**
|
**Berichte**
|
||||||
- `GET /reports.php` — Alle Berichte des Users
|
- `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)
|
- `GET /reports.php?id=X` — Bericht-Detail mit Seiten
|
||||||
- `POST /reports.php?action=create` — Neuen Bericht anlegen
|
- `POST /reports.php?action=create` — Neuen Bericht anlegen
|
||||||
- `POST /reports.php?id=X&action=finalize` — Bericht finalisieren
|
- `POST /reports.php?id=X&action=finalize` — Bericht finalisieren
|
||||||
- `DELETE /reports.php?id=X` — Bericht löschen
|
- `DELETE /reports.php?id=X` — Bericht löschen
|
||||||
|
|
@ -147,16 +137,8 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
||||||
- `GET /shipments.php?id=X&action=pdf[&variant=auto|signed|unsigned]` — Lieferschein-PDF
|
- `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
|
- `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**
|
**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` — Foto abrufen (mit Thumbnail: `&size=thumb`); 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 /delete_photo.php` — Foto löschen
|
||||||
- `POST /voice.php?order_id=X` — Sprachnotiz hochladen
|
- `POST /voice.php?order_id=X` — Sprachnotiz hochladen
|
||||||
- `POST /transcribe.php` — Whisper-Transkription anfordern
|
- `POST /transcribe.php` — Whisper-Transkription anfordern
|
||||||
|
|
|
||||||
149
ROADMAP.md
149
ROADMAP.md
|
|
@ -1,149 +0,0 @@
|
||||||
# ROADMAP — Baustelle PWA
|
|
||||||
|
|
||||||
Stand: 2026-08-22 · 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,5–1 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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
189
app.css
189
app.css
|
|
@ -1279,192 +1279,3 @@ body {
|
||||||
}
|
}
|
||||||
.cam-shutter:active { transform: scale(0.92); }
|
.cam-shutter:active { transform: scale(0.92); }
|
||||||
.cam-shutter.flash { background: #ffd34d; }
|
.cam-shutter.flash { background: #ffd34d; }
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
Eigene Dialoge (Ersatz für confirm/alert/prompt)
|
|
||||||
============================================================ */
|
|
||||||
.dialog-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.65);
|
|
||||||
z-index: 2000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
.dialog-box {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
background: #25252b;
|
|
||||||
border: 1px solid #3a3a42;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
.dialog-title {
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.dialog-text {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.45;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.dialog-input {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 16px; /* < 16px lässt iOS beim Fokus zoomen */
|
|
||||||
background: #1a1a1f;
|
|
||||||
color: #f0f0f0;
|
|
||||||
border: 1px solid #3a3a42;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.dialog-actions .btn { flex: 1; margin: 0; }
|
|
||||||
|
|
||||||
/* Layout-Kennzeichen auf einer Bericht-Seitenkachel */
|
|
||||||
.page-layout-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
color: #fff;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thumbnail liegt über dem Platzhalter, statt ihn im Fluss zu verdrängen —
|
|
||||||
sonst hätte die Kachel kurzzeitig zwei Elemente mit je 100 % Höhe. */
|
|
||||||
.photo-grid .thumb img.thumb-img {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
.photo-grid .thumb img.thumb-img.is-loaded { opacity: 1; }
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
Textnotizen
|
|
||||||
============================================================ */
|
|
||||||
.note-card {
|
|
||||||
background: #25252b;
|
|
||||||
border: 1px solid #33333a;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.note-card:active { background: #2c2c33; }
|
|
||||||
.note-card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.note-card-head strong {
|
|
||||||
font-size: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.note-card-preview {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
opacity: 0.75;
|
|
||||||
/* Zwei Zeilen Vorschau reichen, um die Notiz wiederzuerkennen */
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.note-card-meta {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
/* Kopf-/Rumpf-Aufbau der Modale. Die Klassen waren im Markup schon in Gebrauch
|
|
||||||
(u. a. beim Löschen eines Berichts), hatten aber nie eine Regel — der
|
|
||||||
Schließen-Knopf landete dadurch unter der Überschrift statt daneben. */
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: #25252b;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.modal-header h2 {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.close-btn {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1;
|
|
||||||
background: transparent;
|
|
||||||
color: #f0f0f0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.close-btn:active { background: #33333a; }
|
|
||||||
.modal-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 16px;
|
|
||||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Editor */
|
|
||||||
.note-edit-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
background: #1a1a1f;
|
|
||||||
}
|
|
||||||
.note-edit-body input,
|
|
||||||
.note-edit-body textarea {
|
|
||||||
width: 100%;
|
|
||||||
background: #25252b;
|
|
||||||
color: #f0f0f0;
|
|
||||||
border: 1px solid #3a3a42;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 16px; /* < 16px lässt iOS beim Fokus zoomen */
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.note-edit-body textarea {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
resize: none;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.note-edit-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.5;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
403
app.js
403
app.js
|
|
@ -101,94 +101,6 @@ window.addEventListener('popstate', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* Eigene Dialoge statt confirm()/alert()/prompt().
|
|
||||||
*
|
|
||||||
* Browser-Dialoge sind in der installierten PWA fehl am Platz: sie zeigen unter Android
|
|
||||||
* die Herkunfts-URL, blockieren den kompletten Thread und sehen aus wie ein Systemfehler.
|
|
||||||
* Alle drei Helfer geben ein Promise zurück und hängen im normalen Modal-Stack, damit der
|
|
||||||
* Hardware-Zurück-Button sie schließt (= Abbruch).
|
|
||||||
*
|
|
||||||
* await confirmDialog({title, text, confirmLabel, danger}) → true/false
|
|
||||||
* await inputDialog({title, text, placeholder, value}) → String oder null
|
|
||||||
* await alertDialog({title, text}) → void
|
|
||||||
* ============================================================ */
|
|
||||||
function _dialogShell(inner) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'dialog-overlay';
|
|
||||||
el.innerHTML = `<div class="dialog-box" role="dialog" aria-modal="true">${inner}</div>`;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDialog({ title, text, confirmLabel, cancelLabel, danger } = {}) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const el = _dialogShell(`
|
|
||||||
${title ? `<div class="dialog-title">${escapeHtml(title)}</div>` : ''}
|
|
||||||
${text ? `<div class="dialog-text">${escapeHtml(text)}</div>` : ''}
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="btn btn-secondary" data-act="no">${escapeHtml(cancelLabel || 'Abbrechen')}</button>
|
|
||||||
<button class="btn ${danger ? 'btn-danger' : ''}" data-act="yes">${escapeHtml(confirmLabel || 'OK')}</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
let done = false;
|
|
||||||
const finish = (val) => { if (done) return; done = true; resolve(val); };
|
|
||||||
document.body.appendChild(el);
|
|
||||||
pushModal(el, () => finish(false)); // Android-Back / popstate = Abbruch
|
|
||||||
|
|
||||||
el.querySelector('[data-act="no"]').onclick = () => { finish(false); closeModal(el); };
|
|
||||||
el.querySelector('[data-act="yes"]').onclick = () => { finish(true); closeModal(el); };
|
|
||||||
// Klick auf die Abdunklung neben der Box bricht ab
|
|
||||||
el.addEventListener('click', e => { if (e.target === el) { finish(false); closeModal(el); } });
|
|
||||||
setTimeout(() => el.querySelector('[data-act="yes"]').focus(), 30);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function alertDialog({ title, text, okLabel } = {}) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const el = _dialogShell(`
|
|
||||||
${title ? `<div class="dialog-title">${escapeHtml(title)}</div>` : ''}
|
|
||||||
${text ? `<div class="dialog-text">${escapeHtml(text)}</div>` : ''}
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="btn" data-act="ok">${escapeHtml(okLabel || 'OK')}</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
let done = false;
|
|
||||||
const finish = () => { if (done) return; done = true; resolve(); };
|
|
||||||
document.body.appendChild(el);
|
|
||||||
pushModal(el, finish);
|
|
||||||
el.querySelector('[data-act="ok"]').onclick = () => { finish(); closeModal(el); };
|
|
||||||
el.addEventListener('click', e => { if (e.target === el) { finish(); closeModal(el); } });
|
|
||||||
setTimeout(() => el.querySelector('[data-act="ok"]').focus(), 30);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function inputDialog({ title, text, placeholder, value, okLabel, inputMode } = {}) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const el = _dialogShell(`
|
|
||||||
${title ? `<div class="dialog-title">${escapeHtml(title)}</div>` : ''}
|
|
||||||
${text ? `<div class="dialog-text">${escapeHtml(text)}</div>` : ''}
|
|
||||||
<input class="dialog-input" type="text" ${inputMode ? `inputmode="${escapeHtml(inputMode)}"` : ''}
|
|
||||||
placeholder="${escapeHtml(placeholder || '')}" value="${escapeHtml(value || '')}">
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button class="btn btn-secondary" data-act="no">Abbrechen</button>
|
|
||||||
<button class="btn" data-act="yes">${escapeHtml(okLabel || 'OK')}</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
let done = false;
|
|
||||||
const finish = (val) => { if (done) return; done = true; resolve(val); };
|
|
||||||
document.body.appendChild(el);
|
|
||||||
pushModal(el, () => finish(null));
|
|
||||||
|
|
||||||
const input = el.querySelector('.dialog-input');
|
|
||||||
const submit = () => { finish(input.value.trim()); closeModal(el); };
|
|
||||||
el.querySelector('[data-act="no"]').onclick = () => { finish(null); closeModal(el); };
|
|
||||||
el.querySelector('[data-act="yes"]').onclick = submit;
|
|
||||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
|
|
||||||
el.addEventListener('click', e => { if (e.target === el) { finish(null); closeModal(el); } });
|
|
||||||
setTimeout(() => { input.focus(); input.select(); }, 60);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* Share-Helper: teilt eine oder mehrere Dateien über Web Share API.
|
* Share-Helper: teilt eine oder mehrere Dateien über Web Share API.
|
||||||
* shareFile(blob, filename, mime, title) — einzelne Datei
|
* shareFile(blob, filename, mime, title) — einzelne Datei
|
||||||
|
|
@ -380,7 +292,7 @@ async function promptNewPin() {
|
||||||
if (!p1 || p1.length !== 4) return null;
|
if (!p1 || p1.length !== 4) return null;
|
||||||
const p2 = await promptPin('PIN wiederholen');
|
const p2 = await promptPin('PIN wiederholen');
|
||||||
if (p2 !== p1) {
|
if (p2 !== p1) {
|
||||||
await alertDialog({ title: 'PIN', text: 'Die beiden Eingaben stimmen nicht überein.' });
|
alert('PINs stimmen nicht überein');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return p1;
|
return p1;
|
||||||
|
|
@ -631,12 +543,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.getOrder(args.id);
|
const data = await api.getOrder(args.id);
|
||||||
// Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr
|
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
|
||||||
const [photos, notes] = await Promise.all([
|
|
||||||
api.listOrderPhotos(args.id).catch(() => ({ photos: [] })),
|
|
||||||
api.listNotes(args.id).catch(() => ({ notes: [] })),
|
|
||||||
]);
|
|
||||||
const notesCount = notes.notes.length;
|
|
||||||
title(data.order.ref);
|
title(data.order.ref);
|
||||||
|
|
||||||
// Nach MIME-Type aufteilen
|
// Nach MIME-Type aufteilen
|
||||||
|
|
@ -645,9 +552,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
const otherDocs = photos.photos.filter(p =>
|
const otherDocs = photos.photos.filter(p =>
|
||||||
!(p.mime || '').startsWith('image/') &&
|
!(p.mime || '').startsWith('image/') &&
|
||||||
!(p.mime || '').startsWith('audio/') &&
|
!(p.mime || '').startsWith('audio/') &&
|
||||||
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename) &&
|
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
|
||||||
// Notizen haben ihre eigene Sektion — hier wären sie doppelt
|
|
||||||
!/^notiz_.*\.txt$/i.test(p.filename));
|
|
||||||
|
|
||||||
main().innerHTML = `
|
main().innerHTML = `
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
|
|
@ -669,7 +574,6 @@ router.on('/orders/:id', async (args) => {
|
||||||
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
|
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
|
||||||
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
|
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
|
||||||
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
|
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
|
||||||
<button class="btn btn-secondary" id="btn-notes">📝 Notizen${notesCount ? ' (' + notesCount + ')' : ''}</button>
|
|
||||||
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
|
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
|
||||||
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
||||||
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
||||||
|
|
@ -825,7 +729,6 @@ router.on('/orders/:id', async (args) => {
|
||||||
|
|
||||||
// Sprachnotiz
|
// Sprachnotiz
|
||||||
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
||||||
document.getElementById('btn-notes').onclick = () => openNotesModal(args.id);
|
|
||||||
|
|
||||||
// Neuen Bericht anlegen
|
// Neuen Bericht anlegen
|
||||||
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
||||||
|
|
@ -959,56 +862,23 @@ async function resizeImage(file, maxSide) {
|
||||||
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
|
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
|
||||||
* deshalb Blob-URLs).
|
* deshalb Blob-URLs).
|
||||||
*/
|
*/
|
||||||
/* Kacheln mit serverseitigen Thumbnails füllen.
|
async function loadThumbs() {
|
||||||
*
|
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
|
||||||
* Vorher lief das sequenziell über fetch→Blob→ObjectURL und lud dabei die kompletten
|
for (const t of thumbs) {
|
||||||
* Fotos: photo.php?size=small liefert nur dann ein Thumbnail, wenn Dolibarr eines
|
|
||||||
* vorgefertigt hat — für Uploads aus der PWA gibt es keins. 30 Fotos hießen also
|
|
||||||
* 30 Vollbilder, eines nach dem anderen. Jetzt kommt das <img> direkt an die
|
|
||||||
* Thumb-URL: der Browser lädt parallel, respektiert loading="lazy" und cached über ETag.
|
|
||||||
*/
|
|
||||||
function loadThumbs(root) {
|
|
||||||
const scope = root || document;
|
|
||||||
const thumbs = scope.querySelectorAll('.photo-grid .thumb[data-relpath]');
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
const w = Math.round(160 * dpr); // Kachel ~160 CSS-px
|
|
||||||
|
|
||||||
thumbs.forEach(t => {
|
|
||||||
if (t.dataset.thumbLoaded === '1') return;
|
|
||||||
const rel = t.dataset.relpath;
|
const rel = t.dataset.relpath;
|
||||||
if (!rel) return; // z. B. Seite im Layout "nur Titel" — kein Bild
|
try {
|
||||||
t.dataset.thumbLoaded = '1';
|
// Erst Thumbnail versuchen (_small), bei Misserfolg das Original
|
||||||
|
let url = await api.getPhotoBlobUrl(rel, 'small');
|
||||||
const img = new Image();
|
if (!url) url = await api.getPhotoBlobUrl(rel);
|
||||||
img.loading = 'lazy';
|
if (url) {
|
||||||
img.decoding = 'async';
|
t.innerHTML = '<img loading="lazy" src="' + url + '">';
|
||||||
img.alt = '';
|
} else {
|
||||||
img.className = 'thumb-img';
|
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
|
||||||
img.onerror = () => {
|
}
|
||||||
// Kein Bild (PDF/Video) oder Fehler → Original versuchen, sonst Platzhalter
|
} catch (e) {
|
||||||
if (img.dataset.retried === '1') {
|
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
|
||||||
img.remove();
|
}
|
||||||
const ph = t.querySelector('.thumb-placeholder');
|
|
||||||
if (ph) ph.textContent = '❌';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
img.dataset.retried = '1';
|
|
||||||
img.src = api.photoUrl(rel);
|
|
||||||
};
|
|
||||||
// Nur den Platzhalter entfernen — die Overlays der Kachel (Auswahl-Häkchen,
|
|
||||||
// Seitennummer, Titel-Badge) bleiben stehen. Vorher überschrieb das Laden des
|
|
||||||
// Thumbnails den ganzen Kachel-Inhalt und nahm sie mit.
|
|
||||||
img.onload = () => {
|
|
||||||
const ph = t.querySelector('.thumb-placeholder');
|
|
||||||
if (ph) ph.remove();
|
|
||||||
img.classList.add('is-loaded');
|
|
||||||
};
|
|
||||||
// Erst einhängen, dann src setzen: ein <img loading="lazy">, das nicht im
|
|
||||||
// Dokument steht, lädt der Browser überhaupt nicht — es hat kein Layout und
|
|
||||||
// gilt damit nie als "sichtbar". Der Platzhalter bleibt bis onload stehen.
|
|
||||||
t.insertBefore(img, t.firstChild);
|
|
||||||
img.src = api.photoThumbUrl(rel, w);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.on('/customers', async () => {
|
router.on('/customers', async () => {
|
||||||
|
|
@ -1200,20 +1070,13 @@ router.on('/reports/:id', async (args) => {
|
||||||
|
|
||||||
${hasPages ? `
|
${hasPages ? `
|
||||||
<div class="photo-grid" id="report-pages">
|
<div class="photo-grid" id="report-pages">
|
||||||
${data.pages.map((p, i) => {
|
${data.pages.map((p, i) => `
|
||||||
// composite_path ist das im Editor gebaute Seitenbild (Anmerkungen,
|
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}" data-title="${escapeHtml(p.title || '')}">
|
||||||
// Raster, Notizzone). Nur source_path zu zeigen hieße: das Rohbild
|
<div class="thumb-placeholder">⏳</div>
|
||||||
// ohne Bearbeitung — und bei einem Raster eines von bis zu sechs.
|
|
||||||
const rel = p.composite_path || p.source_path || '';
|
|
||||||
const badge = layoutBadge(p.layout);
|
|
||||||
return `
|
|
||||||
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(rel)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}" data-title="${escapeHtml(p.title || '')}">
|
|
||||||
<div class="thumb-placeholder">${rel ? '⏳' : '📄'}</div>
|
|
||||||
<div class="page-num">${i + 1}</div>
|
<div class="page-num">${i + 1}</div>
|
||||||
${badge ? `<div class="page-layout-badge">${badge}</div>` : ''}
|
|
||||||
${p.title ? `<div class="page-title-badge">${escapeHtml(p.title)}</div>` : ''}
|
${p.title ? `<div class="page-title-badge">${escapeHtml(p.title)}</div>` : ''}
|
||||||
</div>`;
|
</div>
|
||||||
}).join('')}
|
`).join('')}
|
||||||
</div>` : '<div class="empty-state"><div class="icon">📭</div>Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.</div>'}
|
</div>` : '<div class="empty-state"><div class="icon">📭</div>Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.</div>'}
|
||||||
|
|
||||||
<button class="btn btn-large" id="btn-finalize" ${hasPages ? '' : 'disabled'}>${finalizeLabel}</button>
|
<button class="btn btn-large" id="btn-finalize" ${hasPages ? '' : 'disabled'}>${finalizeLabel}</button>
|
||||||
|
|
@ -1245,10 +1108,9 @@ router.on('/reports/:id', async (args) => {
|
||||||
if (finalizeBtn) {
|
if (finalizeBtn) {
|
||||||
finalizeBtn.onclick = async () => {
|
finalizeBtn.onclick = async () => {
|
||||||
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; }
|
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; }
|
||||||
const goOn = await confirmDialog(data.report.status === 1
|
if (!confirm(data.report.status === 1
|
||||||
? { title: 'PDF neu erzeugen', text: 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?', confirmLabel: 'Neu erzeugen' }
|
? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?'
|
||||||
: { title: 'Bericht finalisieren', text: 'Bericht jetzt finalisieren und PDF erzeugen?', confirmLabel: 'Finalisieren' });
|
: 'Bericht jetzt finalisieren und PDF erzeugen?')) return;
|
||||||
if (!goOn) return;
|
|
||||||
showToast('PDF wird erzeugt…');
|
showToast('PDF wird erzeugt…');
|
||||||
try {
|
try {
|
||||||
const r = await api.finalizeReport(args.id);
|
const r = await api.finalizeReport(args.id);
|
||||||
|
|
@ -1350,13 +1212,6 @@ router.on('/settings', async () => {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
await idb.del('pin_hash');
|
await idb.del('pin_hash');
|
||||||
await idb.del('pin_salt');
|
await idb.del('pin_salt');
|
||||||
// Gecachte Kundenfotos vom Gerät nehmen — der Bild-Cache überlebt sonst die Abmeldung
|
|
||||||
try {
|
|
||||||
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
|
|
||||||
navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_MEDIA' });
|
|
||||||
}
|
|
||||||
} catch (e) { /* ohne SW nichts zu tun */ }
|
|
||||||
api.clearPhotoCache();
|
|
||||||
router.go('#/login');
|
router.go('#/login');
|
||||||
};
|
};
|
||||||
// ntfy-Settings
|
// ntfy-Settings
|
||||||
|
|
@ -1406,13 +1261,7 @@ router.on('/settings', async () => {
|
||||||
showToast('✓ PIN gesetzt');
|
showToast('✓ PIN gesetzt');
|
||||||
router.navigate();
|
router.navigate();
|
||||||
} else {
|
} else {
|
||||||
const off = await confirmDialog({
|
if (!confirm('PIN-Schutz wirklich deaktivieren?')) {
|
||||||
title: 'PIN-Schutz aus',
|
|
||||||
text: 'PIN-Schutz wirklich deaktivieren? Die App öffnet dann ohne Abfrage.',
|
|
||||||
confirmLabel: 'Deaktivieren',
|
|
||||||
danger: true,
|
|
||||||
});
|
|
||||||
if (!off) {
|
|
||||||
e.target.checked = true;
|
e.target.checked = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1580,7 +1429,7 @@ async function openMaterialModal(elementType, elementId) {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
const card = e.target.closest('.mini-card');
|
const card = e.target.closest('.mini-card');
|
||||||
const id = card.dataset.id;
|
const id = card.dataset.id;
|
||||||
if (!await confirmDialog({ title: 'Material', text: 'Diesen Eintrag löschen?', confirmLabel: 'Löschen', danger: true })) return;
|
if (!confirm('Eintrag löschen?')) return;
|
||||||
await api.deleteMaterial(id);
|
await api.deleteMaterial(id);
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
|
|
@ -1957,12 +1806,7 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
modal.querySelector('#pa-delete').onclick = async () => {
|
modal.querySelector('#pa-delete').onclick = async () => {
|
||||||
if (!await confirmDialog({
|
if (!confirm('Diese Seite aus dem Bericht entfernen?')) return;
|
||||||
title: 'Seite entfernen',
|
|
||||||
text: 'Diese Seite aus dem Bericht entfernen? Anmerkungen und Notiz der Seite gehen dabei verloren.',
|
|
||||||
confirmLabel: 'Entfernen',
|
|
||||||
danger: true,
|
|
||||||
})) return;
|
|
||||||
try {
|
try {
|
||||||
await api.deletePage(pageId);
|
await api.deletePage(pageId);
|
||||||
showToast('✓ Seite entfernt');
|
showToast('✓ Seite entfernt');
|
||||||
|
|
@ -2596,20 +2440,6 @@ function openHelpModal() {
|
||||||
modal.querySelector('#help-close').onclick = () => closeModal(modal);
|
modal.querySelector('#help-close').onclick = () => closeModal(modal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kurzzeichen für das Seiten-Layout (siehe layout-Spalte in llx_bericht_page).
|
|
||||||
* Ohne das wirkt eine Rasterseite in der Kachel wie ein einzelnes Foto. */
|
|
||||||
function layoutBadge(layout) {
|
|
||||||
switch (layout) {
|
|
||||||
case 'grid_2': return '⊞ 2';
|
|
||||||
case 'grid_2v': return '⊞ 2';
|
|
||||||
case 'grid_4': return '⊞ 4';
|
|
||||||
case 'grid_6': return '⊞ 6';
|
|
||||||
case 'before_after': return '↔ V/N';
|
|
||||||
case 'title_only': return '📄 Titel';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
}
|
}
|
||||||
|
|
@ -2868,12 +2698,7 @@ async function openPhotoModal(orderId, relpath) {
|
||||||
modal.querySelector('#pv-zreset').onclick = resetZoom;
|
modal.querySelector('#pv-zreset').onclick = resetZoom;
|
||||||
|
|
||||||
modal.querySelector('#fs-delete').onclick = async () => {
|
modal.querySelector('#fs-delete').onclick = async () => {
|
||||||
if (!await confirmDialog({
|
if (!confirm('Foto wirklich löschen?')) return;
|
||||||
title: 'Foto löschen',
|
|
||||||
text: 'Das Foto wird endgültig aus den Dokumenten des Auftrags entfernt.',
|
|
||||||
confirmLabel: 'Löschen',
|
|
||||||
danger: true,
|
|
||||||
})) return;
|
|
||||||
try {
|
try {
|
||||||
await api.deletePhoto(allRelpaths[currentIndex]);
|
await api.deletePhoto(allRelpaths[currentIndex]);
|
||||||
showToast('✓ Gelöscht');
|
showToast('✓ Gelöscht');
|
||||||
|
|
@ -3191,12 +3016,7 @@ async function openCameraModal(orderId) {
|
||||||
if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
|
if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
|
||||||
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
|
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
|
||||||
if (!relpath && qid) {
|
if (!relpath && qid) {
|
||||||
if (!await confirmDialog({
|
if (!confirm('Dieses Foto ist noch nicht hochgeladen. Wirklich löschen?')) return;
|
||||||
title: 'Noch nicht hochgeladen',
|
|
||||||
text: 'Dieses Foto liegt nur auf dem Gerät und wurde noch nicht übertragen. Beim Löschen ist es weg.',
|
|
||||||
confirmLabel: 'Trotzdem löschen',
|
|
||||||
danger: true,
|
|
||||||
})) return;
|
|
||||||
}
|
}
|
||||||
t.dataset.deleting = '1';
|
t.dataset.deleting = '1';
|
||||||
const delBtn = t.querySelector('.cam-thumb-del');
|
const delBtn = t.querySelector('.cam-thumb-del');
|
||||||
|
|
@ -3586,12 +3406,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
showToast('Zu kurz — 2 Punkte mit Abstand wählen', 'error');
|
showToast('Zu kurz — 2 Punkte mit Abstand wählen', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const input = await inputDialog({
|
const input = prompt('Reale Länge dieser Strecke:\nFormat: Zahl + Einheit (z. B. "50 cm", "1.5 m", "250 mm")');
|
||||||
title: 'Mess-Skala kalibrieren',
|
|
||||||
text: 'Reale Länge der gezogenen Strecke — Zahl mit Einheit, z. B. „50 cm", „1.5 m" oder „250 mm".',
|
|
||||||
placeholder: '1.5 m',
|
|
||||||
okLabel: 'Übernehmen',
|
|
||||||
});
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
await restoreSnapshot(snapshotBeforeShape);
|
await restoreSnapshot(snapshotBeforeShape);
|
||||||
snapshotBeforeShape = null;
|
snapshotBeforeShape = null;
|
||||||
|
|
@ -3601,7 +3416,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
if (!m) {
|
if (!m) {
|
||||||
await restoreSnapshot(snapshotBeforeShape);
|
await restoreSnapshot(snapshotBeforeShape);
|
||||||
snapshotBeforeShape = null;
|
snapshotBeforeShape = null;
|
||||||
await alertDialog({ title: 'Eingabe nicht verstanden', text: 'Bitte Zahl und Einheit angeben, z. B. 1.5 m' });
|
alert('Ungültiges Format. Beispiel: 1.5 m');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const val = parseFloat(m[1].replace(',', '.'));
|
const val = parseFloat(m[1].replace(',', '.'));
|
||||||
|
|
@ -3622,10 +3437,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
if (!calibration) {
|
if (!calibration) {
|
||||||
await restoreSnapshot(snapshotBeforeShape);
|
await restoreSnapshot(snapshotBeforeShape);
|
||||||
snapshotBeforeShape = null;
|
snapshotBeforeShape = null;
|
||||||
await alertDialog({
|
alert('Zuerst kalibrieren! Tap auf 📏⚙, zwei Punkte mit bekannter Länge wählen.');
|
||||||
title: 'Noch nicht kalibriert',
|
|
||||||
text: 'Erst 📏⚙ antippen und eine Strecke mit bekannter Länge ziehen — danach kann gemessen werden.',
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis
|
// Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis
|
||||||
|
|
@ -3691,150 +3503,3 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
}, 'image/jpeg', 0.9);
|
}, 'image/jpeg', 0.9);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* TEXTNOTIZEN
|
|
||||||
*
|
|
||||||
* Getippte Notiz zum Auftrag — das Gegenstück zur Sprachnotiz für alles, was man
|
|
||||||
* lieber schreibt als diktiert (Zählernummern, Absprachen, Maße). Landet als
|
|
||||||
* notiz_<betreff>_<datum>.txt im Auftragsordner (api/note.php) und ist damit auch
|
|
||||||
* im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors zu sehen.
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
/* Liste der Notizen eines Auftrags */
|
|
||||||
async function openNotesModal(orderId) {
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fullscreen-modal notes-modal';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>📝 Notizen</h2>
|
|
||||||
<button class="close-btn" id="nl-close">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<button class="btn" id="nl-new">+ Neue Notiz</button>
|
|
||||||
<div id="nl-list"><div class="empty-state">Lade…</div></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
pushModal(modal);
|
|
||||||
modal.querySelector('#nl-close').onclick = () => closeModal(modal);
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const listEl = modal.querySelector('#nl-list');
|
|
||||||
try {
|
|
||||||
const data = await api.listNotes(orderId);
|
|
||||||
if (!data.notes.length) {
|
|
||||||
listEl.innerHTML = '<div class="empty-state"><div class="icon">📝</div>Noch keine Notiz</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listEl.innerHTML = data.notes.map(n => `
|
|
||||||
<div class="note-card" data-file="${escapeHtml(n.file)}">
|
|
||||||
<div class="note-card-head">
|
|
||||||
<strong>${escapeHtml(n.subject || '(ohne Betreff)')}</strong>
|
|
||||||
<button class="icon-btn note-del" title="Löschen">🗑</button>
|
|
||||||
</div>
|
|
||||||
<div class="note-card-preview">${escapeHtml(n.preview || '')}</div>
|
|
||||||
<div class="note-card-meta">${escapeHtml(n.modified || n.created || formatShortDate(n.date))}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
listEl.querySelectorAll('.note-card').forEach(card => {
|
|
||||||
card.onclick = (e) => {
|
|
||||||
if (e.target.closest('.note-del')) return;
|
|
||||||
openNoteEditor(orderId, card.dataset.file, refresh);
|
|
||||||
};
|
|
||||||
card.querySelector('.note-del').onclick = async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!await confirmDialog({
|
|
||||||
title: 'Notiz löschen',
|
|
||||||
text: 'Die Notiz wird endgültig aus den Dokumenten des Auftrags entfernt.',
|
|
||||||
confirmLabel: 'Löschen',
|
|
||||||
danger: true,
|
|
||||||
})) return;
|
|
||||||
try {
|
|
||||||
await api.deleteNote(orderId, card.dataset.file);
|
|
||||||
showToast('Notiz gelöscht');
|
|
||||||
refresh();
|
|
||||||
router.navigate();
|
|
||||||
} catch (err) {
|
|
||||||
showToast('Fehler: ' + err.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
listEl.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.querySelector('#nl-new').onclick = () => openNoteEditor(orderId, null, refresh);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Eine Notiz schreiben oder ändern. file = null → neue Notiz. */
|
|
||||||
async function openNoteEditor(orderId, file, onSaved) {
|
|
||||||
let existing = null;
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
existing = (await api.getNote(orderId, file)).note;
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Notiz konnte nicht geladen werden: ' + e.message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fullscreen-modal note-modal';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="fs-header">
|
|
||||||
<button class="icon-btn" id="nt-close">✕</button>
|
|
||||||
<div class="fs-title">📝 ${file ? 'Notiz bearbeiten' : 'Neue Notiz'}</div>
|
|
||||||
<button class="icon-btn" id="nt-save" title="Speichern">✓</button>
|
|
||||||
</div>
|
|
||||||
<div class="note-edit-body">
|
|
||||||
<input type="text" id="nt-subject" maxlength="60" placeholder="Betreff, z. B. Zählernummern Keller"
|
|
||||||
value="${escapeHtml(existing ? existing.subject : '')}">
|
|
||||||
<textarea id="nt-text" placeholder="Notiz…">${escapeHtml(existing ? existing.text : '')}</textarea>
|
|
||||||
${existing && existing.created ? `<div class="note-edit-meta">Erfasst: ${escapeHtml(existing.created)}${existing.modified ? ' · Geändert: ' + escapeHtml(existing.modified) : ''}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
pushModal(modal);
|
|
||||||
|
|
||||||
const subjectEl = modal.querySelector('#nt-subject');
|
|
||||||
const textEl = modal.querySelector('#nt-text');
|
|
||||||
const startSig = (existing ? existing.subject + ' ' + existing.text : ' ');
|
|
||||||
|
|
||||||
const currentSig = () => subjectEl.value + ' ' + textEl.value;
|
|
||||||
|
|
||||||
modal.querySelector('#nt-close').onclick = async () => {
|
|
||||||
if (currentSig() !== startSig && !await confirmDialog({
|
|
||||||
title: 'Notiz verwerfen',
|
|
||||||
text: 'Die Änderungen wurden noch nicht gespeichert.',
|
|
||||||
confirmLabel: 'Verwerfen',
|
|
||||||
danger: true,
|
|
||||||
})) return;
|
|
||||||
closeModal(modal);
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.querySelector('#nt-save').onclick = async () => {
|
|
||||||
const subject = subjectEl.value.trim();
|
|
||||||
const text = textEl.value;
|
|
||||||
if (!subject && !text.trim()) { showToast('Die Notiz ist leer', 'warn'); return; }
|
|
||||||
|
|
||||||
const btn = modal.querySelector('#nt-save');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
await api.saveNote(orderId, { subject, text }, file);
|
|
||||||
showToast('✓ Notiz gespeichert');
|
|
||||||
closeModal(modal);
|
|
||||||
if (onSaved) onSaved();
|
|
||||||
router.navigate();
|
|
||||||
} catch (e) {
|
|
||||||
btn.disabled = false;
|
|
||||||
showToast('Speichern fehlgeschlagen: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bei einer neuen Notiz erst den Betreff, beim Bearbeiten direkt in den Text
|
|
||||||
setTimeout(() => { (file ? textEl : subjectEl).focus(); }, 80);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
43
lib/api.js
43
lib/api.js
|
|
@ -212,27 +212,6 @@
|
||||||
return uploadOrderPhoto(orderId, fileBlob, filename);
|
return uploadOrderPhoto(orderId, fileBlob, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Textnotizen — eigener Endpoint wie die Sprachnotiz, damit die Datei als
|
|
||||||
* notiz_<betreff>_<datum>.txt im Auftragsordner landet. */
|
|
||||||
async function listNotes(orderId) {
|
|
||||||
return request('/note.php?order_id=' + orderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNote(orderId, file) {
|
|
||||||
return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** file weglassen → neue Notiz, file angeben → bestehende ändern */
|
|
||||||
async function saveNote(orderId, data, file) {
|
|
||||||
const qs = '/note.php?order_id=' + orderId + (file ? '&file=' + encodeURIComponent(file) : '');
|
|
||||||
return request(qs, { method: 'POST', body: JSON.stringify(data), timeoutMs: 45000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteNote(orderId, file) {
|
|
||||||
return request('/note.php?order_id=' + orderId + '&file=' + encodeURIComponent(file) + '&delete=1',
|
|
||||||
{ method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePage(pageId) {
|
async function deletePage(pageId) {
|
||||||
return request('/pages.php?id=' + pageId, { method: 'DELETE' });
|
return request('/pages.php?id=' + pageId, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
@ -306,25 +285,6 @@
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Direkte URL auf ein serverseitig erzeugtes Thumbnail — für <img src="…">.
|
|
||||||
*
|
|
||||||
* Bewusst KEIN fetch→Blob: über die echte URL greifen HTTP-Cache (ETag, 30 Tage) und
|
|
||||||
* loading="lazy", und der Browser lädt die Kacheln parallel. Der Blob-Weg lädt jede
|
|
||||||
* Datei komplett, bevor das <img> überhaupt existiert — bei 30 Baustellenfotos war das
|
|
||||||
* der Grund, warum die Fotoliste im Mobilfunknetz so lange stand.
|
|
||||||
* Auth läuft über das awl_sso-Cookie, das der Browser bei same-origin mitschickt.
|
|
||||||
*/
|
|
||||||
function photoThumbUrl(relpath, w) {
|
|
||||||
const params = new URLSearchParams({ relpath, size: 'thumb', w: String(w || 320) });
|
|
||||||
return API_BASE + '/photo.php?' + params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Direkte URL auf die Originaldatei — für Vollbild-<img>, Audio-Player, Download. */
|
|
||||||
function photoUrl(relpath) {
|
|
||||||
return API_BASE + '/photo.php?' + new URLSearchParams({ relpath }).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Bild-Datei von der API als Blob-URL.
|
* Lädt eine Bild-Datei von der API als Blob-URL.
|
||||||
* Auth über das awl_sso-Cookie (same-origin) — photo.php nutzt awlauth_verify.
|
* Auth über das awl_sso-Cookie (same-origin) — photo.php nutzt awlauth_verify.
|
||||||
|
|
@ -385,9 +345,8 @@
|
||||||
listCustomers, getCustomer,
|
listCustomers, getCustomer,
|
||||||
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
||||||
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||||
listNotes, getNote, saveNote, deleteNote,
|
|
||||||
listMaterials, addMaterial, deleteMaterial,
|
listMaterials, addMaterial, deleteMaterial,
|
||||||
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
|
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
|
||||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||||
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
|
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "share.html",
|
"action": "https://awl.data-it-solution.de/custom/baustelle/share.html",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"enctype": "multipart/form-data",
|
"enctype": "multipart/form-data",
|
||||||
"params": {
|
"params": {
|
||||||
|
|
|
||||||
53
sw.js
53
sw.js
|
|
@ -9,42 +9,6 @@
|
||||||
// Cache-Version dynamisch aus ?v=<mtime> — wird von index.php gesetzt
|
// Cache-Version dynamisch aus ?v=<mtime> — wird von index.php gesetzt
|
||||||
const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static';
|
const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static';
|
||||||
const CACHE = 'baustelle-' + SW_VERSION;
|
const CACHE = 'baustelle-' + SW_VERSION;
|
||||||
|
|
||||||
/* Bild-Cache für die Thumbnails aus photo.php — bewusst NICHT versioniert, damit ein
|
|
||||||
* Deploy die schon geladenen Fotos nicht wegräumt. Ohne das ist auf der Baustelle ohne
|
|
||||||
* Netz kein einziges Foto sichtbar, auch keins, das man vorher schon angesehen hat. */
|
|
||||||
const MEDIA_CACHE = 'baustelle-media';
|
|
||||||
const MEDIA_MAX = 300;
|
|
||||||
|
|
||||||
async function trimMediaCache() {
|
|
||||||
const c = await caches.open(MEDIA_CACHE);
|
|
||||||
const keys = await c.keys();
|
|
||||||
// keys() liefert in Einfüge-Reihenfolge — die ältesten stehen vorn
|
|
||||||
for (let i = 0; i < keys.length - MEDIA_MAX; i++) await c.delete(keys[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thumbnails: erst Cache, dann Netz (und im Hintergrund auffrischen).
|
|
||||||
* Die URL enthält relpath + Größe, und der Server schickt ein ETag aus der mtime —
|
|
||||||
* ein ersetztes Original bekommt damit ohnehin eine neue Antwort. */
|
|
||||||
async function mediaCacheFirst(request) {
|
|
||||||
const c = await caches.open(MEDIA_CACHE);
|
|
||||||
const hit = await c.match(request);
|
|
||||||
if (hit) {
|
|
||||||
// Auffrischen ohne den Nutzer warten zu lassen
|
|
||||||
fetch(request).then(r => {
|
|
||||||
if (r && r.ok) c.put(request, r.clone()).then(trimMediaCache);
|
|
||||||
}).catch(() => null);
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const r = await fetch(request);
|
|
||||||
if (r && r.ok) { await c.put(request, r.clone()); trimMediaCache(); }
|
|
||||||
return r;
|
|
||||||
} catch (e) {
|
|
||||||
// Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter
|
|
||||||
return new Response('', { status: 504, statusText: 'Offline' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'./',
|
'./',
|
||||||
'./index.php',
|
'./index.php',
|
||||||
|
|
@ -68,8 +32,7 @@ self.addEventListener('activate', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
caches.keys()
|
caches.keys()
|
||||||
.then(keys => Promise.all(
|
.then(keys => Promise.all(
|
||||||
// MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst
|
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE)
|
||||||
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE)
|
|
||||||
.map(k => caches.delete(k))
|
.map(k => caches.delete(k))
|
||||||
))
|
))
|
||||||
.then(() => self.clients.claim())
|
.then(() => self.clients.claim())
|
||||||
|
|
@ -121,15 +84,7 @@ self.addEventListener('fetch', (e) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumbnails aus photo.php: cache-first, damit die Fotoliste offline steht
|
// API-Requests: nicht cachen, durchreichen
|
||||||
if (e.request.method === 'GET'
|
|
||||||
&& url.pathname.endsWith('/custom/bericht/api/photo.php')
|
|
||||||
&& url.searchParams.get('size') === 'thumb') {
|
|
||||||
e.respondWith(mediaCacheFirst(e.request));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Übrige API-Requests: nicht cachen, durchreichen
|
|
||||||
if (url.pathname.includes('/custom/bericht/api/')) {
|
if (url.pathname.includes('/custom/bericht/api/')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -163,8 +118,4 @@ self.addEventListener('message', (e) => {
|
||||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
}
|
}
|
||||||
// Beim Abmelden dürfen keine Kundenfotos auf dem Gerät zurückbleiben
|
|
||||||
if (e.data && e.data.type === 'CLEAR_MEDIA') {
|
|
||||||
e.waitUntil(caches.delete(MEDIA_CACHE));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue