@@ -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,
};
})();