diff --git a/app.js b/app.js index bf586ce..8b49265 100644 --- a/app.js +++ b/app.js @@ -39,6 +39,53 @@ async function ensureAuth() { 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) * ============================================================ */ @@ -167,6 +214,57 @@ router.on('/login', async () => { }); }); +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'); @@ -253,6 +351,7 @@ router.on('/orders/:id', async (args) => { +
@@ -291,6 +390,9 @@ router.on('/orders/:id', async (args) => { // 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) => { @@ -694,6 +796,8 @@ router.on('/settings', async () => { 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 = `
@@ -702,6 +806,16 @@ router.on('/settings', async () => {

${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.

@@ -726,6 +840,42 @@ router.on('/settings', async () => { 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 @@ -847,6 +997,91 @@ function bindReportPageInteractions(reportId) { }); } +/* ============================================================ + * 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) * ============================================================ */ @@ -1393,6 +1628,9 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { + + + @@ -1440,6 +1678,9 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { 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()); @@ -1504,6 +1745,18 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { 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; @@ -1522,6 +1775,36 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { 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(); @@ -1562,13 +1845,96 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) { } } - function endDraw() { + 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); diff --git a/index.php b/index.php index 81c28dd..19ce34d 100644 --- a/index.php +++ b/index.php @@ -50,6 +50,7 @@ header('Expires: 0');