feat: Initiales Release Bericht-Modul v1.0.0 [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 1s

Dolibarr-Modul für Arbeitsberichte aus Rechnungs-Anhängen mit Browser-PDF-Editor.

- Reiter "Bericht" auf Rechnungen, Aufträgen und Angeboten
- Anhänge-Browser inkl. verknüpfter Objekte (Auftrag → Rechnung)
- PDF.js + Fabric.js Browser-Editor: Pfeile, Kreise, Rechtecke, Freihand, Text
- SortableJS Seiten-Verwaltung mit Drag&Drop
- ODT-Deckblatt mit Platzhaltern, Templates im Admin verwaltbar
- TCPDF + FPDI Finalisierung mit eingebrannten Annotationen
- ECM-Verknüpfung: PDF erscheint unter Verknüpfte Dokumente
- Auftragsnummer aus existierendem Extrafield options_auftragsnummer
- Mehrere Berichte pro Dokument
- Beim Aktivieren werden vorhandene Extrafields nicht überschrieben

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-04-08 15:18:59 +02:00
commit 923b50d65a
30 changed files with 2607 additions and 0 deletions

View file

@ -0,0 +1,41 @@
name: Deploy bericht
on:
push:
tags:
- 'v*'
branches:
- main
jobs:
deploy:
runs-on: docker
if: startsWith(github.ref, 'refs/tags/v') || contains(github.event.head_commit.message, '[deploy]')
steps:
- name: Checkout
run: |
git clone --depth 1 --branch "${GITHUB_REF_NAME}" \
"https://token:${{ secrets.GIT_TOKEN }}@git.data-it-solution.de/${GITHUB_REPOSITORY}.git" .
- name: Deploy nach Dolibarr
run: |
DEPLOY_PATH="/mnt/appdata/firma/dolibarr-202509/modules/bericht"
REF="${GITHUB_REF#refs/*/}"
echo "Deploye ${REF} nach ${DEPLOY_PATH} ..."
if [ -d "$DEPLOY_PATH" ]; then
find "$DEPLOY_PATH" -mindepth 1 -not -path '*/.git/*' -not -name '.git' -delete 2>/dev/null || true
else
mkdir -p "$DEPLOY_PATH"
fi
rsync -a \
--exclude='.git' \
--exclude='.forgejo' \
--exclude='.gitignore' \
--exclude='CLAUDE.md' \
--exclude='test/' \
./ "$DEPLOY_PATH/"
echo "Deployment erfolgreich: ${REF} -> ${DEPLOY_PATH}"

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.DS_Store
*.swp
*.bak
.vscode/
.idea/
*.log
documents/
work/
temp/

21
ChangeLog.md Normal file
View file

@ -0,0 +1,21 @@
# Changelog
## 1.0.0 — 2026-04-08
Initiales Release.
- Modul-Scaffold mit Reiter „Bericht" auf Rechnungen, Aufträgen und Angeboten
- CRUD für `Bericht` und `BerichtPage` (Tabellen `llx_bericht`, `llx_bericht_page`)
- Browser-Editor mit PDF.js + Fabric.js: Pfeile, Kreise, Rechtecke, Freihand, Text, Undo/Redo
- Anhänge-Browser zeigt eigene Anhänge + Anhänge verknüpfter Objekte
- Datei-Upload direkt in den Bericht
- Seiten-Verwaltung mit Drag&Drop (SortableJS), Löschen, Drehen
- Notizen pro Seite (werden im PDF gedruckt)
- Admin-Setup mit ODT-Template-Verwaltung (Upload, Löschen, Standard wählen)
- Platzhalter-System für ODT-Templates (`{auftragsnummer}`, `{kunde_name}`, …)
- PDF-Finalisierung mit TCPDF + FPDI, ODT→PDF Konvertierung des Deckblatts via LibreOffice
- Annotationen werden beim Export ins PDF eingebrannt
- Auftragsnummer wird automatisch aus dem vorhandenen Extrafield `options_auftragsnummer` geholt
- Beim Aktivieren werden fehlende Extrafields auf `llx_facture_extrafields` angelegt, vorhandene NICHT überschrieben
- Mehrere Berichte pro Dokument möglich
- Forgejo-Workflow für Deploy nach Dolibarr (Tag `[deploy]`)

95
README.md Normal file
View file

@ -0,0 +1,95 @@
# Bericht — Arbeitsberichte für Dolibarr
Erstellt aus den Anhängen einer Rechnung (oder eines Auftrags / Angebots) einen Arbeitsbericht als PDF.
Bilder und PDFs lassen sich im Browser annotieren (Pfeile, Kreise, Rechtecke, Text, Freihand) — der fertige Bericht wird unter *Verknüpfte Dokumente* der Rechnung abgelegt.
## Funktionen
- **Reiter „Bericht"** auf Rechnungen, Aufträgen und Angeboten (jeweils per Konstante deaktivierbar)
- **Anhänge-Browser** zeigt alle Dateien des aktuellen Dokuments **und** der direkt verknüpften Objekte (z. B. der Auftrag zur Rechnung)
- **Auswahl per Checkbox** — markierte Dateien werden als Seiten in den Bericht übernommen
- **Browser-Editor** mit PDF.js + Fabric.js: Pfeile, Kreise, Rechtecke, Freihand, Text, Farbe, Strichstärke, Undo/Redo
- **Seiten-Verwaltung** per Drag&Drop (SortableJS): umordnen, löschen, drehen, neue Seiten hochladen
- **Notizen pro Seite** — werden im finalen PDF unten auf der Seite gedruckt
- **Deckblatt aus ODT-Vorlage** mit Platzhaltern (`{auftragsnummer}`, `{kunde_name}`, `{datum}`, …)
- **ODT-Templates** im Admin-Bereich verwaltbar (mehrere Vorlagen, Standard wählbar)
- **Auftragsnummer** wird automatisch aus dem Extrafield `options_auftragsnummer` der Rechnung gezogen
- **Mehrere Berichte pro Dokument** möglich
- Berichte als **Entwurf** speichern (jederzeit wieder editierbar) oder **finalisieren** (PDF erzeugen)
## Voraussetzungen
- Dolibarr ≥ 19.0
- PHP ≥ 7.4
- TCPDF (in Dolibarr enthalten)
- **FPDI** (für PDF-Anhänge in den Bericht zu mergen) — empfohlen, optional
- **LibreOffice headless** (für ODT→PDF Konvertierung der Deckblätter)
- Optional: `pdfinfo` oder `imagick` für PDF-Seitenanzahl-Erkennung
## Installation
1. Modul-Verzeichnis nach `dolibarr/htdocs/custom/bericht/` (oder per Symlink aus dem Module-Mount-Pfad) kopieren
2. In Dolibarr unter **Konfiguration → Module/Anwendungen** das Modul **Bericht** aktivieren
3. Beim Aktivieren werden die SQL-Tabellen `llx_bericht` und `llx_bericht_page` angelegt
4. Vorhandene Extrafields auf `llx_facture_extrafields` (`auftragsnummer`, `angebotsnummer`, …) werden erkannt und nicht überschrieben — fehlende werden angelegt
5. Im Admin-Bereich (`/bericht/admin/setup.php`) die ODT-Templates hochladen und Standard-Template setzen
## Verwendung
1. Eine Rechnung öffnen (`/compta/facture/card.php?id=…`)
2. Reiter **Bericht** auswählen
3. **+ Neuer Bericht** klicken — die Auftragsnummer wird automatisch übernommen
4. Im Editor links die gewünschten Anhänge ankreuzen → **Auswahl in Bericht übernehmen**
5. Im mittleren Editor mit den Werkzeugen Pfeile, Texte etc. zeichnen
6. Seiten rechts per Drag&Drop sortieren, einzelne Seiten löschen, neue Dateien hochladen
7. **Bericht finalisieren** — PDF wird erzeugt, Deckblatt aus der ODT-Vorlage gerendert und unter den verknüpften Dokumenten der Rechnung abgelegt
## ODT-Template Platzhalter
| Platzhalter | Inhalt |
|---|---|
| `{auftragsnummer}` | Aus extrafield `options_auftragsnummer` der Rechnung |
| `{angebotsnummer}` | Aus extrafield `options_angebotsnummer` |
| `{rechnungsnummer}` | `ref` der Rechnung |
| `{kunde_name}` | Name des Kunden (Société) |
| `{kunde_adresse}` | Adresse des Kunden, mehrzeilig |
| `{datum}` | Heutiges Datum |
| `{beschreibung}` | extrafield `options_beschreibung` |
| `{hinweis}` | extrafield `options_hinweis` |
| `{bericht_titel}` | Titel des Berichts |
| `{ersteller}` | Login-Name des erstellenden Users |
## Architektur
```
bericht/
├── core/modules/modBericht.class.php Modul-Descriptor, Tabs, Extrafields-Init
├── class/bericht.class.php Bericht + BerichtPage CRUD
├── lib/bericht.lib.php Helper (Anhänge sammeln, Auftragsnr., Templates)
├── bericht_card.php Editor-Seite (Tab-Inhalt)
├── admin/setup.php Admin: ODT-Templates, Konstanten
├── ajax/ Endpoints (Token-geschützt)
│ ├── _inc.php Gemeinsamer Header
│ ├── add_attachment.php Anhang als Seite hinzufügen
│ ├── upload_extra.php Direkter Upload
│ ├── save_annotations.php Fabric-JSON speichern
│ ├── page_meta.php Annotationen + Notiz laden
│ ├── page_image.php Seitenbild/PDF ausliefern
│ ├── delete_page.php
│ ├── reorder_pages.php
│ └── generate_pdf.php Finalisierung: TCPDF + FPDI + ODT-Deckblatt
├── js/
│ ├── editor.js PDF.js + Fabric.js Integration
│ └── lib/ PDF.js, Fabric.js, SortableJS (lokal)
├── css/bericht.css
├── sql/
│ ├── llx_bericht.sql
│ ├── llx_bericht.key.sql
│ ├── llx_bericht_page.sql
│ └── llx_bericht_page.key.sql
└── langs/{de_DE,en_US}/bericht.lang
```
## Lizenz
GPL v3+

182
admin/setup.php Normal file
View file

@ -0,0 +1,182 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*
* Admin-Setup für das Bericht-Modul.
* Verwaltung von ODT-Templates + globalen Konstanten.
*/
$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/admin.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
require_once __DIR__.'/../lib/bericht.lib.php';
if (!$user->admin && !$user->hasRight('bericht', 'admin')) accessforbidden();
$langs->loadLangs(array("admin", "bericht@bericht"));
$action = GETPOST('action', 'alpha');
$templates_dir = DOL_DATA_ROOT.'/bericht/templates';
if (!is_dir($templates_dir)) {
dol_mkdir($templates_dir);
}
// --- Aktionen ---
if ($action === 'upload_template' && !empty($_FILES['template_file']['name'])) {
$name = dol_sanitizeFileName($_FILES['template_file']['name']);
if (!preg_match('/\.odt$/i', $name)) {
setEventMessages('Nur .odt-Dateien erlaubt', null, 'errors');
} else {
$dest = $templates_dir.'/'.$name;
if (move_uploaded_file($_FILES['template_file']['tmp_name'], $dest)) {
setEventMessages($langs->trans("BerichtSetupTemplateUploaded"), null, 'mesgs');
} else {
setEventMessages('Upload fehlgeschlagen', null, 'errors');
}
}
header("Location: ".$_SERVER['PHP_SELF']);
exit;
}
if ($action === 'delete_template') {
$name = dol_sanitizeFileName(GETPOST('name', 'alphanohtml'));
$path = $templates_dir.'/'.$name;
if ($name && file_exists($path)) {
@unlink($path);
setEventMessages($langs->trans("BerichtSetupTemplateDeleted"), null, 'mesgs');
}
header("Location: ".$_SERVER['PHP_SELF']);
exit;
}
if ($action === 'save_const') {
$consts = array(
'BERICHT_DEFAULT_TEMPLATE' => GETPOST('default_template', 'alphanohtml'),
'BERICHT_TAB_ON_INVOICE' => GETPOST('tab_invoice', 'int') ? '1' : '0',
'BERICHT_TAB_ON_ORDER' => GETPOST('tab_order', 'int') ? '1' : '0',
'BERICHT_TAB_ON_PROPAL' => GETPOST('tab_propal', 'int') ? '1' : '0',
'BERICHT_BURN_ANNOTATIONS' => GETPOST('burn', 'int') ? '1' : '0',
'BERICHT_LIBREOFFICE_BIN' => GETPOST('lobin', 'alphanohtml'),
);
foreach ($consts as $k => $v) {
dolibarr_set_const($db, $k, $v, 'chaine', 0, '', $conf->entity);
}
setEventMessages($langs->trans("SetupSaved"), null, 'mesgs');
header("Location: ".$_SERVER['PHP_SELF']);
exit;
}
// --- Anzeige ---
llxHeader('', $langs->trans("BerichtSetup"));
$linkback = '<a href="'.DOL_URL_ROOT.'/admin/modules.php?restore_lastsearch_values=1">'.$langs->trans("BackToModuleList").'</a>';
print load_fiche_titre($langs->trans("BerichtSetup"), $linkback, 'title_setup');
print '<span class="opacitymedium">'.$langs->trans("BerichtSetupDescription").'</span><br><br>';
// --- Templates ---
print '<div class="bericht-setup-section">';
print '<h3>'.$langs->trans("BerichtSetupTemplates").'</h3>';
print '<p class="opacitymedium">'.$langs->trans("BerichtSetupTemplatesDesc").'</p>';
$templates = bericht_list_templates();
if (empty($templates)) {
print '<div class="opacitymedium">'.$langs->trans("BerichtSetupNoTemplates").'</div>';
} else {
print '<table class="noborder centpercent">';
print '<tr class="liste_titre"><th>'.$langs->trans("File").'</th><th>'.$langs->trans("Size").'</th><th class="right">'.$langs->trans("Action").'</th></tr>';
foreach ($templates as $tpl) {
$path = $templates_dir.'/'.$tpl;
print '<tr class="oddeven">';
print '<td>📄 '.dol_escape_htmltag($tpl).'</td>';
print '<td>'.dol_print_size(filesize($path)).'</td>';
print '<td class="right">';
print '<a href="?action=delete_template&name='.urlencode($tpl).'&token='.newToken().'" '
.'onclick="return confirm(\'Vorlage löschen?\')" class="button-small">'.$langs->trans("Delete").'</a>';
print '</td>';
print '</tr>';
}
print '</table>';
}
print '<br>';
print '<form method="post" enctype="multipart/form-data">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="upload_template">';
print '<label class="butAction" for="template_file">📤 '.$langs->trans("BerichtSetupUploadTemplate").'</label>';
print '<input type="file" id="template_file" name="template_file" accept=".odt" onchange="this.form.submit()" style="display:none">';
print '</form>';
print '</div><br>';
// --- Platzhalter-Hinweis ---
print '<div class="bericht-setup-section">';
print '<h3>'.$langs->trans("BerichtPlaceholdersTitle").'</h3>';
print '<table class="noborder centpercent" style="max-width:700px">';
$placeholders = array(
'{auftragsnummer}' => 'Aus extrafield options_auftragsnummer der Rechnung',
'{angebotsnummer}' => 'Aus extrafield options_angebotsnummer',
'{rechnungsnummer}' => 'Rechnungsnummer (ref)',
'{kunde_name}' => 'Name des Kunden (Société)',
'{kunde_adresse}' => 'Adresse des Kunden (mehrzeilig)',
'{datum}' => 'Heutiges Datum',
'{beschreibung}' => 'Auftragsbeschreibung (extrafield)',
'{hinweis}' => 'Hinweis (extrafield)',
'{bericht_titel}' => 'Titel des Berichts',
'{ersteller}' => 'Login-Name des Erstellers',
);
foreach ($placeholders as $k => $desc) {
print '<tr class="oddeven"><td><code>'.$k.'</code></td><td>'.$desc.'</td></tr>';
}
print '</table>';
print '</div><br>';
// --- Konstanten ---
print '<div class="bericht-setup-section">';
print '<h3>'.$langs->trans("BerichtSetupOptions").'</h3>';
print '<form method="post">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="save_const">';
print '<table class="noborder centpercent">';
print '<tr class="oddeven"><td class="titlefield">'.$langs->trans("BerichtSetupDefaultTemplate").'</td><td>';
print '<select name="default_template">';
print '<option value="">— '.$langs->trans("BerichtNoTemplate").' —</option>';
$current_default = getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', '');
foreach ($templates as $tpl) {
$sel = ($tpl === $current_default) ? ' selected' : '';
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).'</option>';
}
print '</select></td></tr>';
$cb = function ($name, $key, $default) {
$v = getDolGlobalString($key, $default) ? 'checked' : '';
return '<input type="checkbox" name="'.$name.'" value="1" '.$v.'>';
};
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabInvoice").'</td><td>'.$cb('tab_invoice', 'BERICHT_TAB_ON_INVOICE', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabOrder").'</td><td>'.$cb('tab_order', 'BERICHT_TAB_ON_ORDER', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupTabPropal").'</td><td>'.$cb('tab_propal', 'BERICHT_TAB_ON_PROPAL', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupBurnAnnotations").'</td><td>'.$cb('burn', 'BERICHT_BURN_ANNOTATIONS', '1').'</td></tr>';
print '<tr class="oddeven"><td>'.$langs->trans("BerichtSetupLibreOfficeBin").'</td><td>';
print '<input type="text" name="lobin" value="'.dol_escape_htmltag(getDolGlobalString('BERICHT_LIBREOFFICE_BIN', '/usr/bin/libreoffice')).'" size="40">';
print '</td></tr>';
print '</table>';
print '<br><button type="submit" class="butAction">'.$langs->trans("Save").'</button>';
print '</form>';
print '</div>';
llxFooter();
$db->close();

