Rauswurf aus dem Auftrag behoben, wartende Fotos am Auftrag sichtbar
Gemeldet 18.09.2026: nach "Fertig" in der Kamera stand die App auf der Auftragsliste, und im Auftrag war von den Fotos nichts zu sehen. Ursache Rauswurf (Prod-Log 17.09., Auftrag 111): closeModal() ruft history.back() (asynchron), direkt danach setzt router.go() den Hash. Der Ruecksprung laeuft erst nach dem Hash-Wechsel und springt hinter den neuen Eintrag zurueck - angezeigt wird der Auftrag, in der Adresse steht '#/orders'. Das naechste router.navigate() (Kamera "Fertig") zeichnet dann die Liste. Betraf nur in derselben Sitzung ueber den Plus-Knopf angelegte Auftraege. - closeModal(): Zaehler _pendingBacks statt Boolean (KB #1209), History- Eintrag wird vor dem Cleanup zurueckgenommen - router.go() und pushModal() warten ueber historySettled() auf ausstehende Ruecksprunge (Schutzzeit 1,5 s) - Kamera und Galerie-Auswahl steuern nach dem Sichern ausdruecklich '#/orders/<id>' an statt den Hash der Adresszeile - Auftragsseite: Block "Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext; zieht nach photo-uploaded selbst nach - Hinweis-Toast beim Schliessen der Kamera, wenn noch Fotos warten Verifiziert lokal in Chromium mit echtem Offline-Modus; die Kamera selbst (getUserMedia) am Handy ist noch nicht getestet. Kein Deploy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
6bfe2e8824
commit
64ebbf7850
5 changed files with 304 additions and 21 deletions
|
|
@ -27,6 +27,7 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
|
||||||
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
|
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
|
||||||
- ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen)
|
- ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen)
|
||||||
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
|
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
|
||||||
|
- ✅ **Wartende Fotos stehen am Auftrag**: Block „⏳ Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext (gesichert auf dem Gerät, geht raus sobald Netz da ist); nach dem Upload zieht die Auftragsseite von selbst nach. Hinweis-Toast beim Schließen der Kamera, wenn noch etwas wartet
|
||||||
- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304
|
- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304
|
||||||
- ✅ Foto-Viewer mit Zoom + Swipe
|
- ✅ Foto-Viewer mit Zoom + Swipe
|
||||||
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
||||||
|
|
|
||||||
86
ROADMAP.md
86
ROADMAP.md
|
|
@ -1,6 +1,6 @@
|
||||||
# ROADMAP — Baustelle PWA
|
# ROADMAP — Baustelle PWA
|
||||||
|
|
||||||
Stand: 2026-08-31 · Gegenstück: `data/bericht` (Modul-Version 1.5.1)
|
Stand: 2026-09-18 · Gegenstück: `data/bericht` (Modul-Version 1.5.1)
|
||||||
|
|
||||||
Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine
|
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
|
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
|
||||||
|
|
@ -224,6 +224,90 @@ Keine Schreib-Warteschlange für Auftragsdaten. Die bräuchte Idempotenz und
|
||||||
Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst
|
Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst
|
||||||
(Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz.
|
(Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz.
|
||||||
|
|
||||||
|
## 6. Rauswurf aus dem Auftrag + unsichtbare Warteschlange (gemeldet 2026-09-18)
|
||||||
|
|
||||||
|
Gemeldet von Eddy: am 17.09.2026 gegen 18 Uhr mehrere Fotos in einem Auftrag gemacht,
|
||||||
|
„Fertig" getippt — die App stand danach auf der **Auftragsliste** statt im Auftrag. Wieder
|
||||||
|
in den Auftrag gegangen: **keine Fotos zu sehen**, kein Hinweis, dass sie noch warten.
|
||||||
|
|
||||||
|
### Befund (Prod-Zugriffslog 17.09., Auftrag 111 — kein Raten)
|
||||||
|
|
||||||
|
- 17:30:56 `POST orders.php?action=create` — Auftrag 111 über den ➕-Knopf angelegt. In
|
||||||
|
derselben Sekunde laufen **zwei** Routen gleichzeitig: `orders.php` (Liste) **und**
|
||||||
|
`orders.php?id=111` (Detail). Das ist die Signatur des Fehlers.
|
||||||
|
- 17:40:45 `verify.php` + `orders.php` (Liste) — der Rauswurf. **Kein** `index.php` davor,
|
||||||
|
also kein Reload, kein SW-Update, kein Logout (kein 401), kein Absturz.
|
||||||
|
- 17:40:47 Auftrag 111 erneut geöffnet, `action=photos` liefert eine **leere** Liste;
|
||||||
|
die Uploads kommen erst 17:40:54 und 17:57:25–17:58:22 an.
|
||||||
|
|
||||||
|
**Ursache Rauswurf — History-Race in `openNewOrderModal`:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
closeModal(modal); // -> history.back() (asynchron!)
|
||||||
|
router.go('#/orders/' + res.order.id); // -> location.hash = … (sofort)
|
||||||
|
```
|
||||||
|
|
||||||
|
`history.back()` wird erst nach dem Hash-Wechsel ausgeführt und springt dann **hinter** den
|
||||||
|
neuen Eintrag zurück. Ergebnis: Auf dem Bildschirm steht der Auftrag (seine Route wurde als
|
||||||
|
letzte fertig), in der Adresszeile steht aber `#/orders`. Alles, was danach
|
||||||
|
`router.navigate()` ruft — z. B. „Fertig" in der Kamera — zeichnet die **Auftragsliste**.
|
||||||
|
Nachgestellt in Chromium (18.09.2026): ohne Abwarten endet der Ablauf auf `#/orders`, mit
|
||||||
|
abgewartetem `popstate` auf `#/orders/111`.
|
||||||
|
|
||||||
|
Dieselbe Falle an zwei weiteren Stellen: „Neuen Bericht anlegen" (`#/reports/<id>`) und
|
||||||
|
Lieferschein-Unterschrift (`#/shipments/<id>`). Betroffen ist nur, wer den Auftrag **in
|
||||||
|
derselben Sitzung frisch angelegt** hat — deshalb fiel es nicht früher auf.
|
||||||
|
|
||||||
|
**Ursache „keine Fotos, keine Meldung":** Die Auftragsseite zeigt ausschließlich, was der
|
||||||
|
Server schon hat. Fotos in der Warteschlange erscheinen nirgends außer als Zahl im kleinen
|
||||||
|
Badge oben rechts (🔴 3 / 🟡 3), und die Seite zeichnet sich nach einem im Hintergrund
|
||||||
|
fertig gewordenen Upload nicht neu. Verloren ging nichts — alle Fotos kamen an.
|
||||||
|
|
||||||
|
### Umsetzung (2026-09-18)
|
||||||
|
|
||||||
|
- [x] **Race zentral behoben:** `closeModal()` zählt selbst ausgelöste `history.back()`
|
||||||
|
(`_pendingBacks`, Zähler statt Boolean, KB #1209 — der Boolean verschluckt bei zwei
|
||||||
|
schnell nacheinander geschlossenen Dialogen ein `popstate`), `router.go()` wartet über
|
||||||
|
`window.historySettled()`, bis alle durch sind, und wechselt erst dann den Hash
|
||||||
|
(Schutzzeit 1,5 s, falls ein `popstate` ausbleibt). Alle Aufrufstellen bleiben wie sie
|
||||||
|
sind. `closeModal()` nimmt den History-Eintrag jetzt **vor** dem Cleanup zurück, damit
|
||||||
|
ein navigierender Cleanup den Rücksprung sieht; `pushModal()` wartet ebenfalls.
|
||||||
|
- [x] **Sicherheitsnetz in Kamera und Galerie-Auswahl:** nach „Fertig" ausdrücklich
|
||||||
|
`router.go('#/orders/<id>')` statt `router.navigate()` — gezeichnet wird der Auftrag,
|
||||||
|
für den fotografiert wurde, nicht „was gerade in der Adresszeile steht".
|
||||||
|
- [x] **Wartende Fotos am Auftrag:** Block „⏳ Warten auf Upload (n)" über „Hochgeladene
|
||||||
|
Fotos", mit Vorschaubildern direkt aus der Warteschlange (max. 12, danach „+n") und
|
||||||
|
Klartext („📴 Kein Netz — 3 Fotos sind auf dem Gerät gesichert und werden gesendet,
|
||||||
|
sobald wieder Netz da ist."), fehlgeschlagene rot markiert; Tipp öffnet die Warteschlange.
|
||||||
|
- [x] **Auftragsseite zieht nach:** bei `photo-uploaded` für diesen Auftrag wird nach 2,5 s
|
||||||
|
Ruhe neu gezeichnet (Scrollposition bleibt) — nicht, solange ein Modal offen ist,
|
||||||
|
die Mehrfachauswahl läuft oder eine Sprachnotiz abgespielt wird.
|
||||||
|
- [x] **Hinweis beim Schließen der Kamera:** „📴 Kein Netz — n Fotos auf dem Gerät
|
||||||
|
gesichert, Upload folgt automatisch" bzw. „⏳ n Fotos warten noch auf den Upload".
|
||||||
|
|
||||||
|
### Verifiziert (2026-09-18, lokale Testinstanz, Chromium, Auftrag 62)
|
||||||
|
|
||||||
|
- Ablauf „Modal zu + `router.go`": endet auf `#/orders/62` (vorher `#/orders`)
|
||||||
|
- **Echtes Funkloch** (`context.setOffline(true)`): 3 Fotos gesichert, „Fertig" → bleibt im
|
||||||
|
Auftrag, Offline-Band, Block mit 3 Vorschaubildern und Klartext, Badge 🔴 3
|
||||||
|
- Netz zurück: Upload läuft an, Block verschwindet von selbst, „Hochgeladene Fotos" 2 → 5,
|
||||||
|
Badge 🟢, Warteschlange leer. Testfotos danach wieder entfernt.
|
||||||
|
- **Nicht** getestet: die echte Kamera (`getUserMedia`) am Handy — der Test hat den
|
||||||
|
Kamera-Cleanup nachgestellt, nicht die Kamera selbst bedient.
|
||||||
|
|
||||||
|
### Offen
|
||||||
|
|
||||||
|
- [ ] **Doppel-Uploads** (Log: mehrere Antworten mit `duplicate:true`) — Seite und Service
|
||||||
|
Worker (Background Sync) laden dasselbe Queue-Item parallel hoch. Dank md5-Abgleich
|
||||||
|
seit Bericht 1.6.0 folgenlos, kostet aber im Mobilfunk doppeltes Datenvolumen.
|
||||||
|
Lösung: Sperre je Item in IndexedDB (`uploading_since`), die der jeweils andere achtet.
|
||||||
|
- [ ] **SW-Update lädt mitten in der Arbeit neu** — beim Test beobachtet: `controllerchange`
|
||||||
|
→ `location.reload()` (index.php) feuerte, während drei Fotos in der Warteschlange
|
||||||
|
lagen; der Browser zeigte dazu seinen eigenen „Seite verlassen?"-Dialog (`beforeunload`).
|
||||||
|
War am 17.09. **nicht** die Ursache (kein Deploy, kein `index.php` im Log), trifft aber
|
||||||
|
jeden, der direkt nach einem Deploy fotografiert. Lösung: Reload aufschieben, solange
|
||||||
|
ein Modal (Kamera!) offen ist oder die Warteschlange nicht leer ist.
|
||||||
|
|
||||||
## Erledigt
|
## Erledigt
|
||||||
|
|
||||||
### 2026-08-22
|
### 2026-08-22
|
||||||
|
|
|
||||||
31
app.css
31
app.css
|
|
@ -307,6 +307,37 @@ body {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wartende Fotos am Auftrag — bewusst auffaellig (gelber Rand): das ist der Zustand,
|
||||||
|
in dem man sonst glaubt, die Fotos seien weg. */
|
||||||
|
.pending-section {
|
||||||
|
border: 1px solid #e0af68;
|
||||||
|
background: #2b2820;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pending-section h3 { color: #e0af68; }
|
||||||
|
.pending-section p { line-height: 1.45; }
|
||||||
|
.pending-grid .pending-thumb { cursor: pointer; }
|
||||||
|
.pending-grid .pending-thumb img { opacity: 0.7; }
|
||||||
|
.pending-grid .pending-mark {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 3px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.pending-grid .pending-thumb.is-failed { outline: 2px solid #f7768e; outline-offset: -2px; }
|
||||||
|
.pending-grid .pending-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0af68;
|
||||||
|
}
|
||||||
|
|
||||||
/* Foto-Section Kopfzeile mit Auswahl-Toggle */
|
/* Foto-Section Kopfzeile mit Auswahl-Toggle */
|
||||||
.photo-section-head {
|
.photo-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
184
app.js
184
app.js
|
|
@ -39,21 +39,57 @@ function setBack(visible, hash) {
|
||||||
* Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag.
|
* Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag.
|
||||||
* popstate (Android-Back) pop-t den Stack und entfernt das Modal.
|
* popstate (Android-Back) pop-t den Stack und entfernt das Modal.
|
||||||
* Programmatisches Schließen (closeModal) räumt synchron auf und
|
* Programmatisches Schließen (closeModal) räumt synchron auf und
|
||||||
* setzt ein Skip-Flag, damit der eigene history.back() kein
|
* zählt den eigenen history.back() mit (_pendingBacks), damit dessen
|
||||||
* doppeltes Cleanup auslöst.
|
* popstate kein doppeltes Cleanup auslöst.
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
const modalStack = [];
|
const modalStack = [];
|
||||||
let _skipNextPopstate = false;
|
// Selbst ausgeloeste history.back(), deren popstate noch aussteht. Zaehler statt Boolean:
|
||||||
|
// schliessen sich zwei Dialoge direkt nacheinander, stehen ZWEI popstate aus — ein Boolean
|
||||||
|
// verschluckt nur das erste, das zweite raeumt dann faelschlich den Stapel ab (KB #1209).
|
||||||
|
let _pendingBacks = 0;
|
||||||
|
let _backWaiters = [];
|
||||||
let _lastBackAt = 0;
|
let _lastBackAt = 0;
|
||||||
|
|
||||||
|
function _flushBackWaiters() {
|
||||||
|
const w = _backWaiters;
|
||||||
|
_backWaiters = [];
|
||||||
|
w.forEach(fn => { try { fn(); } catch (_) {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* history.back() ist ASYNCHRON. Wer direkt danach den Hash wechselt (closeModal +
|
||||||
|
* router.go), ueberholt den Ruecksprung: der laeuft erst NACH dem Hash-Wechsel und springt
|
||||||
|
* hinter den neuen Eintrag zurueck. Auf dem Bildschirm steht dann der Auftrag, in der
|
||||||
|
* Adresszeile aber '#/orders' — und das naechste router.navigate() (z.B. "Fertig" in der
|
||||||
|
* Kamera) zeichnet die Auftragsliste. Genau so flog Eddy am 17.09.2026 aus dem frisch
|
||||||
|
* angelegten Auftrag 111. router.go() wartet deshalb hierauf, bevor es den Hash setzt.
|
||||||
|
* Die Schutzzeit verhindert, dass ein ausbleibendes popstate die Navigation fuer immer
|
||||||
|
* blockiert (history.back() am Anfang der History ist ein stilles Nichts). */
|
||||||
|
window.historySettled = function () {
|
||||||
|
if (_pendingBacks <= 0) return Promise.resolve();
|
||||||
|
return new Promise(resolve => {
|
||||||
|
_backWaiters.push(resolve);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_pendingBacks > 0) { _pendingBacks = 0; _flushBackWaiters(); }
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
// Aktiver Select-Mode-Cleanup: wird von popstate gerufen, damit Android-Back
|
// Aktiver Select-Mode-Cleanup: wird von popstate gerufen, damit Android-Back
|
||||||
// eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen.
|
// eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen.
|
||||||
let selectModeCleanup = null;
|
let selectModeCleanup = null;
|
||||||
|
|
||||||
function pushModal(el, cleanup) {
|
function pushModal(el, cleanup) {
|
||||||
modalStack.push({ el, cleanup: cleanup || null });
|
modalStack.push({ el, cleanup: cleanup || null });
|
||||||
|
const push = () => {
|
||||||
|
// Inzwischen schon wieder geschlossen? Dann keinen verwaisten Eintrag anlegen.
|
||||||
|
if (!modalStack.some(m => m.el === el)) return;
|
||||||
try {
|
try {
|
||||||
history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash);
|
history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash);
|
||||||
} catch (e) { /* some browsers may block in strict sandboxes */ }
|
} catch (e) { /* some browsers may block in strict sandboxes */ }
|
||||||
|
};
|
||||||
|
// Laeuft noch ein Ruecksprung eines eben geschlossenen Modals (Bestaetigung zu, naechster
|
||||||
|
// Dialog auf), erst den abwarten — sonst nimmt er den NEUEN Eintrag gleich wieder mit.
|
||||||
|
if (_pendingBacks > 0) window.historySettled().then(push);
|
||||||
|
else push();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal(el) {
|
function closeModal(el) {
|
||||||
|
|
@ -65,12 +101,14 @@ function closeModal(el) {
|
||||||
const entry = modalStack[idx];
|
const entry = modalStack[idx];
|
||||||
modalStack.splice(idx, 1);
|
modalStack.splice(idx, 1);
|
||||||
try { entry.el.remove(); } catch {}
|
try { entry.el.remove(); } catch {}
|
||||||
try { entry.cleanup && entry.cleanup(); } catch {}
|
// Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion. Das passiert VOR
|
||||||
// Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion
|
// dem cleanup: navigiert der (Kamera: zurueck in den Auftrag), sieht router.go() den
|
||||||
|
// ausstehenden Ruecksprung und wartet ihn ab, statt von ihm ueberholt zu werden.
|
||||||
if (history.state && history.state._modal) {
|
if (history.state && history.state._modal) {
|
||||||
_skipNextPopstate = true;
|
_pendingBacks++;
|
||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
|
try { entry.cleanup && entry.cleanup(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTopLevelHash(h) {
|
function isTopLevelHash(h) {
|
||||||
|
|
@ -79,7 +117,11 @@ function isTopLevelHash(h) {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
if (_skipNextPopstate) { _skipNextPopstate = false; return; }
|
if (_pendingBacks > 0) {
|
||||||
|
_pendingBacks--;
|
||||||
|
if (_pendingBacks === 0) _flushBackWaiters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (modalStack.length > 0) {
|
if (modalStack.length > 0) {
|
||||||
const top = modalStack.pop();
|
const top = modalStack.pop();
|
||||||
try { top.el.remove(); } catch {}
|
try { top.el.remove(); } catch {}
|
||||||
|
|
@ -790,7 +832,9 @@ router.on('/orders/:id', async (args) => {
|
||||||
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
||||||
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
||||||
|
|
||||||
<div class="detail-section" style="margin-top:16px;">
|
<div id="pending-photos" data-order-id="${escapeHtml(String(args.id))}" style="margin-top:16px;"></div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
<div class="photo-section-head">
|
<div class="photo-section-head">
|
||||||
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
|
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
|
||||||
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
|
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
|
||||||
|
|
@ -1014,6 +1058,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
loadThumbs();
|
loadThumbs();
|
||||||
|
renderPendingPhotos(); // was noch in der Warteschlange liegt, gehoert sichtbar zum Auftrag
|
||||||
|
|
||||||
const camInput = document.getElementById('camera-input');
|
const camInput = document.getElementById('camera-input');
|
||||||
const galInput = document.getElementById('gallery-input');
|
const galInput = document.getElementById('gallery-input');
|
||||||
|
|
@ -1039,8 +1084,10 @@ router.on('/orders/:id', async (args) => {
|
||||||
offline.releaseWakeLock();
|
offline.releaseWakeLock();
|
||||||
}
|
}
|
||||||
// Nach Upload einfach die Route neu rendern — so werden Select-Mode,
|
// Nach Upload einfach die Route neu rendern — so werden Select-Mode,
|
||||||
// Click-Handler und Thumbnails sauber neu aufgebaut.
|
// Click-Handler und Thumbnails sauber neu aufgebaut. Ausdruecklich diesen
|
||||||
router.navigate();
|
// Auftrag, nicht den Hash der Adresszeile (siehe Kamera-Cleanup).
|
||||||
|
router.go('#/orders/' + args.id);
|
||||||
|
announcePendingPhotos(args.id);
|
||||||
}
|
}
|
||||||
camInput.addEventListener('change', () => handleFiles(camInput.files));
|
camInput.addEventListener('change', () => handleFiles(camInput.files));
|
||||||
galInput.addEventListener('change', () => handleFiles(galInput.files));
|
galInput.addEventListener('change', () => handleFiles(galInput.files));
|
||||||
|
|
@ -1049,6 +1096,113 @@ router.on('/orders/:id', async (args) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* WARTENDE FOTOS AM AUFTRAG
|
||||||
|
*
|
||||||
|
* Die Auftragsseite zeigte nur, was der Server schon hat. Fotos in der Warteschlange
|
||||||
|
* standen nirgends ausser als Zahl im Badge oben rechts. Wer im Funkloch fotografiert und
|
||||||
|
* danach in den Auftrag schaut, sieht: nichts — und haelt die Fotos fuer verloren
|
||||||
|
* (gemeldet 18.09.2026). Deshalb ein eigener Block mit Vorschaubildern direkt aus der
|
||||||
|
* Warteschlange und einem Satz, der sagt, was los ist.
|
||||||
|
*
|
||||||
|
* Der Block haengt an #pending-photos (data-order-id). Den gibt es nur, solange die
|
||||||
|
* Auftragsseite gezeichnet ist — daran erkennen die Listener unten, ob sie gemeint sind.
|
||||||
|
* ============================================================ */
|
||||||
|
const PENDING_PREVIEW_MAX = 12; // mehr Vorschaubilder kosten nur Speicher
|
||||||
|
let pendingPhotoUrls = [];
|
||||||
|
let pendingRenderTimer = null;
|
||||||
|
let orderRefreshTimer = null;
|
||||||
|
|
||||||
|
async function renderPendingPhotos() {
|
||||||
|
const box = document.getElementById('pending-photos');
|
||||||
|
if (!box) return;
|
||||||
|
const orderId = box.dataset.orderId;
|
||||||
|
const items = (await offline.listQueue().catch(() => []))
|
||||||
|
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId));
|
||||||
|
if (!document.body.contains(box)) return; // Seite wurde inzwischen neu gezeichnet
|
||||||
|
|
||||||
|
pendingPhotoUrls.forEach(u => { try { URL.revokeObjectURL(u); } catch (_) {} });
|
||||||
|
pendingPhotoUrls = [];
|
||||||
|
if (!items.length) { box.innerHTML = ''; return; }
|
||||||
|
|
||||||
|
const failed = items.filter(i => i.failed).length;
|
||||||
|
const waiting = items.length - failed;
|
||||||
|
const fotos = (n) => n + (n === 1 ? ' Foto' : ' Fotos');
|
||||||
|
const lines = [];
|
||||||
|
if (waiting) {
|
||||||
|
lines.push(navigator.onLine === false
|
||||||
|
? '📴 Kein Netz — ' + fotos(waiting) + (waiting === 1 ? ' ist' : ' sind')
|
||||||
|
+ ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden')
|
||||||
|
+ ' gesendet, sobald wieder Netz da ist.'
|
||||||
|
: fotos(waiting) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und '
|
||||||
|
+ (waiting === 1 ? 'geht' : 'gehen') + ' raus, sobald die Verbindung steht.');
|
||||||
|
}
|
||||||
|
if (failed) lines.push('⚠️ ' + fotos(failed) + ' konnte' + (failed === 1 ? '' : 'n')
|
||||||
|
+ ' nicht hochgeladen werden — antippen zum Prüfen.');
|
||||||
|
|
||||||
|
const shown = items.slice(0, PENDING_PREVIEW_MAX);
|
||||||
|
const thumbs = shown.map(it => {
|
||||||
|
const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' }));
|
||||||
|
pendingPhotoUrls.push(url);
|
||||||
|
return '<div class="thumb pending-thumb' + (it.failed ? ' is-failed' : '') + '">'
|
||||||
|
+ '<img src="' + url + '" alt="" loading="lazy">'
|
||||||
|
+ '<span class="pending-mark">' + (it.failed ? '⚠️' : '⏳') + '</span></div>';
|
||||||
|
}).join('');
|
||||||
|
const more = items.length - shown.length;
|
||||||
|
|
||||||
|
box.innerHTML = '<div class="detail-section pending-section">'
|
||||||
|
+ '<h3>⏳ Warten auf Upload (' + items.length + ')</h3>'
|
||||||
|
+ lines.map(l => '<p>' + escapeHtml(l) + '</p>').join('')
|
||||||
|
+ '<div class="photo-grid pending-grid">' + thumbs
|
||||||
|
+ (more > 0 ? '<div class="thumb pending-thumb pending-more">+' + more + '</div>' : '')
|
||||||
|
+ '</div></div>';
|
||||||
|
box.querySelector('.pending-section').onclick = () => openUploadQueueModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nach dem Schliessen der Kamera sagen, was mit den Fotos ist. Ohne den Satz sieht
|
||||||
|
* "gesichert, aber noch nicht gesendet" genauso aus wie "alles erledigt". */
|
||||||
|
async function announcePendingPhotos(orderId) {
|
||||||
|
const n = (await offline.listQueue().catch(() => []))
|
||||||
|
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)).length;
|
||||||
|
if (!n) return;
|
||||||
|
const fotos = n + (n === 1 ? ' Foto' : ' Fotos');
|
||||||
|
showToast(navigator.onLine === false
|
||||||
|
? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch'
|
||||||
|
: '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPendingPhotosSoon() {
|
||||||
|
if (!document.getElementById('pending-photos')) return;
|
||||||
|
clearTimeout(pendingRenderTimer);
|
||||||
|
pendingRenderTimer = setTimeout(renderPendingPhotos, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kam ein Foto dieses Auftrags an, die Seite nachziehen — sonst steht es weder unter
|
||||||
|
* "Warten auf Upload" noch unter "Hochgeladene Fotos". Gebuendelt (eine Serie soll nicht
|
||||||
|
* je Foto neu zeichnen) und nur, wenn gerade nichts offen ist, das dabei kaputtginge. */
|
||||||
|
function scheduleOrderRefresh() {
|
||||||
|
clearTimeout(orderRefreshTimer);
|
||||||
|
orderRefreshTimer = setTimeout(async () => {
|
||||||
|
const box = document.getElementById('pending-photos');
|
||||||
|
if (!box) return; // Auftragsseite nicht mehr offen
|
||||||
|
const playing = Array.from(document.querySelectorAll('#main audio')).some(a => !a.paused);
|
||||||
|
if (modalStack.length || selectModeCleanup || playing) { scheduleOrderRefresh(); return; }
|
||||||
|
const y = window.scrollY, my = main().scrollTop;
|
||||||
|
try { await router.navigate('#/orders/' + box.dataset.orderId); } catch (_) {}
|
||||||
|
try { window.scrollTo(0, y); main().scrollTop = my; } catch (_) {}
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('photo-uploaded', (e) => {
|
||||||
|
const box = document.getElementById('pending-photos');
|
||||||
|
if (!box || String((e.detail || {}).orderId) !== String(box.dataset.orderId)) return;
|
||||||
|
renderPendingPhotosSoon();
|
||||||
|
scheduleOrderRefresh();
|
||||||
|
});
|
||||||
|
window.addEventListener('queue-changed', renderPendingPhotosSoon);
|
||||||
|
window.addEventListener('online', renderPendingPhotosSoon);
|
||||||
|
window.addEventListener('offline', renderPendingPhotosSoon);
|
||||||
|
|
||||||
/* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität).
|
/* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität).
|
||||||
* Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal
|
* Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal
|
||||||
* zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt.
|
* zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt.
|
||||||
|
|
@ -3349,8 +3503,14 @@ async function openCameraModal(orderId) {
|
||||||
stopStream();
|
stopStream();
|
||||||
window.removeEventListener('photo-uploaded', onUploaded);
|
window.removeEventListener('photo-uploaded', onUploaded);
|
||||||
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
|
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
|
||||||
// Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip)
|
// Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip).
|
||||||
if (shots > 0) { try { router.navigate(); } catch (_) {} }
|
// Ausdruecklich DIESEN Auftrag ansteuern, nicht "was gerade in der Adresszeile steht":
|
||||||
|
// stand dort nach dem History-Race '#/orders', zeichnete router.navigate() die
|
||||||
|
// Auftragsliste und man flog nach "Fertig" aus dem Auftrag (17.09.2026).
|
||||||
|
if (shots > 0) {
|
||||||
|
try { router.go('#/orders/' + orderId); } catch (_) {}
|
||||||
|
announcePendingPhotos(orderId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
|
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(hash) {
|
function go(hash) {
|
||||||
|
// Erst ausstehende history.back() (geschlossene Modals) durchlaufen lassen. Ein
|
||||||
|
// Hash-Wechsel davor wird vom Ruecksprung ueberholt, die Adresse steht danach auf
|
||||||
|
// der ALTEN Route, waehrend die neue angezeigt wird (app.js: historySettled).
|
||||||
|
const settled = (typeof window.historySettled === 'function')
|
||||||
|
? window.historySettled() : Promise.resolve();
|
||||||
|
settled.then(() => {
|
||||||
if (hash !== window.location.hash) {
|
if (hash !== window.location.hash) {
|
||||||
window.location.hash = hash;
|
window.location.hash = hash;
|
||||||
} else {
|
} else {
|
||||||
navigate(hash);
|
navigate(hash);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('hashchange', () => navigate());
|
window.addEventListener('hashchange', () => navigate());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue