feat: PWA Block D — Mess-Werkzeug, Materialliste, Heute-Tab, ntfy-Push
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
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:
parent
54eea0fb22
commit
3dad52367c
3 changed files with 384 additions and 1 deletions
368
app.js
368
app.js
|
|
@ -39,6 +39,53 @@ async function ensureAuth() {
|
||||||
return true;
|
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)
|
* 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 () => {
|
router.on('/orders', async () => {
|
||||||
if (!(await ensureAuth())) return;
|
if (!(await ensureAuth())) return;
|
||||||
title('Aufträge');
|
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>
|
<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>
|
<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-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>
|
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
||||||
|
|
||||||
<div class="detail-section" style="margin-top:16px;">
|
<div class="detail-section" style="margin-top:16px;">
|
||||||
|
|
@ -291,6 +390,9 @@ router.on('/orders/:id', async (args) => {
|
||||||
// Neuen Bericht anlegen
|
// Neuen Bericht anlegen
|
||||||
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
||||||
|
|
||||||
|
// Materialliste
|
||||||
|
document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id);
|
||||||
|
|
||||||
// Transkribieren
|
// Transkribieren
|
||||||
document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => {
|
document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
|
|
@ -694,6 +796,8 @@ router.on('/settings', async () => {
|
||||||
|
|
||||||
const user = await idb.get('user') || {};
|
const user = await idb.get('user') || {};
|
||||||
const pinEnabled = !!(await idb.get('pin_hash'));
|
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 = `
|
main().innerHTML = `
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
|
|
@ -702,6 +806,16 @@ router.on('/settings', async () => {
|
||||||
<p class="label">${escapeHtml(user.login || '')}</p>
|
<p class="label">${escapeHtml(user.login || '')}</p>
|
||||||
</div>
|
</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">
|
<div class="detail-section">
|
||||||
<h3>Sicherheit</h3>
|
<h3>Sicherheit</h3>
|
||||||
<p class="label">PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.</p>
|
<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');
|
await idb.del('pin_salt');
|
||||||
router.go('#/login');
|
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) => {
|
document.getElementById('pin-toggle').addEventListener('change', async (e) => {
|
||||||
if (e.target.checked) {
|
if (e.target.checked) {
|
||||||
// PIN setzen
|
// 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)
|
* 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="Mangel">✗</button>
|
||||||
<button class="sk-tool" data-tool="stamp" data-stamp="🔧" title="Reparatur">🔧</button>
|
<button class="sk-tool" data-tool="stamp" data-stamp="🔧" title="Reparatur">🔧</button>
|
||||||
<span class="sep"></span>
|
<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="color" id="sk-color" value="#ff0000">
|
||||||
<input type="range" id="sk-width" min="2" max="20" value="5">
|
<input type="range" id="sk-width" min="2" max="20" value="5">
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
|
|
@ -1440,6 +1678,9 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
let drawing = false;
|
let drawing = false;
|
||||||
let startX = 0, startY = 0;
|
let startX = 0, startY = 0;
|
||||||
const history = [canvas.toDataURL()];
|
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() {
|
function pushHistory() {
|
||||||
history.push(canvas.toDataURL());
|
history.push(canvas.toDataURL());
|
||||||
|
|
@ -1504,6 +1745,18 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
return;
|
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;
|
drawing = true;
|
||||||
ctx.strokeStyle = color;
|
ctx.strokeStyle = color;
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
|
|
@ -1522,6 +1775,36 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
if (!drawing) return;
|
if (!drawing) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const p = getPos(e);
|
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') {
|
if (tool === 'pen') {
|
||||||
ctx.lineTo(p.x, p.y);
|
ctx.lineTo(p.x, p.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
@ -1562,13 +1845,96 @@ async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function endDraw() {
|
async function endDraw(e) {
|
||||||
if (!drawing) return;
|
if (!drawing) return;
|
||||||
drawing = false;
|
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;
|
snapshotBeforeShape = null;
|
||||||
pushHistory();
|
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('mousedown', startDraw);
|
||||||
canvas.addEventListener('mousemove', moveDraw);
|
canvas.addEventListener('mousemove', moveDraw);
|
||||||
canvas.addEventListener('mouseup', endDraw);
|
canvas.addEventListener('mouseup', endDraw);
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ header('Expires: 0');
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
|
|
||||||
<nav id="bottom-nav" style="display:none">
|
<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="orders" class="active">📋 Aufträge</button>
|
||||||
<button data-route="customers">👥 Kunden</button>
|
<button data-route="customers">👥 Kunden</button>
|
||||||
<button data-route="reports">📑 Berichte</button>
|
<button data-route="reports">📑 Berichte</button>
|
||||||
|
|
|
||||||
16
lib/api.js
16
lib/api.js
|
|
@ -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) {
|
async function uploadAnnotatedPhoto(orderId, fileBlob, filename) {
|
||||||
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
|
// Wie uploadOrderPhoto — Skizze ist schon ins Bild eingebrannt
|
||||||
return uploadOrderPhoto(orderId, fileBlob, filename);
|
return uploadOrderPhoto(orderId, fileBlob, filename);
|
||||||
|
|
@ -230,6 +245,7 @@
|
||||||
listCustomers, getCustomer,
|
listCustomers, getCustomer,
|
||||||
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport,
|
getReport, listReports, createReport, listTemplates, listOdtTemplates, finalizeReport,
|
||||||
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, transcribeAudio, uploadAnnotatedPhoto,
|
||||||
|
listMaterials, addMaterial, deleteMaterial,
|
||||||
getPhotoBlobUrl, clearPhotoCache,
|
getPhotoBlobUrl, clearPhotoCache,
|
||||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue