Feature: Lieferungen-Liste + Vollbild-Signatur-Modal
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 5s
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:
parent
2c32093090
commit
e9b5b05b16
3 changed files with 530 additions and 0 deletions
123
app.css
123
app.css
|
|
@ -1051,3 +1051,126 @@ body {
|
||||||
.photo-viewer-modal .pv-nav { width: 40px; height: 60px; font-size: 24px; }
|
.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; }
|
.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
375
app.js
|
|
@ -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-voice">🎙 Sprachnotiz aufnehmen</button>
|
||||||
<button class="btn btn-secondary" id="btn-material">📦 Materialliste</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-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="detail-section" style="margin-top:16px;">
|
||||||
<div class="photo-section-head">
|
<div class="photo-section-head">
|
||||||
|
|
@ -699,6 +700,9 @@ router.on('/orders/:id', async (args) => {
|
||||||
// Materialliste
|
// Materialliste
|
||||||
document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id);
|
document.getElementById('btn-material').onclick = () => openMaterialModal('order', args.id);
|
||||||
|
|
||||||
|
// Lieferungen
|
||||||
|
document.getElementById('btn-shipments').onclick = () => router.go('#/orders/' + args.id + '/shipments');
|
||||||
|
|
||||||
// Transkribieren
|
// Transkribieren
|
||||||
document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => {
|
document.querySelectorAll('.audio-item .audio-transcribe').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
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() {
|
function openHelpModal() {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
modal.className = 'fullscreen-modal help-modal';
|
modal.className = 'fullscreen-modal help-modal';
|
||||||
|
|
|
||||||
32
lib/api.js
32
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) {
|
async function reorderPages(pageIds) {
|
||||||
return request('/pages.php?action=reorder', {
|
return request('/pages.php?action=reorder', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -277,5 +308,6 @@
|
||||||
listMaterials, addMaterial, deleteMaterial,
|
listMaterials, addMaterial, deleteMaterial,
|
||||||
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
|
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl,
|
||||||
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
|
||||||
|
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue