baustelle-pwa/app.js
Eddy 30202f8f28 Keine Doppel-Uploads mehr, App-Update laedt nicht mitten in der Arbeit neu
Doppel-Uploads (Prod-Log 17.09.: reihenweise duplicate:true): Seite
(syncQueue) und Service Worker (Background Sync) arbeiteten dieselbe
IndexedDB-Queue ab, ohne voneinander zu wissen. Dank md5-Abgleich
folgenlos, aber doppeltes Datenvolumen. Mit eingestelltem Verkleinern
sogar zwei verschiedene Dateien im Auftrag: der Worker lud das
Original, die Seite danach die kleine Fassung.

- idb.js: queueKeys/queuePatch/queueClaim/queueRelease - Lesen-Aendern-
  Schreiben in einer readwrite-Transaktion, ueber Kontexte atomar
- syncQueue (Seite) und drainQueue (Worker) belegen jedes Foto vor dem
  Upload (Lease 90 s > laengster Upload-Timeout); belegt = ueberspringen,
  abgelaufen = uebernehmen. Nur noch ein Foto gleichzeitig im Speicher
- enqueuePhoto({hold:true}) + releaseHold: waehrend des Verkleinerns
  bleibt das Foto belegt
- Versuchszaehler, retryFailed und replaceQueuedBlob per Patch statt
  ganzes Item zurueckschreiben

Reload nach App-Update: controllerchange lud sofort neu - offene Kamera,
getippte Notiz, halbe Unterschrift weg; mit Fotos in der Warteschlange
kam dazu der Browserdialog "Seite verlassen?".

- window.appBusy(): Modal, Auswahl, laufender Upload, Eingabe im Fokus,
  laufende Sprachnotiz. PIN-Sperre zaehlt bewusst nicht
- index.php holt den Reload nach, sobald nichts mehr offen ist; eigener
  Reload setzt __selfReload, beforeunload warnt dann nicht

Verifiziert lokal in Chromium: echte Kamera-Oberflaeche mit Canvas-
Stream offline, paralleler Zugriff Seite+Worker (3 Fotos = 3 POSTs im
Serverlog, kein duplicate), Verkleinern (eine Datei, kleine Fassung),
fremde Lease frisch/abgelaufen, aufgeschobener Reload ohne Dialog.
Nicht getestet: Kamera-Hardware und Background Sync bei Display aus am
Handy. Kein Deploy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:49:02 +02:00

