bericht/bericht_card.php
Eduard Wisch 4102ebfe5b
All checks were successful
Deploy bericht / deploy (push) Successful in 13s
Editor-Ueberarbeitung 1.5.0: Anhaenge-Kacheln, echte Seitengeometrie, Arbeiten ohne Reload [deploy]
Anhaenge-Spalte: Kachelraster mit Server-Thumbnails statt Dateinamen-Liste (Bilder per GD,
PDFs per PDF.js), Grossansicht per Klick, Auswahlreihenfolge sichtbar, Sortierung/Filter
(Aufnahmezeit per eigenem EXIF-Parser, da im Prod-Container ohne exif-Modul), ziehbare
Spaltenbreite, Kachelgroesse, Drag & Drop auf Seitenliste/Arbeitsflaeche/Raster-Plaetze.

Arbeitstisch zeigt jetzt Kopf-, Inhalts- und Fussbereich passend zur echten Seitengeometrie
(bericht_page_geometry()) - Editor und PDF-Erzeugung rechnen mit denselben Werten, das PDF
setzt die Arbeitsflaeche 1:1 statt sie ein zweites Mal einzupassen.

Neu: Bild einer Seite austauschen (auch einzelne Raster-Plaetze), Seiten mehrfach markieren
und sammeln loeschen/duplizieren, Autosave mit Verlassen-Warnung, Tastenkuerzel, kein
location.reload() mehr nach Hinzufuegen/Loeschen/Hochladen (ajax/fragments.php).

Nebenbei behoben: dol_dir_list() ohne mode=1 (Dateigroesse leer), PDF.js-Worker-Pfad ohne
/custom-Praefix, PDFs liefen als Download statt inline, Groessen-Select blieb leer,
Mehrfach-Upload nahm nur die erste Datei, Race-Condition bei schnellem Seitenwechsel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:33:20 +02:00

