/* 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 * setzt ein Skip-Flag, damit der eigene history.back() kein * doppeltes Cleanup auslöst. * ============================================================ */ const modalStack = []; let _skipNextPopstate = false; let _lastBackAt = 0; // 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 }); try { history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash); } catch (e) { /* some browsers may block in strict sandboxes */ } } 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 {} try { entry.cleanup && entry.cleanup(); } catch {} // Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion if (history.state && history.state._modal) { _skipNextPopstate = true; history.back(); } } function isTopLevelHash(h) { const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/'; return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh); } window.addEventListener('popstate', () => { if (_skipNextPopstate) { _skipNextPopstate = false; 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 const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/')); const audioFiles = photos.photos.filter(p => (p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename)); 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)); 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 + otherDocs.length) ? '' : ''}
${imagePhotos.map(p => `
`).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 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); } }); }); // 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 docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : []; return thumbs.concat(docs); } function startSelectMode() { if (photoGrid.classList.contains('selecting')) return; photoGrid.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 (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(); 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 { for (const f of files) { // Ein einzelnes Foto darf die restliche Auswahl nicht blockieren try { await uploadPhoto(args.id, f); } catch (e) { showToast('Foto ' + (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. router.navigate(); } camInput.addEventListener('change', () => handleFiles(camInput.files)); galInput.addEventListener('change', () => handleFiles(galInput.files)); } catch (e) { main().innerHTML = renderError(e); } }); /* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität). * Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal * zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt. */ const PHOTO_CFG_KEY = 'baustelle.photo_cfg'; let photoCfg = (function () { try { return JSON.parse(localStorage.getItem(PHOTO_CFG_KEY)) || null; } catch (_) { return null; } })() || { photo_maxside: 0, photo_quality: 100 }; async function refreshPhotoConfig() { try { const c = await api.getConfig(); if (c && typeof c.photo_maxside === 'number') { photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100 }; localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg)); } } catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ } } async function uploadPhoto(orderId, file) { // Persist-First, streng: das Foto geht UNVERAENDERT und als ALLERERSTES in die // Queue. Frueher wurde davor verkleinert — und weil das Verkleinern bei // ausgeschaltetem Display haengen bleibt (pausiertes Decoding), war das Foto in // dem Moment nirgends gesichert und die ganze Auswahl-Schleife stand. Genau so // gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts // mehr passieren, was das Foto kostet. const name = file.name || ('foto_' + Date.now() + '.jpg'); const qid = await offline.enqueuePhoto(orderId, file, name); // Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es // schiefgeht oder haengt, bleibt schlicht das Original in der Queue. if (photoCfg.photo_maxside > 0) { try { const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100); if (small && small !== file && small.size < file.size) { await offline.replaceQueuedBlob(qid, small); } } catch (_) { /* Original bleibt gesichert */ } } if (navigator.onLine) showToast('Sende ' + name + '…'); else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn'); await offline.syncQueue(); } async function resizeImage(file, maxSide, timeoutMs, quality) { // SCHUTZZEIT (v1.1): Schaltet sich das Display waehrend einer Serie ab, pausiert der // Browser Bild-Decoding und Canvas — dann feuert weder onload noch onerror und das // Promise loeste NIE auf. Das passiert VOR dem Sichern in der Queue, die betroffenen // Fotos waren also nirgends gespeichert, und weil die Auswahl sequentiell laeuft, // stand die komplette restliche Auswahl still (28.08.2026, Auftrag PROV105: 8 von 19 // Fotos kamen nie an und waren auch nicht in der Queue zum Nachholen). // Im Zweifel lieber das unverkleinerte Original sichern als gar nichts. const shrink = new Promise((resolve) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); if (scale === 1) { resolve(file); return; } const c = document.createElement('canvas'); c.width = Math.round(img.width * scale); c.height = Math.round(img.height * scale); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); c.toBlob(b => resolve(b || file), 'image/jpeg', typeof quality === 'number' ? quality : 0.85); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; img.src = url; }); const guard = new Promise((resolve) => setTimeout(() => resolve(file), timeoutMs || 15000)); return Promise.race([shrink, guard]); } /** * Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid. * Nutzt /api/photo.php mit JWT (Header kann nicht schicken, * deshalb Blob-URLs). */ /* Kacheln mit serverseitigen Thumbnails füllen. * * Vorher lief das sequenziell über fetch→Blob→ObjectURL und lud dabei die kompletten * Fotos: photo.php?size=small liefert nur dann ein Thumbnail, wenn Dolibarr eines * vorgefertigt hat — für Uploads aus der PWA gibt es keins. 30 Fotos hießen also * 30 Vollbilder, eines nach dem anderen. Jetzt kommt das direkt an die * Thumb-URL: der Browser lädt parallel, respektiert loading="lazy" und cached über ETag. */ function loadThumbs(root) { const scope = root || document; const thumbs = scope.querySelectorAll('.photo-grid .thumb[data-relpath]'); const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = Math.round(160 * dpr); // Kachel ~160 CSS-px thumbs.forEach(t => { if (t.dataset.thumbLoaded === '1') return; const rel = t.dataset.relpath; if (!rel) return; // z. B. Seite im Layout "nur Titel" — kein Bild t.dataset.thumbLoaded = '1'; const img = new Image(); img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.className = 'thumb-img'; img.onerror = () => { // Kein Bild (PDF/Video) oder Fehler → Original versuchen, sonst Platzhalter if (img.dataset.retried === '1') { img.remove(); const ph = t.querySelector('.thumb-placeholder'); if (ph) ph.textContent = '❌'; return; } img.dataset.retried = '1'; img.src = api.photoUrl(rel); }; // Nur den Platzhalter entfernen — die Overlays der Kachel (Auswahl-Häkchen, // Seitennummer, Titel-Badge) bleiben stehen. Vorher überschrieb das Laden des // Thumbnails den ganzen Kachel-Inhalt und nahm sie mit. img.onload = () => { const ph = t.querySelector('.thumb-placeholder'); if (ph) ph.remove(); img.classList.add('is-loaded'); }; // Erst einhängen, dann src setzen: ein , das nicht im // Dokument steht, lädt der Browser überhaupt nicht — es hat kein Layout und // gilt damit nie als "sichtbar". Der Platzhalter bleibt bis onload stehen. t.insertBefore(img, t.firstChild); img.src = api.photoThumbUrl(rel, w); }); } router.on('/customers', async () => { if (!(await ensureAuth())) return; title('Kunden'); setNav(true, 'customers'); setBack(false); showLoader('Lade Kunden…'); async function fetchCustomers(q) { try { const d = await api.listCustomers(q ? { q } : {}); if (!q) await offline.mirrorSave('customers', d); setStale(false); return d.customers || []; } catch (e) { if (!e || !e.offline) throw e; const m = await offline.mirrorLoad('customers'); if (!m) throw e; setStale(true, m.ts); if (q) localSearchHint(); return localFilter((m.data && m.data.customers) || [], q, CUSTOMER_SEARCH_FIELDS); } } try { const data = { customers: await fetchCustomers('') }; if (!data.customers.length) { main().innerHTML = '
👥
Keine Kunden
'; return; } main().innerHTML = `
${renderCustomerList(data.customers)}
`; bindCustomerCards(); document.getElementById('cust-search').addEventListener('input', async (e) => { try { document.getElementById('cust-list').innerHTML = renderCustomerList(await fetchCustomers(e.target.value)); bindCustomerCards(); } catch (err) { showToast((err && err.message) || 'Fehler', 'error'); } }); } catch (e) { main().innerHTML = renderError(e); } }); function renderCustomerList(list) { return list.map(c => `
${escapeHtml(c.name || '')}
${escapeHtml((c.zip || '') + ' ' + (c.town || '')).trim() || '—'}
${c.phone ? '📞 ' + escapeHtml(c.phone) : ''} ${c.bericht_count > 0 ? `📑 ${c.bericht_count}` : ''}
`).join(''); } function bindCustomerCards() { document.querySelectorAll('.customer-card').forEach(c => { c.addEventListener('click', () => router.go('#/customers/' + c.dataset.id)); }); } router.on('/customers/:id', async (args) => { if (!(await ensureAuth())) return; setNav(true, 'customers'); setBack(true, '#/customers'); showLoader('Lade Kunde…'); try { const mirrored = await loadMirrored('detail:customer:' + args.id, () => api.getCustomer(args.id)); setStale(mirrored.stale, mirrored.ts); const data = mirrored.data; title(data.customer.name); const addr = [data.customer.address, (data.customer.zip || '') + ' ' + (data.customer.town || '')] .map(s => (s || '').trim()).filter(Boolean).join('\n'); main().innerHTML = `

Stammdaten

${escapeHtml(data.customer.name)}

