/* Baustelle PWA Hauptlogik. Alle Routen + Views in einer Datei. */
const main = () => document.getElementById('main');
const title = (s) => document.getElementById('page-title').textContent = s;
window.showToast = function (msg, kind) {
const t = document.createElement('div');
t.className = 'toast' + (kind ? ' ' + kind : '');
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 2800);
};
function showLoader(text) {
main().innerHTML = '
' + (text || 'Lade…') + '
';
}
function setNav(visible, active) {
const nav = document.getElementById('bottom-nav');
nav.style.display = visible ? '' : 'none';
nav.querySelectorAll('button').forEach(b => {
b.classList.toggle('active', b.dataset.route === active);
});
// FAB folgt der Nav-Sichtbarkeit (auf Login verstecken, sonst überall erreichbar)
const fab = document.getElementById('fab-new-order');
if (fab) fab.hidden = !visible;
}
function setBack(visible, hash) {
const btn = document.getElementById('back-btn');
btn.style.display = visible ? '' : 'none';
// Immer history.back() — offene Modals werden vom popstate-Handler geschlossen,
// Hash-Routen per router.go() liegen als History-Einträge vor.
btn.onclick = () => history.back();
}
/* ============================================================
* Modal-Stack: Android-Back schließt oberstes Modal statt die App
* Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag.
* popstate (Android-Back) pop-t den Stack und entfernt das Modal.
* Programmatisches Schließen (closeModal) räumt synchron auf und
* setzt ein Skip-Flag, damit der eigene history.back() kein
* doppeltes Cleanup auslöst.
* ============================================================ */
const modalStack = [];
let _skipNextPopstate = false;
let _lastBackAt = 0;
function pushModal(el, cleanup) {
modalStack.push({ el, cleanup: cleanup || null });
try {
history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash);
} catch (e) { /* some browsers may block in strict sandboxes */ }
}
function closeModal(el) {
const idx = modalStack.findIndex(m => m.el === el);
if (idx === -1) {
try { el.remove(); } catch {}
return;
}
const entry = modalStack[idx];
modalStack.splice(idx, 1);
try { entry.el.remove(); } catch {}
try { entry.cleanup && entry.cleanup(); } catch {}
// Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion
if (history.state && history.state._modal) {
_skipNextPopstate = true;
history.back();
}
}
function isTopLevelHash(h) {
const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/';
return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh);
}
window.addEventListener('popstate', () => {
if (_skipNextPopstate) { _skipNextPopstate = false; return; }
if (modalStack.length > 0) {
const top = modalStack.pop();
try { top.el.remove(); } catch {}
try { top.cleanup && top.cleanup(); } catch {}
return;
}
if (isTopLevelHash(location.hash)) {
const now = Date.now();
if (now - _lastBackAt < 2000) return;
_lastBackAt = now;
try { history.pushState({}, '', location.hash); } catch {}
showToast('Nochmal drücken zum Beenden');
}
});
/* ============================================================
* Share-Helper: teilt eine oder mehrere Dateien über Web Share API.
* shareFile(blob, filename, mime, title) — einzelne Datei
* shareFiles([{blob,filename,mime}, …], title) — mehrere Dateien
* Fällt auf Toast zurück, wenn das Gerät kein Datei-Sharing unterstützt.
* ============================================================ */
function _buildShareFile(blob, filename, mime) {
const fname = filename || 'datei';
// Name-Endung am MIME ausrichten, sonst lehnen manche Android-Apps ab
let safeName = fname;
const m = (mime || blob.type || '').toLowerCase();
if (m === 'application/pdf' && !/\.pdf$/i.test(safeName)) safeName += '.pdf';
return new File([blob], safeName, { type: mime || blob.type || 'application/octet-stream' });
}
async function shareFiles(items, titleHint) {
const files = items.map(it => _buildShareFile(it.blob, it.filename, it.mime));
try {
if (!navigator.canShare || !navigator.share) {
showToast('Teilen wird vom Browser nicht unterstützt', 'error');
return false;
}
if (!navigator.canShare({ files })) {
// Versuche einzeln — manche Geräte lehnen Batches bestimmter MIMEs ab
if (files.length > 1) {
showToast('Batch-Teilen wird nicht unterstützt — sende einzeln', 'error');
return false;
}
showToast('Dieser Dateityp kann nicht geteilt werden', 'error');
return false;
}
await navigator.share({
files,
title: titleHint || (files.length === 1 ? files[0].name : files.length + ' Dateien'),
text: titleHint || '',
});
return true;
} catch (e) {
if (e && e.name !== 'AbortError') {
console.warn('[Share]', e);
showToast('Teilen fehlgeschlagen: ' + e.message, 'error');
}
return false;
}
}
async function shareFile(blob, filename, mime, titleHint) {
return shareFiles([{ blob, filename, mime }], titleHint);
}
/* ----- Auth-Check ----- */
async function ensureAuth() {
const t = await api.getToken();
if (!t) {
router.go('#/login');
return false;
}
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)
* ============================================================ */
async function hashPin(pin) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltStr = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
const enc = new TextEncoder();
const data = enc.encode(saltStr + ':' + pin);
const buf = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
return { hash, salt: saltStr };
}
async function verifyPin(pin) {
const salt = await idb.get('pin_salt');
const stored = await idb.get('pin_hash');
if (!salt || !stored) return false;
const enc = new TextEncoder();
const data = enc.encode(salt + ':' + pin);
const buf = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
return hash === stored;
}
function promptPin(title) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal pin-modal';
modal.innerHTML = `
🔐
${escapeHtml(title || 'PIN eingeben')}
`;
const pad = modal.querySelector('.pin-keypad');
const disp = modal.querySelectorAll('.pin-display span');
let current = '';
function render() {
disp.forEach((el, i) => { el.classList.toggle('filled', i < current.length); });
}
function add(d) {
if (current.length >= 4) return;
current += d;
render();
if (current.length === 4) {
setTimeout(() => { closeModal(modal); resolve(current); }, 150);
}
}
function del() {
current = current.slice(0, -1);
render();
}
const keys = ['1','2','3','4','5','6','7','8','9','','0','←'];
keys.forEach(k => {
const b = document.createElement('button');
b.className = 'pin-key';
b.textContent = k;
if (!k) { b.style.visibility = 'hidden'; }
else if (k === '←') b.onclick = del;
else b.onclick = () => add(k);
pad.appendChild(b);
});
// Cancel-Button
const cancel = document.createElement('button');
cancel.className = 'pin-cancel';
cancel.textContent = 'Abbrechen';
cancel.onclick = () => { closeModal(modal); resolve(null); };
modal.querySelector('.pin-body').appendChild(cancel);
document.body.appendChild(modal);
// Android-Back schließt Modal → Promise mit null auflösen
// (doppeltes resolve ist per JS-Spec no-op, falls PIN bereits eingegeben war)
pushModal(modal, () => resolve(null));
});
}
async function promptNewPin() {
const p1 = await promptPin('Neue 4-stellige PIN');
if (!p1 || p1.length !== 4) return null;
const p2 = await promptPin('PIN wiederholen');
if (p2 !== p1) {
alert('PINs stimmen nicht überein');
return null;
}
return p1;
}
/**
* Startet die App — fragt ggf. PIN ab bevor router läuft.
*/
window.appBoot = async function appBoot() {
// JWT aktiv aus IndexedDB preloaden, bevor eine Route rennt.
// Verhindert Race-Conditions, in denen ensureAuth() zu früh ein `null` bekommt
// und nach Login-Screen redirectet, obwohl ein gültiges Token in IDB liegt.
try {
const t = await api.getToken();
console.log('[boot] jwt vorhanden:', !!t);
} catch (e) {
console.warn('[boot] Token-Preload fehlgeschlagen', e);
}
// Bootstrap-Puffer: legt einen zusätzlichen History-Eintrag an, damit beim
// ersten Android-Back der popstate-Handler greifen kann (Toast „Nochmal
// drücken zum Beenden"), bevor die PWA wirklich verlassen wird.
try {
if (!history.state || !history.state._bootstrap) {
history.pushState({ _bootstrap: true }, '', location.hash || '#/orders');
}
} catch {}
// FAB: Click öffnet das Schnell-Auftrag-Modal
const fab = document.getElementById('fab-new-order');
if (fab && !fab.dataset.bound) {
fab.dataset.bound = '1';
fab.addEventListener('click', () => { openNewOrderModal(); });
}
const pinSet = await idb.get('pin_hash');
if (!pinSet) return; // kein PIN-Schutz aktiv
// Lockscreen: vollständiger Overlay bis richtige PIN
document.getElementById('app').style.visibility = 'hidden';
while (true) {
const pin = await promptPin('PIN eingeben');
if (pin && (await verifyPin(pin))) break;
// Falsch → kurzes Feedback und nochmal
showToast('Falsche PIN', 'error');
}
document.getElementById('app').style.visibility = '';
}
/* ====== ROUTES ====== */
router.on('/login', async () => {
title('Anmelden');
setNav(false);
setBack(false);
main().innerHTML = `
`;
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.login(fd.get('login'), fd.get('password'));
showToast('Erfolgreich angemeldet');
router.go('#/orders');
} catch (err) {
showToast(err.message, 'error');
}
});
});
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 += `
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 = '';
}
});
router.on('/orders', async () => {
if (!(await ensureAuth())) return;
title('Aufträge');
setNav(true, 'orders');
setBack(false);
showLoader('Lade Aufträge…');
// Filter-Status aus localStorage laden (persistiert)
let showAllOrders = localStorage.getItem('pwa_show_all_orders') === '1';
async function loadOrders(q = '') {
const opts = q ? { q } : {};
if (!showAllOrders) opts.open = 1;
return api.listOrders(opts);
}
function bindOrderCards() {
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
});
}
try {
const data = await loadOrders();
if (!data.orders.length) {
main().innerHTML = `
Auch abgeschlossene
📭
${showAllOrders ? 'Keine Aufträge gefunden' : 'Keine offenen Aufträge'}
`;
document.getElementById('show-all-toggle').addEventListener('change', async (e) => {
showAllOrders = e.target.checked;
localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0');
showLoader('Lade Aufträge…');
router.go('#/orders');
});
return;
}
const html = `
Auch abgeschlossene
${renderOrderList(data.orders)}
`;
main().innerHTML = html;
bindOrderCards();
document.getElementById('order-search').addEventListener('input', async (e) => {
const q = e.target.value;
const d = await loadOrders(q);
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
bindOrderCards();
});
document.getElementById('show-all-toggle').addEventListener('change', async (e) => {
showAllOrders = e.target.checked;
localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0');
const q = document.getElementById('order-search').value;
const d = await loadOrders(q);
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
bindOrderCards();
});
} catch (e) {
main().innerHTML = '';
}
});
function renderOrderList(orders) {
return orders.map(o => `
${escapeHtml(o.ref)}${o.ref_client ? ` — ${escapeHtml(o.ref_client)} ` : ''}
${escapeHtml(o.customer.name || '')}
${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))}
${o.bericht_count > 0 ? `📑 ${o.bericht_count} ` : ''}
`).join('');
}
router.on('/orders/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'orders');
setBack(true, '#/orders');
showLoader('Lade Auftrag…');
try {
const data = await api.getOrder(args.id);
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
title(data.order.ref);
// Nach MIME-Type aufteilen
const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/'));
const audioFiles = photos.photos.filter(p => (p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
const otherDocs = photos.photos.filter(p =>
!(p.mime || '').startsWith('image/') &&
!(p.mime || '').startsWith('audio/') &&
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
main().innerHTML = `
Kunde
${escapeHtml(data.customer.name)}
${escapeHtml(data.customer.address || '')}
${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}
${data.customer.phone ? `
📞 ${escapeHtml(data.customer.phone)}
` : ''}
${data.order.auftragsbeschreibung ? `
Beschreibung
${escapeHtml(data.order.auftragsbeschreibung)}
` : ''}
📷 Foto aufnehmen
📂 Aus Galerie wählen
🎙 Sprachnotiz aufnehmen
📦 Materialliste
📑 Neuen Bericht anlegen…
Hochgeladene Fotos (${imagePhotos.length})
${imagePhotos.length ? '☑ Auswählen ' : ''}
${imagePhotos.map(p => `
`).join('')}
${audioFiles.length ? `
🎙 Sprachnotizen (${audioFiles.length})
${audioFiles.map(a => `
${escapeHtml(a.filename)}
▶
📝
`).join('')}
` : ''}
${otherDocs.length ? `
Weitere Dokumente (${otherDocs.length})
${otherDocs.map(p => `
${docIconFor(p.filename, p.mime)}
↗
`).join('')}
` : ''}
`;
// Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus)
const photoGrid = document.getElementById('photo-grid');
photoGrid.querySelectorAll('.thumb').forEach(t => {
t.addEventListener('click', () => {
if (photoGrid.classList.contains('selecting')) {
t.classList.toggle('selected');
updateSelectBar();
} else {
openPhotoModal(args.id, t.dataset.relpath);
}
});
});
// Mehrfachauswahl-Modus
const selectBtn = document.getElementById('btn-select-mode');
if (selectBtn) selectBtn.onclick = () => startSelectMode();
function startSelectMode() {
photoGrid.classList.add('selecting');
renderSelectBar();
updateSelectBar();
}
function endSelectMode() {
photoGrid.classList.remove('selecting');
photoGrid.querySelectorAll('.thumb.selected').forEach(x => x.classList.remove('selected'));
const bar = document.getElementById('select-bar');
if (bar) bar.remove();
}
function renderSelectBar() {
if (document.getElementById('select-bar')) return;
const bar = document.createElement('div');
bar.id = 'select-bar';
bar.className = 'select-bar';
bar.innerHTML = `
✕
0 ausgewählt
◎
📤
`;
document.body.appendChild(bar);
bar.querySelector('#sb-cancel').onclick = endSelectMode;
bar.querySelector('#sb-all').onclick = () => {
const thumbs = photoGrid.querySelectorAll('.thumb[data-relpath]');
const anyUn = Array.from(thumbs).some(t => !t.classList.contains('selected'));
thumbs.forEach(t => t.classList.toggle('selected', anyUn));
updateSelectBar();
};
bar.querySelector('#sb-share').onclick = () => shareSelected();
}
function updateSelectBar() {
const n = photoGrid.querySelectorAll('.thumb.selected').length;
const info = document.getElementById('sb-info');
const share = document.getElementById('sb-share');
if (info) info.textContent = n + ' ausgewählt';
if (share) share.disabled = n === 0;
}
async function shareSelected() {
const sel = Array.from(photoGrid.querySelectorAll('.thumb.selected'));
if (!sel.length) return;
showToast('Lade ' + sel.length + ' Foto' + (sel.length === 1 ? '' : 's') + '…');
const items = [];
for (const t of sel) {
const rp = t.dataset.relpath;
try {
const f = await api.getFileBlobUrl(rp);
if (f) items.push({ blob: f.blob, filename: rp.split('/').pop(), mime: f.mime });
} catch (e) { console.warn('Share-Load', rp, e); }
}
if (!items.length) { showToast('Keine Dateien geladen', 'error'); return; }
const ok = await shareFiles(items, 'Fotos vom Auftrag');
if (ok) endSelectMode();
}
// Weitere Dokumente: Tap = Öffnen (PDF inline, sonst Download)
document.querySelectorAll('.doc-list .doc-item').forEach(el => {
el.addEventListener('click', async () => {
const rel = el.dataset.relpath;
const filename = el.dataset.filename;
el.classList.add('loading');
const res = await api.getFileBlobUrl(rel);
el.classList.remove('loading');
if (!res) { showToast('Datei konnte nicht geladen werden', 'error'); return; }
openFileViewer(res, filename, rel);
});
});
// Sprachnotiz
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
// 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) => {
const item = e.target.closest('.audio-item');
const rel = item.dataset.relpath;
btn.textContent = '⏳';
try {
const r = await api.transcribeAudio(rel);
const text = r.text || '(leer)';
const existing = item.querySelector('.audio-transcript');
if (existing) existing.remove();
const box = document.createElement('div');
box.className = 'audio-transcript';
box.textContent = text;
item.appendChild(box);
btn.textContent = '📝';
showToast('✓ Transkribiert');
} catch (err) {
btn.textContent = '📝';
showToast('Whisper-Fehler: ' + err.message, 'error');
}
});
});
// Audio-Files abspielen
document.querySelectorAll('.audio-item .audio-play').forEach(btn => {
btn.addEventListener('click', async (e) => {
const item = e.target.closest('.audio-item');
const rel = item.dataset.relpath;
const mime = item.dataset.mime || 'audio/webm';
// Existierenden Player toggeln
let player = item.querySelector('audio');
if (player) {
if (player.paused) player.play();
else player.pause();
return;
}
btn.textContent = '⏳';
try {
const t = await api.getToken();
const params = new URLSearchParams({ relpath: rel, jwt: t });
const r = await fetch(window.location.origin + '/custom/bericht/api/photo.php?' + params.toString());
if (!r.ok) throw new Error('Load failed');
const blob = await r.blob();
const url = URL.createObjectURL(new Blob([blob], { type: mime }));
player = document.createElement('audio');
player.controls = true;
player.src = url;
player.style.width = '100%';
player.style.marginTop = '8px';
item.appendChild(player);
player.play();
btn.textContent = '⏸';
player.onplay = () => btn.textContent = '⏸';
player.onpause = () => btn.textContent = '▶';
} catch (err) {
showToast('Audio laden fehlgeschlagen: ' + err.message, 'error');
btn.textContent = '▶';
}
});
});
loadThumbs();
const camInput = document.getElementById('camera-input');
const galInput = document.getElementById('gallery-input');
document.getElementById('btn-take-photo').onclick = () => camInput.click();
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
async function handleFiles(files) {
for (const f of files) {
await uploadPhoto(args.id, f);
}
// Nach Upload einfach die Route neu rendern — so werden Select-Mode,
// Click-Handler und Thumbnails sauber neu aufgebaut.
router.navigate();
}
camInput.addEventListener('change', () => handleFiles(camInput.files));
galInput.addEventListener('change', () => handleFiles(galInput.files));
} catch (e) {
main().innerHTML = '';
}
});
async function uploadPhoto(orderId, file) {
showToast('Optimiere & sende ' + file.name);
const blob = await resizeImage(file, 2000);
if (!navigator.onLine) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Offline — Foto in Queue', 'warn');
return;
}
try {
await api.uploadOrderPhoto(orderId, blob, file.name);
showToast('✓ ' + file.name + ' hochgeladen');
} catch (e) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Upload fehlgeschlagen — in Queue', 'error');
}
}
async function resizeImage(file, maxSide) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
if (scale === 1) { resolve(file); return; }
const c = document.createElement('canvas');
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.toBlob(b => resolve(b || file), 'image/jpeg', 0.85);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
}
/**
* Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid.
* Nutzt /api/photo.php mit JWT (Header kann nicht schicken,
* deshalb Blob-URLs).
*/
async function loadThumbs() {
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
for (const t of thumbs) {
const rel = t.dataset.relpath;
try {
// Erst Thumbnail versuchen (_small), bei Misserfolg das Original
let url = await api.getPhotoBlobUrl(rel, 'small');
if (!url) url = await api.getPhotoBlobUrl(rel);
if (url) {
t.innerHTML = ' ';
} else {
t.innerHTML = '❌
';
}
} catch (e) {
t.innerHTML = '❌
';
}
}
}
router.on('/customers', async () => {
if (!(await ensureAuth())) return;
title('Kunden');
setNav(true, 'customers');
setBack(false);
showLoader('Lade Kunden…');
try {
const data = await api.listCustomers();
if (!data.customers.length) {
main().innerHTML = '';
return;
}
main().innerHTML = `
${renderCustomerList(data.customers)}
`;
bindCustomerCards();
document.getElementById('cust-search').addEventListener('input', async (e) => {
const d = await api.listCustomers({ q: e.target.value });
document.getElementById('cust-list').innerHTML = renderCustomerList(d.customers);
bindCustomerCards();
});
} catch (e) {
main().innerHTML = '';
}
});
function renderCustomerList(list) {
return list.map(c => `
${escapeHtml(c.name || '')}
${escapeHtml((c.zip || '') + ' ' + (c.town || '')).trim() || '—'}
${c.phone ? '📞 ' + escapeHtml(c.phone) : ''}
${c.bericht_count > 0 ? `📑 ${c.bericht_count} ` : ''}
`).join('');
}
function bindCustomerCards() {
document.querySelectorAll('.customer-card').forEach(c => {
c.addEventListener('click', () => router.go('#/customers/' + c.dataset.id));
});
}
router.on('/customers/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'customers');
setBack(true, '#/customers');
showLoader('Lade Kunde…');
try {
const data = await api.getCustomer(args.id);
title(data.customer.name);
const addr = [data.customer.address, (data.customer.zip || '') + ' ' + (data.customer.town || '')]
.map(s => (s || '').trim()).filter(Boolean).join('\n');
main().innerHTML = `
Stammdaten
${escapeHtml(data.customer.name)}
${data.customer.code ? `
Kundennr: ${escapeHtml(data.customer.code)}
` : ''}
${addr ? `
${escapeHtml(addr).replace(/\n/g, ' ')}
` : ''}
${data.customer.phone ? `
📞 ${escapeHtml(data.customer.phone)}
` : ''}
${data.customer.email ? `
✉ ${escapeHtml(data.customer.email)}
` : ''}
${data.customer.siret || data.customer.vat ? `
USt-ID: ${escapeHtml(data.customer.vat || '')}
` : ''}
${data.customer.address || data.customer.town ? `
🗺 Route ` : ''}
${data.orders.length ? `
Aufträge (${data.orders.length})
${data.orders.map(o => `
${escapeHtml(o.ref)}
${formatDate(o.date)} · ${formatEur(o.total)}${o.bericht_count > 0 ? ' · 📑 ' + o.bericht_count : ''}
`).join('')}
` : ''}
${data.reports.length ? `
Berichte (${data.reports.length})
${data.reports.map(r => `
${escapeHtml(r.ref)} ${r.status === 1 ? 'Final' : 'Entwurf'}
${escapeHtml(r.titel || '')} · ${formatDate(r.datec)}
`).join('')}
` : ''}
${data.invoices.length ? `
Rechnungen (${data.invoices.length})
${data.invoices.map(i => `
${escapeHtml(i.ref)}
${formatDate(i.date)} · ${formatEur(i.total)}
`).join('')}
` : ''}
`;
document.querySelectorAll('.mini-card[data-order-id]').forEach(el => {
el.addEventListener('click', () => router.go('#/orders/' + el.dataset.orderId));
});
document.querySelectorAll('.mini-card[data-report-id]').forEach(el => {
el.addEventListener('click', () => router.go('#/reports/' + el.dataset.reportId));
});
} catch (e) {
main().innerHTML = '';
}
});
function formatDate(unix) {
if (!unix) return '—';
const d = new Date(unix * 1000);
return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
function formatEur(v) {
if (v == null) return '';
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
}
router.on('/reports', async () => {
if (!(await ensureAuth())) return;
title('Berichte');
setNav(true, 'reports');
setBack(false);
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 hasPages = data.pages.length > 0;
const finalizeLabel = data.report.status === 1 ? '🔄 PDF neu erzeugen' : '📑 Bericht finalisieren (PDF)';
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}
${hasPages ? `
${data.pages.map((p, i) => `
⏳
${i + 1}
${p.title ? `
${escapeHtml(p.title)}
` : ''}
`).join('')}
` : '📭
Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.
'}
${finalizeLabel}
${hasPages ? '👁 PDF-Vorschau ' : ''}
✍️ Kunden-Unterschrift hinzufügen
✏️ Im Desktop-Editor öffnen
Bericht löschen
`;
loadThumbs();
// Tap = Aktionen, Long-Press = Drag-Sort
bindReportPageInteractions(args.id);
// PDF-Vorschau
const pdfBtn = document.getElementById('btn-view-pdf');
if (pdfBtn) pdfBtn.onclick = async () => {
showToast('PDF wird geladen…');
const url = await api.getPdfBlobUrl(args.id);
if (!url) { showToast('PDF konnte nicht geladen werden', 'error'); return; }
const fname = (data.report && data.report.ref ? data.report.ref : ('bericht-' + args.id)) + '.pdf';
openPdfModal(url, fname);
};
// Unterschrift
document.getElementById('btn-signature').onclick = () => openSignatureModal(args.id);
const finalizeBtn = document.getElementById('btn-finalize');
if (finalizeBtn) {
finalizeBtn.onclick = async () => {
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; }
if (!confirm(data.report.status === 1
? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?'
: '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');
};
document.getElementById('btn-delete-report').onclick = () => {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
Bericht ${escapeHtml(data.report.ref)} mit ${data.pages.length} Seite${data.pages.length === 1 ? '' : 'n'} wirklich löschen?
Alle Seiten und Annotationen werden unwiderruflich entfernt.
Abbrechen
Endgültig löschen
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#dr-cancel').onclick = () => closeModal(modal);
modal.querySelector('#dr-no').onclick = () => closeModal(modal);
modal.querySelector('#dr-yes').onclick = async () => {
try {
await api.deleteReport(args.id);
closeModal(modal);
showToast('Bericht gelöscht');
router.go('#/reports');
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
};
};
} catch (e) {
main().innerHTML = '';
}
});
router.on('/settings', async () => {
if (!(await ensureAuth())) return;
title('Einstellungen');
setNav(true, 'settings');
setBack(false);
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 = `
Konto
${escapeHtml(user.name || user.login || 'Unbekannt')}
${escapeHtml(user.login || '')}
Sicherheit
PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.
PIN beim Öffnen abfragen
${pinEnabled ? '
🔑 PIN ändern ' : ''}
🔄 Offline-Queue synchronisieren
🚪 Abmelden
Baustelle PWA v1.0
`;
document.getElementById('btn-sync').onclick = async () => {
await offline.syncQueue();
showToast('Sync ausgelöst');
};
document.getElementById('btn-logout').onclick = async () => {
await api.logout();
await idb.del('pin_hash');
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
const pin = await promptNewPin();
if (!pin) { e.target.checked = false; return; }
const { hash, salt } = await hashPin(pin);
await idb.set('pin_salt', salt);
await idb.set('pin_hash', hash);
showToast('✓ PIN gesetzt');
router.navigate();
} else {
if (!confirm('PIN-Schutz wirklich deaktivieren?')) {
e.target.checked = true;
return;
}
await idb.del('pin_hash');
await idb.del('pin_salt');
showToast('PIN-Schutz aus');
router.navigate();
}
});
const changeBtn = document.getElementById('btn-pin-change');
if (changeBtn) changeBtn.onclick = async () => {
const old = await promptPin('Aktuelle PIN eingeben');
if (!old) return;
if (!(await verifyPin(old))) { showToast('Falsche PIN', 'error'); return; }
const neu = await promptNewPin();
if (!neu) return;
const { hash, salt } = await hashPin(neu);
await idb.set('pin_salt', salt);
await idb.set('pin_hash', hash);
showToast('✓ PIN geändert');
};
});
/* Bottom nav + Hilfe-Button */
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#bottom-nav button').forEach(b => {
b.addEventListener('click', () => router.go('#/' + b.dataset.route));
});
const helpBtn = document.getElementById('help-btn');
if (helpBtn) helpBtn.addEventListener('click', openHelpModal);
});
/**
* Bindet Tap + Long-Press-Drag an alle .report-page-thumb des Bericht-Details.
*/
function bindReportPageInteractions(reportId) {
const grid = document.getElementById('report-pages');
if (!grid) return;
const thumbs = Array.from(grid.querySelectorAll('.report-page-thumb'));
let longPressTimer = null;
let dragging = null;
let touchStart = null;
thumbs.forEach(t => {
// Click/Tap → Aktion-Modal (nur wenn nicht gedraggt wurde)
t.addEventListener('click', (e) => {
if (dragging) { e.preventDefault(); return; }
openPageActionsModal(reportId, t.dataset.pageId, t.dataset.relpath, t.dataset.note || '', t.dataset.title || '');
});
// Long-Press → Drag-Modus
const startLongPress = (e) => {
touchStart = e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
longPressTimer = setTimeout(() => {
dragging = t;
t.classList.add('dragging');
showToast('Seite verschieben — ziehen und loslassen');
if (navigator.vibrate) navigator.vibrate(50);
}, 500);
};
const cancelLongPress = () => {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
};
t.addEventListener('touchstart', startLongPress, { passive: true });
t.addEventListener('touchmove', (e) => {
if (!dragging && touchStart) {
const dx = e.touches[0].clientX - touchStart.x;
const dy = e.touches[0].clientY - touchStart.y;
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) cancelLongPress();
}
if (dragging) {
e.preventDefault();
// Welcher Thumb liegt unter dem Finger?
const tx = e.touches[0].clientX;
const ty = e.touches[0].clientY;
const below = document.elementFromPoint(tx, ty);
const target = below ? below.closest('.report-page-thumb') : null;
if (target && target !== dragging) {
const rect = target.getBoundingClientRect();
const middle = rect.left + rect.width / 2;
if (tx < middle) grid.insertBefore(dragging, target);
else grid.insertBefore(dragging, target.nextSibling);
}
}
}, { passive: false });
t.addEventListener('touchend', async () => {
cancelLongPress();
if (dragging) {
dragging.classList.remove('dragging');
const ids = Array.from(grid.querySelectorAll('.report-page-thumb')).map(x => x.dataset.pageId);
try {
await api.reorderPages(ids);
showToast('✓ Reihenfolge gespeichert');
// Page-Nummer-Badges neu setzen
Array.from(grid.querySelectorAll('.report-page-thumb')).forEach((el, i) => {
const num = el.querySelector('.page-num');
if (num) num.textContent = (i + 1);
});
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
dragging = null;
}
touchStart = null;
});
t.addEventListener('touchcancel', () => { cancelLongPress(); dragging = null; touchStart = null; });
});
}
/* ============================================================
* MATERIALLISTE MODAL
* ============================================================ */
async function openMaterialModal(elementType, elementId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#mt-close').onclick = () => closeModal(modal);
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)
* ============================================================ */
async function openNewReportModal(orderId) {
// Templates parallel laden
let tpls = [], odts = [];
try { tpls = (await api.listTemplates()).templates || []; } catch (e) {}
try { odts = (await api.listOdtTemplates()).templates || []; } catch (e) {}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
Titel
${tpls.length ? `
Aus Vorlage erstellen (optional)
— Leerer Bericht —
${tpls.map(t => `📋 ${escapeHtml(t.label)} (${t.page_count} Seiten) `).join('')}
` : ''}
Format
A4
A3
A5
Letter
Hochformat
Querformat
${odts.length ? `
Deckblatt-Vorlage (ODT)
— Kein Deckblatt —
${odts.map(o => `${escapeHtml(o.label)} `).join('')}
` : ''}
`;
document.body.appendChild(modal);
pushModal(modal);
// ODT-Default vorauswählen
try {
const odtData = await api.listOdtTemplates();
if (odtData.default) {
const sel = modal.querySelector('#nr-odt');
if (sel) sel.value = odtData.default;
}
} catch (e) {}
modal.querySelector('#nr-close').onclick = () => closeModal(modal);
modal.querySelector('#nr-save').onclick = async () => {
const titel = modal.querySelector('#nr-titel').value.trim();
const tplSel = modal.querySelector('#nr-template');
const tpl = tplSel ? parseInt(tplSel.value, 10) : 0;
const format = modal.querySelector('#nr-format').value;
const orient = modal.querySelector('#nr-orient').value;
const odtSel = modal.querySelector('#nr-odt');
const odt = odtSel ? odtSel.value : '';
showToast('Lege Bericht an…');
try {
const res = await api.createReport({
element_type: 'order',
element_id: parseInt(orderId, 10),
titel: titel,
template_id: tpl,
page_format: format,
page_orientation: orient,
template_odt: odt,
});
showToast('✓ Bericht angelegt');
closeModal(modal);
router.go('#/reports/' + res.bericht_id);
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
};
}
/* ============================================================
* NEUER AUFTRAG MODAL (Schnell-Erfassung vom FAB)
* ============================================================ */
async function openNewOrderModal() {
if (!(await ensureAuth())) return;
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal);
const closeBtn = modal.querySelector('#no-close');
const saveBtn = modal.querySelector('#no-save');
const searchIn = modal.querySelector('#no-customer-search');
const listEl = modal.querySelector('#no-customer-list');
const stepCust = modal.querySelector('#no-step-customer');
const stepDet = modal.querySelector('#no-step-details');
const socName = modal.querySelector('#no-soc-name');
const socMeta = modal.querySelector('#no-soc-meta');
const defaultsEl = modal.querySelector('#no-defaults');
const titleIn = modal.querySelector('#no-title');
const refIn = modal.querySelector('#no-refclient');
const changeBtn = modal.querySelector('#no-change-customer');
let selectedCustomer = null;
closeBtn.onclick = () => closeModal(modal);
/* ---- Letzte Kunden als Quick-Pick ---- */
async function renderRecent() {
const recent = (await idb.get('recent_customers')) || [];
if (!recent.length) {
listEl.innerHTML = 'Tippe zum Suchen…
';
return;
}
listEl.innerHTML = 'Zuletzt verwendet
' +
recent.map(c => `
${escapeHtml(c.name)}
${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}
`).join('');
}
await renderRecent();
/* ---- Suche mit Debounce ---- */
let searchTimer = null;
searchIn.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = searchIn.value.trim();
if (!q) { renderRecent(); return; }
searchTimer = setTimeout(async () => {
try {
const data = await api.listCustomers({ q });
const items = (data.customers || []).slice(0, 30);
if (!items.length) {
listEl.innerHTML = 'Keine Treffer
';
return;
}
listEl.innerHTML = items.map(c => `
${escapeHtml(c.name)}
${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}
`).join('');
} catch (e) {
listEl.innerHTML = 'Fehler: ' + escapeHtml(e.message) + '
';
}
}, 300);
});
/* ---- Kunden-Klick: Details laden, Wechsel zum zweiten Schritt ---- */
listEl.addEventListener('click', async (e) => {
const item = e.target.closest('.nc-item');
if (!item) return;
const id = parseInt(item.dataset.id, 10);
if (!id) return;
showToast('Lade Kundendaten…');
try {
const res = await api.getCustomer(id);
const c = res.customer || res;
selectedCustomer = c;
socName.textContent = c.name || '';
socMeta.textContent = [c.address, ((c.zip || '') + ' ' + (c.town || '')).trim()].filter(Boolean).join(' · ');
const defs = [];
if (c.cond_reglement_label) defs.push('💳 ' + c.cond_reglement_label);
if (c.mode_reglement_label) defs.push('🏦 ' + c.mode_reglement_label);
if (!defs.length) defs.push('Keine speziellen Defaults hinterlegt');
defaultsEl.innerHTML = 'Übernommen: ' + escapeHtml(defs.join(' · '));
stepCust.hidden = true;
stepDet.hidden = false;
saveBtn.disabled = false;
titleIn.focus();
} catch (err) {
showToast('Fehler: ' + err.message, 'error');
}
});
changeBtn.onclick = () => {
selectedCustomer = null;
stepDet.hidden = true;
stepCust.hidden = false;
saveBtn.disabled = true;
searchIn.focus();
};
/* ---- Anlegen ---- */
saveBtn.onclick = async () => {
if (!selectedCustomer) { showToast('Bitte zuerst Kunde wählen', 'error'); return; }
const title = titleIn.value.trim();
if (!title) { showToast('Titel ist Pflicht', 'error'); titleIn.focus(); return; }
saveBtn.disabled = true;
showToast('Lege Auftrag an…');
try {
const res = await api.createOrder({
socid: selectedCustomer.id,
title: title,
ref_client: refIn.value.trim(),
});
// Recent-Liste in IDB aktualisieren (letzte 5)
try {
const recent = (await idb.get('recent_customers')) || [];
const filtered = recent.filter(c => c.id !== selectedCustomer.id);
filtered.unshift({
id: selectedCustomer.id,
name: selectedCustomer.name,
zip: selectedCustomer.zip,
town: selectedCustomer.town,
});
await idb.set('recent_customers', filtered.slice(0, 5));
} catch (e) {}
showToast('✓ Auftrag ' + (res.order.ref || '') + ' angelegt');
closeModal(modal);
router.go('#/orders/' + res.order.id);
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
saveBtn.disabled = false;
}
};
}
/* ============================================================
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
* ============================================================ */
async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
const url = await api.getPhotoBlobUrl(relpath);
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
${url ? `
` : '
⚠
'}
Titel / Zwischentitel (groß oben auf der Seite):
Notiz zur Seite (unten auf der Seite):
💾 Speichern
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#pa-close').onclick = () => closeModal(modal);
modal.querySelector('#pa-save').onclick = async () => {
try {
const note = modal.querySelector('#pa-note').value;
const title = modal.querySelector('#pa-title').value;
await api.request('/pages.php?id=' + pageId, {
method: 'POST',
body: JSON.stringify({ note, title }),
});
showToast('✓ Gespeichert');
closeModal(modal);
router.navigate();
} catch (e) {
// Fallback falls api.request nicht exposed ist
try {
await api.updatePageNote(pageId, modal.querySelector('#pa-note').value);
showToast('✓ Notiz gespeichert');
closeModal(modal);
router.navigate();
} catch (er) { showToast('Fehler: ' + er.message, 'error'); }
}
};
modal.querySelector('#pa-delete').onclick = async () => {
if (!confirm('Diese Seite aus dem Bericht entfernen?')) return;
try {
await api.deletePage(pageId);
showToast('✓ Seite entfernt');
closeModal(modal);
router.navigate();
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
};
}
/* ============================================================
* PDF-VORSCHAU MODAL
* ============================================================ */
function openPdfModal(blobUrl, filename) {
const fname = filename || 'bericht.pdf';
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { URL.revokeObjectURL(blobUrl); } catch {} });
modal.querySelector('#pdf-close').onclick = () => closeModal(modal);
modal.querySelector('#pdf-share').onclick = async () => {
try {
const r = await fetch(blobUrl);
const blob = await r.blob();
await shareFile(blob, fname, 'application/pdf', fname);
} catch (e) { showToast('Teilen fehlgeschlagen: ' + e.message, 'error'); }
};
}
/* ============================================================
* UNTERSCHRIFT MODAL (Touch-Signatur)
* ============================================================ */
function openSignatureModal(berichtId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal signature-modal';
modal.innerHTML = `
🗑 Leeren
Mit dem Finger unterschreiben
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvas); } catch {} });
const canvas = modal.querySelector('#sig-canvas');
const ctx = canvas.getContext('2d');
function fitCanvas() {
const body = modal.querySelector('.signature-body');
const rect = body.getBoundingClientRect();
canvas.width = Math.max(600, Math.floor(rect.width * 2));
canvas.height = Math.max(300, Math.floor(rect.height * 2));
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#000';
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
fitCanvas();
window.addEventListener('resize', fitCanvas);
let drawing = false;
function pos(e) {
const r = canvas.getBoundingClientRect();
const sx = canvas.width / r.width;
const sy = canvas.height / r.height;
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy };
}
function start(e) { e.preventDefault(); drawing = true; const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); }
function move(e) { if (!drawing) return; e.preventDefault(); const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); }
function end() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
canvas.addEventListener('mouseup', end);
canvas.addEventListener('mouseleave', end);
canvas.addEventListener('touchstart', start, { passive: false });
canvas.addEventListener('touchmove', move, { passive: false });
canvas.addEventListener('touchend', end);
modal.querySelector('#sig-clear').onclick = fitCanvas;
modal.querySelector('#sig-close').onclick = () => closeModal(modal);
modal.querySelector('#sig-save').onclick = async () => {
const signer = modal.querySelector('#sig-name').value.trim();
if (!signer) {
showToast('Bitte Namen des Unterzeichners eingeben', 'error');
modal.querySelector('#sig-name').focus();
return;
}
// GPS holen wenn aktiviert
let gps = null;
if (modal.querySelector('#sig-gps').checked && 'geolocation' in navigator) {
showToast('Hole GPS-Position…');
try {
const pos = await new Promise((res, rej) => {
navigator.geolocation.getCurrentPosition(res, rej, { timeout: 8000, enableHighAccuracy: true });
});
gps = { lat: pos.coords.latitude, lon: pos.coords.longitude };
} catch (e) {
console.warn('GPS failed', e);
}
}
showToast('Speichere Unterschrift…');
canvas.toBlob(async (blob) => {
try {
const opts = { signer_name: signer };
if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; }
await api.uploadSignature(berichtId, blob, opts);
showToast('✓ Unterschrift hinzugefügt');
closeModal(modal);
router.navigate();
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
}, 'image/png');
};
}
function openHelpModal() {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal help-modal';
modal.innerHTML = `
📋 So funktioniert die Baustelle-App
1. Aufträge finden
Im Reiter Aufträge siehst du alle offenen Aufträge, die dir zugewiesen sind.
Oben kannst du per Suchfeld nach Auftragsnummer oder Kundenname filtern.
Auf einen Auftrag tippen → Detail-Ansicht mit Kunde, Adresse, Telefon.
2. Fotos aufnehmen
Im Auftrag-Detail:
📷 Foto aufnehmen — öffnet direkt die Kamera
📂 Aus Galerie wählen — mehrere Bilder auf einmal möglich
Die Bilder werden automatisch auf 2000px verkleinert und hochgeladen.
Sie landen in einem Entwurf-Bericht , der automatisch zum Auftrag angelegt wird.
💡 Alle weiteren Fotos werden an denselben Entwurf angehängt —
du hast also einen Bericht mit mehreren Seiten, bis du ihn finalisierst.
3. Fotos bearbeiten (Skizzen)
Tippe auf ein hochgeladenes Foto im Grid → Vollbild-Ansicht .
✏️ (oben) öffnet den Skizzen-Editor
🗑️ (oben) löscht das Foto
Im Skizzen-Editor:
✏️ Stift (Freihand)
↗ Pfeil mit Spitze (von Start bis Ende ziehen)
▭ Rechteck
○ Ellipse
Farbe + Linienstärke rechts daneben
↶ Undo / 🗑 Alles zurücksetzen
Oben rechts ✓ → Skizze wird als eigenständige neue Bericht-Seite gespeichert.
Das Original-Foto bleibt unverändert.
4. Sprachnotizen
🎙 Sprachnotiz aufnehmen im Auftrag-Detail:
Aufnahme starten, reden
Stopp → Vorhören
Senden → Audio landet im Auftrags-Anhang
Unter der Foto-Liste erscheint eine eigene Sektion „🎙 Sprachnotizen" mit Play-Button je Eintrag.
5. Berichte finalisieren
Im Reiter Berichte siehst du alle deine Berichte mit Status.
Entwurf — kannst du noch erweitern
Final — PDF wurde erzeugt, der Bericht ist eingefroren
Bericht öffnen → 📑 Bericht finalisieren → erzeugt das PDF und legt es
unter „Verknüpfte Dokumente" des Auftrags ab. In Dolibarr siehst du den Bericht dann direkt beim Auftrag.
💡 Wenn du nach dem Finalisieren neue Fotos machst, wird automatisch ein
neuer Entwurf angelegt. Der finalisierte bleibt unberührt.
6. Offline arbeiten
Die App funktioniert auch ohne Internet:
Fotos werden in einer lokalen Warteschlange gespeichert
Sobald wieder Empfang da ist, werden sie automatisch hochgeladen
Oben rechts siehst du den Status: 🟢 online ,
🟡 N (N Uploads werden synchronisiert),
🔴 N (offline, N warten)
7. Auf dem Handy installieren
So wird die PWA zur echten App:
Android/Chrome: Menü → „Zum Startbildschirm hinzufügen"
iPhone/Safari: Teilen-Symbol → „Zum Home-Bildschirm"
Danach startet sie wie eine normale App, ohne Browser-Leiste.
8. Einstellungen
Im Reiter ⚙️ :
🔄 Offline-Queue manuell synchronisieren
🚪 Abmelden
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#help-close').onclick = () => closeModal(modal);
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function docIconFor(filename, mime) {
const ext = (filename || '').split('.').pop().toLowerCase();
if ((mime || '').includes('pdf') || ext === 'pdf') return '📕';
if (['doc','docx','odt','rtf'].includes(ext)) return '📝';
if (['xls','xlsx','ods','csv'].includes(ext)) return '📊';
if (['ppt','pptx','odp'].includes(ext)) return '📽';
if (['zip','rar','7z','tar','gz'].includes(ext)) return '🗜';
if (['txt','log','md'].includes(ext)) return '📃';
return '📄';
}
function formatFileSize(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function formatShortDate(ts) {
const d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function openFileViewer({ url, blob, mime }, filename, relpath) {
const isImage = (mime || '').startsWith('image/');
const isPdf = (mime || '').includes('pdf') || /\.pdf$/i.test(filename);
console.log('[FILE] openFileViewer:', {filename, mime, isPdf, pdfjs: typeof pdfjs, blobSize: blob.size});
if (isPdf && typeof pdfjs !== 'undefined') {
console.log('[FILE] → PDF-Viewer aufrufen');
openPdfViewer(blob, filename, relpath);
return;
}
if (isPdf && typeof pdfjs === 'undefined') {
console.warn('[FILE] PDF.js ist NICHT geladen!');
showToast('⚠️ PDF.js nicht verfügbar, Download stattdessen', 'error');
}
if (!isImage) {
// Alles außer Bildern + PDFs: Download
console.log('[FILE] → Download-Fallback');
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
showToast('⬇ ' + escapeHtml(filename || 'Datei') + ' wird heruntergeladen');
return;
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal doc-viewer-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { URL.revokeObjectURL(url); } catch {} });
modal.querySelector('#dv-close').onclick = () => closeModal(modal);
modal.querySelector('#dv-share').onclick = () => {
shareFile(blob, filename || 'datei', mime || blob.type, filename);
};
}
async function openPdfViewer(blob, filename, relpath) {
console.log('[PDF] Opening:', filename, 'size:', blob.size, 'type:', blob.type);
if (blob.size === 0) {
showToast('❌ PDF-Datei ist leer oder nicht gefunden', 'error');
return;
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal pdf-viewer-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal);
const canvas = modal.querySelector('#pdf-canvas');
const ctx = canvas.getContext('2d');
const pageInfo = modal.querySelector('#pdf-page-info');
let pdf, currentPage = 1;
try {
const arrayBuffer = await blob.arrayBuffer();
console.log('[PDF] ArrayBuffer size:', arrayBuffer.byteLength);
if (arrayBuffer.byteLength === 0) {
throw new Error('PDF-Datei ist leer');
}
pdf = await pdfjs.getDocument(new Uint8Array(arrayBuffer)).promise;
console.log('[PDF] Loaded:', pdf.numPages, 'pages');
pageInfo.textContent = `1 / ${pdf.numPages}`;
async function renderPage(num) {
if (num < 1 || num > pdf.numPages) return;
currentPage = num;
try {
const page = await pdf.getPage(num);
const viewport = page.getViewport({ scale: window.devicePixelRatio });
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: ctx, viewport }).promise;
pageInfo.textContent = `${num} / ${pdf.numPages}`;
} catch (e) {
console.error('[PDF] Render error on page', num, e);
pageInfo.textContent = `❌ Seite ${num} Fehler`;
}
}
await renderPage(1);
modal.querySelector('#pdf-prev').onclick = () => renderPage(currentPage - 1);
modal.querySelector('#pdf-next').onclick = () => renderPage(currentPage + 1);
modal.querySelector('#pdf-download').onclick = async () => {
const t = await api.getToken();
const params = new URLSearchParams({ relpath, jwt: t, download: 1 });
window.location.href = window.location.origin + '/custom/bericht/api/photo.php?' + params.toString();
};
modal.querySelector('#pdf-share').onclick = () => {
shareFile(blob, filename || 'dokument.pdf', 'application/pdf', filename);
};
modal.querySelector('#pdf-prev').disabled = false;
modal.querySelector('#pdf-next').disabled = pdf.numPages === 1;
} catch (err) {
pageInfo.textContent = '❌ ' + (err.message || 'PDF-Fehler');
console.error('[PDF] Load error:', err);
showToast('PDF-Fehler: ' + err.message, 'error');
}
modal.querySelector('#pdf-close').onclick = () => {
closeModal(modal);
};
}
/* ============================================================
* PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR
* ============================================================ */
async function openPhotoModal(orderId, relpath) {
// Alle Bilder im Grid sammeln für Navigation
const allThumbs = Array.from(document.querySelectorAll('#photo-grid .thumb[data-relpath]'));
const allRelpaths = allThumbs.map(t => t.dataset.relpath);
let currentIndex = allRelpaths.indexOf(relpath);
if (currentIndex < 0) currentIndex = 0;
// Zoom/Pan State
let zoom = 1, panX = 0, panY = 0;
let currentUrl = null;
const touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 };
const modal = document.createElement('div');
modal.className = 'fullscreen-modal photo-viewer-modal';
modal.innerHTML = `
‹
›
−
⟳
+
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { document.removeEventListener('keydown', keyHandler); } catch {} });
const img = modal.querySelector('#pv-image');
const body = modal.querySelector('#pv-body');
const titleEl = modal.querySelector('#pv-title');
const counter = modal.querySelector('#pv-counter');
const prevBtn = modal.querySelector('#pv-prev');
const nextBtn = modal.querySelector('#pv-next');
function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
function applyTransform() { img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`; }
function setZoom(z) {
zoom = Math.max(0.5, Math.min(5, z));
constrainPan();
applyTransform();
}
function constrainPan() {
if (zoom <= 1) { panX = 0; panY = 0; return; }
const rect = body.getBoundingClientRect();
const maxX = Math.max(0, (img.naturalWidth * zoom - rect.width) / 2);
const maxY = Math.max(0, (img.naturalHeight * zoom - rect.height) / 2);
panX = Math.max(-maxX, Math.min(maxX, panX));
panY = Math.max(-maxY, Math.min(maxY, panY));
}
async function loadImage(idx) {
if (idx < 0 || idx >= allRelpaths.length) return;
currentIndex = idx;
const rp = allRelpaths[idx];
titleEl.textContent = rp.split('/').pop();
counter.textContent = `${idx + 1} / ${allRelpaths.length}`;
prevBtn.disabled = idx === 0;
nextBtn.disabled = idx === allRelpaths.length - 1;
prevBtn.style.display = nextBtn.style.display = allRelpaths.length > 1 ? '' : 'none';
img.style.opacity = '0.3';
const url = await api.getPhotoBlobUrl(rp);
if (url) {
currentUrl = url;
img.src = url;
img.onload = () => { img.style.opacity = '1'; };
}
resetZoom();
}
function close() { closeModal(modal); }
function goPrev() { if (currentIndex > 0) loadImage(currentIndex - 1); }
function goNext() { if (currentIndex < allRelpaths.length - 1) loadImage(currentIndex + 1); }
// Events
modal.querySelector('#fs-close').onclick = close;
prevBtn.onclick = (e) => { e.stopPropagation(); goPrev(); };
nextBtn.onclick = (e) => { e.stopPropagation(); goNext(); };
modal.querySelector('#pv-zin').onclick = () => setZoom(zoom + 0.5);
modal.querySelector('#pv-zout').onclick = () => setZoom(zoom - 0.5);
modal.querySelector('#pv-zreset').onclick = resetZoom;
modal.querySelector('#fs-delete').onclick = async () => {
if (!confirm('Foto wirklich löschen?')) return;
try {
await api.deletePhoto(allRelpaths[currentIndex]);
showToast('✓ Gelöscht');
close();
router.navigate();
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
};
modal.querySelector('#fs-sketch').onclick = () => {
close();
openSketchEditor(orderId, currentUrl, allRelpaths[currentIndex]);
};
modal.querySelector('#fs-share').onclick = async () => {
const rp = allRelpaths[currentIndex];
showToast('Lade Foto…');
try {
const f = await api.getFileBlobUrl(rp);
if (!f) { showToast('Foto konnte nicht geladen werden', 'error'); return; }
await shareFile(f.blob, rp.split('/').pop(), f.mime, 'Foto vom Auftrag');
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
};
// Doppelklick = Zoom Toggle
img.addEventListener('dblclick', (e) => {
e.preventDefault();
if (zoom > 1) resetZoom(); else setZoom(2.5);
});
// Touch: Pinch-to-Zoom + Swipe + Pan
body.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
touchState.startX = t.clientX;
touchState.startY = t.clientY;
touchState.startPanX = panX;
touchState.startPanY = panY;
touchState.isDragging = true;
touchState.isPinching = false;
const now = Date.now();
if (now - touchState.lastTap < 300) {
if (zoom > 1) resetZoom(); else setZoom(2.5);
touchState.lastTap = 0;
} else {
touchState.lastTap = now;
}
} else if (e.touches.length === 2) {
e.preventDefault();
touchState.isPinching = true;
touchState.isDragging = false;
touchState.startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
touchState.startZoom = zoom;
}
}, { passive: false });
body.addEventListener('touchmove', (e) => {
if (touchState.isPinching && e.touches.length === 2) {
e.preventDefault();
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
setZoom(touchState.startZoom * (dist / touchState.startDist));
} else if (touchState.isDragging && e.touches.length === 1) {
const t = e.touches[0];
const dx = t.clientX - touchState.startX;
const dy = t.clientY - touchState.startY;
if (zoom > 1) {
e.preventDefault();
panX = touchState.startPanX + dx;
panY = touchState.startPanY + dy;
constrainPan();
applyTransform();
}
}
}, { passive: false });
body.addEventListener('touchend', (e) => {
if (touchState.isDragging && zoom <= 1 && e.changedTouches.length > 0) {
const t = e.changedTouches[0];
const dx = t.clientX - touchState.startX;
if (Math.abs(dx) > 50) {
if (dx > 0) goPrev(); else goNext();
}
}
touchState.isDragging = false;
touchState.isPinching = false;
});
// Tastatur
const keyHandler = (e) => {
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') goPrev();
if (e.key === 'ArrowRight') goNext();
};
document.addEventListener('keydown', keyHandler);
// Erstes Bild laden
await loadImage(currentIndex);
}
async function openVoiceModal(orderId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal voice-modal';
modal.innerHTML = `
●
00:00
Aufnahme starten
Stopp
Senden
`;
document.body.appendChild(modal);
let mediaRecorder = null;
let chunks = [];
let timer = null;
let startTime = 0;
let audioBlob = null;
pushModal(modal, () => {
try { if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); } catch {}
try { if (timer) clearInterval(timer); } catch {}
});
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 = () => closeModal(modal);
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');
closeModal(modal);
} 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);
pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvasToScreen); } catch {} });
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 stampChar = null;
let color = '#ff0000';
let lineWidth = 5;
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());
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;
stampChar = b.dataset.stamp || null;
});
});
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();
const p = getPos(e);
startX = p.x; startY = p.y;
// Stamps sind ein Single-Click-Tool (kein Drag)
if (tool === 'stamp' && stampChar) {
ctx.fillStyle = color;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 4;
ctx.font = 'bold 96px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeText(stampChar, p.x, p.y);
ctx.fillText(stampChar, p.x, p.y);
pushHistory();
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;
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);
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();
} 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();
}
});
}
}
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);
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 = () => closeModal(modal);
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');
closeModal(modal);
router.navigate();
} catch (e) {
showToast('Upload fehlgeschlagen: ' + e.message, 'error');
}
}, 'image/jpeg', 0.9);
};
}