47
ajax/_inc.php Normal file
View file

@ -0,0 +1,47 @@
<?php
/* Gemeinsamer Header für alle Bericht-Ajax-Endpoints.
* Lädt Dolibarr (symlink-sicher), validiert Token + Rechte.
*/
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 __DIR__.'/../class/bericht.class.php';
require_once __DIR__.'/../lib/bericht.lib.php';
header('Content-Type: application/json; charset=utf-8');
function bericht_ajax_fail($msg, $code = 400)
{
http_response_code($code);
echo json_encode(array('success' => false, 'error' => $msg));
exit;
}
function bericht_ajax_ok($data = array())
{
echo json_encode(array_merge(array('success' => true), $data));
exit;
}
// Token-Check
if (!isset($_REQUEST['token']) || $_REQUEST['token'] !== newToken() && $_REQUEST['token'] !== $_SESSION['token']) {
// Dolibarr-Standard erlaubt aktuellen Token; einfache Prüfung:
if (function_exists('verifCsrfToken')) {
// ok — main.inc.php hat schon geprüft
}
}
global $user;
if (!$user->hasRight('bericht', 'read')) {
bericht_ajax_fail('Permission denied', 403);
}

85
ajax/add_attachment.php Normal file
View file

@ -0,0 +1,85 @@
<?php
/* Fügt einen vorhandenen Anhang als neue Seite zum Bericht hinzu.
* POST: berichtid, relpath, mime, token
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$berichtid = (int) ($_POST['berichtid'] ?? 0);
$relpath = (string) ($_POST['relpath'] ?? '');
$mime = (string) ($_POST['mime'] ?? '');
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
$fullpath = bericht_resolve_data_path($relpath);
if (!$fullpath || !file_exists($fullpath)) bericht_ajax_fail('Datei nicht gefunden', 404);
// Source-Type bestimmen
if (strpos($mime, 'image') === 0) $type = 'image';
elseif (strpos($mime, 'pdf') !== false) $type = 'pdf';
else bericht_ajax_fail('Dateityp nicht unterstützt: '.$mime);
// Höchste page_order ermitteln
$res = $db->query("SELECT COALESCE(MAX(page_order),0) AS m FROM ".$db->prefix()."bericht_page WHERE fk_bericht = ".((int) $berichtid));
$next_order = ($res && ($o = $db->fetch_object($res))) ? ((int) $o->m) + 1 : 1;
$created = array();
if ($type === 'pdf') {
// Bei mehrseitigen PDFs: pro Seite einen Eintrag — Seitenanzahl ermitteln
$pagecount = bericht_pdf_pagecount($fullpath);
if ($pagecount < 1) $pagecount = 1;
for ($p = 1; $p <= $pagecount; $p++) {
$page = new BerichtPage($db);
$page->fk_bericht = $berichtid;
$page->page_order = $next_order++;
$page->source_type = 'pdf';
$page->source_path = $relpath;
$page->source_page = $p;
if ($page->create() > 0) $created[] = $page->id;
}
} else {
$page = new BerichtPage($db);
$page->fk_bericht = $berichtid;
$page->page_order = $next_order++;
$page->source_type = 'image';
$page->source_path = $relpath;
$page->source_page = null;
if ($page->create() > 0) $created[] = $page->id;
}
bericht_ajax_ok(array('created' => $created));
/**
* Liefert die Seitenanzahl eines PDFs (best-effort).
*/
function bericht_pdf_pagecount($path)
{
// Versuch 1: Imagick
if (class_exists('Imagick')) {
try {
$im = new Imagick();
$im->pingImage($path);
$n = $im->getNumberImages();
$im->clear();
return $n;
} catch (Throwable $e) {}
}
// Versuch 2: pdfinfo
$out = @shell_exec('pdfinfo '.escapeshellarg($path).' 2>/dev/null');
if ($out && preg_match('/^Pages:\s+(\d+)/m', $out, $m)) {
return (int) $m[1];
}
// Versuch 3: roher PDF-Count (sehr grob)
$content = @file_get_contents($path);
if ($content !== false) {
if (preg_match_all('/\/Type\s*\/Page[^s]/', $content, $m)) {
return count($m[0]);
}
}
return 1;
}

14
ajax/delete_page.php Normal file
View file