${data.customer.code ? `

Kundennr: ${escapeHtml(data.customer.code)}

` : ''} ${addr ? `

${escapeHtml(addr).replace(/\n/g, '
')}

` : ''} ${data.customer.phone ? `

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

` : ''} ${data.customer.email ? `

✉ ${escapeHtml(data.customer.email)}

` : ''} ${data.customer.siret || data.customer.vat ? `

USt-ID: ${escapeHtml(data.customer.vat || '')}

` : ''}
${data.customer.address || data.customer.town ? `🗺 Route` : ''}
${data.orders.length ? `

Aufträge (${data.orders.length})

${data.orders.map(o => `
${escapeHtml(o.ref)}
${formatDate(o.date)} · ${formatEur(o.total)}${o.bericht_count > 0 ? ' · 📑 ' + o.bericht_count : ''}
`).join('')}
` : ''} ${data.reports.length ? `

Berichte (${data.reports.length})

${data.reports.map(r => `
${escapeHtml(r.ref)} ${r.status === 1 ? 'Final' : 'Entwurf'}
${escapeHtml(r.titel || '')} · ${formatDate(r.datec)}
`).join('')}
` : ''} ${data.invoices.length ? `

Rechnungen (${data.invoices.length})

${data.invoices.map(i => `
${escapeHtml(i.ref)}
${formatDate(i.date)} · ${formatEur(i.total)}
`).join('')}
` : ''} `; document.querySelectorAll('.mini-card[data-order-id]').forEach(el => { el.addEventListener('click', () => router.go('#/orders/' + el.dataset.orderId)); }); document.querySelectorAll('.mini-card[data-report-id]').forEach(el => { el.addEventListener('click', () => router.go('#/reports/' + el.dataset.reportId)); }); } catch (e) { main().innerHTML = renderError(e); } }); function formatDate(unix) { if (!unix) return '—'; const d = new Date(unix * 1000); return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' }); } function formatEur(v) { if (v == null) return ''; return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v); } router.on('/reports', async () => { if (!(await ensureAuth())) return; title('Berichte'); setNav(true, 'reports'); setBack(false); showLoader('Lade Berichte…'); try { const { data, stale, ts } = await loadMirrored('reports', () => api.listReports()); setStale(stale, ts); if (!data.reports.length) { main().innerHTML = '
📑
Noch keine Berichte
'; return; } main().innerHTML = data.reports.map(r => { const statusLabel = r.status === 1 ? 'Final' : 'Entwurf'; const statusClass = r.status === 1 ? 'status-final' : 'status-draft'; const sourceIcon = r.element_type === 'order' ? '🛒' : (r.element_type === 'invoice' ? '📄' : '📋'); return `
${escapeHtml(r.ref)} ${statusLabel}
${escapeHtml(r.titel || '')}
${sourceIcon} ${escapeHtml(r.parent_ref || '')} ${r.page_count} Seite${r.page_count === 1 ? '' : 'n'}
`; }).join(''); document.querySelectorAll('.order-card').forEach(c => { c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id)); }); } catch (e) { main().innerHTML = renderError(e); } }); router.on('/reports/:id', async (args) => { if (!(await ensureAuth())) return; setNav(true, 'reports'); setBack(true, '#/reports'); showLoader('Lade Bericht…'); try { const data = await api.getReport(args.id); title(data.report.ref); const statusLabel = data.report.status === 1 ? 'Final' : 'Entwurf'; const hasPages = data.pages.length > 0; const finalizeLabel = data.report.status === 1 ? '🔄 PDF neu erzeugen' : '📑 Bericht finalisieren (PDF)'; main().innerHTML = `

Bericht

${escapeHtml(data.report.titel || data.report.ref)}

Auftrag: ${escapeHtml(data.report.auftragsnummer || '—')}

Format: ${escapeHtml(data.report.page_format || 'A4')} ${data.report.page_orientation === 'L' ? 'Quer' : 'Hoch'}

Seiten: ${data.pages.length}

Status: ${statusLabel}