4279 lines
196 KiB
JavaScript
Raw 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
* zählt den eigenen history.back() mit (_pendingBacks), damit dessen
* popstate kein doppeltes Cleanup auslöst.
* ============================================================ */
const modalStack = [];
// Selbst ausgeloeste history.back(), deren popstate noch aussteht. Zaehler statt Boolean:
// schliessen sich zwei Dialoge direkt nacheinander, stehen ZWEI popstate aus — ein Boolean
// verschluckt nur das erste, das zweite raeumt dann faelschlich den Stapel ab (KB #1209).
let _pendingBacks = 0;
let _backWaiters = [];
let _lastBackAt = 0;
function _flushBackWaiters() {
const w = _backWaiters;
_backWaiters = [];
w.forEach(fn => { try { fn(); } catch (_) {} });
}
/* history.back() ist ASYNCHRON. Wer direkt danach den Hash wechselt (closeModal +
* router.go), ueberholt den Ruecksprung: der laeuft erst NACH dem Hash-Wechsel und springt
* hinter den neuen Eintrag zurueck. Auf dem Bildschirm steht dann der Auftrag, in der
* Adresszeile aber '#/orders' — und das naechste router.navigate() (z.B. "Fertig" in der
* Kamera) zeichnet die Auftragsliste. Genau so flog Eddy am 17.09.2026 aus dem frisch
* angelegten Auftrag 111. router.go() wartet deshalb hierauf, bevor es den Hash setzt.
* Die Schutzzeit verhindert, dass ein ausbleibendes popstate die Navigation fuer immer
* blockiert (history.back() am Anfang der History ist ein stilles Nichts). */
window.historySettled = function () {
if (_pendingBacks <= 0) return Promise.resolve();
return new Promise(resolve => {
_backWaiters.push(resolve);
setTimeout(() => {
if (_pendingBacks > 0) { _pendingBacks = 0; _flushBackWaiters(); }
}, 1500);
});
};
// 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 });
const push = () => {
// Inzwischen schon wieder geschlossen? Dann keinen verwaisten Eintrag anlegen.
if (!modalStack.some(m => m.el === el)) return;
try {
history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash);
} catch (e) { /* some browsers may block in strict sandboxes */ }
};
// Laeuft noch ein Ruecksprung eines eben geschlossenen Modals (Bestaetigung zu, naechster
// Dialog auf), erst den abwarten — sonst nimmt er den NEUEN Eintrag gleich wieder mit.
if (_pendingBacks > 0) window.historySettled().then(push);
else push();
}
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 {}
// Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion. Das passiert VOR
// dem cleanup: navigiert der (Kamera: zurueck in den Auftrag), sieht router.go() den
// ausstehenden Ruecksprung und wartet ihn ab, statt von ihm ueberholt zu werden.
if (history.state && history.state._modal) {
_pendingBacks++;
history.back();
}
try { entry.cleanup && entry.cleanup(); } catch {}
}
/* Darf die Seite JETZT neu laden? Gefragt von index.php, wenn nach einem Deploy ein neuer
* Service Worker uebernimmt. Bis 18.09.2026 lud sie dann sofort neu — mitten in der offenen
* Kamera, in der getippten Notiz, in der halben Unterschrift. Alles davon ist danach weg.
*
* Ausdruecklich NICHT beschaeftigt ist die PIN-Sperre: dort ist der beste Moment zum
* Neuladen. Wuerde sie als Modal zaehlen, kaeme der Reload direkt NACH der PIN-Eingabe —
* und die PIN-Abfrage gleich ein zweites Mal. */
window.appBusy = function () {
const app = document.getElementById('app');
if (app && app.style.visibility === 'hidden') return false; // PIN-Sperre
if (modalStack.length > 0 || selectModeCleanup) return true; // Kamera, Notiz, Unterschrift, Auswahl
try { if (offline.isSyncing()) return true; } catch (_) {} // halb gesendeter Upload
// Es wird gerade getippt. Suchfelder zaehlen nicht: am Handy behalten sie den Fokus, bis
// man die Seite verlaesst — der Reload kaeme nie, und ein Suchwort ist kein Verlust.
const a = document.activeElement;
if (a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'search') return true;
if (Array.from(document.querySelectorAll('audio')).some(x => !x.paused)) return true;
return false;
};
function isTopLevelHash(h) {
const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/';
return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh);
}
window.addEventListener('popstate', () => {
if (_pendingBacks > 0) {
_pendingBacks--;
if (_pendingBacks === 0) _flushBackWaiters();
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');
}
});
/* ============================================================
* Eigene Dialoge statt confirm()/alert()/prompt().
*
* Browser-Dialoge sind in der installierten PWA fehl am Platz: sie zeigen unter Android
* die Herkunfts-URL, blockieren den kompletten Thread und sehen aus wie ein Systemfehler.
* Alle drei Helfer geben ein Promise zurück und hängen im normalen Modal-Stack, damit der
* Hardware-Zurück-Button sie schließt (= Abbruch).
*
* await confirmDialog({title, text, confirmLabel, danger}) → true/false
* await inputDialog({title, text, placeholder, value}) → String oder null
* await alertDialog({title, text}) → void
* ============================================================ */
function _dialogShell(inner) {
const el = document.createElement('div');
el.className = 'dialog-overlay';
el.innerHTML = `<div class="dialog-box" role="dialog" aria-modal="true">${inner}</div>`;
return el;
}
function confirmDialog({ title, text, confirmLabel, cancelLabel, danger } = {}) {
return new Promise(resolve => {
const el = _dialogShell(`
${title ? `<div class="dialog-title">${escapeHtml(title)}</div>` : ''}
${text ? `<div class="dialog-text">${escapeHtml(text)}</div>` : ''}
<div class="dialog-actions">
<button class="btn btn-secondary" data-act="no">${escapeHtml(cancelLabel || 'Abbrechen')}</button>
<button class="btn ${danger ? 'btn-danger' : ''}" data-act="yes">${escapeHtml(confirmLabel || 'OK')}</button>
</div>
`);
let done = false;
const finish = (val) => { if (done) return; done = true; resolve(val); };
document.body.appendChild(el);
pushModal(el, () => finish(false)); // Android-Back / popstate = Abbruch
el.querySelector('[data-act="no"]').onclick = () => { finish(false); closeModal(el); };
el.querySelector('[data-act="yes"]').onclick = () => { finish(true); closeModal(el); };
// Klick auf die Abdunklung neben der Box bricht ab
el.addEventListener('click', e => { if (e.target === el) { finish(false); closeModal(el); } });
setTimeout(() => el.querySelector('[data-act="yes"]').focus(), 30);
});
}
function alertDialog({ title, text, okLabel } = {}) {
return new Promise(resolve => {
const el = _dialogShell(`
${title ? `<div class="dialog-title">${escapeHtml(title)}</div>` : ''}
${text ? `<div class="dialog-text">${escapeHtml(text)}</div>` : ''}
<div class="dialog-actions">
<button class="btn" data-act="ok">${escapeHtml(okLabel || 'OK')}</button>
</div>
`);
let done = false;
const finish = () => { if (done) return; done = true; resolve(); };
document.body.appendChild(el);
pushModal(el, finish);
el.querySelector('[data-act="ok"]').onclick = () => { finish(); closeModal(el); };
el.addEventListener('click', e => { if (e.target === el) { finish(); closeModal(el); } });
setTimeout(() => el.querySelector('[data-act="ok"]').focus(), 30);
});
}
function inputDialog({ title, text, placeholder, value, okLabel, inputMode } = {}) {
return new Promise(resolve => {
const el = _dialogShell(`
${title ? `<div class="dialog-title">${escapeHtml(title)}</div>` : ''}
${text ? `<div class="dialog-text">${escapeHtml(text)}</div>` : ''}
<input class="dialog-input" type="text" ${inputMode ? `inputmode="${escapeHtml(inputMode)}"` : ''}
placeholder="${escapeHtml(placeholder || '')}" value="${escapeHtml(value || '')}">
<div class="dialog-actions">
<button class="btn btn-secondary" data-act="no">Abbrechen</button>
<button class="btn" data-act="yes">${escapeHtml(okLabel || 'OK')}</button>
</div>
`);
let done = false;
const finish = (val) => { if (done) return; done = true; resolve(val); };
document.body.appendChild(el);
pushModal(el, () => finish(null));
const input = el.querySelector('.dialog-input');
const submit = () => { finish(input.value.trim()); closeModal(el); };
el.querySelector('[data-act="no"]').onclick = () => { finish(null); closeModal(el); };
el.querySelector('[data-act="yes"]').onclick = submit;
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
el.addEventListener('click', e => { if (e.target === el) { finish(null); closeModal(el); } });
setTimeout(() => { input.focus(); input.select(); }, 60);
});
}
/* ============================================================
* 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() {
// Session steckt im awl_sso-Cookie -> serverseitig prüfen (sliding renewal).
try {
await api.request('/verify.php');
return true;
} catch (e) {
// 401 -> Cookie fehlt/abgelaufen -> Login. Netzfehler (offline) -> NICHT
// ausloggen, die PWA muss offline nutzbar bleiben.
if (e.status === 401) { 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) {
await alertDialog({ title: 'PIN', text: 'Die beiden Eingaben stimmen nicht überein.' });
return null;
}
return p1;
}
/**
* Startet die App — fragt ggf. PIN ab bevor router läuft.
*/
window.appBoot = async function appBoot() {
// Kein Token-Preload mehr — die Session steckt im HttpOnly-Cookie awl_sso, das
// der Browser bei jedem same-origin-Request automatisch mitschickt.
// 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 = '';
}
/* ==========================================================================
* Offline-Anzeige: Spiegel statt roher Browsermeldung
*
* Vorher zeigte jede Liste im Fehlerfall stumpf `e.message`. Kam die Anfrage nicht
* bis zum Server, war das woertlich "Failed to fetch" — fuer den Benutzer sinnlos,
* und die App war ohne Netz komplett leer (gemeldet 31.08.2026).
*
* Jetzt: erfolgreiche Antworten werden gespiegelt (offline.mirrorSave), und bei einem
* Netzfehler zeigt die Route den letzten Stand mit sichtbarem Zeitstempel. Ein
* Serverfehler (401/403/500) laeuft weiterhin in die Fehlermeldung — nur Anfragen,
* die gar nicht ankamen, tragen `e.offline`.
* ========================================================================== */
/* Frisch holen, spiegeln — und wenn kein Netz da ist, den Spiegel nehmen.
* Rueckgabe: { data, stale, ts }. Ohne Netz UND ohne Spiegel fliegt der Fehler weiter. */
async function loadMirrored(key, loader) {
try {
const data = await loader();
await offline.mirrorSave(key, data);
return { data, stale: false, ts: 0 };
} catch (e) {
if (!e || !e.offline) throw e; // echter Serverfehler -> ehrlich melden
const m = await offline.mirrorLoad(key);
if (!m) throw e; // nichts gespeichert -> ebenfalls melden
return { data: m.data, stale: true, ts: m.ts };
}
}
/* Orangefarbenes Band unter der Kopfzeile. Ohne sichtbaren Hinweis haelt man alte
* Zahlen fuer aktuell — genau davor warnt KB #1037. */
function setStale(stale, ts) {
let band = document.getElementById('offline-band');
if (!stale) { if (band) band.remove(); return; }
if (!band) {
band = document.createElement('div');
band.id = 'offline-band';
const bar = document.getElementById('topbar');
bar.parentNode.insertBefore(band, bar.nextSibling);
}
const d = ts ? new Date(ts) : null;
const wann = d
? (d.toDateString() === new Date().toDateString()
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'
: d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }))
: 'unbekannt';
band.textContent = '📴 Kein Netz — angezeigt wird der Stand von ' + wann;
}
/* Einheitliche Fehlerbox. escapeHtml, weil hier eine Servermeldung landen kann. */
function renderError(e) {
const msg = escapeHtml((e && e.message) || 'Unbekannter Fehler');
if (e && e.offline) {
return '<div class="empty-state"><div class="icon">📴</div>' + msg
+ '<div class="label" style="margin-top:10px;line-height:1.5">Auf diesem Gerät ist zu dieser'
+ ' Ansicht noch nichts gespeichert. Sobald wieder Netz da ist, lädt sie von selbst.</div></div>';
}
return '<div class="empty-state"><div class="icon">⚠️</div>' + msg + '</div>';
}
/* Ohne Netz kann nicht der Server suchen — dann im gespiegelten Stand filtern.
* Das wird angesagt (Toast), sonst haelt man das Ergebnis fuer eine Vollsuche. */
const ORDER_SEARCH_FIELDS = ['ref', 'customer.name', 'customer.town', 'customer.address', 'customer.zip'];
const CUSTOMER_SEARCH_FIELDS = ['name', 'town', 'zip', 'code', 'phone'];
let localSearchHintShown = false;
function localFilter(list, q, fields) {
const needle = (q || '').trim().toLowerCase();
if (!needle) return list;
return list.filter(o => fields.some(f => {
const v = f.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
return v != null && String(v).toLowerCase().includes(needle);
}));
}
function localSearchHint() {
if (localSearchHintShown) return;
localSearchHintShown = true;
showToast('Ohne Netz wird nur im gespeicherten Stand gesucht');
}
/* Kommt das Netz zurueck, erst frische Daten holen — danach verschwindet das Band
* von selbst, weil die Route dann mit stale=false neu zeichnet. */
window.addEventListener('online', () => {
if (document.getElementById('offline-band')) router.navigate();
});
/* ====== 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>
<div style="position:relative;">
<input type="password" id="bl-pass" name="password" placeholder="Passwort" autocomplete="current-password" required style="padding-right:46px;">
<button type="button" id="bl-pass-toggle" tabindex="-1" aria-label="Passwort anzeigen" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:0;color:#f0f0f0;opacity:0.5;cursor:pointer;padding:6px;line-height:0;">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor"><path d="M12 5c-5 0-9.3 3.1-11 7 1.7 3.9 6 7 11 7s9.3-3.1 11-7c-1.7-3.9-6-7-11-7zm0 12a5 5 0 110-10 5 5 0 010 10zm0-8a3 3 0 100 6 3 3 0 000-6z"/></svg>
</button>
</div>
<button type="submit" class="btn btn-large">Anmelden</button>
<button type="button" id="bl-passkey" class="btn btn-secondary" style="display:none;align-items:center;justify-content:center;gap:8px;margin-top:10px;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true" style="flex:0 0 auto;"><path d="M17.81 4.47c-.08 0-.16-.02-.23-.06C15.66 3.42 14 3 12.01 3c-1.98 0-3.86.47-5.57 1.41-.24.13-.54.04-.68-.2-.13-.24-.04-.55.2-.68C7.82 2.52 9.86 2 12.01 2c2.13 0 3.99.47 6.03 1.52.25.13.34.43.21.67-.09.18-.26.28-.44.28zM3.5 9.72c-.1 0-.2-.03-.29-.09-.23-.16-.28-.47-.12-.7.99-1.4 2.25-2.5 3.75-3.27C9.98 4.04 14 4.03 17.15 5.65c1.5.77 2.76 1.86 3.75 3.25.16.22.11.54-.12.7-.23.16-.54.11-.7-.12-.9-1.26-2.04-2.25-3.39-2.94-2.87-1.47-6.54-1.47-9.4.01-1.36.7-2.5 1.7-3.4 2.96-.08.14-.23.21-.39.21zm6.25 12.07c-.13 0-.26-.05-.35-.15-.87-.87-1.34-1.43-2.01-2.64-.69-1.23-1.05-2.73-1.05-4.34 0-2.97 2.54-5.39 5.66-5.39s5.66 2.42 5.66 5.39c0 .28-.22.5-.5.5s-.5-.22-.5-.5c0-2.42-2.09-4.39-4.66-4.39-2.57 0-4.66 1.97-4.66 4.39 0 1.44.32 2.77.93 3.85.64 1.15 1.08 1.64 1.85 2.42.19.2.19.51 0 .71-.11.1-.24.15-.37.15zm7.17-1.85c-1.19 0-2.24-.3-3.1-.89-1.49-1.01-2.38-2.65-2.38-4.39 0-.28.22-.5.5-.5s.5.22.5.5c0 1.41.72 2.74 1.94 3.56.71.48 1.54.71 2.54.71.24 0 .64-.03 1.04-.1.27-.05.53.13.58.41.05.27-.13.53-.41.58-.57.11-1.07.12-1.21.12zM14.91 22c-.04 0-.09-.01-.13-.02-1.59-.44-2.63-1.03-3.72-2.1-1.4-1.39-2.17-3.24-2.17-5.22 0-1.62 1.38-2.94 3.08-2.94 1.7 0 3.08 1.32 3.08 2.94 0 1.07.93 1.94 2.08 1.94s2.08-.87 2.08-1.94c0-3.77-3.25-6.83-7.25-6.83-2.84 0-5.44 1.58-6.61 4.03-.39.81-.59 1.76-.59 2.8 0 .78.07 2.01.67 3.61.1.26-.03.55-.29.64-.26.1-.55-.04-.64-.29-.49-1.31-.73-2.61-.73-3.96 0-1.2.23-2.29.68-3.24 1.33-2.79 4.28-4.6 7.51-4.6 4.55 0 8.25 3.51 8.25 7.83 0 1.62-1.38 2.94-3.08 2.94s-3.08-1.32-3.08-2.94c0-1.07-.93-1.94-2.08-1.94s-2.08.87-2.08 1.94c0 1.71.66 3.31 1.87 4.51.95.94 1.86 1.46 3.27 1.85.27.07.42.35.35.61-.05.23-.26.38-.48.38z"/></svg>
<span>Mit Fingerabdruck anmelden</span>
</button>
</form>
`;
const _blPass = document.getElementById('bl-pass');
const _blToggle = document.getElementById('bl-pass-toggle');
if (_blToggle && _blPass) _blToggle.addEventListener('click', () => {
const show = _blPass.type === 'password';
_blPass.type = show ? 'text' : 'password';
_blToggle.style.opacity = show ? '1' : '0.5';
});
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');
}
});
// WebAuthn / Passkey (Fingerabdruck) — nur wenn der Browser es kann.
// Der Passkey gilt domainweit (RP awl.data-it-solution.de): ein auf dem
// Handy registrierter Passkey loggt hier ohne Benutzername/Passwort ein.
const _pk = document.getElementById('bl-passkey');
if (_pk && window.awlauthWebAuthn && awlauthWebAuthn.isSupported()) {
_pk.style.display = 'flex';
_pk.addEventListener('click', async () => {
_pk.disabled = true;
try {
const res = await awlauthWebAuthn.login();
// Profil wie beim Passwort-Login ablegen (Einstellungen liest idb 'user'),
// sonst zeigt der Konto-Block nach reinem Passkey-Login 'Unbekannt'.
if (res && res.user) { await idb.set('user', res.user); }
// awl_sso ist gesetzt -> Orders-Route rendert und faehrt ensureAuth()/verify.php
showToast('Erfolgreich angemeldet');
router.go('#/orders');
} catch (err) {
_pk.disabled = false;
showToast(err && err.message ? err.message : 'Anmeldung fehlgeschlagen', '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, stale, ts } = await loadMirrored('orders:open', () => api.listOrders({ open: 1 }));
setStale(stale, ts);
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 = renderError(e);
}
});
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));
});
}
// Online fragt der Server, ohne Netz wird im gespiegelten Stand gefiltert.
// Gespiegelt wird nur die ungefilterte Liste — eine Suchantwort waere ein
// Ausschnitt und wuerde beim naechsten Offline-Start als "der Stand" gelten.
const mirrorKey = () => 'orders:' + (showAllOrders ? 'all' : 'open');
async function fetchOrders(q) {
try {
const d = await loadOrders(q);
if (!q) await offline.mirrorSave(mirrorKey(), d);
setStale(false);
return d.orders || [];
} catch (e) {
if (!e || !e.offline) throw e;
const m = await offline.mirrorLoad(mirrorKey());
if (!m) throw e;
setStale(true, m.ts);
if (q) localSearchHint();
return localFilter((m.data && m.data.orders) || [], q, ORDER_SEARCH_FIELDS);
}
}
try {
const data = { orders: await fetchOrders('') };
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();
async function redrawOrders(q) {
try {
document.getElementById('order-list').innerHTML = renderOrderList(await fetchOrders(q));
bindOrderCards();
} catch (err) {
showToast((err && err.message) || 'Fehler', 'error');
}
}
document.getElementById('order-search').addEventListener('input', (e) => redrawOrders(e.target.value));
document.getElementById('show-all-toggle').addEventListener('change', (e) => {
showAllOrders = e.target.checked;
localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0');
redrawOrders(document.getElementById('order-search').value);
});
} catch (e) {
main().innerHTML = renderError(e);
}
});
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 {
// Als EIN Paket spiegeln — der Auftrag ohne seine Fotos und Notizen waere
// offline nur ein halber Auftrag. Die Vorschaubilder selbst liegen im
// Bild-Cache des Service Workers und stehen damit ebenfalls ohne Netz.
const bundle = await loadMirrored('detail:order:' + args.id, async () => {
const order = await api.getOrder(args.id);
// Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr
const [ph, nt] = await Promise.all([
api.listOrderPhotos(args.id).catch(() => ({ photos: [] })),
api.listNotes(args.id).catch(() => ({ notes: [] })),
]);
return { order, photos: ph, notes: nt };
});
setStale(bundle.stale, bundle.ts);
const data = bundle.data.order;
const photos = bundle.data.photos;
const notes = bundle.data.notes;
const notesCount = notes.notes.length;
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) &&
// Notizen haben ihre eigene Sektion — hier wären sie doppelt
!/^notiz_.*\.txt$/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-notes">📝 Notizen${notesCount ? ' (' + notesCount + ')' : ''}</button>
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
<div id="pending-photos" data-order-id="${escapeHtml(String(args.id))}" style="margin-top:16px;"></div>
<div class="detail-section">
<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);
document.getElementById('btn-notes').onclick = () => openNotesModal(args.id);
// Neuen Bericht anlegen
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
// Materialliste
document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id);
// Lieferungen
document.getElementById('btn-shipments').onclick = () => router.go('#/orders/' + args.id + '/shipments');
// 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 params = new URLSearchParams({ relpath: rel });
const r = await fetch(window.location.origin + '/custom/bericht/api/photo.php?' + params.toString(), { credentials: 'same-origin' });
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();
renderPendingPhotos(); // was noch in der Warteschlange liegt, gehoert sichtbar zum Auftrag
const camInput = document.getElementById('camera-input');
const galInput = document.getElementById('gallery-input');
// Live-Kamera (bleibt im Kamera-Modus für mehrere Fotos). Fällt intern auf
// den nativen camera-input zurück, wenn getUserMedia nicht verfügbar ist.
document.getElementById('btn-take-photo').onclick = () => openCameraModal(args.id);
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
async function handleFiles(files) {
// Screen wachhalten, solange die Auswahl abgearbeitet wird — sonst pausiert
// das Verkleinern mitten in der Serie (siehe resizeImage).
await offline.acquireWakeLock();
try {
for (const f of files) {
// Ein einzelnes Foto darf die restliche Auswahl nicht blockieren
try {
await uploadPhoto(args.id, f);
} catch (e) {
showToast('Foto ' + (f.name || '') + ' fehlgeschlagen: ' + ((e && e.message) || ''), 'error');
}
}
} finally {
offline.releaseWakeLock();
}
// Nach Upload einfach die Route neu rendern — so werden Select-Mode,
// Click-Handler und Thumbnails sauber neu aufgebaut. Ausdruecklich diesen
// Auftrag, nicht den Hash der Adresszeile (siehe Kamera-Cleanup).
router.go('#/orders/' + args.id);
announcePendingPhotos(args.id);
}
camInput.addEventListener('change', () => handleFiles(camInput.files));
galInput.addEventListener('change', () => handleFiles(galInput.files));
} catch (e) {
main().innerHTML = renderError(e);
}
});
/* ============================================================
* WARTENDE FOTOS AM AUFTRAG
*
* Die Auftragsseite zeigte nur, was der Server schon hat. Fotos in der Warteschlange
* standen nirgends ausser als Zahl im Badge oben rechts. Wer im Funkloch fotografiert und
* danach in den Auftrag schaut, sieht: nichts — und haelt die Fotos fuer verloren
* (gemeldet 18.09.2026). Deshalb ein eigener Block mit Vorschaubildern direkt aus der
* Warteschlange und einem Satz, der sagt, was los ist.
*
* Der Block haengt an #pending-photos (data-order-id). Den gibt es nur, solange die
* Auftragsseite gezeichnet ist — daran erkennen die Listener unten, ob sie gemeint sind.
* ============================================================ */
const PENDING_PREVIEW_MAX = 12; // mehr Vorschaubilder kosten nur Speicher
let pendingPhotoUrls = [];
let pendingRenderTimer = null;
let orderRefreshTimer = null;
async function renderPendingPhotos() {
const box = document.getElementById('pending-photos');
if (!box) return;
const orderId = box.dataset.orderId;
const items = (await offline.listQueue().catch(() => []))
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId));
if (!document.body.contains(box)) return; // Seite wurde inzwischen neu gezeichnet
pendingPhotoUrls.forEach(u => { try { URL.revokeObjectURL(u); } catch (_) {} });
pendingPhotoUrls = [];
if (!items.length) { box.innerHTML = ''; return; }
const failed = items.filter(i => i.failed).length;
const waiting = items.length - failed;
const fotos = (n) => n + (n === 1 ? ' Foto' : ' Fotos');
const lines = [];
if (waiting) {
lines.push(navigator.onLine === false
? '📴 Kein Netz — ' + fotos(waiting) + (waiting === 1 ? ' ist' : ' sind')
+ ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden')
+ ' gesendet, sobald wieder Netz da ist.'
: fotos(waiting) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und '
+ (waiting === 1 ? 'geht' : 'gehen') + ' raus, sobald die Verbindung steht.');
}
if (failed) lines.push('⚠️ ' + fotos(failed) + ' konnte' + (failed === 1 ? '' : 'n')
+ ' nicht hochgeladen werden — antippen zum Prüfen.');
const shown = items.slice(0, PENDING_PREVIEW_MAX);
const thumbs = shown.map(it => {
const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' }));
pendingPhotoUrls.push(url);
return '<div class="thumb pending-thumb' + (it.failed ? ' is-failed' : '') + '">'
+ '<img src="' + url + '" alt="" loading="lazy">'
+ '<span class="pending-mark">' + (it.failed ? '⚠️' : '⏳') + '</span></div>';
}).join('');
const more = items.length - shown.length;
box.innerHTML = '<div class="detail-section pending-section">'
+ '<h3>⏳ Warten auf Upload (' + items.length + ')</h3>'
+ lines.map(l => '<p>' + escapeHtml(l) + '</p>').join('')
+ '<div class="photo-grid pending-grid">' + thumbs
+ (more > 0 ? '<div class="thumb pending-thumb pending-more">+' + more + '</div>' : '')
+ '</div></div>';
box.querySelector('.pending-section').onclick = () => openUploadQueueModal();
}
/* Nach dem Schliessen der Kamera sagen, was mit den Fotos ist. Ohne den Satz sieht
* "gesichert, aber noch nicht gesendet" genauso aus wie "alles erledigt". */
async function announcePendingPhotos(orderId) {
const n = (await offline.listQueue().catch(() => []))
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)).length;
if (!n) return;
const fotos = n + (n === 1 ? ' Foto' : ' Fotos');
showToast(navigator.onLine === false
? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch'
: '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn');
}
function renderPendingPhotosSoon() {
if (!document.getElementById('pending-photos')) return;
clearTimeout(pendingRenderTimer);
pendingRenderTimer = setTimeout(renderPendingPhotos, 300);
}
/* Kam ein Foto dieses Auftrags an, die Seite nachziehen — sonst steht es weder unter
* "Warten auf Upload" noch unter "Hochgeladene Fotos". Gebuendelt (eine Serie soll nicht
* je Foto neu zeichnen) und nur, wenn gerade nichts offen ist, das dabei kaputtginge. */
function scheduleOrderRefresh() {
clearTimeout(orderRefreshTimer);
orderRefreshTimer = setTimeout(async () => {
const box = document.getElementById('pending-photos');
if (!box) return; // Auftragsseite nicht mehr offen
const playing = Array.from(document.querySelectorAll('#main audio')).some(a => !a.paused);
if (modalStack.length || selectModeCleanup || playing) { scheduleOrderRefresh(); return; }
const y = window.scrollY, my = main().scrollTop;
try { await router.navigate('#/orders/' + box.dataset.orderId); } catch (_) {}
try { window.scrollTo(0, y); main().scrollTop = my; } catch (_) {}
}, 2500);
}
window.addEventListener('photo-uploaded', (e) => {
const box = document.getElementById('pending-photos');
if (!box || String((e.detail || {}).orderId) !== String(box.dataset.orderId)) return;
renderPendingPhotosSoon();
scheduleOrderRefresh();
});
window.addEventListener('queue-changed', renderPendingPhotosSoon);
window.addEventListener('online', renderPendingPhotosSoon);
window.addEventListener('offline', renderPendingPhotosSoon);
/* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität).
* Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal
* zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt.
*/
const PHOTO_CFG_KEY = 'baustelle.photo_cfg';
let photoCfg = (function () {
try { return JSON.parse(localStorage.getItem(PHOTO_CFG_KEY)) || null; } catch (_) { return null; }
})() || { photo_maxside: 0, photo_quality: 100 };
async function refreshPhotoConfig() {
try {
const c = await api.getConfig();
if (c && typeof c.photo_maxside === 'number') {
photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100 };
localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg));
}
} catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ }
}
async function uploadPhoto(orderId, file) {
// Persist-First, streng: das Foto geht UNVERAENDERT und als ALLERERSTES in die
// Queue. Frueher wurde davor verkleinert — und weil das Verkleinern bei
// ausgeschaltetem Display haengen bleibt (pausiertes Decoding), war das Foto in
// dem Moment nirgends gesichert und die ganze Auswahl-Schleife stand. Genau so
// gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts
// mehr passieren, was das Foto kostet.
const name = file.name || ('foto_' + Date.now() + '.jpg');
// Wird gleich verkleinert, kommt das Foto BELEGT in die Queue (hold) — sonst laedt der
// Service Worker das Original hoch, waehrend hier die kleine Fassung entsteht, und im
// Auftrag liegen zwei verschiedene Dateien.
const willShrink = photoCfg.photo_maxside > 0;
const qid = await offline.enqueuePhoto(orderId, file, name, { hold: willShrink });
// Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
if (willShrink) {
try {
const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
if (small && small !== file && small.size < file.size) {
await offline.replaceQueuedBlob(qid, small);
}
} catch (_) { /* Original bleibt gesichert */ }
finally { await offline.releaseHold(qid); }
}
if (navigator.onLine) showToast('Sende ' + name + '…');
else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn');
await offline.syncQueue();
}
async function resizeImage(file, maxSide, timeoutMs, quality) {
// SCHUTZZEIT (v1.1): Schaltet sich das Display waehrend einer Serie ab, pausiert der
// Browser Bild-Decoding und Canvas — dann feuert weder onload noch onerror und das
// Promise loeste NIE auf. Das passiert VOR dem Sichern in der Queue, die betroffenen
// Fotos waren also nirgends gespeichert, und weil die Auswahl sequentiell laeuft,
// stand die komplette restliche Auswahl still (28.08.2026, Auftrag PROV105: 8 von 19
// Fotos kamen nie an und waren auch nicht in der Queue zum Nachholen).
// Im Zweifel lieber das unverkleinerte Original sichern als gar nichts.
const shrink = 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', typeof quality === 'number' ? quality : 0.85);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
const guard = new Promise((resolve) => setTimeout(() => resolve(file), timeoutMs || 15000));
return Promise.race([shrink, guard]);
}
/**
* Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid.
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
* deshalb Blob-URLs).
*/
/* Kacheln mit serverseitigen Thumbnails füllen.
*
* Vorher lief das sequenziell über fetch→Blob→ObjectURL und lud dabei die kompletten
* Fotos: photo.php?size=small liefert nur dann ein Thumbnail, wenn Dolibarr eines
* vorgefertigt hat — für Uploads aus der PWA gibt es keins. 30 Fotos hießen also
* 30 Vollbilder, eines nach dem anderen. Jetzt kommt das <img> direkt an die
* Thumb-URL: der Browser lädt parallel, respektiert loading="lazy" und cached über ETag.
*/
function loadThumbs(root) {
const scope = root || document;
const thumbs = scope.querySelectorAll('.photo-grid .thumb[data-relpath]');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.round(160 * dpr); // Kachel ~160 CSS-px
thumbs.forEach(t => {
if (t.dataset.thumbLoaded === '1') return;
const rel = t.dataset.relpath;
if (!rel) return; // z. B. Seite im Layout "nur Titel" — kein Bild
t.dataset.thumbLoaded = '1';
const img = new Image();
img.loading = 'lazy';
img.decoding = 'async';
img.alt = '';
img.className = 'thumb-img';
img.onerror = () => {
// Kein Bild (PDF/Video) oder Fehler → Original versuchen, sonst Platzhalter
if (img.dataset.retried === '1') {
img.remove();
const ph = t.querySelector('.thumb-placeholder');
if (ph) ph.textContent = '❌';
return;
}
img.dataset.retried = '1';
img.src = api.photoUrl(rel);
};
// Nur den Platzhalter entfernen — die Overlays der Kachel (Auswahl-Häkchen,
// Seitennummer, Titel-Badge) bleiben stehen. Vorher überschrieb das Laden des
// Thumbnails den ganzen Kachel-Inhalt und nahm sie mit.
img.onload = () => {
const ph = t.querySelector('.thumb-placeholder');
if (ph) ph.remove();
img.classList.add('is-loaded');
};
// Erst einhängen, dann src setzen: ein <img loading="lazy">, das nicht im
// Dokument steht, lädt der Browser überhaupt nicht — es hat kein Layout und
// gilt damit nie als "sichtbar". Der Platzhalter bleibt bis onload stehen.
t.insertBefore(img, t.firstChild);
img.src = api.photoThumbUrl(rel, w);
});
}
router.on('/customers', async () => {
if (!(await ensureAuth())) return;
title('Kunden');
setNav(true, 'customers');
setBack(false);
showLoader('Lade Kunden…');
async function fetchCustomers(q) {
try {
const d = await api.listCustomers(q ? { q } : {});
if (!q) await offline.mirrorSave('customers', d);
setStale(false);
return d.customers || [];
} catch (e) {
if (!e || !e.offline) throw e;
const m = await offline.mirrorLoad('customers');
if (!m) throw e;
setStale(true, m.ts);
if (q) localSearchHint();
return localFilter((m.data && m.data.customers) || [], q, CUSTOMER_SEARCH_FIELDS);
}
}
try {
const data = { customers: await fetchCustomers('') };
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) => {
try {
document.getElementById('cust-list').innerHTML =
renderCustomerList(await fetchCustomers(e.target.value));
bindCustomerCards();
} catch (err) {
showToast((err && err.message) || 'Fehler', 'error');
}
});
} catch (e) {
main().innerHTML = renderError(e);
}
});
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 mirrored = await loadMirrored('detail:customer:' + args.id, () => api.getCustomer(args.id));
setStale(mirrored.stale, mirrored.ts);
const data = mirrored.data;
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 = renderError(e);
}
});
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, stale, ts } = await loadMirrored('reports', () => api.listReports());
setStale(stale, ts);
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 = renderError(e);
}
});
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) => {
// composite_path ist das im Editor gebaute Seitenbild (Anmerkungen,
// Raster, Notizzone). Nur source_path zu zeigen hieße: das Rohbild
// ohne Bearbeitung — und bei einem Raster eines von bis zu sechs.
const rel = p.composite_path || p.source_path || '';
const badge = layoutBadge(p.layout);
return `
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(rel)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}" data-title="${escapeHtml(p.title || '')}">
<div class="thumb-placeholder">${rel ? '⏳' : '📄'}</div>
<div class="page-num">${i + 1}</div>
${badge ? `<div class="page-layout-badge">${badge}</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; }
const goOn = await confirmDialog(data.report.status === 1
? { title: 'PDF neu erzeugen', text: 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?', confirmLabel: 'Neu erzeugen' }
: { title: 'Bericht finalisieren', text: 'Bericht jetzt finalisieren und PDF erzeugen?', confirmLabel: 'Finalisieren' });
if (!goOn) 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 = renderError(e);
}
});
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');
// Der Offline-Spiegel muss mit weg — sonst sieht der naechste Benutzer an
// diesem Geraet ohne Netz die Auftraege, Kunden und Preise des vorherigen.
await offline.mirrorClear();
// Gecachte Kundenfotos vom Gerät nehmen — der Bild-Cache überlebt sonst die Abmeldung
try {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_MEDIA' });
}
} catch (e) { /* ohne SW nichts zu tun */ }
api.clearPhotoCache();
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 {
const off = await confirmDialog({
title: 'PIN-Schutz aus',
text: 'PIN-Schutz wirklich deaktivieren? Die App öffnet dann ohne Abfrage.',
confirmLabel: 'Deaktivieren',
danger: true,
});
if (!off) {
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');
};
});
/* Aus einem abgebrochenen Teilen-Vorgang koennen noch Inhalte bereitliegen — etwa
* weil beim Teilen aus WhatsApp die Anmeldung fehlte oder das Netz weg war. Sie
* bleiben in IndexedDB liegen; ohne diesen Hinweis wuerde sie niemand je wiederfinden. */
async function checkPendingShare() {
if (document.getElementById('shared-banner')) return;
let n = 0, vorhanden = false;
try {
const p = await idb.get('shared_payload');
if (p) { vorhanden = true; n = (p.files || []).length; }
else {
const alt = await idb.get('shared_files'); // altes Format
if (alt && alt.length) { vorhanden = true; n = alt.length; }
}
} catch (_) { return; }
if (!vorhanden) return;
const b = document.createElement('button');
b.id = 'shared-banner';
b.className = 'shared-banner';
b.textContent = '📤 Geteiltes ablegen' + (n ? ' (' + n + ' Bild' + (n > 1 ? 'er' : '') + ')' : '');
b.addEventListener('click', () => { location.href = 'share.html'; });
document.body.appendChild(b);
}
/* 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);
refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen
checkPendingShare(); // liegt noch etwas Geteiltes bereit?
});
/**
* 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 (!await confirmDialog({ title: 'Material', text: 'Diesen Eintrag löschen?', confirmLabel: 'Löschen', danger: true })) 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;justify-content:flex-start;">
<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">Ihr Zeichen <span style="color:#e66">*</span></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;">
<label class="label">Auftragsbeschreibung</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">Geplanter Liefertermin</label>
<input type="datetime-local" id="no-delivery" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;color-scheme:dark;">
<label class="no-check" style="display:flex;align-items:center;gap:10px;margin-top:12px;font-size:15px;color:#eee;cursor:pointer;">
<input type="checkbox" id="no-validate" style="width:20px;height:20px;">
Auftrag direkt freigeben
</label>
</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 deliveryIn = modal.querySelector('#no-delivery');
const validateCb = modal.querySelector('#no-validate');
const changeBtn = modal.querySelector('#no-change-customer');
// Mobile: beim Fokus das aktive Feld über die Bildschirmtastatur schieben
// (Tastatur öffnet verzögert -> kleiner Timeout, bis der Viewport geschrumpft ist)
[refIn, titleIn, deliveryIn].forEach((el) => {
el.addEventListener('focus', () => {
setTimeout(() => {
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {}
}, 300);
});
});
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;
refIn.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 refClient = refIn.value.trim();
if (!refClient) { showToast('Ihr Zeichen ist Pflicht', 'error'); refIn.focus(); return; }
const title = titleIn.value.trim();
const doValidate = validateCb.checked;
// datetime-local liefert lokale Zeit -> Unix-Timestamp (Sekunden); leer = 0
let deliveryTs = 0;
if (deliveryIn.value) {
const d = new Date(deliveryIn.value);
if (!isNaN(d.getTime())) deliveryTs = Math.floor(d.getTime() / 1000);
}
saveBtn.disabled = true;
showToast(doValidate ? 'Lege Auftrag an & gebe frei…' : 'Lege Auftrag an…');
try {
const res = await api.createOrder({
socid: selectedCustomer.id,
ref_client: refClient,
title: title,
date_livraison: deliveryTs,
validate: doValidate,
});
// 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) {}
const ref = res.order.ref || '';
showToast('✓ Auftrag ' + ref + (res.order.validated ? ' angelegt & freigegeben' : ' angelegt'));
if (res.validate_warning) showToast(res.validate_warning, 'error');
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 (!await confirmDialog({
title: 'Seite entfernen',
text: 'Diese Seite aus dem Bericht entfernen? Anmerkungen und Notiz der Seite gehen dabei verloren.',
confirmLabel: 'Entfernen',
danger: true,
})) 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');
};
}
/* ============================================================
* LIEFERUNGEN — Liste + Vollbild-Signatur-Workflow
* ============================================================ */
router.on('/orders/:id/shipments', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'orders');
setBack(true, '#/orders/' + args.id);
showLoader('Lade Lieferungen…');
title('Lieferungen');
try {
const order = await api.getOrder(args.id);
const data = await api.listShipments(args.id);
const list = data.shipments || [];
if (!list.length) {
main().innerHTML = `
<div class="detail-section">
<p><strong>${escapeHtml(order.order.ref)}</strong> · ${escapeHtml(order.customer.name)}</p>
</div>
<div class="empty-state">
<div class="icon">📭</div>
Zu diesem Auftrag gibt es noch keine Lieferungen.<br>
<span style="opacity:0.7;font-size:13px;">In Dolibarr erst eine Lieferung anlegen und validieren.</span>
</div>`;
return;
}
const statusLabel = (s) => ({0:'Entwurf', 1:'Validiert', 2:'Geschlossen', 3:'In Bearbeitung', '-1':'Storniert'})[s] || ('Status '+s);
main().innerHTML = `
<div class="detail-section">
<p><strong>${escapeHtml(order.order.ref)}</strong> · ${escapeHtml(order.customer.name)}</p>
</div>
<div class="ship-list">
${list.map(s => `
<div class="ship-card" data-id="${s.id}">
<div class="ship-head">
<span class="ship-ref">🚚 ${escapeHtml(s.ref)}</span>
${s.signed_status === 1
? '<span class="ship-badge signed">✓ unterschrieben</span>'
: '<span class="ship-badge open">unbestätigt</span>'}
</div>
<div class="ship-meta">
${s.date_delivery ? 'Lieferdatum: ' + formatShortDate(s.date_delivery) : (s.date_creation ? 'Erstellt: ' + formatShortDate(s.date_creation) : '')}
· ${escapeHtml(statusLabel(s.status))}
</div>
</div>
`).join('')}
</div>
`;
document.querySelectorAll('.ship-card').forEach(c => {
c.addEventListener('click', () => router.go('#/shipments/' + c.dataset.id));
});
} catch (e) {
main().innerHTML = renderError(e);
}
});
router.on('/shipments/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'orders');
showLoader('Lade Lieferschein…');
try {
const data = await api.getShipment(args.id);
title(data.shipment.ref);
setBack(true, data.order ? ('#/orders/' + data.order.id + '/shipments') : '#/orders');
const isSigned = data.shipment.signed_status === 1;
main().innerHTML = `
<div class="detail-section">
<p><strong>🚚 ${escapeHtml(data.shipment.ref)}</strong></p>
${data.order ? '<p class="label">Auftrag: '+escapeHtml(data.order.ref)+'</p>' : ''}
<p class="label">Kunde: ${escapeHtml(data.customer.name)}</p>
${isSigned
? '<p class="label" style="color:#5cb85c;font-weight:600;">✓ Bereits unterschrieben</p>'
: '<p class="label" style="color:#f0ad4e;">Noch nicht unterschrieben</p>'}
</div>
<div id="shipment-pdf-wrap" class="pdf-inline-wrap"></div>
<button class="btn btn-large" id="btn-sign-shipment" ${isSigned ? 'disabled' : ''}>✍️ Lieferung unterschreiben lassen</button>
`;
// PDF inline rendern (PDF.js Canvas wie bei Reports)
try {
const res = await api.getShipmentPdfBlobUrl(args.id);
if (res && res.url) {
const wrap = document.getElementById('shipment-pdf-wrap');
wrap.innerHTML = '<div class="pdf-inline-hint">Lieferschein-Vorschau</div>';
await renderPdfInline(res.url, wrap, 1);
}
} catch (e) {
console.warn('PDF-Vorschau', e);
}
document.getElementById('btn-sign-shipment').onclick = () => {
openShipmentSignatureModal(args.id, {
ref: data.shipment.ref,
customer: data.customer.name,
orderRef: data.order ? data.order.ref : '',
});
};
} catch (e) {
main().innerHTML = renderError(e);
}
});
/**
* Rendert alle Seiten eines PDFs (per Blob-URL) untereinander in einen Container — statische Vorschau.
*/
async function renderPdfInline(blobUrl, container, _unusedPageNum) {
if (typeof pdfjs === 'undefined') {
container.innerHTML += '<p class="opacitymedium">PDF-Vorschau nicht verfügbar</p>';
return;
}
try {
const buf = await fetch(blobUrl).then(r => r.arrayBuffer());
const doc = await pdfjs.getDocument({ data: buf }).promise;
const targetW = Math.min(window.innerWidth - 24, 700);
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i);
const viewport1 = page.getViewport({ scale: 1 });
const scale = targetW / viewport1.width;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.maxWidth = '100%';
canvas.style.height = 'auto';
canvas.style.background = '#fff';
canvas.style.marginBottom = '8px';
canvas.style.boxShadow = '0 0 0 1px #888';
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport }).promise;
}
} catch (e) {
console.warn('renderPdfInline', e);
container.innerHTML += '<p class="opacitymedium">PDF-Vorschau-Fehler: '+escapeHtml(e.message || String(e))+'</p>';
}
}
/**
* Findet die Bounding-Box aller nicht-weißen Pixel und gibt ein neues Canvas zurück,
* das nur diesen Bereich enthält (plus 2% Padding). Liefert null wenn nichts gezeichnet wurde.
*/
function trimCanvasToInk(canvas) {
const ctx = canvas.getContext('2d');
const { width: w, height: h } = canvas;
const data = ctx.getImageData(0, 0, w, h).data;
let minX = w, minY = h, maxX = -1, maxY = -1;
// Canvas ist transparent — gezeichnete Pixel haben alpha > 0
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const a = data[(y * w + x) * 4 + 3];
if (a > 16) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) return null; // nichts gezeichnet
// Padding 2% der Diagonale (mind. 8 px)
const pad = Math.max(8, Math.floor(Math.hypot(maxX - minX, maxY - minY) * 0.02));
const sx = Math.max(0, minX - pad);
const sy = Math.max(0, minY - pad);
const sw = Math.min(w, maxX + pad) - sx + 1;
const sh = Math.min(h, maxY + pad) - sy + 1;
const out = document.createElement('canvas');
out.width = sw;
out.height = sh;
const octx = out.getContext('2d');
// KEIN fillRect → transparenter Hintergrund bleibt erhalten
octx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
return out;
}
/**
* Vollbild-Querformat-Modal fuer die Lieferschein-Unterschrift.
* Querformat-Lock via Screen Orientation API (Android Chrome).
* Linke Spalte: Lieferschein-Info + Namens-Input + Buttons.
* Rechte Spalte: HiDPI-Canvas zum Unterschreiben.
*/
function openShipmentSignatureModal(shipmentId, info) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal ship-sign-modal';
modal.innerHTML = `
<div class="ship-sign-grid">
<aside class="ship-sign-side">
<div class="ss-title">Lieferung bestätigen</div>
<div class="ss-meta">
<div><strong>🚚 ${escapeHtml(info.ref || '')}</strong></div>
${info.orderRef ? '<div>Auftrag: '+escapeHtml(info.orderRef)+'</div>' : ''}
<div>${escapeHtml(info.customer || '')}</div>
</div>
<label class="ss-label">Name des Unterzeichners *
<input type="text" id="ss-name" autocomplete="name" placeholder="Vor- und Nachname" value="${escapeHtml(info.customer || '')}">
</label>
<label class="ss-gps">
<input type="checkbox" id="ss-gps" checked> 📍 GPS-Position aufnehmen
</label>
<p class="ss-legal">Mit der Unterschrift bestätige ich den ordnungsgemäßen Erhalt der Lieferung. Server-Zeitstempel und GPS werden mitgespeichert.</p>
<div class="ss-error" id="ss-error" style="display:none;background:#5e1e1e;border:1px solid #c04040;color:#fff;padding:10px 12px;border-radius:6px;font-size:13px;white-space:pre-wrap;word-break:break-word;"></div>
<div class="ss-actions">
<button class="btn btn-secondary" id="ss-clear">🗑 Leeren</button>
<button class="btn btn-secondary" id="ss-cancel">Abbrechen</button>
<button class="btn btn-large" id="ss-save">✓ Bestätigen</button>
</div>
</aside>
<section class="ship-sign-pad">
<div class="ss-hint">Hier mit dem Finger unterschreiben</div>
<canvas id="ss-canvas"></canvas>
</section>
</div>
`;
document.body.appendChild(modal);
pushModal(modal, () => {
try { window.removeEventListener('resize', fitCanvas); } catch {}
try { window.removeEventListener('orientationchange', fitCanvas); } catch {}
// Fullscreen + Orientation unlock
try { if (screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); } catch {}
try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
});
// Fullscreen + Landscape (best effort — iOS Safari hat kein Lock)
requestAnimationFrame(() => {
try { document.documentElement.requestFullscreen({ navigationUI: 'hide' }); } catch {}
try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(() => {}); } catch {}
});
const canvas = modal.querySelector('#ss-canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
function fitCanvas() {
const pad = modal.querySelector('.ship-sign-pad');
const rect = pad.getBoundingClientRect();
const padding = 24;
const targetW = Math.max(400, rect.width - padding);
const targetH = Math.max(200, rect.height - padding - 30); // 30 für Hinweis
canvas.style.width = targetW + 'px';
canvas.style.height = targetH + 'px';
// Resize loescht Canvas automatisch (transparent). Kein fillRect → PNG bekommt Alpha-Kanal.
canvas.width = Math.floor(targetW * dpr);
canvas.height = Math.floor(targetH * dpr);
ctx.scale(dpr, dpr);
ctx.strokeStyle = '#000';
ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
fitCanvas();
window.addEventListener('resize', fitCanvas);
window.addEventListener('orientationchange', () => setTimeout(fitCanvas, 200));
// Drawing — Pointer-Events fuer iOS + Android
let drawing = false;
let lastX = 0, lastY = 0;
function pos(e) {
const r = canvas.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: t.clientX - r.left, y: t.clientY - r.top };
}
function start(e) {
e.preventDefault();
drawing = true;
const p = pos(e);
lastX = p.x; lastY = p.y;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
function move(e) {
if (!drawing) return;
e.preventDefault();
const p = pos(e);
// Quadratic-Glaettung
const midX = (lastX + p.x) / 2;
const midY = (lastY + p.y) / 2;
ctx.quadraticCurveTo(lastX, lastY, midX, midY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(midX, midY);
lastX = p.x; lastY = p.y;
}
function end(e) {
if (drawing && e) e.preventDefault && e.preventDefault();
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('#ss-clear').onclick = fitCanvas;
modal.querySelector('#ss-cancel').onclick = () => closeModal(modal);
const errBox = modal.querySelector('#ss-error');
const setErr = (msg) => {
if (msg) {
errBox.textContent = msg;
errBox.style.display = '';
} else {
errBox.style.display = 'none';
}
};
modal.querySelector('#ss-save').onclick = async () => {
setErr(null);
const name = modal.querySelector('#ss-name').value.trim();
if (!name) {
setErr('Bitte Namen des Unterzeichners eingeben');
modal.querySelector('#ss-name').focus();
return;
}
let gps = null;
if (modal.querySelector('#ss-gps').checked && 'geolocation' in navigator) {
showToast('Hole GPS-Position…');
try {
const p = await new Promise((res, rej) => {
navigator.geolocation.getCurrentPosition(res, rej, { timeout: 5000, enableHighAccuracy: true });
});
gps = { lat: p.coords.latitude, lon: p.coords.longitude };
} catch (e) { /* GPS optional */ }
}
setErr('Speichere Unterschrift…');
// Canvas auf den gezeichneten Bereich trimmen — sonst rendert ODT den vollen
// weißen Bereich + winzige Unterschrift in der Ecke.
const trimmed = trimCanvasToInk(canvas);
if (!trimmed) {
setErr('Bitte erst unterschreiben (Canvas ist leer)');
return;
}
console.log('[Signature] orig='+canvas.width+'x'+canvas.height+' trimmed='+trimmed.width+'x'+trimmed.height);
setErr('Upload… ('+canvas.width+'×'+canvas.height+' → '+trimmed.width+'×'+trimmed.height+')');
trimmed.toBlob(async (blob) => {
try {
if (!blob) throw new Error('Canvas konnte kein PNG erzeugen');
const opts = { signer_name: name };
if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; }
const fd = new FormData();
fd.append('file', blob, 'signature.png');
fd.append('signer_name', name);
if (gps) { fd.append('gps_lat', gps.lat); fd.append('gps_lon', gps.lon); }
// Direkter Fetch statt api.confirmShipment, damit wir den Response-Body
// auch bei 500 lesen koennen
const url = window.location.origin + '/custom/bericht/api/shipments.php?id=' + shipmentId + '&action=confirm';
const r = await fetch(url, {
method: 'POST',
body: fd,
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }, // State-Änderung -> CSRF-pflichtig
});
const text = await r.text();
let payload;
try { payload = JSON.parse(text); } catch { payload = { error: text.substring(0, 800) }; }
if (!r.ok) throw new Error('HTTP '+r.status+': '+(payload.error || text.substring(0, 400)));
setErr(null);
showToast('✓ Lieferung bestätigt');
closeModal(modal);
router.go('#/shipments/' + shipmentId);
} catch (e) {
setErr('Fehler: ' + (e.message || e));
console.error('[Signature confirm]', e);
}
}, '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);
}
/* Kurzzeichen für das Seiten-Layout (siehe layout-Spalte in llx_bericht_page).
* Ohne das wirkt eine Rasterseite in der Kachel wie ein einzelnes Foto. */
function layoutBadge(layout) {
switch (layout) {
case 'grid_2': return '⊞ 2';
case 'grid_2v': return '⊞ 2';
case 'grid_4': return '⊞ 4';
case 'grid_6': return '⊞ 6';
case 'before_after': return '↔ V/N';
case 'title_only': return '📄 Titel';
default: return '';
}
}
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 params = new URLSearchParams({ relpath, 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 (!await confirmDialog({
title: 'Foto löschen',
text: 'Das Foto wird endgültig aus den Dokumenten des Auftrags entfernt.',
confirmLabel: 'Löschen',
danger: true,
})) 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;
}
};
}
/* ============================================================
* LIVE-KAMERA — Schnellschuss + Filmstreifen
* Bleibt im Kamera-Modus: mehrere Fotos ohne Rauswerfen.
* Jedes Foto wird SOFORT persistent in die Offline-Queue geschrieben
* (kein Datenverlust) und dann im Hintergrund hochgeladen.
* ============================================================ */
async function openCameraModal(orderId) {
// Kein getUserMedia (alter Browser / unsicherer Kontext)? → System-Kamera nutzen
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const ci = document.getElementById('camera-input');
if (ci) ci.click();
return;
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal camera-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="cam-close">✕</button>
<div class="fs-title">📷 Kamera</div>
<button class="icon-btn" id="cam-flip" title="Kamera wechseln">🔄</button>
</div>
<div class="cam-stage">
<video id="cam-video" playsinline autoplay muted></video>
<div class="cam-hint" id="cam-hint">Kamera wird gestartet…</div>
</div>
<div class="cam-filmstrip" id="cam-strip"></div>
<div class="cam-controls">
<div class="cam-count" id="cam-count"></div>
<button class="cam-shutter" id="cam-shutter" aria-label="Auslösen"></button>
<div class="cam-done-wrap"><button class="btn btn-small" id="cam-done">Fertig</button></div>
</div>
`;
document.body.appendChild(modal);
const video = modal.querySelector('#cam-video');
const strip = modal.querySelector('#cam-strip');
const hint = modal.querySelector('#cam-hint');
const countEl = modal.querySelector('#cam-count');
const shutter = modal.querySelector('#cam-shutter');
let stream = null;
let facing = 'environment';
let shots = 0;
let gen = 0; // Generations-Token: entwertet in-flight getUserMedia-Aufrufe (Schließen/Flip)
function stopStream() {
if (stream) { try { stream.getTracks().forEach(t => t.stop()); } catch (_) {} stream = null; }
try { video.srcObject = null; } catch (_) {}
}
// Upload bestätigt → passende Miniatur auf „fertig" setzen und relpath merken (für Löschen)
function onUploaded(e) {
const d = e.detail || {};
const thumb = strip.querySelector('.cam-thumb[data-qid="' + d.queueId + '"]');
if (!thumb) return;
if (d.relpath) thumb.dataset.relpath = d.relpath;
thumb.classList.remove('pending', 'saving');
thumb.classList.add('done');
// Server hatte das Bild schon (Wiederholung nach Abbruch): kein Fehler, nur Hinweis
if (d.duplicate) thumb.title = 'War schon hochgeladen';
}
window.addEventListener('photo-uploaded', onUploaded);
// Aufräumen bei Schließen / Android-Back: Kamera aus, Listener weg, Blob-URLs freigeben,
// Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos).
pushModal(modal, () => {
gen++; // entwertet einen evtl. noch laufenden getUserMedia-Aufruf
stopStream();
window.removeEventListener('photo-uploaded', onUploaded);
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
// Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip).
// Ausdruecklich DIESEN Auftrag ansteuern, nicht "was gerade in der Adresszeile steht":
// stand dort nach dem History-Race '#/orders', zeichnete router.navigate() die
// Auftragsliste und man flog nach "Fertig" aus dem Auftrag (17.09.2026).
if (shots > 0) {
try { router.go('#/orders/' + orderId); } catch (_) {}
announcePendingPhotos(orderId);
}
});
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
modal.querySelector('#cam-done').onclick = () => closeModal(modal);
modal.querySelector('#cam-flip').onclick = () => {
facing = (facing === 'environment') ? 'user' : 'environment';
startStream();
};
function updateCount() {
countEl.textContent = shots ? (shots + (shots === 1 ? ' Foto' : ' Fotos')) : '';
}
async function startStream() {
const my = ++gen;
stopStream();
hint.style.display = '';
hint.textContent = 'Kamera wird gestartet…';
try {
const s = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facing }, width: { ideal: 3840 }, height: { ideal: 2160 } },
audio: false,
});
// Inzwischen geschlossen oder neuer Start (Flip) angefordert? → diesen Stream sofort beenden,
// damit keine verwaiste Kamera (LED an) zurückbleibt.
if (my !== gen || !document.body.contains(modal)) {
try { s.getTracks().forEach(t => t.stop()); } catch (_) {}
return;
}
stream = s;
video.srcObject = stream;
await video.play().catch(() => {});
hint.style.display = 'none';
} catch (err) {
if (my !== gen || !document.body.contains(modal)) return; // abgebrochen, nicht mehr relevant
console.warn('[Kamera]', err);
showToast('Kamera nicht verfügbar — nutze System-Kamera', 'warn');
closeModal(modal);
const ci = document.getElementById('camera-input');
if (ci) ci.click();
}
}
function addThumb(url) {
const t = document.createElement('div');
t.className = 'cam-thumb saving';
t.innerHTML = '<img src="' + url + '" alt=""><span class="cam-thumb-badge"></span>'
+ '<button class="cam-thumb-del" title="Löschen">🗑</button>';
// Tipp auf Miniatur → Löschen-Overlay ein/aus
t.querySelector('img').onclick = () => t.classList.toggle('confirm-del');
t.querySelector('.cam-thumb-del').onclick = (ev) => { ev.stopPropagation(); deleteThumb(t); };
strip.prepend(t);
return t;
}
async function deleteThumb(t) {
if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz
const relpath = t.dataset.relpath;
const qid = t.dataset.qid;
if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
if (!relpath && qid) {
if (!await confirmDialog({
title: 'Noch nicht hochgeladen',
text: 'Dieses Foto liegt nur auf dem Gerät und wurde noch nicht übertragen. Beim Löschen ist es weg.',
confirmLabel: 'Trotzdem löschen',
danger: true,
})) return;
}
t.dataset.deleting = '1';
const delBtn = t.querySelector('.cam-thumb-del');
if (delBtn) delBtn.disabled = true;
try {
if (relpath) {
await api.deletePhoto(relpath); // schon hochgeladen → serverseitig löschen
} else {
await idb.queueDelete(Number(qid)); // noch in Queue → aus Queue entfernen
await offline.updateBadge();
}
try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {}
t.remove();
shots = Math.max(0, shots - 1);
updateCount();
showToast('Foto gelöscht');
} catch (e) {
t.dataset.deleting = '';
if (delBtn) delBtn.disabled = false;
showToast('Löschen fehlgeschlagen: ' + (e.message || ''), 'error');
}
}
/* Echtes Kamerafoto in voller Sensoraufloesung.
*
* Ein Standbild aus dem Video-Stream hat hoechstens Stream-Aufloesung (hier bis 4K
* angefordert, real oft weniger) — die Kamera selbst kann ein Vielfaches. Auf
* Typenschildern und Zaehlerstaenden war deshalb wenig zu erkennen. ImageCapture
* .takePhoto() loest dagegen richtig aus und liefert die native JPEG-Datei der
* Kamera, inklusive EXIF. Kann der Browser das nicht (iOS/Safari, aeltere Stände),
* bleibt das Standbild als Rueckfallebene.
*/
async function grabFullResolution() {
try {
if (typeof ImageCapture === 'undefined' || !stream) return null;
const track = stream.getVideoTracks()[0];
if (!track || typeof track.getCapabilities !== 'function') return null;
const cap = new ImageCapture(track);
const blob = await cap.takePhoto();
return (blob && blob.size > 0) ? blob : null;
} catch (_) {
return null; // z.B. „takePhoto not supported" — Standbild nehmen
}
}
async function capture() {
if (!stream || !video.videoWidth) { showToast('Kamera noch nicht bereit', 'warn'); return; }
shutter.classList.add('flash');
setTimeout(() => shutter.classList.remove('flash'), 150);
shots++;
updateCount();
let raw = await grabFullResolution();
if (!raw) {
// Rueckfallebene: Standbild aus dem laufenden Stream
const c = document.createElement('canvas');
c.width = video.videoWidth;
c.height = video.videoHeight;
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.95));
}
if (!raw) { shots = Math.max(0, shots - 1); updateCount(); showToast('Foto fehlgeschlagen', 'error'); return; }
const thumb = addThumb(URL.createObjectURL(raw));
try {
// Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto)
const name = 'foto_' + Date.now() + '_' + shots + '.jpg';
const willShrink = photoCfg.photo_maxside > 0;
const qid = await offline.enqueuePhoto(orderId, raw, name, { hold: willShrink });
if (willShrink) {
try {
const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small);
} catch (_) { /* Original bleibt gesichert */ }
finally { await offline.releaseHold(qid); }
}
thumb.dataset.qid = qid;
thumb.classList.remove('saving');
thumb.classList.add('pending');
offline.syncQueue(); // im Hintergrund hochladen (löscht nur bei bestätigtem Erfolg)
} catch (e) {
thumb.classList.remove('saving');
thumb.classList.add('error');
const quota = e && (e.name === 'QuotaExceededError' || /quota/i.test(e.message || ''));
showToast(quota
? '⚠ Speicher voll — bitte erst vorhandene Fotos hochladen, dann erneut aufnehmen'
: ('Foto sichern fehlgeschlagen: ' + (e.message || '')), 'error');
}
}
shutter.onclick = capture;
startStream();
}
/* ============================================================
* UPLOAD-WARTESCHLANGE — Recovery-UI (per Klick auf das Status-Badge)
* Zeigt offene und fehlgeschlagene Uploads, erlaubt erneutes Senden und
* das Retten fehlgeschlagener Fotos per Teilen/Speichern. So ist ein Foto
* NIE in einer stillen Sackgasse.
* ============================================================ */
async function openUploadQueueModal() {
const items = await offline.listQueue().catch(() => []);
if (!items.length) {
showToast(navigator.onLine ? 'Alle Fotos hochgeladen ✓' : 'Keine offenen Uploads');
return;
}
const pending = items.filter(i => !i.failed);
const failed = items.filter(i => i.failed);
const modal = document.createElement('div');
modal.className = 'fullscreen-modal queue-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="q-close">✕</button>
<div class="fs-title">☁ Upload-Warteschlange</div>
<span></span>
</div>
<div class="fs-body" style="flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px;color:#fff;">
<p style="margin:0;">${pending.length} warten auf Upload${failed.length ? ', <strong>' + failed.length + ' fehlgeschlagen</strong>' : ''}.</p>
<button class="btn" id="q-sync">🔄 Jetzt synchronisieren</button>
${failed.length ? '<button class="btn btn-secondary" id="q-retry">↻ Fehlgeschlagene erneut senden</button>' : ''}
${failed.length ? '<button class="btn btn-secondary" id="q-share">📤 Fehlgeschlagene sichern / teilen</button>' : ''}
<p style="margin:0;font-size:12px;color:#aaa;">Fotos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.</p>
</div>
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#q-close').onclick = () => closeModal(modal);
modal.querySelector('#q-sync').onclick = () => {
showToast('Synchronisiere…');
offline.syncQueue();
closeModal(modal);
};
const retryBtn = modal.querySelector('#q-retry');
if (retryBtn) retryBtn.onclick = async () => {
await offline.retryFailed();
showToast('Erneut senden gestartet…');
closeModal(modal);
};
const shareBtn = modal.querySelector('#q-share');
if (shareBtn) shareBtn.onclick = async () => {
const files = failed.map(it => ({
blob: new Blob([it.data], { type: it.mime || 'image/jpeg' }),
filename: it.filename || 'foto.jpg',
mime: it.mime || 'image/jpeg',
}));
if (!files.length) return;
await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos');
};
}
// Status-Badge in der Kopfzeile klickbar machen → Upload-Warteschlange öffnen
(function wireStatusBadge() {
const badge = document.getElementById('status-badge');
if (badge) {
badge.style.cursor = 'pointer';
badge.title = 'Upload-Warteschlange anzeigen';
badge.addEventListener('click', () => openUploadQueueModal());
}
})();
/* ============================================================
* 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 = await inputDialog({
title: 'Mess-Skala kalibrieren',
text: 'Reale Länge der gezogenen Strecke — Zahl mit Einheit, z. B. „50 cm", „1.5 m" oder „250 mm".',
placeholder: '1.5 m',
okLabel: 'Übernehmen',
});
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;
await alertDialog({ title: 'Eingabe nicht verstanden', text: 'Bitte Zahl und Einheit angeben, z. B. 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;
await alertDialog({
title: 'Noch nicht kalibriert',
text: 'Erst 📏⚙ antippen und eine Strecke mit bekannter Länge ziehen — danach kann gemessen werden.',
});
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);
};
}
/* ============================================================
* TEXTNOTIZEN
*
* Getippte Notiz zum Auftrag — das Gegenstück zur Sprachnotiz für alles, was man
* lieber schreibt als diktiert (Zählernummern, Absprachen, Maße). Landet als
* notiz_<betreff>_<datum>.txt im Auftragsordner (api/note.php) und ist damit auch
* im Dolibarr-Auftrag und in der Anhänge-Spalte des Bericht-Editors zu sehen.
* ============================================================ */
/* Liste der Notizen eines Auftrags */
async function openNotesModal(orderId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal notes-modal';
modal.innerHTML = `
<div class="modal-header">
<h2>📝 Notizen</h2>
<button class="close-btn" id="nl-close">&times;</button>
</div>
<div class="modal-body">
<button class="btn" id="nl-new">+ Neue Notiz</button>
<div id="nl-list"><div class="empty-state">Lade…</div></div>
</div>
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#nl-close').onclick = () => closeModal(modal);
async function refresh() {
const listEl = modal.querySelector('#nl-list');
try {
const data = await api.listNotes(orderId);
if (!data.notes.length) {
listEl.innerHTML = '<div class="empty-state"><div class="icon">📝</div>Noch keine Notiz</div>';
return;
}
listEl.innerHTML = data.notes.map(n => `
<div class="note-card" data-file="${escapeHtml(n.file)}">
<div class="note-card-head">
<strong>${escapeHtml(n.subject || '(ohne Betreff)')}</strong>
<button class="icon-btn note-del" title="Löschen">🗑</button>
</div>
<div class="note-card-preview">${escapeHtml(n.preview || '')}</div>
<div class="note-card-meta">${escapeHtml(n.modified || n.created || formatShortDate(n.date))}</div>
</div>
`).join('');
listEl.querySelectorAll('.note-card').forEach(card => {
card.onclick = (e) => {
if (e.target.closest('.note-del')) return;
openNoteEditor(orderId, card.dataset.file, refresh);
};
card.querySelector('.note-del').onclick = async (e) => {
e.stopPropagation();
if (!await confirmDialog({
title: 'Notiz löschen',
text: 'Die Notiz wird endgültig aus den Dokumenten des Auftrags entfernt.',
confirmLabel: 'Löschen',
danger: true,
})) return;
try {
await api.deleteNote(orderId, card.dataset.file);
showToast('Notiz gelöscht');
refresh();
router.navigate();
} catch (err) {
showToast('Fehler: ' + err.message, 'error');
}
};
});
} catch (e) {
listEl.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>';
}
}
modal.querySelector('#nl-new').onclick = () => openNoteEditor(orderId, null, refresh);
refresh();
}
/* Eine Notiz schreiben oder ändern. file = null → neue Notiz. */
async function openNoteEditor(orderId, file, onSaved) {
let existing = null;
if (file) {
try {
existing = (await api.getNote(orderId, file)).note;
} catch (e) {
showToast('Notiz konnte nicht geladen werden: ' + e.message, 'error');
return;
}
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal note-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="nt-close">✕</button>
<div class="fs-title">📝 ${file ? 'Notiz bearbeiten' : 'Neue Notiz'}</div>
<button class="icon-btn" id="nt-save" title="Speichern">✓</button>
</div>
<div class="note-edit-body">
<input type="text" id="nt-subject" maxlength="60" placeholder="Betreff, z. B. Zählernummern Keller"
value="${escapeHtml(existing ? existing.subject : '')}">
<textarea id="nt-text" placeholder="Notiz…">${escapeHtml(existing ? existing.text : '')}</textarea>
${existing && existing.created ? `<div class="note-edit-meta">Erfasst: ${escapeHtml(existing.created)}${existing.modified ? ' · Geändert: ' + escapeHtml(existing.modified) : ''}</div>` : ''}
</div>
`;
document.body.appendChild(modal);
pushModal(modal);
const subjectEl = modal.querySelector('#nt-subject');
const textEl = modal.querySelector('#nt-text');
const startSig = (existing ? existing.subject + ' ' + existing.text : ' ');
const currentSig = () => subjectEl.value + ' ' + textEl.value;
modal.querySelector('#nt-close').onclick = async () => {
if (currentSig() !== startSig && !await confirmDialog({
title: 'Notiz verwerfen',
text: 'Die Änderungen wurden noch nicht gespeichert.',
confirmLabel: 'Verwerfen',
danger: true,
})) return;
closeModal(modal);
};
modal.querySelector('#nt-save').onclick = async () => {
const subject = subjectEl.value.trim();
const text = textEl.value;
if (!subject && !text.trim()) { showToast('Die Notiz ist leer', 'warn'); return; }
const btn = modal.querySelector('#nt-save');
btn.disabled = true;
try {
await api.saveNote(orderId, { subject, text }, file);
showToast('✓ Notiz gespeichert');
closeModal(modal);
if (onSaved) onSaved();
router.navigate();
} catch (e) {
btn.disabled = false;
showToast('Speichern fehlgeschlagen: ' + e.message, 'error');
}
};
// Bei einer neuen Notiz erst den Betreff, beim Bearbeiten direkt in den Text
setTimeout(() => { (file ? textEl : subjectEl).focus(); }, 80);
}