bericht/bericht_card.php
Eduard Wisch 021ef1cbb2
All checks were successful
Deploy bericht / deploy (push) Successful in 1s
DolEditor für Notiz + Notiz unter Bild + Hack-Font im PDF [deploy]
- bericht_card.php: Plain-Textarea durch DolEditor (CKEditor, dolibarr_notes-Toolbar)
  ersetzt, damit Notizen formatiert werden können.
- editor.js: getNoteValue()/setNoteValue() Helper für transparentes
  CKEditor/Textarea-Handling an allen Zugriffsstellen.
- lib/bericht.lib.php:
  * bericht_write_note_html() rendert CKEditor-HTML via TCPDF::writeHTMLCell
  * Composite-Branch: Notiz direkt unter dem Bild statt mit SetY(-20) unten
  * bericht_ensure_hack_font() registriert Hack-TTFs beim ersten PDF-Run
  * Alle helvetica-SetFont-Calls auf Hack umgestellt (Corporate-Font)
- fonts/: Hack-Regular/Bold/Italic/BoldItalic.ttf (Eddys Corporate-Font)
- bericht_batch.php: ebenfalls Hack-Font
2026-04-09 15:25:10 +02:00

607 lines
33 KiB
PHP

<?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: Bericht zur aktuellen Karte verknüpfen
if ($action === 'link' && $berichtid > 0 && $user->hasRight('bericht', 'write')) {
$b = new Bericht($db);
if ($b->fetch($berichtid) > 0) {
$b->linkToElement($element, $parent->id);
setEventMessages('Bericht der Rechnung zugeordnet', null, 'mesgs');
}
header("Location: ".$_SERVER['PHP_SELF'].'?id='.$parent->id.'&element='.$element);
exit;
}
if ($action === 'unlink' && $berichtid > 0 && $user->hasRight('bericht', 'write')) {
$b = new Bericht($db);
if ($b->fetch($berichtid) > 0) {
$b->unlinkFromElement($element, $parent->id);
setEventMessages('Verknüpfung entfernt', null, 'mesgs');
}
header("Location: ".$_SERVER['PHP_SELF'].'?id='.$parent->id.'&element='.$element);
exit;
}
// Aktion: neuen Bericht anlegen (ggf. aus Vorlage)
if ($action === 'create' && $user->hasRight('bericht', 'write')) {
$tpl_id = GETPOSTINT('template_id');
if ($tpl_id > 0) {
$new_id = Bericht::createFromTemplate($db, $user, $tpl_id, $element, $parent->id, $auftragsnummer);
if ($new_id) {
header("Location: ".$_SERVER['PHP_SELF'].'?berichtid='.$new_id);
exit;
}
setEventMessages('Vorlage konnte nicht angewendet werden', null, 'errors');
}
$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: neue Version erstellen
if ($action === 'new_version' && $berichtid > 0 && $user->hasRight('bericht', 'write')) {
$new_id = $bericht->duplicateAsNewVersion($user);
if ($new_id) {
setEventMessages('Neue Version erstellt', null, 'mesgs');
header("Location: ".$_SERVER['PHP_SELF'].'?berichtid='.$new_id);
exit;
}
setEventMessages('Versionierung fehlgeschlagen', null, '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
* - Berichte direkt zugeordnet (element_type+fk_element)
* - Berichte zusätzlich verknüpft (llx_element_element)
* - NUR auf Rechnung: Berichte aus den verknüpften Aufträgen (read-only Sicht)
*/
$list_direct = Bericht::fetchAllForElement($db, $element, $parent->id);
$list_linked = Bericht::fetchLinkedForElement($db, $element, $parent->id);
// Aus verknüpften Aufträgen: nur wenn aktuelle Karte eine Rechnung ist
$list_from_orders = array();
if ($element === 'invoice') {
$parent->fetchObjectLinked();
if (!empty($parent->linkedObjects['commande'])) {
foreach ($parent->linkedObjects['commande'] as $linked_order) {
$more = Bericht::fetchAllForElement($db, 'order', $linked_order->id);
foreach ($more as $b) {
// Nicht doppelt zeigen wenn schon manuell verknüpft
$b->_source_order_ref = $linked_order->ref;
$b->_source_order_id = $linked_order->id;
$list_from_orders[] = $b;
}
}
}
}
// Set der bereits verknüpften IDs für Vergleich
$linked_ids = array_map(function ($b) { return (int) $b->id; }, $list_linked);
print '<div class="bericht-overview">';
print '<div class="bericht-overview-header">';
print '<h3>'.$langs->trans("Berichte").'</h3>';
if ($user->hasRight('bericht', 'write')) {
$templates = Bericht::fetchAllTemplates($db);
print '<form method="post" class="inline-block" style="display:flex;gap:6px;align-items:center;">';
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.'">';
if (!empty($templates)) {
print '<select name="template_id" title="Vorlage verwenden (optional)">';
print '<option value="0">— Leerer Bericht —</option>';
foreach ($templates as $t) {
print '<option value="'.$t->id.'">📋 '.dol_escape_htmltag($t->template_label ?: $t->titel ?: $t->ref).'</option>';
}
print '</select>';
}
print '<button type="submit" class="butAction">+ '.$langs->trans("BerichtNew").'</button>';
print '</form>';
}
print '</div>';
/* ----- Helper: Bericht-Tabelle rendern ----- */
$renderBerichtTable = function ($title, $items, $mode = 'direct') use ($langs, $user, $element, $parent) {
if (empty($items)) return;
print '<h4 style="margin-top:16px;margin-bottom:6px;opacity:0.8;">'.$title.' ('.count($items).')</h4>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Ref").'</th>';
print '<th>'.$langs->trans("BerichtTitle").'</th>';
if ($mode === 'from_order') print '<th>Aus Auftrag</th>';
print '<th>'.$langs->trans("BerichtCreatedAt").'</th>';
print '<th>'.$langs->trans("BerichtStatus").'</th>';
print '<th class="right">'.$langs->trans("Action").'</th>';
print '</tr>';
foreach ($items as $b) {
$url = $_SERVER['PHP_SELF'].'?berichtid='.$b->id;
print '<tr class="oddeven">';
print '<td><a href="'.$url.'">'.dol_escape_htmltag($b->ref).'</a></td>';
print '<td>'.dol_escape_htmltag($b->titel).'</td>';
if ($mode === 'from_order') {
print '<td>'.dol_escape_htmltag($b->_source_order_ref ?? '').'</td>';
}
print '<td>'.dol_print_date($b->datec, 'dayhour').'</td>';
print '<td>'.$b->getLibStatut().'</td>';
print '<td class="right">';
print '<a href="'.$url.'" class="button-small">'.$langs->trans("Open").'</a> ';
if ($mode === 'from_order' && $user->hasRight('bericht', 'write')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=link&berichtid='.$b->id.'&id='.$parent->id.'&element='.$element.'&token='.newToken().'" '
.'class="button-small" title="Diesen Bericht zusätzlich der aktuellen Karte zuordnen">→ Übernehmen</a> ';
}
if ($mode === 'linked' && $user->hasRight('bericht', 'write')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=unlink&berichtid='.$b->id.'&id='.$parent->id.'&element='.$element.'&token='.newToken().'" '
.'class="button-small" title="Verknüpfung lösen">⊗ Lösen</a> ';
}
if ($mode !== 'from_order' && $user->hasRight('bericht', 'delete')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&berichtid='.$b->id.'&token='.newToken().'" '
.'onclick="return confirm(\''.dol_escape_js($langs->trans("BerichtConfirmDelete")).'\')" '
.'class="button-small button-delete">'.$langs->trans("Delete").'</a>';
}
print '</td>';
print '</tr>';
}
print '</table>';
};
if (empty($list_direct) && empty($list_linked) && empty($list_from_orders)) {
print '<div class="opacitymedium" style="padding:20px;">'.$langs->trans("BerichtNoReports").'</div>';
} else {
$renderBerichtTable('Berichte direkt zu dieser Karte', $list_direct, 'direct');
$renderBerichtTable('Zusätzlich verknüpfte Berichte', $list_linked, 'linked');
if ($element === 'invoice') {
// Auftragsberichte filtern: solche die schon verknüpft sind, ausblenden
$remaining = array_filter($list_from_orders, function ($b) use ($linked_ids) {
return !in_array((int) $b->id, $linked_ids, true);
});
$renderBerichtTable('Berichte aus verknüpften Aufträgen', $remaining, 'from_order');
}
}
print '</div>';
} 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),
'preview_pdf' => dol_buildpath('/bericht/ajax/preview_pdf.php', 1),
'delete_attachment'=> dol_buildpath('/bericht/ajax/delete_attachment.php', 1),
'save_meta' => dol_buildpath('/bericht/ajax/save_meta.php', 1),
'save_page_options'=> dol_buildpath('/bericht/ajax/save_page_options.php', 1),
'create_grid_page' => dol_buildpath('/bericht/ajax/create_grid_page.php', 1),
'set_slot_image' => dol_buildpath('/bericht/ajax/set_slot_image.php', 1),
'create_upload_token' => dol_buildpath('/bericht/ajax/create_upload_token.php', 1),
'list_pages' => dol_buildpath('/bericht/ajax/list_pages.php', 1),
'verify_signature' => dol_buildpath('/bericht/ajax/verify_signature.php', 1),
'save_as_template' => dol_buildpath('/bericht/ajax/save_as_template.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.'">';
// Versionsliste derselben Kette ermitteln
$chain_id = $bericht->fk_bericht_parent ?: $bericht->id;
$version_links = '';
$vres = $db->query("SELECT rowid, version FROM ".$db->prefix()."bericht"
." WHERE rowid = ".((int) $chain_id)." OR fk_bericht_parent = ".((int) $chain_id)
." ORDER BY version ASC");
if ($vres) {
$vs = array();
while ($vr = $db->fetch_object($vres)) {
$active = ((int) $vr->rowid === (int) $bericht->id);
$vs[] = '<a href="'.$_SERVER['PHP_SELF'].'?berichtid='.$vr->rowid.'" '
.($active ? 'style="font-weight:bold;text-decoration:underline;"' : '').'>v'.$vr->version.'</a>';
}
if (count($vs) > 1) $version_links = ' &nbsp; <span class="opacitymedium small">Versionen: '.implode(' · ', $vs).'</span>';
}
print '<table class="border centpercent"><tr>';
print '<td class="titlefield">'.$langs->trans("Ref").'</td><td>'.dol_escape_htmltag($bericht->ref).' <span class="opacitymedium">v'.((int) $bericht->version).'</span>'.$version_links.'</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><tr>';
// Format und Orientation
print '<td>Format</td><td>';
print '<select id="meta-format" name="page_format" title="Seitenformat des PDFs">';
foreach (array('A4', 'A3', 'A5', 'Letter') as $fmt) {
$sel = ($fmt === $bericht->page_format) ? ' selected' : '';
print '<option value="'.$fmt.'"'.$sel.'>'.$fmt.'</option>';
}
print '</select>';
print ' &nbsp; ';
print '<select id="meta-orientation" name="page_orientation" title="Hoch- oder Querformat">';
foreach (array('P' => 'Hochformat', 'L' => 'Querformat') as $k => $v) {
$sel = ($k === $bericht->page_orientation) ? ' selected' : '';
print '<option value="'.$k.'"'.$sel.'>'.$v.'</option>';
}
print '</select>';
print '</td>';
print '<td colspan="2"><span class="opacitymedium small">Wird beim Finalisieren angewendet</span></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 '<div class="bericht-att-item">';
print '<input type="checkbox" class="att-check" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-mime="'.dol_escape_htmltag($f['mime']).'" title="Auswählen">';
print '<span class="att-icon">'.$icon.'</span>';
print '<span class="att-name" title="'.dol_escape_htmltag($f['filename']).'">'.dol_escape_htmltag($f['filename']).'</span>';
print '<span class="att-size opacitymedium small">'.dol_print_size($f['size']).'</span>';
if ($user->hasRight('bericht', 'write')) {
print '<button type="button" class="att-delete" data-relpath="'.dol_escape_htmltag($f['relpath']).'" data-source-ref="'.dol_escape_htmltag($f['source_ref']).'" title="Diese Datei aus dem Anhang löschen">🗑️</button>';
}
print '</div>';
}
print '</div>';
}
print '<div class="bericht-add-selected" style="margin-top:6px;">';
print '<label class="opacitymedium small" style="display:block;margin-bottom:4px;">Layout für die Auswahl:</label>';
print '<select id="add-selected-layout" style="width:100%;margin-bottom:6px;">';
print '<option value="single">Einzeln (1 Bild pro Seite)</option>';
print '<option value="grid_2">2 pro Seite (oben/unten)</option>';
print '<option value="grid_2v">2 pro Seite (links/rechts)</option>';
print '<option value="grid_4">4 pro Seite (2x2)</option>';
print '<option value="grid_6">6 pro Seite (3x2)</option>';
print '<option value="before_after">Vorher / Nachher (2 pro Seite)</option>';
print '</select>';
print '<button type="button" id="btn-add-selected" class="butAction" style="width:100%;" title="Alle ausgewählten Bilder mit dem gewählten Layout in den Bericht übernehmen">'.$langs->trans("BerichtAddSelectedToReport").'</button>';
print '</div>';
}
print '<hr>';
print '<div class="bericht-upload">';
print '<label for="bericht-extra-upload" class="butAction" title="Datei vom PC hochladen">📤 '.$langs->trans("BerichtUploadExtra").'</label>';
print '<input type="file" id="bericht-extra-upload" style="display:none" accept=".pdf,.png,.jpg,.jpeg">';
print '<button type="button" id="btn-show-qr" class="butAction" title="QR-Code für Mobile-Upload erzeugen" style="margin-top:6px;">📱 Mobil hochladen</button>';
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 '<label title="Linien-/Textfarbe">'.$langs->trans("BerichtColor").': <input type="color" id="tool-color" value="#ff0000" title="Farbe wählen"></label>';
print '<label title="Linien-/Strichstärke in Pixel">'.$langs->trans("BerichtStrokeWidth").': <input type="range" id="tool-stroke" min="1" max="20" value="3" title="Strichstärke"></label>';
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 '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").'">🗑️</button>';
// ====== Zweite Zeile ======
print '<div class="row-break"></div>';
// Text-Optionen
print '<label class="text-tool-option" title="Schriftart für Text-Annotationen">Schrift: <select id="tool-fontfamily" title="Schriftart">'
.'<option value="Helvetica">Helvetica</option>'
.'<option value="Arial">Arial</option>'
.'<option value="Times New Roman">Times New Roman</option>'
.'<option value="Courier New">Courier New</option>'
.'<option value="Verdana">Verdana</option>'
.'<option value="Georgia">Georgia</option>'
.'</select></label>';
print '<label class="text-tool-option" title="Schriftgröße in Pixel">Größe: <input type="number" id="tool-fontsize" min="8" max="120" value="24" style="width:60px" title="Schriftgröße"></label>';
print '<label class="text-tool-option" title="Fett"><input type="checkbox" id="tool-bold" title="Fett"> <b>B</b></label>';
print '<label class="text-tool-option" title="Kursiv"><input type="checkbox" id="tool-italic" title="Kursiv"> <i>I</i></label>';
print '<label class="text-tool-option" title="Text-Hintergrundfarbe">BG: <input type="color" id="tool-bgcolor" value="#ffffff"></label>';
print '<button type="button" id="tool-bg-off" class="text-tool-option" title="Kein Hintergrund" style="padding:4px 8px;">Ø</button>';
print '<span class="sep"></span>';
// Zoom
print '<button type="button" id="btn-zoom-out" title="Verkleinern (Zoom -)">🔍−</button>';
print '<span id="zoom-label" title="Aktueller Zoom" style="min-width:42px;text-align:center;font-size:12px;">100%</span>';
print '<button type="button" id="btn-zoom-in" title="Vergrößern (Zoom +)">🔍+</button>';
print '<button type="button" id="btn-zoom-reset" title="Zoom auf 100% zurücksetzen">⟳%</button>';
print '<span class="sep"></span>';
print '<button type="button" id="btn-rotate-left" title="'.$langs->trans("BerichtRotateLeft").' (Seite 90° nach links drehen)">⟲</button>';
print '<button type="button" id="btn-rotate-right" title="'.$langs->trans("BerichtRotateRight").' (Seite 90° nach rechts drehen)">⟳</button>';
// ====== Dritte Zeile: Layout & Bildgröße der aktuellen Seite ======
print '<div class="row-break"></div>';
print '<label title="Seiten-Layout: Single = 1 Bild, Grid = mehrere Bilder">Layout: <select id="page-layout">'
.'<option value="single">Single (1 Bild)</option>'
.'<option value="grid_2">2 Bilder (oben/unten)</option>'
.'<option value="grid_2v">2 Bilder (links/rechts)</option>'
.'<option value="grid_4">4 Bilder (2x2)</option>'
.'<option value="grid_6">6 Bilder (3x2)</option>'
.'<option value="before_after">Vorher / Nachher</option>'
.'<option value="title_only">Nur Titel</option>'
.'</select></label>';
print '<label class="single-only" title="Wie groß das Bild auf der Seite gedruckt wird (nur Single)">Größe: <select id="page-imgscale">'
.'<option value="1.0">Vollseite (100%)</option>'
.'<option value="0.7">Groß (70%)</option>'
.'<option value="0.5">Mittel (50%)</option>'
.'<option value="0.3">Klein (30%)</option>'
.'</select></label>';
print '<label class="single-only" title="Position auf der Seite (nur bei kleineren Bildern)">Position: <select id="page-imgalign">'
.'<option value="fit">Anpassen</option>'
.'<option value="center">Zentriert</option>'
.'<option value="topleft">Oben links</option>'
.'<option value="topright">Oben rechts</option>'
.'<option value="bottomleft">Unten links</option>'
.'<option value="bottomright">Unten rechts</option>'
.'</select></label>';
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>';
// DolEditor (CKEditor) — wird im PDF mit writeHTMLCell gerendert
require_once DOL_DOCUMENT_ROOT.'/core/class/doleditor.class.php';
$noteEditor = new DolEditor('page-note', '', '100%', 120, 'dolibarr_notes', 'In', false, true, true, 4, '100%');
$noteEditor->Create(0);
print '</div>';
print '</main>';
// RECHTS: Seiten-Thumbnails
print '<aside class="bericht-pages">';
print '<div class="bericht-pages-header">';
print '<h4>'.$langs->trans("BerichtPages").' (<span id="page-count">'.count($pages).'</span>)</h4>';
print '<button type="button" id="btn-toggle-thumb-bg" title="Hell/Dunkel">🌓</button>';
print '</div>';
print '<div id="bericht-page-list" class="page-list paper-light">';
foreach ($pages as $idx => $p) {
// Ist das eine gehärtete Unterschrift? (hat .meta.json neben der source_path)
$is_signature = false;
if ($p->source_path && strpos($p->note ?? '', 'Unterschrift') === 0) {
$full_check = DOL_DATA_ROOT.'/'.$p->source_path;
if (file_exists($full_check.'.meta.json')) {
$is_signature = true;
}
}
print '<div class="page-thumb'.($is_signature ? ' page-signature' : '').'" data-pageid="'.$p->id.'" data-order="'.$p->page_order.'">';
print '<div class="page-thumb-paper">';
print '<canvas class="thumb-canvas"></canvas>';
print '</div>';
print '<div class="page-thumb-label"><span class="page-num">'.($idx + 1).'</span>'
.($is_signature ? ' <span class="sig-badge" title="Unterschrift">🔒</span>' : '')
.'</div>';
print '<div class="page-thumb-actions">';
if ($is_signature) {
print '<button type="button" class="thumb-verify" title="Unterschrift prüfen" data-pageid="'.$p->id.'">🔍</button>';
}
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" title="Aktuellen Stand als Entwurf speichern">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
print '<button type="button" id="btn-preview" class="butAction" title="PDF-Vorschau ansehen ohne zu finalisieren">👁️ Vorschau</button>';
print '<button type="button" id="btn-save-as-template" class="butAction" title="Aktuellen Bericht als wiederverwendbare Vorlage speichern">📋 Als Vorlage</button>';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=new_version&berichtid='.$bericht->id.'&token='.newToken().'" '
.'onclick="return confirm(\'Neue Version (v'.((int)$bericht->version + 1).') erstellen? Der aktuelle Bericht bleibt unverändert.\')" '
.'class="butAction" title="Neue Version des Berichts anlegen, aktueller bleibt erhalten">🔀 Neue Version</a>';
print '<button type="button" id="btn-finalize" class="butActionConfirm" title="PDF erzeugen und unter Verknüpfte Dokumente ablegen">📑 '.$langs->trans("BerichtFinalize").'</button>';
if ($user->hasRight('bericht', 'delete')) {
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&berichtid='.$bericht->id.'&token='.newToken().'" '
.'onclick="return confirm(\''.dol_escape_js($langs->trans("BerichtConfirmDelete")).'\')" '
.'class="butActionDelete">🗑️ '.$langs->trans("Delete").'</a>';
}
print '</div>';
print '</div>'; // .bericht-editor
// PDF-Vorschau-Modal
print '<div id="bericht-preview-modal" class="bericht-modal" style="display:none;">';
print ' <div class="bericht-modal-backdrop"></div>';
print ' <div class="bericht-modal-content">';
print ' <div class="bericht-modal-header">';
print ' <h3>📑 PDF-Vorschau</h3>';
print ' <button type="button" id="bericht-modal-close" title="Schließen">✕</button>';
print ' </div>';
print ' <div class="bericht-modal-body">';
print ' <iframe id="bericht-preview-iframe" src="about:blank"></iframe>';
print ' </div>';
print ' </div>';
print '</div>';
// QR-Code-Modal für Mobile-Upload
print '<div id="bericht-qr-modal" class="bericht-modal" style="display:none;">';
print ' <div class="bericht-modal-backdrop"></div>';
print ' <div class="bericht-modal-content qr-modal">';
print ' <div class="bericht-modal-header">';
print ' <h3>📱 Mobile-Upload</h3>';
print ' <button type="button" id="bericht-qr-close" title="Schließen">✕</button>';
print ' </div>';
print ' <div class="bericht-modal-body qr-modal-body">';
print ' <div id="qr-code-container"></div>';
print ' <div class="qr-info">';
print ' <p>Scanne den QR-Code mit deinem Handy, um direkt Fotos in diesen Bericht hochzuladen.</p>';
print ' <p class="opacitymedium small">Token gültig: <span id="qr-validity">—</span> Min</p>';
print ' <p class="qr-url-display"><a id="qr-url-link" href="" target="_blank">Link öffnen</a></p>';
print ' <p class="qr-status opacitymedium small">Warte auf Uploads…</p>';
print ' </div>';
print ' </div>';
print ' </div>';
print '</div>';
// PDF.js + Fabric.js (lokal)
print '<script src="'.dol_buildpath('/bericht/js/lib/pdf.min.js', 1).'"></script>';
print '<script src="'.dol_buildpath('/bericht/js/lib/fabric.min.js', 1).'"></script>';
print '<script src="'.dol_buildpath('/bericht/js/lib/Sortable.min.js', 1).'"></script>';
print '<script src="'.dol_buildpath('/bericht/js/lib/qrcode.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();