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
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:
parent
a7a533f3b8
commit
a7bf3929a4
9 changed files with 785 additions and 171 deletions
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal 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"
|
||||
46
ajax/delete_attachment.php
Normal file
46
ajax/delete_attachment.php
Normal 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));
|
||||
|
|
@ -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
130
ajax/preview_pdf.php
Normal 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;
|
||||
119
bericht_card.php
119
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 '<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>';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
72
js/editor.js
72
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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue