bericht/admin/signature_box_editor.php
Eduard Wisch ca2b796b36
All checks were successful
Deploy bericht / deploy (push) Successful in 6s
Feature: Lieferschein-Unterschrift via ODT-Hook + PWA-Signatur-Workflow
- Neuer API-Endpoint api/shipments.php: Liste Lieferungen zu Auftrag, PDF-Stream, confirm (Unterschrift stempeln)
- ODT-Hook actions_bericht.class.php: ersetzt {signature} Platzhalter via odfphp->setImage, setzt {signer_name}/{signed_at}/{gps}
- Backup-Roundtrip: generateDocument-Backup → signed.pdf erzeugen → Original wiederherstellen
- JWT-Fallback in _jwt.php: ?jwt= Query-Param für <img>/<object> ohne Authorization-Header
- Admin: BERICHT_SIGNATURE_IMAGE_RATIO Feld, Toggle BERICHT_TAB_ON_SHIPMENT, Signature-Box-Editor
- DB: llx_bericht_signature_box für pro-Template mm-Box-Geometrie
- element_type='shipment' in modBericht + lib/bericht.lib.php
- element_element Richtung: commande=source, shipping=target (fk_target=expedition_id)
- DOL_DATA_ROOT-Auflösung für EXPEDITION_ADDON_PDF_ODT_PATH
- Sprachen: de_DE + en_US mit neuen Schlüsseln für Signatur-Workflow

[deploy]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:48:42 +02:00

