diff --git a/CLAUDE.md b/CLAUDE.md index ff6586c..207cc41 100644 --- a/CLAUDE.md +++ b/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 diff --git a/api/customers.php b/api/customers.php new file mode 100644 index 0000000..81013d8 --- /dev/null +++ b/api/customers.php @@ -0,0 +1,147 @@ + — Kunden-Detail: Stammdaten + Aufträge + Berichte + * GET /api/customers.php?q= — 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, +)); diff --git a/api/pages.php b/api/pages.php index cd9ec79..1bf253f 100644 --- a/api/pages.php +++ b/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);