@ -0,0 +1,14 @@
<?php
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$pageid = (int) ($_POST['pageid'] ?? 0);
if (!$pageid) bericht_ajax_fail('pageid fehlt');
if (!$db->query("DELETE FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid))) {
bericht_ajax_fail($db->lasterror());
}
bericht_ajax_ok();

287
ajax/generate_pdf.php Normal file
View file

@ -0,0 +1,287 @@
<?php
/* Finalisiert einen Bericht: Deckblatt aus ODT rendern, Seiten + Annotationen mergen,
* finales PDF unter dem Parent-Ordner ablegen und in llx_ecm_files registrieren.
*
* POST: berichtid, token
*/
require_once __DIR__.'/_inc.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
global $db, $user, $conf;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$berichtid = (int) ($_POST['berichtid'] ?? 0);
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
$parent = bericht_fetch_parent($db, $bericht->element_type, $bericht->fk_element);
if (!$parent) bericht_ajax_fail('Parent nicht gefunden', 404);
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
if (empty($pages)) bericht_ajax_fail('Bericht enthält keine Seiten');
// TCPDF + FPDI laden
$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) bericht_ajax_fail('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; }
}
// FPDI ist optional — wenn fehlt, können wir keine bestehenden PDFs einbetten,
// aber Bilder + Annotationen funktionieren weiterhin.
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');
$pdf->SetAuthor($user->getFullName($langs));
$pdf->SetTitle($bericht->titel ?: $bericht->ref);
$pdf->SetMargins(10, 10, 10);
$pdf->SetAutoPageBreak(true, 10);
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
// --- Deckblatt aus ODT-Template ---
$tempdir = DOL_DATA_ROOT.'/bericht/temp/'.$berichtid;
if (!is_dir($tempdir)) dol_mkdir($tempdir);
if (!empty($bericht->template_odt)) {
$template_path = DOL_DATA_ROOT.'/bericht/templates/'.dol_sanitizeFileName($bericht->template_odt);
if (file_exists($template_path)) {
$cover_pdf = bericht_render_cover($template_path, $bericht, $parent, $tempdir);
if ($cover_pdf && file_exists($cover_pdf) && $fpdi_loaded) {
$cover_pages = $pdf->setSourceFile($cover_pdf);
for ($cp = 1; $cp <= $cover_pages; $cp++) {
$tpl = $pdf->importPage($cp);
$size = $pdf->getTemplateSize($tpl);
$pdf->AddPage($size['orientation'] ?? 'P', array($size['width'], $size['height']));
$pdf->useTemplate($tpl);
}
}
}
}
// --- Seiten ---
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)) {
// Bild als A4-Seite
$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);
// Annotationen drauf
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);
// Annotationen über volle Seitenfläche
bericht_burn_annotations($pdf, $page->fabric_json, 0, 0, $size['width'], $size['height']);
} catch (Throwable $e) {
// Seite überspringen
}
}
// Notiz unten
if (!empty($page->note)) {
$pdf->SetY(-20);
$pdf->SetFont('helvetica', 'I', 9);
$pdf->MultiCell(0, 5, $page->note, 0, 'L');
}
}
// --- Speichern ---
$dir_key = bericht_element_to_dir_key($bericht->element_type);
$target_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
if (!is_dir($target_dir)) dol_mkdir($target_dir);
$filename = 'Bericht_'.dol_sanitizeFileName($bericht->auftragsnummer ?: $bericht->ref).'_'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'.pdf';
$target_path = $target_dir.'/'.$filename;
$pdf->Output($target_path, 'F');
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
// Bericht-Status auf Final
$bericht->status = Bericht::STATUS_FINAL;
$bericht->final_pdf_path = str_replace(DOL_DATA_ROOT.'/', '', $target_path);
$bericht->update($user);
bericht_ajax_ok(array(
'filename' => $filename,
'path' => $bericht->final_pdf_path,
));
/**
* 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)));
}

38
ajax/page_image.php Normal file
View file

@ -0,0 +1,38 @@
<?php
/* Liefert das Bild/PDF einer Bericht-Seite zur Anzeige im Editor.
* GET: pageid
*
* Bei source_type=image: Original-Bild
* Bei source_type=pdf: PDF wird direkt ausgeliefert (PDF.js rendert clientseitig)
* Bei source_type=upload: ebenso, je nach Endung
*/
$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 __DIR__.'/../lib/bericht.lib.php';
if (!$user->hasRight('bericht', 'read')) accessforbidden();
$pageid = GETPOSTINT('pageid');
if (!$pageid) { http_response_code(400); exit; }
$res = $db->query("SELECT source_type, source_path FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res) { http_response_code(500); exit; }
$row = $db->fetch_object($res);
if (!$row) { http_response_code(404); exit; }
$full = bericht_resolve_data_path($row->source_path);
if (!$full || !file_exists($full)) { http_response_code(404); exit; }
$mime = dol_mimetype($full);
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($full));
header('Cache-Control: private, max-age=300');
readfile($full);

18
ajax/page_meta.php Normal file
View file

@ -0,0 +1,18 @@
<?php
/* GET: pageid → liefert fabric_json + note der Seite */
require_once __DIR__.'/_inc.php';
global $db;
$pageid = (int) ($_GET['pageid'] ?? 0);
if (!$pageid) bericht_ajax_fail('pageid fehlt');
$res = $db->query("SELECT fabric_json, note FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res) bericht_ajax_fail($db->lasterror());
$row = $db->fetch_object($res);
if (!$row) bericht_ajax_fail('Page nicht gefunden', 404);
bericht_ajax_ok(array(
'fabric_json' => $row->fabric_json,
'note' => $row->note,
));

23
ajax/reorder_pages.php Normal file
View file

@ -0,0 +1,23 @@
<?php
/* POST: order = JSON-Array von page_ids in neuer Reihenfolge, token */
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$order_raw = $_POST['order'] ?? '';
$ids = json_decode($order_raw, true);
if (!is_array($ids)) bericht_ajax_fail('Ungültige Reihenfolge');
$db->begin();
foreach ($ids as $pos => $pageid) {
$pageid = (int) $pageid;
if ($pageid <= 0) continue;
if (!$db->query("UPDATE ".$db->prefix()."bericht_page SET page_order = ".((int) ($pos + 1))." WHERE rowid = ".$pageid)) {
$db->rollback();
bericht_ajax_fail($db->lasterror());
}
}
$db->commit();
bericht_ajax_ok();

26
ajax/save_annotations.php Normal file
View file

@ -0,0 +1,26 @@
<?php
/* Speichert Fabric.js-JSON für eine Seite + ggf. Notiz.
* POST: pageid, fabric_json, note, token
*/
require_once __DIR__.'/_inc.php';
global $db, $user;
if (!$user->hasRight('bericht', 'write')) bericht_ajax_fail('Permission denied', 403);
$pageid = (int) ($_POST['pageid'] ?? 0);
if (!$pageid) bericht_ajax_fail('pageid fehlt');
$fabric = (string) ($_POST['fabric_json'] ?? '');
$note = (string) ($_POST['note'] ?? '');
// Page laden
$res = $db->query("SELECT rowid FROM ".$db->prefix()."bericht_page WHERE rowid = ".((int) $pageid));
if (!$res || !$db->fetch_object($res)) bericht_ajax_fail('Page nicht gefunden', 404);
$sql = "UPDATE ".$db->prefix()."bericht_page SET "
."fabric_json = ".($fabric !== '' ? "'".$db->escape($fabric)."'" : "NULL").","
."note = ".($note !== '' ? "'".$db->escape($note)."'" : "NULL")
." WHERE rowid = ".((int) $pageid);
if (!$db->query($sql)) bericht_ajax_fail('DB-Fehler: '.$db->lasterror());
bericht_ajax_ok();

60
ajax/upload_extra.php Normal file
View file

@ -0,0 +1,60 @@
<?php
/* Upload einer zusätzlichen Datei direkt in den Bericht.
* POST: berichtid, token, file (multipart)
*/
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);
$berichtid = (int) ($_POST['berichtid'] ?? 0);
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) bericht_ajax_fail('Bericht nicht gefunden', 404);
if (empty($_FILES['file']['tmp_name'])) bericht_ajax_fail('Keine Datei hochgeladen');
$origname = dol_sanitizeFileName($_FILES['file']['name']);
$ext = strtolower(pathinfo($origname, PATHINFO_EXTENSION));
$allowed = array('pdf', 'png', 'jpg', 'jpeg');
if (!in_array($ext, $allowed)) bericht_ajax_fail('Dateityp nicht erlaubt');
$workdir = DOL_DATA_ROOT.'/bericht/work/'.$berichtid;
if (!is_dir($workdir)) dol_mkdir($workdir);
$target = $workdir.'/'.dol_print_date(dol_now(), '%Y%m%d_%H%M%S').'_'.$origname;
if (!move_uploaded_file($_FILES['file']['tmp_name'], $target)) bericht_ajax_fail('Upload fehlgeschlagen');
$relpath = str_replace(DOL_DATA_ROOT.'/', '', $target);
// Zur Bericht-Page-Liste hinzufügen
$res = $db->query("SELECT COALESCE(MAX(page_order),0) AS m FROM ".$db->prefix()."bericht_page WHERE fk_bericht = ".((int) $berichtid));
$next_order = ($res && ($o = $db->fetch_object($res))) ? ((int) $o->m) + 1 : 1;
if ($ext === 'pdf') {
require_once __DIR__.'/add_attachment.php'; // bericht_pdf_pagecount
}
$created = array();
if ($ext === 'pdf') {
$pagecount = function_exists('bericht_pdf_pagecount') ? bericht_pdf_pagecount($target) : 1;
for ($p = 1; $p <= $pagecount; $p++) {
$page = new BerichtPage($db);
$page->fk_bericht = $berichtid;
$page->page_order = $next_order++;
$page->source_type = 'pdf';
$page->source_path = $relpath;
$page->source_page = $p;
if ($page->create() > 0) $created[] = $page->id;
}
} else {
$page = new BerichtPage($db);
$page->fk_bericht = $berichtid;
$page->page_order = $next_order++;
$page->source_type = 'upload';
$page->source_path = $relpath;
if ($page->create() > 0) $created[] = $page->id;
}
bericht_ajax_ok(array('created' => $created, 'relpath' => $relpath));

334
bericht_card.php Normal file
View file

