diff --git a/README.md b/README.md index e83d0f2..c495636 100755 --- a/README.md +++ b/README.md @@ -1,66 +1,41 @@ -# SubtotalTitle - Facturedet Sync Update +# SubtotalTitle - Dolibarr Modul -## Was ist neu? -- **📄 Checkbox** bei jeder Section/Textzeile/Subtotal: Element zur Rechnung hinzufügen -- **→ Zur Rechnung / ← Aus Rechnung** Buttons: Alle Elemente auf einmal -- **ODT-Variablen** für formatierte Ausgabe im PDF/ODT +Erweitert Rechnungen, Angebote und Kundenaufträge um **Sections**, **Textzeilen** und **Zwischensummen**. -## Installation +--- -### 1. Dateien kopieren +## 🔑 ODT Template Schlüsselwörter -``` -subtotaltitle_complete/ -├── class/ -│ └── actions_subtotaltitle.class.php → ERSETZEN -├── ajax/ -│ └── sync_to_facturedet.php → NEU -├── js/ -│ └── subtotaltitle_sync.js → NEU -├── css/ -│ └── subtotaltitle_sync.css → Inhalt zu deiner CSS hinzufügen -└── core/ - └── substitutions/ - └── functions_subtotaltitle.lib.php → NEU (Ordner ggf. erstellen!) -``` +Diese Variablen stehen in ODT-Templates zur Verfügung: -### 2. Modul-Descriptor anpassen +| Variable | Wert | Beschreibung | +|----------|------|--------------| +| `{line_is_section}` | 1/0 | Zeile ist eine Section (Überschrift) | +| `{line_is_textline}` | 1/0 | Zeile ist eine Textzeile | +| `{line_is_subtotal}` | 1/0 | Zeile ist eine Zwischensumme | +| `{line_is_product}` | 1/0 | Zeile ist ein Produkt (inkl. Sections/Text/Subtotals) | +| `{line_is_normal}` | 1/0 | Zeile ist ein normales Produkt (KEIN Section/Text/Subtotal) | +| `{line_is_special}` | 1/0 | Zeile ist Section, Text ODER Subtotal | +| `{line_special_code}` | 0-102 | special_code Wert der Zeile | -In `core/modules/modSubtotalTitle.class.php` ändern: +### special_code Werte -```php -$this->module_parts = array( - 'substitutions' => 1, // ← Diese Zeile hinzufügen/ändern! - 'hooks' => array( - 'data' => array('invoicecard'), - 'entity' => '0' - ) -); -``` +| Typ | special_code | +|-----|-------------| +| Normales Produkt | 0 | +| Section (Überschrift) | 100 | +| Textzeile | 101 | +| Zwischensumme | 102 | -### 3. Modul deaktivieren und wieder aktivieren - -Damit die Substitution-Funktion erkannt wird. - -## Verwendung - -### In der Rechnungsansicht - -| Element | Checkbox | Bedeutung | -|---------|----------|-----------| -| Section | 📄 | Zur Rechnung hinzufügen | -| Textzeile | 📄 | Zur Rechnung hinzufügen | -| Subtotal | 📄 | Zur Rechnung hinzufügen | - -**Grüner Rand** = Element ist in der Rechnung/PDF enthalten - -### Im ODT-Template +### ODT Template Beispiel ``` [!-- BEGIN row.lines --] [!-- IF {line_is_section} --] +═══════════════════════════════════════ {line_desc} +═══════════════════════════════════════ [!-- ENDIF {line_is_section} --] [!-- IF {line_is_textline} --] @@ -72,17 +47,139 @@ Damit die Substitution-Funktion erkannt wird. [!-- ENDIF {line_is_normal} --] [!-- IF {line_is_subtotal} --] +─────────────────────────────────────── Zwischensumme: {line_price_ht_locale} € +─────────────────────────────────────── [!-- ENDIF {line_is_subtotal} --] [!-- END row.lines --] ``` -## special_code Werte +--- -| Typ | special_code | ODT-Variable | -|-----|-------------|--------------| -| Normales Produkt | 0 | `{line_is_normal}` | -| Section | 100 | `{line_is_section}` | -| Textzeile | 101 | `{line_is_textline}` | -| Zwischensumme | 102 | `{line_is_subtotal}` | +## 📋 Modul-Funktionen + +### Sections (Überschriften) +- Erstellen von Überschriften zur Strukturierung +- Produkte können per Drag & Drop oder Link-Button zugeordnet werden +- Optional: Zwischensumme für jede Section anzeigen +- Ein-/Ausklappen von Sections + +### Textzeilen +- Freie Textzeilen ohne Preis +- Ideal für Hinweise, Bedingungen oder Erklärungen + +### Zwischensummen +- Automatische Berechnung der Summe aller Produkte in einer Section +- Checkbox zum Ein-/Ausschalten pro Section + +### Dokument-Synchronisation +- **📄 Checkbox** bei jeder Section/Textzeile/Subtotal: Element zum Dokument hinzufügen +- **→ Zum Dokument / ← Aus Dokument** Buttons: Alle Elemente auf einmal synchronisieren +- **Grüner Rand** = Element ist im Dokument/PDF enthalten +- Buttons werden nur angezeigt wenn Sections oder Textzeilen vorhanden sind + +### Unterstützte Dokumenttypen +- Rechnungen (Factures) +- Angebote (Propals) +- Kundenaufträge (Commandes) + +--- + +## 🔧 Installation + +### 1. Dateien kopieren + +``` +htdocs/custom/subtotaltitle/ +├── class/ +│ ├── actions_subtotaltitle.class.php +│ └── DocumentTypeHelper.class.php +├── ajax/ +│ ├── add_to_section.php +│ ├── assign_last_product.php +│ ├── cleanup_subtotals.php +│ ├── create_section.php +│ ├── create_textline.php +│ ├── get_textlines.php +│ ├── move_section.php +│ ├── reorder_all.php +│ ├── sync_to_facturedet.php +│ └── toggle_subtotal.php +├── core/ +│ ├── modules/ +│ │ └── modSubtotalTitle.class.php +│ └── substitutions/ +│ └── functions_subtotaltitle.lib.php +├── js/ +│ └── subtotaltitle.js +├── css/ +│ └── subtotaltitle.css +├── lib/ +│ └── subtotaltitle.lib.php +└── sql/ + └── llx_facture_lines_manager.sql +``` + +### 2. Modul aktivieren + +Im Dolibarr Backend unter **Home → Setup → Modules** das Modul **SubtotalTitle** aktivieren. + +Die Datenbanktabelle `llx_facture_lines_manager` wird automatisch erstellt. + +### 3. Modul-Konfiguration + +In `core/modules/modSubtotalTitle.class.php` muss folgendes gesetzt sein: + +```php +$this->module_parts = array( + 'substitutions' => 1, // Für ODT-Variablen + 'hooks' => array( + 'data' => array('invoicecard', 'propalcard', 'ordercard'), + 'entity' => '0' + ) +); +``` + +--- + +## 🗄️ Datenbank + +### Tabelle: llx_facture_lines_manager + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| rowid | INT | Primary Key | +| fk_facture | INT | FK zu Rechnung | +| fk_propal | INT | FK zu Angebot | +| fk_commande | INT | FK zu Kundenauftrag | +| document_type | VARCHAR(20) | 'invoice', 'propal', 'order' | +| line_type | VARCHAR(20) | 'section', 'product', 'text', 'subtotal' | +| fk_facturedet | INT | FK zu llx_facturedet | +| fk_propaldet | INT | FK zu llx_propaldet | +| fk_commandedet | INT | FK zu llx_commandedet | +| title | VARCHAR(255) | Titel für Sections/Text | +| parent_section | INT | FK zur übergeordneten Section | +| line_order | INT | Sortierreihenfolge | +| show_subtotal | TINYINT | Zwischensumme anzeigen (0/1) | +| collapsed | TINYINT | Section eingeklappt (0/1) | +| in_facturedet | TINYINT | Im Dokument enthalten (0/1) | + +--- + +## 📝 Changelog + +### Version 1.0 +- Initiale Version mit Section-, Text- und Subtotal-Unterstützung +- ODT-Substitutionsvariablen +- Multi-Dokument-Support (Rechnungen, Angebote, Kundenaufträge) +- Drag & Drop Sortierung +- Dokument-Synchronisation + +--- + +## 📄 Lizenz + +Copyright (C) 2026 Eduard Wisch + +Dieses Programm ist freie Software: Sie können es unter den Bedingungen der GNU General Public License, wie von der Free Software Foundation veröffentlicht, weitergeben und/oder modifizieren, entweder Version 3 der Lizenz oder (nach Ihrer Wahl) jede spätere Version. diff --git a/ajax/add_to_section.php b/ajax/add_to_section.php index 7e49913..c319f50 100644 --- a/ajax/add_to_section.php +++ b/ajax/add_to_section.php @@ -58,38 +58,40 @@ if (!$resql_section || $db->num_rows($resql_section) == 0) { $section = $db->fetch_object($resql_section); $section_line_order = $section->line_order; -// 3. Finde die neue line_order für das Produkt (nach der Section, vor anderen Produkten der Section) -$sql_next = "SELECT MIN(line_order) as next_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; -$sql_next .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; -$sql_next .= " AND document_type = '".$db->escape($docType)."'"; -$sql_next .= " AND parent_section = ".(int)$section_id; -$sql_next .= " AND line_type IN ('product', 'text')"; -$resql_next = $db->query($sql_next); -$obj_next = $db->fetch_object($resql_next); +// 3. Finde die neue line_order für das Produkt +// Das Produkt soll IMMER als LETZTES Produkt der Section eingefügt werden +// (nach allen bestehenden Produkten dieser Section, aber vor der nächsten Section oder dem Subtotal) -if ($obj_next && $obj_next->next_order) { - // Es gibt bereits Produkte in dieser Section → füge VOR dem ersten ein - $new_line_order = $obj_next->next_order; +// Suche das letzte Produkt/Text dieser Section +$sql_last = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql_last .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; +$sql_last .= " AND document_type = '".$db->escape($docType)."'"; +$sql_last .= " AND parent_section = ".(int)$section_id; +$sql_last .= " AND line_type IN ('product', 'text')"; +$sql_last .= " AND rowid != ".(int)$manager_id; // Nicht das aktuelle Produkt selbst +$resql_last = $db->query($sql_last); +$obj_last = $db->fetch_object($resql_last); + +if ($obj_last && $obj_last->max_order) { + // Es gibt bereits Produkte in dieser Section → füge NACH dem letzten ein + $new_line_order = (int)$obj_last->max_order + 1; } else { - // Keine Produkte in der Section → füge direkt nach der Section ein + // Keine anderen Produkte in der Section → füge direkt nach der Section ein $new_line_order = $section_line_order + 1; } subtotaltitle_debug_log(' current_line_order='.$current_line_order.', section_line_order='.$section_line_order.', new_line_order='.$new_line_order); -// 4. Verschiebe Zeilen je nach Richtung -if ($current_line_order > $new_line_order) { - // Nach vorne: Verschiebe Zeilen von new_line_order bis BEFORE current um +1 - $sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; - $sql_shift .= " SET line_order = line_order + 1"; - $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; - $sql_shift .= " AND document_type = '".$db->escape($docType)."'"; - $sql_shift .= " AND line_order >= ".(int)$new_line_order; - $sql_shift .= " AND line_order < ".(int)$current_line_order; - $db->query($sql_shift); - subtotaltitle_debug_log(' Nach vorne verschoben: Zeilen '.$new_line_order.'-'.($current_line_order-1).' um +1'); -} elseif ($current_line_order < $new_line_order) { - // Nach hinten: Verschiebe Zeilen von current+1 bis new um -1 +// 4. Verschiebe Zeilen +// WICHTIG: Zuerst das Produkt "entfernen" (temporär auf -1 setzen), dann verschieben, dann einfügen +$sql_temp = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql_temp .= " SET line_order = -1"; +$sql_temp .= " WHERE rowid = ".(int)$manager_id; +$db->query($sql_temp); + +if ($current_line_order < $new_line_order) { + // Produkt wird nach hinten verschoben + // Schließe die Lücke: alle Zeilen zwischen current+1 und new_line_order um -1 verschieben $sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_shift .= " SET line_order = line_order - 1"; $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; @@ -97,9 +99,21 @@ if ($current_line_order > $new_line_order) { $sql_shift .= " AND line_order > ".(int)$current_line_order; $sql_shift .= " AND line_order <= ".(int)$new_line_order; $db->query($sql_shift); - subtotaltitle_debug_log(' Nach hinten verschoben: Zeilen '.($current_line_order+1).'-'.$new_line_order.' um -1'); + // Korrigiere new_line_order (weil wir verschoben haben) + $new_line_order = $new_line_order - 1; + subtotaltitle_debug_log(' Nach hinten: Lücke geschlossen, new_line_order korrigiert auf '.$new_line_order); +} elseif ($current_line_order > $new_line_order) { + // Produkt wird nach vorne verschoben + // Mache Platz: alle Zeilen ab new_line_order bis current-1 um +1 verschieben + $sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_shift .= " SET line_order = line_order + 1"; + $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_shift .= " AND document_type = '".$db->escape($docType)."'"; + $sql_shift .= " AND line_order >= ".(int)$new_line_order; + $sql_shift .= " AND line_order < ".(int)$current_line_order; + $db->query($sql_shift); + subtotaltitle_debug_log(' Nach vorne: Platz gemacht ab position '.$new_line_order); } -// Wenn current == new, keine Verschiebung nötig // 5. Update das Produkt: setze parent_section und neue line_order $sql_update = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; @@ -108,9 +122,26 @@ $sql_update .= ", line_order = ".(int)$new_line_order; $sql_update .= " WHERE rowid = ".(int)$manager_id; $db->query($sql_update); -subtotaltitle_debug_log('✅ Produkt #'.$line_id.' zu Section #'.$section_id.' hinzugefügt'); +subtotaltitle_debug_log('✅ Produkt #'.$line_id.' zu Section #'.$section_id.' hinzugefügt mit line_order='.$new_line_order); -// 6. Sync rang in Detail-Tabelle +// 6. Normalisiere alle line_order (keine Lücken, keine Duplikate) +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; +$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; +$sql .= " AND document_type = '".$db->escape($docType)."'"; +$sql .= " ORDER BY line_order, rowid"; // Bei Gleichstand nach rowid sortieren +$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++; +} +subtotaltitle_debug_log(' line_order normalisiert: '.($new_order-1).' Zeilen'); + +// 7. Sync rang in Detail-Tabelle subtotaltitle_debug_log(' Starte rang-Synchronisation für docType='.$docType.' doc_id='.$document_id); $sql = "SELECT rowid, line_type, ".$tables['fk_line']." as detail_id FROM ".MAIN_DB_PREFIX."facture_lines_manager"; diff --git a/ajax/cleanup_subtotals.php b/ajax/cleanup_subtotals.php index 5cfa1a5..88cb61d 100644 --- a/ajax/cleanup_subtotals.php +++ b/ajax/cleanup_subtotals.php @@ -32,8 +32,8 @@ $sql_fix_sections .= " WHERE line_type = 'section'"; $sql_fix_sections .= " AND parent_section IS NOT NULL"; $sql_fix_sections .= " AND ".$tables['fk_parent']." = ".(int)$facture_id; $sql_fix_sections .= " AND document_type = '".$db->escape($docType)."'"; -$db->query($sql_fix_sections); -$sections_fixed = $db->affected_rows(); +$resql_fix = $db->query($sql_fix_sections); +$sections_fixed = $resql_fix ? $db->affected_rows($resql_fix) : 0; if ($sections_fixed > 0) { subtotaltitle_debug_log('🧹 ' . $sections_fixed . ' Sections mit falscher parent_section korrigiert'); $fixed += $sections_fixed; @@ -45,8 +45,8 @@ $sql_fix_zero .= " SET parent_section = NULL"; $sql_fix_zero .= " WHERE parent_section = 0"; $sql_fix_zero .= " AND ".$tables['fk_parent']." = ".(int)$facture_id; $sql_fix_zero .= " AND document_type = '".$db->escape($docType)."'"; -$db->query($sql_fix_zero); -$zero_fixed = $db->affected_rows(); +$resql_fix_zero = $db->query($sql_fix_zero); +$zero_fixed = $resql_fix_zero ? $db->affected_rows($resql_fix_zero) : 0; if ($zero_fixed > 0) { subtotaltitle_debug_log('🧹 ' . $zero_fixed . ' Einträge mit parent_section=0 korrigiert'); $fixed += $zero_fixed; diff --git a/ajax/create_textline.php b/ajax/create_textline.php index f98aad8..2bc9578 100755 --- a/ajax/create_textline.php +++ b/ajax/create_textline.php @@ -41,7 +41,7 @@ $fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL'; $sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, line_order, in_facturedet, date_creation)"; -$sql .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'text', '".$db->escape($text)."', ".$next_order.", 1, NOW())"; +$sql .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'text', '".$db->escape($text)."', ".$next_order.", 0, NOW())"; if ($db->query($sql)) { $new_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); diff --git a/ajax/reorder_all.php b/ajax/reorder_all.php index 9010127..e3e46b9 100755 --- a/ajax/reorder_all.php +++ b/ajax/reorder_all.php @@ -98,6 +98,37 @@ foreach ($subtotals_to_update as $subtotal) { } } +// ========== SECTIONS VOR IHREN PRODUKTEN SICHERSTELLEN ========== +subtotaltitle_debug_log('🔧 Stelle sicher dass Sections vor ihren Produkten stehen...'); + +// Für jede Section: Wenn ein Produkt dieser Section VOR der Section steht, +// verschiebe die Section vor das erste Produkt +$sql_sections = "SELECT rowid, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager + WHERE ".$tables['fk_parent']." = ".(int)$facture_id." + AND document_type = '".$db->escape($docType)."' + AND line_type = 'section'"; +$res_sections = $db->query($sql_sections); + +while ($section = $db->fetch_object($res_sections)) { + // Finde das Produkt dieser Section mit der kleinsten line_order + $sql_first_prod = "SELECT MIN(line_order) as min_order FROM ".MAIN_DB_PREFIX."facture_lines_manager + WHERE parent_section = ".(int)$section->rowid." + AND document_type = '".$db->escape($docType)."' + AND line_type = 'product'"; + $res_first = $db->query($sql_first_prod); + $first_prod = $db->fetch_object($res_first); + + if ($first_prod && $first_prod->min_order && $first_prod->min_order < $section->line_order) { + // Section steht NACH ihrem ersten Produkt - verschiebe sie davor + $new_section_order = (float)$first_prod->min_order - 0.5; + $sql_move = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager + SET line_order = ".$new_section_order." + WHERE rowid = ".(int)$section->rowid; + $db->query($sql_move); + subtotaltitle_debug_log(' Section #'.$section->rowid.' verschoben: '.$section->line_order.' → '.$new_section_order.' (vor Produkt mit order='.$first_prod->min_order.')'); + } +} + // ========== ALLES NEU DURCHNUMMERIEREN ========== subtotaltitle_debug_log('🔢 Normalisiere line_order...'); diff --git a/ajax/sync_to_facturedet.php b/ajax/sync_to_facturedet.php index 6aa4735..36bba2b 100755 --- a/ajax/sync_to_facturedet.php +++ b/ajax/sync_to_facturedet.php @@ -141,41 +141,17 @@ if ($action == 'add') { // Bestimme rang (Position) - UNTERSCHIEDLICH für Sections vs andere Zeilen $new_rang = 1; - if ($line_type == 'section') { - // Für Sections: Finde das erste Produkt dieser Section und füge Section DAVOR ein - $sql_first_product = "SELECT MIN(d.rang) as min_rang"; - $sql_first_product .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; - $sql_first_product .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; - $sql_first_product .= " WHERE m.parent_section = ".(int)$line_id; - $sql_first_product .= " AND m.".$tables['fk_parent']." = ".(int)$document_id; - $sql_first_product .= " AND m.document_type = '".$db->escape($docType)."'"; - $sql_first_product .= " AND m.line_type = 'product'"; - $res_first = $db->query($sql_first_product); - $obj_first = $db->fetch_object($res_first); - - if ($obj_first && $obj_first->min_rang) { - // Section VOR dem ersten Produkt einfügen - $new_rang = (int)$obj_first->min_rang; - } else { - // Keine Produkte in dieser Section - ans Ende - $sql_max = "SELECT MAX(rang) as max_rang FROM ".MAIN_DB_PREFIX.$tables['lines_table']; - $sql_max .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; - $res_max = $db->query($sql_max); - $obj_max = $db->fetch_object($res_max); - $new_rang = ($obj_max && $obj_max->max_rang) ? $obj_max->max_rang + 1 : 1; - } - } else { - // Für Text/Subtotal: Basierend auf line_order Position - $sql_rang = "SELECT MAX(d.rang) as max_rang"; - $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; - $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; - $sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id; - $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'"; - $sql_rang .= " AND m.line_order < ".(int)$line->line_order; - $res_rang = $db->query($sql_rang); - $obj_rang = $db->fetch_object($res_rang); - $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1; - } + // EINHEITLICHE BERECHNUNG für ALLE Zeilentypen basierend auf line_order + // Finde den höchsten rang aller Zeilen, die VOR dieser Zeile (nach line_order) liegen + $sql_rang = "SELECT MAX(d.rang) as max_rang"; + $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; + $sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id; + $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_rang .= " AND m.line_order < ".(int)$line->line_order; + $res_rang = $db->query($sql_rang); + $obj_rang = $db->fetch_object($res_rang); + $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1; subtotaltitle_debug_log('📝 Berechne rang: line_type='.$line_type.', new_rang='.$new_rang); diff --git a/ajax/toggle_subtotal.php b/ajax/toggle_subtotal.php index d4962b8..a1f26a8 100755 --- a/ajax/toggle_subtotal.php +++ b/ajax/toggle_subtotal.php @@ -63,8 +63,17 @@ if ($show) { $res_last = $db->query($sql_last); $obj_last = $db->fetch_object($res_last); $last_order = $obj_last->max_order ? $obj_last->max_order : 0; - - // Neue line_order = nach letztem Produkt + + // Wenn keine Produkte in der Section, nimm die line_order der Section selbst + if ($last_order == 0) { + $sql_sec = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_sec .= " WHERE rowid = ".(int)$section_id; + $res_sec = $db->query($sql_sec); + $obj_sec = $db->fetch_object($res_sec); + $last_order = $obj_sec->line_order ? $obj_sec->line_order : 0; + } + + // Neue line_order = nach letztem Produkt (oder nach der Section selbst wenn leer) $new_order = $last_order + 1; // Alle nachfolgenden Zeilen +1 @@ -88,13 +97,14 @@ if ($show) { $subtotal_manager_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); // Subtotal-Zeile auch direkt in Detail-Tabelle einfügen - // Bestimme rang (nach letztem Produkt der Section) + // Bestimme rang basierend auf line_order (EINHEITLICH für alle Zeilentypen) + // Finde den höchsten rang aller Zeilen, die VOR dieser neuen Subtotal-Zeile liegen $sql_rang = "SELECT MAX(d.rang) as max_rang"; $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; - $sql_rang .= " WHERE m.parent_section = ".(int)$section_id; + $sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$facture_id; $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'"; - $sql_rang .= " AND m.line_type = 'product'"; + $sql_rang .= " AND m.line_order < ".(int)$new_order; $res_rang = $db->query($sql_rang); $obj_rang = $db->fetch_object($res_rang); $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1; @@ -109,7 +119,8 @@ if ($show) { // Füge Subtotal in Detail-Tabelle ein (special_code = 102) $sql_ins_fd = "INSERT INTO ".MAIN_DB_PREFIX.$tables['lines_table']; $sql_ins_fd .= " (".$tables['fk_parent'].", description, qty, subprice, total_ht, total_tva, total_ttc,"; - $sql_ins_fd .= " tva_tx, product_type, special_code, rang, info_bits)"; + $sql_ins_fd .= " tva_tx, product_type, special_code, rang, info_bits,"; + $sql_ins_fd .= " multicurrency_subprice, multicurrency_total_ht, multicurrency_total_tva, multicurrency_total_ttc)"; $sql_ins_fd .= " VALUES ("; $sql_ins_fd .= (int)$facture_id.", "; $sql_ins_fd .= "'".$db->escape('Zwischensumme: '.$section->title)."', "; @@ -122,7 +133,11 @@ if ($show) { $sql_ins_fd .= "9, "; // product_type = 9 (Titel/Kommentar) $sql_ins_fd .= "102, "; // special_code = 102 (Subtotal) $sql_ins_fd .= (int)$new_rang.", "; - $sql_ins_fd .= "0)"; + $sql_ins_fd .= "0, "; // info_bits + $sql_ins_fd .= (float)$subtotal_ht.", "; // multicurrency_subprice + $sql_ins_fd .= (float)$subtotal_ht.", "; // multicurrency_total_ht + $sql_ins_fd .= "0, "; // multicurrency_total_tva + $sql_ins_fd .= (float)$subtotal_ht.")"; $db->query($sql_ins_fd); $new_detail_id = $db->last_insert_id(MAIN_DB_PREFIX.$tables['lines_table']); diff --git a/class/actions_subtotaltitle.class.php b/class/actions_subtotaltitle.class.php index 3c210a2..9d9eb89 100755 --- a/class/actions_subtotaltitle.class.php +++ b/class/actions_subtotaltitle.class.php @@ -142,11 +142,14 @@ class ActionsSubtotalTitle extends CommonHookActions // Lade Übersetzungen $langs->load('subtotaltitle@subtotaltitle'); - // Prüfe ob Sections existieren (für Collapse-Buttons) + // Prüfe ob Sections oder Textzeilen existieren (für Buttons) global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); $hasSections = false; + $hasTextLines = false; + $hasSectionsOrTextLines = false; if ($tables && $object->id) { + // Prüfe Sections $sql_sec = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_sec .= " WHERE ".$tables['fk_parent']." = ".(int)$object->id; $sql_sec .= " AND document_type = '".$db->escape($this->currentDocType)."'"; @@ -155,6 +158,18 @@ class ActionsSubtotalTitle extends CommonHookActions if ($res_sec && $obj_sec = $db->fetch_object($res_sec)) { $hasSections = ($obj_sec->cnt > 0); } + + // Prüfe Textzeilen + $sql_text = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_text .= " WHERE ".$tables['fk_parent']." = ".(int)$object->id; + $sql_text .= " AND document_type = '".$db->escape($this->currentDocType)."'"; + $sql_text .= " AND line_type = 'text'"; + $res_text = $db->query($sql_text); + if ($res_text && $obj_text = $db->fetch_object($res_text)) { + $hasTextLines = ($obj_text->cnt > 0); + } + + $hasSectionsOrTextLines = ($hasSections || $hasTextLines); } // CSS @@ -247,20 +262,23 @@ class ActionsSubtotalTitle extends CommonHookActions echo '});'."\n"; // Sync-Buttons + Collapse-Buttons - rechts ausgerichtet - echo ''."\n"; } - echo ' buttons += \'\';'; - echo ' $(".tabsAction").first().after(buttons);'; - echo ' }'; - echo '});'."\n"; } } @@ -646,12 +664,14 @@ class ActionsSubtotalTitle extends CommonHookActions static $last_rang = array(); static $last_parent_section = array(); + static $last_line_order = array(); $doc_key = $docType.'_'.$document_id; if (!isset(self::$rendered_sections[$doc_key])) { self::$rendered_sections[$doc_key] = array(); $last_rang[$doc_key] = 0; $last_parent_section[$doc_key] = null; + $last_line_order[$doc_key] = 0; } // Hole rang dieser Produktzeile @@ -710,7 +730,8 @@ class ActionsSubtotalTitle extends CommonHookActions $sql_combined .= " (SELECT MIN(fd.rang) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2"; $sql_combined .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." fd ON fd.rowid = m2.".$tables['fk_line']; $sql_combined .= " WHERE m2.parent_section = ".MAIN_DB_PREFIX."facture_lines_manager.rowid"; - $sql_combined .= " AND m2.document_type = '".$db->escape($docType)."') as first_product_rang"; + $sql_combined .= " AND m2.document_type = '".$db->escape($docType)."'"; + $sql_combined .= " AND m2.line_type = 'product') as first_product_rang"; // NUR echte Produkte, keine Subtotals! $sql_combined .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_combined .= $this->getDocumentWhere($document_id, $docType, ''); $sql_combined .= " AND (line_type = 'section' OR line_type = 'text')"; @@ -720,25 +741,113 @@ class ActionsSubtotalTitle extends CommonHookActions while ($obj = $db->fetch_object($resql_combined)) { if ($obj->line_type == 'section') { - // Section nur rendern wenn first_product_rang passt - if ($obj->first_product_rang > (int)$last_rang[$doc_key] && $obj->first_product_rang <= (int)$current_rang) { - if (!in_array($obj->rowid, self::$rendered_sections[$doc_key])) { - $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[$doc_key][] = $obj->rowid; + // Section rendern wenn: + // 1. Sie Produkte hat UND first_product_rang im Bereich liegt, ODER + // 2. Sie KEINE Produkte hat (leere Section) UND ihre line_order zwischen last_line_order und current_line_order liegt + $should_render = false; - if ($this->debug) { - error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang); + // DEBUG + error_log('[SubtotalTitle] DEBUG Section "'.$obj->title.'": line_order='.$obj->line_order.', first_product_rang='.($obj->first_product_rang ?? 'NULL').', last_line_order='.$last_line_order[$doc_key].', last_rang='.$last_rang[$doc_key].', current_rang='.$current_rang); + + if ($obj->first_product_rang !== null) { + // Section mit Produkten: prüfe rang-Bereich + if ($obj->first_product_rang > (int)$last_rang[$doc_key] && $obj->first_product_rang <= (int)$current_rang) { + $should_render = true; + } + } else { + // Leere Section: rendern wenn line_order > last_line_order UND < current_line_order + // (SQL filtert bereits nach line_order < current_line_order) + if ($obj->line_order > (int)$last_line_order[$doc_key]) { + $should_render = true; + error_log('[SubtotalTitle] DEBUG → Leere Section "'.$obj->title.'" SOLLTE gerendert werden'); + } + } + + if ($should_render && !in_array($obj->rowid, self::$rendered_sections[$doc_key])) { + $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[$doc_key][] = $obj->rowid; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang.' (leere Section: '.($obj->first_product_rang === null ? 'ja' : 'nein').')'); + } + + // Für LEERE Sections: Subtotal direkt nach Section-Header rendern (wenn show_subtotal aktiviert) + if ($obj->first_product_rang === null && $obj->show_subtotal) { + $sql_subtotal_empty = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_subtotal_empty .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_subtotal_empty .= " AND document_type = '".$db->escape($docType)."'"; + $sql_subtotal_empty .= " AND parent_section = ".(int)$obj->rowid; + $sql_subtotal_empty .= " AND line_type = 'subtotal'"; + $resql_subtotal_empty = $db->query($sql_subtotal_empty); + + if ($obj_sub_empty = $db->fetch_object($resql_subtotal_empty)) { + $subtotal_key_empty = 'subtotal_'.$obj_sub_empty->rowid; + if (!in_array($subtotal_key_empty, self::$rendered_sections[$doc_key])) { + echo $this->renderSubtotalLine($obj_sub_empty); + self::$rendered_sections[$doc_key][] = $subtotal_key_empty; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Subtotal für leere Section "'.$obj->title.'" gerendert'); + } + } } } } } elseif ($obj->line_type == 'text') { + // VOR der Textzeile: Prüfe ob es leere Sections gibt, die noch nicht gerendert wurden + // und deren line_order kleiner ist als die der Textzeile + $sql_empty_sections = "SELECT rowid, title, line_order, show_subtotal, collapsed, in_facturedet,"; + $sql_empty_sections .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2"; + $sql_empty_sections .= " WHERE m2.parent_section = ".MAIN_DB_PREFIX."facture_lines_manager.rowid"; + $sql_empty_sections .= " AND m2.document_type = '".$db->escape($docType)."'"; + $sql_empty_sections .= " AND m2.line_type = 'product') as product_count"; + $sql_empty_sections .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_empty_sections .= $this->getDocumentWhere($document_id, $docType, ''); + $sql_empty_sections .= " AND line_type = 'section'"; + $sql_empty_sections .= " AND line_order < ".(int)$obj->line_order; + $sql_empty_sections .= " ORDER BY line_order"; + $resql_empty = $db->query($sql_empty_sections); + + while ($empty_sec = $db->fetch_object($resql_empty)) { + // Nur leere Sections (keine Produkte) die noch nicht gerendert wurden + if ($empty_sec->product_count == 0 && !in_array($empty_sec->rowid, self::$rendered_sections[$doc_key])) { + $section_data = array( + 'section_id' => $empty_sec->rowid, + 'title' => $empty_sec->title, + 'show_subtotal' => $empty_sec->show_subtotal, + 'collapsed' => $empty_sec->collapsed, + 'in_facturedet' => $empty_sec->in_facturedet + ); + echo $this->renderSectionHeader($section_data); + self::$rendered_sections[$doc_key][] = $empty_sec->rowid; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Leere Section "'.$empty_sec->title.'" vor Textzeile gerendert'); + } + + // Subtotal für diese Section (wenn aktiviert) + if ($empty_sec->show_subtotal) { + $sql_sub = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_sub .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_sub .= " AND document_type = '".$db->escape($docType)."'"; + $sql_sub .= " AND parent_section = ".(int)$empty_sec->rowid; + $sql_sub .= " AND line_type = 'subtotal'"; + $resql_sub = $db->query($sql_sub); + if ($obj_sub = $db->fetch_object($resql_sub)) { + echo $this->renderSubtotalLine($obj_sub); + self::$rendered_sections[$doc_key][] = 'subtotal_'.$obj_sub->rowid; + } + } + } + } + // Textzeile rendern $text_key = 'text_'.$obj->rowid; if (!in_array($text_key, self::$rendered_sections[$doc_key])) { @@ -761,6 +870,7 @@ class ActionsSubtotalTitle extends CommonHookActions // Merke für nächsten Durchlauf $last_rang[$doc_key] = $current_rang; $last_parent_section[$doc_key] = $current_parent_section; + $last_line_order[$doc_key] = $current_line_order; // Prüfe ob dies die LETZTE Produktzeile ist - dann Subtotal per JavaScript NACH dieser Zeile einfügen if ($current_parent_section) { @@ -808,10 +918,13 @@ class ActionsSubtotalTitle extends CommonHookActions } } } + + // Trailing items (leere Sections, Textzeilen) werden jetzt in formAddObjectLine gerendert + // da dieser Hook NACH allen Produktzeilen aber VOR dem Eingabeformular aufgerufen wird } } } - + /** * Rendert eine Textzeile */ @@ -928,7 +1041,7 @@ class ActionsSubtotalTitle extends CommonHookActions /** * Hook: Wird aufgerufen wenn das Produkt-Hinzufügen-Formular gerendert wird - * Fügt Section-Dropdown hinzu + * Fügt Section-Dropdown hinzu UND rendert trailing items (Textzeilen, leere Sections) */ public function formAddObjectLine($parameters, &$object, &$action, $hookmanager) { @@ -947,7 +1060,112 @@ class ActionsSubtotalTitle extends CommonHookActions } $document_id = $object->id; + $tables = DocumentTypeHelper::getTableNames($docType); + $doc_key = $docType.'_'.$document_id; + // Initialisiere rendered_sections falls nicht vorhanden + if (!isset(self::$rendered_sections[$doc_key])) { + self::$rendered_sections[$doc_key] = array(); + } + + // ========== TRAILING ITEMS RENDERN ========== + // Hole ALLE Manager-Einträge, die NACH der letzten facturedet-Zeile kommen + // und noch nicht gerendert wurden + + // Finde die höchste line_order einer Zeile, die in facturedet ist + $sql_max_rendered = "SELECT MAX(m.line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; + $sql_max_rendered .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id; + $sql_max_rendered .= " AND m.document_type = '".$db->escape($docType)."'"; + $sql_max_rendered .= " AND m.".$tables['fk_line']." IS NOT NULL"; + $res_max = $db->query($sql_max_rendered); + $obj_max = $db->fetch_object($res_max); + $last_rendered_order = $obj_max && $obj_max->max_order ? (int)$obj_max->max_order : 0; + + if ($this->debug) { + error_log('[SubtotalTitle] formAddObjectLine: Letzte gerenderte line_order = '.$last_rendered_order); + } + + // Hole alle trailing items (alles mit line_order > last_rendered_order) + $sql_trailing = "SELECT rowid, line_type, title, line_order, parent_section, show_subtotal, collapsed, in_facturedet"; + $sql_trailing .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_trailing .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; + $sql_trailing .= " AND document_type = '".$db->escape($docType)."'"; + $sql_trailing .= " AND line_order > ".$last_rendered_order; + $sql_trailing .= " ORDER BY line_order"; + $resql_trailing = $db->query($sql_trailing); + + while ($trailing = $db->fetch_object($resql_trailing)) { + if ($trailing->line_type == 'section') { + // Section-Header rendern (falls noch nicht gerendert) + if (!in_array($trailing->rowid, self::$rendered_sections[$doc_key])) { + $section_data = array( + 'section_id' => $trailing->rowid, + 'title' => $trailing->title, + 'show_subtotal' => $trailing->show_subtotal, + 'collapsed' => $trailing->collapsed, + 'in_facturedet' => $trailing->in_facturedet + ); + echo $this->renderSectionHeader($section_data); + self::$rendered_sections[$doc_key][] = $trailing->rowid; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Trailing Section "'.$trailing->title.'" gerendert (order='.$trailing->line_order.')'); + } + + // Falls Subtotal aktiviert, auch Subtotal rendern + if ($trailing->show_subtotal) { + $sql_sub = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; + $sql_sub .= " WHERE parent_section = ".(int)$trailing->rowid; + $sql_sub .= " AND line_type = 'subtotal'"; + $sql_sub .= " AND document_type = '".$db->escape($docType)."'"; + $resql_sub = $db->query($sql_sub); + + if ($obj_sub = $db->fetch_object($resql_sub)) { + $subtotal_key = 'subtotal_'.$obj_sub->rowid; + if (!in_array($subtotal_key, self::$rendered_sections[$doc_key])) { + echo $this->renderSubtotalLine($obj_sub); + self::$rendered_sections[$doc_key][] = $subtotal_key; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Trailing Subtotal für Section "'.$trailing->title.'" gerendert'); + } + } + } + } + } + } elseif ($trailing->line_type == 'text') { + // Textzeile rendern + $text_key = 'text_'.$trailing->rowid; + if (!in_array($text_key, self::$rendered_sections[$doc_key])) { + $textline = array( + 'id' => $trailing->rowid, + 'title' => $trailing->title, + 'parent_section' => $trailing->parent_section, + 'line_order' => $trailing->line_order, + 'in_facturedet' => $trailing->in_facturedet + ); + echo $this->renderTextLine($textline); + self::$rendered_sections[$doc_key][] = $text_key; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Trailing Textzeile "'.$trailing->title.'" gerendert (order='.$trailing->line_order.')'); + } + } + } elseif ($trailing->line_type == 'subtotal') { + // Subtotal rendern (falls nicht schon mit Section gerendert) + $subtotal_key = 'subtotal_'.$trailing->rowid; + if (!in_array($subtotal_key, self::$rendered_sections[$doc_key])) { + echo $this->renderSubtotalLine($trailing); + self::$rendered_sections[$doc_key][] = $subtotal_key; + + if ($this->debug) { + error_log('[SubtotalTitle] ✅ Trailing Subtotal gerendert (order='.$trailing->line_order.')'); + } + } + } + } + + // Dann das Section-Dropdown rendern echo $this->renderSectionDropdown($document_id, $docType); return 0; diff --git a/js/subtotaltitle.js b/js/subtotaltitle.js index 513463d..f23ba5c 100755 --- a/js/subtotaltitle.js +++ b/js/subtotaltitle.js @@ -254,8 +254,45 @@ if (typeof SubtotalTitleLoaded === 'undefined') { */ function getDocumentInfo() { var url = window.location.href; + + // Versuche ID aus URL-Parametern zu extrahieren (mehrere Formate) + var id = null; + + // Format 1: ?id=123 oder &id=123 var match = url.match(/[?&]id=(\d+)/); - var id = match ? match[1] : null; + if (match) { + id = match[1]; + } + + // Format 2: Aus URLSearchParams (robuster) + if (!id) { + try { + var params = new URLSearchParams(window.location.search); + id = params.get('id'); + } catch(e) { + // URLSearchParams nicht verfügbar in alten Browsern + } + } + + // Format 3: Aus dem DOM (falls ID dort gespeichert ist) + if (!id) { + // Versuche aus versteckten Feldern oder data-Attributen + var $idInput = $('input[name="id"], input[name="facid"], input[name="socid"]').first(); + if ($idInput.length > 0) { + id = $idInput.val(); + } + } + + // Format 4: Aus der Seite selbst (Dolibarr zeigt oft die ID an) + if (!id) { + var $refDiv = $('.refid, .ref').first(); + if ($refDiv.length > 0) { + var refMatch = $refDiv.text().match(/\((\d+)\)/); + if (refMatch) { + id = refMatch[1]; + } + } + } // Erkenne Dokumenttyp anhand der URL var docType = 'invoice'; // Default @@ -265,6 +302,8 @@ function getDocumentInfo() { docType = 'order'; } + debugLog('getDocumentInfo: id=' + id + ', type=' + docType + ', url=' + url); + return { id: id, type: docType }; } @@ -332,7 +371,13 @@ function moveSection(sectionId, direction) { if (response.success) { window.location.href = window.location.pathname + window.location.search; } else { - alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + // "Already at top/bottom" ist kein echter Fehler, sondern eine Info + if (response.error === 'Already at top' || response.error === 'Already at bottom') { + debugLog('Section ist bereits am ' + (response.error === 'Already at top' ? 'Anfang' : 'Ende')); + // Kein Alert nötig - einfach nichts tun + } else { + alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); + } } }, 'json').fail(function(xhr, status, error) { if (SUBTOTAL_DEBUG) { @@ -788,8 +833,13 @@ function addUnlinkColumn() { if (rowId.match(/^row-\d+/)) { var lineId = rowId.replace('row-', '').split('-')[0]; var parentSection = $row.attr('data-parent-section'); - - if (parentSection && parentSection !== 'null') { + + // Robustere Prüfung: Hat gültige Section-ID? + var hasValidSection = parentSection && parentSection !== 'null' && parentSection !== 'undefined' && parentSection !== '' && parseInt(parentSection) > 0; + + debugLog('🔗 Zeile ' + lineId + ': parent_section="' + parentSection + '" → hasValidSection=' + hasValidSection); + + if (hasValidSection) { // Hat Section → Unlink-Button $row.append(''); } else { @@ -811,9 +861,10 @@ function addUnlinkColumn() { * PLAN B: Section-Assignment beim Produkt hinzufügen */ $(document).ready(function() { - - // Unlink-Spalte hinzufügen (nach data-Attributen) - setTimeout(addUnlinkColumn, 600); + + // Unlink-Spalte hinzufügen - NACH den PHP-Scripts die data-parent-section setzen + // Timeout erhöht auf 1500ms um sicherzustellen dass alle Attribute gesetzt sind + setTimeout(addUnlinkColumn, 1500); // 1. Beim Submit des Formulars: Section merken $(document).on('submit', 'form[name="addproduct"]', function(e) { @@ -847,6 +898,7 @@ $(document).ready(function() { /** * Weist das neueste Produkt einer Section zu + * Nach der Zuweisung wird die Darstellung per JavaScript aktualisiert (KEIN zweiter Reload) */ function assignLastProductToSection(sectionId, factureId) { var docInfo = getDocumentInfo(); @@ -860,7 +912,35 @@ function assignLastProductToSection(sectionId, factureId) { debugLog('✅ Assignment Response: ' + JSON.stringify(response)); if (response.success) { debugLog('✅ Produkt #' + response.product_id + ' zu Section zugewiesen'); - window.location.href = window.location.pathname + window.location.search; + + // KEIN zweiter Reload - stattdessen Zeile per JavaScript aktualisieren + var $productRow = $('#row-' + response.product_id); + if ($productRow.length > 0) { + // Setze data-parent-section Attribut + $productRow.attr('data-parent-section', sectionId); + + // Verschiebe die Zeile an die richtige Position (nach der Section) + var $sectionRow = $('tr.section-header[data-section-id="' + sectionId + '"]'); + if ($sectionRow.length > 0) { + // Finde das letzte Element dieser Section + var $lastInSection = $('tr[data-parent-section="' + sectionId + '"]').last(); + if ($lastInSection.length > 0 && $lastInSection[0] !== $productRow[0]) { + $lastInSection.after($productRow); + } else if ($lastInSection.length === 0) { + // Erstes Produkt in der Section + $sectionRow.after($productRow); + } + } + + // Färbung aktualisieren + colorSections(); + + debugLog('✅ Zeile per JavaScript aktualisiert - kein Reload nötig'); + } else { + // Fallback: Wenn Zeile nicht gefunden, doch reloaden + debugLog('⚠️ Zeile nicht gefunden, Fallback: Reload'); + window.location.href = window.location.pathname + window.location.search; + } } else { debugLog('❌ Fehler: ' + response.error); } @@ -1669,7 +1749,8 @@ function linkToNearestSection(lineId) { debugLog(' Response: ' + JSON.stringify(response)); if (response.success) { - window.location.reload(); + // safeReload statt reload() um POST-Warnung zu vermeiden + safeReload(); } else { showErrorAlert('Fehler: ' + (response.error || 'Unbekannter Fehler')); } diff --git a/sql/llx_facture_lines_manager.sql b/sql/llx_facture_lines_manager.sql new file mode 100644 index 0000000..fb21359 --- /dev/null +++ b/sql/llx_facture_lines_manager.sql @@ -0,0 +1,51 @@ +-- ============================================================================ +-- Copyright (C) 2026 Eduard Wisch +-- +-- 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. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS llx_facture_lines_manager ( + rowid INTEGER AUTO_INCREMENT PRIMARY KEY, + + -- Verknüpfung mit dem Elterndokument (nur eines davon ist gesetzt) + fk_facture INTEGER DEFAULT NULL, -- Rechnung + fk_propal INTEGER DEFAULT NULL, -- Angebot + fk_commande INTEGER DEFAULT NULL, -- Kundenauftrag + + -- Dokumenttyp zur einfachen Unterscheidung + document_type VARCHAR(20) DEFAULT 'invoice', -- 'invoice', 'propal', 'order' + + -- Zeilentyp + line_type VARCHAR(20) NOT NULL, -- 'section', 'product', 'text', 'subtotal' + + -- Verknüpfung zur Detail-Tabelle (facturedet, propaldet, commandedet) + fk_facturedet INTEGER DEFAULT NULL, -- für Rechnungen + fk_propaldet INTEGER DEFAULT NULL, -- für Angebote + fk_commandedet INTEGER DEFAULT NULL, -- für Kundenaufträge + + -- Section-Eigenschaften + title VARCHAR(255) DEFAULT NULL, + parent_section INTEGER DEFAULT NULL, -- FK zu rowid dieser Tabelle + + -- Sortierung und Status + line_order INTEGER DEFAULT 0, + show_subtotal TINYINT DEFAULT 0, -- Zwischensumme anzeigen? + collapsed TINYINT DEFAULT 0, -- Section eingeklappt? + in_facturedet TINYINT DEFAULT 0, -- In der Detail-Tabelle vorhanden? + + -- Zeitstempel + date_creation DATETIME DEFAULT NULL, + tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Indizes + INDEX idx_flm_fk_facture (fk_facture), + INDEX idx_flm_fk_propal (fk_propal), + INDEX idx_flm_fk_commande (fk_commande), + INDEX idx_flm_document_type (document_type), + INDEX idx_flm_line_type (line_type), + INDEX idx_flm_parent_section (parent_section), + INDEX idx_flm_line_order (line_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;