406 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*
* Visueller Editor fuer die Unterschriftsbox auf Lieferschein-PDFs.
* Laedt ein Beispiel-Lieferschein-PDF (PDF.js), zeigt die aktuelle Box als
* Fabric.js-Rechteck und speichert die mm-Geometrie in llx_bericht_signature_box.
*/
$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';
require_once DOL_DOCUMENT_ROOT.'/expedition/class/expedition.class.php';
if (!$user->admin && !$user->hasRight('bericht', 'admin')) accessforbidden();
$langs->loadLangs(array("admin", "bericht@bericht"));
// Verfuegbare Templates: PDF-Module + ODT-Module + ODT-Dateien aus doctemplates/shipments/
$tpl_dir = DOL_DOCUMENT_ROOT.'/core/modules/expedition/doc';
$pdf_modules = array(); // ['espadon' => 'espadon', ...]
$odt_modules = array(); // ['generic_shipment_odt' => 'generic_shipment_odt']
if (is_dir($tpl_dir)) {
foreach (glob($tpl_dir.'/pdf_*.modules.php') as $f) {
$name = basename($f);
if (preg_match('/^pdf_(.+)\.modules\.php$/', $name, $m)) {
$pdf_modules[$m[1]] = $m[1];
}
}
foreach (glob($tpl_dir.'/doc_*.modules.php') as $f) {
$name = basename($f);
if (preg_match('/^doc_(.+)\.modules\.php$/', $name, $m)) {
$odt_modules[$m[1]] = $m[1];
}
}
}
// ODT-Dateien aus doctemplates/shipments/ — pro Datei eine eigene Box konfigurierbar
// EXPEDITION_ADDON_PDF_ODT_PATH enthaelt 'DOL_DATA_ROOT' woertlich + ist komma/zeilengetrennt
$odt_files = array();
$raw_dirs = getDolGlobalString('EXPEDITION_ADDON_PDF_ODT_PATH', 'DOL_DATA_ROOT/doctemplates/shipments');
foreach (explode(',', preg_replace('/[\r\n]+/', ',', trim($raw_dirs))) as $d) {
$d = trim($d);
if (!$d) continue;
$d = preg_replace('/DOL_DATA_ROOT/', DOL_DATA_ROOT, $d);
if (!is_dir($d)) continue;
foreach (glob($d.'/*.odt') as $f) {
$name = basename($f);
$key = 'odt:'.preg_replace('/\.odt$/i', '', $name);
$odt_files[$key] = $name;
}
}
$active_template = getDolGlobalString('EXPEDITION_ADDON_PDF', 'merou');
$selected = GETPOST('template', 'alphanohtml') ?: $active_template;
// Wenn nichts uebergeben + es gibt ODTs + Aktives Modul ist generic_shipment_odt → erstes ODT vorauswaehlen
if (!GETPOST('template', 'alphanohtml') && $active_template === 'generic_shipment_odt' && !empty($odt_files)) {
$selected = array_key_first($odt_files);
}
// Auswahl validieren
$all_choices = array_merge($pdf_modules, $odt_modules, $odt_files);
if (!isset($all_choices[$selected])) {
$selected = $active_template;
if (!isset($all_choices[$selected]) && !empty($all_choices)) {
$selected = array_key_first($all_choices);
}
}
// Aktuelle Box-Geometrie fuer das ausgewaehlte Template laden (oder Default)
$box = bericht_get_signature_box($db, $selected);
// Letzte vorhandene Expedition fuer Preview suchen — sonst Hinweis ausgeben
$preview_shipment_id = 0;
$r = $db->query("SELECT rowid FROM ".$db->prefix()."expedition"
." WHERE entity IN (".getEntity('expedition').")"
." ORDER BY rowid DESC LIMIT 1");
if ($r && ($o = $db->fetch_object($r))) $preview_shipment_id = (int) $o->rowid;
llxHeader('', $langs->trans("BerichtSignatureBoxConfig"));
$linkback = '<a href="'.dol_buildpath('/bericht/admin/setup.php', 1).'">'.$langs->trans("BackToSetup").'</a>';
print load_fiche_titre($langs->trans("BerichtSignatureBoxConfig"), $linkback, 'title_setup');
print '<p class="opacitymedium">'.$langs->trans("BerichtSignatureBoxConfigDesc").'</p>';
// Template-Wahl mit Gruppen
print '<form method="get" style="margin-bottom:16px;">';
print '<label style="margin-right:8px;">'.$langs->trans("BerichtPdfTemplate").':</label>';
print '<select name="template" onchange="this.form.submit()">';
if (!empty($odt_files)) {
print '<optgroup label="'.dol_escape_htmltag($langs->trans("BerichtOdtFiles")).'">';
foreach ($odt_files as $key => $filename) {
$sel = ($key === $selected) ? ' selected' : '';
$hint = ($active_template === 'generic_shipment_odt') ? ' ★' : '';
print '<option value="'.dol_escape_htmltag($key).'"'.$sel.'>'.dol_escape_htmltag($filename).$hint.'</option>';
}
print '</optgroup>';
}
if (!empty($pdf_modules)) {
print '<optgroup label="'.dol_escape_htmltag($langs->trans("BerichtPdfModules")).'">';
foreach ($pdf_modules as $tpl) {
$sel = ($tpl === $selected) ? ' selected' : '';
$is_active = ($tpl === $active_template) ? ' ★' : '';
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).$is_active.'</option>';
}
print '</optgroup>';
}
if (!empty($odt_modules)) {
print '<optgroup label="'.dol_escape_htmltag($langs->trans("BerichtOdtModules")).'">';
foreach ($odt_modules as $tpl) {
$sel = ($tpl === $selected) ? ' selected' : '';
$is_active = ($tpl === $active_template) ? ' ★' : '';
print '<option value="'.dol_escape_htmltag($tpl).'"'.$sel.'>'.dol_escape_htmltag($tpl).$is_active.'</option>';
}
print '</optgroup>';
}
print '</select>';
print ' <span class="opacitymedium" style="font-size:12px;">★ = aktuell in Dolibarr aktiv</span>';
print '</form>';
// Hinweis bei ODT-Auswahl: mm-Editor ist hier NICHT relevant.
// Stattdessen: Bild-Platzhalter "signature" im ODT einbauen — die Position waechst dann automatisch mit dem PDF-Inhalt mit.
$is_odt = (strpos($selected, 'odt:') === 0);
if ($is_odt) {
$sig_ratio = getDolGlobalString('BERICHT_SIGNATURE_IMAGE_RATIO', '0.35');
print '<div class="info" style="background:#1e3a5f;border:1px solid #4080c0;color:#e0e8f0;padding:14px 18px;border-radius:8px;margin-bottom:16px;font-size:14px;line-height:1.6;">';
print '<h4 style="margin:0 0 8px;color:#fff;">📄 ODT-Template — Platzhalter-Methode (wie EPCQR/{qrcode})</h4>';
print '<p style="margin:0;">Bei ODT-Vorlagen ist die mm-Box <strong>nicht sinnvoll</strong>: das PDF wird je nach Positionsmenge unterschiedlich lang — fixe Position würde mitten in den Zeilen landen.</p>';
print '<p style="margin:10px 0 4px;font-weight:600;">Stattdessen:</p>';
print '<ol style="margin:4px 0 0 18px;padding:0;">';
print '<li>In deinem ODT (<code>'.dol_escape_htmltag($odt_files[$selected] ?? '').'</code>) an die gewünschte Stelle den Text-Platzhalter <code>{signature}</code> schreiben — irgendwo, wo die Unterschrift erscheinen soll.</li>';
print '<li>Drumherum optional Text-Variablen einsetzen: <code>{signer_name}</code>, <code>{signed_at}</code>, <code>{gps}</code>, <code>{kunde_name}</code>, <code>{kunde_adresse}</code>, <code>{shipment_ref}</code>, <code>{order_ref}</code>, <code>{auftragsnummer}</code>, <code>{datum}</code>.</li>';
print '<li>Speichern. Fertig.</li>';
print '</ol>';
print '<p style="margin:12px 0 4px;font-size:12px;opacity:0.85;">Beim Bestätigen in der PWA ersetzt <code>setImage()</code> den <code>{signature}</code>-Text durch ein <code>&lt;draw:frame&gt;</code> mit der Kunden-Unterschrift. Die Größe wird durch den Faktor <code>BERICHT_SIGNATURE_IMAGE_RATIO</code> (aktuell <strong>'.dol_escape_htmltag($sig_ratio).'</strong>) gesteuert — bei 0.35 ergibt das ca. 7×3.5 cm.</p>';
print '<p style="margin:8px 0 0;font-size:12px;opacity:0.6;">Die mm-Felder rechts sind hier ohne Wirkung — sie gelten nur für reine PDF-Module (espadon/merou/rouget).</p>';
print '</div>';
}
if (!$preview_shipment_id) {
print '<div class="warning">'.$langs->trans("BerichtSignatureBoxNoPreviewShipment").'</div>';
} else {
// PDF-URL fuer die Vorschau: nutzt den shipments.php-Endpoint mit JWT
// Hier brauchen wir keinen JWT, weil wir admin sind und das PDF lokal lesen.
// Stattdessen: Vorschau-Endpoint nimmt nur die Expedition-ID und laedt das PDF.
$preview_url = dol_buildpath('/bericht/admin/signature_box_preview.php', 1)
.'?id='.$preview_shipment_id
.'&template='.urlencode($selected);
print '<div class="bericht-setup-section">';
print '<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start;">';
// Linke Spalte: PDF-Vorschau mit Box
print '<div style="flex:1;min-width:400px;">';
print '<h4>'.$langs->trans("BerichtPreviewWithBox").'</h4>';
print '<style>'
.'#pdf-container { position:relative; display:inline-block; line-height:0; }'
// PDF zeichnen-Canvas: bleibt im Fluss
.'#pdf-container > canvas:first-child { display:block; position:relative; z-index:1; }'
// Fabric wraps in .canvas-container — der muss absolut OBEN drauf liegen
.'#pdf-container .canvas-container { position:absolute !important; top:0 !important; left:0 !important; z-index:10 !important; }'
// Innere Canvas (upper + lower) auch sauber stacked
.'#pdf-container .canvas-container canvas { position:absolute; top:0; left:0; }'
.'</style>';
print '<div id="pdf-stage" style="background:#222;padding:8px;border-radius:6px;display:inline-block;max-width:100%;overflow:auto;">';
print '<div id="pdf-container"></div>';
print '</div>';
print '<div style="margin-top:8px;display:flex;gap:8px;align-items:center;">';
print '<button type="button" id="btn-prev-page" class="butAction"></button>';
print '<span id="page-info">…</span>';
print '<button type="button" id="btn-next-page" class="butAction"></button>';
print '</div>';
print '</div>';
// Rechte Spalte: Werte editierbar + Speichern (bei ODT optisch gedimmt)
$right_attr = $is_odt ? ' style="flex:0 0 280px;opacity:0.5;pointer-events:none;"' : ' style="flex:0 0 280px;"';
print '<div'.$right_attr.'>';
print '<h4>'.$langs->trans("BerichtBoxGeometry").($is_odt ? ' <span style="font-size:11px;font-weight:normal;">(bei ODT inaktiv)</span>' : '').'</h4>';
print '<form id="box-form">';
print '<input type="hidden" name="template_name" value="'.dol_escape_htmltag($selected).'">';
print '<table class="noborder centpercent">';
print '<tr><td>'.$langs->trans("BerichtBoxPage").'</td><td><select name="page">';
foreach (array('last' => $langs->trans("BerichtBoxPageLast"), 'first' => $langs->trans("BerichtBoxPageFirst")) as $v => $l) {
$sel = ($box['page'] == $v) ? ' selected' : '';
print '<option value="'.$v.'"'.$sel.'>'.$l.'</option>';
}
print '</select></td></tr>';
print '<tr><td>X (mm)</td><td><input type="number" step="0.5" name="x_mm" id="inp-x" value="'.((float) $box['x_mm']).'"></td></tr>';
print '<tr><td>Y (mm)</td><td><input type="number" step="0.5" name="y_mm" id="inp-y" value="'.((float) $box['y_mm']).'"></td></tr>';
print '<tr><td>'.$langs->trans("Width").' (mm)</td><td><input type="number" step="0.5" name="w_mm" id="inp-w" value="'.((float) $box['w_mm']).'"></td></tr>';
print '<tr><td>'.$langs->trans("Height").' (mm)</td><td><input type="number" step="0.5" name="h_mm" id="inp-h" value="'.((float) $box['h_mm']).'"></td></tr>';
print '<tr><td>'.$langs->trans("BerichtBoxLabel").'</td><td><input type="text" name="label" value="'.dol_escape_htmltag($box['label']).'"></td></tr>';
print '</table>';
print '<br>';
print '<button type="button" id="btn-save-box" class="butAction">💾 '.$langs->trans("Save").'</button>';
print '<button type="button" id="btn-reset-box" class="butAction" style="margin-left:8px;">'.$langs->trans("BerichtBoxResetDefault").'</button>';
print '<div id="save-status" style="margin-top:8px;opacity:0.7;font-size:13px;"></div>';
print '</form>';
print '</div>'; // rechte Spalte
print '</div>'; // Flex
print '</div>'; // section
// PDF.js einbinden + Editor-JS
$pdfjs_url = dol_buildpath('/bericht/js/lib/pdf.min.js', 1);
$pdfjs_worker_url = dol_buildpath('/bericht/js/lib/pdf.worker.min.js', 1);
$fabric_url = dol_buildpath('/bericht/js/lib/fabric.min.js', 1);
$save_url = dol_buildpath('/bericht/ajax/save_signature_box.php', 1);
?>
<script src="<?php echo dol_escape_htmltag($pdfjs_url); ?>"></script>
<script src="<?php echo dol_escape_htmltag($fabric_url); ?>"></script>
<script>
(function () {
if (window.pdfjsLib) {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = <?php echo json_encode($pdfjs_worker_url); ?>;
}
const PDF_URL = <?php echo json_encode($preview_url); ?>;
const SAVE_URL = <?php echo json_encode($save_url); ?>;
const TEMPLATE = <?php echo json_encode($selected); ?>;
const IS_ODT = <?php echo json_encode($is_odt); ?>;
const DEFAULT_BOX = <?php echo json_encode(bericht_get_signature_box($db, '')); ?>;
// 1 PDF-Point = 25.4/72 mm
const PT_TO_MM = 25.4 / 72.0;
let pdfDoc = null, pageNum = 1, pageCount = 0;
let pageWmm = 210, pageHmm = 297;
let renderScale = 1.0;
let fabricCanvas = null, fabricRect = null;
const stage = document.getElementById('pdf-container');
const inpX = document.getElementById('inp-x');
const inpY = document.getElementById('inp-y');
const inpW = document.getElementById('inp-w');
const inpH = document.getElementById('inp-h');
const pageInfo = document.getElementById('page-info');
function chosenPageNum() {
const pageSel = document.querySelector('select[name="page"]').value;
if (pageSel === 'first') return 1;
return pageCount; // last
}
async function loadPdf() {
const buf = await fetch(PDF_URL, { credentials: 'same-origin' }).then(r => r.arrayBuffer());
pdfDoc = await pdfjsLib.getDocument({ data: buf }).promise;
pageCount = pdfDoc.numPages;
pageNum = chosenPageNum();
await renderPage();
}
async function renderPage() {
const page = await pdfDoc.getPage(pageNum);
const viewport1 = page.getViewport({ scale: 1 });
// PDF-Punkte → mm
pageWmm = viewport1.width * PT_TO_MM;
pageHmm = viewport1.height * PT_TO_MM;
// Skala so waehlen, dass max 700px breit
const targetW = Math.min(700, window.innerWidth - 360);
renderScale = targetW / viewport1.width;
const viewport = page.getViewport({ scale: renderScale });
// Stage clear + Canvas zeichnen
stage.innerHTML = '';
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.display = 'block';
stage.appendChild(canvas);
const ctx = canvas.getContext('2d');
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
// Fabric-Layer obendrueber
const fabricEl = document.createElement('canvas');
fabricEl.width = viewport.width;
fabricEl.height = viewport.height;
fabricEl.style.position = 'absolute';
fabricEl.style.top = '0';
fabricEl.style.left = '0';
stage.appendChild(fabricEl);
if (fabricCanvas) { try { fabricCanvas.dispose(); } catch (e) {} }
fabricCanvas = new fabric.Canvas(fabricEl, { selection: false });
// Container-Geometrie auf PDF-Canvas-Groesse fixieren, damit das Fabric-Overlay
// (vom CSS '#pdf-container .canvas-container' absolut positioniert) deckt.
stage.style.height = viewport.height + 'px';
stage.style.width = viewport.width + 'px';
const fabricWrap = stage.querySelector('.canvas-container');
if (fabricWrap) {
fabricWrap.style.width = viewport.width + 'px';
fabricWrap.style.height = viewport.height + 'px';
}
// Box aus aktuellen Werten
drawBoxFromInputs();
pageInfo.textContent = 'Seite ' + pageNum + ' / ' + pageCount + ' (' + pageWmm.toFixed(0) + ' × ' + pageHmm.toFixed(0) + ' mm)';
}
function drawBoxFromInputs() {
if (!fabricCanvas) return;
// ODT: keine Box zeichnen — Position kommt aus dem ODT-Bildplatzhalter
if (IS_ODT) {
fabricCanvas.clear();
fabricRect = null;
return;
}
// Wenn Seite nicht der Box-Seite entspricht: keine Box zeigen
if (pageNum !== chosenPageNum()) {
fabricCanvas.clear();
fabricRect = null;
return;
}
const xmm = parseFloat(inpX.value) || 0;
const ymm = parseFloat(inpY.value) || 0;
const wmm = parseFloat(inpW.value) || 50;
const hmm = parseFloat(inpH.value) || 25;
// mm → px = mm / PT_TO_MM * renderScale
const mm2px = (mm) => (mm / PT_TO_MM) * renderScale;
fabricCanvas.clear();
fabricRect = new fabric.Rect({
left: mm2px(xmm),
top: mm2px(ymm),
width: mm2px(wmm),
height: mm2px(hmm),
fill: 'rgba(60,140,220,0.20)',
stroke: '#1e6fc0',
strokeWidth: 2,
cornerColor: '#1e6fc0',
transparentCorners: false,
hasRotatingPoint: false,
lockRotation: true,
});
fabricCanvas.add(fabricRect);
fabricCanvas.setActiveObject(fabricRect);
fabricRect.on('modified', syncInputsFromRect);
fabricRect.on('moving', syncInputsFromRect);
fabricRect.on('scaling', syncInputsFromRect);
}
function syncInputsFromRect() {
if (!fabricRect) return;
const px2mm = (px) => (px / renderScale) * PT_TO_MM;
const wPx = fabricRect.width * (fabricRect.scaleX || 1);
const hPx = fabricRect.height * (fabricRect.scaleY || 1);
inpX.value = px2mm(fabricRect.left).toFixed(1);
inpY.value = px2mm(fabricRect.top).toFixed(1);
inpW.value = px2mm(wPx).toFixed(1);
inpH.value = px2mm(hPx).toFixed(1);
}
// Bei Number-Input-Aenderung Box neu zeichnen
[inpX, inpY, inpW, inpH].forEach(el => el.addEventListener('input', drawBoxFromInputs));
document.querySelector('select[name="page"]').addEventListener('change', () => {
pageNum = chosenPageNum();
renderPage();
});
document.getElementById('btn-prev-page').onclick = () => { if (pageNum > 1) { pageNum--; renderPage(); } };
document.getElementById('btn-next-page').onclick = () => { if (pageNum < pageCount) { pageNum++; renderPage(); } };
document.getElementById('btn-reset-box').onclick = () => {
inpX.value = DEFAULT_BOX.x_mm;
inpY.value = DEFAULT_BOX.y_mm;
inpW.value = DEFAULT_BOX.w_mm;
inpH.value = DEFAULT_BOX.h_mm;
document.querySelector('input[name="label"]').value = DEFAULT_BOX.label || 'Unterschrift Kunde';
document.querySelector('select[name="page"]').value = DEFAULT_BOX.page || 'last';
pageNum = chosenPageNum();
renderPage();
};
document.getElementById('btn-save-box').onclick = async () => {
const status = document.getElementById('save-status');
status.textContent = 'Speichere…';
const fd = new FormData(document.getElementById('box-form'));
fd.append('token', <?php echo json_encode(newToken()); ?>);
try {
const r = await fetch(SAVE_URL, { method: 'POST', body: fd, credentials: 'same-origin' });
const j = await r.json();
if (j.ok) status.textContent = '✓ Gespeichert um ' + new Date().toLocaleTimeString();
else status.textContent = '✕ ' + (j.error || 'Fehler');
} catch (e) {
status.textContent = '✕ ' + e.message;
}
};
loadPdf();
})();
</script>
<?php
}
llxFooter();
$db->close();