feat: Unterschriften-Härtung + Kundenkarten-API
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
All checks were successful
Deploy bericht / deploy (push) Successful in 2s
Unterschrift (api/pages.php?action=signature): - Metadaten einbrennen: Überschrift, Bericht-Ref, Parent-Ref, Kunde, Datum/Zeit (Server), Unterzeichner-Name, Bestätigungstext, GPS - SHA256 Hash-Verkettung über alle vorherigen Seiten + Bericht-Ref → nachträgliches Austauschen von Seiten fällt auf - Composite-Canvas: Header mit Metadaten, Unterschrift mittig, Footer mit Hash + Server + Zeitstempel - Metadaten-JSON neben der PNG (für spätere Verifikation) - signer_name ist Pflicht, gps_lat/gps_lon optional - Page-Note zeigt Unterzeichner + Zeit statt nur 'Unterschrift Kunde' Kundenkarten-API (api/customers.php): - GET ohne id = Liste mit Suche (q-Param), zeigt Stammdaten + Bericht-Count - GET mit id = Detail: Stammdaten, letzten 50 Aufträge, 50 Rechnungen, 100 Berichte über UNION aus commande/facture JOIN - Nur echte Kunden (client IN (1,2,3)), keine reinen Lieferanten Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> [deploy]
This commit is contained in:
parent
44a86fa63d
commit
5db126210c
3 changed files with 295 additions and 3 deletions
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -86,6 +86,27 @@ Speichert: color, stroke, fontFamily, fontSize, bold, italic, zoom
|
|||
|
||||
---
|
||||
|
||||
## Phase 4 Block 1 ✅ (2026-04-09)
|
||||
|
||||
PWA-Usability-Features:
|
||||
- 4.g Seite löschen in PWA (ajax /api/pages.php DELETE)
|
||||
- 4.h Notiz pro Seite (POST /api/pages.php {note})
|
||||
- 4.j PDF-Vorschau-Modal nach Finalize (api/pdf.php liefert Blob mit JWT)
|
||||
- 4.b Touch-Unterschrift (openSignatureModal → api/pages.php?action=signature)
|
||||
- 4.e Web Share Target API (manifest + share.html + SW-Interception in IDB)
|
||||
|
||||
Neue API-Endpoints:
|
||||
- api/pages.php — DELETE/POST (note, rotation) + ?action=signature&bericht_id=
|
||||
- api/pdf.php — liefert Final-PDF oder on-the-fly Preview, JWT via Query-Param
|
||||
|
||||
PWA neue Modals (alle in app.js):
|
||||
- openPageActionsModal — tap auf Report-Page-Thumb, Notiz + Löschen
|
||||
- openPdfModal — iframe mit Blob-URL + Download-Link
|
||||
- openSignatureModal — Touch-Canvas 2:1, Clear, Save als PNG
|
||||
- openHelpModal — komplette Anleitung (bereits in v4 gebaut)
|
||||
|
||||
Service Worker v5 (Share Target), manifest.webmanifest mit share_target.
|
||||
|
||||
## Phase 2 — Mobile-Vorbereitung + API-Layer (geplant)
|
||||
- 2.1 Mobile-Upload-Token Tabelle + Cleanup-Cron
|
||||
- 2.2 QR-Upload Lite Modal im Editor
|
||||
|
|
|
|||
147
api/customers.php
Normal file
147
api/customers.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
/* GET /api/customers.php — Liste der Kunden mit letzten Berichten
|
||||
* GET /api/customers.php?id=<soc_id> — Kunden-Detail: Stammdaten + Aufträge + Berichte
|
||||
* GET /api/customers.php?q=<search> — Filter per Name
|
||||
*/
|
||||
require_once __DIR__.'/_inc.php';
|
||||
|
||||
api_authenticate();
|
||||
global $db, $user, $conf, $langs;
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
$q = (string) ($_GET['q'] ?? '');
|
||||
|
||||
/* ----- LISTE ----- */
|
||||
if (!$id) {
|
||||
$where = "s.entity IN (".getEntity('societe').")";
|
||||
if ($q !== '') {
|
||||
$q_esc = $db->escape($q);
|
||||
$where .= " AND (s.nom LIKE '%$q_esc%' OR s.code_client LIKE '%$q_esc%' OR s.town LIKE '%$q_esc%')";
|
||||
}
|
||||
// Standardmäßig nur Kunden (client=1 oder 3) — keine reinen Lieferanten
|
||||
$where .= " AND (s.client = 1 OR s.client = 2 OR s.client = 3)";
|
||||
|
||||
$sql = "SELECT s.rowid, s.nom, s.code_client, s.zip, s.town, s.phone, s.email,"
|
||||
." (SELECT COUNT(*) FROM ".$db->prefix()."bericht b"
|
||||
." INNER JOIN ".$db->prefix()."commande c ON c.rowid = b.fk_element AND b.element_type='order' AND c.fk_soc = s.rowid) AS b_order,"
|
||||
." (SELECT COUNT(*) FROM ".$db->prefix()."bericht b"
|
||||
." INNER JOIN ".$db->prefix()."facture f ON f.rowid = b.fk_element AND b.element_type='invoice' AND f.fk_soc = s.rowid) AS b_invoice,"
|
||||
." (SELECT MAX(c2.date_commande) FROM ".$db->prefix()."commande c2 WHERE c2.fk_soc = s.rowid) AS last_order_date"
|
||||
." FROM ".$db->prefix()."societe s"
|
||||
." WHERE ".$where
|
||||
." ORDER BY last_order_date DESC, s.nom ASC"
|
||||
." LIMIT 200";
|
||||
$r = $db->query($sql);
|
||||
if (!$r) api_fail('DB-Fehler: '.$db->lasterror(), 500);
|
||||
$out = array();
|
||||
while ($o = $db->fetch_object($r)) {
|
||||
$bericht_count = (int) $o->b_order + (int) $o->b_invoice;
|
||||
$out[] = array(
|
||||
'id' => (int) $o->rowid,
|
||||
'name' => $o->nom,
|
||||
'code' => $o->code_client,
|
||||
'zip' => $o->zip,
|
||||
'town' => $o->town,
|
||||
'phone' => $o->phone,
|
||||
'email' => $o->email,
|
||||
'bericht_count' => $bericht_count,
|
||||
'last_order_date' => $o->last_order_date ? $db->jdate($o->last_order_date) : null,
|
||||
);
|
||||
}
|
||||
api_ok(array('customers' => $out, 'count' => count($out)));
|
||||
}
|
||||
|
||||
/* ----- DETAIL eines Kunden ----- */
|
||||
$soc = new Societe($db);
|
||||
if ($soc->fetch($id) <= 0) api_fail('Kunde nicht gefunden', 404);
|
||||
|
||||
// Aufträge des Kunden
|
||||
$orders = array();
|
||||
$ro = $db->query("SELECT c.rowid, c.ref, c.date_commande, c.fk_statut, c.total_ttc"
|
||||
." FROM ".$db->prefix()."commande c"
|
||||
." WHERE c.fk_soc = ".((int) $id)
|
||||
." AND c.entity IN (".getEntity('commande').")"
|
||||
." ORDER BY c.date_commande DESC, c.rowid DESC LIMIT 50");
|
||||
if ($ro) {
|
||||
while ($o = $db->fetch_object($ro)) {
|
||||
// Bericht-Count pro Auftrag
|
||||
$rb = $db->query("SELECT COUNT(*) AS n FROM ".$db->prefix()."bericht WHERE element_type='order' AND fk_element = ".((int) $o->rowid));
|
||||
$bc = ($rb && ($rbo = $db->fetch_object($rb))) ? (int) $rbo->n : 0;
|
||||
$orders[] = array(
|
||||
'id' => (int) $o->rowid,
|
||||
'ref' => $o->ref,
|
||||
'date' => $db->jdate($o->date_commande),
|
||||
'status' => (int) $o->fk_statut,
|
||||
'total' => (float) $o->total_ttc,
|
||||
'bericht_count' => $bc,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rechnungen des Kunden
|
||||
$invoices = array();
|
||||
$ri = $db->query("SELECT f.rowid, f.ref, f.datef, f.fk_statut, f.total_ttc"
|
||||
." FROM ".$db->prefix()."facture f"
|
||||
." WHERE f.fk_soc = ".((int) $id)
|
||||
." AND f.entity IN (".getEntity('facture').")"
|
||||
." ORDER BY f.datef DESC, f.rowid DESC LIMIT 50");
|
||||
if ($ri) {
|
||||
while ($f = $db->fetch_object($ri)) {
|
||||
$invoices[] = array(
|
||||
'id' => (int) $f->rowid,
|
||||
'ref' => $f->ref,
|
||||
'date' => $db->jdate($f->datef),
|
||||
'status' => (int) $f->fk_statut,
|
||||
'total' => (float) $f->total_ttc,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Berichte des Kunden (über Joins)
|
||||
$reports = array();
|
||||
$rr = $db->query(
|
||||
"SELECT b.rowid, b.ref, b.titel, b.status, b.datec, b.element_type, c.ref AS parent_ref"
|
||||
." FROM ".$db->prefix()."bericht b"
|
||||
." INNER JOIN ".$db->prefix()."commande c ON c.rowid = b.fk_element"
|
||||
." WHERE b.element_type = 'order' AND c.fk_soc = ".((int) $id)
|
||||
." UNION "
|
||||
."SELECT b.rowid, b.ref, b.titel, b.status, b.datec, b.element_type, f.ref AS parent_ref"
|
||||
." FROM ".$db->prefix()."bericht b"
|
||||
." INNER JOIN ".$db->prefix()."facture f ON f.rowid = b.fk_element"
|
||||
." WHERE b.element_type = 'invoice' AND f.fk_soc = ".((int) $id)
|
||||
." ORDER BY datec DESC LIMIT 100"
|
||||
);
|
||||
if ($rr) {
|
||||
while ($b = $db->fetch_object($rr)) {
|
||||
$reports[] = array(
|
||||
'id' => (int) $b->rowid,
|
||||
'ref' => $b->ref,
|
||||
'titel' => $b->titel,
|
||||
'status' => (int) $b->status,
|
||||
'datec' => $db->jdate($b->datec),
|
||||
'element_type' => $b->element_type,
|
||||
'parent_ref' => $b->parent_ref,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
api_ok(array(
|
||||
'customer' => array(
|
||||
'id' => (int) $soc->id,
|
||||
'name' => $soc->name,
|
||||
'code' => $soc->code_client,
|
||||
'address' => $soc->address,
|
||||
'zip' => $soc->zip,
|
||||
'town' => $soc->town,
|
||||
'country' => $soc->country,
|
||||
'phone' => $soc->phone,
|
||||
'email' => $soc->email,
|
||||
'siret' => $soc->idprof1 ?? '',
|
||||
'vat' => $soc->tva_intra ?? '',
|
||||
),
|
||||
'orders' => $orders,
|
||||
'invoices' => $invoices,
|
||||
'reports' => $reports,
|
||||
));
|
||||
130
api/pages.php
130
api/pages.php
|
|
@ -26,15 +26,134 @@ if ($method === 'POST' && $action === 'signature') {
|
|||
$bericht = new Bericht($db);
|
||||
if ($bericht->fetch($bericht_id) <= 0) api_fail('Bericht nicht gefunden', 404);
|
||||
|
||||
// Parent-Objekt (Kunde) holen — wir brauchen den Kundennamen für die Metadaten
|
||||
$parent = bericht_fetch_parent($db, $bericht->element_type, $bericht->fk_element);
|
||||
$kundename = $parent && !empty($parent->thirdparty) ? ($parent->thirdparty->name ?? '') : '';
|
||||
$parent_ref = $parent ? $parent->ref : '';
|
||||
|
||||
// Zusatz-Felder vom Client
|
||||
$signer_name = trim((string) ($_POST['signer_name'] ?? ''));
|
||||
$gps_lat = isset($_POST['gps_lat']) ? (float) $_POST['gps_lat'] : null;
|
||||
$gps_lon = isset($_POST['gps_lon']) ? (float) $_POST['gps_lon'] : null;
|
||||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
|
||||
$workdir = DOL_DATA_ROOT.'/bericht/work/'.$bericht_id;
|
||||
if (!is_dir($workdir)) dol_mkdir($workdir);
|
||||
|
||||
// Hash-Verkettung: SHA256 über alle vorherigen Seiten-Quelldateien
|
||||
$prev_pages = BerichtPage::fetchAllForBericht($db, $bericht_id);
|
||||
$hash_ctx = hash_init('sha256');
|
||||
hash_update($hash_ctx, 'bericht:'.$bericht_id.'|ref:'.$bericht->ref);
|
||||
foreach ($prev_pages as $pp) {
|
||||
$full_pp = bericht_resolve_data_path($pp->source_path);
|
||||
if ($full_pp && file_exists($full_pp)) {
|
||||
hash_update_file($hash_ctx, $full_pp);
|
||||
}
|
||||
hash_update($hash_ctx, '|order:'.$pp->page_order.'|note:'.($pp->note ?? ''));
|
||||
}
|
||||
$chain_hash = hash_final($hash_ctx);
|
||||
|
||||
// Rohe Unterschrift vom Upload
|
||||
$raw_sig_png = file_get_contents($_FILES['file']['tmp_name']);
|
||||
if (!$raw_sig_png) api_fail('Upload fehlgeschlagen', 500);
|
||||
$sig_img = @imagecreatefromstring($raw_sig_png);
|
||||
if (!$sig_img) api_fail('PNG ungültig');
|
||||
$sig_w = imagesx($sig_img);
|
||||
$sig_h = imagesy($sig_img);
|
||||
|
||||
// Composite-Canvas: Metadaten oben, Unterschrift in der Mitte, Footer mit Hash unten
|
||||
$canvas_w = max($sig_w, 1200);
|
||||
$header_h = 180;
|
||||
$footer_h = 120;
|
||||
$sig_area_h = 500;
|
||||
$canvas_h = $header_h + $sig_area_h + $footer_h;
|
||||
$canvas = imagecreatetruecolor($canvas_w, $canvas_h);
|
||||
|
||||
$white = imagecolorallocate($canvas, 255, 255, 255);
|
||||
$black = imagecolorallocate($canvas, 0, 0, 0);
|
||||
$grey = imagecolorallocate($canvas, 120, 120, 120);
|
||||
$line_grey = imagecolorallocate($canvas, 200, 200, 200);
|
||||
$dark_blue = imagecolorallocate($canvas, 51, 122, 183);
|
||||
imagefill($canvas, 0, 0, $white);
|
||||
|
||||
// Header-Box
|
||||
imagefilledrectangle($canvas, 0, 0, $canvas_w, $header_h, imagecolorallocate($canvas, 245, 248, 252));
|
||||
imagerectangle($canvas, 0, 0, $canvas_w - 1, $header_h, $line_grey);
|
||||
|
||||
// Überschrift
|
||||
$title = 'ABNAHMEUNTERSCHRIFT';
|
||||
imagestring($canvas, 5, 20, 15, $title, $dark_blue);
|
||||
|
||||
// Metadaten Zeile 1
|
||||
$line1 = 'Bericht: '.$bericht->ref.' · '.trim($bericht->element_type.' '.$parent_ref);
|
||||
imagestring($canvas, 4, 20, 50, $line1, $black);
|
||||
// Zeile 2: Kunde
|
||||
$line2 = 'Kunde: '.$kundename;
|
||||
imagestring($canvas, 4, 20, 75, $line2, $black);
|
||||
// Zeile 3: Datum + Zeit (Server) + Unterzeichner
|
||||
$datetime = date('Y-m-d H:i:s');
|
||||
$line3 = 'Zeit (Server): '.$datetime.' · Unterzeichner: '.($signer_name ?: '—');
|
||||
imagestring($canvas, 4, 20, 100, $line3, $black);
|
||||
// Zeile 4: GPS
|
||||
if ($gps_lat !== null && $gps_lon !== null) {
|
||||
$line4 = sprintf('GPS: %.6f, %.6f', $gps_lat, $gps_lon);
|
||||
imagestring($canvas, 3, 20, 125, $line4, $black);
|
||||
}
|
||||
// Bestätigungstext
|
||||
$bestaetigung = 'Ich bestaetige hiermit die ordnungsgemaesse Ausfuehrung der dokumentierten Arbeiten.';
|
||||
imagestring($canvas, 3, 20, 150, $bestaetigung, $black);
|
||||
|
||||
// Unterschrift-Bereich mittig einsetzen, auf Breite skalieren
|
||||
$max_sig_w = $canvas_w - 80;
|
||||
$max_sig_h = $sig_area_h - 60;
|
||||
$ratio = min($max_sig_w / $sig_w, $max_sig_h / $sig_h);
|
||||
$dw = (int) ($sig_w * $ratio);
|
||||
$dh = (int) ($sig_h * $ratio);
|
||||
$dx = (int) (($canvas_w - $dw) / 2);
|
||||
$dy = $header_h + 30;
|
||||
imagecopyresampled($canvas, $sig_img, $dx, $dy, 0, 0, $dw, $dh, $sig_w, $sig_h);
|
||||
imagedestroy($sig_img);
|
||||
|
||||
// Trennlinie unter der Unterschrift
|
||||
imageline($canvas, 40, $header_h + $sig_area_h - 20, $canvas_w - 40, $header_h + $sig_area_h - 20, $grey);
|
||||
imagestring($canvas, 3, (int) ($canvas_w / 2 - 50), $header_h + $sig_area_h - 15,
|
||||
'Unterschrift Kunde', $grey);
|
||||
|
||||
// Footer-Box mit Hash
|
||||
$fy = $header_h + $sig_area_h;
|
||||
imagefilledrectangle($canvas, 0, $fy, $canvas_w, $canvas_h, imagecolorallocate($canvas, 248, 248, 248));
|
||||
imagerectangle($canvas, 0, $fy, $canvas_w - 1, $canvas_h - 1, $line_grey);
|
||||
imagestring($canvas, 3, 20, $fy + 15, 'Integritaetspruefung (SHA-256 ueber alle vorherigen Seiten):', $grey);
|
||||
imagestring($canvas, 2, 20, $fy + 40, $chain_hash, $black);
|
||||
imagestring($canvas, 2, 20, $fy + 70, 'Erfasst: '.$datetime.' · Server: '.($_SERVER['SERVER_NAME'] ?? ''), $grey);
|
||||
|
||||
// Schreiben
|
||||
$filename = 'signature_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'.png';
|
||||
$target = $workdir.'/'.$filename;
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) api_fail('Upload fehlgeschlagen', 500);
|
||||
if (!imagepng($canvas, $target, 6)) api_fail('PNG schreiben fehlgeschlagen', 500);
|
||||
imagedestroy($canvas);
|
||||
$relpath = str_replace(DOL_DATA_ROOT.'/', '', $target);
|
||||
|
||||
// Metadaten als JSON neben der Datei speichern (für spätere Verifikation)
|
||||
$meta = array(
|
||||
'bericht_id' => $bericht_id,
|
||||
'bericht_ref' => $bericht->ref,
|
||||
'parent_type' => $bericht->element_type,
|
||||
'parent_ref' => $parent_ref,
|
||||
'kunde' => $kundename,
|
||||
'signer_name' => $signer_name,
|
||||
'signed_at' => $datetime,
|
||||
'signed_at_unix' => time(),
|
||||
'user_id' => $user->id,
|
||||
'user_login' => $user->login,
|
||||
'gps_lat' => $gps_lat,
|
||||
'gps_lon' => $gps_lon,
|
||||
'chain_hash_sha256' => $chain_hash,
|
||||
'page_count_at_signing' => count($prev_pages),
|
||||
'remote_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
);
|
||||
@file_put_contents($target.'.meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$resm = $db->query("SELECT COALESCE(MAX(page_order),0) AS m FROM ".$db->prefix()."bericht_page WHERE fk_bericht = ".$bericht_id);
|
||||
$next = ($resm && ($o = $db->fetch_object($resm))) ? ((int) $o->m) + 1 : 1;
|
||||
|
||||
|
|
@ -43,10 +162,15 @@ if ($method === 'POST' && $action === 'signature') {
|
|||
$page->page_order = $next;
|
||||
$page->source_type = 'upload';
|
||||
$page->source_path = $relpath;
|
||||
$page->note = 'Unterschrift Kunde';
|
||||
$page->note = 'Unterschrift '.($signer_name ?: 'Kunde').' — '.$datetime;
|
||||
if ($page->create() <= 0) api_fail('Page-Insert fehlgeschlagen', 500);
|
||||
|
||||
api_ok(array('page_id' => $page->id, 'filename' => $filename));
|
||||
api_ok(array(
|
||||
'page_id' => $page->id,
|
||||
'filename' => $filename,
|
||||
'chain_hash' => $chain_hash,
|
||||
'signed_at' => $datetime,
|
||||
));
|
||||
}
|
||||
|
||||
$page_id = (int) ($_GET['id'] ?? 0);
|
||||
|
|
|
|||
Loading…
Reference in a new issue