diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f63d8ef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# Bericht-Modul — Projekt-Status & Architektur + +## Stand 2026-04-08 + +Dolibarr-Custom-Modul für Arbeitsberichte mit Browser-PDF-Editor. + +## Architektur (final) + +### Tab-Verteilung +- **Auftrag (commande)** — primärer Erstellungsort, Berichte mit `element_type='order'`, Auftragsnummer = `commande->ref` direkt +- **Rechnung (facture)** — Berichte mit `element_type='invoice'`, Auftragsnummer aus `array_options['options_auftragsnummer']` +- **Angebot (propal)** — möglich, gleiche Logik +- **Kundenkarte (thirdparty)** — read-only Übersicht (Phase 1.7), zeigt alle Berichte des Kunden über Joins, **kein** Anlegen, **kein** Speicher-Ort + +### Verknüpfung Auftrag → Rechnung +Berichte gehören 1:1 zu einem Parent (`element_type` + `fk_element`). Auf einer Rechnungs-Seite werden ZUSÄTZLICH die Berichte der verknüpften Aufträge angezeigt — über `fetchObjectLinked()` der Rechnung. Mit Button "→ Dieser Rechnung zuordnen" erzeugt man einen Eintrag in `llx_element_element` (Standard-Dolibarr-n:m-Verknüpfung), damit der Bericht beim Finalisieren auch im ECM der Rechnung landet. + +### DB-Tabellen +- `llx_bericht` — Bericht (rowid, ref, titel, element_type, fk_element, auftragsnummer, template_odt, status, final_pdf_path, format, orientation, ...) +- `llx_bericht_page` — Seite (rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note, layout, image_scale, image_align, ...) +- `llx_bericht_upload_token` — Phase 2: Mobile-Upload-Tokens (rowid, token, fk_bericht, expires_at, created_by) + +### Permissions +- `bericht/read` (Standard für alle) +- `bericht/write` (Standard für alle) +- `bericht/delete` (Standard für alle) +- `bericht/admin` (nur explizit) + +**WICHTIG:** `$this->rights[$r][4]` = perms-Name (`'read'`), `[5]` = subperms (leer). NICHT Modul-Name in [4]! + +### Modul-Numero +**500033** — kollidiert NICHT mit BankImport (500021). Permission-IDs sind 500033 + n. + +### CSS-Variablen (Dolibarr awl-dark Theme) +Verwendete: `--colorbacktitle1`, `--colortext`, `--colorbackbody`, `--colorboxbordertitle1`, `--colortextlink`, `--colorbackhmenu1`, `--colortextbackhmenu`, `--colorbackvmenu1` + +NICHT vorhanden im awl-dark: `--inputbackgroundcolor`, `--inputtextcolor` — stattdessen Fallbacks nutzen. + +### JS-Libraries (lokal in js/lib/) +- pdf.min.js (PDF.js 3.11) +- pdf.worker.min.js +- fabric.min.js (Fabric.js 5.3) +- Sortable.min.js (SortableJS 1.15) + +### LocalStorage Settings +Key: `bericht.editor.settings.v1` +Speichert: color, stroke, fontFamily, fontSize, bold, italic, zoom + +### Forgejo +- Repo: `data/bericht` (NICHT `data-it/bericht` — Token hat keine org-write rechte) +- Workflow: `[deploy]`-Tag triggert rsync nach `/mnt/appdata/firma/dolibarr-202509/modules/bericht` +- Lokaler Symlink: `/var/www/dolibarr/custom/bericht` → `/mnt/17 - Entwicklungen/30 - Scripts/php/Dolibarr - Module/Bericht/repo` +- Lokales Apache läuft als User `data` (NICHT `http`) + +--- + +## Phase 1 — Bericht-Modul-Erweiterungen (in Arbeit) + +### ✅ Erledigt vor Phase 1 +- Modul-Scaffold (modBericht, SQL, Lang, Rechte korrekt nach Stundenzettel-Format) +- Editor mit PDF.js + Fabric.js +- Toolbar 2-zeilig, einheitliche Höhe 30px +- Schriftart/Größe/Bold/Italic mit localStorage-Persistenz +- Zoom (-/+/Reset), Seitenrotation, Pfeil mit Spitze (drag, drehbar) +- Seiten-Thumbnails als echte Vorschau (PDF.js + Image), Hell/Dunkel-Toggle +- ResizeObserver für Console-Open-Resize +- ODT-Template-Verwaltung im Admin +- Generate-PDF mit FPDI + Annotationen einbrennen +- Mobile-Upload-Idee dokumentiert (Phase 2) + +### 🔄 Phase 1 Features (in Arbeit) +- [ ] **1.6 Verknüpfte Sicht Auftrag→Rechnung** — Übersicht zeigt zwei Sektionen, Übernahme-Button erzeugt llx_element_element. *(in Arbeit)* +- [ ] **1.1 Live-PDF-Vorschau** — neuer Endpoint `ajax/preview_pdf.php` (wie generate_pdf, aber ohne ECM-Insert + status), JS-Modal mit PDF.js +- [ ] **1.2 Anhänge löschen** — neuer Endpoint `ajax/delete_attachment.php`, 🗑️-Icon neben Checkbox in Anhänge-Liste, Path-Whitelist, ECM-Cleanup +- [ ] **1.3 Seitengröße A4/A3/Letter + Hoch/Quer** — neue Spalten in `llx_bericht` (format, orientation), pro Seite überschreibbar in `llx_bericht_page` (format_override) +- [ ] **1.4 Mehrere Bilder pro Seite** — Layout-Picker (1/2/4/6 Grid), neue Spalte `layout` in `llx_bericht_page`, n:m-Bilder evtl. neue Tabelle +- [ ] **1.5 Bildgröße pro Seite** — Spalten image_scale, image_align, image_x, image_y in llx_bericht_page +- [ ] **1.7 Kunden-Tab** — Tab "Berichte" auf thirdparty, read-only, flache Tabelle sortiert nach Datum, Konstante BERICHT_TAB_ON_THIRDPARTY (default 0) + +--- + +## Phase 2 — Mobile-Vorbereitung + API-Layer (geplant) +- 2.1 Mobile-Upload-Token Tabelle + Cleanup-Cron +- 2.2 QR-Upload Lite Modal im Editor +- 2.3 API-Layer unter `bericht/api/` mit JWT-Auth +- 2.4 REST-Endpoints: /auth/login, /orders, /orders/{id}, /orders/{id}/photos, /reports, /reports/{id}/pages + +JWT-Secret aus `$dolibarr_main_instance_unique_id`, 7 Tage Lifetime, Multi-Device OK (stateless). + +Auftragsfilter pro User: `fk_user_author`, `fk_user_valid`, `fk_user_modif` ODER `llx_element_contact` mit "intern verantwortlich". + +--- + +## Phase 3 — PWA MVP (geplant) +- Repo: `data-it/baustelle-pwa` (separates SvelteKit-Projekt) +- Hosting: `awl.data-it-solution.de/baustelle/` (Apache-Alias) +- Stack: SvelteKit + Workbox + idb-keyval +- MVP: Login → Auftragsliste → Detail → Foto-Aufnahme → Offline-Queue → Sync + +--- + +## Phase 4 — PWA Voll (geplant) +- Sprachnotizen (MediaRecorder) +- Touch-Skizzen-Editor +- Schnell-Bericht in PWA +- Touch-Unterschrift +- PIN-Schutz / WebAuthn +- Push-Notifications +- Web Share Target API + +--- + +## Phase 5 — Optional Später +Stamps, Vorher/Nachher, Versionierung, Mess-Werkzeug, Bericht-Vorlagen, Batch-Modus, Whisper-Transkription, Offline-Map + +--- + +## Wichtige Lessons Learned (für andere Dolibarr-Module) + +1. **Numero muss kollisionsfrei sein** — sonst werden Permissions stillschweigend verworfen +2. **Permission-Array-Format**: [4]=perms (action), [5]=subperms (leer) — NICHT [4]=Modulname +3. **CSS-Variablen** über Theme nutzen, nicht hardcoded Hex +4. **JS-Libs lokal** in `js/lib/` (Eddys Regel: kein CDN) +5. **Fabric.js wickelt Canvas** in `.canvas-container` — den positionieren, nicht das innere Canvas +6. **PDF.js Buffer wird konsumiert** — beim Re-Render `arrayBuffer.slice(0)` nutzen +7. **Lokales Dolibarr** läuft als User `data`, mit Symlinks aus `/var/www/dolibarr/custom/` zu `/mnt/17 - Entwicklungen/...` +8. **Niemals direkt** in `/var/www/dolibarr` oder `/mnt/appdata` editieren — alles über Git +9. **Niemals schreibend** in `dolibarr_test` MySQL — nur lesend für Debugging +10. **Computed Extrafields** mit `$object->ref` brauchen Null-Check, sonst PHP-Output beim eval → "headers already sent" diff --git a/ajax/delete_attachment.php b/ajax/delete_attachment.php new file mode 100644 index 0000000..6fa473f --- /dev/null +++ b/ajax/delete_attachment.php @@ -0,0 +1,46 @@ +hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403); + +$relpath = (string) ($_POST['relpath'] ?? ''); +if (empty($relpath)) bericht_ajax_fail('relpath fehlt'); + +// Whitelist-Prüfung: nur unter den drei erlaubten Element-Verzeichnissen +if (!preg_match('#^(facture|commande|propal)/[^/]+/[^/]+$#', $relpath)) { + bericht_ajax_fail('Pfad nicht erlaubt: ' . $relpath, 403); +} + +$full = bericht_resolve_data_path($relpath); +if (!$full || !file_exists($full)) bericht_ajax_fail('Datei nicht gefunden', 404); + +// Datei löschen +if (!@unlink($full)) bericht_ajax_fail('Löschen fehlgeschlagen'); + +// Thumbs ebenfalls löschen (Dolibarr legt _mini/_small unter thumbs/ ab) +$dir = dirname($full); +$base = pathinfo($full, PATHINFO_FILENAME); +$ext = pathinfo($full, PATHINFO_EXTENSION); +foreach (array('_mini', '_small') as $suffix) { + $thumb = $dir.'/thumbs/'.$base.$suffix.'.'.$ext; + if (file_exists($thumb)) @unlink($thumb); + $thumb_png = $dir.'/thumbs/'.$base.$suffix.'.png'; + if (file_exists($thumb_png)) @unlink($thumb_png); +} + +// ECM-Eintrag bereinigen +$relpath_dir = dirname($relpath); +$filename = basename($relpath); +$db->query("DELETE FROM ".$db->prefix()."ecm_files" + ." WHERE filepath = '".$db->escape($relpath_dir)."'" + ." AND filename = '".$db->escape($filename)."'"); + +bericht_ajax_ok(array('deleted' => $relpath)); diff --git a/ajax/generate_pdf.php b/ajax/generate_pdf.php index 2a7a1e1..aa9d4fd 100644 --- a/ajax/generate_pdf.php +++ b/ajax/generate_pdf.php @@ -128,14 +128,41 @@ if (!file_exists($target_path)) bericht_ajax_fail('PDF wurde nicht erzeugt'); // In ECM registrieren (taucht unter Verknüpfte Dokumente auf) require_once DOL_DOCUMENT_ROOT.'/ecm/class/ecmfiles.class.php'; -$ecmfile = new EcmFiles($db); -$ecmfile->filepath = $dir_key.'/'.dol_sanitizeFileName($parent->ref); -$ecmfile->filename = $filename; -$ecmfile->fullpath_orig = $target_path; -$ecmfile->src_object_type = $dir_key; -$ecmfile->src_object_id = $parent->id; -$ecmfile->label = md5_file($target_path); -@$ecmfile->create($user); // Fehler bei bereits existierendem Eintrag ignorieren +$register_ecm = function ($obj_type, $obj_id, $filepath_dir, $filename, $target_path) use ($db, $user) { + $ecmfile = new EcmFiles($db); + $ecmfile->filepath = $filepath_dir; + $ecmfile->filename = $filename; + $ecmfile->fullpath_orig = $target_path; + $ecmfile->src_object_type = $obj_type; + $ecmfile->src_object_id = $obj_id; + $ecmfile->label = md5_file($target_path); + @$ecmfile->create($user); +}; +$register_ecm($dir_key, $parent->id, $dir_key.'/'.dol_sanitizeFileName($parent->ref), $filename, $target_path); + +// Wenn der Bericht zusätzlich per llx_element_element verknüpfte Elemente hat, +// dort ebenfalls ECM-Eintrag anlegen + PDF-Kopie ablegen. +$res_links = $db->query("SELECT targettype, fk_target FROM ".$db->prefix()."element_element WHERE sourcetype='bericht' AND fk_source=".((int) $bericht->id)); +if ($res_links) { + while ($lnk = $db->fetch_object($res_links)) { + $tdir = bericht_element_to_dir_key($lnk->targettype === 'facture' ? 'invoice' + : ($lnk->targettype === 'commande' ? 'order' + : ($lnk->targettype === 'propal' ? 'propal' : $lnk->targettype))); + if (!$tdir) continue; + + // Ziel-Objekt fetchen für ref + $linked_obj = bericht_fetch_parent($db, + $tdir === 'facture' ? 'invoice' : ($tdir === 'commande' ? 'order' : 'propal'), + (int) $lnk->fk_target); + if (!$linked_obj) continue; + + $linked_dir = $conf->{$tdir}->multidir_output[$linked_obj->entity].'/'.dol_sanitizeFileName($linked_obj->ref); + if (!is_dir($linked_dir)) dol_mkdir($linked_dir); + $linked_target = $linked_dir.'/'.$filename; + @copy($target_path, $linked_target); + $register_ecm($tdir, $linked_obj->id, $tdir.'/'.dol_sanitizeFileName($linked_obj->ref), $filename, $linked_target); + } +} // Bericht-Status auf Final $bericht->status = Bericht::STATUS_FINAL; @@ -148,140 +175,8 @@ bericht_ajax_ok(array( )); -/** - * Rendert das ODT-Deckblatt mit Platzhaltern und konvertiert es zu PDF. - * Nutzt LibreOffice headless. - * - * @return string|null Pfad zum erzeugten PDF oder null bei Fehler - */ -function bericht_render_cover($template_path, $bericht, $parent, $tempdir) -{ - global $conf, $user, $langs; - - $odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php'; - if (!file_exists($odt_loader)) return null; - require_once $odt_loader; - - try { - $odf = new Odf($template_path, array('PATH_TO_TMP' => $tempdir)); - - $vars = array( - 'auftragsnummer' => $bericht->auftragsnummer ?: '', - 'angebotsnummer' => $parent->array_options['options_angebotsnummer'] ?? '', - 'rechnungsnummer' => $parent->ref ?? '', - 'kunde_name' => $parent->thirdparty->name ?? '', - 'kunde_adresse' => trim(($parent->thirdparty->address ?? '')."\n".($parent->thirdparty->zip ?? '').' '.($parent->thirdparty->town ?? '')), - 'datum' => dol_print_date(dol_now(), 'day'), - 'beschreibung' => $parent->array_options['options_beschreibung'] ?? '', - 'hinweis' => $parent->array_options['options_hinweis'] ?? '', - 'bericht_titel' => $bericht->titel ?? '', - 'ersteller' => $user->getFullName($langs ?? null) ?: $user->login, - ); - foreach ($vars as $k => $v) { - try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) {} - } - - $odt_out = $tempdir.'/cover_'.$bericht->id.'.odt'; - $odf->saveToDisk($odt_out); - - // ODT → PDF via LibreOffice - $lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice'); - $cmd = escapeshellcmd($lobin) - .' --headless --convert-to pdf --outdir '.escapeshellarg($tempdir).' '.escapeshellarg($odt_out).' 2>&1'; - @shell_exec($cmd); - - $pdf_out = preg_replace('/\.odt$/i', '.pdf', $odt_out); - return file_exists($pdf_out) ? $pdf_out : null; - } catch (Throwable $e) { - return null; - } -} - -/** - * Rendert Fabric.js-Annotationen ins TCPDF-Objekt. - * Versteht die wichtigsten Shape-Typen: rect, circle, line, path (freihand), text. - * Koordinaten in fabric_json sind in Pixel relativ zum Bild → werden auf mm skaliert. - */ -function bericht_burn_annotations(TCPDF $pdf, $fabric_json, $x, $y, $w, $h) -{ - if (empty($fabric_json)) return; - $data = json_decode($fabric_json, true); - if (!is_array($data) || empty($data['objects'])) return; - - // Skalierung: Fabric arbeitet in Pixel, TCPDF in mm - // Wir nehmen an, der Fabric-Canvas hatte die gleiche Pixelgröße wie das gerenderte Bild, - // und das Bild belegt im PDF (x,y,w,h). - $cw = $data['width'] ?? null; - $ch = $data['height'] ?? null; - if (!$cw || !$ch) return; - $sx = $w / $cw; - $sy = $h / $ch; - - foreach ($data['objects'] as $obj) { - $stroke = isset($obj['stroke']) ? bericht_hex_to_rgb($obj['stroke']) : array(255, 0, 0); - $sw = ($obj['strokeWidth'] ?? 2) * $sx; - $pdf->SetDrawColor($stroke[0], $stroke[1], $stroke[2]); - $pdf->SetLineWidth(max(0.2, $sw)); - - $type = $obj['type'] ?? ''; - $ox = ($obj['left'] ?? 0) * $sx + $x; - $oy = ($obj['top'] ?? 0) * $sy + $y; - $ow = ($obj['width'] ?? 0) * ($obj['scaleX'] ?? 1) * $sx; - $oh = ($obj['height'] ?? 0) * ($obj['scaleY'] ?? 1) * $sy; - - switch ($type) { - case 'rect': - $pdf->Rect($ox, $oy, $ow, $oh, 'D'); - break; - case 'circle': - case 'ellipse': - $rx = ($obj['rx'] ?? ($ow / 2)) * $sx; - $ry = ($obj['ry'] ?? ($oh / 2)) * $sy; - $pdf->Ellipse($ox + $rx, $oy + $ry, $rx, $ry, 0, 0, 360, 'D'); - break; - case 'line': - $x1 = ($obj['x1'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx; - $y1 = ($obj['y1'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy; - $x2 = ($obj['x2'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx; - $y2 = ($obj['y2'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy; - $pdf->Line($x1, $y1, $x2, $y2); - break; - case 'path': - // Freihand-Pfade vereinfacht als Polyline rendern - if (!empty($obj['path']) && is_array($obj['path'])) { - $prev = null; - foreach ($obj['path'] as $seg) { - if (!is_array($seg) || count($seg) < 3) continue; - $cmd = $seg[0]; - if ($cmd === 'M') { - $prev = array($seg[1] * $sx + $x, $seg[2] * $sy + $y); - } elseif (($cmd === 'L' || $cmd === 'Q') && $prev) { - $px = $seg[1] * $sx + $x; - $py = $seg[2] * $sy + $y; - $pdf->Line($prev[0], $prev[1], $px, $py); - $prev = array($px, $py); - } - } - } - break; - case 'i-text': - case 'text': - case 'textbox': - $fontsize = max(6, ($obj['fontSize'] ?? 16) * $sx * 2.83); // px → pt - $pdf->SetFont('helvetica', '', $fontsize); - $pdf->SetTextColor($stroke[0], $stroke[1], $stroke[2]); - $pdf->Text($ox, $oy + $fontsize * 0.35, $obj['text'] ?? ''); - break; - } - } -} - -function bericht_hex_to_rgb($hex) -{ - $hex = ltrim($hex, '#'); - if (strlen($hex) === 3) { - $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; - } - if (strlen($hex) !== 6) return array(255, 0, 0); - return array(hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))); +// bericht_render_cover, bericht_burn_annotations und bericht_hex_to_rgb +// liegen jetzt in lib/bericht.lib.php (gemeinsam genutzt von generate_pdf und preview_pdf) +function bericht_render_cover($template_path, $bericht, $parent, $tempdir) { + return bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir); } diff --git a/ajax/preview_pdf.php b/ajax/preview_pdf.php new file mode 100644 index 0000000..9187c4d --- /dev/null +++ b/ajax/preview_pdf.php @@ -0,0 +1,130 @@ +/preview_.pdf + * - registriert NICHT in llx_ecm_files + * - ändert NICHT den Status des Berichts + * - liefert das PDF direkt im Response (Content-Type: application/pdf) + * + * GET/POST: berichtid, token + */ + +if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', 1); + +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; } +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php"; +if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php"; +if (!$res) die("Include of main fails"); + +require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php'; +require_once __DIR__.'/../class/bericht.class.php'; +require_once __DIR__.'/../lib/bericht.lib.php'; + +if (!$user->hasRight('bericht', 'read')) accessforbidden(); + +$berichtid = (int) (GETPOSTINT('berichtid')); +$bericht = new Bericht($db); +if ($bericht->fetch($berichtid) <= 0) { http_response_code(404); exit('Bericht nicht gefunden'); } + +$parent = bericht_fetch_parent($db, $bericht->element_type, $bericht->fk_element); +if (!$parent) { http_response_code(404); exit('Parent nicht gefunden'); } + +$pages = BerichtPage::fetchAllForBericht($db, $bericht->id); +if (empty($pages)) { http_response_code(400); exit('Bericht enthält keine Seiten'); } + +// TCPDF + FPDI +$tcpdf_loaded = false; +foreach (array( + DOL_DOCUMENT_ROOT.'/includes/tecnickcom/tcpdf/tcpdf.php', + DOL_DOCUMENT_ROOT.'/includes/tcpdf/tcpdf.php', +) as $p) { if (file_exists($p)) { require_once $p; $tcpdf_loaded = true; break; } } +if (!$tcpdf_loaded) { http_response_code(500); exit('TCPDF nicht gefunden'); } + +$fpdi_loaded = false; +foreach (array( + DOL_DOCUMENT_ROOT.'/includes/setasign/vendor/setasign/fpdi/src/Tcpdf/Fpdi.php', + DOL_DOCUMENT_ROOT.'/includes/fpdi/src/Tcpdf/Fpdi.php', +) as $p) { if (file_exists($p)) { require_once $p; $fpdi_loaded = true; break; } } + +if ($fpdi_loaded) { + $pdf = new \setasign\Fpdi\Tcpdf\Fpdi('P', 'mm', 'A4', true, 'UTF-8', false); +} else { + $pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8', false); +} +$pdf->SetCreator('Dolibarr Bericht-Modul (Vorschau)'); +$pdf->SetAuthor($user->getFullName($langs)); +$pdf->SetTitle(($bericht->titel ?: $bericht->ref).' [Vorschau]'); +$pdf->SetMargins(10, 10, 10); +$pdf->SetAutoPageBreak(true, 10); +$pdf->setPrintHeader(false); +$pdf->setPrintFooter(false); + +$tempdir = DOL_DATA_ROOT.'/bericht/temp/'.$berichtid; +if (!is_dir($tempdir)) dol_mkdir($tempdir); + +// Deckblatt aus ODT +if (!empty($bericht->template_odt)) { + $template_path = DOL_DATA_ROOT.'/bericht/templates/'.dol_sanitizeFileName($bericht->template_odt); + if (file_exists($template_path) && function_exists('bericht_render_cover_for_preview')) { + $cover_pdf = bericht_render_cover_for_preview($template_path, $bericht, $parent, $tempdir); + if ($cover_pdf && file_exists($cover_pdf) && $fpdi_loaded) { + $cp = $pdf->setSourceFile($cover_pdf); + for ($n = 1; $n <= $cp; $n++) { + $tpl = $pdf->importPage($n); + $size = $pdf->getTemplateSize($tpl); + $pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height'])); + $pdf->useTemplate($tpl); + } + } + } +} + +// Seiten — Logik aus generate_pdf.php duplizieren (vereinfacht: nur Bilder + PDF + Annotationen) +foreach ($pages as $page) { + $full = bericht_resolve_data_path($page->source_path); + if (!$full || !file_exists($full)) continue; + + if ($page->source_type === 'image' || preg_match('/\.(png|jpe?g)$/i', $full)) { + $pdf->AddPage('P', 'A4'); + list($iw, $ih) = @getimagesize($full); + if ($iw && $ih) { + $maxW = 190; $maxH = 277; + $ratio = min($maxW / $iw, $maxH / $ih); + $w = $iw * $ratio; $h = $ih * $ratio; + $x = (210 - $w) / 2; $y = 10; + $pdf->Image($full, $x, $y, $w, $h); + if (function_exists('bericht_burn_annotations')) { + bericht_burn_annotations($pdf, $page->fabric_json, $x, $y, $w, $h); + } + } + } elseif ($fpdi_loaded && preg_match('/\.pdf$/i', $full)) { + try { + $pdf->setSourceFile($full); + $tpl = $pdf->importPage($page->source_page ?: 1); + $size = $pdf->getTemplateSize($tpl); + $pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height'])); + $pdf->useTemplate($tpl); + if (function_exists('bericht_burn_annotations')) { + bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']); + } + } catch (Throwable $e) { /* skip */ } + } + + if (!empty($page->note)) { + $pdf->SetY(-20); + $pdf->SetFont('helvetica', 'I', 9); + $pdf->MultiCell(0, 5, $page->note, 0, 'L'); + } +} + +// Direkt im Browser anzeigen — kein File schreiben, kein ECM +header('Content-Type: application/pdf'); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); +$pdf->Output('preview.pdf', 'I'); +exit; diff --git a/bericht_card.php b/bericht_card.php index 7dd2517..e0c076b 100644 --- a/bericht_card.php +++ b/bericht_card.php @@ -58,6 +58,27 @@ if (!$parent) { $auftragsnummer = bericht_get_auftragsnummer($parent); +// Aktion: Bericht zur aktuellen Karte verknüpfen +if ($action === 'link' && $berichtid > 0 && $user->hasRight('bericht', 'write')) { + $b = new Bericht($db); + if ($b->fetch($berichtid) > 0) { + $b->linkToElement($element, $parent->id); + setEventMessages('Bericht der Rechnung zugeordnet', null, 'mesgs'); + } + header("Location: ".$_SERVER['PHP_SELF'].'?id='.$parent->id.'&element='.$element); + exit; +} + +if ($action === 'unlink' && $berichtid > 0 && $user->hasRight('bericht', 'write')) { + $b = new Bericht($db); + if ($b->fetch($berichtid) > 0) { + $b->unlinkFromElement($element, $parent->id); + setEventMessages('Verknüpfung entfernt', null, 'mesgs'); + } + header("Location: ".$_SERVER['PHP_SELF'].'?id='.$parent->id.'&element='.$element); + exit; +} + // Aktion: neuen Bericht anlegen if ($action === 'create' && $user->hasRight('bericht', 'write')) { $b = new Bericht($db); @@ -114,9 +135,33 @@ print '
'; if (!$bericht) { /* - * MODUS A: Übersicht — Liste vorhandener Berichte + "Neu anlegen" + * MODUS A: Übersicht + * - Berichte direkt zugeordnet (element_type+fk_element) + * - Berichte zusätzlich verknüpft (llx_element_element) + * - NUR auf Rechnung: Berichte aus den verknüpften Aufträgen (read-only Sicht) */ - $list = Bericht::fetchAllForElement($db, $element, $parent->id); + $list_direct = Bericht::fetchAllForElement($db, $element, $parent->id); + $list_linked = Bericht::fetchLinkedForElement($db, $element, $parent->id); + + // Aus verknüpften Aufträgen: nur wenn aktuelle Karte eine Rechnung ist + $list_from_orders = array(); + if ($element === 'invoice') { + $parent->fetchObjectLinked(); + if (!empty($parent->linkedObjects['commande'])) { + foreach ($parent->linkedObjects['commande'] as $linked_order) { + $more = Bericht::fetchAllForElement($db, 'order', $linked_order->id); + foreach ($more as $b) { + // Nicht doppelt zeigen wenn schon manuell verknüpft + $b->_source_order_ref = $linked_order->ref; + $b->_source_order_id = $linked_order->id; + $list_from_orders[] = $b; + } + } + } + } + + // Set der bereits verknüpften IDs für Vergleich + $linked_ids = array_map(function ($b) { return (int) $b->id; }, $list_linked); print '
'; @@ -133,27 +178,41 @@ if (!$bericht) { } print '
'; - if (empty($list)) { - print '
'.$langs->trans("BerichtNoReports").'
'; - } else { + /* ----- Helper: Bericht-Tabelle rendern ----- */ + $renderBerichtTable = function ($title, $items, $mode = 'direct') use ($langs, $user, $element, $parent) { + if (empty($items)) return; + print '