${hasPages ? `
${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 `
${rel ? '⏳' : '📄'}
${i + 1}
${badge ? `
${badge}
` : ''} ${p.title ? `
${escapeHtml(p.title)}
` : ''}
`; }).join('')}
` : '
📭
Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.
'} ${hasPages ? '' : ''} `; loadThumbs(); // Tap = Aktionen, Long-Press = Drag-Sort bindReportPageInteractions(args.id); // PDF-Vorschau const pdfBtn = document.getElementById('btn-view-pdf'); if (pdfBtn) pdfBtn.onclick = async () => { showToast('PDF wird geladen…'); const url = await api.getPdfBlobUrl(args.id); if (!url) { showToast('PDF konnte nicht geladen werden', 'error'); return; } const fname = (data.report && data.report.ref ? data.report.ref : ('bericht-' + args.id)) + '.pdf'; openPdfModal(url, fname); }; // Unterschrift document.getElementById('btn-signature').onclick = () => openSignatureModal(args.id); const finalizeBtn = document.getElementById('btn-finalize'); 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; showToast('PDF wird erzeugt…'); try { const r = await api.finalizeReport(args.id); showToast('✓ PDF erstellt: ' + r.filename); setTimeout(() => router.go('#/reports/' + args.id), 800); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; } document.getElementById('btn-open-editor').onclick = () => { window.open(window.location.origin + '/custom/bericht/bericht_card.php?berichtid=' + args.id, '_blank'); }; document.getElementById('btn-delete-report').onclick = () => { const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); pushModal(modal); modal.querySelector('#dr-cancel').onclick = () => closeModal(modal); modal.querySelector('#dr-no').onclick = () => closeModal(modal); modal.querySelector('#dr-yes').onclick = async () => { try { await api.deleteReport(args.id); closeModal(modal); showToast('Bericht gelöscht'); router.go('#/reports'); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; }; } catch (e) { main().innerHTML = renderError(e); } }); router.on('/settings', async () => { if (!(await ensureAuth())) return; title('Einstellungen'); setNav(true, 'settings'); setBack(false); const user = await idb.get('user') || {}; const pinEnabled = !!(await idb.get('pin_hash')); const ntfyTopic = await idb.get('ntfy_topic') || ''; const ntfyServer = await idb.get('ntfy_server') || 'https://notify.data-it-solution.de'; main().innerHTML = `

Konto

${escapeHtml(user.name || user.login || 'Unbekannt')}

${escapeHtml(user.login || '')}

🔔 Benachrichtigungen

Per ntfy-Topic. Wenn gesetzt, zeigt die App Notifications bei neuen Nachrichten (z. B. neue Aufträge, Team-Pings).

Sicherheit

PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.

${pinEnabled ? '' : ''}

Baustelle PWA v1.0

`; document.getElementById('btn-sync').onclick = async () => { await offline.syncQueue(); showToast('Sync ausgelöst'); }; document.getElementById('btn-logout').onclick = async () => { await api.logout(); await idb.del('pin_hash'); await idb.del('pin_salt'); // Der Offline-Spiegel muss mit weg — sonst sieht der naechste Benutzer an // diesem Geraet ohne Netz die Auftraege, Kunden und Preise des vorherigen. await offline.mirrorClear(); // 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 document.getElementById('btn-ntfy-save').onclick = async () => { const srv = document.getElementById('ntfy-server').value.trim().replace(/\/$/, ''); const topic = document.getElementById('ntfy-topic').value.trim(); await idb.set('ntfy_server', srv); await idb.set('ntfy_topic', topic); if (topic && 'Notification' in window) { if (Notification.permission !== 'granted') { const perm = await Notification.requestPermission(); if (perm !== 'granted') { document.getElementById('ntfy-status').textContent = 'Benachrichtigungen abgelehnt — keine Notifications'; return; } } startNtfySubscription(srv, topic); document.getElementById('ntfy-status').textContent = '✓ Abonniert: ' + topic; showToast('✓ Benachrichtigungen aktiviert'); } else { stopNtfySubscription(); document.getElementById('ntfy-status').textContent = 'Kein Topic gesetzt'; } }; document.getElementById('btn-ntfy-test').onclick = async () => { const srv = document.getElementById('ntfy-server').value.trim().replace(/\/$/, ''); const topic = document.getElementById('ntfy-topic').value.trim(); if (!srv || !topic) { showToast('Server + Topic eingeben', 'error'); return; } try { await fetch(srv + '/' + topic, { method: 'POST', headers: { 'Title': 'Baustelle', 'Tags': 'construction_worker' }, body: 'Test-Nachricht ' + new Date().toLocaleTimeString('de-DE'), }); showToast('✓ Gesendet'); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; document.getElementById('pin-toggle').addEventListener('change', async (e) => { if (e.target.checked) { // PIN setzen const pin = await promptNewPin(); if (!pin) { e.target.checked = false; return; } const { hash, salt } = await hashPin(pin); await idb.set('pin_salt', salt); await idb.set('pin_hash', hash); 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) { e.target.checked = true; return; } await idb.del('pin_hash'); await idb.del('pin_salt'); showToast('PIN-Schutz aus'); router.navigate(); } }); const changeBtn = document.getElementById('btn-pin-change'); if (changeBtn) changeBtn.onclick = async () => { const old = await promptPin('Aktuelle PIN eingeben'); if (!old) return; if (!(await verifyPin(old))) { showToast('Falsche PIN', 'error'); return; } const neu = await promptNewPin(); if (!neu) return; const { hash, salt } = await hashPin(neu); await idb.set('pin_salt', salt); await idb.set('pin_hash', hash); showToast('✓ PIN geändert'); }; }); /* Aus einem abgebrochenen Teilen-Vorgang koennen noch Inhalte bereitliegen — etwa * weil beim Teilen aus WhatsApp die Anmeldung fehlte oder das Netz weg war. Sie * bleiben in IndexedDB liegen; ohne diesen Hinweis wuerde sie niemand je wiederfinden. */ async function checkPendingShare() { if (document.getElementById('shared-banner')) return; let n = 0, vorhanden = false; try { const p = await idb.get('shared_payload'); if (p) { vorhanden = true; n = (p.files || []).length; } else { const alt = await idb.get('shared_files'); // altes Format if (alt && alt.length) { vorhanden = true; n = alt.length; } } } catch (_) { return; } if (!vorhanden) return; const b = document.createElement('button'); b.id = 'shared-banner'; b.className = 'shared-banner'; b.textContent = '📤 Geteiltes ablegen' + (n ? ' (' + n + ' Bild' + (n > 1 ? 'er' : '') + ')' : ''); b.addEventListener('click', () => { location.href = 'share.html'; }); document.body.appendChild(b); } /* Bottom nav + Hilfe-Button */ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('#bottom-nav button').forEach(b => { b.addEventListener('click', () => router.go('#/' + b.dataset.route)); }); const helpBtn = document.getElementById('help-btn'); if (helpBtn) helpBtn.addEventListener('click', openHelpModal); refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen checkPendingShare(); // liegt noch etwas Geteiltes bereit? }); /** * Bindet Tap + Long-Press-Drag an alle .report-page-thumb des Bericht-Details. */ function bindReportPageInteractions(reportId) { const grid = document.getElementById('report-pages'); if (!grid) return; const thumbs = Array.from(grid.querySelectorAll('.report-page-thumb')); let longPressTimer = null; let dragging = null; let touchStart = null; thumbs.forEach(t => { // Click/Tap → Aktion-Modal (nur wenn nicht gedraggt wurde) t.addEventListener('click', (e) => { if (dragging) { e.preventDefault(); return; } openPageActionsModal(reportId, t.dataset.pageId, t.dataset.relpath, t.dataset.note || '', t.dataset.title || ''); }); // Long-Press → Drag-Modus const startLongPress = (e) => { touchStart = e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null; longPressTimer = setTimeout(() => { dragging = t; t.classList.add('dragging'); showToast('Seite verschieben — ziehen und loslassen'); if (navigator.vibrate) navigator.vibrate(50); }, 500); }; const cancelLongPress = () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }; t.addEventListener('touchstart', startLongPress, { passive: true }); t.addEventListener('touchmove', (e) => { if (!dragging && touchStart) { const dx = e.touches[0].clientX - touchStart.x; const dy = e.touches[0].clientY - touchStart.y; if (Math.abs(dx) > 8 || Math.abs(dy) > 8) cancelLongPress(); } if (dragging) { e.preventDefault(); // Welcher Thumb liegt unter dem Finger? const tx = e.touches[0].clientX; const ty = e.touches[0].clientY; const below = document.elementFromPoint(tx, ty); const target = below ? below.closest('.report-page-thumb') : null; if (target && target !== dragging) { const rect = target.getBoundingClientRect(); const middle = rect.left + rect.width / 2; if (tx < middle) grid.insertBefore(dragging, target); else grid.insertBefore(dragging, target.nextSibling); } } }, { passive: false }); t.addEventListener('touchend', async () => { cancelLongPress(); if (dragging) { dragging.classList.remove('dragging'); const ids = Array.from(grid.querySelectorAll('.report-page-thumb')).map(x => x.dataset.pageId); try { await api.reorderPages(ids); showToast('✓ Reihenfolge gespeichert'); // Page-Nummer-Badges neu setzen Array.from(grid.querySelectorAll('.report-page-thumb')).forEach((el, i) => { const num = el.querySelector('.page-num'); if (num) num.textContent = (i + 1); }); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } dragging = null; } touchStart = null; }); t.addEventListener('touchcancel', () => { cancelLongPress(); dragging = null; touchStart = null; }); }); } /* ============================================================ * MATERIALLISTE MODAL * ============================================================ */ async function openMaterialModal(elementType, elementId) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
📦 Materialliste

Neues Material

Erfasst

Lade…
`; document.body.appendChild(modal); pushModal(modal); modal.querySelector('#mt-close').onclick = () => closeModal(modal); async function reload() { try { const d = await api.listMaterials(elementType, elementId); const listEl = modal.querySelector('#mt-list'); if (!d.materials.length) { listEl.innerHTML = '
Noch keine Einträge
'; return; } listEl.innerHTML = d.materials.map(m => `
${escapeHtml(m.label)}
${m.qty} ${escapeHtml(m.unit)}${m.note ? ' · ' + escapeHtml(m.note) : ''}
`).join(''); listEl.querySelectorAll('.mt-del').forEach(btn => { 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; await api.deleteMaterial(id); reload(); }); }); } catch (e) { modal.querySelector('#mt-list').innerHTML = '
Fehler: ' + escapeHtml(e.message) + '
'; } } reload(); modal.querySelector('#mt-add').onclick = async () => { const label = modal.querySelector('#mt-label').value.trim(); if (!label) { showToast('Bezeichnung eingeben', 'error'); return; } const qty = parseFloat(modal.querySelector('#mt-qty').value) || 1; const unit = modal.querySelector('#mt-unit').value; const note = modal.querySelector('#mt-note').value.trim(); try { await api.addMaterial(elementType, elementId, { label, qty, unit, note }); modal.querySelector('#mt-label').value = ''; modal.querySelector('#mt-qty').value = '1'; modal.querySelector('#mt-note').value = ''; showToast('✓ Hinzugefügt'); reload(); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; } /* ============================================================ * NEW REPORT MODAL (Schnell-Bericht mit Meta + Vorlage + ODT) * ============================================================ */ async function openNewReportModal(orderId) { // Templates parallel laden let tpls = [], odts = []; try { tpls = (await api.listTemplates()).templates || []; } catch (e) {} try { odts = (await api.listOdtTemplates()).templates || []; } catch (e) {} const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
📑 Neuer Bericht
${tpls.length ? ` ` : ''}
${odts.length ? ` ` : ''}
`; document.body.appendChild(modal); pushModal(modal); // ODT-Default vorauswählen try { const odtData = await api.listOdtTemplates(); if (odtData.default) { const sel = modal.querySelector('#nr-odt'); if (sel) sel.value = odtData.default; } } catch (e) {} modal.querySelector('#nr-close').onclick = () => closeModal(modal); modal.querySelector('#nr-save').onclick = async () => { const titel = modal.querySelector('#nr-titel').value.trim(); const tplSel = modal.querySelector('#nr-template'); const tpl = tplSel ? parseInt(tplSel.value, 10) : 0; const format = modal.querySelector('#nr-format').value; const orient = modal.querySelector('#nr-orient').value; const odtSel = modal.querySelector('#nr-odt'); const odt = odtSel ? odtSel.value : ''; showToast('Lege Bericht an…'); try { const res = await api.createReport({ element_type: 'order', element_id: parseInt(orderId, 10), titel: titel, template_id: tpl, page_format: format, page_orientation: orient, template_odt: odt, }); showToast('✓ Bericht angelegt'); closeModal(modal); router.go('#/reports/' + res.bericht_id); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; } /* ============================================================ * NEUER AUFTRAG MODAL (Schnell-Erfassung vom FAB) * ============================================================ */ async function openNewOrderModal() { if (!(await ensureAuth())) return; const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
➕ Neuer Auftrag
`; document.body.appendChild(modal); pushModal(modal); const closeBtn = modal.querySelector('#no-close'); const saveBtn = modal.querySelector('#no-save'); const searchIn = modal.querySelector('#no-customer-search'); const listEl = modal.querySelector('#no-customer-list'); const stepCust = modal.querySelector('#no-step-customer'); const stepDet = modal.querySelector('#no-step-details'); const socName = modal.querySelector('#no-soc-name'); const socMeta = modal.querySelector('#no-soc-meta'); const defaultsEl = modal.querySelector('#no-defaults'); const titleIn = modal.querySelector('#no-title'); const refIn = modal.querySelector('#no-refclient'); const deliveryIn = modal.querySelector('#no-delivery'); const validateCb = modal.querySelector('#no-validate'); const changeBtn = modal.querySelector('#no-change-customer'); // Mobile: beim Fokus das aktive Feld über die Bildschirmtastatur schieben // (Tastatur öffnet verzögert -> kleiner Timeout, bis der Viewport geschrumpft ist) [refIn, titleIn, deliveryIn].forEach((el) => { el.addEventListener('focus', () => { setTimeout(() => { try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {} }, 300); }); }); let selectedCustomer = null; closeBtn.onclick = () => closeModal(modal); /* ---- Letzte Kunden als Quick-Pick ---- */ async function renderRecent() { const recent = (await idb.get('recent_customers')) || []; if (!recent.length) { listEl.innerHTML = '
Tippe zum Suchen…
'; return; } listEl.innerHTML = '
Zuletzt verwendet
' + recent.map(c => `
${escapeHtml(c.name)}
${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}
`).join(''); } await renderRecent(); /* ---- Suche mit Debounce ---- */ let searchTimer = null; searchIn.addEventListener('input', () => { clearTimeout(searchTimer); const q = searchIn.value.trim(); if (!q) { renderRecent(); return; } searchTimer = setTimeout(async () => { try { const data = await api.listCustomers({ q }); const items = (data.customers || []).slice(0, 30); if (!items.length) { listEl.innerHTML = '
Keine Treffer
'; return; } listEl.innerHTML = items.map(c => `
${escapeHtml(c.name)}
${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}
`).join(''); } catch (e) { listEl.innerHTML = '
Fehler: ' + escapeHtml(e.message) + '
'; } }, 300); }); /* ---- Kunden-Klick: Details laden, Wechsel zum zweiten Schritt ---- */ listEl.addEventListener('click', async (e) => { const item = e.target.closest('.nc-item'); if (!item) return; const id = parseInt(item.dataset.id, 10); if (!id) return; showToast('Lade Kundendaten…'); try { const res = await api.getCustomer(id); const c = res.customer || res; selectedCustomer = c; socName.textContent = c.name || ''; socMeta.textContent = [c.address, ((c.zip || '') + ' ' + (c.town || '')).trim()].filter(Boolean).join(' · '); const defs = []; if (c.cond_reglement_label) defs.push('💳 ' + c.cond_reglement_label); if (c.mode_reglement_label) defs.push('🏦 ' + c.mode_reglement_label); if (!defs.length) defs.push('Keine speziellen Defaults hinterlegt'); defaultsEl.innerHTML = 'Übernommen: ' + escapeHtml(defs.join(' · ')); stepCust.hidden = true; stepDet.hidden = false; saveBtn.disabled = false; refIn.focus(); } catch (err) { showToast('Fehler: ' + err.message, 'error'); } }); changeBtn.onclick = () => { selectedCustomer = null; stepDet.hidden = true; stepCust.hidden = false; saveBtn.disabled = true; searchIn.focus(); }; /* ---- Anlegen ---- */ saveBtn.onclick = async () => { if (!selectedCustomer) { showToast('Bitte zuerst Kunde wählen', 'error'); return; } const refClient = refIn.value.trim(); if (!refClient) { showToast('Ihr Zeichen ist Pflicht', 'error'); refIn.focus(); return; } const title = titleIn.value.trim(); const doValidate = validateCb.checked; // datetime-local liefert lokale Zeit -> Unix-Timestamp (Sekunden); leer = 0 let deliveryTs = 0; if (deliveryIn.value) { const d = new Date(deliveryIn.value); if (!isNaN(d.getTime())) deliveryTs = Math.floor(d.getTime() / 1000); } saveBtn.disabled = true; showToast(doValidate ? 'Lege Auftrag an & gebe frei…' : 'Lege Auftrag an…'); try { const res = await api.createOrder({ socid: selectedCustomer.id, ref_client: refClient, title: title, date_livraison: deliveryTs, validate: doValidate, }); // Recent-Liste in IDB aktualisieren (letzte 5) try { const recent = (await idb.get('recent_customers')) || []; const filtered = recent.filter(c => c.id !== selectedCustomer.id); filtered.unshift({ id: selectedCustomer.id, name: selectedCustomer.name, zip: selectedCustomer.zip, town: selectedCustomer.town, }); await idb.set('recent_customers', filtered.slice(0, 5)); } catch (e) {} const ref = res.order.ref || ''; showToast('✓ Auftrag ' + ref + (res.order.validated ? ' angelegt & freigegeben' : ' angelegt')); if (res.validate_warning) showToast(res.validate_warning, 'error'); closeModal(modal); router.go('#/orders/' + res.order.id); } catch (e) { showToast('Fehler: ' + e.message, 'error'); saveBtn.disabled = false; } }; } /* ============================================================ * SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild) * ============================================================ */ async function openPageActionsModal(berichtId, pageId, relpath, note, title) { const url = await api.getPhotoBlobUrl(relpath); const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
Seite bearbeiten
${url ? `` : '
'}
`; document.body.appendChild(modal); pushModal(modal); modal.querySelector('#pa-close').onclick = () => closeModal(modal); modal.querySelector('#pa-save').onclick = async () => { try { const note = modal.querySelector('#pa-note').value; const title = modal.querySelector('#pa-title').value; await api.request('/pages.php?id=' + pageId, { method: 'POST', body: JSON.stringify({ note, title }), }); showToast('✓ Gespeichert'); closeModal(modal); router.navigate(); } catch (e) { // Fallback falls api.request nicht exposed ist try { await api.updatePageNote(pageId, modal.querySelector('#pa-note').value); showToast('✓ Notiz gespeichert'); closeModal(modal); router.navigate(); } catch (er) { showToast('Fehler: ' + er.message, 'error'); } } }; 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; try { await api.deletePage(pageId); showToast('✓ Seite entfernt'); closeModal(modal); router.navigate(); } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; } /* ============================================================ * PDF-VORSCHAU MODAL * ============================================================ */ function openPdfModal(blobUrl, filename) { const fname = filename || 'bericht.pdf'; const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
📑 PDF-Vorschau
`; document.body.appendChild(modal); pushModal(modal, () => { try { URL.revokeObjectURL(blobUrl); } catch {} }); modal.querySelector('#pdf-close').onclick = () => closeModal(modal); modal.querySelector('#pdf-share').onclick = async () => { try { const r = await fetch(blobUrl); const blob = await r.blob(); await shareFile(blob, fname, 'application/pdf', fname); } catch (e) { showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); } }; } /* ============================================================ * UNTERSCHRIFT MODAL (Touch-Signatur) * ============================================================ */ function openSignatureModal(berichtId) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal signature-modal'; modal.innerHTML = `
✍️ Kunden-Unterschrift
Mit dem Finger unterschreiben
`; document.body.appendChild(modal); pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvas); } catch {} }); const canvas = modal.querySelector('#sig-canvas'); const ctx = canvas.getContext('2d'); function fitCanvas() { const body = modal.querySelector('.signature-body'); const rect = body.getBoundingClientRect(); canvas.width = Math.max(600, Math.floor(rect.width * 2)); canvas.height = Math.max(300, Math.floor(rect.height * 2)); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#000'; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; } fitCanvas(); window.addEventListener('resize', fitCanvas); let drawing = false; function pos(e) { const r = canvas.getBoundingClientRect(); const sx = canvas.width / r.width; const sy = canvas.height / r.height; const t = e.touches ? e.touches[0] : e; return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; } function start(e) { e.preventDefault(); drawing = true; const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); } function move(e) { if (!drawing) return; e.preventDefault(); const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); } function end() { drawing = false; } canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', move); canvas.addEventListener('mouseup', end); canvas.addEventListener('mouseleave', end); canvas.addEventListener('touchstart', start, { passive: false }); canvas.addEventListener('touchmove', move, { passive: false }); canvas.addEventListener('touchend', end); modal.querySelector('#sig-clear').onclick = fitCanvas; modal.querySelector('#sig-close').onclick = () => closeModal(modal); modal.querySelector('#sig-save').onclick = async () => { const signer = modal.querySelector('#sig-name').value.trim(); if (!signer) { showToast('Bitte Namen des Unterzeichners eingeben', 'error'); modal.querySelector('#sig-name').focus(); return; } // GPS holen wenn aktiviert let gps = null; if (modal.querySelector('#sig-gps').checked && 'geolocation' in navigator) { showToast('Hole GPS-Position…'); try { const pos = await new Promise((res, rej) => { navigator.geolocation.getCurrentPosition(res, rej, { timeout: 8000, enableHighAccuracy: true }); }); gps = { lat: pos.coords.latitude, lon: pos.coords.longitude }; } catch (e) { console.warn('GPS failed', e); } } showToast('Speichere Unterschrift…'); canvas.toBlob(async (blob) => { try { const opts = { signer_name: signer }; if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; } await api.uploadSignature(berichtId, blob, opts); showToast('✓ Unterschrift hinzugefügt'); closeModal(modal); router.navigate(); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }, 'image/png'); }; } /* ============================================================ * LIEFERUNGEN — Liste + Vollbild-Signatur-Workflow * ============================================================ */ router.on('/orders/:id/shipments', async (args) => { if (!(await ensureAuth())) return; setNav(true, 'orders'); setBack(true, '#/orders/' + args.id); showLoader('Lade Lieferungen…'); title('Lieferungen'); try { const order = await api.getOrder(args.id); const data = await api.listShipments(args.id); const list = data.shipments || []; if (!list.length) { main().innerHTML = `

${escapeHtml(order.order.ref)} · ${escapeHtml(order.customer.name)}

📭
Zu diesem Auftrag gibt es noch keine Lieferungen.
In Dolibarr erst eine Lieferung anlegen und validieren.
`; return; } const statusLabel = (s) => ({0:'Entwurf', 1:'Validiert', 2:'Geschlossen', 3:'In Bearbeitung', '-1':'Storniert'})[s] || ('Status '+s); main().innerHTML = `

${escapeHtml(order.order.ref)} · ${escapeHtml(order.customer.name)}

${list.map(s => `
🚚 ${escapeHtml(s.ref)} ${s.signed_status === 1 ? '✓ unterschrieben' : 'unbestätigt'}
${s.date_delivery ? 'Lieferdatum: ' + formatShortDate(s.date_delivery) : (s.date_creation ? 'Erstellt: ' + formatShortDate(s.date_creation) : '')} · ${escapeHtml(statusLabel(s.status))}
`).join('')}
`; document.querySelectorAll('.ship-card').forEach(c => { c.addEventListener('click', () => router.go('#/shipments/' + c.dataset.id)); }); } catch (e) { main().innerHTML = renderError(e); } }); router.on('/shipments/:id', async (args) => { if (!(await ensureAuth())) return; setNav(true, 'orders'); showLoader('Lade Lieferschein…'); try { const data = await api.getShipment(args.id); title(data.shipment.ref); setBack(true, data.order ? ('#/orders/' + data.order.id + '/shipments') : '#/orders'); const isSigned = data.shipment.signed_status === 1; main().innerHTML = `

🚚 ${escapeHtml(data.shipment.ref)}

${data.order ? '

Auftrag: '+escapeHtml(data.order.ref)+'

' : ''}

Kunde: ${escapeHtml(data.customer.name)}

${isSigned ? '

✓ Bereits unterschrieben

' : '

Noch nicht unterschrieben

'}
`; // PDF inline rendern (PDF.js Canvas wie bei Reports) try { const res = await api.getShipmentPdfBlobUrl(args.id); if (res && res.url) { const wrap = document.getElementById('shipment-pdf-wrap'); wrap.innerHTML = '
Lieferschein-Vorschau
'; await renderPdfInline(res.url, wrap, 1); } } catch (e) { console.warn('PDF-Vorschau', e); } document.getElementById('btn-sign-shipment').onclick = () => { openShipmentSignatureModal(args.id, { ref: data.shipment.ref, customer: data.customer.name, orderRef: data.order ? data.order.ref : '', }); }; } catch (e) { main().innerHTML = renderError(e); } }); /** * Rendert alle Seiten eines PDFs (per Blob-URL) untereinander in einen Container — statische Vorschau. */ async function renderPdfInline(blobUrl, container, _unusedPageNum) { if (typeof pdfjs === 'undefined') { container.innerHTML += '

PDF-Vorschau nicht verfügbar

'; return; } try { const buf = await fetch(blobUrl).then(r => r.arrayBuffer()); const doc = await pdfjs.getDocument({ data: buf }).promise; const targetW = Math.min(window.innerWidth - 24, 700); for (let i = 1; i <= doc.numPages; i++) { const page = await doc.getPage(i); const viewport1 = page.getViewport({ scale: 1 }); const scale = targetW / viewport1.width; const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.maxWidth = '100%'; canvas.style.height = 'auto'; canvas.style.background = '#fff'; canvas.style.marginBottom = '8px'; canvas.style.boxShadow = '0 0 0 1px #888'; container.appendChild(canvas); const ctx = canvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport }).promise; } } catch (e) { console.warn('renderPdfInline', e); container.innerHTML += '

PDF-Vorschau-Fehler: '+escapeHtml(e.message || String(e))+'

'; } } /** * Findet die Bounding-Box aller nicht-weißen Pixel und gibt ein neues Canvas zurück, * das nur diesen Bereich enthält (plus 2% Padding). Liefert null wenn nichts gezeichnet wurde. */ function trimCanvasToInk(canvas) { const ctx = canvas.getContext('2d'); const { width: w, height: h } = canvas; const data = ctx.getImageData(0, 0, w, h).data; let minX = w, minY = h, maxX = -1, maxY = -1; // Canvas ist transparent — gezeichnete Pixel haben alpha > 0 for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const a = data[(y * w + x) * 4 + 3]; if (a > 16) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (maxX < 0) return null; // nichts gezeichnet // Padding 2% der Diagonale (mind. 8 px) const pad = Math.max(8, Math.floor(Math.hypot(maxX - minX, maxY - minY) * 0.02)); const sx = Math.max(0, minX - pad); const sy = Math.max(0, minY - pad); const sw = Math.min(w, maxX + pad) - sx + 1; const sh = Math.min(h, maxY + pad) - sy + 1; const out = document.createElement('canvas'); out.width = sw; out.height = sh; const octx = out.getContext('2d'); // KEIN fillRect → transparenter Hintergrund bleibt erhalten octx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh); return out; } /** * Vollbild-Querformat-Modal fuer die Lieferschein-Unterschrift. * Querformat-Lock via Screen Orientation API (Android Chrome). * Linke Spalte: Lieferschein-Info + Namens-Input + Buttons. * Rechte Spalte: HiDPI-Canvas zum Unterschreiben. */ function openShipmentSignatureModal(shipmentId, info) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal ship-sign-modal'; modal.innerHTML = `
Hier mit dem Finger unterschreiben
`; document.body.appendChild(modal); pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvas); } catch {} try { window.removeEventListener('orientationchange', fitCanvas); } catch {} // Fullscreen + Orientation unlock try { if (screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); } catch {} try { if (document.fullscreenElement) document.exitFullscreen(); } catch {} }); // Fullscreen + Landscape (best effort — iOS Safari hat kein Lock) requestAnimationFrame(() => { try { document.documentElement.requestFullscreen({ navigationUI: 'hide' }); } catch {} try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(() => {}); } catch {} }); const canvas = modal.querySelector('#ss-canvas'); const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; function fitCanvas() { const pad = modal.querySelector('.ship-sign-pad'); const rect = pad.getBoundingClientRect(); const padding = 24; const targetW = Math.max(400, rect.width - padding); const targetH = Math.max(200, rect.height - padding - 30); // 30 für Hinweis canvas.style.width = targetW + 'px'; canvas.style.height = targetH + 'px'; // Resize loescht Canvas automatisch (transparent). Kein fillRect → PNG bekommt Alpha-Kanal. canvas.width = Math.floor(targetW * dpr); canvas.height = Math.floor(targetH * dpr); ctx.scale(dpr, dpr); ctx.strokeStyle = '#000'; ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; } fitCanvas(); window.addEventListener('resize', fitCanvas); window.addEventListener('orientationchange', () => setTimeout(fitCanvas, 200)); // Drawing — Pointer-Events fuer iOS + Android let drawing = false; let lastX = 0, lastY = 0; function pos(e) { const r = canvas.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { x: t.clientX - r.left, y: t.clientY - r.top }; } function start(e) { e.preventDefault(); drawing = true; const p = pos(e); lastX = p.x; lastY = p.y; ctx.beginPath(); ctx.moveTo(p.x, p.y); } function move(e) { if (!drawing) return; e.preventDefault(); const p = pos(e); // Quadratic-Glaettung const midX = (lastX + p.x) / 2; const midY = (lastY + p.y) / 2; ctx.quadraticCurveTo(lastX, lastY, midX, midY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(midX, midY); lastX = p.x; lastY = p.y; } function end(e) { if (drawing && e) e.preventDefault && e.preventDefault(); drawing = false; } canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', move); canvas.addEventListener('mouseup', end); canvas.addEventListener('mouseleave', end); canvas.addEventListener('touchstart', start, { passive: false }); canvas.addEventListener('touchmove', move, { passive: false }); canvas.addEventListener('touchend', end); modal.querySelector('#ss-clear').onclick = fitCanvas; modal.querySelector('#ss-cancel').onclick = () => closeModal(modal); const errBox = modal.querySelector('#ss-error'); const setErr = (msg) => { if (msg) { errBox.textContent = msg; errBox.style.display = ''; } else { errBox.style.display = 'none'; } }; modal.querySelector('#ss-save').onclick = async () => { setErr(null); const name = modal.querySelector('#ss-name').value.trim(); if (!name) { setErr('Bitte Namen des Unterzeichners eingeben'); modal.querySelector('#ss-name').focus(); return; } let gps = null; if (modal.querySelector('#ss-gps').checked && 'geolocation' in navigator) { showToast('Hole GPS-Position…'); try { const p = await new Promise((res, rej) => { navigator.geolocation.getCurrentPosition(res, rej, { timeout: 5000, enableHighAccuracy: true }); }); gps = { lat: p.coords.latitude, lon: p.coords.longitude }; } catch (e) { /* GPS optional */ } } setErr('Speichere Unterschrift…'); // Canvas auf den gezeichneten Bereich trimmen — sonst rendert ODT den vollen // weißen Bereich + winzige Unterschrift in der Ecke. const trimmed = trimCanvasToInk(canvas); if (!trimmed) { setErr('Bitte erst unterschreiben (Canvas ist leer)'); return; } console.log('[Signature] orig='+canvas.width+'x'+canvas.height+' trimmed='+trimmed.width+'x'+trimmed.height); setErr('Upload… ('+canvas.width+'×'+canvas.height+' → '+trimmed.width+'×'+trimmed.height+')'); trimmed.toBlob(async (blob) => { try { if (!blob) throw new Error('Canvas konnte kein PNG erzeugen'); const opts = { signer_name: name }; if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; } const fd = new FormData(); fd.append('file', blob, 'signature.png'); fd.append('signer_name', name); if (gps) { fd.append('gps_lat', gps.lat); fd.append('gps_lon', gps.lon); } // Direkter Fetch statt api.confirmShipment, damit wir den Response-Body // auch bei 500 lesen koennen const url = window.location.origin + '/custom/bericht/api/shipments.php?id=' + shipmentId + '&action=confirm'; const r = await fetch(url, { method: 'POST', body: fd, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' }, // State-Änderung -> CSRF-pflichtig }); const text = await r.text(); let payload; try { payload = JSON.parse(text); } catch { payload = { error: text.substring(0, 800) }; } if (!r.ok) throw new Error('HTTP '+r.status+': '+(payload.error || text.substring(0, 400))); setErr(null); showToast('✓ Lieferung bestätigt'); closeModal(modal); router.go('#/shipments/' + shipmentId); } catch (e) { setErr('Fehler: ' + (e.message || e)); console.error('[Signature confirm]', e); } }, 'image/png'); }; } function openHelpModal() { const modal = document.createElement('div'); modal.className = 'fullscreen-modal help-modal'; modal.innerHTML = `
❓ Hilfe / Anleitung

📋 So funktioniert die Baustelle-App

1. Aufträge finden

Im Reiter Aufträge siehst du alle offenen Aufträge, die dir zugewiesen sind. Oben kannst du per Suchfeld nach Auftragsnummer oder Kundenname filtern.

Auf einen Auftrag tippen → Detail-Ansicht mit Kunde, Adresse, Telefon.

2. Fotos aufnehmen

Im Auftrag-Detail:

Die Bilder werden automatisch auf 2000px verkleinert und hochgeladen. Sie landen in einem Entwurf-Bericht, der automatisch zum Auftrag angelegt wird.

💡 Alle weiteren Fotos werden an denselben Entwurf angehängt — du hast also einen Bericht mit mehreren Seiten, bis du ihn finalisierst.

3. Fotos bearbeiten (Skizzen)

Tippe auf ein hochgeladenes Foto im Grid → Vollbild-Ansicht.

Im Skizzen-Editor:

Oben rechts → Skizze wird als eigenständige neue Bericht-Seite gespeichert. Das Original-Foto bleibt unverändert.

4. Sprachnotizen

🎙 Sprachnotiz aufnehmen im Auftrag-Detail:

Unter der Foto-Liste erscheint eine eigene Sektion „🎙 Sprachnotizen" mit Play-Button je Eintrag.

5. Berichte finalisieren

Im Reiter Berichte siehst du alle deine Berichte mit Status.

Bericht öffnen → 📑 Bericht finalisieren → erzeugt das PDF und legt es unter „Verknüpfte Dokumente" des Auftrags ab. In Dolibarr siehst du den Bericht dann direkt beim Auftrag.

💡 Wenn du nach dem Finalisieren neue Fotos machst, wird automatisch ein neuer Entwurf angelegt. Der finalisierte bleibt unberührt.

6. Offline arbeiten

Die App funktioniert auch ohne Internet:

7. Auf dem Handy installieren

So wird die PWA zur echten App:

Danach startet sie wie eine normale App, ohne Browser-Leiste.

8. Einstellungen

Im Reiter ⚙️:

`; document.body.appendChild(modal); pushModal(modal); modal.querySelector('#help-close').onclick = () => closeModal(modal); } /* Kurzzeichen für das Seiten-Layout (siehe layout-Spalte in llx_bericht_page). * Ohne das wirkt eine Rasterseite in der Kachel wie ein einzelnes Foto. */ function layoutBadge(layout) { switch (layout) { case 'grid_2': return '⊞ 2'; case 'grid_2v': return '⊞ 2'; case 'grid_4': return '⊞ 4'; case 'grid_6': return '⊞ 6'; case 'before_after': return '↔ V/N'; case 'title_only': return '📄 Titel'; default: return ''; } } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } function docIconFor(filename, mime) { const ext = (filename || '').split('.').pop().toLowerCase(); if ((mime || '').includes('pdf') || ext === 'pdf') return '📕'; if (['doc','docx','odt','rtf'].includes(ext)) return '📝'; if (['xls','xlsx','ods','csv'].includes(ext)) return '📊'; if (['ppt','pptx','odp'].includes(ext)) return '📽'; if (['zip','rar','7z','tar','gz'].includes(ext)) return '🗜'; if (['txt','log','md'].includes(ext)) return '📃'; return '📄'; } function formatFileSize(bytes) { const n = Number(bytes) || 0; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; } function formatShortDate(ts) { const d = new Date(Number(ts) * 1000); if (isNaN(d.getTime())) return ''; return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }); } function openFileViewer({ url, blob, mime }, filename, relpath) { const isImage = (mime || '').startsWith('image/'); const isPdf = (mime || '').includes('pdf') || /\.pdf$/i.test(filename); console.log('[FILE] openFileViewer:', {filename, mime, isPdf, pdfjs: typeof pdfjs, blobSize: blob.size}); if (isPdf && typeof pdfjs !== 'undefined') { console.log('[FILE] → PDF-Viewer aufrufen'); openPdfViewer(blob, filename, relpath); return; } if (isPdf && typeof pdfjs === 'undefined') { console.warn('[FILE] PDF.js ist NICHT geladen!'); showToast('⚠️ PDF.js nicht verfügbar, Download stattdessen', 'error'); } if (!isImage) { // Alles außer Bildern + PDFs: Download console.log('[FILE] → Download-Fallback'); const a = document.createElement('a'); a.href = url; a.download = filename || 'download'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); showToast('⬇ ' + escapeHtml(filename || 'Datei') + ' wird heruntergeladen'); return; } const modal = document.createElement('div'); modal.className = 'fullscreen-modal doc-viewer-modal'; modal.innerHTML = `
${escapeHtml(filename || '')}
`; document.body.appendChild(modal); pushModal(modal, () => { try { URL.revokeObjectURL(url); } catch {} }); modal.querySelector('#dv-close').onclick = () => closeModal(modal); modal.querySelector('#dv-share').onclick = () => { shareFile(blob, filename || 'datei', mime || blob.type, filename); }; } async function openPdfViewer(blob, filename, relpath) { console.log('[PDF] Opening:', filename, 'size:', blob.size, 'type:', blob.type); if (blob.size === 0) { showToast('❌ PDF-Datei ist leer oder nicht gefunden', 'error'); return; } const modal = document.createElement('div'); modal.className = 'fullscreen-modal pdf-viewer-modal'; modal.innerHTML = `
${escapeHtml(filename || '')}
`; document.body.appendChild(modal); pushModal(modal); const canvas = modal.querySelector('#pdf-canvas'); const ctx = canvas.getContext('2d'); const pageInfo = modal.querySelector('#pdf-page-info'); let pdf, currentPage = 1; try { const arrayBuffer = await blob.arrayBuffer(); console.log('[PDF] ArrayBuffer size:', arrayBuffer.byteLength); if (arrayBuffer.byteLength === 0) { throw new Error('PDF-Datei ist leer'); } pdf = await pdfjs.getDocument(new Uint8Array(arrayBuffer)).promise; console.log('[PDF] Loaded:', pdf.numPages, 'pages'); pageInfo.textContent = `1 / ${pdf.numPages}`; async function renderPage(num) { if (num < 1 || num > pdf.numPages) return; currentPage = num; try { const page = await pdf.getPage(num); const viewport = page.getViewport({ scale: window.devicePixelRatio }); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: ctx, viewport }).promise; pageInfo.textContent = `${num} / ${pdf.numPages}`; } catch (e) { console.error('[PDF] Render error on page', num, e); pageInfo.textContent = `❌ Seite ${num} Fehler`; } } await renderPage(1); modal.querySelector('#pdf-prev').onclick = () => renderPage(currentPage - 1); modal.querySelector('#pdf-next').onclick = () => renderPage(currentPage + 1); modal.querySelector('#pdf-download').onclick = async () => { const params = new URLSearchParams({ relpath, download: 1 }); window.location.href = window.location.origin + '/custom/bericht/api/photo.php?' + params.toString(); }; modal.querySelector('#pdf-share').onclick = () => { shareFile(blob, filename || 'dokument.pdf', 'application/pdf', filename); }; modal.querySelector('#pdf-prev').disabled = false; modal.querySelector('#pdf-next').disabled = pdf.numPages === 1; } catch (err) { pageInfo.textContent = '❌ ' + (err.message || 'PDF-Fehler'); console.error('[PDF] Load error:', err); showToast('PDF-Fehler: ' + err.message, 'error'); } modal.querySelector('#pdf-close').onclick = () => { closeModal(modal); }; } /* ============================================================ * PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR * ============================================================ */ async function openPhotoModal(orderId, relpath) { // Alle Bilder im Grid sammeln für Navigation const allThumbs = Array.from(document.querySelectorAll('#photo-grid .thumb[data-relpath]')); const allRelpaths = allThumbs.map(t => t.dataset.relpath); let currentIndex = allRelpaths.indexOf(relpath); if (currentIndex < 0) currentIndex = 0; // Zoom/Pan State let zoom = 1, panX = 0, panY = 0; let currentUrl = null; const touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 }; const modal = document.createElement('div'); modal.className = 'fullscreen-modal photo-viewer-modal'; modal.innerHTML = `
`; document.body.appendChild(modal); pushModal(modal, () => { try { document.removeEventListener('keydown', keyHandler); } catch {} }); const img = modal.querySelector('#pv-image'); const body = modal.querySelector('#pv-body'); const titleEl = modal.querySelector('#pv-title'); const counter = modal.querySelector('#pv-counter'); const prevBtn = modal.querySelector('#pv-prev'); const nextBtn = modal.querySelector('#pv-next'); function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); } function applyTransform() { img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`; } function setZoom(z) { zoom = Math.max(0.5, Math.min(5, z)); constrainPan(); applyTransform(); } function constrainPan() { if (zoom <= 1) { panX = 0; panY = 0; return; } const rect = body.getBoundingClientRect(); const maxX = Math.max(0, (img.naturalWidth * zoom - rect.width) / 2); const maxY = Math.max(0, (img.naturalHeight * zoom - rect.height) / 2); panX = Math.max(-maxX, Math.min(maxX, panX)); panY = Math.max(-maxY, Math.min(maxY, panY)); } async function loadImage(idx) { if (idx < 0 || idx >= allRelpaths.length) return; currentIndex = idx; const rp = allRelpaths[idx]; titleEl.textContent = rp.split('/').pop(); counter.textContent = `${idx + 1} / ${allRelpaths.length}`; prevBtn.disabled = idx === 0; nextBtn.disabled = idx === allRelpaths.length - 1; prevBtn.style.display = nextBtn.style.display = allRelpaths.length > 1 ? '' : 'none'; img.style.opacity = '0.3'; const url = await api.getPhotoBlobUrl(rp); if (url) { currentUrl = url; img.src = url; img.onload = () => { img.style.opacity = '1'; }; } resetZoom(); } function close() { closeModal(modal); } function goPrev() { if (currentIndex > 0) loadImage(currentIndex - 1); } function goNext() { if (currentIndex < allRelpaths.length - 1) loadImage(currentIndex + 1); } // Events modal.querySelector('#fs-close').onclick = close; prevBtn.onclick = (e) => { e.stopPropagation(); goPrev(); }; nextBtn.onclick = (e) => { e.stopPropagation(); goNext(); }; modal.querySelector('#pv-zin').onclick = () => setZoom(zoom + 0.5); modal.querySelector('#pv-zout').onclick = () => setZoom(zoom - 0.5); 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; try { await api.deletePhoto(allRelpaths[currentIndex]); showToast('✓ Gelöscht'); close(); router.navigate(); } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; modal.querySelector('#fs-sketch').onclick = () => { close(); openSketchEditor(orderId, currentUrl, allRelpaths[currentIndex]); }; modal.querySelector('#fs-share').onclick = async () => { const rp = allRelpaths[currentIndex]; showToast('Lade Foto…'); try { const f = await api.getFileBlobUrl(rp); if (!f) { showToast('Foto konnte nicht geladen werden', 'error'); return; } await shareFile(f.blob, rp.split('/').pop(), f.mime, 'Foto vom Auftrag'); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; // Doppelklick = Zoom Toggle img.addEventListener('dblclick', (e) => { e.preventDefault(); if (zoom > 1) resetZoom(); else setZoom(2.5); }); // Touch: Pinch-to-Zoom + Swipe + Pan body.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { const t = e.touches[0]; touchState.startX = t.clientX; touchState.startY = t.clientY; touchState.startPanX = panX; touchState.startPanY = panY; touchState.isDragging = true; touchState.isPinching = false; const now = Date.now(); if (now - touchState.lastTap < 300) { if (zoom > 1) resetZoom(); else setZoom(2.5); touchState.lastTap = 0; } else { touchState.lastTap = now; } } else if (e.touches.length === 2) { e.preventDefault(); touchState.isPinching = true; touchState.isDragging = false; touchState.startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); touchState.startZoom = zoom; } }, { passive: false }); body.addEventListener('touchmove', (e) => { if (touchState.isPinching && e.touches.length === 2) { e.preventDefault(); const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); setZoom(touchState.startZoom * (dist / touchState.startDist)); } else if (touchState.isDragging && e.touches.length === 1) { const t = e.touches[0]; const dx = t.clientX - touchState.startX; const dy = t.clientY - touchState.startY; if (zoom > 1) { e.preventDefault(); panX = touchState.startPanX + dx; panY = touchState.startPanY + dy; constrainPan(); applyTransform(); } } }, { passive: false }); body.addEventListener('touchend', (e) => { if (touchState.isDragging && zoom <= 1 && e.changedTouches.length > 0) { const t = e.changedTouches[0]; const dx = t.clientX - touchState.startX; if (Math.abs(dx) > 50) { if (dx > 0) goPrev(); else goNext(); } } touchState.isDragging = false; touchState.isPinching = false; }); // Tastatur const keyHandler = (e) => { if (e.key === 'Escape') close(); if (e.key === 'ArrowLeft') goPrev(); if (e.key === 'ArrowRight') goNext(); }; document.addEventListener('keydown', keyHandler); // Erstes Bild laden await loadImage(currentIndex); } async function openVoiceModal(orderId) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal voice-modal'; modal.innerHTML = `
🎙 Sprachnotiz
00:00
`; document.body.appendChild(modal); let mediaRecorder = null; let chunks = []; let timer = null; let startTime = 0; let audioBlob = null; pushModal(modal, () => { try { if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); } catch {} try { if (timer) clearInterval(timer); } catch {} }); const startBtn = modal.querySelector('#v-start'); const stopBtn = modal.querySelector('#v-stop'); const sendBtn = modal.querySelector('#v-send'); const indicator = modal.querySelector('#v-indicator'); const timeEl = modal.querySelector('#v-time'); const preview = modal.querySelector('#v-preview'); modal.querySelector('#v-close').onclick = () => closeModal(modal); startBtn.onclick = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); chunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = e => chunks.push(e.data); mediaRecorder.onstop = () => { stream.getTracks().forEach(t => t.stop()); audioBlob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' }); preview.src = URL.createObjectURL(audioBlob); preview.style.display = ''; sendBtn.style.display = ''; indicator.classList.remove('recording'); }; mediaRecorder.start(); startTime = Date.now(); indicator.classList.add('recording'); startBtn.style.display = 'none'; stopBtn.style.display = ''; timer = setInterval(() => { const s = Math.floor((Date.now() - startTime) / 1000); timeEl.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }, 500); } catch (e) { showToast('Mikrofon-Zugriff verweigert', 'error'); } }; stopBtn.onclick = () => { if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); if (timer) clearInterval(timer); stopBtn.style.display = 'none'; }; sendBtn.onclick = async () => { if (!audioBlob) return; sendBtn.disabled = true; try { await api.uploadVoiceNote(orderId, audioBlob, 'voice_' + Date.now() + '.webm'); showToast('✓ Sprachnotiz hochgeladen'); closeModal(modal); } catch (e) { showToast('Upload fehlgeschlagen: ' + e.message, 'error'); sendBtn.disabled = false; } }; } /* ============================================================ * LIVE-KAMERA — Schnellschuss + Filmstreifen * Bleibt im Kamera-Modus: mehrere Fotos ohne Rauswerfen. * Jedes Foto wird SOFORT persistent in die Offline-Queue geschrieben * (kein Datenverlust) und dann im Hintergrund hochgeladen. * ============================================================ */ async function openCameraModal(orderId) { // Kein getUserMedia (alter Browser / unsicherer Kontext)? → System-Kamera nutzen if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { const ci = document.getElementById('camera-input'); if (ci) ci.click(); return; } const modal = document.createElement('div'); modal.className = 'fullscreen-modal camera-modal'; modal.innerHTML = `
📷 Kamera
Kamera wird gestartet…
`; document.body.appendChild(modal); const video = modal.querySelector('#cam-video'); const strip = modal.querySelector('#cam-strip'); const hint = modal.querySelector('#cam-hint'); const countEl = modal.querySelector('#cam-count'); const shutter = modal.querySelector('#cam-shutter'); let stream = null; let facing = 'environment'; let shots = 0; let gen = 0; // Generations-Token: entwertet in-flight getUserMedia-Aufrufe (Schließen/Flip) function stopStream() { if (stream) { try { stream.getTracks().forEach(t => t.stop()); } catch (_) {} stream = null; } try { video.srcObject = null; } catch (_) {} } // Upload bestätigt → passende Miniatur auf „fertig" setzen und relpath merken (für Löschen) function onUploaded(e) { const d = e.detail || {}; const thumb = strip.querySelector('.cam-thumb[data-qid="' + d.queueId + '"]'); if (!thumb) return; if (d.relpath) thumb.dataset.relpath = d.relpath; thumb.classList.remove('pending', 'saving'); thumb.classList.add('done'); // Server hatte das Bild schon (Wiederholung nach Abbruch): kein Fehler, nur Hinweis if (d.duplicate) thumb.title = 'War schon hochgeladen'; } window.addEventListener('photo-uploaded', onUploaded); // Aufräumen bei Schließen / Android-Back: Kamera aus, Listener weg, Blob-URLs freigeben, // Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos). pushModal(modal, () => { gen++; // entwertet einen evtl. noch laufenden getUserMedia-Aufruf stopStream(); window.removeEventListener('photo-uploaded', onUploaded); strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} }); // Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip) if (shots > 0) { try { router.navigate(); } catch (_) {} } }); modal.querySelector('#cam-close').onclick = () => closeModal(modal); modal.querySelector('#cam-done').onclick = () => closeModal(modal); modal.querySelector('#cam-flip').onclick = () => { facing = (facing === 'environment') ? 'user' : 'environment'; startStream(); }; function updateCount() { countEl.textContent = shots ? (shots + (shots === 1 ? ' Foto' : ' Fotos')) : ''; } async function startStream() { const my = ++gen; stopStream(); hint.style.display = ''; hint.textContent = 'Kamera wird gestartet…'; try { const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing }, width: { ideal: 3840 }, height: { ideal: 2160 } }, audio: false, }); // Inzwischen geschlossen oder neuer Start (Flip) angefordert? → diesen Stream sofort beenden, // damit keine verwaiste Kamera (LED an) zurückbleibt. if (my !== gen || !document.body.contains(modal)) { try { s.getTracks().forEach(t => t.stop()); } catch (_) {} return; } stream = s; video.srcObject = stream; await video.play().catch(() => {}); hint.style.display = 'none'; } catch (err) { if (my !== gen || !document.body.contains(modal)) return; // abgebrochen, nicht mehr relevant console.warn('[Kamera]', err); showToast('Kamera nicht verfügbar — nutze System-Kamera', 'warn'); closeModal(modal); const ci = document.getElementById('camera-input'); if (ci) ci.click(); } } function addThumb(url) { const t = document.createElement('div'); t.className = 'cam-thumb saving'; t.innerHTML = '' + ''; // Tipp auf Miniatur → Löschen-Overlay ein/aus t.querySelector('img').onclick = () => t.classList.toggle('confirm-del'); t.querySelector('.cam-thumb-del').onclick = (ev) => { ev.stopPropagation(); deleteThumb(t); }; strip.prepend(t); return t; } async function deleteThumb(t) { if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz const relpath = t.dataset.relpath; const qid = t.dataset.qid; 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; } t.dataset.deleting = '1'; const delBtn = t.querySelector('.cam-thumb-del'); if (delBtn) delBtn.disabled = true; try { if (relpath) { await api.deletePhoto(relpath); // schon hochgeladen → serverseitig löschen } else { await idb.queueDelete(Number(qid)); // noch in Queue → aus Queue entfernen await offline.updateBadge(); } try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {} t.remove(); shots = Math.max(0, shots - 1); updateCount(); showToast('Foto gelöscht'); } catch (e) { t.dataset.deleting = ''; if (delBtn) delBtn.disabled = false; showToast('Löschen fehlgeschlagen: ' + (e.message || ''), 'error'); } } /* Echtes Kamerafoto in voller Sensoraufloesung. * * Ein Standbild aus dem Video-Stream hat hoechstens Stream-Aufloesung (hier bis 4K * angefordert, real oft weniger) — die Kamera selbst kann ein Vielfaches. Auf * Typenschildern und Zaehlerstaenden war deshalb wenig zu erkennen. ImageCapture * .takePhoto() loest dagegen richtig aus und liefert die native JPEG-Datei der * Kamera, inklusive EXIF. Kann der Browser das nicht (iOS/Safari, aeltere Stände), * bleibt das Standbild als Rueckfallebene. */ async function grabFullResolution() { try { if (typeof ImageCapture === 'undefined' || !stream) return null; const track = stream.getVideoTracks()[0]; if (!track || typeof track.getCapabilities !== 'function') return null; const cap = new ImageCapture(track); const blob = await cap.takePhoto(); return (blob && blob.size > 0) ? blob : null; } catch (_) { return null; // z.B. „takePhoto not supported" — Standbild nehmen } } async function capture() { if (!stream || !video.videoWidth) { showToast('Kamera noch nicht bereit', 'warn'); return; } shutter.classList.add('flash'); setTimeout(() => shutter.classList.remove('flash'), 150); shots++; updateCount(); let raw = await grabFullResolution(); if (!raw) { // Rueckfallebene: Standbild aus dem laufenden Stream const c = document.createElement('canvas'); c.width = video.videoWidth; c.height = video.videoHeight; c.getContext('2d').drawImage(video, 0, 0, c.width, c.height); raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.95)); } if (!raw) { shots = Math.max(0, shots - 1); updateCount(); showToast('Foto fehlgeschlagen', 'error'); return; } const thumb = addThumb(URL.createObjectURL(raw)); try { // Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto) const name = 'foto_' + Date.now() + '_' + shots + '.jpg'; const qid = await offline.enqueuePhoto(orderId, raw, name); if (photoCfg.photo_maxside > 0) { try { const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100); if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small); } catch (_) { /* Original bleibt gesichert */ } } thumb.dataset.qid = qid; thumb.classList.remove('saving'); thumb.classList.add('pending'); offline.syncQueue(); // im Hintergrund hochladen (löscht nur bei bestätigtem Erfolg) } catch (e) { thumb.classList.remove('saving'); thumb.classList.add('error'); const quota = e && (e.name === 'QuotaExceededError' || /quota/i.test(e.message || '')); showToast(quota ? '⚠ Speicher voll — bitte erst vorhandene Fotos hochladen, dann erneut aufnehmen' : ('Foto sichern fehlgeschlagen: ' + (e.message || '')), 'error'); } } shutter.onclick = capture; startStream(); } /* ============================================================ * UPLOAD-WARTESCHLANGE — Recovery-UI (per Klick auf das Status-Badge) * Zeigt offene und fehlgeschlagene Uploads, erlaubt erneutes Senden und * das Retten fehlgeschlagener Fotos per Teilen/Speichern. So ist ein Foto * NIE in einer stillen Sackgasse. * ============================================================ */ async function openUploadQueueModal() { const items = await offline.listQueue().catch(() => []); if (!items.length) { showToast(navigator.onLine ? 'Alle Fotos hochgeladen ✓' : 'Keine offenen Uploads'); return; } const pending = items.filter(i => !i.failed); const failed = items.filter(i => i.failed); const modal = document.createElement('div'); modal.className = 'fullscreen-modal queue-modal'; modal.innerHTML = `
☁ Upload-Warteschlange

${pending.length} warten auf Upload${failed.length ? ', ' + failed.length + ' fehlgeschlagen' : ''}.

${failed.length ? '' : ''} ${failed.length ? '' : ''}

Fotos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.

`; document.body.appendChild(modal); pushModal(modal); modal.querySelector('#q-close').onclick = () => closeModal(modal); modal.querySelector('#q-sync').onclick = () => { showToast('Synchronisiere…'); offline.syncQueue(); closeModal(modal); }; const retryBtn = modal.querySelector('#q-retry'); if (retryBtn) retryBtn.onclick = async () => { await offline.retryFailed(); showToast('Erneut senden gestartet…'); closeModal(modal); }; const shareBtn = modal.querySelector('#q-share'); if (shareBtn) shareBtn.onclick = async () => { const files = failed.map(it => ({ blob: new Blob([it.data], { type: it.mime || 'image/jpeg' }), filename: it.filename || 'foto.jpg', mime: it.mime || 'image/jpeg', })); if (!files.length) return; await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos'); }; } // Status-Badge in der Kopfzeile klickbar machen → Upload-Warteschlange öffnen (function wireStatusBadge() { const badge = document.getElementById('status-badge'); if (badge) { badge.style.cursor = 'pointer'; badge.title = 'Upload-Warteschlange anzeigen'; badge.addEventListener('click', () => openUploadQueueModal()); } })(); /* ============================================================ * SKIZZEN-EDITOR (Touch-fähig, einfache Vektor-Zeichnung) * ============================================================ */ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal sketch-modal'; modal.innerHTML = `
✏️ Skizze
`; document.body.appendChild(modal); pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvasToScreen); } catch {} }); const canvas = modal.querySelector('#sk-canvas'); const ctx = canvas.getContext('2d'); // Bild laden const img = new Image(); img.crossOrigin = 'anonymous'; img.src = imageUrl; await new Promise(res => { img.onload = res; }); // Canvas auf Bildgröße (max 1600px) const maxSide = 1600; const scale = Math.min(1, maxSide / Math.max(img.width, img.height)); canvas.width = img.width * scale; canvas.height = img.height * scale; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // Canvas-Display: in den Container einpassen function fitCanvasToScreen() { const body = modal.querySelector('.sketch-body'); const rect = body.getBoundingClientRect(); const s = Math.min(rect.width / canvas.width, rect.height / canvas.height); canvas.style.width = (canvas.width * s) + 'px'; canvas.style.height = (canvas.height * s) + 'px'; } fitCanvasToScreen(); window.addEventListener('resize', fitCanvasToScreen); // State let tool = 'pen'; let stampChar = null; let color = '#ff0000'; let lineWidth = 5; let drawing = false; let startX = 0, startY = 0; const history = [canvas.toDataURL()]; // Mess-Skala: pixelsPerUnit + unit (z. B. 32.5 px/cm) let calibration = null; // { pxPerUnit: number, unit: 'cm'|'m'|'mm' } let calibStep = 0; // 0 = nicht im Prozess, 1 = warte auf zweiten Punkt function pushHistory() { history.push(canvas.toDataURL()); if (history.length > 20) history.shift(); } async function restoreSnapshot(dataUrl) { return new Promise(res => { const i = new Image(); i.onload = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(i, 0, 0); res(); }; i.src = dataUrl; }); } modal.querySelectorAll('.sk-tool').forEach(b => { b.addEventListener('click', () => { modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active')); b.classList.add('active'); tool = b.dataset.tool; stampChar = b.dataset.stamp || null; }); }); modal.querySelector('#sk-color').oninput = e => { color = e.target.value; }; modal.querySelector('#sk-width').oninput = e => { lineWidth = parseInt(e.target.value, 10); }; modal.querySelector('#sk-undo').onclick = async () => { if (history.length > 1) { history.pop(); await restoreSnapshot(history[history.length - 1]); } }; modal.querySelector('#sk-clear').onclick = async () => { await restoreSnapshot(history[0]); history.length = 1; }; function getPos(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const t = e.touches ? e.touches[0] : e; return { x: (t.clientX - rect.left) * scaleX, y: (t.clientY - rect.top) * scaleY }; } let snapshotBeforeShape = null; function startDraw(e) { e.preventDefault(); const p = getPos(e); startX = p.x; startY = p.y; // Stamps sind ein Single-Click-Tool (kein Drag) if (tool === 'stamp' && stampChar) { ctx.fillStyle = color; ctx.strokeStyle = '#fff'; ctx.lineWidth = 4; ctx.font = 'bold 96px -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.strokeText(stampChar, p.x, p.y); ctx.fillText(stampChar, p.x, p.y); pushHistory(); return; } // Calibrate: Drag-Line zwischen zwei Punkten, dann Länge eingeben if (tool === 'calibrate' || tool === 'measure') { drawing = true; snapshotBeforeShape = canvas.toDataURL(); ctx.strokeStyle = (tool === 'calibrate') ? '#f0ad4e' : '#5cb85c'; ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = Math.max(3, lineWidth); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; return; } drawing = true; ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (tool === 'pen') { ctx.beginPath(); ctx.moveTo(startX, startY); } else { snapshotBeforeShape = canvas.toDataURL(); } } function moveDraw(e) { if (!drawing) return; e.preventDefault(); const p = getPos(e); lastMoveX = p.x; lastMoveY = p.y; if (tool === 'calibrate' || tool === 'measure') { restoreSnapshot(snapshotBeforeShape).then(() => { const col = (tool === 'calibrate') ? '#f0ad4e' : '#5cb85c'; ctx.strokeStyle = col; ctx.fillStyle = col; ctx.lineWidth = Math.max(3, lineWidth); ctx.lineCap = 'round'; // Mess-Linie ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(p.x, p.y); ctx.stroke(); // End-Punkte als kleine Kreuze drawCross(ctx, startX, startY, 10); drawCross(ctx, p.x, p.y, 10); // Live-Distanz bei measure if (tool === 'measure' && calibration) { const dx = p.x - startX, dy = p.y - startY; const dist_px = Math.sqrt(dx * dx + dy * dy); const real = (dist_px / calibration.pxPerUnit); const mid_x = (startX + p.x) / 2; const mid_y = (startY + p.y) / 2 - 15; drawDistanceLabel(ctx, mid_x, mid_y, real, calibration.unit); } }); return; } if (tool === 'pen') { ctx.lineTo(p.x, p.y); ctx.stroke(); } else if (snapshotBeforeShape) { // Shape-Tools: vor jedem Draw den Snapshot wiederherstellen und neu zeichnen restoreSnapshot(snapshotBeforeShape).then(() => { ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; if (tool === 'rect') { ctx.strokeRect(startX, startY, p.x - startX, p.y - startY); } else if (tool === 'circle') { const rx = Math.abs(p.x - startX) / 2; const ry = Math.abs(p.y - startY) / 2; const cx = (startX + p.x) / 2; const cy = (startY + p.y) / 2; ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); ctx.stroke(); } else if (tool === 'arrow') { ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(p.x, p.y); ctx.stroke(); // Pfeilspitze const angle = Math.atan2(p.y - startY, p.x - startX); const head = Math.max(12, lineWidth * 3); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - head * Math.cos(angle - Math.PI / 6), p.y - head * Math.sin(angle - Math.PI / 6)); ctx.lineTo(p.x - head * Math.cos(angle + Math.PI / 6), p.y - head * Math.sin(angle + Math.PI / 6)); ctx.closePath(); ctx.fill(); } }); } } async function endDraw(e) { if (!drawing) return; drawing = false; const tool_now = tool; if (tool_now === 'calibrate') { // Länge vom User abfragen const p = e && (e.changedTouches ? { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY } : { x: e.clientX, y: e.clientY }); // fallback: aktuelle endposition haben wir nicht mehr exakt — wir nutzen die letzte gezeichnete position const dx_px = lastMoveX - startX, dy_px = lastMoveY - startY; const dist_px = Math.sqrt(dx_px * dx_px + dy_px * dy_px); if (dist_px < 5) { // zu kurz await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; 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', }); if (!input) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; return; } const m = input.match(/^\s*([0-9]+(?:[.,][0-9]+)?)\s*(mm|cm|m)?\s*$/i); if (!m) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; await alertDialog({ title: 'Eingabe nicht verstanden', text: 'Bitte Zahl und Einheit angeben, z. B. 1.5 m' }); return; } const val = parseFloat(m[1].replace(',', '.')); const unit = (m[2] || 'cm').toLowerCase(); // Umrechnung auf mm intern? Nein: pxPerUnit in der eingegebenen Einheit halten calibration = { pxPerUnit: dist_px / val, unit }; showToast('✓ Kalibriert: ' + val + ' ' + unit + ' = ' + dist_px.toFixed(0) + ' px'); // Kalibrierungslinie entfernen (zeichnen war nur Helper) await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; // Automatisch auf Measure-Tool wechseln const mt = document.querySelector('.sk-tool[data-tool="measure"]'); if (mt) mt.click(); return; } if (tool_now === 'measure') { 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.', }); return; } // Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis snapshotBeforeShape = null; pushHistory(); return; } snapshotBeforeShape = null; pushHistory(); } // Track der letzten Move-Position für calibrate-endDraw let lastMoveX = 0, lastMoveY = 0; function drawCross(ctx, x, y, size) { ctx.save(); ctx.beginPath(); ctx.moveTo(x - size / 2, y); ctx.lineTo(x + size / 2, y); ctx.moveTo(x, y - size / 2); ctx.lineTo(x, y + size / 2); ctx.stroke(); ctx.restore(); } function drawDistanceLabel(ctx, x, y, value, unit) { const text = value.toFixed(2) + ' ' + unit; ctx.save(); ctx.font = 'bold 28px -apple-system, sans-serif'; const w = ctx.measureText(text).width + 16; const h = 36; ctx.fillStyle = 'rgba(0,0,0,0.75)'; ctx.fillRect(x - w / 2, y - h / 2, w, h); ctx.fillStyle = '#5cb85c'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, x, y); ctx.restore(); } canvas.addEventListener('mousedown', startDraw); canvas.addEventListener('mousemove', moveDraw); canvas.addEventListener('mouseup', endDraw); canvas.addEventListener('mouseleave', endDraw); canvas.addEventListener('touchstart', startDraw, { passive: false }); canvas.addEventListener('touchmove', moveDraw, { passive: false }); canvas.addEventListener('touchend', endDraw); modal.querySelector('#sk-close').onclick = () => closeModal(modal); modal.querySelector('#sk-save').onclick = async () => { showToast('Speichere Skizze…'); canvas.toBlob(async (blob) => { try { await api.uploadAnnotatedPhoto(orderId, blob, 'sketch_' + Date.now() + '.jpg'); showToast('✓ Skizze gespeichert'); closeModal(modal); router.navigate(); } catch (e) { showToast('Upload fehlgeschlagen: ' + e.message, 'error'); } }, '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__.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 = ` `; 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 = '
📝
Noch keine Notiz
'; return; } listEl.innerHTML = data.notes.map(n => `
${escapeHtml(n.subject || '(ohne Betreff)')}
${escapeHtml(n.preview || '')}
${escapeHtml(n.modified || n.created || formatShortDate(n.date))}
`).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 = '
⚠️
' + escapeHtml(e.message) + '
'; } } 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 = `
📝 ${file ? 'Notiz bearbeiten' : 'Neue Notiz'}
${existing && existing.created ? `
Erfasst: ${escapeHtml(existing.created)}${existing.modified ? ' · Geändert: ' + escapeHtml(existing.modified) : ''}
` : ''}
`; 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); }