/* 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'; btn.onclick = () => { if (hash) router.go(hash); else history.back(); }; } /* ----- Auth-Check ----- */ async function ensureAuth() { const t = await api.getToken(); if (!t) { 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(() => { modal.remove(); 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 = () => { modal.remove(); resolve(null); }; modal.querySelector('.pin-body').appendChild(cancel); document.body.appendChild(modal); }); } 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) { alert('PINs stimmen nicht überein'); return null; } return p1; } /** * Startet die App — fragt ggf. PIN ab bevor router läuft. */ window.appBoot = async function appBoot() { // JWT aktiv aus IndexedDB preloaden, bevor eine Route rennt. // Verhindert Race-Conditions, in denen ensureAuth() zu früh ein `null` bekommt // und nach Login-Screen redirectet, obwohl ein gültiges Token in IDB liegt. try { const t = await api.getToken(); console.log('[boot] jwt vorhanden:', !!t); } catch (e) { console.warn('[boot] Token-Preload fehlgeschlagen', e); } // 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 = ''; } /* ====== ROUTES ====== */ router.on('/login', async () => { title('Anmelden'); setNav(false); setBack(false); main().innerHTML = `

🔧 Baustelle

`; 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'); } }); }); 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 = await api.listOrders({ open: 1 }); 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 = '
⚠️
' + e.message + '
'; } }); 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)); }); } try { const data = await loadOrders(); 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(); document.getElementById('order-search').addEventListener('input', async (e) => { const q = e.target.value; const d = await loadOrders(q); document.getElementById('order-list').innerHTML = renderOrderList(d.orders); bindOrderCards(); }); document.getElementById('show-all-toggle').addEventListener('change', async (e) => { showAllOrders = e.target.checked; localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0'); const q = document.getElementById('order-search').value; const d = await loadOrders(q); document.getElementById('order-list').innerHTML = renderOrderList(d.orders); bindOrderCards(); }); } catch (e) { main().innerHTML = '
⚠️
' + e.message + '
'; } }); 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 { const data = await api.getOrder(args.id); const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] })); 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)); 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.map(p => `
`).join('')}
${audioFiles.length ? `

🎙 Sprachnotizen (${audioFiles.length})

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

Weitere Dokumente (${otherDocs.length})

${otherDocs.map(p => `

📄 ${escapeHtml(p.filename)}

`).join('')}
` : ''} `; // Foto-Thumbnails: Tap = Vollbild-Modal document.querySelectorAll('#photo-grid .thumb').forEach(t => { t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath)); }); // Sprachnotiz document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id); // Neuen Bericht anlegen document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id); // Materialliste document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id); // 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 t = await api.getToken(); const params = new URLSearchParams({ relpath: rel, jwt: t }); const r = await fetch(window.location.origin + '/custom/bericht/api/photo.php?' + params.toString()); 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'); document.getElementById('btn-take-photo').onclick = () => camInput.click(); document.getElementById('btn-pick-photo').onclick = () => galInput.click(); async function handleFiles(files) { for (const f of files) { await uploadPhoto(args.id, f); } // Reload Photo-Liste try { const np = await api.listOrderPhotos(args.id); const imgs = np.photos.filter(p => (p.mime || '').startsWith('image/')); document.getElementById('photo-grid').innerHTML = imgs.map(p => `
`).join(''); loadThumbs(); } catch (e) {} } camInput.addEventListener('change', () => handleFiles(camInput.files)); galInput.addEventListener('change', () => handleFiles(galInput.files)); } catch (e) { main().innerHTML = '
⚠️
' + e.message + '
'; } }); async function uploadPhoto(orderId, file) { showToast('Optimiere & sende ' + file.name); const blob = await resizeImage(file, 2000); if (!navigator.onLine) { await offline.enqueuePhoto(orderId, blob, file.name); showToast('Offline — Foto in Queue', 'warn'); return; } try { await api.uploadOrderPhoto(orderId, blob, file.name); showToast('✓ ' + file.name + ' hochgeladen'); } catch (e) { await offline.enqueuePhoto(orderId, blob, file.name); showToast('Upload fehlgeschlagen — in Queue', 'error'); } } async function resizeImage(file, maxSide) { return 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', 0.85); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; img.src = url; }); } /** * Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid. * Nutzt /api/photo.php mit JWT (Header kann nicht schicken, * deshalb Blob-URLs). */ async function loadThumbs() { const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]'); for (const t of thumbs) { const rel = t.dataset.relpath; try { // Erst Thumbnail versuchen (_small), bei Misserfolg das Original let url = await api.getPhotoBlobUrl(rel, 'small'); if (!url) url = await api.getPhotoBlobUrl(rel); if (url) { t.innerHTML = ''; } else { t.innerHTML = '
'; } } catch (e) { t.innerHTML = '
'; } } } router.on('/customers', async () => { if (!(await ensureAuth())) return; title('Kunden'); setNav(true, 'customers'); setBack(false); showLoader('Lade Kunden…'); try { const data = await api.listCustomers(); if (!data.customers.length) { main().innerHTML = '
👥
Keine Kunden
'; return; } main().innerHTML = `
${renderCustomerList(data.customers)}
`; bindCustomerCards(); document.getElementById('cust-search').addEventListener('input', async (e) => { const d = await api.listCustomers({ q: e.target.value }); document.getElementById('cust-list').innerHTML = renderCustomerList(d.customers); bindCustomerCards(); }); } catch (e) { main().innerHTML = '
⚠️
' + e.message + '
'; } }); 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 data = await api.getCustomer(args.id); 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 = '
⚠️
' + e.message + '
'; } }); 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 = await api.listReports(); 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 = '
⚠️
' + e.message + '
'; } }); 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) => `
${i + 1}
${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; } openPdfModal(url); }; // 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; } if (!confirm(data.report.status === 1 ? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?' : 'Bericht jetzt finalisieren und PDF erzeugen?')) return; showToast('PDF wird erzeugt…'); try { const r = await api.finalizeReport(args.id); 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); modal.querySelector('#dr-cancel').onclick = () => modal.remove(); modal.querySelector('#dr-no').onclick = () => modal.remove(); modal.querySelector('#dr-yes').onclick = async () => { try { await api.deleteReport(args.id); modal.remove(); showToast('Bericht gelöscht'); router.go('#/reports'); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }; }; } catch (e) { main().innerHTML = '
⚠️
' + e.message + '
'; } }); 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'); 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 { if (!confirm('PIN-Schutz wirklich deaktivieren?')) { 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'); }; }); /* 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); }); /** * 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); modal.querySelector('#mt-close').onclick = () => modal.remove(); 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 (!confirm('Eintrag löschen?')) 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); // 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 = () => modal.remove(); 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'); modal.remove(); 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); 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 changeBtn = modal.querySelector('#no-change-customer'); let selectedCustomer = null; closeBtn.onclick = () => modal.remove(); /* ---- 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; titleIn.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 title = titleIn.value.trim(); if (!title) { showToast('Titel ist Pflicht', 'error'); titleIn.focus(); return; } saveBtn.disabled = true; showToast('Lege Auftrag an…'); try { const res = await api.createOrder({ socid: selectedCustomer.id, title: title, ref_client: refIn.value.trim(), }); // 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) {} showToast('✓ Auftrag ' + (res.order.ref || '') + ' angelegt'); modal.remove(); 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); modal.querySelector('#pa-close').onclick = () => modal.remove(); 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'); modal.remove(); router.navigate(); } catch (e) { // Fallback falls api.request nicht exposed ist try { await api.updatePageNote(pageId, modal.querySelector('#pa-note').value); showToast('✓ Notiz gespeichert'); modal.remove(); router.navigate(); } catch (er) { showToast('Fehler: ' + er.message, 'error'); } } }; modal.querySelector('#pa-delete').onclick = async () => { if (!confirm('Diese Seite aus dem Bericht entfernen?')) return; try { await api.deletePage(pageId); showToast('✓ Seite entfernt'); modal.remove(); router.navigate(); } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; } /* ============================================================ * PDF-VORSCHAU MODAL * ============================================================ */ function openPdfModal(blobUrl) { const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
📑 PDF-Vorschau
`; document.body.appendChild(modal); modal.querySelector('#pdf-close').onclick = () => { URL.revokeObjectURL(blobUrl); modal.remove(); }; } /* ============================================================ * 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); 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 = () => { window.removeEventListener('resize', fitCanvas); modal.remove(); }; 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'); modal.remove(); router.navigate(); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } }, '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); modal.querySelector('#help-close').onclick = () => modal.remove(); } function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } /* ============================================================ * 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); 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() { modal.remove(); document.removeEventListener('keydown', keyHandler); } 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 (!confirm('Foto wirklich löschen?')) 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]); }; // 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; 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 = () => { if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); if (timer) clearInterval(timer); modal.remove(); }; 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'); modal.remove(); } catch (e) { showToast('Upload fehlgeschlagen: ' + e.message, 'error'); sendBtn.disabled = false; } }; } /* ============================================================ * 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); 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 = prompt('Reale Länge dieser Strecke:\nFormat: Zahl + Einheit (z. B. "50 cm", "1.5 m", "250 mm")'); if (!input) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; return; } const m = input.match(/^\s*([0-9]+(?:[.,][0-9]+)?)\s*(mm|cm|m)?\s*$/i); if (!m) { await restoreSnapshot(snapshotBeforeShape); snapshotBeforeShape = null; alert('Ungültiges Format. Beispiel: 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; alert('Zuerst kalibrieren! Tap auf 📏⚙, zwei Punkte mit bekannter Länge wählen.'); return; } // Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis 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 = () => { window.removeEventListener('resize', fitCanvasToScreen); modal.remove(); }; 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'); modal.remove(); router.navigate(); } catch (e) { showToast('Upload fehlgeschlagen: ' + e.message, 'error'); } }, 'image/jpeg', 0.9); }; }