Compare commits

..

No commits in common. "10d5f915df9c36ab4ef7a2a984c583e6a3cc8eae" and "7c7de5846070e38a9d2dde4cba09438739c80cd1" have entirely different histories.

7 changed files with 44 additions and 825 deletions

View file

@ -1,6 +1,6 @@
# 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
@ -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
- ✅ 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
@ -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
- ✅ „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
@ -90,9 +81,8 @@ baustelle-pwa/
├── index.php App-Shell (Cache-Busting via filemtime → ?v=<mtime>)
├── share.html Web-Share-Target-Empfänger
├── manifest.webmanifest PWA Manifest
├── sw.js Service Worker (Network-First für Assets, API pass-through,
│ cache-first für photo.php?size=thumb → Cache baustelle-media)
├── app.js Hauptlogik (Routes, Views, Dialoge, Notizen)
├── sw.js Service Worker (Network-First, API pass-through)
├── app.js Hauptlogik (Routes, Views)
├── app.css Mobile-first Styling
├── lib/
│ ├── idb.js IndexedDB-Wrapper (Foto-Queue)
@ -116,7 +106,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 (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)
**Kunden**
@ -125,7 +115,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 (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?id=X&action=finalize` — Bericht finalisieren
- `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
- `POST /shipments.php?id=X&action=confirm` — Kunden-Unterschrift einstempeln (FormData mit signature_png, signer_name, gps_lat, gps_lon, signed_at). Backend setzt signed_status=1 und schließt die Expedition
**Notizen**
- `GET /note.php?order_id=X` — Liste (Betreff, Vorschau, Zeitstempel)
- `GET /note.php?order_id=X&file=<name>` — eine Notiz mit vollem Text
- `POST /note.php?order_id=X` — anlegen `{ subject, text }`
- `POST /note.php?order_id=X&file=<name>` — ändern
- `POST /note.php?order_id=X&file=<name>&delete=1` — löschen
**Medien**
- `GET /photo.php?relpath=X` — Datei abrufen; Auth über `awl_sso`-Cookie (same-origin, auch für `<img>`-Tags — kein `?jwt=` mehr nötig)
- `GET /photo.php?relpath=X&size=thumb&w=<px>` — serverseitiges Thumbnail (GD, gecacht, ETag → 304). **Der Weg für Kachel-Ansichten.** `size=small` ist etwas anderes: das liefert nur ein von Dolibarr vorgefertigtes Thumb, das es für PWA-Uploads gar nicht gibt — dort kam bis Modul-Version 1.5.1 immer das Original zurück
- `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)
- `POST /delete_photo.php` — Foto löschen
- `POST /voice.php?order_id=X` — Sprachnotiz hochladen
- `POST /transcribe.php` — Whisper-Transkription anfordern

View file

@ -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,51 MB (bei über die
Dolibarr-Oberfläche hochgeladenen Originalen entsprechend mehr)
Das Modul kann das seit 1.5.0 längst: `bericht_attachment_thumb($full, $maxside)` in
`lib/bericht.lib.php` (GD, Cache unter `bericht/thumbs/`, EXIF-Rotation, Speicher-Notbremse).
`api/photo.php` lädt diese lib bereits.
- [x] **[API]** `photo.php` um `size=thumb&w=<px>` erweitern → `bericht_attachment_thumb()`,
mit ETag aus mtime und `Cache-Control: private, max-age=…, immutable`
- [x] `loadThumbs()` umbauen: `<img src="…api/photo.php?…&size=thumb">` **direkt** setzen statt
fetch → Blob → ObjectURL. Damit greifen HTTP-Cache und `loading="lazy"` wirklich; heute
sind beide wirkungslos, weil der Blob schon vorher komplett geladen wird.
- [x] Sequenzielle Schleife auflösen — `for (const t of thumbs) { await … }` lädt Foto für Foto
nacheinander. Beim `<img>`-Weg erledigt der Browser das parallel von selbst.
- [x] Doppel-Ladung entfernen: schlägt `size=small` fehl, wird das Original **noch einmal**
geladen.
### 1.2 Aufnahmezeit statt Dateidatum ✅ [API]
1.5.0 hat einen eigenen EXIF-Parser bekommen (`bericht_jpeg_taken_at()` /
`bericht_file_taken_at()`), weil im Prod-Container das `exif`-Modul fehlt. Der Editor sortiert
Anhänge damit nach Aufnahmezeit. `orders.php?action=photos` lieferte nur `filemtime`.
- [x] **[API]** `taken_at` je Datei ausgeben
- [x] Fotoliste nach Aufnahmezeit sortieren (Auswahl gemerkt, wie im Editor)
### 1.3 Seiten-Titel kommt nie an ✅ [API]
`api/pages.php` **kann** `title` einer Seite setzen, die PWA rendert ihn (`page-title-badge`,
`openPageActionsModal(…, title)`) — aber `api/reports.php` gibt `title` im `pages`-Array
**nicht aus**. Ein im Editor gesetzter Titel ist in der PWA also unsichtbar, und ein in der PWA
gesetzter Titel scheint nach dem Neuladen verschwunden.
- [x] **[API]** `'title' => $p->title` in `reports.php` ergänzen (eine Zeile)
### 1.4 Bearbeitete Seiten und Raster-Layouts ✅ [API]
Seit 1.5.0 hat eine Seite `composite_path` (das im Editor gebaute Seitenbild) und `layout`
(`single`, `grid_2`, `grid_4`, `before_after`, `title_only`, …). Die PWA zeigt stur
`source_path`:
- Nach Bearbeitung im Desktop-Editor zeigt die PWA weiter das **Rohbild** ohne Anmerkungen
- Bei einer Rasterseite ist `source_path` nur **eines von bis zu sechs** Bildern
- Bei `title_only` gibt es gar keine Quelle → ❌-Kachel
- [x] **[API]** `composite_path` in `reports.php` mit ausgeben
- [x] PWA zeigt `composite_path`, wenn vorhanden, sonst `source_path`
- [x] Layout-Kennzeichen an der Kachel (z. B. „⊞ 4er-Raster"), damit klar ist, warum dort
mehr drauf ist als das eine Foto
---
## 2. Verbesserungen unabhängig vom Modul
### 2.1 Browser-Dialoge raus (10 Stück) ✅
`confirm()` / `alert()` / `prompt()` widersprechen der Hausregel und sehen in der
Standalone-PWA auf Android wie ein Systemfehler aus. Ein sauberes Muster liegt schon vor:
das „Bericht löschen"-Modal in `router.on('/reports/:id')`.
| Stelle | Zeile (vor dem Umbau) |
|---|---|
| PIN stimmt nicht überein | `alert` 295 |
| Bericht finalisieren | `confirm` 1111 |
| PIN-Schutz deaktivieren | `confirm` 1264 |
| Material-Eintrag löschen | `confirm` 1432 |
| Seite aus Bericht entfernen | `confirm` 1809 |
| Foto löschen | `confirm` 2701 |
| Nicht hochgeladenes Foto löschen | `confirm` 3019 |
| Mess-Skala: Länge eingeben | `prompt` 3409 |
| Ungültiges Längenformat | `alert` 3419 |
| „Zuerst kalibrieren" | `alert` 3440 |
- [x] Eine `confirmDialog({title, text, danger})`-Hilfsfunktion (Promise) + `inputDialog()`
für die Längeneingabe, alle zehn Stellen darauf umstellen
### 2.2 Fotos offline sichtbar halten ✅
Der Service Worker reichte alles unter `/custom/bericht/api/` ungecacht durch. Auf der Baustelle
ohne Netz ist damit **kein einziges** schon angesehenes Foto verfügbar.
- [x] Eigener Runtime-Cache `baustelle-media` für `photo.php`-GETs, cache-first mit
Größenbegrenzung (LRU, z. B. 200 Einträge). Die Thumb-URLs aus 1.1 sind dafür der
richtige Kandidat — klein und unveränderlich.
- [x] Vom App-Shell-Cache getrennt halten, damit ein Deploy die Medien nicht wegräumt
---
## 3. Textnotizen ✅ (2026-08-22)
Getippte Notiz zum Auftrag — für alles, was man lieber schreibt als diktiert: Zählernummern,
Absprachen, Maße.
- [x] „📝 Notizen (n)" am Auftrag, direkt unter der Sprachnotiz
- [x] Liste mit Betreff, zweizeiliger Vorschau und Zeitstempel; Antippen öffnet zum Lesen
und Ändern, 🗑 löscht (mit Rückfrage)
- [x] Editor: Betreff-Feld + Textfeld über die volle Höhe, Speichern über ✓ in der Kopfzeile,
Rückfrage beim Verlassen mit ungespeicherten Änderungen
- [x] Ablage als `notiz_<betreff>_<datum>.txt` im Auftragsordner (`api/note.php`) — genau der
Weg der Sprachnotiz. Damit auch im Dolibarr-Auftrag und in der Anhänge-Spalte des
Bericht-Editors sichtbar.
- [x] Notizen sind aus „Weitere Dokumente" ausgefiltert — sie haben ihre eigene Sektion und
stünden dort sonst doppelt
**Nicht** umgesetzt (und bewusst verworfen): eine Zeichenfläche für echte Handschrift. Am
Handy ohne Stift ist das unbrauchbar.
## 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
View file

@ -1279,192 +1279,3 @@ 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
View file

@ -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.
* shareFile(blob, filename, mime, title) einzelne Datei
@ -380,7 +292,7 @@ async function promptNewPin() {
if (!p1 || p1.length !== 4) return null;
const p2 = await promptPin('PIN wiederholen');
if (p2 !== p1) {
await alertDialog({ title: 'PIN', text: 'Die beiden Eingaben stimmen nicht überein.' });
alert('PINs stimmen nicht überein');
return null;
}
return p1;
@ -631,12 +543,7 @@ router.on('/orders/:id', async (args) => {
try {
const data = await api.getOrder(args.id);
// 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;
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
title(data.order.ref);
// Nach MIME-Type aufteilen
@ -645,9 +552,7 @@ 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) &&
// Notizen haben ihre eigene Sektion — hier wären sie doppelt
!/^notiz_.*\.txt$/i.test(p.filename));
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
main().innerHTML = `
<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>
<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>
@ -825,7 +729,6 @@ 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);
@ -959,56 +862,23 @@ async function resizeImage(file, maxSide) {
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
* deshalb Blob-URLs).
*/
/* Kacheln mit serverseitigen Thumbnails füllen.
*
* Vorher lief das sequenziell über fetchBlobObjectURL 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;
async function loadThumbs() {
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
for (const t of thumbs) {
const rel = t.dataset.relpath;
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;
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>';
}
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);
});
} catch (e) {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
}
}
router.on('/customers', async () => {
@ -1200,20 +1070,13 @@ router.on('/reports/:id', async (args) => {
${hasPages ? `
<div class="photo-grid" id="report-pages">
${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>
${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>
<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>
@ -1245,10 +1108,9 @@ router.on('/reports/:id', async (args) => {
if (finalizeBtn) {
finalizeBtn.onclick = async () => {
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); 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;
if (!confirm(data.report.status === 1
? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?'
: 'Bericht jetzt finalisieren und PDF erzeugen?')) return;
showToast('PDF wird erzeugt…');
try {
const r = await api.finalizeReport(args.id);
@ -1350,13 +1212,6 @@ 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
@ -1406,13 +1261,7 @@ router.on('/settings', async () => {
showToast('✓ PIN gesetzt');
router.navigate();
} else {
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) {
if (!confirm('PIN-Schutz wirklich deaktivieren?')) {
e.target.checked = true;
return;
}
@ -1580,7 +1429,7 @@ async function openMaterialModal(elementType, elementId) {
btn.addEventListener('click', async (e) => {
const card = e.target.closest('.mini-card');
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);
reload();
});
@ -1957,12 +1806,7 @@ async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
}
};
modal.querySelector('#pa-delete').onclick = async () => {
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;
if (!confirm('Diese Seite aus dem Bericht entfernen?')) return;
try {
await api.deletePage(pageId);
showToast('✓ Seite entfernt');
@ -2596,20 +2440,6 @@ 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
@ -2868,12 +2698,7 @@ async function openPhotoModal(orderId, relpath) {
modal.querySelector('#pv-zreset').onclick = resetZoom;
modal.querySelector('#fs-delete').onclick = async () => {
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;
if (!confirm('Foto wirklich löschen?')) return;
try {
await api.deletePhoto(allRelpaths[currentIndex]);
showToast('✓ Gelöscht');
@ -3191,12 +3016,7 @@ 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 (!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;
if (!confirm('Dieses Foto ist noch nicht hochgeladen. Wirklich löschen?')) return;
}
t.dataset.deleting = '1';
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');
return;
}
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',
});
const input = prompt('Reale Länge dieser Strecke:\nFormat: Zahl + Einheit (z. B. "50 cm", "1.5 m", "250 mm")');
if (!input) {
await restoreSnapshot(snapshotBeforeShape);
snapshotBeforeShape = null;
@ -3601,7 +3416,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
if (!m) {
await restoreSnapshot(snapshotBeforeShape);
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;
}
const val = parseFloat(m[1].replace(',', '.'));
@ -3622,10 +3437,7 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
if (!calibration) {
await restoreSnapshot(snapshotBeforeShape);
snapshotBeforeShape = null;
await alertDialog({
title: 'Noch nicht kalibriert',
text: 'Erst 📏⚙ antippen und eine Strecke mit bekannter Länge ziehen — danach kann gemessen werden.',
});
alert('Zuerst kalibrieren! Tap auf 📏⚙, zwei Punkte mit bekannter Länge wählen.');
return;
}
// 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);
};
}
/* ============================================================
* 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">&times;</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);
}

View file

@ -212,27 +212,6 @@
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' });
}
@ -306,25 +285,6 @@
return URL.createObjectURL(blob);
}
/**
* Direkte URL auf ein serverseitig erzeugtes Thumbnail für <img src="…">.
*
* Bewusst KEIN fetchBlob: ü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.
@ -385,9 +345,8 @@
listCustomers, getCustomer,
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport, deleteReport,
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
listNotes, getNote, saveNote, deleteNote,
listMaterials, addMaterial, deleteMaterial,
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
};

View file

@ -19,7 +19,7 @@
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"share_target": {
"action": "share.html",
"action": "https://awl.data-it-solution.de/custom/baustelle/share.html",
"method": "POST",
"enctype": "multipart/form-data",
"params": {

53
sw.js
View file

@ -9,42 +9,6 @@
// 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',
@ -68,8 +32,7 @@ self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(
// MEDIA_CACHE ausnehmen — der überlebt Deploys bewusst
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE && k !== MEDIA_CACHE)
keys.filter(k => k.startsWith('baustelle-') && k !== CACHE)
.map(k => caches.delete(k))
))
.then(() => self.clients.claim())
@ -121,15 +84,7 @@ self.addEventListener('fetch', (e) => {
return;
}
// 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
// API-Requests: nicht cachen, durchreichen
if (url.pathname.includes('/custom/bericht/api/')) {
return;
}
@ -163,8 +118,4 @@ 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));
}
});