/* Baustelle PWA Hauptlogik. Alle Routen + Views in einer Datei. */ const main = () => document.getElementById('main'); const title = (s) => document.getElementById('page-title').textContent = s; window.showToast = function (msg, kind) { const t = document.createElement('div'); t.className = 'toast' + (kind ? ' ' + kind : ''); t.textContent = msg; document.getElementById('toast-container').appendChild(t); setTimeout(() => t.remove(), 2800); }; function showLoader(text) { main().innerHTML = '
' + (text || 'Lade…') + '
'; } function setNav(visible, active) { const nav = document.getElementById('bottom-nav'); nav.style.display = visible ? '' : 'none'; nav.querySelectorAll('button').forEach(b => { b.classList.toggle('active', b.dataset.route === active); }); // FAB folgt der Nav-Sichtbarkeit (auf Login verstecken, sonst überall erreichbar) const fab = document.getElementById('fab-new-order'); if (fab) fab.hidden = !visible; } function setBack(visible, hash) { const btn = document.getElementById('back-btn'); btn.style.display = visible ? '' : 'none'; // Immer history.back() — offene Modals werden vom popstate-Handler geschlossen, // Hash-Routen per router.go() liegen als History-Einträge vor. btn.onclick = () => history.back(); } /* ============================================================ * Modal-Stack: Android-Back schließt oberstes Modal statt die App * Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag. * popstate (Android-Back) pop-t den Stack und entfernt das Modal. * Programmatisches Schließen (closeModal) räumt synchron auf und * zählt den eigenen history.back() mit (_pendingBacks), damit dessen * popstate kein doppeltes Cleanup auslöst. * ============================================================ */ const modalStack = []; // 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; 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 // eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen. let selectModeCleanup = null; function pushModal(el, cleanup) { 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 { history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash); } 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) { const idx = modalStack.findIndex(m => m.el === el); if (idx === -1) { try { el.remove(); } catch {} return; } const entry = modalStack[idx]; modalStack.splice(idx, 1); try { entry.el.remove(); } catch {} // Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion. Das passiert VOR // 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) { _pendingBacks++; history.back(); } try { entry.cleanup && entry.cleanup(); } catch {} } /* Darf die Seite JETZT neu laden? Gefragt von index.php, wenn nach einem Deploy ein neuer * Service Worker uebernimmt. Bis 18.09.2026 lud sie dann sofort neu — mitten in der offenen * Kamera, in der getippten Notiz, in der halben Unterschrift. Alles davon ist danach weg. * * Ausdruecklich NICHT beschaeftigt ist die PIN-Sperre: dort ist der beste Moment zum * Neuladen. Wuerde sie als Modal zaehlen, kaeme der Reload direkt NACH der PIN-Eingabe — * und die PIN-Abfrage gleich ein zweites Mal. */ window.appBusy = function () { const app = document.getElementById('app'); if (app && app.style.visibility === 'hidden') return false; // PIN-Sperre if (modalStack.length > 0 || selectModeCleanup) return true; // Kamera, Notiz, Unterschrift, Auswahl try { if (offline.isSyncing()) return true; } catch (_) {} // halb gesendeter Upload // Es wird gerade getippt. Suchfelder zaehlen nicht: am Handy behalten sie den Fokus, bis // man die Seite verlaesst — der Reload kaeme nie, und ein Suchwort ist kein Verlust. const a = document.activeElement; if (a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'search') return true; if (Array.from(document.querySelectorAll('audio')).some(x => !x.paused)) return true; return false; }; function isTopLevelHash(h) { const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/'; return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh); } window.addEventListener('popstate', () => { if (_pendingBacks > 0) { _pendingBacks--; if (_pendingBacks === 0) _flushBackWaiters(); return; } if (modalStack.length > 0) { const top = modalStack.pop(); try { top.el.remove(); } catch {} try { top.cleanup && top.cleanup(); } catch {} return; } if (selectModeCleanup) { const fn = selectModeCleanup; selectModeCleanup = null; try { fn(); } catch {} return; } if (isTopLevelHash(location.hash)) { const now = Date.now(); if (now - _lastBackAt < 2000) return; _lastBackAt = now; try { history.pushState({}, '', location.hash); } catch {} showToast('Nochmal drücken zum Beenden'); } }); /* ============================================================ * 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 = ``; return el; } function confirmDialog({ title, text, confirmLabel, cancelLabel, danger } = {}) { return new Promise(resolve => { const el = _dialogShell(` ${title ? `
${escapeHtml(title)}
` : ''} ${text ? `
${escapeHtml(text)}
` : ''}
`); 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 ? `
${escapeHtml(title)}
` : ''} ${text ? `
${escapeHtml(text)}
` : ''}
`); 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 ? `
${escapeHtml(title)}
` : ''} ${text ? `
${escapeHtml(text)}
` : ''}
`); 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 * shareFiles([{blob,filename,mime}, …], title) — mehrere Dateien * Fällt auf Toast zurück, wenn das Gerät kein Datei-Sharing unterstützt. * ============================================================ */ function _buildShareFile(blob, filename, mime) { const fname = filename || 'datei'; // Name-Endung am MIME ausrichten, sonst lehnen manche Android-Apps ab let safeName = fname; const m = (mime || blob.type || '').toLowerCase(); if (m === 'application/pdf' && !/\.pdf$/i.test(safeName)) safeName += '.pdf'; return new File([blob], safeName, { type: mime || blob.type || 'application/octet-stream' }); } async function shareFiles(items, titleHint) { const files = items.map(it => _buildShareFile(it.blob, it.filename, it.mime)); try { if (!navigator.canShare || !navigator.share) { showToast('Teilen wird vom Browser nicht unterstützt', 'error'); return false; } if (!navigator.canShare({ files })) { // Versuche einzeln — manche Geräte lehnen Batches bestimmter MIMEs ab if (files.length > 1) { showToast('Batch-Teilen wird nicht unterstützt — sende einzeln', 'error'); return false; } showToast('Dieser Dateityp kann nicht geteilt werden', 'error'); return false; } await navigator.share({ files, title: titleHint || (files.length === 1 ? files[0].name : files.length + ' Dateien'), text: titleHint || '', }); return true; } catch (e) { if (e && e.name !== 'AbortError') { console.warn('[Share]', e); showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); } return false; } } async function shareFile(blob, filename, mime, titleHint) { return shareFiles([{ blob, filename, mime }], titleHint); } /* ----- Auth-Check ----- */ async function ensureAuth() { // Session steckt im awl_sso-Cookie -> serverseitig prüfen (sliding renewal). try { await api.request('/verify.php'); return true; } catch (e) { // 401 -> Cookie fehlt/abgelaufen -> Login. Netzfehler (offline) -> NICHT // ausloggen, die PWA muss offline nutzbar bleiben. if (e.status === 401) { router.go('#/login'); return false; } return true; } } /* ============================================================ * ntfy-Benachrichtigungen (statt klassisches Web-Push) * ============================================================ */ let ntfyEventSource = null; function stopNtfySubscription() { if (ntfyEventSource) { try { ntfyEventSource.close(); } catch (e) {} ntfyEventSource = null; } } function startNtfySubscription(server, topic) { stopNtfySubscription(); if (!server || !topic) return; try { const url = server + '/' + encodeURIComponent(topic) + '/sse'; ntfyEventSource = new EventSource(url); ntfyEventSource.onmessage = (e) => { try { const data = JSON.parse(e.data); if (data.event === 'message' && 'Notification' in window && Notification.permission === 'granted') { const n = new Notification(data.title || 'Baustelle', { body: data.message || '', icon: './icons/icon-192.png', badge: './icons/icon-192.png', tag: 'baustelle-' + (data.id || Date.now()), }); n.onclick = () => { window.focus(); n.close(); }; } } catch (err) { /* ignore */ } }; ntfyEventSource.onerror = () => { // Auto-Reconnect nach 10s setTimeout(() => startNtfySubscription(server, topic), 10000); }; } catch (e) { console.warn('ntfy sub failed', e); } } // Beim App-Start automatisch subscriben, wenn Topic gesetzt (async () => { try { const srv = await idb.get('ntfy_server'); const topic = await idb.get('ntfy_topic'); if (srv && topic && 'Notification' in window && Notification.permission === 'granted') { startNtfySubscription(srv, topic); } } catch (e) {} })(); /* ============================================================ * PIN-Schutz (optional, Settings → Sicherheit) * ============================================================ */ async function hashPin(pin) { const salt = crypto.getRandomValues(new Uint8Array(16)); const saltStr = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join(''); const enc = new TextEncoder(); const data = enc.encode(saltStr + ':' + pin); const buf = await crypto.subtle.digest('SHA-256', data); const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); return { hash, salt: saltStr }; } async function verifyPin(pin) { const salt = await idb.get('pin_salt'); const stored = await idb.get('pin_hash'); if (!salt || !stored) return false; const enc = new TextEncoder(); const data = enc.encode(salt + ':' + pin); const buf = await crypto.subtle.digest('SHA-256', data); const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); return hash === stored; } function promptPin(title) { return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'fullscreen-modal pin-modal'; modal.innerHTML = `
🔐
${escapeHtml(title || 'PIN eingeben')}
`; const pad = modal.querySelector('.pin-keypad'); const disp = modal.querySelectorAll('.pin-display span'); let current = ''; function render() { disp.forEach((el, i) => { el.classList.toggle('filled', i < current.length); }); } function add(d) { if (current.length >= 4) return; current += d; render(); if (current.length === 4) { setTimeout(() => { closeModal(modal); resolve(current); }, 150); } } function del() { current = current.slice(0, -1); render(); } const keys = ['1','2','3','4','5','6','7','8','9','','0','←']; keys.forEach(k => { const b = document.createElement('button'); b.className = 'pin-key'; b.textContent = k; if (!k) { b.style.visibility = 'hidden'; } else if (k === '←') b.onclick = del; else b.onclick = () => add(k); pad.appendChild(b); }); // Cancel-Button const cancel = document.createElement('button'); cancel.className = 'pin-cancel'; cancel.textContent = 'Abbrechen'; cancel.onclick = () => { closeModal(modal); resolve(null); }; modal.querySelector('.pin-body').appendChild(cancel); document.body.appendChild(modal); // Android-Back schließt Modal → Promise mit null auflösen // (doppeltes resolve ist per JS-Spec no-op, falls PIN bereits eingegeben war) pushModal(modal, () => resolve(null)); }); } async function promptNewPin() { const p1 = await promptPin('Neue 4-stellige PIN'); 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.' }); return null; } return p1; } /** * Startet die App — fragt ggf. PIN ab bevor router läuft. */ window.appBoot = async function appBoot() { // Kein Token-Preload mehr — die Session steckt im HttpOnly-Cookie awl_sso, das // der Browser bei jedem same-origin-Request automatisch mitschickt. // Bootstrap-Puffer: legt einen zusätzlichen History-Eintrag an, damit beim // ersten Android-Back der popstate-Handler greifen kann (Toast „Nochmal // drücken zum Beenden"), bevor die PWA wirklich verlassen wird. try { if (!history.state || !history.state._bootstrap) { history.pushState({ _bootstrap: true }, '', location.hash || '#/orders'); } } catch {} // FAB: Click öffnet das Schnell-Auftrag-Modal const fab = document.getElementById('fab-new-order'); if (fab && !fab.dataset.bound) { fab.dataset.bound = '1'; fab.addEventListener('click', () => { openNewOrderModal(); }); } const pinSet = await idb.get('pin_hash'); if (!pinSet) return; // kein PIN-Schutz aktiv // Lockscreen: vollständiger Overlay bis richtige PIN document.getElementById('app').style.visibility = 'hidden'; while (true) { const pin = await promptPin('PIN eingeben'); if (pin && (await verifyPin(pin))) break; // Falsch → kurzes Feedback und nochmal showToast('Falsche PIN', 'error'); } document.getElementById('app').style.visibility = ''; } /* ========================================================================== * Offline-Anzeige: Spiegel statt roher Browsermeldung * * Vorher zeigte jede Liste im Fehlerfall stumpf `e.message`. Kam die Anfrage nicht * bis zum Server, war das woertlich "Failed to fetch" — fuer den Benutzer sinnlos, * und die App war ohne Netz komplett leer (gemeldet 31.08.2026). * * Jetzt: erfolgreiche Antworten werden gespiegelt (offline.mirrorSave), und bei einem * Netzfehler zeigt die Route den letzten Stand mit sichtbarem Zeitstempel. Ein * Serverfehler (401/403/500) laeuft weiterhin in die Fehlermeldung — nur Anfragen, * die gar nicht ankamen, tragen `e.offline`. * ========================================================================== */ /* Frisch holen, spiegeln — und wenn kein Netz da ist, den Spiegel nehmen. * Rueckgabe: { data, stale, ts }. Ohne Netz UND ohne Spiegel fliegt der Fehler weiter. */ async function loadMirrored(key, loader) { try { const data = await loader(); await offline.mirrorSave(key, data); return { data, stale: false, ts: 0 }; } catch (e) { if (!e || !e.offline) throw e; // echter Serverfehler -> ehrlich melden const m = await offline.mirrorLoad(key); if (!m) throw e; // nichts gespeichert -> ebenfalls melden return { data: m.data, stale: true, ts: m.ts }; } } /* Orangefarbenes Band unter der Kopfzeile. Ohne sichtbaren Hinweis haelt man alte * Zahlen fuer aktuell — genau davor warnt KB #1037. */ function setStale(stale, ts) { let band = document.getElementById('offline-band'); if (!stale) { if (band) band.remove(); return; } if (!band) { band = document.createElement('div'); band.id = 'offline-band'; const bar = document.getElementById('topbar'); bar.parentNode.insertBefore(band, bar.nextSibling); } const d = ts ? new Date(ts) : null; const wann = d ? (d.toDateString() === new Date().toDateString() ? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr' : d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })) : 'unbekannt'; band.textContent = '📴 Kein Netz — angezeigt wird der Stand von ' + wann; } /* Einheitliche Fehlerbox. escapeHtml, weil hier eine Servermeldung landen kann. */ function renderError(e) { const msg = escapeHtml((e && e.message) || 'Unbekannter Fehler'); if (e && e.offline) { return '
📴
' + msg + '
Auf diesem Gerät ist zu dieser' + ' Ansicht noch nichts gespeichert. Sobald wieder Netz da ist, lädt sie von selbst.
'; } return '
⚠️
' + msg + '
'; } /* Ohne Netz kann nicht der Server suchen — dann im gespiegelten Stand filtern. * Das wird angesagt (Toast), sonst haelt man das Ergebnis fuer eine Vollsuche. */ const ORDER_SEARCH_FIELDS = ['ref', 'customer.name', 'customer.town', 'customer.address', 'customer.zip']; const CUSTOMER_SEARCH_FIELDS = ['name', 'town', 'zip', 'code', 'phone']; let localSearchHintShown = false; function localFilter(list, q, fields) { const needle = (q || '').trim().toLowerCase(); if (!needle) return list; return list.filter(o => fields.some(f => { const v = f.split('.').reduce((a, k) => (a == null ? a : a[k]), o); return v != null && String(v).toLowerCase().includes(needle); })); } function localSearchHint() { if (localSearchHintShown) return; localSearchHintShown = true; showToast('Ohne Netz wird nur im gespeicherten Stand gesucht'); } /* Kommt das Netz zurueck, erst frische Daten holen — danach verschwindet das Band * von selbst, weil die Route dann mit stale=false neu zeichnet. */ window.addEventListener('online', () => { if (document.getElementById('offline-band')) router.navigate(); }); /* ====== ROUTES ====== */ router.on('/login', async () => { title('Anmelden'); setNav(false); setBack(false); main().innerHTML = `

🔧 Baustelle

`; const _blPass = document.getElementById('bl-pass'); const _blToggle = document.getElementById('bl-pass-toggle'); if (_blToggle && _blPass) _blToggle.addEventListener('click', () => { const show = _blPass.type === 'password'; _blPass.type = show ? 'text' : 'password'; _blToggle.style.opacity = show ? '1' : '0.5'; }); document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { await api.login(fd.get('login'), fd.get('password')); showToast('Erfolgreich angemeldet'); router.go('#/orders'); } catch (err) { showToast(err.message, 'error'); } }); // WebAuthn / Passkey (Fingerabdruck) — nur wenn der Browser es kann. // Der Passkey gilt domainweit (RP awl.data-it-solution.de): ein auf dem // Handy registrierter Passkey loggt hier ohne Benutzername/Passwort ein. const _pk = document.getElementById('bl-passkey'); if (_pk && window.awlauthWebAuthn && awlauthWebAuthn.isSupported()) { _pk.style.display = 'flex'; _pk.addEventListener('click', async () => { _pk.disabled = true; try { const res = await awlauthWebAuthn.login(); // Profil wie beim Passwort-Login ablegen (Einstellungen liest idb 'user'), // sonst zeigt der Konto-Block nach reinem Passkey-Login 'Unbekannt'. if (res && res.user) { await idb.set('user', res.user); } // awl_sso ist gesetzt -> Orders-Route rendert und faehrt ensureAuth()/verify.php showToast('Erfolgreich angemeldet'); router.go('#/orders'); } catch (err) { _pk.disabled = false; showToast(err && err.message ? err.message : 'Anmeldung fehlgeschlagen', 'error'); } }); } }); router.on('/today', async () => { if (!(await ensureAuth())) return; title('Heute'); setNav(true, 'today'); setBack(false); showLoader('Lade Aufträge…'); try { // Wir nutzen einfach die offenen Aufträge und filtern clientseitig die von heute const { data, stale, ts } = await loadMirrored('orders:open', () => api.listOrders({ open: 1 })); setStale(stale, ts); const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const t_unix = today.getTime() / 1000; const tm_unix = tomorrow.getTime() / 1000; const todays = data.orders.filter(o => o.date >= t_unix && o.date < tm_unix); const open = data.orders.filter(o => o.date < t_unix); // Map-URL mit allen Adressen const allAddresses = [...todays, ...open.slice(0, 10)] .map(o => [o.customer.name, o.customer.address, o.customer.zip, o.customer.town].filter(Boolean).join(' ')) .filter(Boolean); let html = ''; if (todays.length) { const waypoints = todays.map(o => encodeURIComponent([o.customer.address, o.customer.zip, o.customer.town].filter(Boolean).join(' '))).join('/'); html += `

🚗 Route für heute (${todays.length})

🗺 Alle in Google Maps öffnen

Heute (${todays.length})

${renderOrderList(todays)} `; } else { html += '
☀️
Keine Aufträge für heute geplant
'; } if (open.length) { html += `

Offen (${open.length})

${renderOrderList(open.slice(0, 20))}`; } main().innerHTML = html; document.querySelectorAll('.order-card').forEach(c => { c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); }); } catch (e) { main().innerHTML = renderError(e); } }); router.on('/orders', async () => { if (!(await ensureAuth())) return; title('Aufträge'); setNav(true, 'orders'); setBack(false); showLoader('Lade Aufträge…'); // Filter-Status aus localStorage laden (persistiert) let showAllOrders = localStorage.getItem('pwa_show_all_orders') === '1'; async function loadOrders(q = '') { const opts = q ? { q } : {}; if (!showAllOrders) opts.open = 1; return api.listOrders(opts); } function bindOrderCards() { document.querySelectorAll('.order-card').forEach(c => { c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); }); } // Online fragt der Server, ohne Netz wird im gespiegelten Stand gefiltert. // Gespiegelt wird nur die ungefilterte Liste — eine Suchantwort waere ein // Ausschnitt und wuerde beim naechsten Offline-Start als "der Stand" gelten. const mirrorKey = () => 'orders:' + (showAllOrders ? 'all' : 'open'); async function fetchOrders(q) { try { const d = await loadOrders(q); if (!q) await offline.mirrorSave(mirrorKey(), d); setStale(false); return d.orders || []; } catch (e) { if (!e || !e.offline) throw e; const m = await offline.mirrorLoad(mirrorKey()); if (!m) throw e; setStale(true, m.ts); if (q) localSearchHint(); return localFilter((m.data && m.data.orders) || [], q, ORDER_SEARCH_FIELDS); } } try { const data = { orders: await fetchOrders('') }; if (!data.orders.length) { main().innerHTML = `
📭
${showAllOrders ? 'Keine Aufträge gefunden' : 'Keine offenen Aufträge'}
`; document.getElementById('show-all-toggle').addEventListener('change', async (e) => { showAllOrders = e.target.checked; localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0'); showLoader('Lade Aufträge…'); router.go('#/orders'); }); return; } const html = `
${renderOrderList(data.orders)}
`; main().innerHTML = html; bindOrderCards(); async function redrawOrders(q) { try { document.getElementById('order-list').innerHTML = renderOrderList(await fetchOrders(q)); bindOrderCards(); } catch (err) { showToast((err && err.message) || 'Fehler', 'error'); } } document.getElementById('order-search').addEventListener('input', (e) => redrawOrders(e.target.value)); document.getElementById('show-all-toggle').addEventListener('change', (e) => { showAllOrders = e.target.checked; localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0'); redrawOrders(document.getElementById('order-search').value); }); } catch (e) { main().innerHTML = renderError(e); } }); function renderOrderList(orders) { return orders.map(o => `
${escapeHtml(o.ref)}${o.ref_client ? ` — ${escapeHtml(o.ref_client)}` : ''}
${escapeHtml(o.customer.name || '')}
${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))} ${o.bericht_count > 0 ? `📑 ${o.bericht_count}` : ''}
`).join(''); } router.on('/orders/:id', async (args) => { if (!(await ensureAuth())) return; setNav(true, 'orders'); setBack(true, '#/orders'); showLoader('Lade Auftrag…'); try { // Als EIN Paket spiegeln — der Auftrag ohne seine Fotos und Notizen waere // offline nur ein halber Auftrag. Die Vorschaubilder selbst liegen im // Bild-Cache des Service Workers und stehen damit ebenfalls ohne Netz. const bundle = await loadMirrored('detail:order:' + args.id, async () => { const order = await api.getOrder(args.id); // Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr const [ph, nt] = await Promise.all([ api.listOrderPhotos(args.id).catch(() => ({ photos: [] })), api.listNotes(args.id).catch(() => ({ notes: [] })), ]); return { order, photos: ph, notes: nt }; }); setStale(bundle.stale, bundle.ts); const data = bundle.data.order; const photos = bundle.data.photos; const notes = bundle.data.notes; const notesCount = notes.notes.length; title(data.order.ref); // Nach MIME-Type aufteilen. Videos am Namen: der Server nennt sie video_… (mp4/m4v/mov/webm). // Ein WebM ohne video_ ist eine Sprachnotiz (voice_….webm) — Dolibarr meldet fuer .webm // immer video/webm, am MIME-Typ allein liessen sich beide nicht trennen. const istVideo = (p) => /^video_/i.test(p.filename) || /\.(mp4|m4v|mov)$/i.test(p.filename); const videoFiles = photos.photos.filter(istVideo); const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/')); const audioFiles = photos.photos.filter(p => !istVideo(p) && ((p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename))); const otherDocs = photos.photos.filter(p => !istVideo(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)); main().innerHTML = `

