feat: PWA Block D — Mess-Werkzeug, Materialliste, Heute-Tab, ntfy-Push
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s

5.4 Mess-Werkzeug mit Skala-Kalibrierung:
- Zwei neue Sketch-Tools: 📏⚙ Kalibrieren + 📏 Messen
- Kalibrieren: 2 Punkte ziehen → reale Länge eingeben (cm/m/mm)
- Messen: 2 Punkte ziehen → Live-Distanz-Label mit berechnetem Wert
- pxPerUnit + unit werden im State gehalten, Linie im Canvas gezeichnet

5.9 Materialliste pro Auftrag:
- 📦 Materialliste-Button im Auftrags-Detail
- Modal: Bezeichnung + Menge + Einheit (Stk/m/m²/kg/l/Set/Pa/h) + Notiz
- Live-Liste mit Löschen pro Eintrag

5.8 Tages-Baustellen-Map:
- Neuer Bottom-Nav-Tab ☀️ Heute (ganz links)
- Filtert Aufträge nach heutigem Datum
- Route-Button öffnet Google Maps mit allen heutigen Adressen als Waypoints
- Darunter noch die offenen Aufträge als Backup

4.d Benachrichtigungen via ntfy (statt VAPID):
- Settings → Benachrichtigungen: ntfy-Server + Topic
- EventSource auf /topic/sse → native Browser-Notifications
- Test-Nachricht-Button sendet POST an das Topic
- Auto-Reconnect bei Fehlern nach 10s
- Auto-Start beim Boot wenn Topic gesetzt und Permission granted

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 09:18:31 +02:00
parent 54eea0fb22
commit 3dad52367c
3 changed files with 384 additions and 1 deletions

368
app.js
View file

@ -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 += `
<div class="detail-section">
<h3>🚗 Route für heute (${todays.length})</h3>
<a class="btn" href="https://www.google.com/maps/dir/${waypoints}" target="_blank" style="text-decoration:none;text-align:center;">🗺 Alle in Google Maps öffnen</a>
</div>
<h4 style="opacity:0.7;margin:16px 0 8px;">Heute (${todays.length})</h4>
${renderOrderList(todays)}
`;
} else {
html += '<div class="empty-state"><div class="icon">☀️</div>Keine Aufträge für heute geplant</div>';
}
if (open.length) {
html += `<h4 style="opacity:0.7;margin:16px 0 8px;">Offen (${open.length})</h4>${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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
router.on('/orders', async () => {
if (!(await ensureAuth())) return;
title('Aufträge');
@ -253,6 +351,7 @@ router.on('/orders/:id', async (args) => {
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen</button>
<div class="detail-section" style="margin-top:16px;">
@ -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 = `
<div class="detail-section">
@ -702,6 +806,16 @@ router.on('/settings', async () => {
<p class="label">${escapeHtml(user.login || '')}</p>
</div>
<div class="detail-section">
<h3>🔔 Benachrichtigungen</h3>
<p class="label">Per ntfy-Topic. Wenn gesetzt, zeigt die App Notifications bei neuen Nachrichten (z. B. neue Aufträge, Team-Pings).</p>
<input type="text" id="ntfy-server" placeholder="https://notify.data-it-solution.de" value="${escapeHtml(ntfyServer)}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-bottom:6px;">
<input type="text" id="ntfy-topic" placeholder="z. B. baustelle-eddy" value="${escapeHtml(ntfyTopic)}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;">
<button class="btn btn-secondary" id="btn-ntfy-save" style="margin-top:6px;">💾 Speichern & Abonnieren</button>
<button class="btn btn-secondary" id="btn-ntfy-test">🔔 Test-Nachricht senden</button>
<p class="label" id="ntfy-status"></p>
</div>
<div class="detail-section">
<h3>Sicherheit</h3>
<p class="label">PIN-Schutz beim App-Start nützlich falls das Handy verloren geht.</p>
@ -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 = `
<div class="fs-header">
<button class="icon-btn" id="mt-close"></button>
<div class="fs-title">📦 Materialliste</div>
<span></span>
</div>
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;align-items:stretch;overflow-y:auto;">
<div class="detail-section" style="margin:0;">
<h3>Neues Material</h3>
<input type="text" id="mt-label" placeholder="Artikel / Bezeichnung" style="width:100%;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-bottom:6px;">
<div style="display:flex;gap:8px;">
<input type="number" step="0.01" id="mt-qty" value="1" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<select id="mt-unit" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option>Stk</option><option>m</option><option>m²</option><option>kg</option>
<option>l</option><option>Set</option><option>Pa</option><option>h</option>
</select>
</div>
<input type="text" id="mt-note" placeholder="Notiz (optional)" style="width:100%;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-top:6px;">
<button class="btn" id="mt-add" style="margin-top:8px;">+ Hinzufügen</button>
</div>
<div class="detail-section" style="margin:0;">
<h3>Erfasst</h3>
<div id="mt-list"><div class="loader">Lade</div></div>
</div>
</div>
`;
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 = '<div class="opacitymedium">Noch keine Einträge</div>';
return;
}
listEl.innerHTML = d.materials.map(m => `
<div class="mini-card" data-id="${m.id}" style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;">
<div class="mini-ref">${escapeHtml(m.label)}</div>
<div class="mini-meta">${m.qty} ${escapeHtml(m.unit)}${m.note ? ' · ' + escapeHtml(m.note) : ''}</div>
</div>
<button class="mt-del" title="Löschen" style="background:rgba(217,83,79,0.2);color:#d9534f;border:none;border-radius:6px;padding:8px 12px;cursor:pointer;">🗑</button>
</div>
`).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 = '<div class="opacitymedium">Fehler: ' + escapeHtml(e.message) + '</div>';
}
}
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) {
<button class="sk-tool" data-tool="stamp" data-stamp="✗" title="Mangel"></button>
<button class="sk-tool" data-tool="stamp" data-stamp="🔧" title="Reparatur">🔧</button>
<span class="sep"></span>
<button class="sk-tool" data-tool="calibrate" title="Mess-Skala kalibrieren (2 Punkte + Länge)">📏</button>
<button class="sk-tool" data-tool="measure" title="Messen (nach Kalibrierung)">📏</button>
<span class="sep"></span>
<input type="color" id="sk-color" value="#ff0000">
<input type="range" id="sk-width" min="2" max="20" value="5">
<span class="sep"></span>
@ -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,11 +1845,94 @@ 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);

View file

@ -50,6 +50,7 @@ header('Expires: 0');
<main id="main"></main>
<nav id="bottom-nav" style="display:none">
<button data-route="today">☀️ Heute</button>
<button data-route="orders" class="active">📋 Aufträge</button>
<button data-route="customers">👥 Kunden</button>
<button data-route="reports">📑 Berichte</button>

View file

@ -138,6 +138,21 @@
});
}
async function listMaterials(elementType, elementId) {
return request('/materials.php?element_type=' + elementType + '&element_id=' + elementId);
}
async function addMaterial(elementType, elementId, data) {
return request('/materials.php?element_type=' + elementType + '&element_id=' + elementId, {
method: 'POST',
body: JSON.stringify(data),
});
}
async function deleteMaterial(id) {
return request('/materials.php?id=' + id + '&delete=1', { method: 'POST' });
}
async function uploadAnnotatedPhoto(orderId, fileBlob, filename) {
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
return uploadOrderPhoto(orderId, fileBlob, filename);
@ -230,6 +245,7 @@
listCustomers, getCustomer,
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport,
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
listMaterials, addMaterial, deleteMaterial,
getPhotoBlobUrl, clearPhotoCache,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
};