feat: Phase 1.6 + 1.1 + 1.2 — verknüpfte Sicht, PDF-Vorschau, Anhänge löschen
All checks were successful
Deploy bericht / deploy (push) Successful in 1s

Phase 1.6 Verknüpfte Sicht Auftrag↔Rechnung:
- Bericht::fetchLinkedForElement liest llx_element_element-Verknüpfungen
- linkToElement/unlinkFromElement n:m-API
- Bericht-Übersicht zeigt drei Sektionen: direkt zugeordnet,
  zusätzlich verknüpft, aus verknüpften Aufträgen (read-only)
- 'Übernehmen'-Button erstellt llx_element_element-Eintrag
- 'Lösen'-Button entfernt Verknüpfung
- generate_pdf legt das fertige PDF auch unter den verknüpften
  Elementen ab + ECM-Eintrag

Phase 1.1 Live-PDF-Vorschau:
- Neuer Endpoint ajax/preview_pdf.php — wie generate_pdf, aber:
  schreibt nicht in ECM, ändert nicht den Status, streamt direkt
- 👁️ Vorschau-Button im Editor öffnet Modal mit iframe (PDF.js
  Viewer des Browsers)
- bericht_burn_annotations und bericht_render_cover_internal in
  lib/bericht.lib.php verschoben (gemeinsam genutzt)
- ESC-Key + Backdrop-Click schließen das Modal

Phase 1.2 Anhänge löschen:
- Neuer Endpoint ajax/delete_attachment.php mit Path-Whitelist
  (nur facture/, commande/, propal/), löscht Datei + thumbs +
  llx_ecm_files-Eintrag
- 🗑️-Button in jeder Anhang-Zeile, Confirm-Dialog mit
  Quell-Auftrag/Rechnung im Text
- Inline-Remove ohne Page-Reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[deploy]
This commit is contained in:
Eduard Wisch 2026-04-08 22:13:46 +02:00
parent a7a533f3b8
commit a7bf3929a4
9 changed files with 785 additions and 171 deletions

129
CLAUDE.md Normal file
View file

