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; }
|
||||
|
||||
/* 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
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 () => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
17
lib/api.js
17
lib/api.js
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue