subtotaltitle/class/actions_subtotaltitle.class.php

1118 lines
46 KiB
PHP

<?php
/* Copyright (C) 2023 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* \file subtotaltitle/class/actions_subtotaltitle.class.php
* \ingroup subtotaltitle
* \brief Hook overload for SubtotalTitle module
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonhookactions.class.php';
/**
* Class ActionsSubtotalTitle
*/
class ActionsSubtotalTitle extends CommonHookActions
{
private $debug = 0; // Wird dynamisch aus Config geladen
// Gemeinsames Array für gerenderte Sections
private static $rendered_sections = array();
/**
* @var DoliDB Database handler.
*/
public $db;
/**
* @var string Error code (or message)
*/
public $error = '';
/**
* @var string[] Errors
*/
public $errors = array();
/**
* @var mixed[] Hook results. Propagated to $hookmanager->resArray for later reuse
*/
public $results = array();
/**
* @var ?string String displayed by executeHook() immediately after return
*/
public $resprints;
/**
* @var int Priority of hook (50 is used if value is not defined)
*/
public $priority;
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
global $conf;
$this->db = $db;
// Debug-Modus aus Config laden
$this->debug = getDolGlobalInt('SUBTOTALTITLE_DEBUG_MODE', 0);
// IMMER Debug-Log in Datei schreiben
$logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log';
@mkdir(dirname($logFile), 0777, true);
error_log('['.date('Y-m-d H:i:s').'] SubtotalTitle Constructor aufgerufen'."\n", 3, $logFile);
}
public function formObjectOptions($parameters, &$object, &$action, $hookmanager)
{
global $conf, $langs;
if (in_array('invoicecard', explode(':', $parameters['currentcontext']))) {
// Lade Übersetzungen
$langs->load('subtotaltitle@subtotaltitle');
// CSS
$cssPath = dol_buildpath('/custom/subtotaltitle/css/subtotaltitle.css', 1);
echo '<link rel="stylesheet" type="text/css" href="'.$cssPath.'">'."\n";
// Übersetzungen als JavaScript-Variablen bereitstellen
echo '<script type="text/javascript">'."\n";
echo 'var subtotalTitleLang = {'."\n";
echo ' buttonCreateTextline: '.json_encode($langs->trans('ButtonCreateTextline')).','."\n";
echo ' buttonToInvoice: '.json_encode($langs->trans('ButtonToInvoice')).','."\n";
echo ' buttonFromInvoice: '.json_encode($langs->trans('ButtonFromInvoice')).','."\n";
echo ' buttonExpandAll: '.json_encode($langs->trans('ButtonExpandAll')).','."\n";
echo ' buttonCollapseAll: '.json_encode($langs->trans('ButtonCollapseAll')).','."\n";
echo ' buttonMassDelete: '.json_encode($langs->trans('ButtonMassDelete')).','."\n";
echo ' buttonDelete: '.json_encode($langs->trans('ButtonDelete')).','."\n";
echo ' buttonEdit: '.json_encode($langs->trans('ButtonEdit')).','."\n";
echo ' buttonSave: '.json_encode($langs->trans('ButtonSave')).','."\n";
echo ' buttonCancel: '.json_encode($langs->trans('ButtonCancel')).','."\n";
echo ' sectionName: '.json_encode($langs->trans('SectionName')).','."\n";
echo ' sectionSubtotal: '.json_encode($langs->trans('SectionSubtotal')).','."\n";
echo ' productCount: '.json_encode($langs->trans('ProductCount')).','."\n";
echo ' confirmMassDelete: '.json_encode($langs->trans('ConfirmMassDelete')).','."\n";
echo ' confirmDeleteSection: '.json_encode($langs->trans('ConfirmDeleteSection')).','."\n";
echo ' confirmDeleteSectionForce: '.json_encode($langs->trans('ConfirmDeleteSectionForce')).','."\n";
echo ' confirmDeleteSectionForce2: '.json_encode($langs->trans('ConfirmDeleteSectionForce2')).','."\n";
echo ' confirmDeleteTextline: '.json_encode($langs->trans('ConfirmDeleteTextline')).','."\n";
echo ' confirmRemoveFromSection: '.json_encode($langs->trans('ConfirmRemoveFromSection')).','."\n";
echo ' confirmDeleteLines: '.json_encode($langs->trans('ConfirmDeleteLines')).','."\n";
echo ' confirmDeleteLinesWarning: '.json_encode($langs->trans('ConfirmDeleteLinesWarning')).','."\n";
echo ' noLinesSelected: '.json_encode($langs->trans('NoLinesSelected')).','."\n";
echo ' errorLoadingSections: '.json_encode($langs->trans('ErrorLoadingSections')).','."\n";
echo ' errorSavingSection: '.json_encode($langs->trans('ErrorSavingSection')).','."\n";
echo ' errorDeletingSection: '.json_encode($langs->trans('ErrorDeletingSection')).','."\n";
echo ' errorReordering: '.json_encode($langs->trans('ErrorReordering')).','."\n";
echo ' successSectionCreated: '.json_encode($langs->trans('SuccessSectionCreated')).','."\n";
echo ' successSectionUpdated: '.json_encode($langs->trans('SuccessSectionUpdated')).','."\n";
echo ' successSectionDeleted: '.json_encode($langs->trans('SuccessSectionDeleted')).','."\n";
echo ' successReordered: '.json_encode($langs->trans('SuccessReordered')).','."\n";
echo ' textlineContent: '.json_encode($langs->trans('TextlineContent')).','."\n";
echo ' errorSavingTextline: '.json_encode($langs->trans('ErrorSavingTextline')).','."\n";
echo ' errorDeletingTextline: '.json_encode($langs->trans('ErrorDeletingTextline')).','."\n";
echo ' successTextlineCreated: '.json_encode($langs->trans('SuccessTextlineCreated')).','."\n";
echo ' successTextlineUpdated: '.json_encode($langs->trans('SuccessTextlineUpdated')).','."\n";
echo ' successTextlineDeleted: '.json_encode($langs->trans('SuccessTextlineDeleted')).','."\n";
echo ' syncToInvoice: '.json_encode($langs->trans('SyncToInvoice')).','."\n";
echo ' syncFromInvoice: '.json_encode($langs->trans('SyncFromInvoice')).','."\n";
echo ' confirmRemoveLine: '.json_encode($langs->trans('ConfirmRemoveLine')).','."\n";
echo ' confirmSyncAll: '.json_encode($langs->trans('ConfirmSyncAll')).','."\n";
echo ' confirmRemoveAll: '.json_encode($langs->trans('ConfirmRemoveAll')).','."\n";
echo ' allElementsAlreadyInInvoice: '.json_encode($langs->trans('AllElementsAlreadyInInvoice')).','."\n";
echo ' noElementsInInvoice: '.json_encode($langs->trans('NoElementsInInvoice')).','."\n";
echo ' elementsAddedToInvoice: '.json_encode($langs->trans('ElementsAddedToInvoice')).','."\n";
echo ' elementsRemovedFromInvoice: '.json_encode($langs->trans('ElementsRemovedFromInvoice')).','."\n";
echo ' elementsAddedWithErrors: '.json_encode($langs->trans('ElementsAddedWithErrors')).','."\n";
echo ' elementsRemovedWithErrors: '.json_encode($langs->trans('ElementsRemovedWithErrors')).','."\n";
echo ' successSyncedToInvoice: '.json_encode($langs->trans('SuccessSyncedToInvoice')).','."\n";
echo ' successRemovedFromInvoice: '.json_encode($langs->trans('SuccessRemovedFromInvoice')).','."\n";
echo ' errorSyncing: '.json_encode($langs->trans('ErrorSyncing'))."\n";
echo '};'."\n";
echo '</script>'."\n";
// Haupt-JavaScript
$jsPath = dol_buildpath('/custom/subtotaltitle/js/subtotaltitle.js', 1);
echo '<script type="text/javascript" src="'.$jsPath.'"></script>'."\n";
// Sync-JavaScript (NEU!)
$jsSyncPath = dol_buildpath('/custom/subtotaltitle/js/subtotaltitle_sync.js', 1);
echo '<script type="text/javascript" src="'.$jsSyncPath.'"></script>'."\n";
// Textzeile-Button
echo '<script>$(document).ready(function() {';
echo ' if ($(".tabsAction").length > 0) {';
echo ' $(".tabsAction").find("a.butAction:first").after(\'<a class="butAction" href="#" onclick="createTextLine(); return false;">\' + subtotalTitleLang.buttonCreateTextline + \'</a>\');';
echo ' }';
echo '});</script>'."\n";
// Massenlösch-Button (ans ENDE der Hauptzeile) - NUR EINMAL EINFÜGEN
echo '<script>$(document).ready(function() {';
echo ' if ($("#btnMassDelete").length === 0) {';
echo ' $(".tabsAction").first().append(\'<a id="btnMassDelete" class="butAction" href="#" onclick="toggleMassDelete(); return false;" style="margin-left:20px;">Zeilen löschen</a>\');';
echo ' }';
echo '});</script>'."\n";
// Sync-Buttons + Collapse-Buttons - rechts ausgerichtet
echo '<script>$(document).ready(function() {';
echo ' if ($(".sync-collapse-row").length === 0) {';
echo ' var hasCollapse = $("tr.section-header").length > 0;';
echo ' var buttons = \'<div class="sync-collapse-row" style="text-align:right; margin:5px 0;">\';';
echo ' buttons += \'<a class="button" href="#" onclick="syncAllToFacturedet(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonToInvoice + \'</a>\';';
echo ' buttons += \'<a class="button" href="#" onclick="removeAllFromFacturedet(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonFromInvoice + \'</a>\';';
echo ' if (hasCollapse) {';
echo ' buttons += \'<a class="button" href="#" onclick="expandAllSections(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonExpandAll + \'</a>\';';
echo ' buttons += \'<a class="button" href="#" onclick="collapseAllSections(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonCollapseAll + \'</a>\';';
echo ' }';
echo ' buttons += \'</div>\';';
echo ' $(".tabsAction").first().after(buttons);';
echo ' }';
echo '});</script>'."\n";
}
return 0;
}
/**
* Execute action
*/
public function getNomUrl($parameters, &$object, &$action)
{
global $db, $langs, $conf, $user;
$this->resprints = '';
return 0;
}
/**
* Overload the doActions function
*/
public function doActions($parameters, &$object, &$action, $hookmanager)
{
return 0;
}
/**
* Overload the doMassActions function
*/
public function doMassActions($parameters, &$object, &$action, $hookmanager)
{
global $conf, $user, $langs;
$error = 0;
if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) {
foreach ($parameters['toselect'] as $objectid) {
// Do action on each object id
}
if (!$error) {
$this->results = array('myreturn' => 999);
$this->resprints = 'A text to show';
return 0;
} else {
$this->errors[] = 'Error message';
return -1;
}
}
return 0;
}
/**
* Overload the addMoreMassActions function
*/
public function addMoreMassActions($parameters, &$object, &$action, $hookmanager)
{
global $conf, $user, $langs;
$error = 0;
$disabled = 1;
if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) {
$this->resprints = '<option value="0"'.($disabled ? ' disabled="disabled"' : '').'>'.$langs->trans("SubtotalTitleMassAction").'</option>';
}
if (!$error) {
return 0;
} else {
$this->errors[] = 'Error message';
return -1;
}
}
/**
* Execute action before PDF (document) creation
*/
public function beforePDFCreation($parameters, &$object, &$action)
{
global $conf, $user, $langs;
global $hookmanager;
$outputlangs = $langs;
$ret = 0;
$deltemp = array();
dol_syslog(get_class($this).'::executeHooks action='.$action);
if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) {
// do something only for the context 'somecontext1' or 'somecontext2'
}
return $ret;
}
/**
* Execute action after PDF (document) creation
*/
public function afterPDFCreation($parameters, &$pdfhandler, &$action)
{
global $conf, $user, $langs;
global $hookmanager;
$outputlangs = $langs;
$ret = 0;
$deltemp = array();
dol_syslog(get_class($this).'::executeHooks action='.$action);
if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) {
// do something only for the context 'somecontext1' or 'somecontext2'
}
return $ret;
}
/**
* Overload the loadDataForCustomReports function
*/
public function loadDataForCustomReports($parameters, &$action, $hookmanager)
{
global $langs;
$langs->load("subtotaltitle@subtotaltitle");
$this->results = array();
$head = array();
$h = 0;
if ($parameters['tabfamily'] == 'subtotaltitle') {
$head[$h][0] = dol_buildpath('/module/index.php', 1);
$head[$h][1] = $langs->trans("Home");
$head[$h][2] = 'home';
$h++;
$this->results['title'] = $langs->trans("SubtotalTitle");
$this->results['picto'] = 'subtotaltitle@subtotaltitle';
}
$head[$h][0] = 'customreports.php?objecttype='.$parameters['objecttype'].(empty($parameters['tabfamily']) ? '' : '&tabfamily='.$parameters['tabfamily']);
$head[$h][1] = $langs->trans("CustomReports");
$head[$h][2] = 'customreports';
$this->results['head'] = $head;
$arrayoftypes = array();
$this->results['arrayoftype'] = $arrayoftypes;
return 0;
}
/**
* Overload the restrictedArea function
*/
public function restrictedArea($parameters, $object, &$action, $hookmanager)
{
global $user;
if ($parameters['features'] == 'myobject') {
if ($user->hasRight('subtotaltitle', 'myobject', 'read')) {
$this->results['result'] = 1;
return 1;
} else {
$this->results['result'] = 0;
return 1;
}
}
return 0;
}
/**
* Execute action completeTabsHead
*/
public function completeTabsHead(&$parameters, &$object, &$action, $hookmanager)
{
global $langs, $conf, $user;
if (!isset($parameters['object']->element)) {
return 0;
}
if ($parameters['mode'] == 'remove') {
return 0;
} elseif ($parameters['mode'] == 'add') {
$langs->load('subtotaltitle@subtotaltitle');
$counter = count($parameters['head']);
$element = $parameters['object']->element;
$id = $parameters['object']->id;
if (in_array($element, ['context1', 'context2'])) {
$datacount = 0;
$parameters['head'][$counter][0] = dol_buildpath('/subtotaltitle/subtotaltitle_tab.php', 1) . '?id=' . $id . '&amp;module='.$element;
$parameters['head'][$counter][1] = $langs->trans('SubtotalTitleTab');
if ($datacount > 0) {
$parameters['head'][$counter][1] .= '<span class="badge marginleftonlyshort">' . $datacount . '</span>';
}
$parameters['head'][$counter][2] = 'subtotaltitleemails';
$counter++;
}
if ($counter > 0 && (int) DOL_VERSION < 14) {
$this->results = $parameters['head'];
return 1;
} else {
return 0;
}
} else {
return -1;
}
}
/**
* Overload the showLinkToObjectBlock function
*/
public function showLinkToObjectBlock($parameters, &$object, &$action, $hookmanager)
{
return 0;
}
/**
* Hook: Wird aufgerufen wenn eine Rechnung geladen wird
* Synchronisiert die Tabelle und rendert Section-Header
*/
public function printObjectLine($parameters, &$object, &$action, $hookmanager)
{
global $db, $langs;
if ($parameters['currentcontext'] != 'invoicecard') {
return 0;
}
if (!is_object($object) || $object->element != 'facture') {
return 0;
}
$facture_id = $object->id;
$line = $parameters['line'];
if (!$line || !$line->id) {
return 0;
}
// Prüfe ob diese Zeile eine unserer speziellen Zeilen ist (Section, Text, Subtotal)
// special_code: 100=Section, 101=Text, 102=Subtotal
if (isset($line->special_code) && in_array($line->special_code, array(100, 101, 102))) {
// Diese Zeile ist eine unserer speziellen Zeilen - per JS ausblenden
echo '<script>$(document).ready(function() { ';
echo ' $("tr[id*=\''.$line->id.'\']").hide();';
echo '});</script>';
return 0;
}
// Rechnung neu laden um aktuelle rang zu haben
static $reloaded = array();
if (!isset($reloaded[$facture_id])) {
$object->fetch($facture_id);
$object->fetch_thirdparty();
$object->fetch_lines();
$reloaded[$facture_id] = true;
}
// Synchronisiere Manager-Tabelle
$this->syncManagerTable($facture_id);
// Rendere alle Sections die VOR dieser Zeile kommen sollten (inkl. leere!)
$this->renderAllPendingSections($facture_id, $line);
// WICHTIG: Markiere diese Zeile mit line_order UND parent_section für JavaScript
$sql = "SELECT line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE fk_facturedet = ".(int)$line->id;
$resql = $db->query($sql);
if ($obj = $db->fetch_object($resql)) {
$parentSection = $obj->parent_section ? $obj->parent_section : 'null';
echo '<script>$(document).ready(function() { ';
echo ' $("tr[id*=\''.$line->id.'\']").attr("data-line-order", '.$obj->line_order.');';
echo ' $("tr[id*=\''.$line->id.'\']").attr("data-parent-section", "'.$parentSection.'");';
echo '});</script>';
}
return 0;
}
/**
* Rendert ALLE Sections die VOR dieser RANG-Position kommen sollten
*/
private function renderAllPendingSections($facture_id, $line)
{
global $db;
static $last_rang = array();
static $last_parent_section = array();
if (!isset(self::$rendered_sections[$facture_id])) {
self::$rendered_sections[$facture_id] = array();
$last_rang[$facture_id] = 0;
$last_parent_section[$facture_id] = null;
}
// Hole rang dieser Produktzeile aus facturedet
$sql = "SELECT rang FROM ".MAIN_DB_PREFIX."facturedet";
$sql .= " WHERE rowid = ".(int)$line->id;
$resql = $db->query($sql);
if (!$resql || $db->num_rows($resql) == 0) return;
$current_rang = $db->fetch_object($resql)->rang;
// Hole line_order und parent_section des aktuellen Produkts aus Manager-Tabelle
$sql_current = "SELECT line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_current .= " WHERE fk_facturedet = ".(int)$line->id;
$res_current = $db->query($sql_current);
$current_line_order = 0;
$current_parent_section = null;
if ($obj_current = $db->fetch_object($res_current)) {
$current_line_order = $obj_current->line_order;
$current_parent_section = $obj_current->parent_section;
}
// Subtotal der VORHERIGEN Section rendern (wenn Section-Wechsel)
if ($last_parent_section[$facture_id] && $last_parent_section[$facture_id] != $current_parent_section) {
$sql_subtotal = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_subtotal .= " WHERE parent_section = ".(int)$last_parent_section[$facture_id];
$sql_subtotal .= " AND line_type = 'subtotal'";
$resql_subtotal = $db->query($sql_subtotal);
if ($obj_sub = $db->fetch_object($resql_subtotal)) {
$subtotal_key = 'subtotal_'.$obj_sub->rowid;
if (!in_array($subtotal_key, self::$rendered_sections[$facture_id])) {
echo $this->renderSubtotalLine($obj_sub);
self::$rendered_sections[$facture_id][] = $subtotal_key;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Subtotal "'.$obj_sub->title.'" gerendert (Section-Wechsel)');
}
}
}
}
// Hole ALLE Sections ZWISCHEN letztem rang und aktuellem rang
$sql = "SELECT DISTINCT s.rowid, s.title, s.show_subtotal, s.collapsed, s.line_order, s.in_facturedet,";
$sql .= " (SELECT MIN(fd.rang) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."facturedet fd ON fd.rowid = m2.fk_facturedet";
$sql .= " WHERE m2.parent_section = s.rowid) as first_product_rang";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s";
$sql .= " WHERE s.fk_facture = ".(int)$facture_id;
$sql .= " AND s.line_type = 'section'";
$sql .= " HAVING first_product_rang > ".(int)$last_rang[$facture_id];
$sql .= " AND first_product_rang <= ".(int)$current_rang;
$sql .= " ORDER BY first_product_rang";
$resql = $db->query($sql);
while ($obj = $db->fetch_object($resql)) {
if (!in_array($obj->rowid, self::$rendered_sections[$facture_id])) {
$section = array(
'section_id' => $obj->rowid,
'title' => $obj->title,
'show_subtotal' => $obj->show_subtotal,
'collapsed' => $obj->collapsed,
'in_facturedet' => $obj->in_facturedet
);
echo $this->renderSectionHeader($section);
self::$rendered_sections[$facture_id][] = $obj->rowid;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang);
}
}
}
// Rendere Textzeilen die VOR dieser Produktzeile kommen
$sql_text = "SELECT rowid, title, line_order, in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_text .= " WHERE fk_facture = ".(int)$facture_id;
$sql_text .= " AND line_type = 'text'";
$sql_text .= " AND line_order < ".(int)$current_line_order;
$sql_text .= " ORDER BY line_order";
$resql_text = $db->query($sql_text);
while ($obj_text = $db->fetch_object($resql_text)) {
$text_key = 'text_'.$obj_text->rowid;
if (!in_array($text_key, self::$rendered_sections[$facture_id])) {
$textline = array(
'id' => $obj_text->rowid,
'title' => $obj_text->title,
'line_order' => $obj_text->line_order,
'in_facturedet' => $obj_text->in_facturedet
);
echo $this->renderTextLine($textline);
self::$rendered_sections[$facture_id][] = $text_key;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Textzeile "'.$obj_text->title.'" gerendert');
}
}
}
// Merke für nächsten Durchlauf
$last_rang[$facture_id] = $current_rang;
$last_parent_section[$facture_id] = $current_parent_section;
}
/**
* Rendert eine Textzeile
*/
private function renderTextLine($textline)
{
global $db;
// In-Facturedet Status
$in_facturedet = isset($textline['in_facturedet']) ? $textline['in_facturedet'] : 0;
$sync_checked = $in_facturedet ? 'checked' : '';
$in_class = $in_facturedet ? ' in-facturedet' : '';
$html = '<tr class="textline-row drag'.$in_class.'" data-textline-id="'.$textline['id'].'" data-line-order="'.$textline['line_order'].'">';
// Inhalt (colspan=10)
$html .= '<td colspan="10" style="padding:8px; font-weight:bold;">';
$html .= htmlspecialchars($textline['title']);
// Sync-Checkbox (NEU!)
$html .= ' <label style="margin-left:15px;font-weight:normal;font-size:12px;" title="In Rechnung/PDF anzeigen">';
$html .= '<input type="checkbox" class="sync-checkbox" data-line-id="'.$textline['id'].'" data-line-type="text" '.$sync_checked.' onclick="toggleFacturedetSync('.$textline['id'].', \'text\', this);">';
$html .= ' 📄</label>';
$html .= '</td>';
// Edit (Spalte 11)
$html .= '<td class="linecoledit center">';
$html .= '<a href="#" onclick="editTextLine('.$textline['id'].'); return false;" title="Bearbeiten">';
$html .= '<span class="fas fa-pencil-alt" style="color:#444;" title="Ändern"></span></a>';
$html .= '</td>';
// Delete (Spalte 12)
$html .= '<td class="linecoldelete center">';
$html .= '<a href="#" onclick="deleteTextLine('.$textline['id'].'); return false;" title="Löschen">';
$html .= '<span class="fas fa-trash pictodelete" title="Löschen"></span></a>';
$html .= '</td>';
// Move (Spalte 13)
$html .= '<td class="linecolmove tdlineupdown center"></td>';
// Unlink (Spalte 14)
$html .= '<td class="linecolunlink"></td>';
$html .= '</tr>';
return $html;
}
/**
* Rendert eine Subtotal-Zeile
*/
private function renderSubtotalLine($subtotal)
{
global $db;
// Hole in_facturedet Status
$sql_sync = "SELECT in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE rowid = ".(int)$subtotal->rowid;
$res_sync = $db->query($sql_sync);
$obj_sync = $db->fetch_object($res_sync);
$in_facturedet = $obj_sync ? $obj_sync->in_facturedet : 0;
$sync_checked = $in_facturedet ? 'checked' : '';
$in_class = $in_facturedet ? ' in-facturedet' : '';
// Berechne aktuelle Summe
$sql = "SELECT SUM(d.total_ht) as total";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."facturedet d ON d.rowid = m.fk_facturedet";
$sql .= " WHERE m.parent_section = ".(int)$subtotal->parent_section;
$sql .= " AND m.line_type = 'product'";
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$sum = $obj->total ? $obj->total : 0;
$formatted = number_format($sum, 2, ',', '.');
$html = '<tr class="subtotal-row'.$in_class.'" data-section-id="'.$subtotal->parent_section.'" data-subtotal-id="'.$subtotal->rowid.'" data-line-order="'.$subtotal->line_order.'" style="font-weight:bold;">';
$html .= '<td colspan="9" style="text-align:right; padding:8px;">';
$html .= 'Zwischensumme:';
// Sync-Checkbox (NEU!)
$html .= ' <label style="font-weight:normal;font-size:12px;margin-left:10px;" title="In Rechnung/PDF anzeigen">';
$html .= '<input type="checkbox" class="sync-checkbox" data-line-id="'.$subtotal->rowid.'" data-line-type="subtotal" '.$sync_checked.' onclick="toggleFacturedetSync('.$subtotal->rowid.', \'subtotal\', this);">';
$html .= ' 📄</label>';
$html .= '</td>';
$html .= '<td class="linecolht right" style="padding:8px;">'.$formatted.'</td>';
$html .= '<td class="linecoledit"></td>';
$html .= '<td class="linecoldelete"></td>';
$html .= '<td class="linecolmove"></td>';
$html .= '<td class="linecolunlink"></td>';
$html .= '</tr>';
return $html;
}
/**
* Hook: Wird aufgerufen wenn das Produkt-Hinzufügen-Formular gerendert wird
* Fügt Section-Dropdown hinzu
*/
public function formAddObjectLine($parameters, &$object, &$action, $hookmanager)
{
global $db;
if ($parameters['currentcontext'] != 'invoicecard') {
return 0;
}
if (!is_object($object) || $object->element != 'facture') {
return 0;
}
$facture_id = $object->id;
echo $this->renderSectionDropdown($facture_id);
return 0;
}
/**
* Synchronisiert llx_facture_lines_manager mit vorhandenen facturedet-Zeilen
*/
private function syncManagerTable($facture_id)
{
global $db;
// 1. CLEANUP: Lösche verwaiste Einträge
$sql_cleanup = "DELETE m FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql_cleanup .= " LEFT JOIN ".MAIN_DB_PREFIX."facturedet d ON m.fk_facturedet = d.rowid";
$sql_cleanup .= " WHERE m.fk_facture = ".(int)$facture_id;
$sql_cleanup .= " AND m.line_type = 'product'";
$sql_cleanup .= " AND d.rowid IS NULL";
$result = $db->query($sql_cleanup);
/*if ($result && $db->affected_rows > 0 && $this->debug) {
error_log('[SubtotalTitle] 🧹 Cleanup: '.$db->affected_rows.' verwaiste Einträge entfernt für Facture '.$facture_id);
}*/
// 2. Hole alle Produktzeilen der Rechnung
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facturedet";
$sql .= " WHERE fk_facture = ".(int)$facture_id;
$sql .= " ORDER BY rang";
$resql = $db->query($sql);
while ($obj = $db->fetch_object($resql)) {
$sql_check = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_check .= " WHERE fk_facturedet = ".(int)$obj->rowid;
$resql_check = $db->query($sql_check);
if ($db->num_rows($resql_check) == 0) {
$next_order = $this->getNextLineOrder($facture_id);
$sql_ins = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_ins .= " (fk_facture, line_type, fk_facturedet, line_order, date_creation)";
$sql_ins .= " VALUES (".(int)$facture_id.", 'product', ".(int)$obj->rowid.", ".$next_order.", NOW())";
$db->query($sql_ins);
}
}
}
/**
* Holt die Section für eine Produktzeile
*/
private function getSectionForLine($line_id)
{
global $db;
$sql = "SELECT m.parent_section as section_id, s.title, s.show_subtotal, s.collapsed";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture_lines_manager s ON s.rowid = m.parent_section";
$sql .= " WHERE m.fk_facturedet = ".(int)$line_id;
$sql .= " AND m.line_type = 'product'";
$resql = $db->query($sql);
if ($obj = $db->fetch_object($resql)) {
if ($obj->section_id) {
return array(
'section_id' => $obj->section_id,
'title' => $obj->title,
'show_subtotal' => $obj->show_subtotal,
'collapsed' => $obj->collapsed
);
}
}
return null;
}
/**
* Prüft ob dies die erste Zeile einer Section ist
*/
private function isFirstLineInSection($line_id, $section_id)
{
global $db;
$sql = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE fk_facturedet = ".(int)$line_id;
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$current_order = $obj->line_order;
$sql = "SELECT MIN(line_order) as min_order";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE parent_section = ".(int)$section_id;
$sql .= " AND line_type = 'product'";
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
return ($current_order == $obj->min_order);
}
/**
* Rendert einen Section-Header
*/
private function renderSectionHeader($section)
{
global $db;
// Hole line_order und in_facturedet der Section
$sql = "SELECT line_order, in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE rowid = ".(int)$section['section_id'];
$resql = $db->query($sql);
$line_order = 0;
$in_facturedet = 0;
if ($obj = $db->fetch_object($resql)) {
$line_order = $obj->line_order;
$in_facturedet = $obj->in_facturedet;
}
$sync_checked = $in_facturedet ? 'checked' : '';
$in_class = $in_facturedet ? ' in-facturedet' : '';
// Hole Produkte dieser Section (IDs + Anzahl)
$sql_products = "SELECT fk_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_products .= " WHERE parent_section = ".(int)$section['section_id'];
$sql_products .= " AND line_type = 'product'";
$res_products = $db->query($sql_products);
$product_ids = [];
while ($obj_prod = $db->fetch_object($res_products)) {
$product_ids[] = (int)$obj_prod->fk_facturedet;
}
$product_count = count($product_ids);
$product_ids_json = htmlspecialchars(json_encode($product_ids));
$html = '<tr class="section-header'.$in_class.'" data-section-id="'.$section['section_id'].'" data-section-title="'.htmlspecialchars($section['title']).'" data-line-order="'.$line_order.'" data-product-count="'.$product_count.'" data-product-ids="'.$product_ids_json.'">';
// Titel (colspan=10)
$html .= '<td colspan="6" style="font-weight:bold; padding:8px;">';
$html .= '<span class="section-toggle" onclick="toggleSection('.$section['section_id'].'); event.stopPropagation();">▼</span>';
$html .= htmlspecialchars($section['title']);
$html .= '<span class="section-count">'.$product_count.' Produkte</span></td><td colspan="2" align="right">';
// Zwischensummen-Checkbox
$checked = $section['show_subtotal'] ? 'checked' : '';
$html .= ' <label style="margin-left:15px;font-weight:normal;font-size:12px;" onclick="event.stopPropagation();">';
$html .= '<input type="checkbox" class="subtotal-toggle" data-section-id="'.$section['section_id'].'" '.$checked.' onclick="event.stopPropagation(); toggleSubtotal('.$section['section_id'].', this);">';
$html .= ' <b>€</b></label>';
// Sync-Checkbox (NEU!)
$html .= ' <label style="margin-left:15px;font-weight:normal;font-size:12px;" onclick="event.stopPropagation();" title="In Rechnung/PDF anzeigen">';
$html .= '<input type="checkbox" class="sync-checkbox" data-line-id="'.$section['section_id'].'" data-line-type="section" '.$sync_checked.' onclick="event.stopPropagation(); toggleFacturedetSync('.$section['section_id'].', \'section\', this);">';
$html .= ' 📄</label>';
$html .= '</td><td colspan="2" align="right">';
$html .= ' <a href="#" onclick="moveSection('.$section['section_id'].', \'up\'); return false;" title="Nach oben"><span class="fa fa-long-arrow-alt-up"></span></a>';
$html .= ' <a href="#" onclick="moveSection('.$section['section_id'].', \'down\'); return false;" title="Nach unten"><span class="fa fa-long-arrow-alt-down"></span></a>';
$html .= '</td>';
// Edit (Spalte 11)
$html .= '<td class="linecoledit center">';
$html .= '<a href="#" onclick="renameSection('.$section['section_id'].'); return false;" title="Umbenennen">';
$html .= '<span class="fas fa-pencil-alt" style="color:#444;" title="Ändern"></span></a>';
$html .= '</td>';
// Delete (Spalte 12)
$html .= '<td class="linecoldelete center">';
if ($product_count == 0) {
$html .= '<a href="#" onclick="deleteSection('.$section['section_id'].'); return false;" title="Leere Gruppe löschen">';
$html .= '<span class="fas fa-trash pictodelete" title="Löschen"></span></a>';
} else {
$html .= '<a href="#" onclick="deleteSectionForce('.$section['section_id'].'); return false;" title="⚠️ Gruppe MIT '.$product_count.' Produkten löschen">';
$html .= '<span class="fas fa-trash" style="color:#c00;" title="Alles löschen"></span></a>';
}
$html .= '</td>';
// Move (Spalte 13)
$html .= '<td class="linecolmove center"></td>';
// Unlink (Spalte 14)
$html .= '<td class="linecolunlink"></td>';
$html .= '</tr>';
return $html;
}
/**
* Rendert Section-Dropdown im Add-Product-Formular
*/
private function renderSectionDropdown($facture_id)
{
global $db;
$sql = "SELECT rowid, title FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE fk_facture = ".(int)$facture_id;
$sql .= " AND line_type = 'section'";
$sql .= " ORDER BY line_order";
$resql = $db->query($sql);
$html = '<script>';
$html .= '$(document).ready(function() {';
$html .= ' var dropdown = \'<tr><td colspan="13">Positionsgruppe: <select name="section_id_dropdown" id="section_id_dropdown" style="margin-left:5px;">\';';
$html .= ' dropdown += \'<option value="">-- Keine Positionsgruppe --</option>\';';
while ($obj = $db->fetch_object($resql)) {
$html .= ' dropdown += \'<option value="'.$obj->rowid.'">'.addslashes($obj->title).'</option>\';';
}
$html .= ' dropdown += \'</select></td></tr>\';';
$html .= ' $(\'#addproduct tr:last\').before(dropdown);';
$html .= ' $(\'form[name="addproduct"]\').append(\'<input type="hidden" name="section_id" id="section_id" value="">\');';
$html .= ' $(document).on(\'change\', \'#section_id_dropdown\', function() {';
$html .= ' var val = $(this).val();';
$html .= ' console.log(\'Section selected:\', val);';
$html .= ' $(\'#section_id\').val(val);';
$html .= ' });';
$html .= '});';
$html .= '</script>';
return $html;
}
/**
* Fügt ein Produkt zu einer Section hinzu
*/
private function addProductToSection($facture_id, $line_id, $section_id)
{
global $db;
if ($this->debug) {
error_log('[SubtotalTitle] 🔴 addProductToSection: facture='.$facture_id.', line='.$line_id.', section='.$section_id);
}
$sql_check = "SELECT rowid, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_check .= " WHERE fk_facturedet = ".(int)$line_id;
$resql_check = $db->query($sql_check);
if ($db->num_rows($resql_check) > 0) {
$obj = $db->fetch_object($resql_check);
$sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_upd .= " SET parent_section = ".(int)$section_id;
$sql_upd .= " WHERE rowid = ".(int)$obj->rowid;
$db->query($sql_upd);
$this->reorderLines($facture_id);
} else {
$sql = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE rowid = ".(int)$section_id;
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
$section_order = $obj->line_order;
$new_order = $section_order + 1;
$sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " SET line_order = line_order + 1";
$sql .= " WHERE fk_facture = ".(int)$facture_id;
$sql .= " AND line_order >= ".$new_order;
$db->query($sql);
$sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " (fk_facture, line_type, fk_facturedet, parent_section, line_order, date_creation)";
$sql .= " VALUES (".(int)$facture_id.", 'product', ".(int)$line_id.", ".(int)$section_id.", ".$new_order.", NOW())";
$db->query($sql);
}
}
/**
* Fügt ein freies Produkt hinzu
*/
private function addFreeProduct($facture_id, $line_id)
{
global $db;
if ($this->debug) {
error_log('[SubtotalTitle] 🟢 addFreeProduct: facture='.$facture_id.', line='.$line_id.' (parent_section=NULL)');
}
$next_order = $this->getNextLineOrder($facture_id);
$sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " (fk_facture, line_type, fk_facturedet, line_order, date_creation)";
$sql .= " VALUES (".(int)$facture_id.", 'product', ".(int)$line_id.", ".$next_order.", NOW())";
$db->query($sql);
}
/**
* Synchronisiert facturedet.rang aus line_order
*/
private function syncRangFromManager($facture_id)
{
global $db;
$sql = "SELECT fk_facturedet, line_order";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE fk_facture = ".(int)$facture_id;
$sql .= " AND line_type = 'product'";
$sql .= " ORDER BY line_order";
$resql = $db->query($sql);
$rang = 1;
while ($obj = $db->fetch_object($resql)) {
$sql_upd = "UPDATE ".MAIN_DB_PREFIX."facturedet";
$sql_upd .= " SET rang = ".$rang;
$sql_upd .= " WHERE rowid = ".(int)$obj->fk_facturedet;
$db->query($sql_upd);
$rang++;
}
}
/**
* Holt die nächste freie line_order
*/
private function getNextLineOrder($facture_id)
{
global $db;
$sql = "SELECT MAX(line_order) as max_order";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE fk_facture = ".(int)$facture_id;
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
return ($obj->max_order ? $obj->max_order + 1 : 1);
}
/**
* Holt die rowid der letzten Produktzeile
*/
private function getLastProductLine($facture_id)
{
global $db;
$sql = "SELECT m.fk_facturedet";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql .= " WHERE m.fk_facture = ".(int)$facture_id;
$sql .= " AND m.line_type = 'product'";
$sql .= " ORDER BY m.line_order DESC";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($obj = $db->fetch_object($resql)) {
return $obj->fk_facturedet;
}
return 0;
}
/**
* Holt alle Sections die keine Produkte haben
*/
private function getEmptySections($facture_id)
{
global $db;
$sql = "SELECT s.rowid as section_id, s.title, s.show_subtotal, s.collapsed";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s";
$sql .= " WHERE s.fk_facture = ".(int)$facture_id;
$sql .= " AND s.line_type = 'section'";
$sql .= " AND NOT EXISTS (";
$sql .= " SELECT 1 FROM ".MAIN_DB_PREFIX."facture_lines_manager p";
$sql .= " WHERE p.parent_section = s.rowid";
$sql .= " )";
$sql .= " ORDER BY s.line_order";
$resql = $db->query($sql);
$sections = array();
while ($obj = $db->fetch_object($resql)) {
$sections[] = array(
'section_id' => $obj->section_id,
'title' => $obj->title,
'show_subtotal' => $obj->show_subtotal,
'collapsed' => $obj->collapsed
);
}
return $sections;
}
/**
* Sortiert alle Zeilen neu: Schließt nur Lücken, behält Reihenfolge bei
*/
private function reorderLines($facture_id)
{
global $db;
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE fk_facture = ".(int)$facture_id;
$sql .= " ORDER BY line_order";
$resql = $db->query($sql);
$new_order = 1;
while ($obj = $db->fetch_object($resql)) {
$sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_upd .= " SET line_order = ".$new_order;
$sql_upd .= " WHERE rowid = ".(int)$obj->rowid;
$db->query($sql_upd);
$new_order++;
}
}
}