baustelle-pwa/app.js
Eduard Wisch 02ffdca57c
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 1s
feat: Phase 4 Block 1 — Seite bearbeiten, PDF-Vorschau, Unterschrift, Share Target
Bericht-Detail-Ansicht:
- Tap auf Seiten-Thumb → openPageActionsModal mit:
  - Vorschaubild
  - Textarea für Seiten-Notiz (wird im PDF unter der Seite gedruckt)
  - '💾 Notiz speichern' → api.updatePageNote
  - 🗑️ im Header → api.deletePage mit Confirm
- Seiten-Thumbs haben Nummer-Badge oben links (1, 2, 3…)
- Neuer '👁 PDF-Vorschau'-Button öffnet openPdfModal (iframe mit
  Blob-URL) für Final-PDF oder on-the-fly Preview
- Neuer '✍️ Kunden-Unterschrift hinzufügen'-Button öffnet
  openSignatureModal: Touch-Canvas 2:1, Clear, Save → PNG wird als
  neue Bericht-Seite mit note='Unterschrift Kunde' angelegt

Web Share Target API:
- manifest.webmanifest: share_target mit photos array
- share.html: empfängt geteilte Fotos aus IDB (vom SW befüllt),
  zeigt Auftragsliste, Tap → Upload aller Fotos
- Service Worker v5: fängt POST /share.html ab, schreibt Files in
  IDB Key 'shared_files', redirected 303

Cache-Version bumpt damit neue Files geladen werden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
2026-04-09 01:00:24 +02:00

1056 lines
45 KiB
JavaScript

