feat: Kundenkarten-Tab + Unterschriften-Härtung im Signature-Modal
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 0s
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:
parent
d19e4eb41e
commit
503d36bd09
4 changed files with 239 additions and 3 deletions
62
app.css
62
app.css
|
|
@ -439,6 +439,38 @@ body {
|
||||||
|
|
||||||
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
.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 mit Nummer */
|
||||||
.report-page-thumb { position: relative; }
|
.report-page-thumb { position: relative; }
|
||||||
.report-page-thumb .page-num {
|
.report-page-thumb .page-num {
|
||||||
|
|
@ -453,6 +485,36 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unterschrift-Modal */
|
/* 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 {
|
.signature-modal .signature-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
162
app.js
162
app.js
|
|
@ -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 () => {
|
router.on('/reports', async () => {
|
||||||
if (!(await ensureAuth())) return;
|
if (!(await ensureAuth())) return;
|
||||||
title('Berichte');
|
title('Berichte');
|
||||||
|
|
@ -538,6 +667,14 @@ function openSignatureModal(berichtId) {
|
||||||
<div class="fs-title">✍️ Kunden-Unterschrift</div>
|
<div class="fs-title">✍️ Kunden-Unterschrift</div>
|
||||||
<button class="icon-btn" id="sig-save" title="Speichern">✓</button>
|
<button class="icon-btn" id="sig-save" title="Speichern">✓</button>
|
||||||
</div>
|
</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">
|
<div class="signature-toolbar">
|
||||||
<button id="sig-clear">🗑 Leeren</button>
|
<button id="sig-clear">🗑 Leeren</button>
|
||||||
<span class="sig-hint">Mit dem Finger unterschreiben</span>
|
<span class="sig-hint">Mit dem Finger unterschreiben</span>
|
||||||
|
|
@ -592,11 +729,32 @@ function openSignatureModal(berichtId) {
|
||||||
window.removeEventListener('resize', fitCanvas);
|
window.removeEventListener('resize', fitCanvas);
|
||||||
modal.remove();
|
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…');
|
showToast('Speichere Unterschrift…');
|
||||||
canvas.toBlob(async (blob) => {
|
canvas.toBlob(async (blob) => {
|
||||||
try {
|
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');
|
showToast('✓ Unterschrift hinzugefügt');
|
||||||
modal.remove();
|
modal.remove();
|
||||||
router.navigate();
|
router.navigate();
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ header('Expires: 0');
|
||||||
|
|
||||||
<nav id="bottom-nav" style="display:none">
|
<nav id="bottom-nav" style="display:none">
|
||||||
<button data-route="orders" class="active">📋 Aufträge</button>
|
<button data-route="orders" class="active">📋 Aufträge</button>
|
||||||
|
<button data-route="customers">👥 Kunden</button>
|
||||||
<button data-route="reports">📑 Berichte</button>
|
<button data-route="reports">📑 Berichte</button>
|
||||||
<button data-route="settings">⚙️</button>
|
<button data-route="settings">⚙️</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
17
lib/api.js
17
lib/api.js
|
|
@ -55,6 +55,17 @@
|
||||||
await clearToken();
|
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 = {}) {
|
async function listOrders(opts = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (opts.q) params.set('q', opts.q);
|
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();
|
const fd = new FormData();
|
||||||
fd.append('file', pngBlob, 'signature.png');
|
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, {
|
return request('/pages.php?action=signature&bericht_id=' + berichtId, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: fd,
|
body: fd,
|
||||||
|
|
@ -182,6 +196,7 @@
|
||||||
getToken, setToken, clearToken,
|
getToken, setToken, clearToken,
|
||||||
login, logout,
|
login, logout,
|
||||||
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
|
listOrders, getOrder, listOrderPhotos, uploadOrderPhoto,
|
||||||
|
listCustomers, getCustomer,
|
||||||
getReport, listReports, finalizeReport,
|
getReport, listReports, finalizeReport,
|
||||||
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
deletePhoto, uploadVoiceNote, uploadAnnotatedPhoto,
|
||||||
getPhotoBlobUrl, clearPhotoCache,
|
getPhotoBlobUrl, clearPhotoCache,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue