/* 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 = '
🔐
${escapeHtml(title || 'PIN eingeben')}
`;
const pad = modal.querySelector('.pin-keypad');
const disp = modal.querySelectorAll('.pin-display span');
let current = '';
function render() {
disp.forEach((el, i) => { el.classList.toggle('filled', i < current.length); });
}
function add(d) {
if (current.length >= 4) return;
current += d;
render();
if (current.length === 4) {
setTimeout(() => { closeModal(modal); resolve(current); }, 150);
}
}
function del() {
current = current.slice(0, -1);
render();
}
const keys = ['1','2','3','4','5','6','7','8','9','','0','←'];
keys.forEach(k => {
const b = document.createElement('button');
b.className = 'pin-key';
b.textContent = k;
if (!k) { b.style.visibility = 'hidden'; }
else if (k === '←') b.onclick = del;
else b.onclick = () => add(k);
pad.appendChild(b);
});
// Cancel-Button
const cancel = document.createElement('button');
cancel.className = 'pin-cancel';
cancel.textContent = 'Abbrechen';
cancel.onclick = () => { closeModal(modal); resolve(null); };
modal.querySelector('.pin-body').appendChild(cancel);
document.body.appendChild(modal);
// Android-Back schließt Modal → Promise mit null auflösen
// (doppeltes resolve ist per JS-Spec no-op, falls PIN bereits eingegeben war)
pushModal(modal, () => resolve(null));
});
}
async function promptNewPin() {
const p1 = await promptPin('Neue 4-stellige PIN');
if (!p1 || p1.length !== 4) return null;
const p2 = await promptPin('PIN wiederholen');
if (p2 !== p1) {
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 '
* erst nach einem Sprung — der Zeitanker #t=0.1 in der Adresse allein laesst die Kachel schwarz
* (Emulator, 19.10.2026: das Bild hatte Helligkeit 231, angezeigt wurde Schwarz). Also nach den
* Metadaten einmal ausdruecklich auf 0,1 s springen. */
function videoVorschau(scope) {
scope.querySelectorAll('video[data-vorschau]').forEach(v => {
v.removeAttribute('data-vorschau');
const springen = () => {
try { v.currentTime = Math.min(0.1, (isFinite(v.duration) && v.duration) || 0.1); } catch (_) {}
};
if (v.readyState >= 1) springen(); // Metadaten schon da (Cache)
else v.addEventListener('loadedmetadata', springen, { once: true });
});
}
/* Videos laufen als type 'photo' durch die Warteschlange (siehe offline.enqueuePhoto) —
* erkannt werden sie am MIME-Typ bzw. am Namen. */
function istQueueVideo(it) {
return /^video\//.test(it.mime || '') || /^video_/i.test(it.filename || '');
}
// „3 Fotos", „1 Video", „2 Fotos und 1 Video"
function medienText(items) {
const v = items.filter(istQueueVideo).length;
const f = items.length - v;
const teile = [];
if (f) teile.push(f + (f === 1 ? ' Foto' : ' Fotos'));
if (v) teile.push(v + (v === 1 ? ' Video' : ' Videos'));
return teile.join(' und ');
}
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, upload_max: 0 };
async function refreshPhotoConfig() {
try {
const c = await api.getConfig();
if (c && typeof c.photo_maxside === 'number') {
// upload_max (Bytes, Bericht ≥ 1.8.0): bestimmt die Hoechstlaenge eines Videos
photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100,
upload_max: c.upload_max || 0 };
localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg));
}
} catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ }
}
/* Eine ausgewaehlte Datei sofort komplett in den Speicher holen (siehe handleFiles).
* Ein Fehlversuch wird einmal wiederholt — frisch aufgenommene Fotos schreibt die
* Galerie-App teils noch nach, dann klappt der zweite Lesezugriff. */
async function readFileNow(f) {
let buf;
try {
buf = await f.arrayBuffer();
} catch (_) {
await new Promise(r => setTimeout(r, 500));
buf = await f.arrayBuffer();
}
return new File([buf], f.name || ('foto_' + Date.now() + '.jpg'),
{ type: f.type || 'image/jpeg', lastModified: f.lastModified || Date.now() });
}
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');
const istVideo = /^video\//.test(file.type || '');
// 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. Videos werden nie verkleinert; ihre
// Aufnahmezeit ist das Dateidatum aus der Galerie (der Server sortiert danach).
const willShrink = photoCfg.photo_maxside > 0 && !istVideo;
const qid = await offline.enqueuePhoto(orderId, file, name,
{ hold: willShrink, takenAt: istVideo ? (file.lastModified || 0) : 0 });
// 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);
const ctx = c.getContext('2d');
// Weisser Grund: JPEG kennt keine Transparenz, ein freigestelltes PNG/WebP wuerde sonst schwarz
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, c.width, c.height);
ctx.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 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 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 , 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 = '';
return;
}
main().innerHTML = `
${renderCustomerList(data.customers)}
`;
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 => `
${escapeHtml(c.name || '')}
${escapeHtml((c.zip || '') + ' ' + (c.town || '')).trim() || '—'}
${c.phone ? '📞 ' + escapeHtml(c.phone) : ''}
${c.bericht_count > 0 ? `📑 ${c.bericht_count} ` : ''}
`).join('');
}
function bindCustomerCards() {
document.querySelectorAll('.customer-card').forEach(c => {
c.addEventListener('click', () => router.go('#/customers/' + c.dataset.id));
});
}
router.on('/customers/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'customers');
setBack(true, '#/customers');
showLoader('Lade Kunde…');
try {
const 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 = `
Stammdaten
${escapeHtml(data.customer.name)}
${data.customer.code ? `
Kundennr: ${escapeHtml(data.customer.code)}
` : ''}
${addr ? `
${escapeHtml(addr).replace(/\n/g, ' ')}
` : ''}
${data.customer.phone ? `
📞 ${escapeHtml(data.customer.phone)}
` : ''}
${data.customer.email ? `
✉ ${escapeHtml(data.customer.email)}
` : ''}
${data.customer.siret || data.customer.vat ? `
USt-ID: ${escapeHtml(data.customer.vat || '')}
` : ''}
${data.customer.address || data.customer.town ? `
🗺 Route ` : ''}
${data.orders.length ? `
Aufträge (${data.orders.length})
${data.orders.map(o => `
${escapeHtml(o.ref)}
${formatDate(o.date)} · ${formatEur(o.total)}${o.bericht_count > 0 ? ' · 📑 ' + o.bericht_count : ''}
`).join('')}
` : ''}
${data.reports.length ? `
Berichte (${data.reports.length})
${data.reports.map(r => `
${escapeHtml(r.ref)} ${r.status === 1 ? 'Final' : 'Entwurf'}
${escapeHtml(r.titel || '')} · ${formatDate(r.datec)}
`).join('')}
` : ''}
${data.invoices.length ? `
Rechnungen (${data.invoices.length})
${data.invoices.map(i => `
${escapeHtml(i.ref)}
${formatDate(i.date)} · ${formatEur(i.total)}
`).join('')}
` : ''}
`;
document.querySelectorAll('.mini-card[data-order-id]').forEach(el => {
el.addEventListener('click', () => router.go('#/orders/' + el.dataset.orderId));
});
document.querySelectorAll('.mini-card[data-report-id]').forEach(el => {
el.addEventListener('click', () => router.go('#/reports/' + el.dataset.reportId));
});
} catch (e) {
main().innerHTML = 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 = '';
return;
}
main().innerHTML = data.reports.map(r => {
const statusLabel = r.status === 1 ? 'Final' : 'Entwurf';
const statusClass = r.status === 1 ? 'status-final' : 'status-draft';
const sourceIcon = r.element_type === 'order' ? '🛒' : (r.element_type === 'invoice' ? '📄' : '📋');
return `
${escapeHtml(r.ref)} ${statusLabel}
${escapeHtml(r.titel || '')}
${sourceIcon} ${escapeHtml(r.parent_ref || '')}
${r.page_count} Seite${r.page_count === 1 ? '' : 'n'}
`;
}).join('');
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id));
});
} catch (e) {
main().innerHTML = 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 = `
Bericht
${escapeHtml(data.report.titel || data.report.ref)}
Auftrag: ${escapeHtml(data.report.auftragsnummer || '—')}
Format: ${escapeHtml(data.report.page_format || 'A4')} ${data.report.page_orientation === 'L' ? 'Quer' : 'Hoch'}
Seiten: ${data.pages.length}
Status: ${statusLabel}
${hasPages ? `
${data.pages.map((p, i) => {
// 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 `
${rel ? '⏳' : '📄'}
${i + 1}
${badge ? `
${badge}
` : ''}
${p.title ? `
${escapeHtml(p.title)}
` : ''}
`;
}).join('')}
` : '📭
Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.
'}
${finalizeLabel}
${hasPages ? '👁 PDF-Vorschau ' : ''}
✍️ Kunden-Unterschrift hinzufügen
✏️ Im Desktop-Editor öffnen
Bericht löschen
`;
loadThumbs();
// Tap = Aktionen, Long-Press = Drag-Sort
bindReportPageInteractions(args.id);
// PDF-Vorschau
const pdfBtn = document.getElementById('btn-view-pdf');
if (pdfBtn) pdfBtn.onclick = async () => {
showToast('PDF wird geladen…');
const url = await api.getPdfBlobUrl(args.id);
if (!url) { showToast('PDF konnte nicht geladen werden', 'error'); return; }
const fname = (data.report && data.report.ref ? data.report.ref : ('bericht-' + args.id)) + '.pdf';
openPdfModal(url, fname);
};
// Unterschrift
document.getElementById('btn-signature').onclick = () => openSignatureModal(args.id);
const finalizeBtn = document.getElementById('btn-finalize');
if (finalizeBtn) {
finalizeBtn.onclick = async () => {
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; }
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 = `
Bericht ${escapeHtml(data.report.ref)} mit ${data.pages.length} Seite${data.pages.length === 1 ? '' : 'n'} wirklich löschen?
Alle Seiten und Annotationen werden unwiderruflich entfernt.
Abbrechen
Endgültig löschen
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#dr-cancel').onclick = () => closeModal(modal);
modal.querySelector('#dr-no').onclick = () => closeModal(modal);
modal.querySelector('#dr-yes').onclick = async () => {
try {
await api.deleteReport(args.id);
closeModal(modal);
showToast('Bericht gelöscht');
router.go('#/reports');
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
};
};
} catch (e) {
main().innerHTML = 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 = `
Konto
${escapeHtml(user.name || user.login || 'Unbekannt')}
${escapeHtml(user.login || '')}
Sicherheit
PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.
PIN beim Öffnen abfragen
${pinEnabled ? '
🔑 PIN ändern ' : ''}
🔄 Offline-Queue synchronisieren
🚪 Abmelden
Baustelle PWA v1.0
`;
document.getElementById('btn-sync').onclick = async () => {
await offline.syncQueue();
showToast('Sync ausgelöst');
};
document.getElementById('btn-logout').onclick = async () => {
await api.logout();
await idb.del('pin_hash');
await idb.del('pin_salt');
// 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, notiz = false;
try {
const p = await idb.get('shared_payload');
if (p) {
n = (p.files || []).length;
notiz = !!((p.title || '').trim() || (p.text || '').trim() || (p.url || '').trim());
} else {
const alt = await idb.get('shared_files'); // altes Format
if (alt && alt.length) n = alt.length;
}
// Ein Paket OHNE Inhalt (Teilen abgebrochen, nichts mitgekommen) hielt den Knopf für immer
// stehen: share.html meldete „Es kam nichts an" und ließ den Rest liegen (Eddy 19.10.2026).
if (!n && !notiz) {
await idb.del('shared_payload').catch(() => {});
await idb.del('shared_files').catch(() => {});
return;
}
} catch (_) { return; }
const b = document.createElement('button');
b.id = 'shared-banner';
b.className = 'shared-banner';
b.textContent = '📤 Geteiltes ablegen' + (n ? ' (' + n + ' Bild' + (n > 1 ? 'er' : '') + ')' : ' (Notiz)');
b.addEventListener('click', () => { location.href = 'share.html'; });
// Als Leiste in die App-Spalte direkt über die Navigation (Eddy: „unten am Rand, nicht auf den
// Aufträgen") — vorher schwebte der Knopf fest über der Liste und verdeckte Einträge und den FAB
const app = document.getElementById('app');
const nav = document.getElementById('bottom-nav');
if (app && nav) app.insertBefore(b, nav);
else document.body.appendChild(b);
document.body.classList.add('hat-geteiltes'); // hebt den FAB über die Leiste (app.css)
}
/* 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 = `
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#mt-close').onclick = () => closeModal(modal);
async function reload() {
try {
const d = await api.listMaterials(elementType, elementId);
const listEl = modal.querySelector('#mt-list');
if (!d.materials.length) {
listEl.innerHTML = 'Noch keine Einträge
';
return;
}
listEl.innerHTML = d.materials.map(m => `
${escapeHtml(m.label)}
${m.qty} ${escapeHtml(m.unit)}${m.note ? ' · ' + escapeHtml(m.note) : ''}
🗑️
`).join('');
listEl.querySelectorAll('.mt-del').forEach(btn => {
btn.addEventListener('click', async (e) => {
const card = e.target.closest('.mini-card');
const id = card.dataset.id;
if (!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 = 'Fehler: ' + escapeHtml(e.message) + '
';
}
}
reload();
modal.querySelector('#mt-add').onclick = async () => {
const label = modal.querySelector('#mt-label').value.trim();
if (!label) { showToast('Bezeichnung eingeben', 'error'); return; }
const qty = parseFloat(modal.querySelector('#mt-qty').value) || 1;
const unit = modal.querySelector('#mt-unit').value;
const note = modal.querySelector('#mt-note').value.trim();
try {
await api.addMaterial(elementType, elementId, { label, qty, unit, note });
modal.querySelector('#mt-label').value = '';
modal.querySelector('#mt-qty').value = '1';
modal.querySelector('#mt-note').value = '';
showToast('✓ Hinzugefügt');
reload();
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
};
}
/* ============================================================
* NEW REPORT MODAL (Schnell-Bericht mit Meta + Vorlage + ODT)
* ============================================================ */
async function openNewReportModal(orderId) {
// Templates parallel laden
let tpls = [], odts = [];
try { tpls = (await api.listTemplates()).templates || []; } catch (e) {}
try { odts = (await api.listOdtTemplates()).templates || []; } catch (e) {}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
Titel
${tpls.length ? `
Aus Vorlage erstellen (optional)
— Leerer Bericht —
${tpls.map(t => `📋 ${escapeHtml(t.label)} (${t.page_count} Seiten) `).join('')}
` : ''}
Format
A4
A3
A5
Letter
Hochformat
Querformat
${odts.length ? `
Deckblatt-Vorlage (ODT)
— Kein Deckblatt —
${odts.map(o => `${escapeHtml(o.label)} `).join('')}
` : ''}
`;
document.body.appendChild(modal);
pushModal(modal);
// ODT-Default vorauswählen
try {
const odtData = await api.listOdtTemplates();
if (odtData.default) {
const sel = modal.querySelector('#nr-odt');
if (sel) sel.value = odtData.default;
}
} catch (e) {}
modal.querySelector('#nr-close').onclick = () => closeModal(modal);
modal.querySelector('#nr-save').onclick = async () => {
const titel = modal.querySelector('#nr-titel').value.trim();
const tplSel = modal.querySelector('#nr-template');
const tpl = tplSel ? parseInt(tplSel.value, 10) : 0;
const format = modal.querySelector('#nr-format').value;
const orient = modal.querySelector('#nr-orient').value;
const odtSel = modal.querySelector('#nr-odt');
const odt = odtSel ? odtSel.value : '';
showToast('Lege Bericht an…');
try {
const res = await api.createReport({
element_type: 'order',
element_id: parseInt(orderId, 10),
titel: titel,
template_id: tpl,
page_format: format,
page_orientation: orient,
template_odt: odt,
});
showToast('✓ Bericht angelegt');
closeModal(modal);
router.go('#/reports/' + res.bericht_id);
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
};
}
/* ============================================================
* NEUER AUFTRAG MODAL (Schnell-Erfassung vom FAB)
* ============================================================ */
async function openNewOrderModal() {
if (!(await ensureAuth())) return;
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal);
const closeBtn = modal.querySelector('#no-close');
const saveBtn = modal.querySelector('#no-save');
const searchIn = modal.querySelector('#no-customer-search');
const listEl = modal.querySelector('#no-customer-list');
const stepCust = modal.querySelector('#no-step-customer');
const stepDet = modal.querySelector('#no-step-details');
const socName = modal.querySelector('#no-soc-name');
const socMeta = modal.querySelector('#no-soc-meta');
const defaultsEl = modal.querySelector('#no-defaults');
const titleIn = modal.querySelector('#no-title');
const refIn = modal.querySelector('#no-refclient');
const 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 = 'Tippe zum Suchen…
';
return;
}
listEl.innerHTML = 'Zuletzt verwendet
' +
recent.map(c => `
${escapeHtml(c.name)}
${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}
`).join('');
}
await renderRecent();
/* ---- Suche mit Debounce ---- */
let searchTimer = null;
searchIn.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = searchIn.value.trim();
if (!q) { renderRecent(); return; }
searchTimer = setTimeout(async () => {
try {
const data = await api.listCustomers({ q });
const items = (data.customers || []).slice(0, 30);
if (!items.length) {
listEl.innerHTML = 'Keine Treffer
';
return;
}
listEl.innerHTML = items.map(c => `
${escapeHtml(c.name)}
${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}
`).join('');
} catch (e) {
listEl.innerHTML = 'Fehler: ' + escapeHtml(e.message) + '
';
}
}, 300);
});
/* ---- Kunden-Klick: Details laden, Wechsel zum zweiten Schritt ---- */
listEl.addEventListener('click', async (e) => {
const item = e.target.closest('.nc-item');
if (!item) return;
const id = parseInt(item.dataset.id, 10);
if (!id) return;
showToast('Lade Kundendaten…');
try {
const res = await api.getCustomer(id);
const c = res.customer || res;
selectedCustomer = c;
socName.textContent = c.name || '';
socMeta.textContent = [c.address, ((c.zip || '') + ' ' + (c.town || '')).trim()].filter(Boolean).join(' · ');
const defs = [];
if (c.cond_reglement_label) defs.push('💳 ' + c.cond_reglement_label);
if (c.mode_reglement_label) defs.push('🏦 ' + c.mode_reglement_label);
if (!defs.length) defs.push('Keine speziellen Defaults hinterlegt');
defaultsEl.innerHTML = 'Übernommen: ' + escapeHtml(defs.join(' · '));
stepCust.hidden = true;
stepDet.hidden = false;
saveBtn.disabled = false;
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 = `
${url ? `
` : '
⚠
'}
Titel / Zwischentitel (groß oben auf der Seite):
Notiz zur Seite (unten auf der Seite):
💾 Speichern
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#pa-close').onclick = () => closeModal(modal);
modal.querySelector('#pa-save').onclick = async () => {
try {
const note = modal.querySelector('#pa-note').value;
const title = modal.querySelector('#pa-title').value;
await api.request('/pages.php?id=' + pageId, {
method: 'POST',
body: JSON.stringify({ note, title }),
});
showToast('✓ Gespeichert');
closeModal(modal);
router.navigate();
} catch (e) {
// Fallback falls api.request nicht exposed ist
try {
await api.updatePageNote(pageId, modal.querySelector('#pa-note').value);
showToast('✓ Notiz gespeichert');
closeModal(modal);
router.navigate();
} catch (er) { showToast('Fehler: ' + er.message, 'error'); }
}
};
modal.querySelector('#pa-delete').onclick = async () => {
if (!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
* ============================================================ */
/**
* Der EINE PDF-Betrachter der App — Berichtsvorschau, Lieferschein und Dokumente am Auftrag.
* Bis 15.10.2026 gab es zwei: die Dokumente öffneten einen älteren ohne Zoom (eine Seite,
* nur Vor/Zurück), deshalb ging „Finger auseinanderziehen" dort nicht.
*
* @param {string} blobUrl
* @param {string} filename
* @param {Function} [beiDownload] eigener Download-Weg (z. B. Originaldatei vom Server)
*/
function openPdfModal(blobUrl, filename, beiDownload) {
const fname = filename || 'bericht.pdf';
const modal = document.createElement('div');
modal.className = 'fullscreen-modal pdfv-modal';
modal.innerHTML = `
−
100 %
+
`;
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'); }
};
if (typeof beiDownload === 'function') {
modal.querySelector('#pdf-download').onclick = (e) => { e.preventDefault(); beiDownload(); };
}
const scroller = modal.querySelector('#pdfv-scroll');
const stack = modal.querySelector('#pdfv-stack');
const info = modal.querySelector('#pdfv-info');
const fitBtn = modal.querySelector('#pdfv-fit');
if (typeof pdfjs === 'undefined') {
// Ohne PDF.js bleibt der Browser-Betrachter
scroller.innerHTML = ``;
modal.querySelector('.pdfv-bar').style.display = 'none';
return;
}
const ZMIN = 0.5, ZMAX = 5;
let doc = null, zoom = 1, baseW = 0, pages = [], lauf = 0;
const fitWidth = () => Math.max(200, scroller.clientWidth - 16);
async function render() {
const mein = ++lauf;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssW = Math.round(baseW * zoom);
stack.style.width = cssW + 'px';
fitBtn.textContent = Math.round(zoom * 100) + ' %';
for (const pg of pages) {
if (mein !== lauf) return;
const vp1 = pg.page.getViewport({ scale: 1 });
const cssScale = cssW / vp1.width;
pg.canvas.style.width = cssW + 'px';
pg.canvas.style.height = Math.round(vp1.height * cssScale) + 'px';
// Scharf neu zeichnen, aber nur wenn sich die Auflösung wirklich ändert
let px = cssScale * dpr;
const maxSide = 4096;
px = Math.min(px, maxSide / Math.max(vp1.width, vp1.height));
const w = Math.round(vp1.width * px), h = Math.round(vp1.height * px);
if (pg.w === w && pg.h === h) continue;
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
await pg.page.render({ canvasContext: tmp.getContext('2d'), viewport: pg.page.getViewport({ scale: px }) }).promise;
if (mein !== lauf) return;
pg.canvas.width = w; pg.canvas.height = h;
pg.canvas.getContext('2d').drawImage(tmp, 0, 0);
pg.w = w; pg.h = h;
}
}
/** Zoom setzen; die Stelle unter (fx, fy) im Betrachter bleibt stehen. */
function setZoom(neu, fx, fy) {
neu = Math.min(ZMAX, Math.max(ZMIN, neu));
if (Math.abs(neu - zoom) < 0.001) return;
if (fx === undefined) { fx = scroller.clientWidth / 2; fy = scroller.clientHeight / 2; }
const relX = (scroller.scrollLeft + fx) / Math.max(1, stack.offsetWidth);
const relY = (scroller.scrollTop + fy) / Math.max(1, stack.offsetHeight);
zoom = neu;
stack.style.transform = '';
render();
// Größe steht sofort fest (CSS), die Schärfe folgt
scroller.scrollLeft = relX * stack.offsetWidth - fx;
scroller.scrollTop = relY * stack.offsetHeight - fy;
}
modal.querySelector('#pdfv-plus').onclick = () => setZoom(zoom * 1.25);
modal.querySelector('#pdfv-minus').onclick = () => setZoom(zoom / 1.25);
fitBtn.onclick = () => setZoom(1);
// Strg + Mausrad (Desktop)
scroller.addEventListener('wheel', (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
const r = scroller.getBoundingClientRect();
setZoom(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), e.clientX - r.left, e.clientY - r.top);
}, { passive: false });
// Zwei Finger: Vorschau per transform, scharf gezeichnet wird beim Loslassen
let pinch = null;
const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
scroller.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
const r = scroller.getBoundingClientRect();
pinch = {
d0: dist(e.touches), ratio: 1,
fx: (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left,
fy: (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top,
};
stack.style.transformOrigin = (scroller.scrollLeft + pinch.fx) + 'px ' + (scroller.scrollTop + pinch.fy) + 'px';
}
}, { passive: true });
scroller.addEventListener('touchmove', (e) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
pinch.ratio = Math.min(ZMAX / zoom, Math.max(ZMIN / zoom, dist(e.touches) / pinch.d0));
stack.style.transform = 'scale(' + pinch.ratio + ')';
}, { passive: false });
const pinchEnde = () => {
if (!pinch) return;
const p = pinch; pinch = null;
// Mit Vorschau-Skalierung: erst zurücksetzen, dann echt vergrößern (Anker bleibt)
stack.style.transform = '';
setZoom(zoom * p.ratio, p.fx, p.fy);
};
scroller.addEventListener('touchend', (e) => { if (e.touches.length < 2) pinchEnde(); });
scroller.addEventListener('touchcancel', pinchEnde);
// Doppeltipp: zwischen Seitenbreite und 2,5-fach wechseln
let letzterTipp = 0;
scroller.addEventListener('touchend', (e) => {
if (e.touches.length || e.changedTouches.length !== 1 || pinch) return;
const jetzt = Date.now();
if (jetzt - letzterTipp < 300) {
const r = scroller.getBoundingClientRect();
const t = e.changedTouches[0];
setZoom(zoom > 1.2 ? 1 : 2.5, t.clientX - r.left, t.clientY - r.top);
letzterTipp = 0;
} else { letzterTipp = jetzt; }
});
// Seitenanzeige
scroller.addEventListener('scroll', () => {
if (!pages.length) return;
const mitte = scroller.scrollTop + scroller.clientHeight / 3;
let nr = 1;
pages.forEach((pg, i) => { if (pg.canvas.offsetTop <= mitte) nr = i + 1; });
info.textContent = 'Seite ' + nr + ' / ' + pages.length;
}, { passive: true });
(async () => {
try {
const buf = await fetch(blobUrl).then(r => r.arrayBuffer());
doc = await pdfjs.getDocument({ data: buf }).promise;
baseW = fitWidth();
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i);
const canvas = document.createElement('canvas');
canvas.className = 'pdfv-page';
stack.appendChild(canvas);
pages.push({ page, canvas, w: 0, h: 0 });
}
info.textContent = 'Seite 1 / ' + doc.numPages;
await render();
} catch (e) {
scroller.innerHTML = 'PDF-Fehler: ' + escapeHtml(e.message || String(e)) + '
';
}
})();
}
/* ============================================================
* UNTERSCHRIFT MODAL (Touch-Signatur)
* ============================================================ */
function openSignatureModal(berichtId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal signature-modal';
modal.innerHTML = `
🗑 Leeren
Mit dem Finger unterschreiben
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { window.removeEventListener('resize', fitCanvas); } catch {} });
const canvas = modal.querySelector('#sig-canvas');
const ctx = canvas.getContext('2d');
function fitCanvas() {
const body = modal.querySelector('.signature-body');
const rect = body.getBoundingClientRect();
canvas.width = Math.max(600, Math.floor(rect.width * 2));
canvas.height = Math.max(300, Math.floor(rect.height * 2));
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#000';
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
fitCanvas();
window.addEventListener('resize', fitCanvas);
let drawing = false;
function pos(e) {
const r = canvas.getBoundingClientRect();
const sx = canvas.width / r.width;
const sy = canvas.height / r.height;
const t = e.touches ? e.touches[0] : e;
return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy };
}
function start(e) { e.preventDefault(); drawing = true; const p = pos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); }
function move(e) { if (!drawing) return; e.preventDefault(); const p = pos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); }
function end() { drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
canvas.addEventListener('mouseup', end);
canvas.addEventListener('mouseleave', end);
canvas.addEventListener('touchstart', start, { passive: false });
canvas.addEventListener('touchmove', move, { passive: false });
canvas.addEventListener('touchend', end);
modal.querySelector('#sig-clear').onclick = fitCanvas;
modal.querySelector('#sig-close').onclick = () => closeModal(modal);
modal.querySelector('#sig-save').onclick = async () => {
const signer = modal.querySelector('#sig-name').value.trim();
if (!signer) {
showToast('Bitte Namen des Unterzeichners eingeben', 'error');
modal.querySelector('#sig-name').focus();
return;
}
// GPS holen wenn aktiviert
let gps = null;
if (modal.querySelector('#sig-gps').checked && 'geolocation' in navigator) {
showToast('Hole GPS-Position…');
try {
const pos = await new Promise((res, rej) => {
navigator.geolocation.getCurrentPosition(res, rej, { timeout: 8000, enableHighAccuracy: true });
});
gps = { lat: pos.coords.latitude, lon: pos.coords.longitude };
} catch (e) {
console.warn('GPS failed', e);
}
}
showToast('Speichere Unterschrift…');
canvas.toBlob(async (blob) => {
try {
const opts = { signer_name: signer };
if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; }
await api.uploadSignature(berichtId, blob, opts);
showToast('✓ Unterschrift hinzugefügt');
closeModal(modal);
router.navigate();
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
}, 'image/png');
};
}
/* ============================================================
* 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 = `
${escapeHtml(order.order.ref)} · ${escapeHtml(order.customer.name)}
📭
Zu diesem Auftrag gibt es noch keine Lieferungen.
In Dolibarr erst eine Lieferung anlegen und validieren.
`;
return;
}
const statusLabel = (s) => ({0:'Entwurf', 1:'Validiert', 2:'Geschlossen', 3:'In Bearbeitung', '-1':'Storniert'})[s] || ('Status '+s);
main().innerHTML = `
${escapeHtml(order.order.ref)} · ${escapeHtml(order.customer.name)}
${list.map(s => `
🚚 ${escapeHtml(s.ref)}
${s.signed_status === 1
? '✓ unterschrieben '
: 'unbestätigt '}
${s.date_delivery ? 'Lieferdatum: ' + formatShortDate(s.date_delivery) : (s.date_creation ? 'Erstellt: ' + formatShortDate(s.date_creation) : '')}
· ${escapeHtml(statusLabel(s.status))}
`).join('')}
`;
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 = `
🚚 ${escapeHtml(data.shipment.ref)}
${data.order ? '
Auftrag: '+escapeHtml(data.order.ref)+'
' : ''}
Kunde: ${escapeHtml(data.customer.name)}
${isSigned
? '
✓ Bereits unterschrieben
'
: '
Noch nicht unterschrieben
'}
✍️ Lieferung unterschreiben lassen
`;
// 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 = 'Lieferschein-Vorschau
';
await renderPdfInline(res.url, wrap, 1);
wrap.style.cursor = 'zoom-in';
wrap.onclick = () => openPdfModal(res.url, (data.shipment.ref || 'lieferschein') + '.pdf');
wrap.insertAdjacentHTML('afterbegin', 'Antippen zum Vergrößern
');
}
} 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 += 'PDF-Vorschau nicht verfügbar
';
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 += 'PDF-Vorschau-Fehler: '+escapeHtml(e.message || String(e))+'
';
}
}
/**
* 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 = `
Hier mit dem Finger unterschreiben
`;
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 = `
📋 So funktioniert die Baustelle-App
1. Aufträge finden
Im Reiter Aufträge siehst du alle offenen Aufträge, die dir zugewiesen sind.
Oben kannst du per Suchfeld nach Auftragsnummer oder Kundenname filtern.
Auf einen Auftrag tippen → Detail-Ansicht mit Kunde, Adresse, Telefon.
2. Fotos aufnehmen
Im Auftrag-Detail:
📷 Foto aufnehmen — öffnet direkt die Kamera
📂 Aus Galerie wählen — mehrere Bilder auf einmal möglich
Die Bilder werden automatisch auf 2000px verkleinert und hochgeladen.
Sie landen in einem Entwurf-Bericht , der automatisch zum Auftrag angelegt wird.
💡 Alle weiteren Fotos werden an denselben Entwurf angehängt —
du hast also einen Bericht mit mehreren Seiten, bis du ihn finalisierst.
3. Fotos bearbeiten (Skizzen)
Tippe auf ein hochgeladenes Foto im Grid → Vollbild-Ansicht .
✏️ (oben) öffnet den Skizzen-Editor
🗑️ (oben) löscht das Foto
Im Skizzen-Editor:
✏️ Stift (Freihand)
↗ Pfeil mit Spitze (von Start bis Ende ziehen)
▭ Rechteck
○ Ellipse
Farbe + Linienstärke rechts daneben
↶ Undo / 🗑 Alles zurücksetzen
Oben rechts ✓ → Skizze wird als eigenständige neue Bericht-Seite gespeichert.
Das Original-Foto bleibt unverändert.
4. Sprachnotizen
🎙 Sprachnotiz aufnehmen im Auftrag-Detail:
Aufnahme starten, reden
Stopp → Vorhören
Senden → Audio landet im Auftrags-Anhang
Unter der Foto-Liste erscheint eine eigene Sektion „🎙 Sprachnotizen" mit Play-Button je Eintrag.
5. Berichte finalisieren
Im Reiter Berichte siehst du alle deine Berichte mit Status.
Entwurf — kannst du noch erweitern
Final — PDF wurde erzeugt, der Bericht ist eingefroren
Bericht öffnen → 📑 Bericht finalisieren → erzeugt das PDF und legt es
unter „Verknüpfte Dokumente" des Auftrags ab. In Dolibarr siehst du den Bericht dann direkt beim Auftrag.
💡 Wenn du nach dem Finalisieren neue Fotos machst, wird automatisch ein
neuer Entwurf angelegt. Der finalisierte bleibt unberührt.
6. Offline arbeiten
Die App funktioniert auch ohne Internet:
Fotos werden in einer lokalen Warteschlange gespeichert
Sobald wieder Empfang da ist, werden sie automatisch hochgeladen
Oben rechts siehst du den Status: 🟢 online ,
🟡 N (N Uploads werden synchronisiert),
🔴 N (offline, N warten)
7. Auf dem Handy installieren
So wird die PWA zur echten App:
Android/Chrome: Menü → „Zum Startbildschirm hinzufügen"
iPhone/Safari: Teilen-Symbol → „Zum Home-Bildschirm"
Danach startet sie wie eine normale App, ohne Browser-Leiste.
8. Einstellungen
Im Reiter ⚙️ :
🔄 Offline-Queue manuell synchronisieren
🚪 Abmelden
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#help-close').onclick = () => closeModal(modal);
}
/* 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function docIconFor(filename, mime) {
const ext = (filename || '').split('.').pop().toLowerCase();
if ((mime || '').includes('pdf') || ext === 'pdf') return '📕';
if (['doc','docx','odt','rtf'].includes(ext)) return '📝';
if (['xls','xlsx','ods','csv'].includes(ext)) return '📊';
if (['ppt','pptx','odp'].includes(ext)) return '📽';
if (['zip','rar','7z','tar','gz'].includes(ext)) return '🗜';
if (['txt','log','md'].includes(ext)) return '📃';
return '📄';
}
function formatFileSize(bytes) {
const n = Number(bytes) || 0;
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function formatShortDate(ts) {
const d = new Date(Number(ts) * 1000);
if (isNaN(d.getTime())) return '';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function openFileViewer({ url, blob, mime }, filename, relpath) {
const isImage = (mime || '').startsWith('image/');
const isPdf = (mime || '').includes('pdf') || /\.pdf$/i.test(filename);
console.log('[FILE] openFileViewer:', {filename, mime, isPdf, pdfjs: typeof pdfjs, blobSize: blob.size});
if (isPdf && typeof pdfjs !== 'undefined') {
console.log('[FILE] → PDF-Viewer aufrufen');
openPdfViewer(blob, filename, relpath);
return;
}
if (isPdf && typeof pdfjs === 'undefined') {
console.warn('[FILE] PDF.js ist NICHT geladen!');
showToast('⚠️ PDF.js nicht verfügbar, Download stattdessen', 'error');
}
if (!isImage) {
// Alles außer Bildern + PDFs: Download
console.log('[FILE] → Download-Fallback');
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
showToast('⬇ ' + escapeHtml(filename || 'Datei') + ' wird heruntergeladen');
return;
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal doc-viewer-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { URL.revokeObjectURL(url); } catch {} });
modal.querySelector('#dv-close').onclick = () => closeModal(modal);
modal.querySelector('#dv-share').onclick = () => {
shareFile(blob, filename || 'datei', mime || blob.type, filename);
};
}
async function openPdfViewer(blob, filename, relpath) {
if (!blob || blob.size === 0) {
showToast('❌ PDF-Datei ist leer oder nicht gefunden', 'error');
return;
}
// Derselbe Betrachter wie überall (Zoom per Knopf, Zwei-Finger, Doppeltipp, Strg+Rad).
// Download wie bisher als Originaldatei vom Server, nicht aus dem Zwischenspeicher.
const url = URL.createObjectURL(blob);
openPdfModal(url, filename || 'dokument.pdf', relpath ? () => {
const params = new URLSearchParams({ relpath, download: 1 });
window.location.href = window.location.origin + '/custom/bericht/api/photo.php?' + params.toString();
} : null);
}
/* ============================================================
* PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR
* ============================================================ */
async function openPhotoModal(orderId, relpath) {
// Alle Bilder im Grid sammeln für Navigation
const allThumbs = Array.from(document.querySelectorAll('#photo-grid .thumb[data-relpath]'));
const allRelpaths = allThumbs.map(t => t.dataset.relpath);
let currentIndex = allRelpaths.indexOf(relpath);
if (currentIndex < 0) currentIndex = 0;
// Zoom/Pan State
let zoom = 1, panX = 0, panY = 0;
let currentUrl = null;
const touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 };
const modal = document.createElement('div');
modal.className = 'fullscreen-modal photo-viewer-modal';
modal.innerHTML = `
‹
›
−
⟳
+
`;
document.body.appendChild(modal);
pushModal(modal, () => { try { document.removeEventListener('keydown', keyHandler); } catch {} });
const img = modal.querySelector('#pv-image');
const body = modal.querySelector('#pv-body');
const titleEl = modal.querySelector('#pv-title');
const counter = modal.querySelector('#pv-counter');
const prevBtn = modal.querySelector('#pv-prev');
const nextBtn = modal.querySelector('#pv-next');
function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
function applyTransform() { img.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`; }
function setZoom(z) {
zoom = Math.max(0.5, Math.min(5, z));
constrainPan();
applyTransform();
}
function constrainPan() {
if (zoom <= 1) { panX = 0; panY = 0; return; }
const rect = body.getBoundingClientRect();
const maxX = Math.max(0, (img.naturalWidth * zoom - rect.width) / 2);
const maxY = Math.max(0, (img.naturalHeight * zoom - rect.height) / 2);
panX = Math.max(-maxX, Math.min(maxX, panX));
panY = Math.max(-maxY, Math.min(maxY, panY));
}
async function loadImage(idx) {
if (idx < 0 || idx >= allRelpaths.length) return;
currentIndex = idx;
const rp = allRelpaths[idx];
titleEl.textContent = rp.split('/').pop();
counter.textContent = `${idx + 1} / ${allRelpaths.length}`;
prevBtn.disabled = idx === 0;
nextBtn.disabled = idx === allRelpaths.length - 1;
prevBtn.style.display = nextBtn.style.display = allRelpaths.length > 1 ? '' : 'none';
img.style.opacity = '0.3';
const url = await api.getPhotoBlobUrl(rp);
if (url) {
currentUrl = url;
img.src = url;
img.onload = () => { img.style.opacity = '1'; };
}
resetZoom();
}
function close() { closeModal(modal); }
function goPrev() { if (currentIndex > 0) loadImage(currentIndex - 1); }
function goNext() { if (currentIndex < allRelpaths.length - 1) loadImage(currentIndex + 1); }
// Events
modal.querySelector('#fs-close').onclick = close;
prevBtn.onclick = (e) => { e.stopPropagation(); goPrev(); };
nextBtn.onclick = (e) => { e.stopPropagation(); goNext(); };
modal.querySelector('#pv-zin').onclick = () => setZoom(zoom + 0.5);
modal.querySelector('#pv-zout').onclick = () => setZoom(zoom - 0.5);
modal.querySelector('#pv-zreset').onclick = resetZoom;
modal.querySelector('#fs-delete').onclick = async () => {
if (!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 = `
●
00:00
Aufnahme starten
Stopp
Senden
`;
document.body.appendChild(modal);
let mediaRecorder = null;
let chunks = [];
let timer = null;
let startTime = 0;
let audioBlob = null;
pushModal(modal, () => {
try { if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop(); } catch {}
try { if (timer) clearInterval(timer); } catch {}
});
const startBtn = modal.querySelector('#v-start');
const stopBtn = modal.querySelector('#v-stop');
const sendBtn = modal.querySelector('#v-send');
const indicator = modal.querySelector('#v-indicator');
const timeEl = modal.querySelector('#v-time');
const preview = modal.querySelector('#v-preview');
modal.querySelector('#v-close').onclick = () => closeModal(modal);
startBtn.onclick = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
chunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
audioBlob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });
preview.src = URL.createObjectURL(audioBlob);
preview.style.display = '';
sendBtn.style.display = '';
indicator.classList.remove('recording');
};
mediaRecorder.start();
startTime = Date.now();
indicator.classList.add('recording');
startBtn.style.display = 'none';
stopBtn.style.display = '';
timer = setInterval(() => {
const s = Math.floor((Date.now() - startTime) / 1000);
timeEl.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
}, 500);
} catch (e) {
showToast('Mikrofon-Zugriff verweigert', 'error');
}
};
stopBtn.onclick = () => {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
if (timer) clearInterval(timer);
stopBtn.style.display = 'none';
};
sendBtn.onclick = async () => {
if (!audioBlob) return;
sendBtn.disabled = true;
try {
await api.uploadVoiceNote(orderId, audioBlob, 'voice_' + Date.now() + '.webm');
showToast('✓ Sprachnotiz hochgeladen');
closeModal(modal);
} catch (e) {
showToast('Upload fehlgeschlagen: ' + e.message, 'error');
sendBtn.disabled = false;
}
};
}
/* ============================================================
* 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 = `
Kamera wird gestartet…
1×
Foto
Video
`;
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');
const stage = modal.querySelector('#cam-stage');
const blitzBtn = modal.querySelector('#cam-blitz');
const fokusRing = modal.querySelector('#cam-fokus-ring');
const flipBtn = modal.querySelector('#cam-flip');
const zoomBtn = modal.querySelector('#cam-zoom');
const recEl = modal.querySelector('#cam-rec');
const modusLeiste = modal.querySelector('#cam-modus');
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
// Laeuft gerade ein Video? Schliessen (✕, Fertig, Zurueck-Geste) beendet die Aufnahme und
// SICHERT sie — das Video kommt danach wie jedes Foto in die Warteschlange.
// gezaehlt: das Video zaehlt schon jetzt als Aufnahme (Auftrag wird unten neu geladen),
// videoSichern zaehlt es dann nicht noch einmal.
if (aufnahme) { aufnahme.gezaehlt = true; shots++; videoStoppen(); }
document.removeEventListener('visibilitychange', beiHintergrund);
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);
flipBtn.onclick = () => {
facing = (facing === 'environment') ? 'user' : 'environment';
startStream();
};
let videos = 0; // davon Videos (shots zaehlt alle Aufnahmen)
function updateCount() {
const fotos = shots - videos;
const teile = [];
if (fotos > 0) teile.push(fotos + (fotos === 1 ? ' Foto' : ' Fotos'));
if (videos > 0) teile.push(videos + (videos === 1 ? ' Video' : ' Videos'));
countEl.textContent = teile.join(' · ');
}
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';
// Wechselknopf nur, wenn es eine zweite Kamera gibt (Emulator, Tablet-Webcam: nur eine).
// Erst nach der Freigabe ist die Geraeteliste vollstaendig.
navigator.mediaDevices.enumerateDevices().then(l => {
if (my === gen) flipBtn.hidden = l.filter(d => d.kind === 'videoinput').length < 2;
}).catch(() => {});
await kameraExtras(my);
} 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, istVideo) {
const t = document.createElement('div');
t.className = 'cam-thumb saving' + (istVideo ? ' is-video' : '');
t.innerHTML = (url ? ' ' : '🎬
')
+ (istVideo ? '▶ ' : '')
+ ' '
+ '🗑 ';
// Tipp auf Miniatur → Löschen-Overlay ein/aus
t.firstElementChild.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;
const was = t.classList.contains('is-video') ? 'Video' : 'Foto';
if (!relpath && !qid) { showToast(was + ' 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 ' + was + ' 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);
if (was === 'Video') videos = Math.max(0, videos - 1);
updateCount();
showToast(was + ' gelöscht');
} catch (e) {
t.dataset.deleting = '';
if (delBtn) delBtn.disabled = false;
showToast('Löschen fehlgeschlagen: ' + (e.message || ''), 'error');
}
}
/* Blitz und Fokus (Eddy, 19.10.2026: „den Blitz kann man nicht einstellen und den Fokus auch nicht").
*
* Angeboten wird nur, was das Geraet meldet — Chrome kennt es erst, wenn der Strom laeuft:
* - Blitz beim Ausloesen: ImageCapture.getPhotoCapabilities().fillLightMode (auto/flash),
* wirkt nur ueber takePhoto({ fillLightMode }) — nicht beim Standbild aus dem Sucher.
* - Licht (Dauerlicht): track.getCapabilities().torch, per applyConstraints.
* - Scharfstellen per Antippen: pointsOfInterest = angetippte Stelle, focusMode 'continuous'
* → der Autofokus stellt laufend auf diese Stelle scharf. 'single-shot' meldet Chrome zwar,
* loest es auf Android aber nicht ein (kein AF-Trigger in VideoCaptureCamera2.java, der
* Modus bleibt CONTINUOUS_PICTURE — im Emulator per dumpsys gemessen, 19.10.2026).
* Chrome setzt mit dem Punkt zugleich Belichtungs- und Weissabgleich-Bereich (AE/AWB_REGIONS):
* Display oder Typenschild antippen = dort auch richtig belichtet, kein eigener Regler noetig.
* - Zoom mit zwei Fingern: track.getCapabilities().zoom, Chrome schneidet den Sensor aus
* (SCALER_CROP_REGION, gilt auch fuer takePhoto). Beim Zoomen verwirft Chrome den Tippbereich.
* Frontkamera, Webcam am Tablet (X12) usw. melden meist nichts davon → kein Knopf, kein Antippen.
*/
const BLITZ_TEXT = { off: '⚡ Aus', auto: '⚡ Auto', flash: '⚡ An', torch: '🔦 Licht' };
const BLITZ_KEY = 'baustelle.kamera.blitz';
let imageCap = null;
let blitzModi = []; // Modi des aktuellen Kamera-Modus (Foto bzw. Video)
let blitz = 'off'; // aktuell gewaehlt
let blitzFotoModi = [], blitzVideoModi = [];
let blitzFoto = 'off', blitzVideo = 'off'; // Auswahl je Modus (Foto wird gemerkt)
let fotoBlitz = false; // kann takePhoto() blitzen (fillLightMode)?
let fokusModus = ''; // 'continuous' | 'single-shot' | '' (kein Antippen)
let zoomBereich = null; // { min, max, step } aus getCapabilities, sonst kein Zoom
let zoom = 1;
let tippHinweis = false;
function videoTrack() {
return stream ? stream.getVideoTracks()[0] || null : null;
}
async function kameraExtras(my) {
imageCap = null; blitzModi = []; fotoBlitz = false; fokusModus = ''; zoomBereich = null; zoom = 1;
blitzBtn.hidden = true; fokusRing.hidden = true; zoomBtn.hidden = true;
const track = videoTrack();
if (!track) return;
let caps = {};
try { caps = typeof track.getCapabilities === 'function' ? (track.getCapabilities() || {}) : {}; } catch (_) {}
let licht = [];
if (typeof ImageCapture === 'function') {
try {
imageCap = new ImageCapture(track);
const pc = await imageCap.getPhotoCapabilities();
licht = Array.isArray(pc && pc.fillLightMode) ? pc.fillLightMode : [];
} catch (_) { /* kein Standbild-Erfasser — Sucherbild bleibt */ }
}
if (my !== gen) return; // inzwischen geschlossen/gewechselt
fotoBlitz = licht.includes('auto') || licht.includes('flash');
const modi = [];
if (fotoBlitz || caps.torch) modi.push('off');
if (licht.includes('auto')) modi.push('auto');
if (licht.includes('flash')) modi.push('flash');
if (caps.torch) modi.push('torch');
blitzFotoModi = modi;
blitzVideoModi = caps.torch ? ['off', 'torch'] : []; // beim Filmen gibt es nur Dauerlicht
let gemerkt = 'off';
try { gemerkt = localStorage.getItem(BLITZ_KEY) || 'off'; } catch (_) {}
blitzFoto = modi.includes(gemerkt) ? gemerkt : 'off';
blitz = 'off'; // frischer Strom: Licht ist aus
blitzFuerModus();
const fm = Array.isArray(caps.focusMode) ? caps.focusMode : [];
fokusModus = fm.includes('continuous') ? 'continuous' : (fm.includes('single-shot') ? 'single-shot' : '');
const zb = caps.zoom;
if (zb && typeof zb.max === 'number' && zb.max > (zb.min || 1)) {
zoomBereich = { min: zb.min || 1, max: zb.max, step: zb.step || 0.1 };
try { zoom = track.getSettings().zoom || zoomBereich.min; } catch (_) { zoom = zoomBereich.min; }
zoomAnzeigen();
zoomBtn.hidden = false;
}
const tipps = [];
if (fokusModus) tipps.push('Antippen stellt dort scharf');
if (zoomBereich) tipps.push('zwei Finger zoomen');
if (tipps.length && !tippHinweis) {
tippHinweis = true;
const text = tipps.join(' · ');
hint.textContent = text;
hint.style.display = '';
setTimeout(() => { if (hint.textContent === text) hint.style.display = 'none'; }, 2500);
}
}
function zoomAnzeigen() {
const z = Math.round(zoom * 10) / 10;
zoomBtn.textContent = (Number.isInteger(z) ? String(z) : z.toFixed(1).replace('.', ',')) + '×';
zoomBtn.classList.toggle('aktiv', z > (zoomBereich ? zoomBereich.min : 1));
}
// Zoom setzen: waehrend der Geste kommen viele Werte — immer nur einen Aufruf gleichzeitig
// an die Kamera schicken, danach den zuletzt gewuenschten (Zwischenwerte verfallen).
let zoomLaeuft = false, zoomZiel = null;
async function zoomSetzen(z) {
if (!zoomBereich) return;
const { min, max, step } = zoomBereich;
zoomZiel = Math.min(max, Math.max(min, Math.round(Math.round(z / step) * step * 100) / 100));
zoom = zoomZiel;
zoomAnzeigen();
if (zoomLaeuft) return;
zoomLaeuft = true;
while (zoomZiel !== null) {
const ziel = zoomZiel; zoomZiel = null;
const track = videoTrack();
if (!track) break;
try { await track.applyConstraints({ advanced: [{ zoom: ziel }] }); } catch (_) {}
}
zoomLaeuft = false;
}
zoomBtn.onclick = (ev) => { ev.stopPropagation(); if (zoomBereich) zoomSetzen(zoomBereich.min); };
// Zwei Finger auf dem Sucher = zoomen (Abstand der Finger relativ zum Start)
let pinch = null, pinchEnde = 0;
const fingerAbstand = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
stage.addEventListener('touchstart', (e) => {
if (e.touches.length === 2 && zoomBereich) pinch = { d0: fingerAbstand(e.touches), z0: zoom };
}, { passive: true });
stage.addEventListener('touchmove', (e) => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
zoomSetzen(pinch.z0 * fingerAbstand(e.touches) / pinch.d0);
}, { passive: false });
const pinchAus = (e) => { if (pinch && (!e.touches || e.touches.length < 2)) { pinch = null; pinchEnde = Date.now(); } };
stage.addEventListener('touchend', pinchAus);
stage.addEventListener('touchcancel', pinchAus);
async function lichtSchalten(an) {
const track = videoTrack();
if (!track) return false;
try { await track.applyConstraints({ advanced: [{ torch: an }] }); return true; }
catch (_) { return false; }
}
blitzBtn.onclick = async () => {
if (blitzModi.length < 2) return;
const vorher = blitz;
blitz = blitzModi[(blitzModi.indexOf(blitz) + 1) % blitzModi.length];
if (vorher === 'torch' || blitz === 'torch') {
if (!await lichtSchalten(blitz === 'torch') && blitz === 'torch') {
showToast('Das Licht lässt sich hier nicht schalten', 'warn');
blitz = blitzModi[(blitzModi.indexOf(blitz) + 1) % blitzModi.length];
}
}
blitzBtn.textContent = BLITZ_TEXT[blitz];
if (modus === 'video') {
blitzVideo = blitz;
} else {
blitzFoto = blitz;
try { localStorage.setItem(BLITZ_KEY, blitz); } catch (_) {}
}
};
/* Blitz-Knopf passend zum Modus: beim Filmen gibt es nur Aus/Licht (ein Blitz wirkt nur
* beim Standbild). Das Licht folgt der Auswahl des jeweiligen Modus. */
function blitzFuerModus() {
const vorher = blitz;
blitzModi = modus === 'video' ? blitzVideoModi : blitzFotoModi;
blitz = modus === 'video' ? blitzVideo : blitzFoto;
if (!blitzModi.includes(blitz)) blitz = 'off';
blitzBtn.hidden = blitzModi.length < 2;
blitzBtn.textContent = BLITZ_TEXT[blitz];
if ((vorher === 'torch') !== (blitz === 'torch')) lichtSchalten(blitz === 'torch');
}
/* ---- Video ----
* MediaRecorder auf derselben Kameraspur wie der Sucher (Licht, Zoom und Antippen-Fokus
* wirken also auch beim Filmen), dazu das Mikrofon — erst beim ersten Video angefragt.
* MP4 (H.264/AAC), wenn der Browser es kann: spielt ueberall (Dolibarr, Windows, iPhone);
* Chrome auf Android kann es (im Emulator geprueft, 19.10.2026), sonst WebM.
* 5 Mbit/s ≈ 38 MB pro Minute. Hoechstlaenge 5 Minuten, kuerzer, wenn der Server weniger
* annimmt (config.php upload_max) — das Video muss am Ende auch hochgehen. */
const VIDEO_BPS = 5000000;
const TON_BPS = 128000;
const VIDEO_MAX_SEK = 300;
let modus = 'foto';
let aufnahme = null; // laufende Aufnahme { rec, ton, start, stuecke, max, uhr, poster }
let videoStartet = false;
function videoHoechstSek() {
const max = photoCfg.upload_max || 0;
if (!max) return VIDEO_MAX_SEK;
// 85 % der Servergrenze — MediaRecorder haelt die Zielrate nur ungefaehr
return Math.max(15, Math.min(VIDEO_MAX_SEK, Math.floor(max * 0.85 / ((VIDEO_BPS + TON_BPS) / 8))));
}
function videoFormat(mitTon) {
const kandidaten = mitTon
? ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm']
: ['video/mp4;codecs=avc1', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm'];
for (const k of kandidaten) {
try { if (MediaRecorder.isTypeSupported(k)) return k; } catch (_) {}
}
return '';
}
const zeitText = (s) => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
// Kleines Standbild aus dem Sucher als Vorschau im Filmstreifen
function sucherVorschau() {
return new Promise((res) => {
try {
if (!video.videoWidth) return res('');
const c = document.createElement('canvas');
c.width = 240;
c.height = Math.round(240 * video.videoHeight / video.videoWidth);
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
c.toBlob(b => res(b ? URL.createObjectURL(b) : ''), 'image/jpeg', 0.7);
} catch (_) { res(''); }
});
}
function modusSetzen(m) {
if (aufnahme || videoStartet || m === modus) return;
modus = m;
modusLeiste.querySelectorAll('.cam-modus-btn').forEach(b => b.classList.toggle('aktiv', b.dataset.modus === m));
shutter.classList.toggle('video', m === 'video');
shutter.setAttribute('aria-label', m === 'video' ? 'Aufnahme starten' : 'Auslösen');
blitzFuerModus();
}
async function videoStarten() {
const track = videoTrack();
if (!track || aufnahme || videoStartet) return;
videoStartet = true;
try {
// Ton dazu — verweigert der Nutzer das Mikrofon, wird stumm gefilmt
let ton = null;
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
ton = s.getAudioTracks()[0] || null;
} catch (_) {
showToast('Kein Mikrofon freigegeben — Video ohne Ton', 'warn');
}
if (videoTrack() !== track || !document.body.contains(modal) || modus !== 'video') {
if (ton) ton.stop();
return;
}
const format = videoFormat(!!ton);
const opts = { videoBitsPerSecond: VIDEO_BPS };
if (ton) opts.audioBitsPerSecond = TON_BPS;
if (format) opts.mimeType = format;
let rec;
try {
rec = new MediaRecorder(new MediaStream(ton ? [track, ton] : [track]), opts);
} catch (e) {
if (ton) ton.stop();
showToast('Video-Aufnahme nicht möglich: ' + (e.message || e.name), 'error');
return;
}
const a = { rec, ton, start: Date.now(), stuecke: [], max: videoHoechstSek(), uhr: null, poster: '' };
a.poster = await sucherVorschau();
rec.ondataavailable = (ev) => { if (ev.data && ev.data.size) a.stuecke.push(ev.data); };
rec.onstop = () => videoSichern(a);
rec.onerror = (ev) => {
showToast('Video-Aufnahme abgebrochen: ' + ((ev.error && ev.error.message) || ''), 'error');
if (aufnahme === a) videoStoppen();
};
rec.start(1000); // jede Sekunde ein Stueck — bricht etwas ab, ist das Bisherige da
aufnahme = a;
shutter.classList.add('laeuft');
shutter.setAttribute('aria-label', 'Aufnahme beenden');
modusLeiste.classList.add('gesperrt');
flipBtn.disabled = true;
recEl.hidden = false;
uhrStellen();
a.uhr = setInterval(uhrStellen, 500);
} finally {
videoStartet = false;
}
}
function uhrStellen() {
const a = aufnahme;
if (!a) return;
const sek = Math.floor((Date.now() - a.start) / 1000);
recEl.textContent = '● ' + zeitText(Math.min(sek, a.max)) + ' / ' + zeitText(a.max);
if (sek >= a.max) {
videoStoppen();
showToast('Höchstlänge erreicht — Video gespeichert', 'warn');
}
}
function videoStoppen() {
const a = aufnahme;
if (!a) return;
aufnahme = null;
clearInterval(a.uhr);
try {
if (a.rec.state !== 'inactive') a.rec.stop(); // onstop → videoSichern
else videoSichern(a);
} catch (_) { videoSichern(a); }
shutter.classList.remove('laeuft');
shutter.setAttribute('aria-label', 'Aufnahme starten');
modusLeiste.classList.remove('gesperrt');
flipBtn.disabled = false;
recEl.hidden = true;
}
// Persist-First wie beim Foto: das Video geht als Erstes in die Warteschlange
async function videoSichern(a) {
if (a.gesichert) return;
a.gesichert = true;
if (a.ton) { try { a.ton.stop(); } catch (_) {} }
const typ = ((a.rec.mimeType || 'video/webm').split(';')[0]) || 'video/webm';
const blob = new Blob(a.stuecke, { type: typ });
a.stuecke = [];
if (!blob.size) { showToast('Video leer — es wurde nichts aufgenommen', 'error'); return; }
if (!a.gezaehlt) { shots++; videos++; updateCount(); }
const thumb = addThumb(a.poster, true);
try {
const name = 'video_' + a.start + '.' + (typ.indexOf('mp4') >= 0 ? 'mp4' : 'webm');
const qid = await offline.enqueuePhoto(orderId, blob, name, { takenAt: a.start });
thumb.dataset.qid = qid;
thumb.classList.remove('saving');
thumb.classList.add('pending');
offline.syncQueue();
} 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 und Videos hochladen'
: ('Video sichern fehlgeschlagen: ' + (e.message || '')), 'error');
}
}
// App in den Hintergrund (Anruf, App-Wechsel): Chrome haelt die Kamera an — Video sichern
function beiHintergrund() {
if (document.visibilityState === 'hidden' && aufnahme) videoStoppen();
}
document.addEventListener('visibilitychange', beiHintergrund);
if (typeof MediaRecorder === 'function') {
modusLeiste.hidden = false;
modusLeiste.querySelectorAll('.cam-modus-btn').forEach(b => { b.onclick = () => modusSetzen(b.dataset.modus); });
}
/* Chrome gibt pointsOfInterest UNGEDREHT an den Sensor weiter (Punkt × Sensorflaeche,
* VideoCaptureCamera2.java) — das Sucherbild kommt aber schon gedreht an: Sensor quer,
* Bild am Handy hochkant. Ohne Rueckdrehung stellte ein Tipp bei 67 % / 82 % auf 67 % / 82 %
* des QUER liegenden Sensors scharf (Emulator, dumpsys afRegions) — also an falscher Stelle.
* Drehung wie in Chrome: Rueckkamera Sensor − Bildschirmwinkel, Frontkamera Sensor + Winkel.
* Sensorlage Rueckkamera 90° (Nothing A059: takePhoto-Drehmarke 6, KB #1494), Frontkamera 270°;
* passt das nicht zum Bildformat (Tablet mit 0°-Sensor), ohne 90°-Anteil. */
function sensorPunkt(x, y) {
const winkel = (screen.orientation && typeof screen.orientation.angle === 'number') ? screen.orientation.angle : 0;
const drehung = (sensor) => (facing === 'user' ? sensor + winkel : sensor - winkel + 360) % 360;
let d = drehung(facing === 'user' ? 270 : 90);
if ((d % 180 === 90) !== (video.videoHeight > video.videoWidth)) d = drehung(0);
switch (d) {
case 90: return { x: y, y: 1 - x };
case 180: return { x: 1 - x, y: 1 - y };
case 270: return { x: 1 - y, y: x };
default: return { x, y };
}
}
stage.addEventListener('click', async (ev) => {
const track = videoTrack();
if (!track || !fokusModus || !video.videoWidth) return;
if (pinch || Date.now() - pinchEnde < 400) return; // Ende einer Zoom-Geste ist kein Tipp
// Antippstelle in Bildkoordinaten 0..1 — die Vorschau ist eingepasst (object-fit: contain)
const r = video.getBoundingClientRect();
const sk = Math.min(r.width / video.videoWidth, r.height / video.videoHeight);
const w = video.videoWidth * sk, h = video.videoHeight * sk;
const x = (ev.clientX - r.left - (r.width - w) / 2) / w;
const y = (ev.clientY - r.top - (r.height - h) / 2) / h;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
const punkt = sensorPunkt(x, y);
const sr = stage.getBoundingClientRect();
fokusRing.style.left = (ev.clientX - sr.left) + 'px';
fokusRing.style.top = (ev.clientY - sr.top) + 'px';
fokusRing.hidden = false;
fokusRing.classList.remove('ok', 'aktiv');
void fokusRing.offsetWidth; // Animation neu starten
fokusRing.classList.add('aktiv');
let ok = false;
try {
await track.applyConstraints({ advanced: [{ pointsOfInterest: [punkt], focusMode: fokusModus }] });
ok = true;
} catch (_) {
// Ohne Punktwahl wenigstens neu scharfstellen
try { await track.applyConstraints({ advanced: [{ focusMode: fokusModus }] }); ok = true; } catch (_) {}
}
fokusRing.classList.toggle('ok', ok);
setTimeout(() => { fokusRing.hidden = true; }, 900);
});
/* 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 = imageCap || new ImageCapture(track);
let blob = null;
if (fotoBlitz && blitz !== 'torch') {
// Blitz ausdruecklich setzen (auch „aus"); klappt das nicht, ohne Einstellungen ausloesen
try { blob = await cap.takePhoto({ fillLightMode: blitz }); } catch (_) { blob = null; }
}
if (!blob) blob = await cap.takePhoto();
// Manche Geraete schalten das Dauerlicht beim Ausloesen ab
if (blitz === 'torch') lichtSchalten(true);
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 = () => {
if (modus !== 'video') return capture();
if (aufnahme) videoStoppen();
else videoStarten();
};
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 und Videos 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 = `
${pending.length} warten auf Upload${failed.length ? ', ' + failed.length + ' fehlgeschlagen ' : ''}.
🔄 Jetzt synchronisieren
${failed.length ? '
↻ Fehlgeschlagene erneut senden ' : ''}
${failed.length ? '
📤 Fehlgeschlagene sichern / teilen ' : ''}
Fotos und Videos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.
`;
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 und -Videos');
};
}
// 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 = `
✏️
↗
▭
○
⚠
✓
✗
🔧
📏⚙
📏
↶
🗑
`;
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__.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 = `
`;
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 = '';
return;
}
listEl.innerHTML = data.notes.map(n => `
${escapeHtml(n.subject || '(ohne Betreff)')}
🗑
${escapeHtml(n.preview || '')}
${escapeHtml(n.modified || n.created || formatShortDate(n.date))}
`).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 = '⚠️
' + escapeHtml(e.message) + '
';
}
}
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 = `
${existing && existing.created ? `
Erfasst: ${escapeHtml(existing.created)}${existing.modified ? ' · Geändert: ' + escapeHtml(existing.modified) : ''}
` : ''}
`;
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);
}