@ -0,0 +1,334 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*
* Editor-Seite des Bericht-Moduls.
* Eingang über Tab "Bericht" auf Rechnung/Auftrag/Angebot.
*
* Aufruf:
* /bericht/bericht_card.php?id=<parent_id>&element=invoice|order|propal
* /bericht/bericht_card.php?berichtid=<bericht_id> (direkter Aufruf eines bestehenden Berichts)
*/
// Standard-Dolibarr-Include-Kaskade (symlink-sicher)
$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/functions2.lib.php';
require_once __DIR__.'/class/bericht.class.php';
require_once __DIR__.'/lib/bericht.lib.php';
if (!$user->hasRight('bericht', 'read')) accessforbidden();
$langs->loadLangs(array("bericht@bericht", "main", "other"));
$id = GETPOSTINT('id');
$berichtid = GETPOSTINT('berichtid');
$element = GETPOST('element', 'alpha');
$action = GETPOST('action', 'alpha');
// Bericht laden bzw. Parent ermitteln
$bericht = null;
$parent = null;
if ($berichtid > 0) {
$bericht = new Bericht($db);
if ($bericht->fetch($berichtid) <= 0) {
accessforbidden('Bericht nicht gefunden');
}
$element = $bericht->element_type;
$id = $bericht->fk_element;
}
$parent = bericht_fetch_parent($db, $element, $id);
if (!$parent) {
setEventMessages($langs->trans("BerichtErrorNoParent"), null, 'errors');
llxHeader('', $langs->trans("Bericht"));
print '<div class="error">'.$langs->trans("BerichtErrorNoParent").'</div>';
llxFooter();
exit;
}
$auftragsnummer = bericht_get_auftragsnummer($parent);
// Aktion: neuen Bericht anlegen
if ($action === 'create' && $user->hasRight('bericht', 'write')) {
$b = new Bericht($db);
$b->element_type = $element;
$b->fk_element = $parent->id;
$b->titel = GETPOST('titel', 'alphanohtml') ?: ('Bericht '.$auftragsnummer);
$b->auftragsnummer = $auftragsnummer;
$b->template_odt = getDolGlobalString('BERICHT_DEFAULT_TEMPLATE', '');
if ($b->create($user) > 0) {
header("Location: ".$_SERVER['PHP_SELF'].'?berichtid='.$b->id);
exit;
}
setEventMessages($b->error, $b->errors, 'errors');
}
// Aktion: Bericht löschen
if ($action === 'delete' && $berichtid > 0 && $user->hasRight('bericht', 'delete')) {
if ($bericht->delete($user) > 0) {
setEventMessages($langs->trans("BerichtDeleteSuccess"), null, 'mesgs');
// Zurück auf die Tab-Übersicht (gleiche URL ohne berichtid)
header("Location: ".$_SERVER['PHP_SELF'].'?id='.$parent->id.'&element='.$element);
exit;
}
setEventMessages($bericht->error, $bericht->errors, 'errors');
}
/*
* Anzeige
*/
$title = $langs->trans("Bericht").' — '.$parent->ref;
llxHeader('', $title, '', '', 0, 0, array(), array(), '', 'mod-bericht page-bericht-card');
// Header des Parent-Objekts (Standard-Dolibarr-Tabs für Rechnung/Auftrag/Angebot)
if ($element === 'invoice') {
require_once DOL_DOCUMENT_ROOT.'/core/lib/invoice.lib.php';
$head = facture_prepare_head($parent);
print dol_get_fiche_head($head, 'bericht', $langs->trans("Bill"), -1, 'bill');
} elseif ($element === 'order') {
require_once DOL_DOCUMENT_ROOT.'/core/lib/order.lib.php';
$head = commande_prepare_head($parent);
print dol_get_fiche_head($head, 'bericht', $langs->trans("CustomerOrder"), -1, 'order');
} elseif ($element === 'propal') {
require_once DOL_DOCUMENT_ROOT.'/core/lib/propal.lib.php';
$head = propal_prepare_head($parent);
print dol_get_fiche_head($head, 'bericht', $langs->trans("Proposal"), -1, 'propal');
}
// Banner mit Parent-Infos
$linkback = '<a href="javascript:history.back()">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($parent, 'ref', $linkback, 1, 'ref');
print dol_get_fiche_end();
print '<br>';
if (!$bericht) {
/*
* MODUS A: Übersicht Liste vorhandener Berichte + "Neu anlegen"
*/
$list = Bericht::fetchAllForElement($db, $element, $parent->id);
print '<div class="bericht-overview">';
print '<div class="bericht-overview-header">';
print '<h3>'.$langs->trans("Berichte").'</h3>';
if ($user->hasRight('bericht', 'write')) {
print '<form method="post" class="inline-block">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="create">';
print '<input type="hidden" name="id" value="'.$parent->id.'">';
print '<input type="hidden" name="element" value="'.$element.'">';
print '<button type="submit" class="butAction">+ '.$langs->trans("BerichtNew").'</button>';
print '</form>';
}
print '</div>';
if (empty($list)) {
print '<div class="opacitymedium" style="padding:20px;">'.$langs->trans("BerichtNoReports").'</div>';
} else {
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Ref").'</th>';
print '<th>'.$langs->trans("BerichtTitle").'</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) {
$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>';
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')) {
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>';
}
print '</td>';
print '</tr>';
}
print '</table>';
}
print '</div>';
} else {
/*
* MODUS B: Editor bestehender Bericht wird bearbeitet
*/
$pages = BerichtPage::fetchAllForBericht($db, $bericht->id);
$attachments = bericht_collect_attachments($db, $parent, $element);
$templates = bericht_list_templates();
// Daten für JS bereitstellen
$editor_config = array(
'berichtid' => (int) $bericht->id,
'token' => newToken(),
'urls' => array(
'save_annotations' => dol_buildpath('/bericht/ajax/save_annotations.php', 1),
'upload_extra' => dol_buildpath('/bericht/ajax/upload_extra.php', 1),
'add_attachment' => dol_buildpath('/bericht/ajax/add_attachment.php', 1),
'delete_page' => dol_buildpath('/bericht/ajax/delete_page.php', 1),
'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),
),
'lang' => array(
'undo' => $langs->trans("BerichtUndo"),
'redo' => $langs->trans("BerichtRedo"),
'select' => $langs->trans("BerichtToolSelect"),
'draw' => $langs->trans("BerichtToolDraw"),
'rect' => $langs->trans("BerichtToolRect"),
'circle' => $langs->trans("BerichtToolCircle"),
'arrow' => $langs->trans("BerichtToolArrow"),
'text' => $langs->trans("BerichtToolText"),
'delete' => $langs->trans("BerichtToolDelete"),
'note_hint' => $langs->trans("BerichtNoteHint"),
'confirm_del' => $langs->trans("BerichtConfirmDelete"),
),
);
print '<div class="bericht-editor">';
// Kopfzeile mit Bericht-Meta
print '<div class="bericht-meta">';
print '<form method="post" id="bericht-meta-form">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="berichtid" value="'.$bericht->id.'">';
print '<table class="border centpercent"><tr>';
print '<td class="titlefield">'.$langs->trans("Ref").'</td><td>'.dol_escape_htmltag($bericht->ref).'</td>';
print '<td class="titlefield">'.$langs->trans("BerichtAuftragsnummer").'</td><td>'.dol_escape_htmltag($bericht->auftragsnummer).'</td>';
print '</tr><tr>';
print '<td>'.$langs->trans("BerichtTitle").'</td><td><input type="text" name="titel" value="'.dol_escape_htmltag($bericht->titel).'" size="40"></td>';
print '<td>'.$langs->trans("BerichtTemplate").'</td><td><select name="template_odt">';
print '<option value="">— '.$langs->trans("BerichtNoTemplate").' —</option>';
foreach ($templates as $tpl) {
$sel = ($tpl === $bericht->template_odt) ? ' selected' : '';
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).'</option>';
}
print '</select></td>';
print '</tr></table>';
print '</form>';
print '</div>';
// Hauptlayout: links Anhänge, Mitte Editor, rechts Seiten
print '<div class="bericht-layout">';
// LINKS: Anhänge-Browser
print '<aside class="bericht-attachments">';
print '<h4>'.$langs->trans("BerichtAvailableAttachments").'</h4>';
$by_source = array();
foreach ($attachments as $a) {
$key = $a['source'].':'.$a['source_ref'];
$by_source[$key][] = $a;
}
if (empty($by_source)) {
print '<div class="opacitymedium small">'.$langs->trans("None").'</div>';
} else {
foreach ($by_source as $key => $files) {
list($src, $ref) = explode(':', $key, 2);
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 '<span class="att-icon">'.$icon.'</span>';
print '<span class="att-name">'.dol_escape_htmltag($f['filename']).'</span>';
print '<span class="att-size opacitymedium small">'.dol_print_size($f['size']).'</span>';
print '</label>';
}
print '</div>';
}
print '<button type="button" id="btn-add-selected" class="butAction">'.$langs->trans("BerichtAddSelectedToReport").'</button>';
}
print '<hr>';
print '<div class="bericht-upload">';
print '<label for="bericht-extra-upload" class="butAction">📤 '.$langs->trans("BerichtUploadExtra").'</label>';
print '<input type="file" id="bericht-extra-upload" style="display:none" accept=".pdf,.png,.jpg,.jpeg">';
print '</div>';
print '</aside>';
// MITTE: PDF.js + Fabric.js Editor
print '<main class="bericht-canvas-area">';
print '<div class="bericht-toolbar">';
print '<button type="button" class="tool-btn" data-tool="select" title="'.$langs->trans("BerichtToolSelect").'">↖</button>';
print '<button type="button" class="tool-btn" data-tool="draw" title="'.$langs->trans("BerichtToolDraw").'">✏️</button>';
print '<button type="button" class="tool-btn" data-tool="rect" title="'.$langs->trans("BerichtToolRect").'">▭</button>';
print '<button type="button" class="tool-btn" data-tool="circle" title="'.$langs->trans("BerichtToolCircle").'">○</button>';
print '<button type="button" class="tool-btn" data-tool="arrow" title="'.$langs->trans("BerichtToolArrow").'">↗</button>';
print '<button type="button" class="tool-btn" data-tool="text" title="'.$langs->trans("BerichtToolText").'">T</button>';
print '<span class="sep"></span>';
print '<button type="button" id="btn-undo" title="'.$langs->trans("BerichtUndo").'">↶</button>';
print '<button type="button" id="btn-redo" title="'.$langs->trans("BerichtRedo").'">↷</button>';
print '<span class="sep"></span>';
print '<label>'.$langs->trans("BerichtColor").': <input type="color" id="tool-color" value="#ff0000"></label>';
print '<label>'.$langs->trans("BerichtStrokeWidth").': <input type="range" id="tool-stroke" min="1" max="20" value="3"></label>';
print '<span class="sep"></span>';
print '<button type="button" id="btn-rotate-left" title="'.$langs->trans("BerichtRotateLeft").'">⟲</button>';
print '<button type="button" id="btn-rotate-right" title="'.$langs->trans("BerichtRotateRight").'">⟳</button>';
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").'">🗑️</button>';
print '</div>';
print '<div class="bericht-canvas-wrap">';
print '<canvas id="pdf-canvas"></canvas>';
print '<canvas id="fabric-canvas" class="fabric-overlay"></canvas>';
print '</div>';
print '<div class="bericht-page-note">';
print '<label>'.$langs->trans("BerichtNoteHint").'</label>';
print '<textarea id="page-note" rows="2"></textarea>';
print '</div>';
print '</main>';
// RECHTS: Seiten-Thumbnails
print '<aside class="bericht-pages">';
print '<h4>'.$langs->trans("BerichtPages").' (<span id="page-count">'.count($pages).'</span>)</h4>';
print '<div id="bericht-page-list" class="page-list">';
foreach ($pages as $idx => $p) {
print '<div class="page-thumb" data-pageid="'.$p->id.'" data-order="'.$p->page_order.'">';
print '<div class="page-thumb-inner"><span class="page-num">'.($idx + 1).'</span></div>';
print '<div class="page-thumb-actions">';
print '<button type="button" class="thumb-del" title="'.$langs->trans("BerichtDeletePage").'">🗑️</button>';
print '</div>';
print '</div>';
}
print '</div>';
print '</aside>';
print '</div>'; // .bericht-layout
// 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>';
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")).'\')" '
.'class="butActionDelete">🗑️ '.$langs->trans("Delete").'</a>';
}
print '</div>';
print '</div>'; // .bericht-editor
// 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>';
print '<script src="'.dol_buildpath('/bericht/js/lib/Sortable.min.js', 1).'"></script>';
print '<script>window.BERICHT_CONFIG = '.json_encode($editor_config).';</script>';
print '<script src="'.dol_buildpath('/bericht/js/editor.js', 1).'"></script>';
}
llxFooter();
$db->close();

267
class/bericht.class.php Normal file
View file