/* Baustelle PWA Hauptlogik. Alle Routen + Views in einer Datei. */
const main = () => document.getElementById('main');
const title = (s) => document.getElementById('page-title').textContent = s;
window.showToast = function (msg, kind) {
const t = document.createElement('div');
t.className = 'toast' + (kind ? ' ' + kind : '');
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 2800);
};
function showLoader(text) {
main().innerHTML = '<div class="loader">' + (text || 'Lade…') + '</div>';
}
function setNav(visible, active) {
const nav = document.getElementById('bottom-nav');
nav.style.display = visible ? '' : 'none';
nav.querySelectorAll('button').forEach(b => {
b.classList.toggle('active', b.dataset.route === active);
});
}
function setBack(visible, hash) {
const btn = document.getElementById('back-btn');
btn.style.display = visible ? '' : 'none';
btn.onclick = () => { if (hash) router.go(hash); else history.back(); };
}
/* ----- Auth-Check ----- */
async function ensureAuth() {
const t = await api.getToken();
if (!t) {
router.go('#/login');
return false;
}
return true;
}
/* ====== ROUTES ====== */
router.on('/login', async () => {
title('Anmelden');
setNav(false);
setBack(false);
main().innerHTML = `
<form class="login-form" id="login-form">
<h2>🔧 Baustelle</h2>
<input type="text" name="login" placeholder="Benutzername" autocomplete="username" required>
<input type="password" name="password" placeholder="Passwort" autocomplete="current-password" required>
<button type="submit" class="btn btn-large">Anmelden</button>
</form>
`;
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
await api.login(fd.get('login'), fd.get('password'));
showToast('Erfolgreich angemeldet');
router.go('#/orders');
} catch (err) {
showToast(err.message, 'error');
}
});
});
router.on('/orders', async () => {
if (!(await ensureAuth())) return;
title('Aufträge');
setNav(true, 'orders');
setBack(false);
showLoader('Lade Aufträge…');
try {
const data = await api.listOrders({ open: 1 });
if (!data.orders.length) {
main().innerHTML = '<div class="empty-state"><div class="icon">📭</div>Keine offenen Aufträge</div>';
return;
}
const html = `
<div class="search-bar"><input type="search" id="order-search" placeholder="🔍 Suchen…"></div>
<div id="order-list">${renderOrderList(data.orders)}</div>
`;
main().innerHTML = html;
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
});
document.getElementById('order-search').addEventListener('input', async (e) => {
const q = e.target.value;
const d = await api.listOrders({ q, open: 1 });
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
});
});
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
function renderOrderList(orders) {
return orders.map(o => `
<div class="order-card" data-id="${o.id}">
<div class="ref">${escapeHtml(o.ref)}</div>
<div class="name">${escapeHtml(o.customer.name || '')}</div>
<div class="meta">
<span>${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))}</span>
${o.bericht_count > 0 ? `<span class="badge">📑 ${o.bericht_count}</span>` : ''}
</div>
</div>
`).join('');
}
router.on('/orders/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'orders');
setBack(true, '#/orders');
showLoader('Lade Auftrag…');
try {
const data = await api.getOrder(args.id);
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
title(data.order.ref);
// Nach MIME-Type aufteilen
const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/'));
const audioFiles = photos.photos.filter(p => (p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
const otherDocs = photos.photos.filter(p =>
!(p.mime || '').startsWith('image/') &&
!(p.mime || '').startsWith('audio/') &&
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
main().innerHTML = `
<div class="detail-section">
<h3>Kunde</h3>
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
<p>${escapeHtml(data.customer.address || '')}</p>
<p>${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}</p>
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
</div>
${data.order.auftragsbeschreibung ? `
<div class="detail-section">
<h3>Beschreibung</h3>
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
</div>` : ''}
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
<div class="detail-section" style="margin-top:16px;">
<h3>Hochgeladene Fotos (${imagePhotos.length})</h3>
<div class="photo-grid" id="photo-grid">
${imagePhotos.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('')}
</div>
</div>
${audioFiles.length ? `
<div class="detail-section">
<h3>🎙 Sprachnotizen (${audioFiles.length})</h3>
<div class="audio-list">
${audioFiles.map(a => `<div class="audio-item" data-relpath="${escapeHtml(a.relpath)}" data-mime="${escapeHtml(a.mime || 'audio/webm')}"><span class="audio-name">${escapeHtml(a.filename)}</span><button class="audio-play" title="Abspielen">▶</button></div>`).join('')}
</div>
</div>` : ''}
${otherDocs.length ? `
<div class="detail-section">
<h3>Weitere Dokumente (${otherDocs.length})</h3>
${otherDocs.map(p => `<p>📄 ${escapeHtml(p.filename)}</p>`).join('')}
</div>` : ''}
`;
// Foto-Thumbnails: Tap = Vollbild-Modal
document.querySelectorAll('#photo-grid .thumb').forEach(t => {
t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath));
});
// Sprachnotiz
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
// Audio-Files abspielen
document.querySelectorAll('.audio-item .audio-play').forEach(btn => {
btn.addEventListener('click', async (e) => {
const item = e.target.closest('.audio-item');
const rel = item.dataset.relpath;
const mime = item.dataset.mime || 'audio/webm';
// Existierenden Player toggeln
let player = item.querySelector('audio');
if (player) {
if (player.paused) player.play();
else player.pause();
return;
}
btn.textContent = '⏳';
try {
const t = await api.getToken();
const params = new URLSearchParams({ relpath: rel, jwt: t });
const r = await fetch(window.location.origin + '/custom/bericht/api/photo.php?' + params.toString());
if (!r.ok) throw new Error('Load failed');
const blob = await r.blob();
const url = URL.createObjectURL(new Blob([blob], { type: mime }));
player = document.createElement('audio');
player.controls = true;
player.src = url;
player.style.width = '100%';
player.style.marginTop = '8px';
item.appendChild(player);
player.play();
btn.textContent = '⏸';
player.onplay = () => btn.textContent = '⏸';
player.onpause = () => btn.textContent = '▶';
} catch (err) {
showToast('Audio laden fehlgeschlagen: ' + err.message, 'error');
btn.textContent = '▶';
}
});
});
loadThumbs();
const camInput = document.getElementById('camera-input');
const galInput = document.getElementById('gallery-input');
document.getElementById('btn-take-photo').onclick = () => camInput.click();
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
async function handleFiles(files) {
for (const f of files) {
await uploadPhoto(args.id, f);
}
// Reload Photo-Liste
try {
const np = await api.listOrderPhotos(args.id);
const imgs = np.photos.filter(p => (p.mime || '').startsWith('image/'));
document.getElementById('photo-grid').innerHTML =
imgs.map(p => `<div class="thumb" data-relpath="${escapeHtml(p.relpath)}"><div class="thumb-placeholder">⏳</div></div>`).join('');
loadThumbs();
} catch (e) {}
}
camInput.addEventListener('change', () => handleFiles(camInput.files));
galInput.addEventListener('change', () => handleFiles(galInput.files));
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
async function uploadPhoto(orderId, file) {
showToast('Optimiere & sende ' + file.name);
const blob = await resizeImage(file, 2000);
if (!navigator.onLine) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Offline — Foto in Queue', 'warn');
return;
}
try {
await api.uploadOrderPhoto(orderId, blob, file.name);
showToast('✓ ' + file.name + ' hochgeladen');
} catch (e) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Upload fehlgeschlagen — in Queue', 'error');
}
}
async function resizeImage(file, maxSide) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
if (scale === 1) { resolve(file); return; }
const c = document.createElement('canvas');
c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.toBlob(b => resolve(b || file), 'image/jpeg', 0.85);
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
img.src = url;
});
}
/**
* Lädt alle sichtbaren Thumbnails im aktuellen Photo-Grid.
* Nutzt /api/photo.php mit JWT (Header kann <img src> nicht schicken,
* deshalb Blob-URLs).
*/
async function loadThumbs() {
const thumbs = document.querySelectorAll('.photo-grid .thumb[data-relpath]');
for (const t of thumbs) {
const rel = t.dataset.relpath;
try {
// Erst Thumbnail versuchen (_small), bei Misserfolg das Original
let url = await api.getPhotoBlobUrl(rel, 'small');
if (!url) url = await api.getPhotoBlobUrl(rel);
if (url) {
t.innerHTML = '<img loading="lazy" src="' + url + '">';
} else {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
} catch (e) {
t.innerHTML = '<div class="thumb-placeholder">❌</div>';
}
}
}
router.on('/reports', async () => {
if (!(await ensureAuth())) return;
title('Berichte');
setNav(true, 'reports');
setBack(false);
showLoader('Lade Berichte…');
try {
const data = await api.listReports();
if (!data.reports.length) {
main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Noch keine Berichte</div>';
return;
}
main().innerHTML = data.reports.map(r => {
const statusLabel = r.status === 1 ? 'Final' : 'Entwurf';
const statusClass = r.status === 1 ? 'status-final' : 'status-draft';
const sourceIcon = r.element_type === 'order' ? '🛒' : (r.element_type === 'invoice' ? '📄' : '📋');
return `
<div class="order-card" data-id="${r.id}">
<div class="ref">${escapeHtml(r.ref)} <span class="${statusClass}">${statusLabel}</span></div>
<div class="name">${escapeHtml(r.titel || '')}</div>
<div class="meta">
<span>${sourceIcon} ${escapeHtml(r.parent_ref || '')}</span>
<span class="badge">${r.page_count} Seite${r.page_count === 1 ? '' : 'n'}</span>
</div>
</div>`;
}).join('');
document.querySelectorAll('.order-card').forEach(c => {
c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id));
});
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
router.on('/reports/:id', async (args) => {
if (!(await ensureAuth())) return;
setNav(true, 'reports');
setBack(true, '#/reports');
showLoader('Lade Bericht…');
try {
const data = await api.getReport(args.id);
title(data.report.ref);
const statusLabel = data.report.status === 1 ? 'Final' : 'Entwurf';
const hasPages = data.pages.length > 0;
const finalizeLabel = data.report.status === 1 ? '🔄 PDF neu erzeugen' : '📑 Bericht finalisieren (PDF)';
main().innerHTML = `
<div class="detail-section">
<h3>Bericht</h3>
<p><strong>${escapeHtml(data.report.titel || data.report.ref)}</strong></p>
<p class="label">Auftrag: ${escapeHtml(data.report.auftragsnummer || '—')}</p>
<p class="label">Format: ${escapeHtml(data.report.page_format || 'A4')} ${data.report.page_orientation === 'L' ? 'Quer' : 'Hoch'}</p>
<p class="label">Seiten: ${data.pages.length}</p>
<p class="label">Status: <strong>${statusLabel}</strong></p>
</div>
${hasPages ? `
<div class="photo-grid" id="report-pages">
${data.pages.map((p, i) => `
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}">
<div class="thumb-placeholder">⏳</div>
<div class="page-num">${i + 1}</div>
</div>
`).join('')}
</div>` : '<div class="empty-state"><div class="icon">📭</div>Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.</div>'}
<button class="btn btn-large" id="btn-finalize" ${hasPages ? '' : 'disabled'}>${finalizeLabel}</button>
${hasPages ? '<button class="btn btn-secondary" id="btn-view-pdf">👁 PDF-Vorschau</button>' : ''}
<button class="btn btn-secondary" id="btn-signature">✍️ Kunden-Unterschrift hinzufügen</button>
<button class="btn btn-secondary" id="btn-open-editor">✏️ Im Desktop-Editor öffnen</button>
`;
loadThumbs();
// Tap auf Report-Page-Thumb → Seiten-Aktionen-Modal
document.querySelectorAll('.report-page-thumb').forEach(t => {
t.addEventListener('click', () => openPageActionsModal(args.id, t.dataset.pageId, t.dataset.relpath, t.dataset.note || ''));
});
// 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; }
openPdfModal(url);
};
// Unterschrift
document.getElementById('btn-signature').onclick = () => openSignatureModal(args.id);
const finalizeBtn = document.getElementById('btn-finalize');
if (finalizeBtn) {
finalizeBtn.onclick = async () => {
if (!hasPages) { showToast('Bericht hat keine Seiten', 'warn'); return; }
if (!confirm(data.report.status === 1
? 'PDF neu erzeugen und unter den verknüpften Dokumenten ablegen?'
: 'Bericht jetzt finalisieren und PDF erzeugen?')) return;
showToast('PDF wird erzeugt…');
try {
const r = await api.finalizeReport(args.id);
showToast('✓ PDF erstellt: ' + r.filename);
setTimeout(() => router.go('#/reports/' + args.id), 800);
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
};
}
document.getElementById('btn-open-editor').onclick = () => {
window.open(window.location.origin + '/custom/bericht/bericht_card.php?berichtid=' + args.id, '_blank');
};
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
}
});
router.on('/settings', async () => {
if (!(await ensureAuth())) return;
title('Einstellungen');
setNav(true, 'settings');
setBack(false);
const user = await idb.get('user') || {};
main().innerHTML = `
<div class="detail-section">
<h3>Konto</h3>
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
<p class="label">${escapeHtml(user.login || '')}</p>
</div>
<button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button>
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</button>
<p class="label" style="text-align:center;margin-top:24px;">Baustelle PWA v1.0</p>
`;
document.getElementById('btn-sync').onclick = async () => {
await offline.syncQueue();
showToast('Sync ausgelöst');
};
document.getElementById('btn-logout').onclick = async () => {
await api.logout();
router.go('#/login');
};
});
/* 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);
});
/* ============================================================
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
* ============================================================ */
async function openPageActionsModal(berichtId, pageId, relpath, note) {
const url = await api.getPhotoBlobUrl(relpath);
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="pa-close">✕</button>
<div class="fs-title">Seite bearbeiten</div>
<button class="icon-btn" id="pa-delete" title="Seite löschen">🗑️</button>
</div>
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;">
${url ? `<img src="${url}" style="max-height:50vh;">` : '<div class="thumb-placeholder">⚠</div>'}
<label class="label" style="align-self:flex-start;">Notiz zur Seite (wird im PDF gedruckt):</label>
<textarea id="pa-note" rows="4" style="width:100%;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;padding:10px;font-size:14px;">${escapeHtml(note)}</textarea>
<button class="btn" id="pa-save-note">💾 Notiz speichern</button>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#pa-close').onclick = () => modal.remove();
modal.querySelector('#pa-save-note').onclick = async () => {
try {
await api.updatePageNote(pageId, modal.querySelector('#pa-note').value);
showToast('✓ Notiz gespeichert');
modal.remove();
router.navigate();
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
};
modal.querySelector('#pa-delete').onclick = async () => {
if (!confirm('Diese Seite aus dem Bericht entfernen?')) return;
try {
await api.deletePage(pageId);
showToast('✓ Seite entfernt');
modal.remove();
router.navigate();
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
};
}
/* ============================================================
* PDF-VORSCHAU MODAL
* ============================================================ */
function openPdfModal(blobUrl) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="pdf-close">✕</button>
<div class="fs-title">📑 PDF-Vorschau</div>
<a class="icon-btn" id="pdf-download" href="${blobUrl}" download="bericht.pdf" title="Download">⬇</a>
</div>
<div class="fs-body" style="padding:0;">
<iframe src="${blobUrl}" style="width:100%;height:100%;border:none;background:#444;"></iframe>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#pdf-close').onclick = () => {
URL.revokeObjectURL(blobUrl);
modal.remove();
};
}
/* ============================================================
* UNTERSCHRIFT MODAL (Touch-Signatur)
* ============================================================ */
function openSignatureModal(berichtId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal signature-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="sig-close">✕</button>
<div class="fs-title">✍️ Kunden-Unterschrift</div>
<button class="icon-btn" id="sig-save" title="Speichern">✓</button>
</div>
<div class="signature-toolbar">
<button id="sig-clear">🗑 Leeren</button>
<span class="sig-hint">Mit dem Finger unterschreiben</span>
</div>
<div class="signature-body">
<canvas id="sig-canvas"></canvas>
</div>
`;
document.body.appendChild(modal);
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 = () => {
window.removeEventListener('resize', fitCanvas);
modal.remove();
};
modal.querySelector('#sig-save').onclick = () => {
showToast('Speichere Unterschrift…');
canvas.toBlob(async (blob) => {
try {
await api.uploadSignature(berichtId, blob);
showToast('✓ Unterschrift hinzugefügt');
modal.remove();
router.navigate();
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
}, 'image/png');
};
}
function openHelpModal() {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal help-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="help-close">✕</button>
<div class="fs-title">❓ Hilfe / Anleitung</div>
<span></span>
</div>
<div class="help-body">
<h2>📋 So funktioniert die Baustelle-App</h2>
<section>
<h3>1. Aufträge finden</h3>
<p>Im Reiter <strong>Aufträge</strong> siehst du alle offenen Aufträge, die dir zugewiesen sind.
Oben kannst du per Suchfeld nach Auftragsnummer oder Kundenname filtern.</p>
<p>Auf einen Auftrag tippen → Detail-Ansicht mit Kunde, Adresse, Telefon.</p>
</section>
<section>
<h3>2. Fotos aufnehmen</h3>
<p>Im Auftrag-Detail:</p>
<ul>
<li><strong>📷 Foto aufnehmen</strong> — öffnet direkt die Kamera</li>
<li><strong>📂 Aus Galerie wählen</strong> — mehrere Bilder auf einmal möglich</li>
</ul>
<p>Die Bilder werden automatisch auf 2000px verkleinert und hochgeladen.
Sie landen in einem <strong>Entwurf-Bericht</strong>, der automatisch zum Auftrag angelegt wird.</p>
<p class="tip">💡 Alle weiteren Fotos werden an denselben Entwurf angehängt —
du hast also einen Bericht mit mehreren Seiten, bis du ihn finalisierst.</p>
</section>
<section>
<h3>3. Fotos bearbeiten (Skizzen)</h3>
<p>Tippe auf ein hochgeladenes Foto im Grid → <strong>Vollbild-Ansicht</strong>.</p>
<ul>
<li><strong>✏️ (oben)</strong> öffnet den Skizzen-Editor</li>
<li><strong>🗑️ (oben)</strong> löscht das Foto</li>
</ul>
<p>Im Skizzen-Editor:</p>
<ul>
<li>✏️ Stift (Freihand)</li>
<li>↗ Pfeil mit Spitze (von Start bis Ende ziehen)</li>
<li>▭ Rechteck</li>
<li>○ Ellipse</li>
<li>Farbe + Linienstärke rechts daneben</li>
<li>↶ Undo / 🗑 Alles zurücksetzen</li>
</ul>
<p>Oben rechts <strong>✓</strong> → Skizze wird als eigenständige neue Bericht-Seite gespeichert.
Das Original-Foto bleibt unverändert.</p>
</section>
<section>
<h3>4. Sprachnotizen</h3>
<p><strong>🎙 Sprachnotiz aufnehmen</strong> im Auftrag-Detail:</p>
<ul>
<li>Aufnahme starten, reden</li>
<li>Stopp → Vorhören</li>
<li>Senden → Audio landet im Auftrags-Anhang</li>
</ul>
<p>Unter der Foto-Liste erscheint eine eigene Sektion „🎙 Sprachnotizen" mit Play-Button je Eintrag.</p>
</section>
<section>
<h3>5. Berichte finalisieren</h3>
<p>Im Reiter <strong>Berichte</strong> siehst du alle deine Berichte mit Status.</p>
<ul>
<li><span class="status-draft">Entwurf</span> — kannst du noch erweitern</li>
<li><span class="status-final">Final</span> — PDF wurde erzeugt, der Bericht ist eingefroren</li>
</ul>
<p>Bericht öffnen → <strong>📑 Bericht finalisieren</strong> → erzeugt das PDF und legt es
unter „Verknüpfte Dokumente" des Auftrags ab. In Dolibarr siehst du den Bericht dann direkt beim Auftrag.</p>
<p class="tip">💡 Wenn du nach dem Finalisieren neue Fotos machst, wird automatisch ein
<strong>neuer</strong> Entwurf angelegt. Der finalisierte bleibt unberührt.</p>
</section>
<section>
<h3>6. Offline arbeiten</h3>
<p>Die App funktioniert auch ohne Internet:</p>
<ul>
<li>Fotos werden in einer lokalen Warteschlange gespeichert</li>
<li>Sobald wieder Empfang da ist, werden sie automatisch hochgeladen</li>
<li>Oben rechts siehst du den Status: <span class="status-badge-help">🟢 online</span>,
<span class="status-badge-help">🟡 N</span> (N Uploads werden synchronisiert),
<span class="status-badge-help">🔴 N</span> (offline, N warten)</li>
</ul>
</section>
<section>
<h3>7. Auf dem Handy installieren</h3>
<p>So wird die PWA zur echten App:</p>
<ul>
<li><strong>Android/Chrome:</strong> Menü → „Zum Startbildschirm hinzufügen"</li>
<li><strong>iPhone/Safari:</strong> Teilen-Symbol → „Zum Home-Bildschirm"</li>
</ul>
<p>Danach startet sie wie eine normale App, ohne Browser-Leiste.</p>
</section>
<section>
<h3>8. Einstellungen</h3>
<p>Im Reiter <strong>⚙️</strong>:</p>
<ul>
<li>🔄 Offline-Queue manuell synchronisieren</li>
<li>🚪 Abmelden</li>
</ul>
</section>
<div class="help-footer">
<p>Baustelle PWA v1.0 · Fragen? → Eddy</p>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#help-close').onclick = () => modal.remove();
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
/* ============================================================
* PHOTO VOLLBILD MODAL + SKIZZEN-EDITOR
* ============================================================ */
async function openPhotoModal(orderId, relpath) {
const url = await api.getPhotoBlobUrl(relpath);
if (!url) { showToast('Foto konnte nicht geladen werden', 'error'); return; }
const modal = document.createElement('div');
modal.className = 'fullscreen-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="fs-close">✕</button>
<div class="fs-title">${escapeHtml(relpath.split('/').pop())}</div>
<button class="icon-btn" id="fs-sketch" title="Zeichnen">✏️</button>
<button class="icon-btn" id="fs-delete" title="Löschen">🗑️</button>
</div>
<div class="fs-body"><img src="${url}" alt=""></div>
`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector('#fs-close').onclick = close;
modal.querySelector('#fs-delete').onclick = async () => {
if (!confirm('Foto wirklich löschen?')) return;
try {
await api.deletePhoto(relpath);
showToast('✓ Gelöscht');
close();
// Parent view neu laden
router.navigate();
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
};
modal.querySelector('#fs-sketch').onclick = () => {
close();
openSketchEditor(orderId, url, relpath);
};
}
async function openVoiceModal(orderId) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal voice-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="v-close">✕</button>
<div class="fs-title">🎙 Sprachnotiz</div>
<span></span>
</div>
<div class="voice-body">
<div class="voice-indicator" id="v-indicator">●</div>
<div class="voice-time" id="v-time">00:00</div>
<button class="btn btn-large" id="v-start">Aufnahme starten</button>
<button class="btn btn-secondary" id="v-stop" style="display:none">Stopp</button>
<button class="btn" id="v-send" style="display:none">Senden</button>
<audio id="v-preview" controls style="display:none;width:100%;margin-top:12px;"></audio>
</div>
`;
document.body.appendChild(modal);
let mediaRecorder = null;
let chunks = [];
let timer = null;
let startTime = 0;
let audioBlob = null;
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 = () => {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
if (timer) clearInterval(timer);
modal.remove();
};
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');
modal.remove();
} catch (e) {
showToast('Upload fehlgeschlagen: ' + e.message, 'error');
sendBtn.disabled = false;
}
};
}
/* ============================================================
* SKIZZEN-EDITOR (Touch-fähig, einfache Vektor-Zeichnung)
* ============================================================ */
async function openSketchEditor(orderId, imageUrl, sourceRelpath) {
const modal = document.createElement('div');
modal.className = 'fullscreen-modal sketch-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="sk-close">✕</button>
<div class="fs-title">✏️ Skizze</div>
<button class="icon-btn" id="sk-save" title="Speichern">✓</button>
</div>
<div class="sketch-toolbar">
<button class="sk-tool active" data-tool="pen">✏️</button>
<button class="sk-tool" data-tool="arrow">↗</button>
<button class="sk-tool" data-tool="rect">▭</button>
<button class="sk-tool" data-tool="circle">○</button>
<span class="sep"></span>
<input type="color" id="sk-color" value="#ff0000">
<input type="range" id="sk-width" min="2" max="20" value="5">
<span class="sep"></span>
<button id="sk-undo">↶</button>
<button id="sk-clear">🗑</button>
</div>
<div class="sketch-body">
<canvas id="sk-canvas"></canvas>
</div>
`;
document.body.appendChild(modal);
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 color = '#ff0000';
let lineWidth = 5;
let drawing = false;
let startX = 0, startY = 0;
const history = [canvas.toDataURL()];
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;
});
});
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();
drawing = true;
const p = getPos(e);
startX = p.x; startY = p.y;
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);
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();
}
});
}
}
function endDraw() {
if (!drawing) return;
drawing = false;
snapshotBeforeShape = null;
pushHistory();
}
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 = () => {
window.removeEventListener('resize', fitCanvasToScreen);
modal.remove();
};
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');
modal.remove();
router.navigate();
} catch (e) {
showToast('Upload fehlgeschlagen: ' + e.message, 'error');
}
}, 'image/jpeg', 0.9);
};
}