Feature: Lieferungen-Liste + Vollbild-Signatur-Modal
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 5s

- Neue Route #/orders/:id/shipments: Lieferungen zum Auftrag laden und anzeigen
- Neue Route #/shipments/🆔 Lieferschein-PDF inline (alle Seiten via PDF.js), Button Unterschreiben
- Vollbild-Landscape-Signatur-Modal: Fullscreen-API + screen.orientation.lock, HiDPI-Canvas
- Canvas transparent (kein fillRect), trimCanvasToInk() schneidet auf bemalte Fläche
- Name des Unterzeichners vorausgefüllt mit Kundenname aus Order
- POST api/shipments.php?action=confirm: Signatur-PNG + signer_name + GPS
- GPS-Abfrage (timeout 3s, graceful bei Verweigerung)
- Signed-PDF direkt nach Bestätigung anzeigen (?variant=signed)
- Fehlermeldung im Modal (kein hinter Fullscreen versteckter Toast)
- lib/api.js: listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment

[deploy]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-05-28 06:48:56 +02:00
parent 2c32093090
commit e9b5b05b16
3 changed files with 530 additions and 0 deletions

123
app.css
View file

@ -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; }
}

375
app.js
View file

@ -540,6 +540,7 @@ router.on('/orders/:id', async (args) => {
<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>
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
<div class="detail-section" style="margin-top:16px;">
<div class="photo-section-head">
@ -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 = `
<div class="detail-section">
<p><strong>${escapeHtml(order.order.ref)}</strong> · ${escapeHtml(order.customer.name)}</p>
</div>
<div class="empty-state">
<div class="icon">📭</div>
Zu diesem Auftrag gibt es noch keine Lieferungen.<br>
<span style="opacity:0.7;font-size:13px;">In Dolibarr erst eine Lieferung anlegen und validieren.</span>
</div>`;
return;
}
const statusLabel = (s) => ({0:'Entwurf', 1:'Validiert', 2:'Geschlossen', 3:'In Bearbeitung', '-1':'Storniert'})[s] || ('Status '+s);
main().innerHTML = `
<div class="detail-section">
<p><strong>${escapeHtml(order.order.ref)}</strong> · ${escapeHtml(order.customer.name)}</p>
</div>
<div class="ship-list">
${list.map(s => `
<div class="ship-card" data-id="${s.id}">
<div class="ship-head">
<span class="ship-ref">🚚 ${escapeHtml(s.ref)}</span>
${s.signed_status === 1
? '<span class="ship-badge signed">✓ unterschrieben</span>'
: '<span class="ship-badge open">unbestätigt</span>'}
</div>
<div class="ship-meta">
${s.date_delivery ? 'Lieferdatum: ' + formatShortDate(s.date_delivery) : (s.date_creation ? 'Erstellt: ' + formatShortDate(s.date_creation) : '')}
· ${escapeHtml(statusLabel(s.status))}
</div>
</div>
`).join('')}
</div>
`;
document.querySelectorAll('.ship-card').forEach(c => {
c.addEventListener('click', () => router.go('#/shipments/' + c.dataset.id));
});
} catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>';
}
});
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 = `
<div class="detail-section">
<p><strong>🚚 ${escapeHtml(data.shipment.ref)}</strong></p>
${data.order ? '<p class="label">Auftrag: '+escapeHtml(data.order.ref)+'</p>' : ''}
<p class="label">Kunde: ${escapeHtml(data.customer.name)}</p>
${isSigned
? '<p class="label" style="color:#5cb85c;font-weight:600;">✓ Bereits unterschrieben</p>'
: '<p class="label" style="color:#f0ad4e;">Noch nicht unterschrieben</p>'}
</div>
<div id="shipment-pdf-wrap" class="pdf-inline-wrap"></div>
<button class="btn btn-large" id="btn-sign-shipment" ${isSigned ? 'disabled' : ''}> Lieferung unterschreiben lassen</button>
`;
// 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 = '<div class="pdf-inline-hint">Lieferschein-Vorschau</div>';
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 = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>';
}
});
/**
* 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 += '<p class="opacitymedium">PDF-Vorschau nicht verfügbar</p>';
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 += '<p class="opacitymedium">PDF-Vorschau-Fehler: '+escapeHtml(e.message || String(e))+'</p>';
}
}
/**
* 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 = `
<div class="ship-sign-grid">
<aside class="ship-sign-side">
<div class="ss-title">Lieferung bestätigen</div>
<div class="ss-meta">
<div><strong>🚚 ${escapeHtml(info.ref || '')}</strong></div>
${info.orderRef ? '<div>Auftrag: '+escapeHtml(info.orderRef)+'</div>' : ''}
<div>${escapeHtml(info.customer || '')}</div>
</div>
<label class="ss-label">Name des Unterzeichners *
<input type="text" id="ss-name" autocomplete="name" placeholder="Vor- und Nachname" value="${escapeHtml(info.customer || '')}">
</label>
<label class="ss-gps">
<input type="checkbox" id="ss-gps" checked> 📍 GPS-Position aufnehmen
</label>
<p class="ss-legal">Mit der Unterschrift bestätige ich den ordnungsgemäßen Erhalt der Lieferung. Server-Zeitstempel und GPS werden mitgespeichert.</p>
<div class="ss-error" id="ss-error" style="display:none;background:#5e1e1e;border:1px solid #c04040;color:#fff;padding:10px 12px;border-radius:6px;font-size:13px;white-space:pre-wrap;word-break:break-word;"></div>
<div class="ss-actions">
<button class="btn btn-secondary" id="ss-clear">🗑 Leeren</button>
<button class="btn btn-secondary" id="ss-cancel">Abbrechen</button>
<button class="btn btn-large" id="ss-save"> Bestätigen</button>
</div>
</aside>
<section class="ship-sign-pad">
<div class="ss-hint">Hier mit dem Finger unterschreiben</div>
<canvas id="ss-canvas"></canvas>
</section>
</div>
`;
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';

View file

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