725 lines
38 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(dol_buildpath('/bericht/css/imageviewer.css', 1)), '', '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().'" '
.'data-dolconfirm="'.dol_escape_htmltag($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>';
// Bestätigungsdialog für Lösch-Buttons (eigener Modal statt browser-confirm)
print '<script>
document.addEventListener("click", function(e) {
var el = e.target.closest("[data-dolconfirm]");
if (!el) return;
if (el.dataset._dolconfirmed === "1") return;
e.preventDefault();
e.stopPropagation();
var msg = el.getAttribute("data-dolconfirm");
var overlay = document.createElement("div");
overlay.className = "bericht-dolmodal-overlay";
overlay.innerHTML = \'<div class="bericht-dolmodal">\'
+ \'<div class="bericht-dolmodal-title">Bestätigen</div>\'
+ \'<div class="bericht-dolmodal-body"></div>\'
+ \'<div class="bericht-dolmodal-foot">\'
+ \'<button class="button" data-action="cancel">Abbrechen</button>\'
+ \'<button class="butActionDelete" data-action="ok">Löschen</button>\'
+ \'</div></div>\';
overlay.querySelector(".bericht-dolmodal-body").textContent = msg;
document.body.appendChild(overlay);
overlay.addEventListener("click", function(ev) {
var action = ev.target.getAttribute("data-action");
if (!action && ev.target === overlay) action = "cancel";
if (!action) return;
overlay.remove();
if (action === "ok") {
el.dataset._dolconfirmed = "1";
if (el.tagName === "A") window.location.href = el.getAttribute("href");
else el.click();
}
});
}, true);
</script>';
} else {
/*
* MODUS B: Editor — bestehender Bericht wird bearbeitet
*/
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
$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,
'element_id' => (int) $parent->id,
'element_type' => $element,
'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),
'replace_page_source' => dol_buildpath('/bericht/ajax/replace_page_source.php', 1),
'fragments' => dol_buildpath('/bericht/ajax/fragments.php', 1),
'page_bulk' => dol_buildpath('/bericht/ajax/page_bulk.php', 1),
'pdf_worker' => dol_buildpath('/bericht/js/lib/pdf.worker.min.js', 1),
),
'geometry' => array(
'plain' => bericht_page_geometry($bericht->page_format ?: 'A4', $bericht->page_orientation ?: 'P', false),
'note' => bericht_page_geometry($bericht->page_format ?: 'A4', $bericht->page_orientation ?: 'P', true),
),
'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>';
}
// --- Kopfzeile: Identität des Berichts auf einen Blick ---
$is_final = ((int) $bericht->status === Bericht::STATUS_FINAL);
$parent_link = '';
if (!empty($parent->id)) {
$parent_link = $parent->getNomUrl(1);
}
$cust_link = '';
if (!empty($parent->socid)) {
$soc = new Societe($db);
if ($soc->fetch($parent->socid) > 0) $cust_link = $soc->getNomUrl(1);
}
print '<div class="bericht-meta-head">';
print '<div class="bericht-meta-ident">';
print '<span class="bericht-meta-icon">📄</span>';
print '<span class="bericht-meta-ref">'.dol_escape_htmltag($bericht->ref).'</span>';
print '<span class="bericht-meta-version">v'.((int) $bericht->version).'</span>';
print '<span class="bericht-badge '.($is_final ? 'badge-final' : 'badge-draft').'">'
.($is_final ? 'Finalisiert' : 'Entwurf').'</span>';
if ($version_links) print '<span class="bericht-meta-versions">'.$version_links.'</span>';
print '</div>';
print '<div class="bericht-meta-facts">';
if ($cust_link) {
print '<span class="bericht-fact"><span class="bericht-fact-label">Kunde</span>'.$cust_link.'</span>';
}
if ($parent_link) {
print '<span class="bericht-fact"><span class="bericht-fact-label">Beleg</span>'.$parent_link.'</span>';
}
if ($bericht->auftragsnummer) {
print '<span class="bericht-fact"><span class="bericht-fact-label">'.$langs->trans("BerichtAuftragsnummer").'</span>'
.'<strong>'.dol_escape_htmltag($bericht->auftragsnummer).'</strong></span>';
}
print '<span class="bericht-fact"><span class="bericht-fact-label">Seiten</span><strong id="meta-page-count">'.count($pages).'</strong></span>';
print '</div>';
print '</div>'; // .bericht-meta-head
// --- Bearbeitbare Felder ---
print '<div class="bericht-meta-fields">';
print '<div class="bericht-field grow">';
print '<label for="meta-titel">'.$langs->trans("BerichtTitle").'</label>';
print '<input type="text" id="meta-titel" name="titel" value="'.dol_escape_htmltag($bericht->titel).'">';
print '</div>';
print '<div class="bericht-field">';
print '<label for="meta-template">'.$langs->trans("BerichtTemplate").'</label>';
print '<select id="meta-template" 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>';
print '</div>';
print '<div class="bericht-field">';
print '<label for="meta-format">Seitenformat</label>';
print '<div class="bericht-field-row">';
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 '<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 '</div>';
print '<span class="bericht-field-hint">Wird beim Finalisieren angewendet</span>';
print '</div>';
print '</div>'; // .bericht-meta-fields
print '</form>';
print '</div>';
// Hauptlayout: links Anhänge, Mitte Editor, rechts Seiten
print '<div class="bericht-layout">';
// LINKS: Anhänge-Browser (Kachelraster mit Miniaturen)
$used_paths = bericht_used_source_paths($db, $bericht->id);
print '<aside class="bericht-attachments">';
print '<div class="bericht-att-header">';
print '<h4>'.$langs->trans("BerichtAvailableAttachments").'</h4>';
print '<div class="bericht-att-viewswitch">';
print '<button type="button" class="att-view-btn active" data-view="grid" title="Kachelansicht mit Vorschaubildern">▦</button>';
print '<button type="button" class="att-view-btn" data-view="list" title="Kompakte Listenansicht">☰</button>';
print '<button type="button" id="att-size-btn" title="Kachelgröße wechseln (klein / mittel / groß)">⤢</button>';
print '<button type="button" id="att-collapse-btn" title="Anhänge-Spalte einklappen — mehr Platz für die Seite">«</button>';
print '</div>';
print '</div>';
// Sortierung + Filter der Anhänge (wirkt innerhalb jeder Herkunfts-Gruppe)
print '<div class="bericht-att-tools">';
print '<select id="att-sort" title="Reihenfolge der Anhänge">';
print '<option value="taken">Aufnahmezeit ↑</option>';
print '<option value="taken_desc">Aufnahmezeit ↓</option>';
print '<option value="name">Name</option>';
print '<option value="size_desc">Größe ↓</option>';
print '</select>';
print '<select id="att-filter" title="Nur bestimmte Dateitypen zeigen">';
print '<option value="all">Alle</option>';
print '<option value="image">Nur Bilder</option>';
print '<option value="pdf">Nur PDFs</option>';
print '<option value="unused">Noch nicht verwendet</option>';
print '</select>';
print '</div>';
$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 {
bericht_print_attachment_list($by_source, $used_paths);
print '<div class="bericht-add-selected">';
print '<div class="att-selection-info"><span id="att-selected-count">0</span> ausgewählt'
.' <button type="button" id="att-clear-selection" title="Auswahl aufheben">zurücksetzen</button></div>';
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 in der angezeigten Reihenfolge (① ② ③) mit dem gewählten Layout ü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,.webp" multiple>';
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>';
// Ziehbare Trennlinie: Breite der Anhänge-Spalte anpassen
print '<div id="bericht-splitter" class="bericht-splitter" title="Breite der Anhänge-Spalte ziehen (Doppelklick setzt zurück)"></div>';
// 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").' (V)">↖</button>';
print '<button type="button" class="tool-btn" data-tool="draw" title="'.$langs->trans("BerichtToolDraw").' (P)">✏️</button>';
print '<button type="button" class="tool-btn" data-tool="rect" title="'.$langs->trans("BerichtToolRect").' (R)">▭</button>';
print '<button type="button" class="tool-btn" data-tool="circle" title="'.$langs->trans("BerichtToolCircle").' (K)">○</button>';
print '<button type="button" class="tool-btn" data-tool="arrow" title="'.$langs->trans("BerichtToolArrow").' (A)">↗</button>';
print '<button type="button" class="tool-btn" data-tool="text" title="'.$langs->trans("BerichtToolText").' (T)">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").' (Strg+Z)">↶</button>';
print '<button type="button" id="btn-redo" title="'.$langs->trans("BerichtRedo").' (Strg+Y)">↷</button>';
print '<button type="button" id="btn-delete-selected" title="'.$langs->trans("BerichtToolDelete").' (Entf)">🗑️</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 '<button type="button" id="btn-toggle-margins" class="active" title="Seitenränder und Kopf-/Fußbereich ein- oder ausblenden">📐 Rand</button>';
print '</div>';
print '<div class="bericht-canvas-wrap">';
// Das Blatt: Kopfzone (Logo/Titel), Inhaltsbereich (= Arbeitsfläche), Fusszone.
// Die Arbeitsfläche entspricht 1:1 dem Bereich, der später gedruckt wird.
print '<div id="bericht-sheet" class="bericht-sheet">';
print '<div class="sheet-zone sheet-header"><span class="sheet-zone-label">Kopfbereich — Logo, Titel, Firmenname</span></div>';
print '<div class="sheet-body">';
print '<canvas id="pdf-canvas"></canvas>';
print '<canvas id="fabric-canvas" class="fabric-overlay"></canvas>';
print '</div>';
print '<div id="sheet-note-zone" class="sheet-zone sheet-note" style="display:none;"><span class="sheet-zone-label">Notiz</span></div>';
print '<div class="sheet-zone sheet-footer"><span class="sheet-zone-label">Fusszeile — Seitenzahl</span></div>';
print '</div>';
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>';
// Sammelaktionen — erscheinen, sobald Seiten markiert sind (Klick auf die Nummer)
print '<div id="bericht-page-bulk" class="bericht-page-bulk" style="display:none;">';
print '<span><strong id="page-sel-count">0</strong> markiert</span>';
print '<button type="button" id="btn-pages-duplicate" title="Markierte Seiten verdoppeln — die Kopie kommt direkt dahinter">⧉</button>';
print '<button type="button" id="btn-pages-delete" title="Markierte Seiten löschen">🗑️</button>';
print '<button type="button" id="btn-pages-unselect" title="Markierung aufheben (Esc)">✕</button>';
print '</div>';
bericht_print_page_list($pages);
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 (Strg+S) — es wird ohnehin automatisch gespeichert">💾 '.$langs->trans("BerichtSaveDraft").'</button>';
print '<span id="bericht-save-state" class="save-state" title="Änderungen werden nach kurzer Pause automatisch gespeichert"></span>';
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().'" '
.'data-dolconfirm="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="butAction" 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().'" '
.'data-dolconfirm="'.dol_escape_htmltag($langs->trans("BerichtConfirmDelete")).'" '
.'class="butActionDelete">🗑️ '.$langs->trans("Delete").'</a>';
}
print '</div>';
print '</div>'; // .bericht-editor
// Statusleiste für den Bild-Austausch — wird sichtbar, sobald auf einer
// Seite "🔄" geklickt wurde. Fest am unteren Rand, damit sie beim Scrollen
// durch die Fotos nicht verschwindet.
print '<div id="bericht-replace-hint" class="bericht-replace-hint" style="display:none;">';
print '<span class="replace-hint-icon">🔄</span>';
print '<span class="replace-hint-text">Ersatzbild für <strong id="replace-page-label">Seite</strong> wählen — links auf ein Bild klicken</span>';
print '<span id="replace-slot-picker" class="replace-slot-picker" style="display:none;">';
print '<span class="replace-slot-label">Welcher Platz?</span><span id="replace-slot-buttons"></span>';
print '</span>';
print '<button type="button" id="btn-cancel-replace" title="Austausch abbrechen (Esc)">Abbrechen (Esc)</button>';
print '</div>';
// 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 src="'.dol_buildpath('/bericht/js/imageviewer.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();