Compare commits
2 commits
7c7de58460
...
10d5f915df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d5f915df | ||
|
|
4a53ab0ebe |
7 changed files with 825 additions and 44 deletions
30
README.md
30
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Baustelle PWA
|
||||
|
||||
Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotizen, Skizzen, offline-fähig. Spricht die REST-API des Dolibarr-Bericht-Moduls.
|
||||
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
|
||||
|
||||
|
|
@ -25,15 +25,22 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotiz
|
|||
- ✅ **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
|
||||
- ✅ 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
|
||||
|
|
@ -65,6 +72,8 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprachnotiz
|
|||
- ✅ 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)
|
||||
- ✅ **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
|
||||
|
||||
|
|
@ -81,8 +90,9 @@ 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, API pass-through)
|
||||
├── app.js Hauptlogik (Routes, Views)
|
||||
├── 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)
|
||||
|
|
@ -106,7 +116,7 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
|||
**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
|
||||
- `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)
|
||||
|
||||
**Kunden**
|
||||
|
|
@ -115,7 +125,7 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
|||
|
||||
**Berichte**
|
||||
- `GET /reports.php` — Alle Berichte des Users
|
||||
- `GET /reports.php?id=X` — Bericht-Detail mit Seiten
|
||||
- `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
|
||||
|
|
@ -137,8 +147,16 @@ Die App spricht die Bericht-API unter `/custom/bericht/api/`:
|
|||
- `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` — 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` — 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
|
||||
|
|
|
|||
149
ROADMAP.md
Normal file
149
ROADMAP.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# 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,3 +1279,192 @@ body {
|
|||
}
|
||||
.cam-shutter:active { transform: scale(0.92); }
|
||||
.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,6 +101,94 @@ 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.
|
||||
* shareFile(blob, filename, mime, title) — einzelne Datei
|
||||
|
|
@ -292,7 +380,7 @@ async function promptNewPin() {
|
|||
if (!p1 || p1.length !== 4) return null;
|
||||
const p2 = await promptPin('PIN wiederholen');
|
||||
if (p2 !== p1) {
|
||||
alert('PINs stimmen nicht überein');
|
||||
await alertDialog({ title: 'PIN', text: 'Die beiden Eingaben stimmen nicht überein.' });
|
||||
return null;
|
||||
}
|
||||
return p1;
|
||||
|
|
@ -543,7 +631,12 @@ router.on('/orders/:id', async (args) => {
|
|||
|
||||
try {
|
||||
const data = await api.getOrder(args.id);
|
||||
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
|
||||
// Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr
|
||||
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);
|
||||
|
||||
// Nach MIME-Type aufteilen
|
||||
|
|
@ -552,7 +645,9 @@ router.on('/orders/:id', async (args) => {
|
|||
const otherDocs = photos.photos.filter(p =>
|
||||
!(p.mime || '').startsWith('image/') &&
|
||||
!(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 = `
|
||||
<div class="detail-section">
|
||||
|
|
@ -574,6 +669,7 @@ router.on('/orders/:id', async (args) => {
|
|||
<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>
|
||||
<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-new-report">📑 Neuen Bericht anlegen…</button>
|
||||
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
||||
|
|
@ -729,6 +825,7 @@ router.on('/orders/:id', async (args) => {
|
|||
|
||||
// Sprachnotiz
|
||||
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
||||
document.getElementById('btn-notes').onclick = () => openNotesModal(args.id);
|
||||
|
||||
// Neuen Bericht anlegen
|
||||
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
||||
|
|
@ -862,23 +959,56 @@ async function resizeImage(file, maxSide) {
|
|||
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
|
||||
* deshalb Blob-URLs).
|
||||
*/
|
||||
async function loadThumbs() {
|
||||
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
|
||||
for (const t of thumbs) {
|
||||
/* Kacheln mit serverseitigen Thumbnails füllen.
|
||||
*
|
||||
* Vorher lief das sequenziell über fetch→Blob→ObjectURL und lud dabei die kompletten
|
||||
* 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;
|
||||
try {
|
||||
// Erst Thumbnail versuchen (_small), bei Misserfolg das Original
|
||||
let url = await api.getPhotoBlobUrl(rel, 'small');
|
||||
if (!url) url = await api.getPhotoBlobUrl(rel);
|
||||
if (url) {
|
||||
t.innerHTML = '<img loading="lazy" src="' + url + '">';
|
||||
} else {
|
||||
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
|
||||
}
|
||||
if (!rel) return; // z. B. Seite im Layout "nur Titel" — kein Bild
|
||||
t.dataset.thumbLoaded = '1';
|
||||
|
||||
const img = new Image();
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.alt = '';
|
||||
img.className = 'thumb-img';
|
||||
img.onerror = () => {
|
||||
// Kein Bild (PDF/Video) oder Fehler → Original versuchen, sonst Platzhalter
|
||||
if (img.dataset.retried === '1') {
|
||||
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 () => {
|
||||
|
|
@ -1070,13 +1200,20 @@ router.on('/reports/:id', async (args) => {
|
|||
|
||||
${hasPages ? `
|
||||
<div class="photo-grid" id="report-pages">
|
||||
${data.pages.map((p, i) => `
|
||||
<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 || '')}">
|
||||
<div class="thumb-placeholder">⏳</div>
|
||||
${data.pages.map((p, i) => {
|
||||
// composite_path ist das im Editor gebaute Seitenbild (Anmerkungen,
|
||||
// Raster, Notizzone). Nur source_path zu zeigen hieße: das Rohbild
|
||||
// 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>
|
||||
${badge ? `<div class="page-layout-badge">${badge}</div>` : ''}
|
||||
${p.title ? `<div class="page-title-badge">${escapeHtml(p.title)}</div>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</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>
|
||||
|
|
@ -1108,9 +1245,10 @@ router.on('/reports/:id', async (args) => {
|
|||
if (finalizeBtn) {
|
||||
finalizeBtn.onclick = async () => {
|
||||
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; }
|
||||
if (!confirm(data.report.status === 1
|
||||
? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?'
|
||||
: 'Bericht jetzt finalisieren und PDF erzeugen?')) return;
|
||||
const goOn = await confirmDialog(data.report.status === 1
|
||||
? { title: 'PDF neu erzeugen', text: 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?', confirmLabel: 'Neu erzeugen' }
|
||||
: { title: 'Bericht finalisieren', text: 'Bericht jetzt finalisieren und PDF erzeugen?', confirmLabel: 'Finalisieren' });
|
||||
if (!goOn) return;
|
||||
showToast('PDF wird erzeugt…');
|
||||
try {
|
||||
const r = await api.finalizeReport(args.id);
|
||||
|
|
@ -1212,6 +1350,13 @@ router.on('/settings', async () => {
|
|||
await api.logout();
|
||||
await idb.del('pin_hash');
|
||||
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');
|
||||
};
|
||||
// ntfy-Settings
|
||||
|
|
@ -1261,7 +1406,13 @@ router.on('/settings', async () => {
|
|||
showToast('✓ PIN gesetzt');
|
||||
router.navigate();
|
||||
} else {
|
||||
if (!confirm('PIN-Schutz wirklich deaktivieren?')) {
|
||||
const off = await confirmDialog({
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
|
@ -1429,7 +1580,7 @@ async function openMaterialModal(elementType, elementId) {
|
|||
btn.addEventListener('click', async (e) => {
|
||||
const card = e.target.closest('.mini-card');
|
||||
const id = card.dataset.id;
|
||||
if (!confirm('Eintrag löschen?')) return;
|
||||
if (!await confirmDialog({ title: 'Material', text: 'Diesen Eintrag löschen?', confirmLabel: 'Löschen', danger: true })) return;
|
||||
await api.deleteMaterial(id);
|
||||
reload();
|
||||
});
|
||||
|
|
@ -1806,7 +1957,12 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
|
|||
}
|
||||
};
|
||||
modal.querySelector('#pa-delete').onclick = async () => {
|
||||
if (!confirm('Diese Seite aus dem Bericht entfernen?')) return;
|
||||
if (!await confirmDialog({
|
||||
title: 'Seite entfernen',
|
||||
text: 'Diese Seite aus dem Bericht entfernen? Anmerkungen und Notiz der Seite gehen dabei verloren.',
|
||||
confirmLabel: 'Entfernen',
|
||||
danger: true,
|
||||
})) return;
|
||||
try {
|
||||
await api.deletePage(pageId);
|
||||
showToast('✓ Seite entfernt');
|
||||
|
|
@ -2440,6 +2596,20 @@ function openHelpModal() {
|
|||
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) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
|
@ -2698,7 +2868,12 @@ async function openPhotoModal(orderId, relpath) {
|
|||
modal.querySelector('#pv-zreset').onclick = resetZoom;
|
||||
|
||||
modal.querySelector('#fs-delete').onclick = async () => {
|
||||
if (!confirm('Foto wirklich löschen?')) return;
|
||||
if (!await confirmDialog({
|
||||
title: 'Foto löschen',
|
||||
text: 'Das Foto wird endgültig aus den Dokumenten des Auftrags entfernt.',
|
||||
confirmLabel: 'Löschen',
|
||||
danger: true,
|
||||
})) return;
|
||||
try {
|
||||
await api.deletePhoto(allRelpaths[currentIndex]);
|
||||
showToast('✓ Gelöscht');
|
||||
|
|
@ -3016,7 +3191,12 @@ async function openCameraModal(orderId) {
|
|||
if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
|
||||
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
|
||||
if (!relpath && qid) {
|
||||
if (!confirm('Dieses Foto ist noch nicht hochgeladen. Wirklich löschen?')) return;
|
||||
if (!await confirmDialog({
|
||||
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';
|
||||
const delBtn = t.querySelector('.cam-thumb-del');
|
||||
|
|
@ -3406,7 +3586,12 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
|||
showToast('Zu kurz — 2 Punkte mit Abstand wählen', 'error');
|
||||
return;
|
||||
}
|
||||
const input = prompt('Reale Länge dieser Strecke:\nFormat: Zahl + Einheit (z. B. "50 cm", "1.5 m", "250 mm")');
|
||||
const input = await inputDialog({
|
||||
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) {
|
||||
await restoreSnapshot(snapshotBeforeShape);
|
||||
snapshotBeforeShape = null;
|
||||
|
|
@ -3416,7 +3601,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
|||
if (!m) {
|
||||
await restoreSnapshot(snapshotBeforeShape);
|
||||
snapshotBeforeShape = null;
|
||||
alert('Ungültiges Format. Beispiel: 1.5 m');
|
||||
await alertDialog({ title: 'Eingabe nicht verstanden', text: 'Bitte Zahl und Einheit angeben, z. B. 1.5 m' });
|
||||
return;
|
||||
}
|
||||
const val = parseFloat(m[1].replace(',', '.'));
|
||||
|
|
@ -3437,7 +3622,10 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
|||
if (!calibration) {
|
||||
await restoreSnapshot(snapshotBeforeShape);
|
||||
snapshotBeforeShape = null;
|
||||
alert('Zuerst kalibrieren! Tap auf 📏⚙, zwei Punkte mit bekannter Länge wählen.');
|
||||
await alertDialog({
|
||||
title: 'Noch nicht kalibriert',
|
||||
text: 'Erst 📏⚙ antippen und eine Strecke mit bekannter Länge ziehen — danach kann gemessen werden.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis
|
||||
|
|
@ -3503,3 +3691,150 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
|||
}, '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,6 +212,27 @@
|
|||
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) {
|
||||
return request('/pages.php?id=' + pageId, { method: 'DELETE' });
|
||||
}
|
||||
|
|
@ -285,6 +306,25 @@
|
|||
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.
|
||||
* Auth über das awl_sso-Cookie (same-origin) — photo.php nutzt awlauth_verify.
|
||||
|
|
@ -345,8 +385,9 @@
|
|||
listCustomers, getCustomer,
|
||||
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
|
||||
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||
listNotes, getNote, saveNote, deleteNote,
|
||||
listMaterials, addMaterial, deleteMaterial,
|
||||
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
|
||||
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
|
||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"share_target": {
|
||||
"action": "https://awl.data-it-solution.de/custom/baustelle/share.html",
|
||||
"action": "share.html",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
|
|
|
|||
53
sw.js
53
sw.js
|
|
@ -9,6 +9,42 @@
|
|||
// Cache-Version dynamisch aus ?v=<mtime> — wird von index.php gesetzt
|
||||
const SW_VERSION = (new URL(self.location.href)).searchParams.get('v') || 'static';
|
||||
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 = [
|
||||
'./',
|
||||
'./index.php',
|
||||
|
|
@ -32,7 +68,8 @@ self.addEventListener('activate', (e) => {
|
|||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE)
|
||||
// MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst
|
||||
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE)
|
||||
.map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
|
|
@ -84,7 +121,15 @@ self.addEventListener('fetch', (e) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// API-Requests: nicht cachen, durchreichen
|
||||
// Thumbnails aus photo.php: cache-first, damit die Fotoliste offline steht
|
||||
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/')) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -118,4 +163,8 @@ self.addEventListener('message', (e) => {
|
|||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||
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