All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 6s
- otherDocs-Sektion mit gestylten doc-items (Icon, Name, Größe, Datum) - getFileBlobUrl() in api.js ohne Mime-Filter - PDF/Bilder: Fullscreen-Modal mit iframe/img + Download-Button - Andere Dateitypen: direkter Download - CSS analog zu .audio-item Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2458 lines
108 KiB
JavaScript
2458 lines
108 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);
|
||
});
|
||
// FAB folgt der Nav-Sichtbarkeit (auf Login verstecken, sonst überall erreichbar)
|
||
const fab = document.getElementById('fab-new-order');
|
||
if (fab) fab.hidden = !visible;
|
||
}
|
||
|
||
function setBack(visible, hash) {
|
||
const btn = document.getElementById('back-btn');
|
||
btn.style.display = visible ? '' : 'none';
|
||
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;
|
||
}
|
||
|
||
/* ============================================================
|
||
* ntfy-Benachrichtigungen (statt klassisches Web-Push)
|
||
* ============================================================ */
|
||
let ntfyEventSource = null;
|
||
function stopNtfySubscription() {
|
||
if (ntfyEventSource) {
|
||
try { ntfyEventSource.close(); } catch (e) {}
|
||
ntfyEventSource = null;
|
||
}
|
||
}
|
||
function startNtfySubscription(server, topic) {
|
||
stopNtfySubscription();
|
||
if (!server || !topic) return;
|
||
try {
|
||
const url = server + '/' + encodeURIComponent(topic) + '/sse';
|
||
ntfyEventSource = new EventSource(url);
|
||
ntfyEventSource.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
if (data.event === 'message' && 'Notification' in window && Notification.permission === 'granted') {
|
||
const n = new Notification(data.title || 'Baustelle', {
|
||
body: data.message || '',
|
||
icon: './icons/icon-192.png',
|
||
badge: './icons/icon-192.png',
|
||
tag: 'baustelle-' + (data.id || Date.now()),
|
||
});
|
||
n.onclick = () => { window.focus(); n.close(); };
|
||
}
|
||
} catch (err) { /* ignore */ }
|
||
};
|
||
ntfyEventSource.onerror = () => {
|
||
// Auto-Reconnect nach 10s
|
||
setTimeout(() => startNtfySubscription(server, topic), 10000);
|
||
};
|
||
} catch (e) { console.warn('ntfy sub failed', e); }
|
||
}
|
||
// Beim App-Start automatisch subscriben, wenn Topic gesetzt
|
||
(async () => {
|
||
try {
|
||
const srv = await idb.get('ntfy_server');
|
||
const topic = await idb.get('ntfy_topic');
|
||
if (srv && topic && 'Notification' in window && Notification.permission === 'granted') {
|
||
startNtfySubscription(srv, topic);
|
||
}
|
||
} catch (e) {}
|
||
})();
|
||
|
||
/* ============================================================
|
||
* PIN-Schutz (optional, Settings → Sicherheit)
|
||
* ============================================================ */
|
||
async function hashPin(pin) {
|
||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||
const saltStr = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
const enc = new TextEncoder();
|
||
const data = enc.encode(saltStr + ':' + pin);
|
||
const buf = await crypto.subtle.digest('SHA-256', data);
|
||
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
return { hash, salt: saltStr };
|
||
}
|
||
async function verifyPin(pin) {
|
||
const salt = await idb.get('pin_salt');
|
||
const stored = await idb.get('pin_hash');
|
||
if (!salt || !stored) return false;
|
||
const enc = new TextEncoder();
|
||
const data = enc.encode(salt + ':' + pin);
|
||
const buf = await crypto.subtle.digest('SHA-256', data);
|
||
const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||
return hash === stored;
|
||
}
|
||
function promptPin(title) {
|
||
return new Promise((resolve) => {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal pin-modal';
|
||
modal.innerHTML = `
|
||
<div class="pin-body">
|
||
<div class="pin-icon">🔐</div>
|
||
<div class="pin-title">${escapeHtml(title || 'PIN eingeben')}</div>
|
||
<div class="pin-display"><span></span><span></span><span></span><span></span></div>
|
||
<div class="pin-keypad"></div>
|
||
<div class="pin-error" id="pin-error"></div>
|
||
</div>
|
||
`;
|
||
const pad = modal.querySelector('.pin-keypad');
|
||
const disp = modal.querySelectorAll('.pin-display span');
|
||
let current = '';
|
||
function render() {
|
||
disp.forEach((el, i) => { el.classList.toggle('filled', i < current.length); });
|
||
}
|
||
function add(d) {
|
||
if (current.length >= 4) return;
|
||
current += d;
|
||
render();
|
||
if (current.length === 4) {
|
||
setTimeout(() => { modal.remove(); 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 = () => { modal.remove(); resolve(null); };
|
||
modal.querySelector('.pin-body').appendChild(cancel);
|
||
document.body.appendChild(modal);
|
||
});
|
||
}
|
||
async function promptNewPin() {
|
||
const p1 = await promptPin('Neue 4-stellige PIN');
|
||
if (!p1 || p1.length !== 4) return null;
|
||
const p2 = await promptPin('PIN wiederholen');
|
||
if (p2 !== p1) {
|
||
alert('PINs stimmen nicht überein');
|
||
return null;
|
||
}
|
||
return p1;
|
||
}
|
||
|
||
/**
|
||
* Startet die App — fragt ggf. PIN ab bevor router läuft.
|
||
*/
|
||
window.appBoot = async function appBoot() {
|
||
// JWT aktiv aus IndexedDB preloaden, bevor eine Route rennt.
|
||
// Verhindert Race-Conditions, in denen ensureAuth() zu früh ein `null` bekommt
|
||
// und nach Login-Screen redirectet, obwohl ein gültiges Token in IDB liegt.
|
||
try {
|
||
const t = await api.getToken();
|
||
console.log('[boot] jwt vorhanden:', !!t);
|
||
} catch (e) {
|
||
console.warn('[boot] Token-Preload fehlgeschlagen', e);
|
||
}
|
||
|
||
// FAB: Click öffnet das Schnell-Auftrag-Modal
|
||
const fab = document.getElementById('fab-new-order');
|
||
if (fab && !fab.dataset.bound) {
|
||
fab.dataset.bound = '1';
|
||
fab.addEventListener('click', () => { openNewOrderModal(); });
|
||
}
|
||
|
||
const pinSet = await idb.get('pin_hash');
|
||
if (!pinSet) return; // kein PIN-Schutz aktiv
|
||
// Lockscreen: vollständiger Overlay bis richtige PIN
|
||
document.getElementById('app').style.visibility = 'hidden';
|
||
while (true) {
|
||
const pin = await promptPin('PIN eingeben');
|
||
if (pin && (await verifyPin(pin))) break;
|
||
// Falsch → kurzes Feedback und nochmal
|
||
showToast('Falsche PIN', 'error');
|
||
}
|
||
document.getElementById('app').style.visibility = '';
|
||
}
|
||
|
||
|
||
/* ====== ROUTES ====== */
|
||
|
||
router.on('/login', async () => {
|
||
title('Anmelden');
|
||
setNav(false);
|
||
setBack(false);
|
||
main().innerHTML = `
|
||
<form class="login-form" id="login-form">
|
||
<h2>🔧 Baustelle</h2>
|
||
<input type="text" name="login" placeholder="Benutzername" autocomplete="username" required>
|
||
<input type="password" name="password" placeholder="Passwort" autocomplete="current-password" required>
|
||
<button type="submit" class="btn btn-large">Anmelden</button>
|
||
</form>
|
||
`;
|
||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
await api.login(fd.get('login'), fd.get('password'));
|
||
showToast('Erfolgreich angemeldet');
|
||
router.go('#/orders');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
});
|
||
});
|
||
|
||
router.on('/today', async () => {
|
||
if (!(await ensureAuth())) return;
|
||
title('Heute');
|
||
setNav(true, 'today');
|
||
setBack(false);
|
||
showLoader('Lade Aufträge…');
|
||
|
||
try {
|
||
// Wir nutzen einfach die offenen Aufträge und filtern clientseitig die von heute
|
||
const data = await api.listOrders({ open: 1 });
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
|
||
const t_unix = today.getTime() / 1000;
|
||
const tm_unix = tomorrow.getTime() / 1000;
|
||
|
||
const todays = data.orders.filter(o => o.date >= t_unix && o.date < tm_unix);
|
||
const open = data.orders.filter(o => o.date < t_unix);
|
||
|
||
// Map-URL mit allen Adressen
|
||
const allAddresses = [...todays, ...open.slice(0, 10)]
|
||
.map(o => [o.customer.name, o.customer.address, o.customer.zip, o.customer.town].filter(Boolean).join(' '))
|
||
.filter(Boolean);
|
||
|
||
let html = '';
|
||
if (todays.length) {
|
||
const waypoints = todays.map(o => encodeURIComponent([o.customer.address, o.customer.zip, o.customer.town].filter(Boolean).join(' '))).join('/');
|
||
html += `
|
||
<div class="detail-section">
|
||
<h3>🚗 Route für heute (${todays.length})</h3>
|
||
<a class="btn" href="https://www.google.com/maps/dir/${waypoints}" target="_blank" style="text-decoration:none;text-align:center;">🗺 Alle in Google Maps öffnen</a>
|
||
</div>
|
||
<h4 style="opacity:0.7;margin:16px 0 8px;">Heute (${todays.length})</h4>
|
||
${renderOrderList(todays)}
|
||
`;
|
||
} else {
|
||
html += '<div class="empty-state"><div class="icon">☀️</div>Keine Aufträge für heute geplant</div>';
|
||
}
|
||
if (open.length) {
|
||
html += `<h4 style="opacity:0.7;margin:16px 0 8px;">Offen (${open.length})</h4>${renderOrderList(open.slice(0, 20))}`;
|
||
}
|
||
|
||
main().innerHTML = html;
|
||
document.querySelectorAll('.order-card').forEach(c => {
|
||
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
|
||
});
|
||
} catch (e) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||
}
|
||
});
|
||
|
||
router.on('/orders', async () => {
|
||
if (!(await ensureAuth())) return;
|
||
title('Aufträge');
|
||
setNav(true, 'orders');
|
||
setBack(false);
|
||
showLoader('Lade Aufträge…');
|
||
|
||
// Filter-Status aus localStorage laden (persistiert)
|
||
let showAllOrders = localStorage.getItem('pwa_show_all_orders') === '1';
|
||
|
||
async function loadOrders(q = '') {
|
||
const opts = q ? { q } : {};
|
||
if (!showAllOrders) opts.open = 1;
|
||
return api.listOrders(opts);
|
||
}
|
||
|
||
function bindOrderCards() {
|
||
document.querySelectorAll('.order-card').forEach(c => {
|
||
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
|
||
});
|
||
}
|
||
|
||
try {
|
||
const data = await loadOrders();
|
||
if (!data.orders.length) {
|
||
main().innerHTML = `
|
||
<div class="search-bar">
|
||
<input type="search" id="order-search" placeholder="🔍 Suchen…">
|
||
<label class="filter-toggle"><input type="checkbox" id="show-all-toggle" ${showAllOrders ? 'checked' : ''}> Auch abgeschlossene</label>
|
||
</div>
|
||
<div class="empty-state"><div class="icon">📭</div>${showAllOrders ? 'Keine Aufträge gefunden' : 'Keine offenen Aufträge'}</div>
|
||
`;
|
||
document.getElementById('show-all-toggle').addEventListener('change', async (e) => {
|
||
showAllOrders = e.target.checked;
|
||
localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0');
|
||
showLoader('Lade Aufträge…');
|
||
router.go('#/orders');
|
||
});
|
||
return;
|
||
}
|
||
const html = `
|
||
<div class="search-bar">
|
||
<input type="search" id="order-search" placeholder="🔍 Suchen…">
|
||
<label class="filter-toggle"><input type="checkbox" id="show-all-toggle" ${showAllOrders ? 'checked' : ''}> Auch abgeschlossene</label>
|
||
</div>
|
||
<div id="order-list">${renderOrderList(data.orders)}</div>
|
||
`;
|
||
main().innerHTML = html;
|
||
bindOrderCards();
|
||
|
||
document.getElementById('order-search').addEventListener('input', async (e) => {
|
||
const q = e.target.value;
|
||
const d = await loadOrders(q);
|
||
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
|
||
bindOrderCards();
|
||
});
|
||
|
||
document.getElementById('show-all-toggle').addEventListener('change', async (e) => {
|
||
showAllOrders = e.target.checked;
|
||
localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0');
|
||
const q = document.getElementById('order-search').value;
|
||
const d = await loadOrders(q);
|
||
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
|
||
bindOrderCards();
|
||
});
|
||
} catch (e) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||
}
|
||
});
|
||
|
||
function renderOrderList(orders) {
|
||
return orders.map(o => `
|
||
<div class="order-card" data-id="${o.id}">
|
||
<div class="ref">${escapeHtml(o.ref)}${o.ref_client ? ` <span class="ref-client">— ${escapeHtml(o.ref_client)}</span>` : ''}</div>
|
||
<div class="name">${escapeHtml(o.customer.name || '')}</div>
|
||
<div class="meta">
|
||
<span>${escapeHtml((o.customer.zip || '') + ' ' + (o.customer.town || ''))}</span>
|
||
${o.bericht_count > 0 ? `<span class="badge">📑 ${o.bericht_count}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
router.on('/orders/:id', async (args) => {
|
||
if (!(await ensureAuth())) return;
|
||
setNav(true, 'orders');
|
||
setBack(true, '#/orders');
|
||
showLoader('Lade Auftrag…');
|
||
|
||
try {
|
||
const data = await api.getOrder(args.id);
|
||
const photos = await api.listOrderPhotos(args.id).catch(() => ({ photos: [] }));
|
||
title(data.order.ref);
|
||
|
||
// Nach MIME-Type aufteilen
|
||
const imagePhotos = photos.photos.filter(p => (p.mime || '').startsWith('image/'));
|
||
const audioFiles = photos.photos.filter(p => (p.mime || '').startsWith('audio/') || /\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
|
||
const otherDocs = photos.photos.filter(p =>
|
||
!(p.mime || '').startsWith('image/') &&
|
||
!(p.mime || '').startsWith('audio/') &&
|
||
!/\.(webm|mp3|ogg|m4a|wav)$/i.test(p.filename));
|
||
|
||
main().innerHTML = `
|
||
<div class="detail-section">
|
||
<h3>Kunde</h3>
|
||
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
|
||
<p>${escapeHtml(data.customer.address || '')}</p>
|
||
<p>${escapeHtml((data.customer.zip || '') + ' ' + (data.customer.town || ''))}</p>
|
||
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
|
||
</div>
|
||
|
||
${data.order.auftragsbeschreibung ? `
|
||
<div class="detail-section">
|
||
<h3>Beschreibung</h3>
|
||
<p>${escapeHtml(data.order.auftragsbeschreibung)}</p>
|
||
</div>` : ''}
|
||
|
||
<button class="btn btn-large" id="btn-take-photo">📷 Foto aufnehmen</button>
|
||
<input type="file" id="camera-input" class="hidden-input" accept="image/*" capture="environment" multiple>
|
||
<button class="btn btn-secondary" id="btn-pick-photo">📂 Aus Galerie wählen</button>
|
||
<input type="file" id="gallery-input" class="hidden-input" accept="image/*" multiple>
|
||
<button class="btn btn-secondary" id="btn-voice">🎙 Sprachnotiz aufnehmen</button>
|
||
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</button>
|
||
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
||
|
||
<div class="detail-section" style="margin-top:16px;">
|
||
<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>
|
||
<button class="audio-transcribe" title="Transkribieren (Whisper)">📝</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
${otherDocs.length ? `
|
||
<div class="detail-section">
|
||
<h3>Weitere Dokumente (${otherDocs.length})</h3>
|
||
<div class="doc-list">
|
||
${otherDocs.map(p => `<div class="doc-item" data-relpath="${escapeHtml(p.relpath)}" data-mime="${escapeHtml(p.mime || '')}" data-filename="${escapeHtml(p.filename)}">
|
||
<span class="doc-icon">${docIconFor(p.filename, p.mime)}</span>
|
||
<div class="doc-meta">
|
||
<div class="doc-name">${escapeHtml(p.filename)}</div>
|
||
<div class="doc-sub">${formatFileSize(p.size)}${p.date ? ' · ' + formatShortDate(p.date) : ''}</div>
|
||
</div>
|
||
<button class="doc-open" title="Öffnen">↗</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
`;
|
||
|
||
// Foto-Thumbnails: Tap = Vollbild-Modal
|
||
document.querySelectorAll('#photo-grid .thumb').forEach(t => {
|
||
t.addEventListener('click', () => openPhotoModal(args.id, t.dataset.relpath));
|
||
});
|
||
|
||
// Weitere Dokumente: Tap = Öffnen (PDF inline, sonst Download)
|
||
document.querySelectorAll('.doc-list .doc-item').forEach(el => {
|
||
el.addEventListener('click', async () => {
|
||
const rel = el.dataset.relpath;
|
||
const filename = el.dataset.filename;
|
||
el.classList.add('loading');
|
||
const res = await api.getFileBlobUrl(rel);
|
||
el.classList.remove('loading');
|
||
if (!res) { showToast('Datei konnte nicht geladen werden', 'error'); return; }
|
||
openFileViewer(res, filename);
|
||
});
|
||
});
|
||
|
||
// Sprachnotiz
|
||
document.getElementById('btn-voice').onclick = () => openVoiceModal(args.id);
|
||
|
||
// Neuen Bericht anlegen
|
||
document.getElementById('btn-new-report').onclick = () => openNewReportModal(args.id);
|
||
|
||
// Materialliste
|
||
document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id);
|
||
|
||
// Transkribieren
|
||
document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
const item = e.target.closest('.audio-item');
|
||
const rel = item.dataset.relpath;
|
||
btn.textContent = '⏳';
|
||
try {
|
||
const r = await api.transcribeAudio(rel);
|
||
const text = r.text || '(leer)';
|
||
const existing = item.querySelector('.audio-transcript');
|
||
if (existing) existing.remove();
|
||
const box = document.createElement('div');
|
||
box.className = 'audio-transcript';
|
||
box.textContent = text;
|
||
item.appendChild(box);
|
||
btn.textContent = '📝';
|
||
showToast('✓ Transkribiert');
|
||
} catch (err) {
|
||
btn.textContent = '📝';
|
||
showToast('Whisper-Fehler: ' + err.message, 'error');
|
||
}
|
||
});
|
||
});
|
||
|
||
// Audio-Files abspielen
|
||
document.querySelectorAll('.audio-item .audio-play').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
const item = e.target.closest('.audio-item');
|
||
const rel = item.dataset.relpath;
|
||
const mime = item.dataset.mime || 'audio/webm';
|
||
// Existierenden Player toggeln
|
||
let player = item.querySelector('audio');
|
||
if (player) {
|
||
if (player.paused) player.play();
|
||
else player.pause();
|
||
return;
|
||
}
|
||
btn.textContent = '⏳';
|
||
try {
|
||
const t = await api.getToken();
|
||
const params = new URLSearchParams({ relpath: rel, jwt: t });
|
||
const r = await fetch(window.location.origin + '/custom/bericht/api/photo.php?' + params.toString());
|
||
if (!r.ok) throw new Error('Load failed');
|
||
const blob = await r.blob();
|
||
const url = URL.createObjectURL(new Blob([blob], { type: mime }));
|
||
player = document.createElement('audio');
|
||
player.controls = true;
|
||
player.src = url;
|
||
player.style.width = '100%';
|
||
player.style.marginTop = '8px';
|
||
item.appendChild(player);
|
||
player.play();
|
||
btn.textContent = '⏸';
|
||
player.onplay = () => btn.textContent = '⏸';
|
||
player.onpause = () => btn.textContent = '▶';
|
||
} catch (err) {
|
||
showToast('Audio laden fehlgeschlagen: ' + err.message, 'error');
|
||
btn.textContent = '▶';
|
||
}
|
||
});
|
||
});
|
||
|
||
loadThumbs();
|
||
|
||
const camInput = document.getElementById('camera-input');
|
||
const galInput = document.getElementById('gallery-input');
|
||
document.getElementById('btn-take-photo').onclick = () => camInput.click();
|
||
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
|
||
|
||
async function handleFiles(files) {
|
||
for (const f of files) {
|
||
await uploadPhoto(args.id, f);
|
||
}
|
||
// 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('/customers', async () => {
|
||
if (!(await ensureAuth())) return;
|
||
title('Kunden');
|
||
setNav(true, 'customers');
|
||
setBack(false);
|
||
showLoader('Lade Kunden…');
|
||
|
||
try {
|
||
const data = await api.listCustomers();
|
||
if (!data.customers.length) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">👥</div>Keine Kunden</div>';
|
||
return;
|
||
}
|
||
main().innerHTML = `
|
||
<div class="search-bar"><input type="search" id="cust-search" placeholder="🔍 Kunde suchen…"></div>
|
||
<div id="cust-list">${renderCustomerList(data.customers)}</div>
|
||
`;
|
||
bindCustomerCards();
|
||
document.getElementById('cust-search').addEventListener('input', async (e) => {
|
||
const d = await api.listCustomers({ q: e.target.value });
|
||
document.getElementById('cust-list').innerHTML = renderCustomerList(d.customers);
|
||
bindCustomerCards();
|
||
});
|
||
} catch (e) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||
}
|
||
});
|
||
|
||
function renderCustomerList(list) {
|
||
return list.map(c => `
|
||
<div class="order-card customer-card" data-id="${c.id}">
|
||
<div class="ref">${escapeHtml(c.name || '')}</div>
|
||
<div class="name">${escapeHtml((c.zip || '') + ' ' + (c.town || '')).trim() || '—'}</div>
|
||
<div class="meta">
|
||
<span>${c.phone ? '📞 ' + escapeHtml(c.phone) : ''}</span>
|
||
${c.bericht_count > 0 ? `<span class="badge">📑 ${c.bericht_count}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function bindCustomerCards() {
|
||
document.querySelectorAll('.customer-card').forEach(c => {
|
||
c.addEventListener('click', () => router.go('#/customers/' + c.dataset.id));
|
||
});
|
||
}
|
||
|
||
router.on('/customers/:id', async (args) => {
|
||
if (!(await ensureAuth())) return;
|
||
setNav(true, 'customers');
|
||
setBack(true, '#/customers');
|
||
showLoader('Lade Kunde…');
|
||
|
||
try {
|
||
const data = await api.getCustomer(args.id);
|
||
title(data.customer.name);
|
||
|
||
const addr = [data.customer.address, (data.customer.zip || '') + ' ' + (data.customer.town || '')]
|
||
.map(s => (s || '').trim()).filter(Boolean).join('\n');
|
||
|
||
main().innerHTML = `
|
||
<div class="detail-section">
|
||
<h3>Stammdaten</h3>
|
||
<p><strong>${escapeHtml(data.customer.name)}</strong></p>
|
||
${data.customer.code ? `<p class="label">Kundennr: ${escapeHtml(data.customer.code)}</p>` : ''}
|
||
${addr ? `<p>${escapeHtml(addr).replace(/\n/g, '<br>')}</p>` : ''}
|
||
${data.customer.phone ? `<p><a href="tel:${escapeHtml(data.customer.phone)}">📞 ${escapeHtml(data.customer.phone)}</a></p>` : ''}
|
||
${data.customer.email ? `<p><a href="mailto:${escapeHtml(data.customer.email)}">✉ ${escapeHtml(data.customer.email)}</a></p>` : ''}
|
||
${data.customer.siret || data.customer.vat ? `<p class="label">USt-ID: ${escapeHtml(data.customer.vat || '')}</p>` : ''}
|
||
<div class="customer-actions">
|
||
${data.customer.address || data.customer.town ? `<a class="btn btn-secondary" href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(data.customer.name + ' ' + (data.customer.address||'') + ' ' + (data.customer.zip||'') + ' ' + (data.customer.town||''))}" target="_blank">🗺 Route</a>` : ''}
|
||
</div>
|
||
</div>
|
||
|
||
${data.orders.length ? `
|
||
<div class="detail-section">
|
||
<h3>Aufträge (${data.orders.length})</h3>
|
||
${data.orders.map(o => `
|
||
<div class="mini-card" data-order-id="${o.id}">
|
||
<div class="mini-ref">${escapeHtml(o.ref)}</div>
|
||
<div class="mini-meta">${formatDate(o.date)} · ${formatEur(o.total)}${o.bericht_count > 0 ? ' · 📑 ' + o.bericht_count : ''}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>` : ''}
|
||
|
||
${data.reports.length ? `
|
||
<div class="detail-section">
|
||
<h3>Berichte (${data.reports.length})</h3>
|
||
${data.reports.map(r => `
|
||
<div class="mini-card" data-report-id="${r.id}">
|
||
<div class="mini-ref">${escapeHtml(r.ref)} <span class="${r.status === 1 ? 'status-final' : 'status-draft'}">${r.status === 1 ? 'Final' : 'Entwurf'}</span></div>
|
||
<div class="mini-meta">${escapeHtml(r.titel || '')} · ${formatDate(r.datec)}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>` : ''}
|
||
|
||
${data.invoices.length ? `
|
||
<div class="detail-section">
|
||
<h3>Rechnungen (${data.invoices.length})</h3>
|
||
${data.invoices.map(i => `
|
||
<div class="mini-card">
|
||
<div class="mini-ref">${escapeHtml(i.ref)}</div>
|
||
<div class="mini-meta">${formatDate(i.date)} · ${formatEur(i.total)}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>` : ''}
|
||
`;
|
||
|
||
document.querySelectorAll('.mini-card[data-order-id]').forEach(el => {
|
||
el.addEventListener('click', () => router.go('#/orders/' + el.dataset.orderId));
|
||
});
|
||
document.querySelectorAll('.mini-card[data-report-id]').forEach(el => {
|
||
el.addEventListener('click', () => router.go('#/reports/' + el.dataset.reportId));
|
||
});
|
||
} catch (e) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||
}
|
||
});
|
||
|
||
function formatDate(unix) {
|
||
if (!unix) return '—';
|
||
const d = new Date(unix * 1000);
|
||
return d.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||
}
|
||
function formatEur(v) {
|
||
if (v == null) return '';
|
||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
|
||
}
|
||
|
||
router.on('/reports', async () => {
|
||
if (!(await ensureAuth())) return;
|
||
title('Berichte');
|
||
setNav(true, 'reports');
|
||
setBack(false);
|
||
showLoader('Lade Berichte…');
|
||
try {
|
||
const data = await api.listReports();
|
||
if (!data.reports.length) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Noch keine Berichte</div>';
|
||
return;
|
||
}
|
||
main().innerHTML = data.reports.map(r => {
|
||
const statusLabel = r.status === 1 ? 'Final' : 'Entwurf';
|
||
const statusClass = r.status === 1 ? 'status-final' : 'status-draft';
|
||
const sourceIcon = r.element_type === 'order' ? '🛒' : (r.element_type === 'invoice' ? '📄' : '📋');
|
||
return `
|
||
<div class="order-card" data-id="${r.id}">
|
||
<div class="ref">${escapeHtml(r.ref)} <span class="${statusClass}">${statusLabel}</span></div>
|
||
<div class="name">${escapeHtml(r.titel || '')}</div>
|
||
<div class="meta">
|
||
<span>${sourceIcon} ${escapeHtml(r.parent_ref || '')}</span>
|
||
<span class="badge">${r.page_count} Seite${r.page_count === 1 ? '' : 'n'}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
document.querySelectorAll('.order-card').forEach(c => {
|
||
c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id));
|
||
});
|
||
} catch (e) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||
}
|
||
});
|
||
|
||
router.on('/reports/:id', async (args) => {
|
||
if (!(await ensureAuth())) return;
|
||
setNav(true, 'reports');
|
||
setBack(true, '#/reports');
|
||
showLoader('Lade Bericht…');
|
||
|
||
try {
|
||
const data = await api.getReport(args.id);
|
||
title(data.report.ref);
|
||
|
||
const statusLabel = data.report.status === 1 ? 'Final' : 'Entwurf';
|
||
const hasPages = data.pages.length > 0;
|
||
const finalizeLabel = data.report.status === 1 ? '🔄 PDF neu erzeugen' : '📑 Bericht finalisieren (PDF)';
|
||
|
||
main().innerHTML = `
|
||
<div class="detail-section">
|
||
<h3>Bericht</h3>
|
||
<p><strong>${escapeHtml(data.report.titel || data.report.ref)}</strong></p>
|
||
<p class="label">Auftrag: ${escapeHtml(data.report.auftragsnummer || '—')}</p>
|
||
<p class="label">Format: ${escapeHtml(data.report.page_format || 'A4')} ${data.report.page_orientation === 'L' ? 'Quer' : 'Hoch'}</p>
|
||
<p class="label">Seiten: ${data.pages.length}</p>
|
||
<p class="label">Status: <strong>${statusLabel}</strong></p>
|
||
</div>
|
||
|
||
${hasPages ? `
|
||
<div class="photo-grid" id="report-pages">
|
||
${data.pages.map((p, i) => `
|
||
<div class="thumb report-page-thumb" data-relpath="${escapeHtml(p.source_path)}" data-page-id="${p.id}" data-note="${escapeHtml(p.note || '')}" data-title="${escapeHtml(p.title || '')}">
|
||
<div class="thumb-placeholder">⏳</div>
|
||
<div class="page-num">${i + 1}</div>
|
||
${p.title ? `<div class="page-title-badge">${escapeHtml(p.title)}</div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>` : '<div class="empty-state"><div class="icon">📭</div>Dieser Bericht hat noch keine Seiten. Fotos aufnehmen, um Seiten hinzuzufügen.</div>'}
|
||
|
||
<button class="btn btn-large" id="btn-finalize" ${hasPages ? '' : 'disabled'}>${finalizeLabel}</button>
|
||
${hasPages ? '<button class="btn btn-secondary" id="btn-view-pdf">👁 PDF-Vorschau</button>' : ''}
|
||
<button class="btn btn-secondary" id="btn-signature">✍️ Kunden-Unterschrift hinzufügen</button>
|
||
<button class="btn btn-secondary" id="btn-open-editor">✏️ Im Desktop-Editor öffnen</button>
|
||
<button class="btn btn-danger" id="btn-delete-report">Bericht löschen</button>
|
||
`;
|
||
|
||
loadThumbs();
|
||
|
||
// Tap = Aktionen, Long-Press = Drag-Sort
|
||
bindReportPageInteractions(args.id);
|
||
|
||
// PDF-Vorschau
|
||
const pdfBtn = document.getElementById('btn-view-pdf');
|
||
if (pdfBtn) pdfBtn.onclick = async () => {
|
||
showToast('PDF wird geladen…');
|
||
const url = await api.getPdfBlobUrl(args.id);
|
||
if (!url) { showToast('PDF konnte nicht geladen werden', 'error'); return; }
|
||
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');
|
||
};
|
||
|
||
document.getElementById('btn-delete-report').onclick = () => {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal';
|
||
modal.innerHTML = `
|
||
<div class="modal-header">
|
||
<h2>Bericht löschen</h2>
|
||
<button class="close-btn" id="dr-cancel">×</button>
|
||
</div>
|
||
<div class="modal-body" style="padding:20px;text-align:center;">
|
||
<p style="font-size:16px;margin:16px 0;">Bericht <strong>${escapeHtml(data.report.ref)}</strong> mit ${data.pages.length} Seite${data.pages.length === 1 ? '' : 'n'} wirklich löschen?</p>
|
||
<p style="opacity:0.6;font-size:13px;">Alle Seiten und Annotationen werden unwiderruflich entfernt.</p>
|
||
<div style="display:flex;gap:12px;justify-content:center;margin-top:24px;">
|
||
<button class="btn btn-secondary" id="dr-no">Abbrechen</button>
|
||
<button class="btn btn-danger" id="dr-yes">Endgültig löschen</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
modal.querySelector('#dr-cancel').onclick = () => modal.remove();
|
||
modal.querySelector('#dr-no').onclick = () => modal.remove();
|
||
modal.querySelector('#dr-yes').onclick = async () => {
|
||
try {
|
||
await api.deleteReport(args.id);
|
||
modal.remove();
|
||
showToast('Bericht gelöscht');
|
||
router.go('#/reports');
|
||
} catch (e) {
|
||
showToast('Fehler: ' + e.message, 'error');
|
||
}
|
||
};
|
||
};
|
||
} catch (e) {
|
||
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||
}
|
||
});
|
||
|
||
router.on('/settings', async () => {
|
||
if (!(await ensureAuth())) return;
|
||
title('Einstellungen');
|
||
setNav(true, 'settings');
|
||
setBack(false);
|
||
|
||
const user = await idb.get('user') || {};
|
||
const pinEnabled = !!(await idb.get('pin_hash'));
|
||
const ntfyTopic = await idb.get('ntfy_topic') || '';
|
||
const ntfyServer = await idb.get('ntfy_server') || 'https://notify.data-it-solution.de';
|
||
|
||
main().innerHTML = `
|
||
<div class="detail-section">
|
||
<h3>Konto</h3>
|
||
<p><strong>${escapeHtml(user.name || user.login || 'Unbekannt')}</strong></p>
|
||
<p class="label">${escapeHtml(user.login || '')}</p>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>🔔 Benachrichtigungen</h3>
|
||
<p class="label">Per ntfy-Topic. Wenn gesetzt, zeigt die App Notifications bei neuen Nachrichten (z. B. neue Aufträge, Team-Pings).</p>
|
||
<input type="text" id="ntfy-server" placeholder="https://notify.data-it-solution.de" value="${escapeHtml(ntfyServer)}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-bottom:6px;">
|
||
<input type="text" id="ntfy-topic" placeholder="z. B. baustelle-eddy" value="${escapeHtml(ntfyTopic)}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;">
|
||
<button class="btn btn-secondary" id="btn-ntfy-save" style="margin-top:6px;">💾 Speichern & Abonnieren</button>
|
||
<button class="btn btn-secondary" id="btn-ntfy-test">🔔 Test-Nachricht senden</button>
|
||
<p class="label" id="ntfy-status"></p>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h3>Sicherheit</h3>
|
||
<p class="label">PIN-Schutz beim App-Start — nützlich falls das Handy verloren geht.</p>
|
||
<label class="toggle-row">
|
||
<input type="checkbox" id="pin-toggle" ${pinEnabled ? 'checked' : ''}>
|
||
<span>PIN beim Öffnen abfragen</span>
|
||
</label>
|
||
${pinEnabled ? '<button class="btn btn-secondary" id="btn-pin-change">🔑 PIN ändern</button>' : ''}
|
||
</div>
|
||
|
||
<button class="btn btn-secondary" id="btn-sync">🔄 Offline-Queue synchronisieren</button>
|
||
<button class="btn btn-secondary" id="btn-logout">🚪 Abmelden</button>
|
||
<p class="label" style="text-align:center;margin-top:24px;">Baustelle PWA v1.0</p>
|
||
`;
|
||
document.getElementById('btn-sync').onclick = async () => {
|
||
await offline.syncQueue();
|
||
showToast('Sync ausgelöst');
|
||
};
|
||
document.getElementById('btn-logout').onclick = async () => {
|
||
await api.logout();
|
||
await idb.del('pin_hash');
|
||
await idb.del('pin_salt');
|
||
router.go('#/login');
|
||
};
|
||
// ntfy-Settings
|
||
document.getElementById('btn-ntfy-save').onclick = async () => {
|
||
const srv = document.getElementById('ntfy-server').value.trim().replace(/\/$/, '');
|
||
const topic = document.getElementById('ntfy-topic').value.trim();
|
||
await idb.set('ntfy_server', srv);
|
||
await idb.set('ntfy_topic', topic);
|
||
if (topic && 'Notification' in window) {
|
||
if (Notification.permission !== 'granted') {
|
||
const perm = await Notification.requestPermission();
|
||
if (perm !== 'granted') {
|
||
document.getElementById('ntfy-status').textContent = 'Benachrichtigungen abgelehnt — keine Notifications';
|
||
return;
|
||
}
|
||
}
|
||
startNtfySubscription(srv, topic);
|
||
document.getElementById('ntfy-status').textContent = '✓ Abonniert: ' + topic;
|
||
showToast('✓ Benachrichtigungen aktiviert');
|
||
} else {
|
||
stopNtfySubscription();
|
||
document.getElementById('ntfy-status').textContent = 'Kein Topic gesetzt';
|
||
}
|
||
};
|
||
document.getElementById('btn-ntfy-test').onclick = async () => {
|
||
const srv = document.getElementById('ntfy-server').value.trim().replace(/\/$/, '');
|
||
const topic = document.getElementById('ntfy-topic').value.trim();
|
||
if (!srv || !topic) { showToast('Server + Topic eingeben', 'error'); return; }
|
||
try {
|
||
await fetch(srv + '/' + topic, {
|
||
method: 'POST',
|
||
headers: { 'Title': 'Baustelle', 'Tags': 'construction_worker' },
|
||
body: 'Test-Nachricht ' + new Date().toLocaleTimeString('de-DE'),
|
||
});
|
||
showToast('✓ Gesendet');
|
||
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
|
||
};
|
||
|
||
document.getElementById('pin-toggle').addEventListener('change', async (e) => {
|
||
if (e.target.checked) {
|
||
// PIN setzen
|
||
const pin = await promptNewPin();
|
||
if (!pin) { e.target.checked = false; return; }
|
||
const { hash, salt } = await hashPin(pin);
|
||
await idb.set('pin_salt', salt);
|
||
await idb.set('pin_hash', hash);
|
||
showToast('✓ PIN gesetzt');
|
||
router.navigate();
|
||
} else {
|
||
if (!confirm('PIN-Schutz wirklich deaktivieren?')) {
|
||
e.target.checked = true;
|
||
return;
|
||
}
|
||
await idb.del('pin_hash');
|
||
await idb.del('pin_salt');
|
||
showToast('PIN-Schutz aus');
|
||
router.navigate();
|
||
}
|
||
});
|
||
const changeBtn = document.getElementById('btn-pin-change');
|
||
if (changeBtn) changeBtn.onclick = async () => {
|
||
const old = await promptPin('Aktuelle PIN eingeben');
|
||
if (!old) return;
|
||
if (!(await verifyPin(old))) { showToast('Falsche PIN', 'error'); return; }
|
||
const neu = await promptNewPin();
|
||
if (!neu) return;
|
||
const { hash, salt } = await hashPin(neu);
|
||
await idb.set('pin_salt', salt);
|
||
await idb.set('pin_hash', hash);
|
||
showToast('✓ PIN geändert');
|
||
};
|
||
});
|
||
|
||
/* Bottom nav + Hilfe-Button */
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.querySelectorAll('#bottom-nav button').forEach(b => {
|
||
b.addEventListener('click', () => router.go('#/' + b.dataset.route));
|
||
});
|
||
const helpBtn = document.getElementById('help-btn');
|
||
if (helpBtn) helpBtn.addEventListener('click', openHelpModal);
|
||
});
|
||
|
||
/**
|
||
* Bindet Tap + Long-Press-Drag an alle .report-page-thumb des Bericht-Details.
|
||
*/
|
||
function bindReportPageInteractions(reportId) {
|
||
const grid = document.getElementById('report-pages');
|
||
if (!grid) return;
|
||
const thumbs = Array.from(grid.querySelectorAll('.report-page-thumb'));
|
||
|
||
let longPressTimer = null;
|
||
let dragging = null;
|
||
let touchStart = null;
|
||
|
||
thumbs.forEach(t => {
|
||
// Click/Tap → Aktion-Modal (nur wenn nicht gedraggt wurde)
|
||
t.addEventListener('click', (e) => {
|
||
if (dragging) { e.preventDefault(); return; }
|
||
openPageActionsModal(reportId, t.dataset.pageId, t.dataset.relpath, t.dataset.note || '', t.dataset.title || '');
|
||
});
|
||
|
||
// Long-Press → Drag-Modus
|
||
const startLongPress = (e) => {
|
||
touchStart = e.touches ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
|
||
longPressTimer = setTimeout(() => {
|
||
dragging = t;
|
||
t.classList.add('dragging');
|
||
showToast('Seite verschieben — ziehen und loslassen');
|
||
if (navigator.vibrate) navigator.vibrate(50);
|
||
}, 500);
|
||
};
|
||
const cancelLongPress = () => {
|
||
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
|
||
};
|
||
|
||
t.addEventListener('touchstart', startLongPress, { passive: true });
|
||
t.addEventListener('touchmove', (e) => {
|
||
if (!dragging && touchStart) {
|
||
const dx = e.touches[0].clientX - touchStart.x;
|
||
const dy = e.touches[0].clientY - touchStart.y;
|
||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) cancelLongPress();
|
||
}
|
||
if (dragging) {
|
||
e.preventDefault();
|
||
// Welcher Thumb liegt unter dem Finger?
|
||
const tx = e.touches[0].clientX;
|
||
const ty = e.touches[0].clientY;
|
||
const below = document.elementFromPoint(tx, ty);
|
||
const target = below ? below.closest('.report-page-thumb') : null;
|
||
if (target && target !== dragging) {
|
||
const rect = target.getBoundingClientRect();
|
||
const middle = rect.left + rect.width / 2;
|
||
if (tx < middle) grid.insertBefore(dragging, target);
|
||
else grid.insertBefore(dragging, target.nextSibling);
|
||
}
|
||
}
|
||
}, { passive: false });
|
||
t.addEventListener('touchend', async () => {
|
||
cancelLongPress();
|
||
if (dragging) {
|
||
dragging.classList.remove('dragging');
|
||
const ids = Array.from(grid.querySelectorAll('.report-page-thumb')).map(x => x.dataset.pageId);
|
||
try {
|
||
await api.reorderPages(ids);
|
||
showToast('✓ Reihenfolge gespeichert');
|
||
// Page-Nummer-Badges neu setzen
|
||
Array.from(grid.querySelectorAll('.report-page-thumb')).forEach((el, i) => {
|
||
const num = el.querySelector('.page-num');
|
||
if (num) num.textContent = (i + 1);
|
||
});
|
||
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
|
||
dragging = null;
|
||
}
|
||
touchStart = null;
|
||
});
|
||
t.addEventListener('touchcancel', () => { cancelLongPress(); dragging = null; touchStart = null; });
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
* MATERIALLISTE MODAL
|
||
* ============================================================ */
|
||
async function openMaterialModal(elementType, elementId) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="mt-close">✕</button>
|
||
<div class="fs-title">📦 Materialliste</div>
|
||
<span></span>
|
||
</div>
|
||
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;align-items:stretch;overflow-y:auto;">
|
||
<div class="detail-section" style="margin:0;">
|
||
<h3>Neues Material</h3>
|
||
<input type="text" id="mt-label" placeholder="Artikel / Bezeichnung" style="width:100%;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-bottom:6px;">
|
||
<div style="display:flex;gap:8px;">
|
||
<input type="number" step="0.01" id="mt-qty" value="1" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
<select id="mt-unit" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
<option>Stk</option><option>m</option><option>m²</option><option>kg</option>
|
||
<option>l</option><option>Set</option><option>Pa</option><option>h</option>
|
||
</select>
|
||
</div>
|
||
<input type="text" id="mt-note" placeholder="Notiz (optional)" style="width:100%;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;margin-top:6px;">
|
||
<button class="btn" id="mt-add" style="margin-top:8px;">+ Hinzufügen</button>
|
||
</div>
|
||
<div class="detail-section" style="margin:0;">
|
||
<h3>Erfasst</h3>
|
||
<div id="mt-list"><div class="loader">Lade…</div></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.querySelector('#mt-close').onclick = () => modal.remove();
|
||
|
||
async function reload() {
|
||
try {
|
||
const d = await api.listMaterials(elementType, elementId);
|
||
const listEl = modal.querySelector('#mt-list');
|
||
if (!d.materials.length) {
|
||
listEl.innerHTML = '<div class="opacitymedium">Noch keine Einträge</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = d.materials.map(m => `
|
||
<div class="mini-card" data-id="${m.id}" style="display:flex;align-items:center;gap:8px;">
|
||
<div style="flex:1;">
|
||
<div class="mini-ref">${escapeHtml(m.label)}</div>
|
||
<div class="mini-meta">${m.qty} ${escapeHtml(m.unit)}${m.note ? ' · ' + escapeHtml(m.note) : ''}</div>
|
||
</div>
|
||
<button class="mt-del" title="Löschen" style="background:rgba(217,83,79,0.2);color:#d9534f;border:none;border-radius:6px;padding:8px 12px;cursor:pointer;">🗑️</button>
|
||
</div>
|
||
`).join('');
|
||
listEl.querySelectorAll('.mt-del').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
const card = e.target.closest('.mini-card');
|
||
const id = card.dataset.id;
|
||
if (!confirm('Eintrag löschen?')) return;
|
||
await api.deleteMaterial(id);
|
||
reload();
|
||
});
|
||
});
|
||
} catch (e) {
|
||
modal.querySelector('#mt-list').innerHTML = '<div class="opacitymedium">Fehler: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
reload();
|
||
|
||
modal.querySelector('#mt-add').onclick = async () => {
|
||
const label = modal.querySelector('#mt-label').value.trim();
|
||
if (!label) { showToast('Bezeichnung eingeben', 'error'); return; }
|
||
const qty = parseFloat(modal.querySelector('#mt-qty').value) || 1;
|
||
const unit = modal.querySelector('#mt-unit').value;
|
||
const note = modal.querySelector('#mt-note').value.trim();
|
||
try {
|
||
await api.addMaterial(elementType, elementId, { label, qty, unit, note });
|
||
modal.querySelector('#mt-label').value = '';
|
||
modal.querySelector('#mt-qty').value = '1';
|
||
modal.querySelector('#mt-note').value = '';
|
||
showToast('✓ Hinzugefügt');
|
||
reload();
|
||
} catch (e) { showToast('Fehler: ' + e.message, 'error'); }
|
||
};
|
||
}
|
||
|
||
/* ============================================================
|
||
* NEW REPORT MODAL (Schnell-Bericht mit Meta + Vorlage + ODT)
|
||
* ============================================================ */
|
||
async function openNewReportModal(orderId) {
|
||
// Templates parallel laden
|
||
let tpls = [], odts = [];
|
||
try { tpls = (await api.listTemplates()).templates || []; } catch (e) {}
|
||
try { odts = (await api.listOdtTemplates()).templates || []; } catch (e) {}
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="nr-close">✕</button>
|
||
<div class="fs-title">📑 Neuer Bericht</div>
|
||
<button class="icon-btn" id="nr-save" title="Anlegen">✓</button>
|
||
</div>
|
||
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;align-items:stretch;">
|
||
<label class="label">Titel</label>
|
||
<input type="text" id="nr-titel" placeholder="z. B. Wallbox-Installation" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
|
||
${tpls.length ? `
|
||
<label class="label">Aus Vorlage erstellen (optional)</label>
|
||
<select id="nr-template" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
<option value="0">— Leerer Bericht —</option>
|
||
${tpls.map(t => `<option value="${t.id}">📋 ${escapeHtml(t.label)} (${t.page_count} Seiten)</option>`).join('')}
|
||
</select>` : ''}
|
||
|
||
<label class="label">Format</label>
|
||
<div style="display:flex;gap:8px;">
|
||
<select id="nr-format" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
<option value="A4">A4</option>
|
||
<option value="A3">A3</option>
|
||
<option value="A5">A5</option>
|
||
<option value="Letter">Letter</option>
|
||
</select>
|
||
<select id="nr-orient" style="flex:1;padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
<option value="P">Hochformat</option>
|
||
<option value="L">Querformat</option>
|
||
</select>
|
||
</div>
|
||
|
||
${odts.length ? `
|
||
<label class="label">Deckblatt-Vorlage (ODT)</label>
|
||
<select id="nr-odt" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
<option value="">— Kein Deckblatt —</option>
|
||
${odts.map(o => `<option value="${escapeHtml(o.filename)}">${escapeHtml(o.label)}</option>`).join('')}
|
||
</select>` : ''}
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
// 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 = () => modal.remove();
|
||
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');
|
||
modal.remove();
|
||
router.go('#/reports/' + res.bericht_id);
|
||
} catch (e) {
|
||
showToast('Fehler: ' + e.message, 'error');
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ============================================================
|
||
* NEUER AUFTRAG MODAL (Schnell-Erfassung vom FAB)
|
||
* ============================================================ */
|
||
async function openNewOrderModal() {
|
||
if (!(await ensureAuth())) return;
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="no-close" aria-label="Schliessen">✕</button>
|
||
<div class="fs-title">➕ Neuer Auftrag</div>
|
||
<button class="icon-btn" id="no-save" title="Anlegen & Fotos machen" disabled>✓</button>
|
||
</div>
|
||
<div class="fs-body" style="flex-direction:column;gap:10px;padding:16px;align-items:stretch;">
|
||
<div id="no-step-customer">
|
||
<label class="label">Kunde wählen</label>
|
||
<input type="search" id="no-customer-search" class="new-order-customer-search" placeholder="🔍 Name, Ort…" autocomplete="off">
|
||
<div id="no-customer-list" class="new-order-customer-list"></div>
|
||
</div>
|
||
|
||
<div id="no-step-details" hidden>
|
||
<div class="new-order-customer-row">
|
||
<div style="flex:1">
|
||
<div class="nc-name" id="no-soc-name"></div>
|
||
<div class="nc-meta" id="no-soc-meta"></div>
|
||
</div>
|
||
<button class="nc-change" id="no-change-customer">Ändern</button>
|
||
</div>
|
||
<div class="new-order-defaults" id="no-defaults"></div>
|
||
|
||
<label class="label">Titel / Kurzbeschreibung</label>
|
||
<input type="text" id="no-title" placeholder="z. B. Wallbox-Installation" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
|
||
<label class="label">Kunden-Referenz (optional)</label>
|
||
<input type="text" id="no-refclient" placeholder="z. B. Angebot Nr. 123" style="padding:12px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;">
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const closeBtn = modal.querySelector('#no-close');
|
||
const saveBtn = modal.querySelector('#no-save');
|
||
const searchIn = modal.querySelector('#no-customer-search');
|
||
const listEl = modal.querySelector('#no-customer-list');
|
||
const stepCust = modal.querySelector('#no-step-customer');
|
||
const stepDet = modal.querySelector('#no-step-details');
|
||
const socName = modal.querySelector('#no-soc-name');
|
||
const socMeta = modal.querySelector('#no-soc-meta');
|
||
const defaultsEl = modal.querySelector('#no-defaults');
|
||
const titleIn = modal.querySelector('#no-title');
|
||
const refIn = modal.querySelector('#no-refclient');
|
||
const changeBtn = modal.querySelector('#no-change-customer');
|
||
|
||
let selectedCustomer = null;
|
||
|
||
closeBtn.onclick = () => modal.remove();
|
||
|
||
/* ---- Letzte Kunden als Quick-Pick ---- */
|
||
async function renderRecent() {
|
||
const recent = (await idb.get('recent_customers')) || [];
|
||
if (!recent.length) {
|
||
listEl.innerHTML = '<div class="new-order-empty">Tippe zum Suchen…</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = '<div style="padding:8px 12px;color:#888;font-size:12px;">Zuletzt verwendet</div>' +
|
||
recent.map(c => `
|
||
<div class="nc-item recent" data-id="${c.id}">
|
||
<div>${escapeHtml(c.name)}</div>
|
||
<div class="nc-sub">${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
await renderRecent();
|
||
|
||
/* ---- Suche mit Debounce ---- */
|
||
let searchTimer = null;
|
||
searchIn.addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
const q = searchIn.value.trim();
|
||
if (!q) { renderRecent(); return; }
|
||
searchTimer = setTimeout(async () => {
|
||
try {
|
||
const data = await api.listCustomers({ q });
|
||
const items = (data.customers || []).slice(0, 30);
|
||
if (!items.length) {
|
||
listEl.innerHTML = '<div class="new-order-empty">Keine Treffer</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = items.map(c => `
|
||
<div class="nc-item" data-id="${c.id}">
|
||
<div>${escapeHtml(c.name)}</div>
|
||
<div class="nc-sub">${escapeHtml((c.zip || '') + ' ' + (c.town || ''))}</div>
|
||
</div>
|
||
`).join('');
|
||
} catch (e) {
|
||
listEl.innerHTML = '<div class="new-order-empty">Fehler: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
/* ---- Kunden-Klick: Details laden, Wechsel zum zweiten Schritt ---- */
|
||
listEl.addEventListener('click', async (e) => {
|
||
const item = e.target.closest('.nc-item');
|
||
if (!item) return;
|
||
const id = parseInt(item.dataset.id, 10);
|
||
if (!id) return;
|
||
|
||
showToast('Lade Kundendaten…');
|
||
try {
|
||
const res = await api.getCustomer(id);
|
||
const c = res.customer || res;
|
||
selectedCustomer = c;
|
||
|
||
socName.textContent = c.name || '';
|
||
socMeta.textContent = [c.address, ((c.zip || '') + ' ' + (c.town || '')).trim()].filter(Boolean).join(' · ');
|
||
|
||
const defs = [];
|
||
if (c.cond_reglement_label) defs.push('💳 ' + c.cond_reglement_label);
|
||
if (c.mode_reglement_label) defs.push('🏦 ' + c.mode_reglement_label);
|
||
if (!defs.length) defs.push('Keine speziellen Defaults hinterlegt');
|
||
defaultsEl.innerHTML = 'Übernommen: ' + escapeHtml(defs.join(' · '));
|
||
|
||
stepCust.hidden = true;
|
||
stepDet.hidden = false;
|
||
saveBtn.disabled = false;
|
||
titleIn.focus();
|
||
} catch (err) {
|
||
showToast('Fehler: ' + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
changeBtn.onclick = () => {
|
||
selectedCustomer = null;
|
||
stepDet.hidden = true;
|
||
stepCust.hidden = false;
|
||
saveBtn.disabled = true;
|
||
searchIn.focus();
|
||
};
|
||
|
||
/* ---- Anlegen ---- */
|
||
saveBtn.onclick = async () => {
|
||
if (!selectedCustomer) { showToast('Bitte zuerst Kunde wählen', 'error'); return; }
|
||
const title = titleIn.value.trim();
|
||
if (!title) { showToast('Titel ist Pflicht', 'error'); titleIn.focus(); return; }
|
||
|
||
saveBtn.disabled = true;
|
||
showToast('Lege Auftrag an…');
|
||
try {
|
||
const res = await api.createOrder({
|
||
socid: selectedCustomer.id,
|
||
title: title,
|
||
ref_client: refIn.value.trim(),
|
||
});
|
||
// Recent-Liste in IDB aktualisieren (letzte 5)
|
||
try {
|
||
const recent = (await idb.get('recent_customers')) || [];
|
||
const filtered = recent.filter(c => c.id !== selectedCustomer.id);
|
||
filtered.unshift({
|
||
id: selectedCustomer.id,
|
||
name: selectedCustomer.name,
|
||
zip: selectedCustomer.zip,
|
||
town: selectedCustomer.town,
|
||
});
|
||
await idb.set('recent_customers', filtered.slice(0, 5));
|
||
} catch (e) {}
|
||
|
||
showToast('✓ Auftrag ' + (res.order.ref || '') + ' angelegt');
|
||
modal.remove();
|
||
router.go('#/orders/' + res.order.id);
|
||
} catch (e) {
|
||
showToast('Fehler: ' + e.message, 'error');
|
||
saveBtn.disabled = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
/* ============================================================
|
||
* SEITEN-AKTIONEN MODAL (Notiz, Löschen, Vollbild)
|
||
* ============================================================ */
|
||
async function openPageActionsModal(berichtId, pageId, relpath, note, title) {
|
||
const url = await api.getPhotoBlobUrl(relpath);
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="pa-close">✕</button>
|
||
<div class="fs-title">Seite bearbeiten</div>
|
||
<button class="icon-btn" id="pa-delete" title="Seite löschen">🗑️</button>
|
||
</div>
|
||
<div class="fs-body" style="flex-direction:column;gap:12px;padding:16px;">
|
||
${url ? `<img src="${url}" style="max-height:40vh;">` : '<div class="thumb-placeholder">⚠</div>'}
|
||
<label class="label" style="align-self:flex-start;">Titel / Zwischentitel (groß oben auf der Seite):</label>
|
||
<input type="text" id="pa-title" placeholder="z. B. 'Vor Beginn der Arbeiten'" value="${escapeHtml(title || '')}" style="width:100%;padding:10px;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;box-sizing:border-box;">
|
||
<label class="label" style="align-self:flex-start;">Notiz zur Seite (unten auf der Seite):</label>
|
||
<textarea id="pa-note" rows="3" style="width:100%;background:#2a2a30;color:#fff;border:1px solid #444;border-radius:6px;padding:10px;font-size:14px;box-sizing:border-box;">${escapeHtml(note || '')}</textarea>
|
||
<button class="btn" id="pa-save">💾 Speichern</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.querySelector('#pa-close').onclick = () => modal.remove();
|
||
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');
|
||
modal.remove();
|
||
router.navigate();
|
||
} catch (e) {
|
||
// Fallback falls api.request nicht exposed ist
|
||
try {
|
||
await api.updatePageNote(pageId, modal.querySelector('#pa-note').value);
|
||
showToast('✓ Notiz gespeichert');
|
||
modal.remove();
|
||
router.navigate();
|
||
} catch (er) { showToast('Fehler: ' + er.message, 'error'); }
|
||
}
|
||
};
|
||
modal.querySelector('#pa-delete').onclick = async () => {
|
||
if (!confirm('Diese Seite aus dem Bericht entfernen?')) return;
|
||
try {
|
||
await api.deletePage(pageId);
|
||
showToast('✓ Seite entfernt');
|
||
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-meta">
|
||
<input type="text" id="sig-name" placeholder="Name des Unterzeichners (Pflicht)" autocomplete="name">
|
||
<label class="sig-gps-label">
|
||
<input type="checkbox" id="sig-gps" checked>
|
||
📍 GPS-Position aufnehmen
|
||
</label>
|
||
<div class="sig-legal">Mit der Unterschrift bestätige ich die ordnungsgemäße Ausführung der dokumentierten Arbeiten. Server-Zeitstempel, GPS-Koordinaten und ein SHA-256-Integritäts-Hash werden zusammen mit der Unterschrift gespeichert.</div>
|
||
</div>
|
||
<div class="signature-toolbar">
|
||
<button id="sig-clear">🗑 Leeren</button>
|
||
<span class="sig-hint">Mit dem Finger unterschreiben</span>
|
||
</div>
|
||
<div class="signature-body">
|
||
<canvas id="sig-canvas"></canvas>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
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 = 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');
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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) {
|
||
const isPdf = (mime || '').includes('pdf') || /\.pdf$/i.test(filename);
|
||
const isImage = (mime || '').startsWith('image/');
|
||
|
||
if (!isPdf && !isImage) {
|
||
// Nicht inline anzeigbar → Download anstoßen
|
||
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);
|
||
return;
|
||
}
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal doc-viewer-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="dv-close">✕</button>
|
||
<div class="fs-title">${escapeHtml(filename || '')}</div>
|
||
<a class="icon-btn" id="dv-download" title="Herunterladen" download="${escapeHtml(filename || 'download')}" href="${url}">⬇</a>
|
||
</div>
|
||
<div class="dv-body">
|
||
${isPdf
|
||
? `<iframe src="${url}" style="width:100%;height:100%;border:0;background:#fff;"></iframe>`
|
||
: `<img src="${url}" style="max-width:100%;max-height:100%;object-fit:contain;">`}
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
const cleanup = () => {
|
||
URL.revokeObjectURL(url);
|
||
modal.remove();
|
||
};
|
||
modal.querySelector('#dv-close').onclick = cleanup;
|
||
}
|
||
|
||
/* ============================================================
|
||
* PHOTO VOLLBILD MODAL MIT ZOOM + SWIPE + SKIZZEN-EDITOR
|
||
* ============================================================ */
|
||
async function openPhotoModal(orderId, relpath) {
|
||
// Alle Bilder im Grid sammeln für Navigation
|
||
const allThumbs = Array.from(document.querySelectorAll('#photo-grid .thumb[data-relpath]'));
|
||
const allRelpaths = allThumbs.map(t => t.dataset.relpath);
|
||
let currentIndex = allRelpaths.indexOf(relpath);
|
||
if (currentIndex < 0) currentIndex = 0;
|
||
|
||
// Zoom/Pan State
|
||
let zoom = 1, panX = 0, panY = 0;
|
||
let currentUrl = null;
|
||
const touchState = { startX: 0, startY: 0, startDist: 0, startZoom: 1, startPanX: 0, startPanY: 0, isDragging: false, isPinching: false, lastTap: 0 };
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal photo-viewer-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="fs-close">✕</button>
|
||
<div class="fs-title" id="pv-title"></div>
|
||
<div class="pv-counter" id="pv-counter"></div>
|
||
<button class="icon-btn" id="fs-sketch" title="Zeichnen">✏️</button>
|
||
<button class="icon-btn" id="fs-delete" title="Löschen">🗑️</button>
|
||
</div>
|
||
<div class="pv-body" id="pv-body">
|
||
<img id="pv-image" src="" draggable="false">
|
||
</div>
|
||
<button class="pv-nav pv-prev" id="pv-prev">‹</button>
|
||
<button class="pv-nav pv-next" id="pv-next">›</button>
|
||
<div class="pv-zoom-btns">
|
||
<button id="pv-zout">−</button>
|
||
<button id="pv-zreset">⟳</button>
|
||
<button id="pv-zin">+</button>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
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() { modal.remove(); document.removeEventListener('keydown', keyHandler); }
|
||
function goPrev() { if (currentIndex > 0) loadImage(currentIndex - 1); }
|
||
function goNext() { if (currentIndex < allRelpaths.length - 1) loadImage(currentIndex + 1); }
|
||
|
||
// Events
|
||
modal.querySelector('#fs-close').onclick = close;
|
||
prevBtn.onclick = (e) => { e.stopPropagation(); goPrev(); };
|
||
nextBtn.onclick = (e) => { e.stopPropagation(); goNext(); };
|
||
modal.querySelector('#pv-zin').onclick = () => setZoom(zoom + 0.5);
|
||
modal.querySelector('#pv-zout').onclick = () => setZoom(zoom - 0.5);
|
||
modal.querySelector('#pv-zreset').onclick = resetZoom;
|
||
|
||
modal.querySelector('#fs-delete').onclick = async () => {
|
||
if (!confirm('Foto wirklich löschen?')) return;
|
||
try {
|
||
await api.deletePhoto(allRelpaths[currentIndex]);
|
||
showToast('✓ Gelöscht');
|
||
close();
|
||
router.navigate();
|
||
} catch (e) { showToast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
|
||
};
|
||
modal.querySelector('#fs-sketch').onclick = () => {
|
||
close();
|
||
openSketchEditor(orderId, currentUrl, allRelpaths[currentIndex]);
|
||
};
|
||
|
||
// Doppelklick = Zoom Toggle
|
||
img.addEventListener('dblclick', (e) => {
|
||
e.preventDefault();
|
||
if (zoom > 1) resetZoom(); else setZoom(2.5);
|
||
});
|
||
|
||
// Touch: Pinch-to-Zoom + Swipe + Pan
|
||
body.addEventListener('touchstart', (e) => {
|
||
if (e.touches.length === 1) {
|
||
const t = e.touches[0];
|
||
touchState.startX = t.clientX;
|
||
touchState.startY = t.clientY;
|
||
touchState.startPanX = panX;
|
||
touchState.startPanY = panY;
|
||
touchState.isDragging = true;
|
||
touchState.isPinching = false;
|
||
const now = Date.now();
|
||
if (now - touchState.lastTap < 300) {
|
||
if (zoom > 1) resetZoom(); else setZoom(2.5);
|
||
touchState.lastTap = 0;
|
||
} else {
|
||
touchState.lastTap = now;
|
||
}
|
||
} else if (e.touches.length === 2) {
|
||
e.preventDefault();
|
||
touchState.isPinching = true;
|
||
touchState.isDragging = false;
|
||
touchState.startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
||
touchState.startZoom = zoom;
|
||
}
|
||
}, { passive: false });
|
||
|
||
body.addEventListener('touchmove', (e) => {
|
||
if (touchState.isPinching && e.touches.length === 2) {
|
||
e.preventDefault();
|
||
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
|
||
setZoom(touchState.startZoom * (dist / touchState.startDist));
|
||
} else if (touchState.isDragging && e.touches.length === 1) {
|
||
const t = e.touches[0];
|
||
const dx = t.clientX - touchState.startX;
|
||
const dy = t.clientY - touchState.startY;
|
||
if (zoom > 1) {
|
||
e.preventDefault();
|
||
panX = touchState.startPanX + dx;
|
||
panY = touchState.startPanY + dy;
|
||
constrainPan();
|
||
applyTransform();
|
||
}
|
||
}
|
||
}, { passive: false });
|
||
|
||
body.addEventListener('touchend', (e) => {
|
||
if (touchState.isDragging && zoom <= 1 && e.changedTouches.length > 0) {
|
||
const t = e.changedTouches[0];
|
||
const dx = t.clientX - touchState.startX;
|
||
if (Math.abs(dx) > 50) {
|
||
if (dx > 0) goPrev(); else goNext();
|
||
}
|
||
}
|
||
touchState.isDragging = false;
|
||
touchState.isPinching = false;
|
||
});
|
||
|
||
// Tastatur
|
||
const keyHandler = (e) => {
|
||
if (e.key === 'Escape') close();
|
||
if (e.key === 'ArrowLeft') goPrev();
|
||
if (e.key === 'ArrowRight') goNext();
|
||
};
|
||
document.addEventListener('keydown', keyHandler);
|
||
|
||
// Erstes Bild laden
|
||
await loadImage(currentIndex);
|
||
}
|
||
|
||
async function openVoiceModal(orderId) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fullscreen-modal voice-modal';
|
||
modal.innerHTML = `
|
||
<div class="fs-header">
|
||
<button class="icon-btn" id="v-close">✕</button>
|
||
<div class="fs-title">🎙 Sprachnotiz</div>
|
||
<span></span>
|
||
</div>
|
||
<div class="voice-body">
|
||
<div class="voice-indicator" id="v-indicator">●</div>
|
||
<div class="voice-time" id="v-time">00:00</div>
|
||
<button class="btn btn-large" id="v-start">Aufnahme starten</button>
|
||
<button class="btn btn-secondary" id="v-stop" style="display:none">Stopp</button>
|
||
<button class="btn" id="v-send" style="display:none">Senden</button>
|
||
<audio id="v-preview" controls style="display:none;width:100%;margin-top:12px;"></audio>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
let mediaRecorder = null;
|
||
let chunks = [];
|
||
let timer = null;
|
||
let startTime = 0;
|
||
let audioBlob = null;
|
||
|
||
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" title="Stift">✏️</button>
|
||
<button class="sk-tool" data-tool="arrow" title="Pfeil">↗</button>
|
||
<button class="sk-tool" data-tool="rect" title="Rechteck">▭</button>
|
||
<button class="sk-tool" data-tool="circle" title="Kreis">○</button>
|
||
<span class="sep"></span>
|
||
<button class="sk-tool" data-tool="stamp" data-stamp="⚠" title="Achtung">⚠</button>
|
||
<button class="sk-tool" data-tool="stamp" data-stamp="✓" title="OK">✓</button>
|
||
<button class="sk-tool" data-tool="stamp" data-stamp="✗" title="Mangel">✗</button>
|
||
<button class="sk-tool" data-tool="stamp" data-stamp="🔧" title="Reparatur">🔧</button>
|
||
<span class="sep"></span>
|
||
<button class="sk-tool" data-tool="calibrate" title="Mess-Skala kalibrieren (2 Punkte + Länge)">📏⚙</button>
|
||
<button class="sk-tool" data-tool="measure" title="Messen (nach Kalibrierung)">📏</button>
|
||
<span class="sep"></span>
|
||
<input type="color" id="sk-color" value="#ff0000">
|
||
<input type="range" id="sk-width" min="2" max="20" value="5">
|
||
<span class="sep"></span>
|
||
<button id="sk-undo" title="Rückgängig">↶</button>
|
||
<button id="sk-clear" title="Alles löschen">🗑</button>
|
||
</div>
|
||
<div class="sketch-body">
|
||
<canvas id="sk-canvas"></canvas>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const canvas = modal.querySelector('#sk-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// Bild laden
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.src = imageUrl;
|
||
await new Promise(res => { img.onload = res; });
|
||
|
||
// Canvas auf Bildgröße (max 1600px)
|
||
const maxSide = 1600;
|
||
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
|
||
canvas.width = img.width * scale;
|
||
canvas.height = img.height * scale;
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
|
||
// Canvas-Display: in den Container einpassen
|
||
function fitCanvasToScreen() {
|
||
const body = modal.querySelector('.sketch-body');
|
||
const rect = body.getBoundingClientRect();
|
||
const s = Math.min(rect.width / canvas.width, rect.height / canvas.height);
|
||
canvas.style.width = (canvas.width * s) + 'px';
|
||
canvas.style.height = (canvas.height * s) + 'px';
|
||
}
|
||
fitCanvasToScreen();
|
||
window.addEventListener('resize', fitCanvasToScreen);
|
||
|
||
// State
|
||
let tool = 'pen';
|
||
let stampChar = null;
|
||
let color = '#ff0000';
|
||
let lineWidth = 5;
|
||
let drawing = false;
|
||
let startX = 0, startY = 0;
|
||
const history = [canvas.toDataURL()];
|
||
// Mess-Skala: pixelsPerUnit + unit (z. B. 32.5 px/cm)
|
||
let calibration = null; // { pxPerUnit: number, unit: 'cm'|'m'|'mm' }
|
||
let calibStep = 0; // 0 = nicht im Prozess, 1 = warte auf zweiten Punkt
|
||
|
||
function pushHistory() {
|
||
history.push(canvas.toDataURL());
|
||
if (history.length > 20) history.shift();
|
||
}
|
||
async function restoreSnapshot(dataUrl) {
|
||
return new Promise(res => {
|
||
const i = new Image();
|
||
i.onload = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(i, 0, 0); res(); };
|
||
i.src = dataUrl;
|
||
});
|
||
}
|
||
|
||
modal.querySelectorAll('.sk-tool').forEach(b => {
|
||
b.addEventListener('click', () => {
|
||
modal.querySelectorAll('.sk-tool').forEach(x => x.classList.remove('active'));
|
||
b.classList.add('active');
|
||
tool = b.dataset.tool;
|
||
stampChar = b.dataset.stamp || null;
|
||
});
|
||
});
|
||
modal.querySelector('#sk-color').oninput = e => { color = e.target.value; };
|
||
modal.querySelector('#sk-width').oninput = e => { lineWidth = parseInt(e.target.value, 10); };
|
||
|
||
modal.querySelector('#sk-undo').onclick = async () => {
|
||
if (history.length > 1) {
|
||
history.pop();
|
||
await restoreSnapshot(history[history.length - 1]);
|
||
}
|
||
};
|
||
modal.querySelector('#sk-clear').onclick = async () => {
|
||
await restoreSnapshot(history[0]);
|
||
history.length = 1;
|
||
};
|
||
|
||
function getPos(e) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
const t = e.touches ? e.touches[0] : e;
|
||
return { x: (t.clientX - rect.left) * scaleX, y: (t.clientY - rect.top) * scaleY };
|
||
}
|
||
|
||
let snapshotBeforeShape = null;
|
||
|
||
function startDraw(e) {
|
||
e.preventDefault();
|
||
const p = getPos(e);
|
||
startX = p.x; startY = p.y;
|
||
|
||
// Stamps sind ein Single-Click-Tool (kein Drag)
|
||
if (tool === 'stamp' && stampChar) {
|
||
ctx.fillStyle = color;
|
||
ctx.strokeStyle = '#fff';
|
||
ctx.lineWidth = 4;
|
||
ctx.font = 'bold 96px -apple-system, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.strokeText(stampChar, p.x, p.y);
|
||
ctx.fillText(stampChar, p.x, p.y);
|
||
pushHistory();
|
||
return;
|
||
}
|
||
|
||
// Calibrate: Drag-Line zwischen zwei Punkten, dann Länge eingeben
|
||
if (tool === 'calibrate' || tool === 'measure') {
|
||
drawing = true;
|
||
snapshotBeforeShape = canvas.toDataURL();
|
||
ctx.strokeStyle = (tool === 'calibrate') ? '#f0ad4e' : '#5cb85c';
|
||
ctx.fillStyle = ctx.strokeStyle;
|
||
ctx.lineWidth = Math.max(3, lineWidth);
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
return;
|
||
}
|
||
|
||
drawing = true;
|
||
ctx.strokeStyle = color;
|
||
ctx.fillStyle = color;
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
if (tool === 'pen') {
|
||
ctx.beginPath();
|
||
ctx.moveTo(startX, startY);
|
||
} else {
|
||
snapshotBeforeShape = canvas.toDataURL();
|
||
}
|
||
}
|
||
|
||
function moveDraw(e) {
|
||
if (!drawing) return;
|
||
e.preventDefault();
|
||
const p = getPos(e);
|
||
lastMoveX = p.x; lastMoveY = p.y;
|
||
|
||
if (tool === 'calibrate' || tool === 'measure') {
|
||
restoreSnapshot(snapshotBeforeShape).then(() => {
|
||
const col = (tool === 'calibrate') ? '#f0ad4e' : '#5cb85c';
|
||
ctx.strokeStyle = col;
|
||
ctx.fillStyle = col;
|
||
ctx.lineWidth = Math.max(3, lineWidth);
|
||
ctx.lineCap = 'round';
|
||
// Mess-Linie
|
||
ctx.beginPath();
|
||
ctx.moveTo(startX, startY);
|
||
ctx.lineTo(p.x, p.y);
|
||
ctx.stroke();
|
||
// End-Punkte als kleine Kreuze
|
||
drawCross(ctx, startX, startY, 10);
|
||
drawCross(ctx, p.x, p.y, 10);
|
||
// Live-Distanz bei measure
|
||
if (tool === 'measure' && calibration) {
|
||
const dx = p.x - startX, dy = p.y - startY;
|
||
const dist_px = Math.sqrt(dx * dx + dy * dy);
|
||
const real = (dist_px / calibration.pxPerUnit);
|
||
const mid_x = (startX + p.x) / 2;
|
||
const mid_y = (startY + p.y) / 2 - 15;
|
||
drawDistanceLabel(ctx, mid_x, mid_y, real, calibration.unit);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (tool === 'pen') {
|
||
ctx.lineTo(p.x, p.y);
|
||
ctx.stroke();
|
||
} else if (snapshotBeforeShape) {
|
||
// Shape-Tools: vor jedem Draw den Snapshot wiederherstellen und neu zeichnen
|
||
restoreSnapshot(snapshotBeforeShape).then(() => {
|
||
ctx.strokeStyle = color;
|
||
ctx.fillStyle = color;
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
if (tool === 'rect') {
|
||
ctx.strokeRect(startX, startY, p.x - startX, p.y - startY);
|
||
} else if (tool === 'circle') {
|
||
const rx = Math.abs(p.x - startX) / 2;
|
||
const ry = Math.abs(p.y - startY) / 2;
|
||
const cx = (startX + p.x) / 2;
|
||
const cy = (startY + p.y) / 2;
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI);
|
||
ctx.stroke();
|
||
} else if (tool === 'arrow') {
|
||
ctx.beginPath();
|
||
ctx.moveTo(startX, startY);
|
||
ctx.lineTo(p.x, p.y);
|
||
ctx.stroke();
|
||
// Pfeilspitze
|
||
const angle = Math.atan2(p.y - startY, p.x - startX);
|
||
const head = Math.max(12, lineWidth * 3);
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x, p.y);
|
||
ctx.lineTo(p.x - head * Math.cos(angle - Math.PI / 6), p.y - head * Math.sin(angle - Math.PI / 6));
|
||
ctx.lineTo(p.x - head * Math.cos(angle + Math.PI / 6), p.y - head * Math.sin(angle + Math.PI / 6));
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function endDraw(e) {
|
||
if (!drawing) return;
|
||
drawing = false;
|
||
const tool_now = tool;
|
||
|
||
if (tool_now === 'calibrate') {
|
||
// Länge vom User abfragen
|
||
const p = e && (e.changedTouches ? { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY } : { x: e.clientX, y: e.clientY });
|
||
// fallback: aktuelle endposition haben wir nicht mehr exakt — wir nutzen die letzte gezeichnete position
|
||
const dx_px = lastMoveX - startX, dy_px = lastMoveY - startY;
|
||
const dist_px = Math.sqrt(dx_px * dx_px + dy_px * dy_px);
|
||
if (dist_px < 5) { // zu kurz
|
||
await restoreSnapshot(snapshotBeforeShape);
|
||
snapshotBeforeShape = null;
|
||
showToast('Zu kurz — 2 Punkte mit Abstand wählen', 'error');
|
||
return;
|
||
}
|
||
const input = prompt('Reale Länge dieser Strecke:\nFormat: Zahl + Einheit (z. B. "50 cm", "1.5 m", "250 mm")');
|
||
if (!input) {
|
||
await restoreSnapshot(snapshotBeforeShape);
|
||
snapshotBeforeShape = null;
|
||
return;
|
||
}
|
||
const m = input.match(/^\s*([0-9]+(?:[.,][0-9]+)?)\s*(mm|cm|m)?\s*$/i);
|
||
if (!m) {
|
||
await restoreSnapshot(snapshotBeforeShape);
|
||
snapshotBeforeShape = null;
|
||
alert('Ungültiges Format. Beispiel: 1.5 m');
|
||
return;
|
||
}
|
||
const val = parseFloat(m[1].replace(',', '.'));
|
||
const unit = (m[2] || 'cm').toLowerCase();
|
||
// Umrechnung auf mm intern? Nein: pxPerUnit in der eingegebenen Einheit halten
|
||
calibration = { pxPerUnit: dist_px / val, unit };
|
||
showToast('✓ Kalibriert: ' + val + ' ' + unit + ' = ' + dist_px.toFixed(0) + ' px');
|
||
// Kalibrierungslinie entfernen (zeichnen war nur Helper)
|
||
await restoreSnapshot(snapshotBeforeShape);
|
||
snapshotBeforeShape = null;
|
||
// Automatisch auf Measure-Tool wechseln
|
||
const mt = document.querySelector('.sk-tool[data-tool="measure"]');
|
||
if (mt) mt.click();
|
||
return;
|
||
}
|
||
|
||
if (tool_now === 'measure') {
|
||
if (!calibration) {
|
||
await restoreSnapshot(snapshotBeforeShape);
|
||
snapshotBeforeShape = null;
|
||
alert('Zuerst kalibrieren! Tap auf 📏⚙, zwei Punkte mit bekannter Länge wählen.');
|
||
return;
|
||
}
|
||
// Die letzte Preview-Zeichnung ist jetzt schon auf dem Canvas — das ist unser finales Ergebnis
|
||
snapshotBeforeShape = null;
|
||
pushHistory();
|
||
return;
|
||
}
|
||
|
||
snapshotBeforeShape = null;
|
||
pushHistory();
|
||
}
|
||
|
||
// Track der letzten Move-Position für calibrate-endDraw
|
||
let lastMoveX = 0, lastMoveY = 0;
|
||
|
||
function drawCross(ctx, x, y, size) {
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - size / 2, y);
|
||
ctx.lineTo(x + size / 2, y);
|
||
ctx.moveTo(x, y - size / 2);
|
||
ctx.lineTo(x, y + size / 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawDistanceLabel(ctx, x, y, value, unit) {
|
||
const text = value.toFixed(2) + ' ' + unit;
|
||
ctx.save();
|
||
ctx.font = 'bold 28px -apple-system, sans-serif';
|
||
const w = ctx.measureText(text).width + 16;
|
||
const h = 36;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.75)';
|
||
ctx.fillRect(x - w / 2, y - h / 2, w, h);
|
||
ctx.fillStyle = '#5cb85c';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, x, y);
|
||
ctx.restore();
|
||
}
|
||
|
||
canvas.addEventListener('mousedown', startDraw);
|
||
canvas.addEventListener('mousemove', moveDraw);
|
||
canvas.addEventListener('mouseup', endDraw);
|
||
canvas.addEventListener('mouseleave', endDraw);
|
||
canvas.addEventListener('touchstart', startDraw, { passive: false });
|
||
canvas.addEventListener('touchmove', moveDraw, { passive: false });
|
||
canvas.addEventListener('touchend', endDraw);
|
||
|
||
modal.querySelector('#sk-close').onclick = () => {
|
||
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);
|
||
};
|
||
}
|