@ -0,0 +1,267 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
/**
* CRUD-Klasse für einen Bericht.
* Eine Bericht-Instanz gehört zu einem Parent-Objekt (invoice|order|propal)
* und enthält n Seiten (BerichtPage).
*/
class Bericht extends CommonObject
{
public $element = 'bericht';
public $table_element = 'bericht';
public $picto = 'fa-file-pdf';
public $id;
public $entity;
public $ref;
public $titel;
public $element_type; // invoice | order | propal
public $fk_element;
public $auftragsnummer;
public $template_odt;
public $status; // 0 = Entwurf, 1 = Final
public $final_pdf_path;
public $fk_user_creat;
public $fk_user_modif;
public $datec;
public $tms;
public $note;
const STATUS_DRAFT = 0;
const STATUS_FINAL = 1;
public function __construct(DoliDB $db)
{
$this->db = $db;
}
public function create(User $user, $notrigger = 0)
{
global $conf;
$this->entity = $conf->entity;
$this->datec = dol_now();
$this->status = self::STATUS_DRAFT;
$this->fk_user_creat = $user->id;
if (empty($this->ref)) {
$this->ref = 'BR'.dol_print_date($this->datec, '%y%m%d-%H%i%s');
}
$sql = "INSERT INTO ".$this->db->prefix()."bericht ("
."entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt, status, fk_user_creat, datec, note"
.") VALUES ("
.((int) $this->entity).","
."'".$this->db->escape($this->ref)."',"
.($this->titel ? "'".$this->db->escape($this->titel)."'" : "NULL").","
."'".$this->db->escape($this->element_type)."',"
.((int) $this->fk_element).","
.($this->auftragsnummer ? "'".$this->db->escape($this->auftragsnummer)."'" : "NULL").","
.($this->template_odt ? "'".$this->db->escape($this->template_odt)."'" : "NULL").","
.((int) $this->status).","
.((int) $this->fk_user_creat).","
."'".$this->db->idate($this->datec)."',"
.($this->note ? "'".$this->db->escape($this->note)."'" : "NULL")
.")";
$this->db->begin();
$res = $this->db->query($sql);
if (!$res) {
$this->error = $this->db->lasterror();
$this->db->rollback();
return -1;
}
$this->id = $this->db->last_insert_id($this->db->prefix()."bericht");
$this->db->commit();
return $this->id;
}
public function fetch($id)
{
$sql = "SELECT rowid, entity, ref, titel, element_type, fk_element, auftragsnummer, template_odt,"
." status, final_pdf_path, fk_user_creat, fk_user_modif, datec, tms, note"
." FROM ".$this->db->prefix()."bericht WHERE rowid = ".((int) $id);
$res = $this->db->query($sql);
if (!$res) {
$this->error = $this->db->lasterror();
return -1;
}
if ($this->db->num_rows($res) == 0) {
return 0;
}
$obj = $this->db->fetch_object($res);
$this->id = $obj->rowid;
$this->entity = $obj->entity;
$this->ref = $obj->ref;
$this->titel = $obj->titel;
$this->element_type = $obj->element_type;
$this->fk_element = $obj->fk_element;
$this->auftragsnummer = $obj->auftragsnummer;
$this->template_odt = $obj->template_odt;
$this->status = (int) $obj->status;
$this->final_pdf_path = $obj->final_pdf_path;
$this->fk_user_creat = $obj->fk_user_creat;
$this->fk_user_modif = $obj->fk_user_modif;
$this->datec = $this->db->jdate($obj->datec);
$this->tms = $this->db->jdate($obj->tms);
$this->note = $obj->note;
return 1;
}
public function update(User $user, $notrigger = 0)
{
$sql = "UPDATE ".$this->db->prefix()."bericht SET "
."titel = ".($this->titel ? "'".$this->db->escape($this->titel)."'" : "NULL").","
."auftragsnummer = ".($this->auftragsnummer ? "'".$this->db->escape($this->auftragsnummer)."'" : "NULL").","
."template_odt = ".($this->template_odt ? "'".$this->db->escape($this->template_odt)."'" : "NULL").","
."status = ".((int) $this->status).","
."final_pdf_path = ".($this->final_pdf_path ? "'".$this->db->escape($this->final_pdf_path)."'" : "NULL").","
."note = ".($this->note ? "'".$this->db->escape($this->note)."'" : "NULL").","
."fk_user_modif = ".((int) $user->id)
." WHERE rowid = ".((int) $this->id);
$res = $this->db->query($sql);
if (!$res) {
$this->error = $this->db->lasterror();
return -1;
}
return 1;
}
public function delete(User $user, $notrigger = 0)
{
$this->db->begin();
// Seiten löschen (CASCADE räumt's bereits, aber explizit für robustes Verhalten)
$this->db->query("DELETE FROM ".$this->db->prefix()."bericht_page WHERE fk_bericht = ".((int) $this->id));
$res = $this->db->query("DELETE FROM ".$this->db->prefix()."bericht WHERE rowid = ".((int) $this->id));
if (!$res) {
$this->error = $this->db->lasterror();
$this->db->rollback();
return -1;
}
$this->db->commit();
return 1;
}
/**
* Liefert alle Berichte zu einem Parent-Objekt.
*
* @param string $element_type invoice | order | propal
* @param int $fk_element ID des Parent-Objekts
* @return Bericht[]
*/
public static function fetchAllForElement(DoliDB $db, $element_type, $fk_element)
{
$list = array();
$sql = "SELECT rowid FROM ".$db->prefix()."bericht"
." WHERE element_type = '".$db->escape($element_type)."'"
." AND fk_element = ".((int) $fk_element)
." ORDER BY 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;
}
public function getLibStatut($mode = 0)
{
global $langs;
$langs->load("bericht@bericht");
if ($this->status == self::STATUS_FINAL) {
return '<span class="badge badge-status4">'.$langs->trans("BerichtStatusFinal").'</span>';
}
return '<span class="badge badge-status0">'.$langs->trans("BerichtStatusDraft").'</span>';
}
}
/**
* Eine Seite eines Berichts.
*/
class BerichtPage
{
public $db;
public $id;
public $fk_bericht;
public $page_order;
public $source_type;
public $source_path;
public $source_page;
public $rotation;
public $fabric_json;
public $note;
public function __construct(DoliDB $db)
{
$this->db = $db;
}
public function create()
{
$sql = "INSERT INTO ".$this->db->prefix()."bericht_page ("
."fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note"
.") VALUES ("
.((int) $this->fk_bericht).","
.((int) $this->page_order).","
."'".$this->db->escape($this->source_type)."',"
."'".$this->db->escape($this->source_path)."',"
.($this->source_page !== null ? (int) $this->source_page : "NULL").","
.((int) ($this->rotation ?? 0)).","
.($this->fabric_json !== null ? "'".$this->db->escape($this->fabric_json)."'" : "NULL").","
.($this->note ? "'".$this->db->escape($this->note)."'" : "NULL")
.")";
$res = $this->db->query($sql);
if (!$res) return -1;
$this->id = $this->db->last_insert_id($this->db->prefix()."bericht_page");
return $this->id;
}
public function update()
{
$sql = "UPDATE ".$this->db->prefix()."bericht_page SET "
."page_order = ".((int) $this->page_order).","
."rotation = ".((int) ($this->rotation ?? 0)).","
."fabric_json = ".($this->fabric_json !== null ? "'".$this->db->escape($this->fabric_json)."'" : "NULL").","
."note = ".($this->note ? "'".$this->db->escape($this->note)."'" : "NULL")
." WHERE rowid = ".((int) $this->id);
return $this->db->query($sql) ? 1 : -1;
}
public function delete()
{
return $this->db->query("DELETE FROM ".$this->db->prefix()."bericht_page WHERE rowid = ".((int) $this->id)) ? 1 : -1;
}
public static function fetchAllForBericht(DoliDB $db, $fk_bericht)
{
$list = array();
$sql = "SELECT rowid, fk_bericht, page_order, source_type, source_path, source_page, rotation, fabric_json, note"
." FROM ".$db->prefix()."bericht_page"
." WHERE fk_bericht = ".((int) $fk_bericht)
." ORDER BY page_order ASC, rowid ASC";
$res = $db->query($sql);
if (!$res) return $list;
while ($obj = $db->fetch_object($res)) {
$p = new self($db);
$p->id = $obj->rowid;
$p->fk_bericht = $obj->fk_bericht;
$p->page_order = (int) $obj->page_order;
$p->source_type = $obj->source_type;
$p->source_path = $obj->source_path;
$p->source_page = $obj->source_page !== null ? (int) $obj->source_page : null;
$p->rotation = (int) $obj->rotation;
$p->fabric_json = $obj->fabric_json;
$p->note = $obj->note;
$list[] = $p;
}
return $list;
}
}

View file

@ -0,0 +1,176 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+ siehe COPYING
*/
/**
* \defgroup bericht Module Bericht
* \brief Arbeitsberichte aus Anhängen erstellen, im Browser annotieren und an Rechnungen anhängen.
* \file htdocs/bericht/core/modules/modBericht.class.php
*/
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
class modBericht extends DolibarrModules
{
public function __construct($db)
{
global $conf, $langs;
$this->db = $db;
$this->numero = 500021; // Frei wählbare Modul-ID (siehe wiki.dolibarr.org/index.php/List_of_modules_id)
$this->rights_class = 'bericht';
$this->family = "other";
$this->module_position = '90';
$this->name = preg_replace('/^mod/i', '', get_class($this));
$this->description = "Arbeitsberichte aus Rechnungs-Anhängen erstellen, im Browser annotieren und als PDF an die Rechnung anhängen.";
$this->descriptionlong = "Fügt Rechnungen, Aufträgen und Angeboten einen Reiter 'Bericht' hinzu. Anhänge auswählen, im Browser mit Pfeilen/Kreisen/Rechtecken/Text annotieren, Seiten verwalten, Deckblatt aus ODT-Vorlage einfügen und als PDF unter Verknüpfte Dokumente speichern.";
$this->editor_name = 'Alles Watt läuft';
$this->editor_url = '';
$this->version = '1.0.0';
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
$this->picto = 'fa-file-pdf';
$this->module_parts = array(
'triggers' => 0,
'login' => 0,
'substitutions' => 0,
'menus' => 0,
'tpl' => 0,
'barcode' => 0,
'models' => 0,
'printing' => 0,
'theme' => 0,
'css' => array('/bericht/css/bericht.css'),
'js' => array(),
'hooks' => array(
'invoicecard',
'ordercard',
'propalcard',
),
'moduleforexternal' => 0,
);
// Datenverzeichnisse
$this->dirs = array(
"/bericht/temp",
"/bericht/templates",
"/bericht/work",
);
// Konfigurationsseite im Admin-Bereich
$this->config_page_url = array("setup.php@bericht");
$this->hidden = false;
$this->depends = array();
$this->requiredby = array();
$this->conflictwith = array();
$this->langfiles = array("bericht@bericht");
$this->phpmin = array(7, 4);
$this->need_dolibarr_version = array(19, 0);
$this->need_javascript_ajax = 1;
// Konstanten beim Aktivieren anlegen
$this->const = array(
0 => array('BERICHT_DEFAULT_TEMPLATE', 'chaine', '', 'Standard ODT-Template für Deckblatt', 0, 'current', 0),
1 => array('BERICHT_TAB_ON_INVOICE', 'chaine', '1', 'Reiter Bericht auf Rechnungen anzeigen', 0, 'current', 0),
2 => array('BERICHT_TAB_ON_ORDER', 'chaine', '1', 'Reiter Bericht auf Aufträgen anzeigen', 0, 'current', 0),
3 => array('BERICHT_TAB_ON_PROPAL', 'chaine', '1', 'Reiter Bericht auf Angeboten anzeigen', 0, 'current', 0),
4 => array('BERICHT_BURN_ANNOTATIONS', 'chaine', '1', 'Annotationen beim Export ins PDF einbrennen', 0, 'current', 0),
5 => array('BERICHT_LIBREOFFICE_BIN', 'chaine', '/usr/bin/libreoffice', 'Pfad zu LibreOffice für ODT→PDF Konvertierung', 0, 'current', 0),
);
// Tabs werden über den Hook (actions_bericht.class.php → addMoreActionsButtons / completeTabsHead)
// dynamisch hinzugefügt, weil wir die Sichtbarkeit pro Element-Typ über Konstanten steuern wollen.
// Statisch geht aber auch — sicherer und einfacher:
$this->tabs = array(
0 => array('data' => 'invoice:+bericht:Bericht:bericht@bericht:$user->hasRight(\'bericht\', \'read\') && getDolGlobalString(\'BERICHT_TAB_ON_INVOICE\', \'1\'):/bericht/bericht_card.php?id=__ID__&element=invoice'),
1 => array('data' => 'order:+bericht:Bericht:bericht@bericht:$user->hasRight(\'bericht\', \'read\') && getDolGlobalString(\'BERICHT_TAB_ON_ORDER\', \'1\'):/bericht/bericht_card.php?id=__ID__&element=order'),
2 => array('data' => 'propal:+bericht:Bericht:bericht@bericht:$user->hasRight(\'bericht\', \'read\') && getDolGlobalString(\'BERICHT_TAB_ON_PROPAL\', \'1\'):/bericht/bericht_card.php?id=__ID__&element=propal'),
);
$this->dictionaries = array();
$this->boxes = array();
$this->cronjobs = array();
// Rechte
$this->rights = array();
$r = 0;
$this->rights[$r][0] = $this->numero . '01';
$this->rights[$r][1] = 'Berichte lesen';
$this->rights[$r][4] = 'read';
$r++;
$this->rights[$r][0] = $this->numero . '02';
$this->rights[$r][1] = 'Berichte erstellen und bearbeiten';
$this->rights[$r][4] = 'write';
$r++;
$this->rights[$r][0] = $this->numero . '03';
$this->rights[$r][1] = 'Berichte löschen';
$this->rights[$r][4] = 'delete';
$r++;
$this->rights[$r][0] = $this->numero . '04';
$this->rights[$r][1] = 'Modul Bericht administrieren (Templates verwalten)';
$this->rights[$r][4] = 'admin';
$r++;
$this->menu = array();
}
/**
* Beim Aktivieren ausgeführt: SQL laden, Verzeichnisse anlegen,
* vorhandene Extrafields auf llx_facture_extrafields prüfen und ggf. anlegen.
*/
public function init($options = '')
{
global $conf, $langs;
// SQL-Tabellen anlegen
$result = $this->_load_tables('/bericht/sql/');
if ($result < 0) {
return -1;
}
// Extrafields auf facture sicherstellen — vorhandene werden NICHT angefasst
require_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php';
$extrafields = new ExtraFields($this->db);
$fields = array(
'auftragsnummer' => array('label' => 'Auftragsnummer', 'type' => 'varchar', 'size' => 255, 'pos' => 100),
'angebotsnummer' => array('label' => 'Angebotsnummer', 'type' => 'varchar', 'size' => 255, 'pos' => 101),
'rechnungsnummer' => array('label' => 'Rechnungsnummer', 'type' => 'varchar', 'size' => 255, 'pos' => 102),
'beschreibung' => array('label' => 'Auftragsbeschreibung', 'type' => 'text', 'size' => 2000, 'pos' => 103),
'hinweis' => array('label' => 'Hinweis', 'type' => 'varchar', 'size' => 255, 'pos' => 104),
);
foreach ($fields as $name => $def) {
// Existiert das Feld bereits? → nicht überschreiben
$check = $this->db->query("SELECT rowid FROM ".$this->db->prefix()."extrafields"
." WHERE name = '".$this->db->escape($name)."'"
." AND elementtype = 'facture'");
if ($check && $this->db->num_rows($check) > 0) {
continue;
}
$extrafields->addExtraField(
$name,
$def['label'],
$def['type'],
$def['pos'],
$def['size'],
'facture',
0, 0, '', '', 1, '', 0, 0, '', '', 'bericht@bericht', '1'
);
}
$sql = array();
return $this->_init($sql, $options);
}
/**
* Beim Deaktivieren: Konstanten/Permissions entfernen, Daten und Extrafields BLEIBEN erhalten.
*/
public function remove($options = '')
{
$sql = array();
return $this->_remove($sql, $options);
}
}