Kunde

${escapeHtml(data.customer.name)}

${escapeHtml(data.customer.address || '')}

${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}

${data.customer.phone ? `

📞 ${escapeHtml(data.customer.phone)}

` : ''}
${data.order.auftragsbeschreibung ? `

Beschreibung

${escapeHtml(data.order.auftragsbeschreibung)}

` : ''}

Hochgeladene Fotos (${imagePhotos.length})

${(imagePhotos.length + videoFiles.length + otherDocs.length) ? '' : ''}
${imagePhotos.map(p => `
⏳
✓
`).join('')}
${videoFiles.length ? `

🎬 Videos (${videoFiles.length})

${videoFiles.map(v => `
▶ ${escapeHtml(formatShortDate(v.taken_at || v.date))} · ${escapeHtml(formatFileSize(v.size))}
✓
`).join('')}
` : ''} ${audioFiles.length ? `

🎙 Sprachnotizen (${audioFiles.length})

${audioFiles.map(a => `
${escapeHtml(a.filename)}
`).join('')}
` : ''} ${otherDocs.length ? `

Weitere Dokumente (${otherDocs.length})

${otherDocs.map(p => `
✓
${docIconFor(p.filename, p.mime)}
${escapeHtml(p.filename)}
${formatFileSize(p.size)}${p.date ? ' · ' + formatShortDate(p.date) : ''}
`).join('')}
` : ''} `; // Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus) const photoGrid = document.getElementById('photo-grid'); const videoGrid = document.getElementById('video-grid'); const docList = document.querySelector('.doc-list'); photoGrid.querySelectorAll('.thumb').forEach(t => { t.addEventListener('click', () => { if (photoGrid.classList.contains('selecting')) { t.classList.toggle('selected'); updateSelectBar(); } else { openPhotoModal(args.id, t.dataset.relpath); } }); }); // Videos: Tap = Abspielen (oder Auswahl im Select-Modus) if (videoGrid) videoVorschau(videoGrid); if (videoGrid) videoGrid.querySelectorAll('.thumb').forEach(t => { t.addEventListener('click', () => { if (videoGrid.classList.contains('selecting')) { t.classList.toggle('selected'); updateSelectBar(); } else { openVideoPlayer(args.id, t.dataset.relpath, t.dataset.filename); } }); }); // Mehrfachauswahl-Modus const selectBtn = document.getElementById('btn-select-mode'); if (selectBtn) selectBtn.onclick = () => startSelectMode(); function allSelectables() { const thumbs = Array.from(photoGrid.querySelectorAll('.thumb[data-relpath]')); const vids = videoGrid ? Array.from(videoGrid.querySelectorAll('.thumb[data-relpath]')) : []; const docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : []; return thumbs.concat(vids, docs); } function startSelectMode() { if (photoGrid.classList.contains('selecting')) return; photoGrid.classList.add('selecting'); if (videoGrid) videoGrid.classList.add('selecting'); if (docList) docList.classList.add('selecting'); renderSelectBar(); updateSelectBar(); // Android-Back soll nur die Auswahl aufheben history.pushState({ _selecting: true }, '', location.hash); selectModeCleanup = endSelectMode; } function endSelectMode() { selectModeCleanup = null; photoGrid.classList.remove('selecting'); if (videoGrid) videoGrid.classList.remove('selecting'); if (docList) docList.classList.remove('selecting'); allSelectables().forEach(x => x.classList.remove('selected')); const bar = document.getElementById('select-bar'); if (bar) bar.remove(); } function cancelViaBack() { if (history.state && history.state._selecting) history.back(); else endSelectMode(); } function renderSelectBar() { if (document.getElementById('select-bar')) return; const bar = document.createElement('div'); bar.id = 'select-bar'; bar.className = 'select-bar'; bar.innerHTML = `
0 ausgewählt
`; document.body.appendChild(bar); bar.querySelector('#sb-cancel').onclick = cancelViaBack; bar.querySelector('#sb-all').onclick = () => { const items = allSelectables(); const anyUn = items.some(t => !t.classList.contains('selected')); items.forEach(t => t.classList.toggle('selected', anyUn)); updateSelectBar(); }; bar.querySelector('#sb-share').onclick = () => shareSelected(); } function updateSelectBar() { const n = allSelectables().filter(x => x.classList.contains('selected')).length; const info = document.getElementById('sb-info'); const share = document.getElementById('sb-share'); if (info) info.textContent = n + ' ausgewählt'; if (share) share.disabled = n === 0; } async function shareSelected() { const sel = allSelectables().filter(x => x.classList.contains('selected')); if (!sel.length) return; showToast('Lade ' + sel.length + ' Datei' + (sel.length === 1 ? '' : 'en') + '…'); const items = []; for (const t of sel) { const rp = t.dataset.relpath; try { const f = await api.getFileBlobUrl(rp); if (f) items.push({ blob: f.blob, filename: t.dataset.filename || rp.split('/').pop(), mime: f.mime }); } catch (e) { console.warn('Share-Load', rp, e); } } if (!items.length) { showToast('Keine Dateien geladen', 'error'); return; } const ok = await shareFiles(items, 'Dateien vom Auftrag'); if (ok) cancelViaBack(); } // Weitere Dokumente: Tap = Öffnen (oder Auswahl im Select-Modus) document.querySelectorAll('.doc-list .doc-item').forEach(el => { el.addEventListener('click', async (e) => { if (docList && docList.classList.contains('selecting')) { // Auch Klick auf Öffnen-Button soll nur selektieren e.preventDefault(); e.stopPropagation(); el.classList.toggle('selected'); updateSelectBar(); return; } const rel = el.dataset.relpath; const filename = el.dataset.filename; el.classList.add('loading'); const res = await api.getFileBlobUrl(rel); el.classList.remove('loading'); if (!res) { showToast('Datei konnte nicht geladen werden', 'error'); return; } openFileViewer(res, filename, rel); }); }); // 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); // Materialliste document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id); // Lieferungen document.getElementById('btn-shipments').onclick = () => router.go('#/orders/' + args.id + '/shipments'); // Transkribieren document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => { btn.addEventListener('click', async (e) => { const item = e.target.closest('.audio-item'); const rel = item.dataset.relpath; btn.textContent = '⏳'; try { const r = await api.transcribeAudio(rel); const text = r.text || '(leer)'; const existing = item.querySelector('.audio-transcript'); if (existing) existing.remove(); const box = document.createElement('div'); box.className = 'audio-transcript'; box.textContent = text; item.appendChild(box); btn.textContent = '📝'; showToast('✓ Transkribiert'); } catch (err) { btn.textContent = '📝'; showToast('Whisper-Fehler: ' + err.message, 'error'); } }); }); // Audio-Files abspielen document.querySelectorAll('.audio-item .audio-play').forEach(btn => { btn.addEventListener('click', async (e) => { const item = e.target.closest('.audio-item'); const rel = item.dataset.relpath; const mime = item.dataset.mime || 'audio/webm'; // Existierenden Player toggeln let player = item.querySelector('audio'); if (player) { if (player.paused) player.play(); else player.pause(); return; } btn.textContent = '⏳'; try { const params = new URLSearchParams({ relpath: rel }); const r = await fetch(window.location.origin + '/custom/bericht/api/photo.php?' + params.toString(), { credentials: 'same-origin' }); if (!r.ok) throw new Error('Load failed'); const blob = await r.blob(); const url = URL.createObjectURL(new Blob([blob], { type: mime })); player = document.createElement('audio'); player.controls = true; player.src = url; player.style.width = '100%'; player.style.marginTop = '8px'; item.appendChild(player); player.play(); btn.textContent = '⏸'; player.onplay = () => btn.textContent = '⏸'; player.onpause = () => btn.textContent = '▶'; } catch (err) { showToast('Audio laden fehlgeschlagen: ' + err.message, 'error'); btn.textContent = '▶'; } }); }); loadThumbs(); renderPendingPhotos(); // was noch in der Warteschlange liegt, gehoert sichtbar zum Auftrag const camInput = document.getElementById('camera-input'); const galInput = document.getElementById('gallery-input'); // Live-Kamera (bleibt im Kamera-Modus für mehrere Fotos). Fällt intern auf // den nativen camera-input zurück, wenn getUserMedia nicht verfügbar ist. document.getElementById('btn-take-photo').onclick = () => openCameraModal(args.id); document.getElementById('btn-pick-photo').onclick = () => galInput.click(); async function handleFiles(files) { // Screen wachhalten, solange die Auswahl abgearbeitet wird — sonst pausiert // das Verkleinern mitten in der Serie (siehe resizeImage). await offline.acquireWakeLock(); try { // ERST ALLE Bilder einlesen, DANN hochladen. Die Datei-Referenzen aus der // Galerie-Auswahl sind unter Android nur kurz lesbar: frueher wurde Foto 3 // erst gelesen, nachdem Foto 1 und 2 schon hochgeladen waren — da warf Chrome // "NotReadableError: ... could not be read, typically due to permission // problems ..." und die restliche Auswahl kam nie an (30.09.2026, JHB Bio, // Prod-Log: nur 2 upload_photo-POSTs). Eine File aus dem Speicher bleibt lesbar. const copies = []; for (const f of Array.from(files)) { // Zu grosses Video gleich ablehnen — es bliebe sonst fuer immer in der // Warteschlange haengen. Es liegt ja weiter in der Galerie. const max = photoCfg.upload_max || 0; if (/^video\//.test(f.type || '') && max && f.size > max) { showToast('Video ' + (f.name || '') + ' ist zu groß (' + Math.round(f.size / 1048576) + ' MB) — der Server nimmt höchstens ' + Math.round(max / 1048576) + ' MB', 'error'); continue; } try { copies.push(await readFileNow(f)); } catch (e) { showToast('Datei ' + (f.name || '') + ' konnte nicht gelesen werden — bitte nochmal auswählen', 'error'); } } for (const f of copies) { // Ein einzelnes Foto darf die restliche Auswahl nicht blockieren try { await uploadPhoto(args.id, f); } catch (e) { showToast('Datei ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error'); } } } finally { offline.releaseWakeLock(); } // Nach Upload einfach die Route neu rendern — so werden Select-Mode, // Click-Handler und Thumbnails sauber neu aufgebaut. Ausdruecklich diesen // Auftrag, nicht den Hash der Adresszeile (siehe Kamera-Cleanup). router.go('#/orders/' + args.id); announcePendingPhotos(args.id); } camInput.addEventListener('change', () => handleFiles(camInput.files)); galInput.addEventListener('change', () => handleFiles(galInput.files)); } catch (e) { main().innerHTML = renderError(e); } }); /* ============================================================ * VIDEO ABSPIELEN * * Direkt vom Server: photo.php liefert seit Bericht 1.8.0 Teilbereiche (Range), das Video * spielt also sofort und laesst sich spulen, ohne vorher komplett geladen zu werden. * Teilen laedt es einmal ganz. Kann der Browser das Format nicht (z. B. HEVC-.mov vom * iPhone), bleibt Teilen bzw. Dolibarr. * ============================================================ */ function openVideoPlayer(orderId, relpath, filename) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal video-modal'; modal.innerHTML = `
🎬 ${escapeHtml(filename || 'Video')}
`; document.body.appendChild(modal); const v = modal.querySelector('video'); v.onerror = () => showToast('Dieses Video kann der Browser nicht abspielen — über 📤 teilen oder in Dolibarr öffnen', 'warn'); v.src = api.photoUrl(relpath); pushModal(modal, () => { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (_) {} }); modal.querySelector('#vp-close').onclick = () => closeModal(modal); modal.querySelector('#vp-share').onclick = async () => { showToast('Lade Video…'); try { const f = await api.getFileBlobUrl(relpath); if (!f) { showToast('Video konnte nicht geladen werden', 'error'); return; } await shareFile(f.blob, filename || relpath.split('/').pop(), f.mime, 'Video vom Auftrag'); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; modal.querySelector('#vp-delete').onclick = async () => { if (!await confirmDialog({ title: 'Video löschen', text: 'Das Video wird endgültig aus den Dokumenten des Auftrags entfernt.', confirmLabel: 'Löschen', danger: true, })) return; try { await api.deletePhoto(relpath); showToast('✓ Gelöscht'); closeModal(modal); router.go('#/orders/' + orderId); // ausdruecklich diesen Auftrag (History-Race, siehe Kamera) } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; } /* ============================================================ * 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 wartende = items.filter(i => !i.failed); const lines = []; if (waiting) { lines.push(navigator.onLine === false ? '📴 Kein Netz — ' + medienText(wartende) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden') + ' gesendet, sobald wieder Netz da ist.' : medienText(wartende) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'geht' : 'gehen') + ' raus, sobald die Verbindung steht.'); } if (failed) lines.push('⚠️ ' + medienText(items.filter(i => i.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); // Video: erstes Bild direkt aus dem Geraetespeicher (kein Netz noetig) const bild = istQueueVideo(it) ? '▶' : ''; return '
' + bild + '' + (it.failed ? '⚠️' : '⏳') + '
'; }).join(''); const more = items.length - shown.length; box.innerHTML = '
' + '

⏳ Warten auf Upload (' + items.length + ')

' + lines.map(l => '

' + escapeHtml(l) + '

').join('') + '
' + thumbs + (more > 0 ? '
+' + more + '
' : '') + '
'; videoVorschau(box); 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 items = (await offline.listQueue().catch(() => [])) .filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)); const n = items.length; if (!n) return; const fotos = medienText(items); 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'); } /* Vorschaubild eines Videos (Kachel): Chrome auf Android malt ein