@ -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/<modul>` 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"

View file

@ -0,0 +1,46 @@
<?php
/* Löscht eine Datei aus dem Anhang-Verzeichnis eines Parent-Objekts.
* Sicherheits-Whitelist: nur Pfade unter facture/, commande/, propal/.
* Bereinigt zugehörigen llx_ecm_files-Eintrag.
*
* POST: relpath, token
*/
require_once __DIR__.'/_inc.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
global $db, $user;
if (!$user->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));

View file

@ -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);
}

130
ajax/preview_pdf.php Normal file
View file

@ -0,0 +1,130 @@
<?php
/* Live-PDF-Vorschau eines Berichts.
* Funktioniert wie generate_pdf, aber:
* - schreibt nach DOL_DATA_ROOT/bericht/temp/<id>/preview_<rand>.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;

View file

@ -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 '<br>';
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 '<div class="bericht-overview">';
@ -133,27 +178,41 @@ if (!$bericht) {
}
print '</div>';
if (empty($list)) {
print '<div class="opacitymedium" style="padding:20px;">'.$langs->trans("BerichtNoReports").'</div>';
} else {
/* ----- Helper: Bericht-Tabelle rendern ----- */
$renderBerichtTable = function ($title, $items, $mode = 'direct') use ($langs, $user, $element, $parent) {
if (empty($items)) return;
print '<h4 style="margin-top:16px;margin-bottom:6px;opacity:0.8;">'.$title.' ('.count($items).')</h4>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Ref").'</th>';
print '<th>'.$langs->trans("BerichtTitle").'</th>';
if ($mode === 'from_order') print '<th>Aus Auftrag</th>';
print '<th>'.$langs->trans("BerichtCreatedAt").'</th>';
print '<th>'.$langs->trans("BerichtStatus").'</th>';
print '<th class="right">'.$langs->trans("Action").'</th>';
print '</tr>';
foreach ($list as $b) {
foreach ($items as $b) {
$url = $_SERVER['PHP_SELF'].'?berichtid='.$b->id;
print '<tr class="oddeven">';
print '<td><a href="'.$url.'">'.dol_escape_htmltag($b->ref).'</a></td>';
print '<td>'.dol_escape_htmltag($b->titel).'</td>';
if ($mode === 'from_order') {
print '<td>'.dol_escape_htmltag($b->_source_order_ref ?? '').'</td>';
}
print '<td>'.dol_print_date($b->datec, 'dayhour').'</td>';
print '<td>'.$b->getLibStatut().'</td>';
print '<td class="right">';
print '<a href="'.$url.'" class="button-small">'.$langs->trans("Open").'</a> ';
if ($user->hasRight('bericht', 'delete')) {
if ($mode === 'from_order' && $user->hasRight('bericht', 'write')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=link&berichtid='.$b->id.'&id='.$parent->id.'&element='.$element.'&token='.newToken().'" '
.'class="button-small" title="Diesen Bericht zusätzlich der aktuellen Karte zuordnen">→ Übernehmen</a> ';
}
if ($mode === 'linked' && $user->hasRight('bericht', 'write')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=unlink&berichtid='.$b->id.'&id='.$parent->id.'&element='.$element.'&token='.newToken().'" '
.'class="button-small" title="Verknüpfung lösen">⊗ Lösen</a> ';
}
if ($mode !== 'from_order' && $user->hasRight('bericht', 'delete')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&berichtid='.$b->id.'&token='.newToken().'" '
.'onclick="return confirm(\''.dol_escape_js($langs->trans("BerichtConfirmDelete")).'\')" '
.'class="button-small button-delete">'.$langs->trans("Delete").'</a>';
@ -162,6 +221,20 @@ if (!$bericht) {
print '</tr>';
}
print '</table>';
};
if (empty($list_direct) && empty($list_linked) && empty($list_from_orders)) {
print '<div class="opacitymedium" style="padding:20px;">'.$langs->trans("BerichtNoReports").'</div>';
} 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 '</div>';
@ -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 '<div class="bericht-att-group"><div class="bericht-att-group-title">'.dol_escape_htmltag($ref).'</div>';
foreach ($files as $f) {
$icon = (strpos($f['mime'], 'image') === 0) ? '🖼' : ((strpos($f['mime'], 'pdf') !== false) ? '📄' : '📎');
print '<label class="bericht-att-item">';
print '<input type="checkbox" class="att-check" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-mime="'.dol_escape_htmltag($f['mime']).'">';
print '<div class="bericht-att-item">';
print '<input type="checkbox" class="att-check" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-mime="'.dol_escape_htmltag($f['mime']).'" title="Auswählen">';
print '<span class="att-icon">'.$icon.'</span>';
print '<span class="att-name">'.dol_escape_htmltag($f['filename']).'</span>';
print '<span class="att-name" title="'.dol_escape_htmltag($f['filename']).'">'.dol_escape_htmltag($f['filename']).'</span>';
print '<span class="att-size opacitymedium small">'.dol_print_size($f['size']).'</span>';
print '</label>';
if ($user->hasRight('bericht', 'write')) {
print '<button type="button" class="att-delete" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-source-ref="'.dol_escape_htmltag($f['source_ref']).'" title="Diese Datei aus dem Anhang löschen">🗑️</button>';
}
print '</div>';
}
print '</div>';
}
@ -339,8 +417,9 @@ if (!$bericht) {
// Footer-Aktionen
print '<div class="bericht-actions">';
print '<button type="button" id="btn-save-draft" class="butAction">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
print '<button type="button" id="btn-finalize" class="butActionConfirm">📑 '.$langs->trans("BerichtFinalize").'</button>';
print '<button type="button" id="btn-save-draft" class="butAction" title="Aktuellen Stand als Entwurf speichern">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
print '<button type="button" id="btn-preview" class="butAction" title="PDF-Vorschau ansehen ohne zu finalisieren">👁️ Vorschau</button>';
print '<button type="button" id="btn-finalize" class="butActionConfirm" title="PDF erzeugen und unter Verknüpfte Dokumente ablegen">📑 '.$langs->trans("BerichtFinalize").'</button>';
if ($user->hasRight('bericht', 'delete')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&berichtid='.$bericht->id.'&token='.newToken().'" '
.'onclick="return confirm(\''.dol_escape_js($langs->trans("BerichtConfirmDelete")).'\')" '
@ -350,6 +429,20 @@ if (!$bericht) {
print '</div>'; // .bericht-editor
// PDF-Vorschau-Modal
print '<div id="bericht-preview-modal" class="bericht-modal" style="display:none;">';
print ' <div class="bericht-modal-backdrop"></div>';
print ' <div class="bericht-modal-content">';
print ' <div class="bericht-modal-header">';
print ' <h3>📑 PDF-Vorschau</h3>';
print ' <button type="button" id="bericht-modal-close" title="Schließen">✕</button>';
print ' </div>';
print ' <div class="bericht-modal-body">';
print ' <iframe id="bericht-preview-iframe" src="about:blank"></iframe>';
print ' </div>';
print ' </div>';
print '</div>';
// PDF.js + Fabric.js (lokal)
print '<script src="'.dol_buildpath('/bericht/js/lib/pdf.min.js', 1).'"></script>';
print '<script src="'.dol_buildpath('/bericht/js/lib/fabric.min.js', 1).'"></script>';

View file

@ -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;

View file

@ -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;
}

View file

@ -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,7 +607,7 @@
/* ---------- Anhänge & Thumbs ---------- */
function bindAttachments() {
const btn = document.getElementById('btn-add-selected');
if (!btn) return;
if (btn) {
btn.addEventListener('click', async () => {
const checks = document.querySelectorAll('.att-check:checked');
for (const c of checks) {
@ -600,7 +619,31 @@
await fetch(cfg.urls.add_attachment, { method: 'POST', body: fd });
c.checked = false;
}
location.reload(); // einfacher als Thumbnail-Liste neu zu rendern
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('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');

View file

@ -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;
}
}