diff --git a/app.css b/app.css index 32be8f7..01e0418 100644 --- a/app.css +++ b/app.css @@ -1051,3 +1051,126 @@ body { .photo-viewer-modal .pv-nav { width: 40px; height: 60px; font-size: 24px; } .photo-viewer-modal .pv-zoom-btns button { width: 36px; height: 36px; font-size: 18px; } } + +/* ============================================================ + * Lieferungen-Liste + Vollbild-Signatur-Modal + * ============================================================ */ +.ship-list { display: flex; flex-direction: column; gap: 10px; padding: 12px; } +.ship-card { + background: #25252b; + border: 1px solid #333; + border-radius: 10px; + padding: 14px 16px; + cursor: pointer; + transition: background .15s; +} +.ship-card:active { background: #2e2e36; } +.ship-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 6px; +} +.ship-ref { font-size: 16px; font-weight: 600; } +.ship-badge { font-size: 12px; padding: 3px 9px; border-radius: 12px; } +.ship-badge.signed { background: #214a2e; color: #9ae6a8; } +.ship-badge.open { background: #4a3818; color: #f0c570; } +.ship-meta { font-size: 13px; opacity: 0.75; } + +.pdf-inline-wrap { + background: #fff; + border-radius: 8px; + padding: 8px; + margin: 12px; + text-align: center; +} +.pdf-inline-hint { + color: #555; + font-size: 12px; + margin-bottom: 8px; +} + +/* Vollbild-Querformat-Signatur */ +.ship-sign-modal { + background: #1a1a1f; + z-index: 9999; +} +.ship-sign-grid { + display: grid; + grid-template-columns: minmax(220px, 30%) 1fr; + height: 100vh; + height: 100dvh; +} +.ship-sign-side { + background: #25252b; + border-right: 1px solid #333; + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; +} +.ss-title { font-size: 18px; font-weight: 700; } +.ss-meta { font-size: 14px; line-height: 1.5; background: #1a1a1f; padding: 10px 12px; border-radius: 8px; } +.ss-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} +.ss-label input[type="text"] { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #444; + background: #1a1a1f; + color: #fff; + font-size: 16px; +} +.ss-gps { + display: flex; align-items: center; gap: 8px; + font-size: 13px; + padding: 8px 0; +} +.ss-legal { + font-size: 12px; + line-height: 1.45; + opacity: 0.7; + margin: 0; +} +.ss-actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: auto; +} +.ss-actions .btn-large { + background: #5cb85c; + color: #fff; + border: none; + padding: 14px; + border-radius: 10px; + font-size: 16px; + font-weight: 600; +} +.ship-sign-pad { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #f5f5f5; + padding: 12px; + overflow: hidden; +} +.ss-hint { font-size: 12px; color: #777; margin-bottom: 8px; } +#ss-canvas { + background: #fff; + border: 2px dashed #bbb; + border-radius: 8px; + touch-action: none; + cursor: crosshair; +} +@media (max-width: 700px) and (orientation: portrait) { + .ship-sign-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } + .ship-sign-side { border-right: none; border-bottom: 1px solid #333; } +} diff --git a/app.js b/app.js index a137082..c7cbaa5 100644 --- a/app.js +++ b/app.js @@ -540,6 +540,7 @@ router.on('/orders/:id', async (args) => { +
@@ -699,6 +700,9 @@ router.on('/orders/:id', async (args) => { // Materialliste document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id); + // Lieferungen + document.getElementById('btn-shipments').onclick = () => router.go('#/orders/' + args.id + '/shipments'); + // Transkribieren document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => { btn.addEventListener('click', async (e) => { @@ -1882,6 +1886,377 @@ function openSignatureModal(berichtId) { }; } +/* ============================================================ + * LIEFERUNGEN — Liste + Vollbild-Signatur-Workflow + * ============================================================ */ +router.on('/orders/:id/shipments', async (args) => { + if (!(await ensureAuth())) return; + setNav(true, 'orders'); + setBack(true, '#/orders/' + args.id); + showLoader('Lade Lieferungen…'); + title('Lieferungen'); + + try { + const order = await api.getOrder(args.id); + const data = await api.listShipments(args.id); + const list = data.shipments || []; + + if (!list.length) { + main().innerHTML = ` +
+

${escapeHtml(order.order.ref)} · ${escapeHtml(order.customer.name)}

+
+
+
đź“­
+ Zu diesem Auftrag gibt es noch keine Lieferungen.
+ In Dolibarr erst eine Lieferung anlegen und validieren. +
`; + return; + } + + const statusLabel = (s) => ({0:'Entwurf', 1:'Validiert', 2:'Geschlossen', 3:'In Bearbeitung', '-1':'Storniert'})[s] || ('Status '+s); + main().innerHTML = ` +
+

${escapeHtml(order.order.ref)} · ${escapeHtml(order.customer.name)}

+
+
+ ${list.map(s => ` +
+
+ 🚚 ${escapeHtml(s.ref)} + ${s.signed_status === 1 + ? '✓ unterschrieben' + : 'unbestätigt'} +
+
+ ${s.date_delivery ? 'Lieferdatum: ' + formatShortDate(s.date_delivery) : (s.date_creation ? 'Erstellt: ' + formatShortDate(s.date_creation) : '')} + · ${escapeHtml(statusLabel(s.status))} +
+
+ `).join('')} +
+ `; + document.querySelectorAll('.ship-card').forEach(c => { + c.addEventListener('click', () => router.go('#/shipments/' + c.dataset.id)); + }); + } catch (e) { + main().innerHTML = '
⚠️
' + escapeHtml(e.message) + '
'; + } +}); + +router.on('/shipments/:id', async (args) => { + if (!(await ensureAuth())) return; + setNav(true, 'orders'); + showLoader('Lade Lieferschein…'); + + try { + const data = await api.getShipment(args.id); + title(data.shipment.ref); + setBack(true, data.order ? ('#/orders/' + data.order.id + '/shipments') : '#/orders'); + + const isSigned = data.shipment.signed_status === 1; + main().innerHTML = ` +
+

đźšš ${escapeHtml(data.shipment.ref)}

+ ${data.order ? '

Auftrag: '+escapeHtml(data.order.ref)+'

' : ''} +

Kunde: ${escapeHtml(data.customer.name)}

+ ${isSigned + ? '

âś“ Bereits unterschrieben

' + : '

Noch nicht unterschrieben

'} +
+
+ + `; + + // PDF inline rendern (PDF.js Canvas wie bei Reports) + try { + const res = await api.getShipmentPdfBlobUrl(args.id); + if (res && res.url) { + const wrap = document.getElementById('shipment-pdf-wrap'); + wrap.innerHTML = '
Lieferschein-Vorschau
'; + await renderPdfInline(res.url, wrap, 1); + } + } catch (e) { + console.warn('PDF-Vorschau', e); + } + + document.getElementById('btn-sign-shipment').onclick = () => { + openShipmentSignatureModal(args.id, { + ref: data.shipment.ref, + customer: data.customer.name, + orderRef: data.order ? data.order.ref : '', + }); + }; + } catch (e) { + main().innerHTML = '
⚠️
' + escapeHtml(e.message) + '
'; + } +}); + +/** + * Rendert alle Seiten eines PDFs (per Blob-URL) untereinander in einen Container — statische Vorschau. + */ +async function renderPdfInline(blobUrl, container, _unusedPageNum) { + if (typeof pdfjs === 'undefined') { + container.innerHTML += '

PDF-Vorschau nicht verfĂĽgbar

'; + return; + } + try { + const buf = await fetch(blobUrl).then(r => r.arrayBuffer()); + const doc = await pdfjs.getDocument({ data: buf }).promise; + const targetW = Math.min(window.innerWidth - 24, 700); + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const viewport1 = page.getViewport({ scale: 1 }); + const scale = targetW / viewport1.width; + const viewport = page.getViewport({ scale }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + canvas.style.maxWidth = '100%'; + canvas.style.height = 'auto'; + canvas.style.background = '#fff'; + canvas.style.marginBottom = '8px'; + canvas.style.boxShadow = '0 0 0 1px #888'; + container.appendChild(canvas); + const ctx = canvas.getContext('2d'); + await page.render({ canvasContext: ctx, viewport }).promise; + } + } catch (e) { + console.warn('renderPdfInline', e); + container.innerHTML += '

PDF-Vorschau-Fehler: '+escapeHtml(e.message || String(e))+'

'; + } +} + +/** + * Findet die Bounding-Box aller nicht-weißen Pixel und gibt ein neues Canvas zurück, + * das nur diesen Bereich enthält (plus 2% Padding). Liefert null wenn nichts gezeichnet wurde. + */ +function trimCanvasToInk(canvas) { + const ctx = canvas.getContext('2d'); + const { width: w, height: h } = canvas; + const data = ctx.getImageData(0, 0, w, h).data; + let minX = w, minY = h, maxX = -1, maxY = -1; + // Canvas ist transparent — gezeichnete Pixel haben alpha > 0 + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const a = data[(y * w + x) * 4 + 3]; + if (a > 16) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + if (maxX < 0) return null; // nichts gezeichnet + // Padding 2% der Diagonale (mind. 8 px) + const pad = Math.max(8, Math.floor(Math.hypot(maxX - minX, maxY - minY) * 0.02)); + const sx = Math.max(0, minX - pad); + const sy = Math.max(0, minY - pad); + const sw = Math.min(w, maxX + pad) - sx + 1; + const sh = Math.min(h, maxY + pad) - sy + 1; + const out = document.createElement('canvas'); + out.width = sw; + out.height = sh; + const octx = out.getContext('2d'); + // KEIN fillRect → transparenter Hintergrund bleibt erhalten + octx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh); + return out; +} + +/** + * Vollbild-Querformat-Modal fuer die Lieferschein-Unterschrift. + * Querformat-Lock via Screen Orientation API (Android Chrome). + * Linke Spalte: Lieferschein-Info + Namens-Input + Buttons. + * Rechte Spalte: HiDPI-Canvas zum Unterschreiben. + */ +function openShipmentSignatureModal(shipmentId, info) { + const modal = document.createElement('div'); + modal.className = 'fullscreen-modal ship-sign-modal'; + modal.innerHTML = ` +
+ +
+
Hier mit dem Finger unterschreiben
+ +
+
+ `; + document.body.appendChild(modal); + pushModal(modal, () => { + try { window.removeEventListener('resize', fitCanvas); } catch {} + try { window.removeEventListener('orientationchange', fitCanvas); } catch {} + // Fullscreen + Orientation unlock + try { if (screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); } catch {} + try { if (document.fullscreenElement) document.exitFullscreen(); } catch {} + }); + + // Fullscreen + Landscape (best effort — iOS Safari hat kein Lock) + requestAnimationFrame(() => { + try { document.documentElement.requestFullscreen({ navigationUI: 'hide' }); } catch {} + try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(() => {}); } catch {} + }); + + const canvas = modal.querySelector('#ss-canvas'); + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + + function fitCanvas() { + const pad = modal.querySelector('.ship-sign-pad'); + const rect = pad.getBoundingClientRect(); + const padding = 24; + const targetW = Math.max(400, rect.width - padding); + const targetH = Math.max(200, rect.height - padding - 30); // 30 für Hinweis + canvas.style.width = targetW + 'px'; + canvas.style.height = targetH + 'px'; + // Resize loescht Canvas automatisch (transparent). Kein fillRect → PNG bekommt Alpha-Kanal. + canvas.width = Math.floor(targetW * dpr); + canvas.height = Math.floor(targetH * dpr); + ctx.scale(dpr, dpr); + ctx.strokeStyle = '#000'; + ctx.lineWidth = 2.2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + } + fitCanvas(); + window.addEventListener('resize', fitCanvas); + window.addEventListener('orientationchange', () => setTimeout(fitCanvas, 200)); + + // Drawing — Pointer-Events fuer iOS + Android + let drawing = false; + let lastX = 0, lastY = 0; + function pos(e) { + const r = canvas.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + return { x: t.clientX - r.left, y: t.clientY - r.top }; + } + function start(e) { + e.preventDefault(); + drawing = true; + const p = pos(e); + lastX = p.x; lastY = p.y; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + } + function move(e) { + if (!drawing) return; + e.preventDefault(); + const p = pos(e); + // Quadratic-Glaettung + const midX = (lastX + p.x) / 2; + const midY = (lastY + p.y) / 2; + ctx.quadraticCurveTo(lastX, lastY, midX, midY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(midX, midY); + lastX = p.x; lastY = p.y; + } + function end(e) { + if (drawing && e) e.preventDefault && e.preventDefault(); + drawing = false; + } + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + canvas.addEventListener('mouseup', end); + canvas.addEventListener('mouseleave', end); + canvas.addEventListener('touchstart', start, { passive: false }); + canvas.addEventListener('touchmove', move, { passive: false }); + canvas.addEventListener('touchend', end); + + modal.querySelector('#ss-clear').onclick = fitCanvas; + modal.querySelector('#ss-cancel').onclick = () => closeModal(modal); + const errBox = modal.querySelector('#ss-error'); + const setErr = (msg) => { + if (msg) { + errBox.textContent = msg; + errBox.style.display = ''; + } else { + errBox.style.display = 'none'; + } + }; + modal.querySelector('#ss-save').onclick = async () => { + setErr(null); + const name = modal.querySelector('#ss-name').value.trim(); + if (!name) { + setErr('Bitte Namen des Unterzeichners eingeben'); + modal.querySelector('#ss-name').focus(); + return; + } + let gps = null; + if (modal.querySelector('#ss-gps').checked && 'geolocation' in navigator) { + showToast('Hole GPS-Position…'); + try { + const p = await new Promise((res, rej) => { + navigator.geolocation.getCurrentPosition(res, rej, { timeout: 5000, enableHighAccuracy: true }); + }); + gps = { lat: p.coords.latitude, lon: p.coords.longitude }; + } catch (e) { /* GPS optional */ } + } + setErr('Speichere Unterschrift…'); + // Canvas auf den gezeichneten Bereich trimmen — sonst rendert ODT den vollen + // weißen Bereich + winzige Unterschrift in der Ecke. + const trimmed = trimCanvasToInk(canvas); + if (!trimmed) { + setErr('Bitte erst unterschreiben (Canvas ist leer)'); + return; + } + console.log('[Signature] orig='+canvas.width+'x'+canvas.height+' trimmed='+trimmed.width+'x'+trimmed.height); + setErr('Upload… ('+canvas.width+'×'+canvas.height+' → '+trimmed.width+'×'+trimmed.height+')'); + trimmed.toBlob(async (blob) => { + try { + if (!blob) throw new Error('Canvas konnte kein PNG erzeugen'); + const opts = { signer_name: name }; + if (gps) { opts.gps_lat = gps.lat; opts.gps_lon = gps.lon; } + const fd = new FormData(); + fd.append('file', blob, 'signature.png'); + fd.append('signer_name', name); + if (gps) { fd.append('gps_lat', gps.lat); fd.append('gps_lon', gps.lon); } + + // Direkter Fetch statt api.confirmShipment, damit wir den Response-Body + // auch bei 500 lesen koennen + const token = await api.getToken(); + const url = window.location.origin + '/custom/bericht/api/shipments.php?id=' + shipmentId + '&action=confirm'; + const r = await fetch(url, { + method: 'POST', + body: fd, + headers: { 'Authorization': 'Bearer ' + token }, + }); + const text = await r.text(); + let payload; + try { payload = JSON.parse(text); } catch { payload = { error: text.substring(0, 800) }; } + if (!r.ok) throw new Error('HTTP '+r.status+': '+(payload.error || text.substring(0, 400))); + setErr(null); + showToast('✓ Lieferung bestätigt'); + closeModal(modal); + router.go('#/shipments/' + shipmentId); + } catch (e) { + setErr('Fehler: ' + (e.message || e)); + console.error('[Signature confirm]', e); + } + }, 'image/png'); + }; +} + function openHelpModal() { const modal = document.createElement('div'); modal.className = 'fullscreen-modal help-modal'; diff --git a/lib/api.js b/lib/api.js index 44a08c1..a9f4852 100644 --- a/lib/api.js +++ b/lib/api.js @@ -192,6 +192,37 @@ }); } + /* ----- Lieferungen ----- */ + async function listShipments(orderId) { + return request('/shipments.php?order_id=' + orderId); + } + + async function getShipment(id) { + return request('/shipments.php?id=' + id); + } + + async function getShipmentPdfBlobUrl(shipmentId) { + const t = await getToken(); + if (!t) return null; + const params = new URLSearchParams({ id: shipmentId, action: 'pdf', jwt: t }); + const r = await fetch(API_BASE + '/shipments.php?' + params.toString()); + if (!r.ok) return null; + const blob = await r.blob(); + return { url: URL.createObjectURL(blob), blob }; + } + + async function confirmShipment(shipmentId, pngBlob, opts) { + const fd = new FormData(); + fd.append('file', pngBlob, 'signature.png'); + fd.append('signer_name', (opts && opts.signer_name) || ''); + if (opts && opts.gps_lat != null) fd.append('gps_lat', opts.gps_lat); + if (opts && opts.gps_lon != null) fd.append('gps_lon', opts.gps_lon); + return request('/shipments.php?id=' + shipmentId + '&action=confirm', { + method: 'POST', + body: fd, + }); + } + async function reorderPages(pageIds) { return request('/pages.php?action=reorder', { method: 'POST', @@ -277,5 +308,6 @@ listMaterials, addMaterial, deleteMaterial, getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages, + listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment, }; })();