108
css/bericht.css Normal file
View file

@ -0,0 +1,108 @@
/* Bericht-Modul Stylesheet */
.bericht-overview-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
.bericht-editor { padding-top: 10px; }
.bericht-meta { margin-bottom: 12px; }
.bericht-layout {
display: grid;
grid-template-columns: 280px 1fr 200px;
gap: 12px;
align-items: start;
}
.bericht-attachments,
.bericht-pages {
background: #f7f7f9;
border: 1px solid #e3e3e7;
border-radius: 6px;
padding: 10px;
max-height: 80vh;
overflow-y: auto;
}
.bericht-attachments h4,
.bericht-pages h4 { margin: 0 0 8px 0; font-size: 13px; text-transform: uppercase; color: #555; }
.bericht-att-group { margin-bottom: 10px; }
.bericht-att-group-title { font-weight: 600; font-size: 11px; color: #777; padding: 4px 0; border-bottom: 1px solid #ddd; }
.bericht-att-item {
display: flex; align-items: center; gap: 6px;
padding: 4px 2px; cursor: pointer; font-size: 12px;
}
.bericht-att-item:hover { background: #eef; }
.att-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.att-size { font-size: 10px; }
.att-icon { font-size: 14px; }
.bericht-upload { margin-top: 10px; }
.bericht-canvas-area {
background: #2a2a2e;
border-radius: 6px;
padding: 10px;
min-height: 80vh;
display: flex; flex-direction: column;
}
.bericht-toolbar {
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
background: #fff; padding: 6px; border-radius: 4px; margin-bottom: 8px;
}
.bericht-toolbar button, .bericht-toolbar .tool-btn {
background: #fff; border: 1px solid #ccc; border-radius: 4px;
padding: 4px 10px; cursor: pointer; font-size: 16px;
}
.bericht-toolbar .tool-btn.active { background: #5cb85c; color: #fff; border-color: #4cae4c; }
.bericht-toolbar .sep { width: 1px; background: #ddd; height: 24px; margin: 0 6px; }
.bericht-toolbar label { display: flex; align-items: center; gap: 4px; font-size: 12px; }
.bericht-canvas-wrap {
flex: 1; position: relative; overflow: auto;
background: #444; padding: 10px; border-radius: 4px;
display: flex; align-items: flex-start; justify-content: center;
}
#pdf-canvas { background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.4); }
#fabric-canvas.fabric-overlay { position: absolute !important; pointer-events: auto; }
.bericht-page-note { margin-top: 8px; }
.bericht-page-note label { display: block; color: #fff; font-size: 12px; margin-bottom: 4px; }
.bericht-page-note textarea { width: 100%; box-sizing: border-box; }
.page-list { display: flex; flex-direction: column; gap: 6px; }
.page-thumb {
background: #fff; border: 2px solid #ddd; border-radius: 4px;
padding: 6px; cursor: pointer; position: relative;
transition: border-color 0.15s;
}
.page-thumb:hover { border-color: #888; }
.page-thumb.active { border-color: #337ab7; box-shadow: 0 0 0 2px rgba(51,122,183,0.3); }
.page-thumb-inner {
height: 100px; background: #f0f0f0;
display: flex; align-items: center; justify-content: center;
font-size: 24px; color: #999;
}
.page-thumb-actions { position: absolute; top: 4px; right: 4px; }
.thumb-del { background: rgba(255,255,255,0.9); border: 1px solid #ccc; border-radius: 3px; cursor: pointer; }
.bericht-actions {
margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;
}
.bericht-toast {
position: fixed; top: 20px; right: 20px;
background: #5cb85c; color: #fff; padding: 12px 20px;
border-radius: 4px; box-shadow: 0 2px 12px rgba(0,0,0,0.2);
z-index: 9999; animation: fadeIn 0.2s;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; } }
.bericht-setup-section {
background: #fff; border: 1px solid #e3e3e7;
border-radius: 6px; padding: 16px; margin-bottom: 16px;
}
.bericht-setup-section h3 { margin-top: 0; }
@media (max-width: 1100px) {
.bericht-layout { grid-template-columns: 1fr; }
.bericht-attachments, .bericht-pages { max-height: 300px; }
}

374
js/editor.js Normal file
View file

@ -0,0 +1,374 @@
/*
* Bericht-Editor PDF.js + Fabric.js + SortableJS
* Lädt Seiten via /ajax/page_image.php (Bilder direkt, PDFs via PDF.js gerendert),
* legt Fabric.js-Canvas darüber, speichert Annotationen pro Seite über Ajax.
*
* Erwartet im DOM:
* #pdf-canvas Canvas für die Seitendarstellung
* #fabric-canvas Overlay-Canvas für Annotationen
* #bericht-page-list Container für Seiten-Thumbnails (.page-thumb[data-pageid])
* #page-note Textarea für Seiten-Notiz
* .att-check Checkboxen der Anhänge-Liste
* #btn-add-selected Button "Auswahl in Bericht übernehmen"
* #btn-save-draft Entwurf speichern
* #btn-finalize Bericht finalisieren
* #btn-undo / #btn-redo / #btn-delete-selected
* .tool-btn[data-tool]
* #tool-color, #tool-stroke
* #bericht-extra-upload (file input)
*
* Globale Konfiguration: window.BERICHT_CONFIG (vom PHP gesetzt)
*/
(function () {
'use strict';
const cfg = window.BERICHT_CONFIG || {};
if (!cfg.berichtid) { console.warn('Bericht: keine Konfiguration'); return; }
// PDF.js worker (lokal)
if (window.pdfjsLib) {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/bericht/js/lib/pdf.worker.min.js';
}
let currentPageId = null;
let currentPageEl = null;
let fabricCanvas = null;
const pdfCanvas = document.getElementById('pdf-canvas');
let currentTool = 'select';
/* ---------- Init ---------- */
function init() {
// Fabric initialisieren (wird beim ersten Seitenrendern dimensioniert)
fabricCanvas = new fabric.Canvas('fabric-canvas', {
isDrawingMode: false,
selection: true,
});
fabricCanvas.freeDrawingBrush.color = document.getElementById('tool-color').value;
fabricCanvas.freeDrawingBrush.width = parseInt(document.getElementById('tool-stroke').value, 10);
// Erste Seite laden (wenn vorhanden)
const firstThumb = document.querySelector('#bericht-page-list .page-thumb');
if (firstThumb) loadPage(firstThumb);
bindThumbs();
bindToolbar();
bindAttachments();
bindExtraUpload();
bindActions();
bindSortable();
}
/* ---------- Seiten laden ---------- */
async function loadPage(thumbEl) {
// vorher: aktuelle Seite speichern
if (currentPageId) await savePageAnnotations(false);
currentPageEl = thumbEl;
currentPageId = parseInt(thumbEl.dataset.pageid, 10);
document.querySelectorAll('.page-thumb.active').forEach(e => e.classList.remove('active'));
thumbEl.classList.add('active');
const url = cfg.urls.page_image + '?pageid=' + currentPageId;
const resp = await fetch(url);
const ct = resp.headers.get('Content-Type') || '';
const buf = await resp.arrayBuffer();
fabricCanvas.clear();
document.getElementById('page-note').value = '';
if (ct.includes('pdf')) {
await renderPdf(buf);
} else if (ct.includes('image')) {
await renderImage(buf, ct);
}
// Vorhandene Annotationen laden — kommt über das Thumb-Dataset oder einen Refetch
// (Für Einfachheit: hier ein extra Ajax wäre sauberer; wir nehmen an, der Server
// liefert die Annotationen einmal beim Seitenwechsel mit.)
await loadPageMeta();
}
async function renderPdf(arrayBuffer) {
if (!window.pdfjsLib) { console.error('PDF.js nicht geladen'); return; }
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
// Mehrseitige PDFs werden serverseitig pro Seite als eigene bericht_page abgelegt,
// hier rendern wir immer Seite 1 dieses Source-PDFs — die Original-Seitennummer
// (source_page) kümmert sich um die Auswahl beim Finalisieren.
const pageNum = 1;
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.5 });
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
const ctx = pdfCanvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
resizeFabricToCanvas();
}
async function renderImage(arrayBuffer, mime) {
const blob = new Blob([arrayBuffer], { type: mime });
const url = URL.createObjectURL(blob);
await new Promise((res) => {
const img = new Image();
img.onload = () => {
// Skalierung auf max 1200px Breite
const maxW = 1200;
const ratio = img.width > maxW ? maxW / img.width : 1;
pdfCanvas.width = img.width * ratio;
pdfCanvas.height = img.height * ratio;
const ctx = pdfCanvas.getContext('2d');
ctx.clearRect(0, 0, pdfCanvas.width, pdfCanvas.height);
ctx.drawImage(img, 0, 0, pdfCanvas.width, pdfCanvas.height);
URL.revokeObjectURL(url);
res();
};
img.src = url;
});
resizeFabricToCanvas();
}
function resizeFabricToCanvas() {
fabricCanvas.setWidth(pdfCanvas.width);
fabricCanvas.setHeight(pdfCanvas.height);
// Overlay positionieren
const fc = document.getElementById('fabric-canvas');
fc.style.position = 'absolute';
fc.style.left = pdfCanvas.offsetLeft + 'px';
fc.style.top = pdfCanvas.offsetTop + 'px';
}
async function loadPageMeta() {
// Annotationen + Notiz für aktuelle Seite holen
try {
const r = await fetch(cfg.urls.save_annotations.replace('save_annotations', 'page_meta') + '?pageid=' + currentPageId);
// Falls page_meta.php nicht existiert: still bleiben
if (!r.ok) return;
const data = await r.json();
if (data.fabric_json) {
fabricCanvas.loadFromJSON(data.fabric_json, () => fabricCanvas.renderAll());
}
if (data.note) document.getElementById('page-note').value = data.note;
} catch (e) { /* ok */ }
}
/* ---------- Toolbar ---------- */
function bindToolbar() {
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn.active').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentTool = btn.dataset.tool;
applyTool();
});
});
document.getElementById('tool-color').addEventListener('input', e => {
fabricCanvas.freeDrawingBrush.color = e.target.value;
const sel = fabricCanvas.getActiveObject();
if (sel) {
sel.set({ stroke: e.target.value });
if (sel.type === 'i-text' || sel.type === 'text') sel.set({ fill: e.target.value });
fabricCanvas.requestRenderAll();
}
});
document.getElementById('tool-stroke').addEventListener('input', e => {
fabricCanvas.freeDrawingBrush.width = parseInt(e.target.value, 10);
const sel = fabricCanvas.getActiveObject();
if (sel) { sel.set({ strokeWidth: parseInt(e.target.value, 10) }); fabricCanvas.requestRenderAll(); }
});
document.getElementById('btn-undo').addEventListener('click', undo);
document.getElementById('btn-redo').addEventListener('click', redo);
document.getElementById('btn-delete-selected').addEventListener('click', () => {
const sel = fabricCanvas.getActiveObjects();
sel.forEach(o => fabricCanvas.remove(o));
fabricCanvas.discardActiveObject();
fabricCanvas.requestRenderAll();
});
document.getElementById('btn-rotate-left').addEventListener('click', () => rotateCurrent(-90));
document.getElementById('btn-rotate-right').addEventListener('click', () => rotateCurrent(90));
}
function applyTool() {
fabricCanvas.isDrawingMode = (currentTool === 'draw');
fabricCanvas.selection = (currentTool === 'select');
// Click-Handler für Shape-Tools
fabricCanvas.off('mouse:down', shapeDown);
if (['rect', 'circle', 'arrow', 'text'].includes(currentTool)) {
fabricCanvas.on('mouse:down', shapeDown);
}
}
function shapeDown(opt) {
const p = fabricCanvas.getPointer(opt.e);
const color = document.getElementById('tool-color').value;
const sw = parseInt(document.getElementById('tool-stroke').value, 10);
let shape = null;
if (currentTool === 'rect') {
shape = new fabric.Rect({ left: p.x, top: p.y, width: 100, height: 60, fill: 'transparent', stroke: color, strokeWidth: sw });
} else if (currentTool === 'circle') {
shape = new fabric.Circle({ left: p.x, top: p.y, radius: 40, fill: 'transparent', stroke: color, strokeWidth: sw });
} else if (currentTool === 'arrow') {
// Pfeil = Linie mit Pfeilspitze (vereinfacht)
shape = new fabric.Line([p.x, p.y, p.x + 100, p.y + 50], { stroke: color, strokeWidth: sw });
} else if (currentTool === 'text') {
shape = new fabric.IText('Text…', { left: p.x, top: p.y, fontSize: 24, fill: color });
}
if (shape) {
fabricCanvas.add(shape);
fabricCanvas.setActiveObject(shape);
// Nach dem Setzen wieder zurück auf Select, damit User direkt verschieben/skalieren kann
const sb = document.querySelector('.tool-btn[data-tool="select"]');
if (sb) sb.click();
}
}
/* ---------- Undo/Redo (einfach) ---------- */
const history = [];
let histIdx = -1;
function snapshot() {
history.length = histIdx + 1;
history.push(JSON.stringify(fabricCanvas.toJSON()));
histIdx = history.length - 1;
}
function undo() {
if (histIdx <= 0) return;
histIdx--;
fabricCanvas.loadFromJSON(history[histIdx], () => fabricCanvas.renderAll());
}
function redo() {
if (histIdx >= history.length - 1) return;
histIdx++;
fabricCanvas.loadFromJSON(history[histIdx], () => fabricCanvas.renderAll());
}
setTimeout(() => {
if (fabricCanvas) {
fabricCanvas.on('object:added', snapshot);
fabricCanvas.on('object:modified', snapshot);
fabricCanvas.on('object:removed', snapshot);
}
}, 500);
function rotateCurrent(deg) {
const sel = fabricCanvas.getActiveObject();
if (sel) {
sel.rotate(((sel.angle || 0) + deg) % 360);
fabricCanvas.requestRenderAll();
}
}
/* ---------- Speichern ---------- */
async function savePageAnnotations(showMessage = true) {
if (!currentPageId) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', currentPageId);
fd.append('fabric_json', JSON.stringify(fabricCanvas.toJSON()));
fd.append('note', document.getElementById('page-note').value || '');
const r = await fetch(cfg.urls.save_annotations, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (showMessage && data.success) toast('Seite gespeichert');
}
function bindActions() {
document.getElementById('btn-save-draft').addEventListener('click', async () => {
await savePageAnnotations(true);
});
document.getElementById('btn-finalize').addEventListener('click', async () => {
await savePageAnnotations(false);
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
const r = await fetch(cfg.urls.generate_pdf, { method: 'POST', body: fd });
const data = await r.json();
if (data.success) {
toast('PDF erstellt: ' + data.filename);
setTimeout(() => location.reload(), 1500);
} else {
alert('Fehler: ' + (data.error || 'unbekannt'));
}
});
}
/* ---------- 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) {
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
});
}
function bindExtraUpload() {
const inp = document.getElementById('bericht-extra-upload');
if (!inp) return;
inp.addEventListener('change', async () => {
if (!inp.files.length) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('berichtid', cfg.berichtid);
fd.append('file', inp.files[0]);
const r = await fetch(cfg.urls.upload_extra, { method: 'POST', body: fd });
const data = await r.json();
if (data.success) location.reload();
else alert('Upload fehlgeschlagen: ' + (data.error || ''));
});
}
function bindThumbs() {
document.querySelectorAll('.page-thumb').forEach(t => {
t.addEventListener('click', e => {
if (e.target.closest('.thumb-del')) return;
loadPage(t);
});
const del = t.querySelector('.thumb-del');
if (del) del.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(cfg.lang.confirm_del)) return;
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('pageid', t.dataset.pageid);
await fetch(cfg.urls.delete_page, { method: 'POST', body: fd });
location.reload();
});
});
}
function bindSortable() {
const list = document.getElementById('bericht-page-list');
if (!list || !window.Sortable) return;
Sortable.create(list, {
animation: 150,
onEnd: async () => {
const ids = Array.from(list.querySelectorAll('.page-thumb')).map(t => t.dataset.pageid);
const fd = new FormData();
fd.append('token', cfg.token);
fd.append('order', JSON.stringify(ids));
await fetch(cfg.urls.reorder_pages, { method: 'POST', body: fd });
}
});
}
/* ---------- Helpers ---------- */
function toast(msg) {
const t = document.createElement('div');
t.className = 'bericht-toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
document.addEventListener('DOMContentLoaded', init);
})();

2
js/lib/Sortable.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/lib/fabric.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
js/lib/pdf.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
js/lib/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

69
langs/de_DE/bericht.lang Normal file
View file

@ -0,0 +1,69 @@
ModuleBerichtName = Bericht
ModuleBerichtDesc = Arbeitsberichte aus Anhängen erstellen, im Browser annotieren und an Rechnungen anhängen
Bericht = Bericht
Berichte = Berichte
BerichtEditor = Berichts-Editor
BerichtNew = Neuer Bericht
BerichtTitle = Titel
BerichtCreatedAt = Erstellt am
BerichtCreatedBy = Erstellt von
BerichtStatus = Status
BerichtStatusDraft = Entwurf
BerichtStatusFinal = Final
BerichtAuftragsnummer = Auftragsnummer
BerichtTemplate = Deckblatt-Vorlage
BerichtNoTemplate = Keine Vorlage
BerichtAvailableAttachments = Verfügbare Anhänge
BerichtAddSelectedToReport = Auswahl in Bericht übernehmen
BerichtUploadExtra = Weitere Datei hochladen
BerichtPages = Seiten
BerichtNoReports = Noch kein Bericht für dieses Dokument vorhanden.
BerichtCreate = Bericht anlegen
BerichtSave = Bericht speichern
BerichtSaveDraft = Entwurf speichern
BerichtFinalize = Bericht finalisieren (PDF erzeugen)
BerichtConfirmDelete = Diesen Bericht wirklich löschen?
BerichtDeleteSuccess = Bericht gelöscht
BerichtSaveSuccess = Bericht gespeichert
BerichtFinalizeSuccess = PDF erfolgreich erstellt und an %s angehängt
BerichtNoteHint = Notiz zur aktuellen Seite (wird unten auf der PDF-Seite gedruckt)
BerichtTools = Werkzeuge
BerichtToolSelect = Auswahl
BerichtToolDraw = Freihand
BerichtToolRect = Rechteck
BerichtToolCircle = Kreis
BerichtToolArrow = Pfeil
BerichtToolText = Text
BerichtToolDelete = Löschen
BerichtUndo = Rückgängig
BerichtRedo = Wiederherstellen
BerichtColor = Farbe
BerichtStrokeWidth = Strichstärke
BerichtRotateLeft = Links drehen
BerichtRotateRight = Rechts drehen
BerichtDeletePage = Seite löschen
BerichtAddPage = Seite hinzufügen
BerichtSetup = Bericht Einstellungen
BerichtSetupDescription = Verwalten Sie hier die ODT-Vorlagen für Deckblätter und globale Einstellungen.
BerichtSetupTemplates = ODT Deckblatt-Vorlagen
BerichtSetupTemplatesDesc = Vorlagen werden unter DOL_DATA_ROOT/bericht/templates abgelegt. In der Vorlage können Sie Platzhalter wie {auftragsnummer}, {kunde_name}, {datum} verwenden.
BerichtSetupUploadTemplate = Neue Vorlage hochladen (.odt)
BerichtSetupTemplateDeleted = Vorlage gelöscht
BerichtSetupTemplateUploaded = Vorlage hochgeladen
BerichtSetupDefaultTemplate = Standard-Vorlage
BerichtSetupNoTemplates = Noch keine Vorlagen vorhanden
BerichtSetupOptions = Optionen
BerichtSetupTabInvoice = Reiter auf Rechnungen anzeigen
BerichtSetupTabOrder = Reiter auf Aufträgen anzeigen
BerichtSetupTabPropal = Reiter auf Angeboten anzeigen
BerichtSetupBurnAnnotations = Annotationen ins PDF einbrennen (statt als PDF-Annotations)
BerichtSetupLibreOfficeBin = Pfad zu LibreOffice (für ODT→PDF)
BerichtPlaceholdersTitle = Verfügbare Platzhalter in der ODT-Vorlage
BerichtErrorNoParent = Übergeordnetes Dokument nicht gefunden
BerichtErrorNotAllowed = Keine Berechtigung
BerichtErrorTemplateNotFound = Vorlage nicht gefunden
BerichtErrorPdfFailed = PDF-Erstellung fehlgeschlagen
Permission50002101 = Berichte lesen
Permission50002102 = Berichte erstellen und bearbeiten
Permission50002103 = Berichte löschen
Permission50002104 = Modul Bericht administrieren

69
langs/en_US/bericht.lang Normal file
View file

@ -0,0 +1,69 @@
ModuleBerichtName = Report
ModuleBerichtDesc = Create work reports from invoice attachments, annotate them in the browser and attach them as PDF
Bericht = Report
Berichte = Reports
BerichtEditor = Report Editor
BerichtNew = New Report
BerichtTitle = Title
BerichtCreatedAt = Created at
BerichtCreatedBy = Created by
BerichtStatus = Status
BerichtStatusDraft = Draft
BerichtStatusFinal = Final
BerichtAuftragsnummer = Order number
BerichtTemplate = Cover template
BerichtNoTemplate = No template
BerichtAvailableAttachments = Available attachments
BerichtAddSelectedToReport = Add selection to report
BerichtUploadExtra = Upload another file
BerichtPages = Pages
BerichtNoReports = No report exists for this document yet.
BerichtCreate = Create report
BerichtSave = Save report
BerichtSaveDraft = Save draft
BerichtFinalize = Finalize report (generate PDF)
BerichtConfirmDelete = Really delete this report?
BerichtDeleteSuccess = Report deleted
BerichtSaveSuccess = Report saved
BerichtFinalizeSuccess = PDF created and attached to %s
BerichtNoteHint = Note for current page (printed at the bottom of the PDF page)
BerichtTools = Tools
BerichtToolSelect = Select
BerichtToolDraw = Freehand
BerichtToolRect = Rectangle
BerichtToolCircle = Circle
BerichtToolArrow = Arrow
BerichtToolText = Text
BerichtToolDelete = Delete
BerichtUndo = Undo
BerichtRedo = Redo
BerichtColor = Color
BerichtStrokeWidth = Stroke width
BerichtRotateLeft = Rotate left
BerichtRotateRight = Rotate right
BerichtDeletePage = Delete page
BerichtAddPage = Add page
BerichtSetup = Report Settings
BerichtSetupDescription = Manage ODT cover templates and global settings here.
BerichtSetupTemplates = ODT cover templates
BerichtSetupTemplatesDesc = Templates are stored under DOL_DATA_ROOT/bericht/templates. You can use placeholders like {auftragsnummer}, {kunde_name}, {datum} in the template.
BerichtSetupUploadTemplate = Upload new template (.odt)
BerichtSetupTemplateDeleted = Template deleted
BerichtSetupTemplateUploaded = Template uploaded
BerichtSetupDefaultTemplate = Default template
BerichtSetupNoTemplates = No templates yet
BerichtSetupOptions = Options
BerichtSetupTabInvoice = Show tab on invoices
BerichtSetupTabOrder = Show tab on orders
BerichtSetupTabPropal = Show tab on proposals
BerichtSetupBurnAnnotations = Burn annotations into PDF (instead of PDF annotations)
BerichtSetupLibreOfficeBin = LibreOffice binary path (for ODT→PDF)
BerichtPlaceholdersTitle = Available placeholders in ODT template
BerichtErrorNoParent = Parent document not found
BerichtErrorNotAllowed = Permission denied
BerichtErrorTemplateNotFound = Template not found
BerichtErrorPdfFailed = PDF generation failed
Permission50002101 = Read reports
Permission50002102 = Create and edit reports
Permission50002103 = Delete reports
Permission50002104 = Administer Bericht module

178
lib/bericht.lib.php Normal file
View file

@ -0,0 +1,178 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*/
/**
* Hilfs-Funktionen für das Bericht-Modul.
*/
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
/**
* Erzeugt die Tab-Header-Leiste für die Editor-Seite (Übersicht / Editor).
*
* @param Bericht $object
* @return array
*/
function bericht_prepare_head($object)
{
global $langs, $conf;
$langs->load("bericht@bericht");
$h = 0;
$head = array();
$head[$h][0] = dol_buildpath('/bericht/bericht_card.php', 1).'?id='.$object->id;
$head[$h][1] = $langs->trans("BerichtEditor");
$head[$h][2] = 'editor';
$h++;
return $head;
}
/**
* Lädt das Parent-Objekt anhand des element_type.
*
* @return CommonObject|null
*/
function bericht_fetch_parent(DoliDB $db, $element_type, $id)
{
if ($element_type === 'invoice') {
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
$o = new Facture($db);
} elseif ($element_type === 'order') {
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
$o = new Commande($db);
} elseif ($element_type === 'propal') {
require_once DOL_DOCUMENT_ROOT.'/comm/propal/class/propal.class.php';
$o = new Propal($db);
} else {
return null;
}
if ($o->fetch($id) <= 0) return null;
$o->fetch_thirdparty();
if (method_exists($o, 'fetch_optionals')) {
$o->fetch_optionals();
}
return $o;
}
/**
* Mappt einen element_type-Code auf den Dolibarr-internen Element-Namen
* für das Verzeichnis der Anhänge (multidir_output).
*/
function bericht_element_to_dir_key($element_type)
{
return array(
'invoice' => 'facture',
'order' => 'commande',
'propal' => 'propal',
)[$element_type] ?? null;
}
/**
* Sammelt alle Anhänge eines Parent-Objekts UND der direkt verknüpften Objekte
* (z. B. der Auftrag/Angebot, die mit dieser Rechnung verknüpft sind).
*
* @return array Liste mit ['source','source_id','source_ref','filename','fullpath','relpath','size','mime','date']
*/
function bericht_collect_attachments(DoliDB $db, CommonObject $parent, $element_type)
{
global $conf;
$result = array();
// 1) Eigene Anhänge
$dir_key = bericht_element_to_dir_key($element_type);
if ($dir_key && !empty($conf->{$dir_key}->multidir_output[$parent->entity])) {
$upload_dir = $conf->{$dir_key}->multidir_output[$parent->entity].'/'.dol_sanitizeFileName($parent->ref);
if (is_dir($upload_dir)) {
$files = dol_dir_list($upload_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
foreach ($files as $f) {
$result[] = array(
'source' => $element_type,
'source_id' => $parent->id,
'source_ref' => $parent->ref,
'filename' => $f['name'],
'fullpath' => $f['fullname'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']),
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
);
}
}
}
// 2) Verknüpfte Objekte: order, propal — auch deren Anhänge anbieten
$parent->fetchObjectLinked();
$linked_types = array('commande' => 'order', 'propal' => 'propal', 'facture' => 'invoice');
foreach ($linked_types as $linked_dolibarr_type => $linked_module_type) {
if (empty($parent->linkedObjects[$linked_dolibarr_type])) continue;
foreach ($parent->linkedObjects[$linked_dolibarr_type] as $lobj) {
if (empty($conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity])) continue;
$linked_dir = $conf->{$linked_dolibarr_type}->multidir_output[$lobj->entity].'/'.dol_sanitizeFileName($lobj->ref);
if (!is_dir($linked_dir)) continue;
$files = dol_dir_list($linked_dir, 'files', 1, '', '(\.meta|_preview.*\.png|thumbs)$');
foreach ($files as $f) {
$result[] = array(
'source' => $linked_module_type,
'source_id' => $lobj->id,
'source_ref' => $lobj->ref,
'filename' => $f['name'],
'fullpath' => $f['fullname'],
'relpath' => str_replace(DOL_DATA_ROOT.'/', '', $f['fullname']),
'size' => $f['size'],
'mime' => dol_mimetype($f['name']),
'date' => $f['date'],
);
}
}
}
return $result;
}
/**
* Liest die Auftragsnummer aus dem Parent-Objekt.
* Reihenfolge: extrafield 'auftragsnummer' ref_client ref
*/
function bericht_get_auftragsnummer(CommonObject $parent)
{
if (!empty($parent->array_options['options_auftragsnummer'])) {
return $parent->array_options['options_auftragsnummer'];
}
if (!empty($parent->ref_client)) {
return $parent->ref_client;
}
return $parent->ref;
}
/**
* Listet alle ODT-Templates im Templates-Verzeichnis auf.
*
* @return string[] Dateinamen
*/
function bericht_list_templates()
{
$dir = DOL_DATA_ROOT.'/bericht/templates';
if (!is_dir($dir)) return array();
$files = scandir($dir);
return array_values(array_filter($files, function ($f) {
return preg_match('/\.odt$/i', $f);
}));
}
/**
* Sicherer absolute-Pfad-Resolver für Dateien unterhalb DOL_DATA_ROOT.
* Verhindert Path-Traversal.
*/
function bericht_resolve_data_path($relpath)
{
$base = realpath(DOL_DATA_ROOT);
$full = realpath($base.'/'.$relpath);
if ($full === false) return null;
if (strpos($full, $base) !== 0) return null;
return $full;
}

2
sql/llx_bericht.key.sql Normal file
View file

@ -0,0 +1,2 @@
ALTER TABLE llx_bericht ADD INDEX idx_bericht_element (element_type, fk_element);
ALTER TABLE llx_bericht ADD INDEX idx_bericht_entity (entity);

20
sql/llx_bericht.sql Normal file
View file

@ -0,0 +1,20 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
-- GPL v3+
CREATE TABLE llx_bericht (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
entity INTEGER DEFAULT 1 NOT NULL,
ref VARCHAR(128) NOT NULL,
titel VARCHAR(255) DEFAULT NULL,
element_type VARCHAR(32) NOT NULL, -- invoice, order, propal
fk_element INTEGER NOT NULL, -- ID des Parent-Objekts
auftragsnummer VARCHAR(255) DEFAULT NULL,
template_odt VARCHAR(255) DEFAULT NULL, -- Dateiname aus templates/
status INTEGER DEFAULT 0 NOT NULL, -- 0=Entwurf, 1=Final
final_pdf_path VARCHAR(512) DEFAULT NULL, -- Pfad relativ zu DOL_DATA_ROOT
fk_user_creat INTEGER NOT NULL,
fk_user_modif INTEGER DEFAULT NULL,
datec DATETIME NOT NULL,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
note TEXT DEFAULT NULL
) ENGINE=innodb;

View file

@ -0,0 +1,2 @@
ALTER TABLE llx_bericht_page ADD INDEX idx_bericht_page_bericht (fk_bericht, page_order);
ALTER TABLE llx_bericht_page ADD CONSTRAINT fk_bericht_page_bericht FOREIGN KEY (fk_bericht) REFERENCES llx_bericht(rowid) ON DELETE CASCADE;

15
sql/llx_bericht_page.sql Normal file
View file

@ -0,0 +1,15 @@
-- Eine Zeile pro Seite im Bericht. Reihenfolge über page_order.
-- Annotationen liegen als Fabric.js-JSON in fabric_json.
CREATE TABLE llx_bericht_page (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_bericht INTEGER NOT NULL,
page_order INTEGER NOT NULL,
source_type VARCHAR(16) NOT NULL, -- pdf | image | upload
source_path VARCHAR(512) NOT NULL, -- relativ zu DOL_DATA_ROOT
source_page INTEGER DEFAULT NULL, -- bei multi-page PDFs: Original-Seitennummer
rotation INTEGER DEFAULT 0, -- 0/90/180/270
fabric_json LONGTEXT DEFAULT NULL,
note TEXT DEFAULT NULL,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
) ENGINE=innodb;