/* 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); }); } 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; } /* ====== 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('/orders', async () => { if (!(await ensureAuth())) return; title('Aufträge'); setNav(true, 'orders'); setBack(false); showLoader('Lade Aufträge…'); try { const data = await api.listOrders({ open: 1 }); if (!data.orders.length) { main().innerHTML = '
📭
Keine offenen Aufträge
'; return; } const html = `
${renderOrderList(data.orders)}
`; main().innerHTML = html; document.querySelectorAll('.order-card').forEach(c => { c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); }); document.getElementById('order-search').addEventListener('input', async (e) => { const q = e.target.value; const d = await api.listOrders({ q, open: 1 }); document.getElementById('order-list').innerHTML = renderOrderList(d.orders); document.querySelectorAll('.order-card').forEach(c => { c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); }); }); } catch (e) { main().innerHTML = '
⚠️
' + e.message + '
'; } }); function renderOrderList(orders) { return orders.map(o => `
${escapeHtml(o.ref)}
${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); // 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('/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}
`).join('')}
` : '
📭
Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.
'} ${hasPages ? '' : ''} `; loadThumbs(); // Tap auf Report-Page-Thumb → Seiten-Aktionen-Modal document.querySelectorAll('.report-page-thumb').forEach(t => { t.addEventListener('click', () => openPageActionsModal(args.id, t.dataset.pageId, t.dataset.relpath, t.dataset.note || '')); }); // 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'); }; } 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') || {}; main().innerHTML = `

Konto

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

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

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(); router.go('#/login'); }; }); /* 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); }); /* ============================================================ * SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild) * ============================================================ */ async function openPageActionsModal(berichtId, pageId, relpath, note) { 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-note').onclick = async () => { try { await api.updatePageNote(pageId, modal.querySelector('#pa-note').value); showToast('✓ Notiz gespeichert'); modal.remove(); router.navigate(); } catch (e) { showToast('Fehler: ' + e.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 = () => { showToast('Speichere Unterschrift…'); canvas.toBlob(async (blob) => { try { await api.uploadSignature(berichtId, blob); 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 + SKIZZEN-EDITOR * ============================================================ */ async function openPhotoModal(orderId, relpath) { const url = await api.getPhotoBlobUrl(relpath); if (!url) { showToast('Foto konnte nicht geladen werden', 'error'); return; } const modal = document.createElement('div'); modal.className = 'fullscreen-modal'; modal.innerHTML = `
${escapeHtml(relpath.split('/').pop())}
`; document.body.appendChild(modal); const close = () => modal.remove(); modal.querySelector('#fs-close').onclick = close; modal.querySelector('#fs-delete').onclick = async () => { if (!confirm('Foto wirklich löschen?')) return; try { await api.deletePhoto(relpath); showToast('✓ Gelöscht'); close(); // Parent view neu laden router.navigate(); } catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); } }; modal.querySelector('#fs-sketch').onclick = () => { close(); openSketchEditor(orderId, url, relpath); }; } 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 color = '#ff0000'; let lineWidth = 5; let drawing = false; let startX = 0, startY = 0; const history = [canvas.toDataURL()]; 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; }); }); 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(); drawing = true; const p = getPos(e); startX = p.x; startY = p.y; 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); 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(); } }); } } function endDraw() { if (!drawing) return; drawing = false; snapshotBeforeShape = null; pushHistory(); } 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); }; }