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 = '';
+ showLoader('Lade Berichte…');
+ try {
+ const data = await api.listReports();
+ if (!data.reports.length) {
+ main().innerHTML = '';
+ 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 = '';
+ }
+});
+
+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 = '';
+ }
});
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 = `
+
+
+ `;
+ 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 = `
+
+
+
â—Ź
+
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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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',