feat: Initiales Release Bericht-Modul v1.0.0 [deploy]
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
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:
commit
923b50d65a
30 changed files with 2607 additions and 0 deletions
41
.forgejo/workflows/deploy.yml
Normal file
41
.forgejo/workflows/deploy.yml
Normal 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
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.DS_Store
|
||||
*.swp
|
||||
*.bak
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
documents/
|
||||
work/
|
||||
temp/
|
||||
21
ChangeLog.md
Normal file
21
ChangeLog.md
Normal 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
95
README.md
Normal 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
182
admin/setup.php
Normal 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
47
ajax/_inc.php
Normal 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
85
ajax/add_attachment.php
Normal 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
14
ajax/delete_page.php
Normal 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
287
ajax/generate_pdf.php
Normal 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
38
ajax/page_image.php
Normal 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
18
ajax/page_meta.php
Normal 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
23
ajax/reorder_pages.php
Normal 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
26
ajax/save_annotations.php
Normal 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
60
ajax/upload_extra.php
Normal 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
334
bericht_card.php
Normal 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
267
class/bericht.class.php
Normal 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;
|
||||
}
|
||||
}
|
||||
176
core/modules/modBericht.class.php
Normal file
176
core/modules/modBericht.class.php
Normal 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
108
css/bericht.css
Normal 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
374
js/editor.js
Normal 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
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
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
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
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
69
langs/de_DE/bericht.lang
Normal 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
69
langs/en_US/bericht.lang
Normal 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
178
lib/bericht.lib.php
Normal 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
2
sql/llx_bericht.key.sql
Normal 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
20
sql/llx_bericht.sql
Normal 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;
|
||||
2
sql/llx_bericht_page.key.sql
Normal file
2
sql/llx_bericht_page.key.sql
Normal 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
15
sql/llx_bericht_page.sql
Normal 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;
|
||||
Loading…
Reference in a new issue