'.$title.' ('.count($items).')

'; print ''; print ''; print ''; print ''; + if ($mode === 'from_order') print ''; print ''; print ''; print ''; print ''; - foreach ($list as $b) { + foreach ($items as $b) { $url = $_SERVER['PHP_SELF'].'?berichtid='.$b->id; print ''; print ''; print ''; + if ($mode === 'from_order') { + print ''; + } print ''; print ''; print ''; } print '
'.$langs->trans("Ref").''.$langs->trans("BerichtTitle").'Aus Auftrag'.$langs->trans("BerichtCreatedAt").''.$langs->trans("BerichtStatus").''.$langs->trans("Action").'
'.dol_escape_htmltag($b->ref).''.dol_escape_htmltag($b->titel).''.dol_escape_htmltag($b->_source_order_ref ?? '').''.dol_print_date($b->datec, 'dayhour').''.$b->getLibStatut().''; print ''.$langs->trans("Open").' '; - if ($user->hasRight('bericht', 'delete')) { + + if ($mode === 'from_order' && $user->hasRight('bericht', 'write')) { + print '→ Übernehmen '; + } + if ($mode === 'linked' && $user->hasRight('bericht', 'write')) { + print '⊗ Lösen '; + } + if ($mode !== 'from_order' && $user->hasRight('bericht', 'delete')) { print ''.$langs->trans("Delete").''; @@ -162,6 +221,20 @@ if (!$bericht) { print '
'; + }; + + if (empty($list_direct) && empty($list_linked) && empty($list_from_orders)) { + print '
'.$langs->trans("BerichtNoReports").'
'; + } else { + $renderBerichtTable('Berichte direkt zu dieser Karte', $list_direct, 'direct'); + $renderBerichtTable('Zusätzlich verknüpfte Berichte', $list_linked, 'linked'); + if ($element === 'invoice') { + // Auftragsberichte filtern: solche die schon verknüpft sind, ausblenden + $remaining = array_filter($list_from_orders, function ($b) use ($linked_ids) { + return !in_array((int) $b->id, $linked_ids, true); + }); + $renderBerichtTable('Berichte aus verknüpften Aufträgen', $remaining, 'from_order'); + } } print ''; @@ -186,6 +259,8 @@ if (!$bericht) { 'reorder_pages' => dol_buildpath('/bericht/ajax/reorder_pages.php', 1), 'page_image' => dol_buildpath('/bericht/ajax/page_image.php', 1), 'generate_pdf' => dol_buildpath('/bericht/ajax/generate_pdf.php', 1), + 'preview_pdf' => dol_buildpath('/bericht/ajax/preview_pdf.php', 1), + 'delete_attachment'=> dol_buildpath('/bericht/ajax/delete_attachment.php', 1), ), 'lang' => array( 'undo' => $langs->trans("BerichtUndo"), @@ -244,12 +319,15 @@ if (!$bericht) { print '
'.dol_escape_htmltag($ref).'
'; foreach ($files as $f) { $icon = (strpos($f['mime'], 'image') === 0) ? '🖼' : ((strpos($f['mime'], 'pdf') !== false) ? '📄' : '📎'); - print '
'; } @@ -339,8 +417,9 @@ if (!$bericht) { // Footer-Aktionen print '
'; - print ''; - print ''; + print ''; + print ''; + print ''; if ($user->hasRight('bericht', 'delete')) { print ''; // .bericht-editor + // PDF-Vorschau-Modal + print ''; + // PDF.js + Fabric.js (lokal) print ''; print ''; diff --git a/class/bericht.class.php b/class/bericht.class.php index 5d5353b..1925d4c 100644 --- a/class/bericht.class.php +++ b/class/bericht.class.php @@ -148,7 +148,7 @@ class Bericht extends CommonObject } /** - * Liefert alle Berichte zu einem Parent-Objekt. + * Liefert alle Berichte zu einem Parent-Objekt (direkt zugeordnet). * * @param string $element_type invoice | order | propal * @param int $fk_element ID des Parent-Objekts @@ -172,6 +172,60 @@ class Bericht extends CommonObject return $list; } + /** + * Liefert Berichte, die per llx_element_element zusätzlich zu einem Parent verknüpft wurden. + */ + public static function fetchLinkedForElement(DoliDB $db, $targettype, $fk_target) + { + $list = array(); + $sql = "SELECT b.rowid FROM ".$db->prefix()."bericht b" + ." JOIN ".$db->prefix()."element_element e ON e.fk_source = b.rowid" + ." WHERE e.sourcetype = 'bericht'" + ." AND e.targettype = '".$db->escape($targettype)."'" + ." AND e.fk_target = ".((int) $fk_target) + ." ORDER BY b.datec DESC"; + $res = $db->query($sql); + if (!$res) return $list; + while ($obj = $db->fetch_object($res)) { + $b = new self($db); + if ($b->fetch($obj->rowid) > 0) { + $list[] = $b; + } + } + return $list; + } + + /** + * Erzeugt eine n:m-Verknüpfung zwischen einem Bericht und einem anderen Element. + */ + public function linkToElement($targettype, $fk_target) + { + // Schon vorhanden? + $sql = "SELECT rowid FROM ".$this->db->prefix()."element_element" + ." WHERE sourcetype = 'bericht' AND fk_source = ".((int) $this->id) + ." AND targettype = '".$this->db->escape($targettype)."'" + ." AND fk_target = ".((int) $fk_target); + $res = $this->db->query($sql); + if ($res && $this->db->num_rows($res) > 0) return 1; + + $ins = "INSERT INTO ".$this->db->prefix()."element_element" + ." (fk_source, sourcetype, fk_target, targettype) VALUES (" + .((int) $this->id).", 'bericht', ".((int) $fk_target).", '".$this->db->escape($targettype)."')"; + return $this->db->query($ins) ? 1 : -1; + } + + /** + * Entfernt eine n:m-Verknüpfung. + */ + public function unlinkFromElement($targettype, $fk_target) + { + $sql = "DELETE FROM ".$this->db->prefix()."element_element" + ." WHERE sourcetype = 'bericht' AND fk_source = ".((int) $this->id) + ." AND targettype = '".$this->db->escape($targettype)."'" + ." AND fk_target = ".((int) $fk_target); + return $this->db->query($sql) ? 1 : -1; + } + public function getLibStatut($mode = 0) { global $langs; diff --git a/css/bericht.css b/css/bericht.css index 2628fd2..96b6072 100644 --- a/css/bericht.css +++ b/css/bericht.css @@ -44,10 +44,19 @@ } .bericht-att-item { display: flex; align-items: center; gap: 6px; - padding: 4px 2px; cursor: pointer; font-size: 12px; + padding: 4px 2px; font-size: 12px; color: var(--colortext, inherit); } -.bericht-att-item:hover { background: var(--colorbackhmenu1, rgba(255,255,255,0.05)); } +.bericht-att-item:hover { background: rgba(255,255,255,0.05); } +.att-delete { + background: transparent; + border: none; + cursor: pointer; + opacity: 0.4; + padding: 2px 4px; + font-size: 13px; +} +.att-delete:hover { opacity: 1; color: #d9534f; } .att-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .att-size { font-size: 10px; opacity: 0.6; } .att-icon { font-size: 14px; } @@ -288,3 +297,53 @@ .bericht-layout { grid-template-columns: 1fr; } .bericht-attachments, .bericht-pages { max-height: 300px; } } + +/* PDF-Vorschau-Modal */ +.bericht-modal { + position: fixed; + inset: 0; + z-index: 9998; +} +.bericht-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0,0,0,0.7); +} +.bericht-modal-content { + position: absolute; + top: 5vh; left: 5vw; + right: 5vw; bottom: 5vh; + background: var(--colorbacktitle1, #2a2a30); + border: 1px solid var(--colorboxbordertitle1, #555); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.6); + display: flex; flex-direction: column; + overflow: hidden; +} +.bericht-modal-header { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 16px; + background: var(--colorbackbody, #222); + border-bottom: 1px solid var(--colorboxbordertitle1, #555); +} +.bericht-modal-header h3 { margin: 0; font-size: 16px; color: var(--colortext, #ddd); } +.bericht-modal-header button { + background: transparent; + border: 1px solid var(--colorboxbordertitle1, #555); + color: var(--colortext, #ddd); + border-radius: 3px; + padding: 4px 12px; + cursor: pointer; + font-size: 16px; +} +.bericht-modal-header button:hover { background: rgba(255,255,255,0.1); } +.bericht-modal-body { + flex: 1; + overflow: hidden; +} +.bericht-modal-body iframe { + width: 100%; + height: 100%; + border: none; + background: #444; +} diff --git a/js/editor.js b/js/editor.js index 823131a..a6737ac 100644 --- a/js/editor.js +++ b/js/editor.js @@ -569,6 +569,25 @@ document.getElementById('btn-save-draft').addEventListener('click', async () => { await savePageAnnotations(true); }); + + // Vorschau-Modal + const previewBtn = document.getElementById('btn-preview'); + if (previewBtn) { + previewBtn.addEventListener('click', async () => { + await savePageAnnotations(false); + const url = cfg.urls.preview_pdf + '?berichtid=' + cfg.berichtid + '&t=' + Date.now(); + document.getElementById('bericht-preview-iframe').src = url; + document.getElementById('bericht-preview-modal').style.display = 'block'; + }); + } + const modalClose = document.getElementById('bericht-modal-close'); + if (modalClose) modalClose.addEventListener('click', closePreviewModal); + document.querySelector('#bericht-preview-modal .bericht-modal-backdrop') + ?.addEventListener('click', closePreviewModal); + document.addEventListener('keydown', e => { + if (e.key === 'Escape') closePreviewModal(); + }); + document.getElementById('btn-finalize').addEventListener('click', async () => { await savePageAnnotations(false); const fd = new FormData(); @@ -588,19 +607,43 @@ /* ---------- Anhänge & Thumbs ---------- */ function bindAttachments() { const btn = document.getElementById('btn-add-selected'); - if (!btn) return; - btn.addEventListener('click', async () => { - const checks = document.querySelectorAll('.att-check:checked'); - for (const c of checks) { + if (btn) { + btn.addEventListener('click', async () => { + const checks = document.querySelectorAll('.att-check:checked'); + for (const c of checks) { + const fd = new FormData(); + fd.append('token', cfg.token); + fd.append('berichtid', cfg.berichtid); + fd.append('relpath', c.dataset.relpath); + fd.append('mime', c.dataset.mime); + await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd }); + c.checked = false; + } + location.reload(); + }); + } + + // Lösch-Buttons in der Anhänge-Liste + document.querySelectorAll('.att-delete').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const rel = btn.dataset.relpath; + const ref = btn.dataset.sourceRef || ''; + const name = btn.parentElement.querySelector('.att-name')?.textContent || rel; + if (!confirm('Datei "' + name + '" aus ' + (ref ? ref : 'dem Anhang') + ' wirklich löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.')) return; const fd = new FormData(); fd.append('token', cfg.token); - fd.append('berichtid', cfg.berichtid); - fd.append('relpath', c.dataset.relpath); - fd.append('mime', c.dataset.mime); - await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd }); - c.checked = false; - } - location.reload(); // einfacher als Thumbnail-Liste neu zu rendern + fd.append('relpath', rel); + const r = await fetch(cfg.urls.delete_attachment, { method: 'POST', body: fd }); + const data = await r.json().catch(() => ({})); + if (data.success) { + btn.parentElement.remove(); + toast('Datei gelöscht'); + } else { + alert('Löschen fehlgeschlagen: ' + (data.error || 'unbekannt')); + } + }); }); } @@ -720,6 +763,13 @@ }); } + function closePreviewModal() { + const m = document.getElementById('bericht-preview-modal'); + if (m) m.style.display = 'none'; + const ifr = document.getElementById('bericht-preview-iframe'); + if (ifr) ifr.src = 'about:blank'; + } + /* ---------- Helpers ---------- */ function toast(msg) { const t = document.createElement('div'); diff --git a/lib/bericht.lib.php b/lib/bericht.lib.php index e960873..d1c97a3 100644 --- a/lib/bericht.lib.php +++ b/lib/bericht.lib.php @@ -176,3 +176,161 @@ function bericht_resolve_data_path($relpath) if (strpos($full, $base) !== 0) return null; return $full; } + +function bericht_hex_to_rgb($hex) +{ + $hex = ltrim($hex, '#'); + if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; + if (strlen($hex) !== 6) return array(255, 0, 0); + return array(hexdec(substr($hex, 0, 2)), hexdec(substr($hex, 2, 2)), hexdec(substr($hex, 4, 2))); +} + +/** + * Rendert Fabric.js-Annotationen in ein TCPDF-Objekt. + * Nutzt die wichtigsten Shape-Typen: rect, circle/ellipse, line, path, text, group (Pfeil). + */ +function bericht_burn_annotations($pdf, $fabric_json, $x, $y, $w, $h) +{ + if (empty($fabric_json)) return; + $data = json_decode($fabric_json, true); + if (!is_array($data) || empty($data['objects'])) return; + + $cw = $data['width'] ?? null; + $ch = $data['height'] ?? null; + if (!$cw || !$ch) return; + $sx = $w / $cw; + $sy = $h / $ch; + + foreach ($data['objects'] as $obj) { + $stroke = isset($obj['stroke']) ? bericht_hex_to_rgb($obj['stroke']) : array(255, 0, 0); + $sw = ($obj['strokeWidth'] ?? 2) * $sx; + $pdf->SetDrawColor($stroke[0], $stroke[1], $stroke[2]); + $pdf->SetLineWidth(max(0.2, $sw)); + + $type = $obj['type'] ?? ''; + $ox = ($obj['left'] ?? 0) * $sx + $x; + $oy = ($obj['top'] ?? 0) * $sy + $y; + $ow = ($obj['width'] ?? 0) * ($obj['scaleX'] ?? 1) * $sx; + $oh = ($obj['height'] ?? 0) * ($obj['scaleY'] ?? 1) * $sy; + + switch ($type) { + case 'rect': + $pdf->Rect($ox, $oy, $ow, $oh, 'D'); + break; + case 'circle': + case 'ellipse': + $rx = ($obj['rx'] ?? ($ow / 2)) * $sx; + $ry = ($obj['ry'] ?? ($oh / 2)) * $sy; + $pdf->Ellipse($ox + $rx, $oy + $ry, $rx, $ry, 0, 0, 360, 'D'); + break; + case 'line': + $x1 = ($obj['x1'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx; + $y1 = ($obj['y1'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy; + $x2 = ($obj['x2'] ?? 0) * $sx + $x + ($obj['left'] ?? 0) * $sx; + $y2 = ($obj['y2'] ?? 0) * $sy + $y + ($obj['top'] ?? 0) * $sy; + $pdf->Line($x1, $y1, $x2, $y2); + break; + case 'path': + if (!empty($obj['path']) && is_array($obj['path'])) { + $prev = null; + foreach ($obj['path'] as $seg) { + if (!is_array($seg) || count($seg) < 3) continue; + $cmd = $seg[0]; + if ($cmd === 'M') { + $prev = array($seg[1] * $sx + $x, $seg[2] * $sy + $y); + } elseif (($cmd === 'L' || $cmd === 'Q') && $prev) { + $px = $seg[1] * $sx + $x; + $py = $seg[2] * $sy + $y; + $pdf->Line($prev[0], $prev[1], $px, $py); + $prev = array($px, $py); + } + } + } + break; + case 'group': + // Pfeil = Group(Line + Triangle) — als Pfeillinie rendern + if (!empty($obj['objects']) && is_array($obj['objects'])) { + $angle = ($obj['angle'] ?? 0) * M_PI / 180; + $startX = $ox; $startY = $oy; + // Länge aus den Children abschätzen + $len = 0; + foreach ($obj['objects'] as $child) { + if (($child['type'] ?? '') === 'line') { + $lx2 = ($child['x2'] ?? 0) - ($child['x1'] ?? 0); + $len = max($len, abs($lx2)); + } + } + $len *= $sx * ($obj['scaleX'] ?? 1); + $endX = $startX + cos($angle) * $len; + $endY = $startY + sin($angle) * $len; + $pdf->Line($startX, $startY, $endX, $endY); + // Pfeilspitze + $headLen = max(2, $sw * 4); + $a1 = $angle + M_PI - 0.4; + $a2 = $angle + M_PI + 0.4; + $pdf->Line($endX, $endY, $endX + cos($a1) * $headLen, $endY + sin($a1) * $headLen); + $pdf->Line($endX, $endY, $endX + cos($a2) * $headLen, $endY + sin($a2) * $headLen); + } + break; + case 'i-text': + case 'text': + case 'textbox': + $fontsize = max(6, ($obj['fontSize'] ?? 16) * $sx * 2.83); + $pdf->SetFont('helvetica', '', $fontsize); + $pdf->SetTextColor($stroke[0], $stroke[1], $stroke[2]); + $pdf->Text($ox, $oy + $fontsize * 0.35, $obj['text'] ?? ''); + break; + } + } +} + +/** + * Rendert ein ODT-Template als PDF (Deckblatt). + * Nutzt Dolibarrs odtphp + LibreOffice headless für die Konvertierung. + * + * @return string|null Pfad zum erzeugten PDF oder null + */ +function bericht_render_cover_for_preview($template_path, $bericht, $parent, $tempdir) +{ + return bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir); +} + +function bericht_render_cover_internal($template_path, $bericht, $parent, $tempdir) +{ + global $user, $langs; + + $odt_loader = DOL_DOCUMENT_ROOT.'/includes/odtphp/odf.php'; + if (!file_exists($odt_loader)) return null; + require_once $odt_loader; + + try { + $odf = new Odf($template_path, array('PATH_TO_TMP' => $tempdir)); + $vars = array( + 'auftragsnummer' => $bericht->auftragsnummer ?: '', + 'angebotsnummer' => $parent->array_options['options_angebotsnummer'] ?? '', + 'rechnungsnummer' => $parent->ref ?? '', + 'kunde_name' => $parent->thirdparty->name ?? '', + 'kunde_adresse' => trim(($parent->thirdparty->address ?? '')."\n".($parent->thirdparty->zip ?? '').' '.($parent->thirdparty->town ?? '')), + 'datum' => dol_print_date(dol_now(), 'day'), + 'beschreibung' => $parent->array_options['options_beschreibung'] ?? '', + 'hinweis' => $parent->array_options['options_hinweis'] ?? '', + 'bericht_titel' => $bericht->titel ?? '', + 'ersteller' => $user->getFullName($langs ?? null) ?: $user->login, + ); + foreach ($vars as $k => $v) { + try { $odf->setVars($k, $v, true, 'UTF-8'); } catch (Throwable $e) {} + } + $odt_out = $tempdir.'/cover_'.$bericht->id.'_'.uniqid().'.odt'; + $odf->saveToDisk($odt_out); + + $lobin = getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice'); + $cmd = escapeshellcmd($lobin) + .' --headless --convert-to pdf --outdir '.escapeshellarg($tempdir).' '.escapeshellarg($odt_out).' 2>&1'; + @shell_exec($cmd); + + $pdf_out = preg_replace('/\.odt$/i', '.pdf', $odt_out); + return file_exists($pdf_out) ? $pdf_out : null; + } catch (Throwable $e) { + return null; + } +}