baustelle-pwa/app.js
Eddy 0a78d9e5d9
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 4s
PWA: Dokumente (PDFs) in Mehrfachauswahl + Back-Button schließt Auswahl [deploy]
- PDFs/Dokumente lassen sich jetzt auch im Auswahl-Modus markieren und
  mitteilen (bisher öffnete der Tap immer den Viewer)
- ☑ Auswählen-Button erscheint auch wenn nur Dokumente vorhanden sind
- Android-Zurück-Button im Select-Modus: hebt die Auswahl auf und
  beendet den Auswahl-Modus (statt die Seite zu verlassen)
- ✕ Abbrechen-Button nutzt denselben History-Back-Pfad

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:38:06 +02:00

2800 lines
123 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 = '<div class="loader">' + (text || 'Lade…') + '</div>';
}
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;
// Aktiver Select-Mode-Cleanup: wird von popstate gerufen, damit Android-Back
// eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen.
let selectModeCleanup = null;
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 (selectModeCleanup) {
const fn = selectModeCleanup;
selectModeCleanup = null;
try { fn(); } 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 = `
<div class="pin-body">
<div class="pin-icon">🔐</div>
<div class="pin-title">${escapeHtml(title || 'PIN eingeben')}</div>
<div class="pin-display"><span></span><span></span><span></span><span></span></div>
<div class="pin-keypad"></div>
<div class="pin-error" id="pin-error"></div>
</div>
`;
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 = `
<form class="login-form" id="login-form">
<h2>🔧 Baustelle</h2>
<input type="text" name="login" placeholder="Benutzername" autocomplete="username" required>
<input type="password" name="password" placeholder="Passwort" autocomplete="current-password" required>
<button type="submit" class="btn btn-large">Anmelden</button>
</form>
`;
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 += `
<div class="detail-section">
<h3>🚗 Route für heute (${todays.length})</h3>
<a class="btn" href="https://www.google.com/maps/dir/${waypoints}" target="_blank" style="text-decoration:none;text-align:center;">🗺 Alle in Google Maps öffnen</a>
</div>
<h4 style="opacity:0.7;margin:16px 0 8px;">Heute (${todays.length})</h4>
${renderOrderList(todays)}
`;
} else {
html += '<div class="empty-state"><div class="icon">☀️</div>Keine Aufträge für heute geplant</div>';
}
if (open.length) {
html += `<h4 style="opacity:0.7;margin:16px 0 8px;">Offen (${open.length})</h4>${renderOrderList(open.slice(0, 20))}`;
}
main().innerHTML = html;
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
});
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
router.on('/orders', async () => {
if (!(await ensureAuth())) return;
title('Aufträge');
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 = `
<div class="search-bar">
<input type="search" id="order-search" placeholder="🔍 Suchen…">
<label class="filter-toggle"><input type="checkbox" id="show-all-toggle" ${showAllOrders ? 'checked' : ''}> Auch abgeschlossene</label>
</div>
<div class="empty-state"><div class="icon">📭</div>${showAllOrders ? 'Keine Aufträge gefunden' : 'Keine offenen Aufträge'}</div>
`;
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 = `
<div class="search-bar">
<input type="search" id="order-search" placeholder="🔍 Suchen…">
<label class="filter-toggle"><input type="checkbox" id="show-all-toggle" ${showAllOrders ? 'checked' : ''}> Auch abgeschlossene</label>
</div>
<div id="order-list">${renderOrderList(data.orders)}</div>
`;
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
function renderOrderList(orders) {
return orders.map(o => `
<div class="order-card" data-id="${o.id}">
<div class="ref">${escapeHtml(o.ref)}${o.ref_client ? ` <span class="ref-client">— ${escapeHtml(o.ref_client)}</span>` : ''}</div>
<div class="name">${escapeHtml(o.customer.name || '')}</div>
<div class="meta">
<span>${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))}</span>
${o.bericht_count > 0 ? `<span class="badge">📑 ${o.bericht_count}</span>` : ''}
</div>
</div>
`).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 = `
<div class="detail-section">
<h3>Kunde</h3>
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
<p>${escapeHtml(data.customer.address || '')}</p>
<p>${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}</p>
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
</div>
${data.order.auftragsbeschreibung ? `
<div class="detail-section">
<h3>Beschreibung</h3>
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
</div>` : ''}
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
<div class="detail-section" style="margin-top:16px;">
<div class="photo-section-head">
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
</div>
<div class="photo-grid" id="photo-grid">
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div><div class="thumb-check">✓</div></div>`).join('')}
</div>
</div>
${audioFiles.length ? `
<div class="detail-section">
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
<div class="audio-list">
${audioFiles.map(a => `<div class="audio-item" data-relpath="${escapeHtml(a.relpath)}" data-mime="${escapeHtml(a.mime || 'audio/webm')}">
<span class="audio-name">${escapeHtml(a.filename)}</span>
<button class="audio-play" title="Abspielen">▶</button>
<button class="audio-transcribe" title="Transkribieren (Whisper)">📝</button>
</div>`).join('')}
</div>
</div>` : ''}
${otherDocs.length ? `
<div class="detail-section">
<h3>Weitere Dokumente (${otherDocs.length})</h3>
<div class="doc-list">
${otherDocs.map(p => `<div class="doc-item" data-relpath="${escapeHtml(p.relpath)}" data-mime="${escapeHtml(p.mime || '')}" data-filename="${escapeHtml(p.filename)}">
<div class="doc-check">✓</div>
<span class="doc-icon">${docIconFor(p.filename, p.mime)}</span>
<div class="doc-meta">
<div class="doc-name">${escapeHtml(p.filename)}</div>
<div class="doc-sub">${formatFileSize(p.size)}${p.date ? ' · ' + formatShortDate(p.date) : ''}</div>
</div>
<button class="doc-open" title="Öffnen">↗</button>
</div>`).join('')}
</div>
</div>` : ''}
`;
// Foto-Thumbnails: Tap = Vollbild-Modal (oder Auswahl im Select-Modus)
const photoGrid = document.getElementById('photo-grid');
const docList = document.querySelector('.doc-list');
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 allSelectables() {
const thumbs = Array.from(photoGrid.querySelectorAll('.thumb[data-relpath]'));
const docs = docList ? Array.from(docList.querySelectorAll('.doc-item[data-relpath]')) : [];
return thumbs.concat(docs);
}
function startSelectMode() {
if (photoGrid.classList.contains('selecting')) return;
photoGrid.classList.add('selecting');
if (docList) docList.classList.add('selecting');
renderSelectBar();
updateSelectBar();
// Android-Back soll nur die Auswahl aufheben
history.pushState({ _selecting: true }, '', location.hash);
selectModeCleanup = endSelectMode;
}
function endSelectMode() {
selectModeCleanup = null;
photoGrid.classList.remove('selecting');
if (docList) docList.classList.remove('selecting');
allSelectables().forEach(x => x.classList.remove('selected'));
const bar = document.getElementById('select-bar');
if (bar) bar.remove();
}
function cancelViaBack() {
if (history.state && history.state._selecting) history.back();
else endSelectMode();
}
function renderSelectBar() {
if (document.getElementById('select-bar')) return;
const bar = document.createElement('div');
bar.id = 'select-bar';
bar.className = 'select-bar';
bar.innerHTML = `
<button class="icon-btn" id="sb-cancel" title="Abbrechen">✕</button>
<div class="sb-info" id="sb-info">0 ausgewählt</div>
<button class="icon-btn" id="sb-all" title="Alle">◎</button>
<button class="icon-btn" id="sb-share" title="Teilen" disabled>📤</button>
`;
document.body.appendChild(bar);
bar.querySelector('#sb-cancel').onclick = cancelViaBack;
bar.querySelector('#sb-all').onclick = () => {
const items = allSelectables();
const anyUn = items.some(t => !t.classList.contains('selected'));
items.forEach(t => t.classList.toggle('selected', anyUn));
updateSelectBar();
};
bar.querySelector('#sb-share').onclick = () => shareSelected();
}
function updateSelectBar() {
const n = allSelectables().filter(x => x.classList.contains('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 = allSelectables().filter(x => x.classList.contains('selected'));
if (!sel.length) return;
showToast('Lade ' + sel.length + ' Datei' + (sel.length === 1 ? '' : 'en') + '…');
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: t.dataset.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, 'Dateien vom Auftrag');
if (ok) cancelViaBack();
}
// Weitere Dokumente: Tap = Öffnen (oder Auswahl im Select-Modus)
document.querySelectorAll('.doc-list .doc-item').forEach(el => {
el.addEventListener('click', async (e) => {
if (docList && docList.classList.contains('selecting')) {
// Auch Klick auf Öffnen-Button soll nur selektieren
e.preventDefault();
e.stopPropagation();
el.classList.toggle('selected');
updateSelectBar();
return;
}
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
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 <img src> 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 = '<img loading="lazy" src="' + url + '">';
} else {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
} catch (e) {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
}
}
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 = '<div class="empty-state"><div class="icon">👥</div>Keine Kunden</div>';
return;
}
main().innerHTML = `
<div class="search-bar"><input type="search" id="cust-search" placeholder="🔍 Kunde suchen…"></div>
<div id="cust-list">${renderCustomerList(data.customers)}</div>
`;
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
function renderCustomerList(list) {
return list.map(c => `
<div class="order-card customer-card" data-id="${c.id}">
<div class="ref">${escapeHtml(c.name || '')}</div>
<div class="name">${escapeHtml((c.zip || '') + ' ' + (c.town || '')).trim() || '—'}</div>
<div class="meta">
<span>${c.phone ? '📞 ' + escapeHtml(c.phone) : ''}</span>
${c.bericht_count > 0 ? `<span class="badge">📑 ${c.bericht_count}</span>` : ''}
</div>
</div>
`).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 = `
<div class="detail-section">
<h3>Stammdaten</h3>
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
${data.customer.code ? `<p class="label">Kundennr: ${escapeHtml(data.customer.code)}</p>` : ''}
${addr ? `<p>${escapeHtml(addr).replace(/\n/g, '<br>')}</p>` : ''}
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
${data.customer.email ? `<p><a href="mailto:${escapeHtml(data.customer.email)}">✉ ${escapeHtml(data.customer.email)}</a></p>` : ''}
${data.customer.siret || data.customer.vat ? `<p class="label">USt-ID: ${escapeHtml(data.customer.vat || '')}</p>` : ''}
<div class="customer-actions">
${data.customer.address || data.customer.town ? `<a class="btn btn-secondary" href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(data.customer.name + ' ' + (data.customer.address||'') + ' ' + (data.customer.zip||'') + ' ' + (data.customer.town||''))}" target="_blank">🗺 Route</a>` : ''}
</div>
</div>
${data.orders.length ? `
<div class="detail-section">
<h3>Aufträge (${data.orders.length})</h3>
${data.orders.map(o => `
<div class="mini-card" data-order-id="${o.id}">
<div class="mini-ref">${escapeHtml(o.ref)}</div>
<div class="mini-meta">${formatDate(o.date)} · ${formatEur(o.total)}${o.bericht_count > 0 ? ' · 📑 ' + o.bericht_count : ''}</div>
</div>
`).join('')}
</div>` : ''}
${data.reports.length ? `
<div class="detail-section">
<h3>Berichte (${data.reports.length})</h3>
${data.reports.map(r => `
<div class="mini-card" data-report-id="${r.id}">
<div class="mini-ref">${escapeHtml(r.ref)} <span class="${r.status === 1 ? 'status-final' : 'status-draft'}">${r.status === 1 ? 'Final' : 'Entwurf'}</span></div>
<div class="mini-meta">${escapeHtml(r.titel || '')} · ${formatDate(r.datec)}</div>
</div>
`).join('')}
</div>` : ''}
${data.invoices.length ? `
<div class="detail-section">
<h3>Rechnungen (${data.invoices.length})</h3>
${data.invoices.map(i => `
<div class="mini-card">
<div class="mini-ref">${escapeHtml(i.ref)}</div>
<div class="mini-meta">${formatDate(i.date)} · ${formatEur(i.total)}</div>
</div>
`).join('')}
</div>` : ''}
`;
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
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 = '<div class="empty-state"><div class="icon">📑</div>Noch keine Berichte</div>';
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 `
<div class="order-card" data-id="${r.id}">
<div class="ref">${escapeHtml(r.ref)} <span class="${statusClass}">${statusLabel}</span></div>
<div class="name">${escapeHtml(r.titel || '')}</div>
<div class="meta">
<span>${sourceIcon} ${escapeHtml(r.parent_ref || '')}</span>
<span class="badge">${r.page_count} Seite${r.page_count === 1 ? '' : 'n'}</span>
</div>
</div>`;
}).join('');
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id));
});
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
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 = `
<div class="detail-section">
<h3>Bericht</h3>
<p><strong>${escapeHtml(data.report.titel || data.report.ref)}</strong></p>
<p class="label">Auftrag: ${escapeHtml(data.report.auftragsnummer || '—')}</p>
<p class="label">Format: ${escapeHtml(data.report.page_format || 'A4')} ${data.report.page_orientation === 'L' ? 'Quer' : 'Hoch'}</p>
<p class="label">Seiten: ${data.pages.length}</p>
<p class="label">Status: <strong>${statusLabel}</strong></p>
</div>
${hasPages ? `
<div class="photo-grid" id="report-pages">
${data.pages.map((p, i) => `
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}" data-title="${escapeHtml(p.title || '')}">
<div class="thumb-placeholder">⏳</div>
<div class="page-num">${i + 1}</div>
${p.title ? `<div class="page-title-badge">${escapeHtml(p.title)}</div>` : ''}
</div>
`).join('')}
</div>` : '<div class="empty-state"><div class="icon">📭</div>Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.</div>'}
<button class="btn btn-large" id="btn-finalize" ${hasPages ? '' : 'disabled'}>${finalizeLabel}</button>
${hasPages ? '<button class="btn btn-secondary" id="btn-view-pdf">👁 PDF-Vorschau</button>' : ''}
<button class="btn btn-secondary" id="btn-signature">✍️ Kunden-Unterschrift hinzufügen</button>
<button class="btn btn-secondary" id="btn-open-editor">✏️ Im Desktop-Editor öffnen</button>
<button class="btn btn-danger" id="btn-delete-report">Bericht löschen</button>
`;
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 = `
<div class="modal-header">
<h2>Bericht löschen</h2>
<button class="close-btn" id="dr-cancel">&times;</button>
</div>
<div class="modal-body" style="padding:20px;text-align:center;">
<p style="font-size:16px;margin:16px 0;">Bericht <strong>${escapeHtml(data.report.ref)}</strong> mit ${data.pages.length} Seite${data.pages.length === 1 ? '' : 'n'} wirklich löschen?</p>
<p style="opacity:0.6;font-size:13px;">Alle Seiten und Annotationen werden unwiderruflich entfernt.</p>
<div style="display:flex;gap:12px;justify-content:center;margin-top:24px;">
<button class="btn btn-secondary" id="dr-no">Abbrechen</button>
<button class="btn btn-danger" id="dr-yes">Endgültig löschen</button>
</div>
</div>
`;
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
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 = `
<div class="detail-section">
<h3>Konto</h3>
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
<p class="label">${escapeHtml(user.login || '')}</p>
</div>
<div class="detail-section">
<h3>🔔 Benachrichtigungen</h3>
<p class="label">Per ntfy-Topic. Wenn gesetzt, zeigt die App Notifications bei neuen Nachrichten (z. B. neue Aufträge, Team-Pings).</p>
<input type="text" id="ntfy-server" placeholder="https://notify.data-it-solution.de" value="${escapeHtml(ntfyServer)}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-bottom:6px;">
<input type="text" id="ntfy-topic" placeholder="z. B. baustelle-eddy" value="${escapeHtml(ntfyTopic)}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;">
<button class="btn btn-secondary" id="btn-ntfy-save" style="margin-top:6px;">💾 Speichern & Abonnieren</button>
<button class="btn btn-secondary" id="btn-ntfy-test">🔔 Test-Nachricht senden</button>
<p class="label" id="ntfy-status"></p>
</div>
<div class="detail-section">
<h3>Sicherheit</h3>
<p class="label">PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.</p>
<label class="toggle-row">
<input type="checkbox" id="pin-toggle" ${pinEnabled ? 'checked' : ''}>
<span>PIN beim Öffnen abfragen</span>
</label>
${pinEnabled ? '<button class="btn btn-secondary" id="btn-pin-change">🔑 PIN ändern</button>' : ''}
</div>
<button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button>
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</button>
<p class="label" style="text-align:center;margin-top:24px;">Baustelle PWA v1.0</p>
`;
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 = `
<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);
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 = '<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)
* ============================================================ */
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 = `
<div class="fs-header">
<button class="icon-btn" id="nr-close">✕</button>
<div class="fs-title">📑 Neuer Bericht</div>
<button class="icon-btn" id="nr-save" title="Anlegen">✓</button>
</div>
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;align-items:stretch;">
<label class="label">Titel</label>
<input type="text" id="nr-titel" placeholder="z. B. Wallbox-Installation" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
${tpls.length ? `
<label class="label">Aus Vorlage erstellen (optional)</label>
<select id="nr-template" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="0">— Leerer Bericht —</option>
${tpls.map(t => `<option value="${t.id}">📋 ${escapeHtml(t.label)} (${t.page_count} Seiten)</option>`).join('')}
</select>` : ''}
<label class="label">Format</label>
<div style="display:flex;gap:8px;">
<select id="nr-format" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="A4">A4</option>
<option value="A3">A3</option>
<option value="A5">A5</option>
<option value="Letter">Letter</option>
</select>
<select id="nr-orient" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="P">Hochformat</option>
<option value="L">Querformat</option>
</select>
</div>
${odts.length ? `
<label class="label">Deckblatt-Vorlage (ODT)</label>
<select id="nr-odt" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<option value="">— Kein Deckblatt —</option>
${odts.map(o => `<option value="${escapeHtml(o.filename)}">${escapeHtml(o.label)}</option>`).join('')}
</select>` : ''}
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="no-close" aria-label="Schliessen">✕</button>
<div class="fs-title"> Neuer Auftrag</div>
<button class="icon-btn" id="no-save" title="Anlegen & Fotos machen" disabled>✓</button>
</div>
<div class="fs-body" style="flex-direction:column;gap:10px;padding:16px;align-items:stretch;">
<div id="no-step-customer">
<label class="label">Kunde wählen</label>
<input type="search" id="no-customer-search" class="new-order-customer-search" placeholder="🔍 Name, Ort…" autocomplete="off">
<div id="no-customer-list" class="new-order-customer-list"></div>
</div>
<div id="no-step-details" hidden>
<div class="new-order-customer-row">
<div style="flex:1">
<div class="nc-name" id="no-soc-name"></div>
<div class="nc-meta" id="no-soc-meta"></div>
</div>
<button class="nc-change" id="no-change-customer">Ändern</button>
</div>
<div class="new-order-defaults" id="no-defaults"></div>
<label class="label">Titel / Kurzbeschreibung</label>
<input type="text" id="no-title" placeholder="z. B. Wallbox-Installation" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
<label class="label">Kunden-Referenz (optional)</label>
<input type="text" id="no-refclient" placeholder="z. B. Angebot Nr. 123" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
</div>
</div>
`;
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 = '<div class="new-order-empty">Tippe zum Suchen…</div>';
return;
}
listEl.innerHTML = '<div style="padding:8px 12px;color:#888;font-size:12px;">Zuletzt verwendet</div>' +
recent.map(c => `
<div class="nc-item recent" data-id="${c.id}">
<div>${escapeHtml(c.name)}</div>
<div class="nc-sub">${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}</div>
</div>
`).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 = '<div class="new-order-empty">Keine Treffer</div>';
return;
}
listEl.innerHTML = items.map(c => `
<div class="nc-item" data-id="${c.id}">
<div>${escapeHtml(c.name)}</div>
<div class="nc-sub">${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}</div>
</div>
`).join('');
} catch (e) {
listEl.innerHTML = '<div class="new-order-empty">Fehler: ' + escapeHtml(e.message) + '</div>';
}
}, 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 = `
<div class="fs-header">
<button class="icon-btn" id="pa-close">✕</button>
<div class="fs-title">Seite bearbeiten</div>
<button class="icon-btn" id="pa-delete" title="Seite löschen">🗑️</button>
</div>
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;">
${url ? `<img src="${url}" style="max-height:40vh;">` : '<div class="thumb-placeholder">⚠</div>'}
<label class="label" style="align-self:flex-start;">Titel / Zwischentitel (groß oben auf der Seite):</label>
<input type="text" id="pa-title" placeholder="z. B. 'Vor Beginn der Arbeiten'" value="${escapeHtml(title || '')}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;">
<label class="label" style="align-self:flex-start;">Notiz zur Seite (unten auf der Seite):</label>
<textarea id="pa-note" rows="3" style="width:100%;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;padding:10px;font-size:14px;box-sizing:border-box;">${escapeHtml(note || '')}</textarea>
<button class="btn" id="pa-save">💾 Speichern</button>
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="pdf-close">✕</button>
<div class="fs-title">📑 PDF-Vorschau</div>
<button class="icon-btn" id="pdf-share" title="Teilen">📤</button>
<a class="icon-btn" id="pdf-download" href="${blobUrl}" download="${escapeHtml(fname)}" title="Download">⬇</a>
</div>
<div class="fs-body" style="padding:0;">
<iframe src="${blobUrl}" style="width:100%;height:100%;border:none;background:#444;"></iframe>
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="sig-close">✕</button>
<div class="fs-title">✍️ Kunden-Unterschrift</div>
<button class="icon-btn" id="sig-save" title="Speichern">✓</button>
</div>
<div class="signature-meta">
<input type="text" id="sig-name" placeholder="Name des Unterzeichners (Pflicht)" autocomplete="name">
<label class="sig-gps-label">
<input type="checkbox" id="sig-gps" checked>
📍 GPS-Position aufnehmen
</label>
<div class="sig-legal">Mit der Unterschrift bestätige ich die ordnungsgemäße Ausführung der dokumentierten Arbeiten. Server-Zeitstempel, GPS-Koordinaten und ein SHA-256-Integritäts-Hash werden zusammen mit der Unterschrift gespeichert.</div>
</div>
<div class="signature-toolbar">
<button id="sig-clear">🗑 Leeren</button>
<span class="sig-hint">Mit dem Finger unterschreiben</span>
</div>
<div class="signature-body">
<canvas id="sig-canvas"></canvas>
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="help-close">✕</button>
<div class="fs-title">❓ Hilfe / Anleitung</div>
<span></span>
</div>
<div class="help-body">
<h2>📋 So funktioniert die Baustelle-App</h2>
<section>
<h3>1. Aufträge finden</h3>
<p>Im Reiter <strong>Aufträge</strong> siehst du alle offenen Aufträge, die dir zugewiesen sind.
Oben kannst du per Suchfeld nach Auftragsnummer oder Kundenname filtern.</p>
<p>Auf einen Auftrag tippen → Detail-Ansicht mit Kunde, Adresse, Telefon.</p>
</section>
<section>
<h3>2. Fotos aufnehmen</h3>
<p>Im Auftrag-Detail:</p>
<ul>
<li><strong>📷 Foto aufnehmen</strong> — öffnet direkt die Kamera</li>
<li><strong>📂 Aus Galerie wählen</strong> — mehrere Bilder auf einmal möglich</li>
</ul>
<p>Die Bilder werden automatisch auf 2000px verkleinert und hochgeladen.
Sie landen in einem <strong>Entwurf-Bericht</strong>, der automatisch zum Auftrag angelegt wird.</p>
<p class="tip">💡 Alle weiteren Fotos werden an denselben Entwurf angehängt —
du hast also einen Bericht mit mehreren Seiten, bis du ihn finalisierst.</p>
</section>
<section>
<h3>3. Fotos bearbeiten (Skizzen)</h3>
<p>Tippe auf ein hochgeladenes Foto im Grid → <strong>Vollbild-Ansicht</strong>.</p>
<ul>
<li><strong>✏️ (oben)</strong> öffnet den Skizzen-Editor</li>
<li><strong>🗑️ (oben)</strong> löscht das Foto</li>
</ul>
<p>Im Skizzen-Editor:</p>
<ul>
<li>✏️ Stift (Freihand)</li>
<li>↗ Pfeil mit Spitze (von Start bis Ende ziehen)</li>
<li>▭ Rechteck</li>
<li>○ Ellipse</li>
<li>Farbe + Linienstärke rechts daneben</li>
<li>↶ Undo / 🗑 Alles zurücksetzen</li>
</ul>
<p>Oben rechts <strong>✓</strong> → Skizze wird als eigenständige neue Bericht-Seite gespeichert.
Das Original-Foto bleibt unverändert.</p>
</section>
<section>
<h3>4. Sprachnotizen</h3>
<p><strong>🎙 Sprachnotiz aufnehmen</strong> im Auftrag-Detail:</p>
<ul>
<li>Aufnahme starten, reden</li>
<li>Stopp → Vorhören</li>
<li>Senden → Audio landet im Auftrags-Anhang</li>
</ul>
<p>Unter der Foto-Liste erscheint eine eigene Sektion „🎙 Sprachnotizen" mit Play-Button je Eintrag.</p>
</section>
<section>
<h3>5. Berichte finalisieren</h3>
<p>Im Reiter <strong>Berichte</strong> siehst du alle deine Berichte mit Status.</p>
<ul>
<li><span class="status-draft">Entwurf</span> — kannst du noch erweitern</li>
<li><span class="status-final">Final</span> — PDF wurde erzeugt, der Bericht ist eingefroren</li>
</ul>
<p>Bericht öffnen → <strong>📑 Bericht finalisieren</strong> → erzeugt das PDF und legt es
unter „Verknüpfte Dokumente" des Auftrags ab. In Dolibarr siehst du den Bericht dann direkt beim Auftrag.</p>
<p class="tip">💡 Wenn du nach dem Finalisieren neue Fotos machst, wird automatisch ein
<strong>neuer</strong> Entwurf angelegt. Der finalisierte bleibt unberührt.</p>
</section>
<section>
<h3>6. Offline arbeiten</h3>
<p>Die App funktioniert auch ohne Internet:</p>
<ul>
<li>Fotos werden in einer lokalen Warteschlange gespeichert</li>
<li>Sobald wieder Empfang da ist, werden sie automatisch hochgeladen</li>
<li>Oben rechts siehst du den Status: <span class="status-badge-help">🟢 online</span>,
<span class="status-badge-help">🟡 N</span> (N Uploads werden synchronisiert),
<span class="status-badge-help">🔴 N</span> (offline, N warten)</li>
</ul>
</section>
<section>
<h3>7. Auf dem Handy installieren</h3>
<p>So wird die PWA zur echten App:</p>
<ul>
<li><strong>Android/Chrome:</strong> Menü → „Zum Startbildschirm hinzufügen"</li>
<li><strong>iPhone/Safari:</strong> Teilen-Symbol → „Zum Home-Bildschirm"</li>
</ul>
<p>Danach startet sie wie eine normale App, ohne Browser-Leiste.</p>
</section>
<section>
<h3>8. Einstellungen</h3>
<p>Im Reiter <strong>⚙️</strong>:</p>
<ul>
<li>🔄 Offline-Queue manuell synchronisieren</li>
<li>🚪 Abmelden</li>
</ul>
</section>
<div class="help-footer">
<p>Baustelle PWA v1.0 · Fragen? → Eddy</p>
</div>
</div>
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#help-close').onclick = () => closeModal(modal);
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 = `
<div class="fs-header">
<button class="icon-btn" id="dv-close">✕</button>
<div class="fs-title">${escapeHtml(filename || '')}</div>
<button class="icon-btn" id="dv-share" title="Teilen">📤</button>
</div>
<div class="dv-body">
<img src="${url}" style="max-width:100%;max-height:100%;object-fit:contain;">
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="pdf-prev" title="← Seite zurück"></button>
<div class="pdf-page-info" id="pdf-page-info">⏳</div>
<button class="icon-btn" id="pdf-next" title="Seite vor →"></button>
<div class="fs-title">${escapeHtml(filename || '')}</div>
<button class="icon-btn" id="pdf-close">✕</button>
<button class="icon-btn" id="pdf-share" title="Teilen">📤</button>
<button class="icon-btn" id="pdf-download" title="Download">⬇</button>
</div>
<div class="pdf-body" id="pdf-body">
<canvas id="pdf-canvas"></canvas>
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="fs-close">✕</button>
<div class="fs-title" id="pv-title"></div>
<div class="pv-counter" id="pv-counter"></div>
<button class="icon-btn" id="fs-share" title="Teilen">📤</button>
<button class="icon-btn" id="fs-sketch" title="Zeichnen">✏️</button>
<button class="icon-btn" id="fs-delete" title="Löschen">🗑️</button>
</div>
<div class="pv-body" id="pv-body">
<img id="pv-image" src="" draggable="false">
</div>
<button class="pv-nav pv-prev" id="pv-prev"></button>
<button class="pv-nav pv-next" id="pv-next"></button>
<div class="pv-zoom-btns">
<button id="pv-zout"></button>
<button id="pv-zreset">⟳</button>
<button id="pv-zin">+</button>
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="v-close">✕</button>
<div class="fs-title">🎙 Sprachnotiz</div>
<span></span>
</div>
<div class="voice-body">
<div class="voice-indicator" id="v-indicator">●</div>
<div class="voice-time" id="v-time">00:00</div>
<button class="btn btn-large" id="v-start">Aufnahme starten</button>
<button class="btn btn-secondary" id="v-stop" style="display:none">Stopp</button>
<button class="btn" id="v-send" style="display:none">Senden</button>
<audio id="v-preview" controls style="display:none;width:100%;margin-top:12px;"></audio>
</div>
`;
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 = `
<div class="fs-header">
<button class="icon-btn" id="sk-close">✕</button>
<div class="fs-title">✏️ Skizze</div>
<button class="icon-btn" id="sk-save" title="Speichern">✓</button>
</div>
<div class="sketch-toolbar">
<button class="sk-tool active" data-tool="pen" title="Stift">✏️</button>
<button class="sk-tool" data-tool="arrow" title="Pfeil">↗</button>
<button class="sk-tool" data-tool="rect" title="Rechteck">▭</button>
<button class="sk-tool" data-tool="circle" title="Kreis">○</button>
<span class="sep"></span>
<button class="sk-tool" data-tool="stamp" data-stamp="⚠" title="Achtung">⚠</button>
<button class="sk-tool" data-tool="stamp" data-stamp="✓" title="OK">✓</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>
<span class="sep"></span>
<button class="sk-tool" data-tool="calibrate" title="Mess-Skala kalibrieren (2 Punkte + Länge)">📏⚙</button>
<button class="sk-tool" data-tool="measure" title="Messen (nach Kalibrierung)">📏</button>
<span class="sep"></span>
<input type="color" id="sk-color" value="#ff0000">
<input type="range" id="sk-width" min="2" max="20" value="5">
<span class="sep"></span>
<button id="sk-undo" title="Rückgängig">↶</button>
<button id="sk-clear" title="Alles löschen">🗑</button>
</div>
<div class="sketch-body">
<canvas id="sk-canvas"></canvas>
</div>
`;
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);
};
}