diff --git a/app.css b/app.css index 235fc20..8268f6f 100644 --- a/app.css +++ b/app.css @@ -253,3 +253,155 @@ body { padding: 40px; opacity: 0.6; } + +.status-draft { + background: #6c757d; + color: #fff; + font-size: 10px; + padding: 2px 6px; + border-radius: 8px; + margin-left: 6px; + vertical-align: middle; +} +.status-final { + background: #5cb85c; + color: #fff; + font-size: 10px; + padding: 2px 6px; + border-radius: 8px; + margin-left: 6px; + vertical-align: middle; +} + +/* ============================================================ + * Fullscreen Modals (Foto-Vollbild, Voice, Sketch) + * ============================================================ */ +.fullscreen-modal { + position: fixed; + inset: 0; + background: #000; + z-index: 1000; + display: flex; + flex-direction: column; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); +} +.fs-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: rgba(0,0,0,0.7); + color: #fff; + border-bottom: 1px solid #222; +} +.fs-header .fs-title { + flex: 1; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.fs-header .icon-btn { color: #fff; } +.fs-body { + flex: 1; + overflow: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; +} +.fs-body img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* Voice Modal */ +.voice-modal .voice-body { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + gap: 16px; + color: #fff; +} +.voice-indicator { + font-size: 80px; + color: #444; + transition: color 0.3s; +} +.voice-indicator.recording { + color: #d9534f; + animation: pulse 1s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.voice-time { + font-size: 32px; + font-variant-numeric: tabular-nums; + color: #7aa2f7; +} + +/* Sketch Modal */ +.sketch-modal .sketch-body { + flex: 1; + background: #222; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.sketch-modal .sketch-body canvas { + background: #fff; + box-shadow: 0 0 20px rgba(0,0,0,0.5); + touch-action: none; +} +.sketch-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + background: #1a1a1f; + border-bottom: 1px solid #333; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} +.sketch-toolbar button { + background: #2a2a30; + color: #fff; + border: 1px solid #444; + border-radius: 6px; + padding: 8px 12px; + font-size: 16px; + cursor: pointer; + flex-shrink: 0; +} +.sketch-toolbar button.active { + background: #337ab7; + border-color: #2868a0; +} +.sketch-toolbar .sep { + width: 1px; + background: #444; + height: 24px; + margin: 0 6px; + flex-shrink: 0; +} +.sketch-toolbar input[type="color"] { + width: 40px; + height: 36px; + border: 1px solid #444; + background: #2a2a30; + border-radius: 6px; + padding: 2px; + flex-shrink: 0; +} +.sketch-toolbar input[type="range"] { + width: 100px; + flex-shrink: 0; +} diff --git a/app.js b/app.js index d3f9d6c..acb5267 100644 --- a/app.js +++ b/app.js @@ -147,6 +147,7 @@ router.on('/orders/:id', async (args) => { +

Hochgeladene Fotos (${imagePhotos.length})

@@ -161,6 +162,14 @@ router.on('/orders/:id', async (args) => {
` : ''} `; + // 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); + loadThumbs(); const camInput = document.getElementById('camera-input'); @@ -253,7 +262,87 @@ router.on('/reports', async () => { title('Berichte'); setNav(true, 'reports'); setBack(false); - main().innerHTML = '
đź“‘
Berichte-Liste folgt
'; + 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 canFinalize = data.report.status !== 1 && data.pages.length > 0; + + 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}

+
+ +
+ ${data.pages.map(p => `
⏳
`).join('')} +
+ + ${canFinalize ? `` : ''} + + `; + + loadThumbs(); + + if (canFinalize) { + document.getElementById('btn-finalize').onclick = async () => { + if (!confirm('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 () => { @@ -293,3 +382,333 @@ document.addEventListener('DOMContentLoaded', () => { 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); + }; +} diff --git a/lib/api.js b/lib/api.js index f9909ea..079c1ac 100644 --- a/lib/api.js +++ b/lib/api.js @@ -84,6 +84,32 @@ return request('/reports.php?id=' + id); } + async function listReports() { + return request('/reports.php'); + } + + async function finalizeReport(id) { + return request('/reports.php?id=' + id + '&action=finalize', { method: 'POST' }); + } + + async function deletePhoto(relpath) { + return request('/delete_photo.php', { + method: 'POST', + body: JSON.stringify({ relpath }), + }); + } + + async function uploadVoiceNote(orderId, audioBlob, filename) { + const fd = new FormData(); + fd.append('file', audioBlob, filename || 'voice.webm'); + return request('/voice.php?order_id=' + orderId, { method: 'POST', body: fd }); + } + + async function uploadAnnotatedPhoto(orderId, fileBlob, filename) { + // Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt + return uploadOrderPhoto(orderId, fileBlob, filename); + } + /** * Lädt eine Bild-Datei von der API als Blob-URL (inkl. JWT). * Wird benötigt weil keine Authorization-Header mitschickt. @@ -125,7 +151,9 @@ window.api = { getToken, setToken, clearToken, login, logout, - listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, getReport, + listOrders, getOrder, listOrderPhotos, uploadOrderPhoto, + getReport, listReports, finalizeReport, + deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto, getPhotoBlobUrl, clearPhotoCache, }; })(); diff --git a/sw.js b/sw.js index bc10dfc..071e390 100644 --- a/sw.js +++ b/sw.js @@ -4,7 +4,7 @@ * - API-Calls: network-first, kein offline-cache (da auth-pflichtig) */ -const CACHE = 'baustelle-v1'; +const CACHE = 'baustelle-v2'; const SHELL = [ './', './index.html',