feat: Kundenkarten-Tab + Unterschriften-Härtung im Signature-Modal
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 0s

Kundenkarten:
- Neuer Bottom-Nav Eintrag '👥 Kunden'
- /customers Route: Liste mit Suche, gefiltert über api/customers.php
- /customers/:id Route: Stammdaten mit Click-to-Call, Click-to-Mail,
  Google-Maps-Route-Button, Listen für Aufträge/Berichte/Rechnungen
  mit Mini-Card-Layout
- Mini-Cards navigieren zum jeweiligen Auftrag/Bericht
- formatDate + formatEur Helper (de-DE Locale)

Unterschriften-Härtung (Signature-Modal):
- Pflicht-Input für Signer-Name
- GPS-Checkbox (default an) — fragt navigator.geolocation ab
- Rechtstext als Hinweis
- Beim Save: Name + GPS werden an api.uploadSignature übergeben
- Server brennt Metadaten, Hash und GPS in die PNG ein

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-09 08:06:22 +02:00
parent d19e4eb41e
commit 503d36bd09
4 changed files with 239 additions and 3 deletions

62
app.css
View file

@ -439,6 +439,38 @@ body {
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
/* Mini-Cards für Kundendetail (Aufträge/Rechnungen/Berichte) */
.mini-card {
background: #2a2a30;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 6px;
cursor: pointer;
transition: background 0.15s;
}
.mini-card:active { background: #35353b; }
.mini-card .mini-ref {
font-weight: 600;
color: #7aa2f7;
font-size: 13px;
}
.mini-card .mini-meta {
font-size: 12px;
opacity: 0.7;
margin-top: 3px;
}
.customer-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.customer-actions a {
text-decoration: none;
text-align: center;
flex: 1;
}
/* Report-Page-Thumb mit Nummer */
.report-page-thumb { position: relative; }
.report-page-thumb .page-num {
@ -453,6 +485,36 @@ body {
}
/* Unterschrift-Modal */
.signature-modal .signature-meta {
padding: 12px 16px;
background: #1a1a1f;
border-bottom: 1px solid #333;
display: flex;
flex-direction: column;
gap: 8px;
}
.signature-modal .signature-meta input[type="text"] {
width: 100%;
padding: 12px 14px;
background: #2a2a30;
color: #fff;
border: 1px solid #444;
border-radius: 6px;
font-size: 15px;
box-sizing: border-box;
}
.signature-modal .sig-gps-label {
color: #e0e0e0;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.signature-modal .sig-legal {
color: #999;
font-size: 11px;
line-height: 1.4;
}
.signature-modal .signature-toolbar {
display: flex;
align-items: center;

162
app.js
View file

@ -307,6 +307,135 @@ async function loadThumbs() {
}
}
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');
@ -538,6 +667,14 @@ function openSignatureModal(berichtId) {
<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>
@ -592,11 +729,32 @@ function openSignatureModal(berichtId) {
window.removeEventListener('resize', fitCanvas);
modal.remove();
};
modal.querySelector('#sig-save').onclick = () => {
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 {
await api.uploadSignature(berichtId, blob);
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();

View file

@ -51,6 +51,7 @@ header('Expires: 0');
<nav id="bottom-nav" style="display:none">
<button data-route="orders" class="active">📋 Aufträge</button>
<button data-route="customers">👥 Kunden</button>
<button data-route="reports">📑 Berichte</button>
<button data-route="settings">⚙️</button>
</nav>

View file

@ -55,6 +55,17 @@
await clearToken();
}
async function listCustomers(opts = {}) {
const params = new URLSearchParams();
if (opts.q) params.set('q', opts.q);
const qs = params.toString();
return request('/customers.php' + (qs ? '?' + qs : ''));
}
async function getCustomer(id) {
return request('/customers.php?id=' + id);
}
async function listOrders(opts = {}) {
const params = new URLSearchParams();
if (opts.q) params.set('q', opts.q);
@ -121,9 +132,12 @@
});
}
async function uploadSignature(berichtId, pngBlob) {
async function uploadSignature(berichtId, pngBlob, opts) {
const fd = new FormData();
fd.append('file', pngBlob, 'signature.png');
if (opts && opts.signer_name) fd.append('signer_name', 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('/pages.php?action=signature&bericht_id=' + berichtId, {
method: 'POST',
body: fd,
@ -182,6 +196,7 @@
getToken, setToken, clearToken,
login, logout,
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
listCustomers, getCustomer,
getReport, listReports, finalizeReport,
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
getPhotoBlobUrl, clearPhotoCache,