Version 3.5.0 - Drag & Drop Sortierung, Duplicate-Key-Fix

- Drag & Drop Sortierung im Anlagenbaum (Geschwister-Ebene)
- UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitert
- Automatische DB-Migration beim Modul-Aktivieren
- Visueller Abstand zwischen Root-Elementen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-18 21:05:13 +01:00
parent f2f3393b12
commit 06f8bc8fde
72 changed files with 281 additions and 5 deletions

0
CLAUDE.md Normal file → Executable file
View file

View file

@ -1,5 +1,25 @@
# CHANGELOG MODULE KUNDENKARTE FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
## 3.5.0 (2026-02)
### Neue Features
- **Drag & Drop Sortierung**: Elemente im Anlagenbaum per Drag & Drop umsortieren
- Geschwister-Elemente auf gleicher Ebene verschieben
- Visuelle Drop-Indikatoren (blaue Linie)
- Reihenfolge wird sofort per AJAX gespeichert (kein Seitenreload)
- Funktioniert in Kunden- und Kontakt-Anlagen
### Bugfixes
- **Duplicate-Key-Fehler behoben**: UNIQUE KEY `uk_kundenkarte_societe_system` um `fk_contact` erweitert
- Systeme koennen nun gleichzeitig auf Kunden- und Kontaktebene existieren
- Migration wird automatisch beim Modul-Aktivieren ausgefuehrt
### Verbesserungen
- Visueller Abstand zwischen Root-Elementen im Anlagenbaum
- INSERT fuer Kunden-Systeme setzt explizit `fk_contact = 0`
---
## 2.0 (2026-01)
### Neue Features

View file

@ -12,6 +12,7 @@ Das KundenKarte-Modul erweitert Dolibarr um zwei wichtige Funktionen fuer Kunden
### Technische Anlagen (Anlagen)
- Hierarchische Baumstruktur fuer technische Installationen
- Drag & Drop Sortierung der Elemente innerhalb einer Ebene
- Flexible Systemkategorien (z.B. Strom, Internet, Kabel, Sat)
- Kategorie-Auswahl beim Erstellen: Gebaeude/Standort oder Element/Geraet
- Typ-Select mit FontAwesome-Icons und Farbkodierung (Select2)

0
admin/building_types.php Normal file → Executable file
View file

0
admin/busbar_types.php Normal file → Executable file
View file

0
admin/equipment_types.php Normal file → Executable file
View file

0
admin/medium_types.php Normal file → Executable file
View file

21
ajax/anlage.php Normal file → Executable file
View file

@ -130,6 +130,27 @@ switch ($action) {
}
break;
case 'reorder':
// Reihenfolge der Elemente aktualisieren
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = $langs->trans('ErrorPermissionDenied');
break;
}
$idsRaw = GETPOST('ids', 'array');
if (!empty($idsRaw) && is_array($idsRaw)) {
$ids = array_map('intval', $idsRaw);
$result = $anlage->updateRangs($ids);
if ($result > 0) {
$response['success'] = true;
} else {
$response['error'] = 'Fehler beim Speichern der Reihenfolge';
}
} else {
$response['error'] = 'Keine IDs übergeben';
}
break;
default:
$response['error'] = 'Unknown action';
}

0
ajax/anlage_connection.php Normal file → Executable file
View file

0
ajax/audit_log.php Normal file → Executable file
View file

0
ajax/bom_generator.php Normal file → Executable file
View file

0
ajax/building_types.php Normal file → Executable file
View file

0
ajax/equipment.php Normal file → Executable file
View file

0
ajax/equipment_carrier.php Normal file → Executable file
View file

0
ajax/equipment_connection.php Normal file → Executable file
View file

0
ajax/equipment_panel.php Normal file → Executable file
View file

0
ajax/equipment_type_fields.php Normal file → Executable file
View file

0
ajax/equipment_type_icon.php Normal file → Executable file
View file

0
ajax/export_schematic_pdf.php Normal file → Executable file
View file

0
ajax/medium_types.php Normal file → Executable file
View file

0
ajax/tree_config.php Normal file → Executable file
View file

0
anlage_connection.php Normal file → Executable file
View file

View file

@ -301,6 +301,36 @@ class Anlage extends CommonObject
}
}
/**
* Reihenfolge (rang) für mehrere Elemente aktualisieren
*
* @param array $ids Array von Anlage-IDs in gewünschter Reihenfolge
* @return int 1 bei Erfolg, <0 bei Fehler
*/
public function updateRangs($ids)
{
$this->db->begin();
$error = 0;
foreach ($ids as $rang => $id) {
$sql = "UPDATE ".MAIN_DB_PREFIX."kundenkarte_anlage";
$sql .= " SET rang = ".((int) $rang);
$sql .= " WHERE rowid = ".((int) $id);
if (!$this->db->query($sql)) {
$error++;
break;
}
}
if ($error) {
$this->db->rollback();
return -1;
}
$this->db->commit();
return 1;
}
/**
* Delete object in database
*

0
class/auditlog.class.php Normal file → Executable file
View file

0
class/busbartype.class.php Normal file → Executable file
View file

0
class/equipment.class.php Normal file → Executable file
View file

0
class/equipmentcarrier.class.php Normal file → Executable file
View file

0
class/equipmentconnection.class.php Normal file → Executable file
View file

0
class/equipmentpanel.class.php Normal file → Executable file
View file

0
class/equipmenttype.class.php Normal file → Executable file
View file

0
class/mediumtype.class.php Normal file → Executable file
View file

View file

@ -76,7 +76,7 @@ class modKundenKarte extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@kundenkarte'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '3.4.0';
$this->version = '3.5.0';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -512,6 +512,9 @@ class modKundenKarte extends DolibarrModules
//$result4=$extrafields->addExtraField('kundenkarte_myattr4', "New Attr 4 label", 'select', 1, 3, 'thirdparty', 0, 1, '', array('options'=>array('code1'=>'Val1','code2'=>'Val2','code3'=>'Val3')), 1,'', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")');
//$result5=$extrafields->addExtraField('kundenkarte_myattr5', "New Attr 5 label", 'text', 1, 10, 'user', 0, 0, '', '', 1, '', -1, 0, '', '', 'kundenkarte@kundenkarte', 'isModEnabled("kundenkarte")');
// Migrationen: UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitern
$this->_migrateSocieteSystemUniqueKey();
// Permissions
$this->remove($options);
@ -551,6 +554,41 @@ class modKundenKarte extends DolibarrModules
return $this->_init($sql, $options);
}
/**
* Migration: UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitern.
* Idempotent - kann mehrfach ausgeführt werden.
*
* @return void
*/
private function _migrateSocieteSystemUniqueKey()
{
$table = MAIN_DB_PREFIX.'kundenkarte_societe_system';
// Prüfen ob Tabelle existiert
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// NULL-Werte in fk_contact auf 0 normalisieren
$this->db->query("UPDATE ".$table." SET fk_contact = 0 WHERE fk_contact IS NULL");
// Spalte NOT NULL mit Default 0 setzen
$this->db->query("ALTER TABLE ".$table." MODIFY COLUMN fk_contact integer DEFAULT 0 NOT NULL");
// Prüfen ob der UNIQUE KEY fk_contact enthält
$resql = $this->db->query("SHOW INDEX FROM ".$table." WHERE Key_name = 'uk_kundenkarte_societe_system' AND Column_name = 'fk_contact'");
if ($resql && $this->db->num_rows($resql) > 0) {
return; // Bereits migriert
}
// Alten UNIQUE KEY entfernen (falls vorhanden)
$this->db->query("ALTER TABLE ".$table." DROP INDEX uk_kundenkarte_societe_system");
// Neuen UNIQUE KEY mit fk_contact anlegen
$this->db->query("ALTER TABLE ".$table." ADD UNIQUE INDEX uk_kundenkarte_societe_system (fk_soc, fk_contact, fk_system)");
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.

View file

@ -146,6 +146,11 @@
padding-left: 0 !important;
}
/* Abstand zwischen Root-Elementen (nicht beim ersten) */
.kundenkarte-tree > .kundenkarte-tree-node + .kundenkarte-tree-node {
margin-top: 12px !important;
}
/* Tree-row at root level - no lines */
.kundenkarte-tree > .kundenkarte-tree-row .cable-line {
display: none !important;
@ -254,6 +259,32 @@
display: none !important;
}
/* Drag & Drop Sortierung */
.kundenkarte-dragging {
opacity: 0.4 !important;
}
.kundenkarte-dragging > .kundenkarte-tree-item {
border: 1px dashed #666 !important;
}
body.kundenkarte-drag-active {
cursor: grabbing !important;
user-select: none !important;
}
body.kundenkarte-drag-active * {
cursor: grabbing !important;
}
.kundenkarte-drag-above > .kundenkarte-tree-item {
border-top: 3px solid #4a9eff !important;
}
.kundenkarte-drag-below > .kundenkarte-tree-item {
border-bottom: 3px solid #4a9eff !important;
}
.kundenkarte-tree-type {
font-size: 0.75em !important;
padding: 2px 8px !important;

View file

@ -171,9 +171,13 @@
hideTimeout: null,
currentTooltip: null,
currentItem: null,
draggedNode: null,
isDragging: false,
dropTarget: null,
init: function() {
this.bindEvents();
this.initDragDrop();
},
bindEvents: function() {
@ -580,6 +584,118 @@
});
},
// Drag & Drop Sortierung initialisieren
initDragDrop: function() {
var self = this;
this.draggedNode = null;
$(document).on('mousedown', '.kundenkarte-tree-item', function(e) {
// Nicht bei Klick auf Links, Buttons oder Toggle
if ($(e.target).closest('a, button, .kundenkarte-tree-toggle').length) return;
var $item = $(this);
var $node = $item.closest('.kundenkarte-tree-node');
// Nur wenn Schreibberechtigung (Actions vorhanden)
if (!$item.find('.kundenkarte-tree-actions').length) return;
self.draggedNode = $node;
self.dragStartY = e.pageY;
self.isDragging = false;
$(document).on('mousemove.treeDrag', function(e) {
// Erst ab 5px Bewegung als Drag werten
if (!self.isDragging && Math.abs(e.pageY - self.dragStartY) > 5) {
self.isDragging = true;
self.draggedNode.addClass('kundenkarte-dragging');
$('body').addClass('kundenkarte-drag-active');
}
if (self.isDragging) {
self.handleDragOver(e);
}
});
$(document).on('mouseup.treeDrag', function(e) {
$(document).off('mousemove.treeDrag mouseup.treeDrag');
if (self.isDragging) {
self.handleDrop();
}
self.draggedNode.removeClass('kundenkarte-dragging');
$('body').removeClass('kundenkarte-drag-active');
$('.kundenkarte-drag-above, .kundenkarte-drag-below').removeClass('kundenkarte-drag-above kundenkarte-drag-below');
self.draggedNode = null;
self.isDragging = false;
self.dropTarget = null;
});
});
},
handleDragOver: function(e) {
var self = this;
// Alle Markierungen entfernen
$('.kundenkarte-drag-above, .kundenkarte-drag-below').removeClass('kundenkarte-drag-above kundenkarte-drag-below');
// Element unter dem Mauszeiger finden
var $target = $(e.target).closest('.kundenkarte-tree-node');
if (!$target.length || $target.is(self.draggedNode)) return;
// Nur Geschwister erlauben (gleicher Parent-Container)
var $dragParent = self.draggedNode.parent();
var $targetParent = $target.parent();
if (!$dragParent.is($targetParent)) return;
// Position bestimmen: obere oder untere Hälfte des Ziels
var targetRect = $target.children('.kundenkarte-tree-item')[0].getBoundingClientRect();
var midY = targetRect.top + targetRect.height / 2;
if (e.clientY < midY) {
$target.addClass('kundenkarte-drag-above');
self.dropTarget = { node: $target, position: 'before' };
} else {
$target.addClass('kundenkarte-drag-below');
self.dropTarget = { node: $target, position: 'after' };
}
},
handleDrop: function() {
var self = this;
if (!self.dropTarget) return;
var $target = self.dropTarget.node;
var position = self.dropTarget.position;
// DOM-Reihenfolge aktualisieren
if (position === 'before') {
self.draggedNode.insertBefore($target);
} else {
self.draggedNode.insertAfter($target);
}
// Neue Reihenfolge der Geschwister sammeln
var $parent = self.draggedNode.parent();
var ids = [];
$parent.children('.kundenkarte-tree-node').each(function() {
var id = $(this).children('.kundenkarte-tree-item').data('anlage-id');
if (id) ids.push(id);
});
// Per AJAX speichern
var baseUrl = $('meta[name="dolibarr-baseurl"]').attr('content') || '';
$.ajax({
type: 'POST',
url: baseUrl + '/custom/kundenkarte/ajax/anlage.php',
data: {
action: 'reorder',
token: $('input[name="token"]').val() || '',
'ids[]': ids
},
dataType: 'json'
});
},
expandAll: function() {
$('.kundenkarte-tree-toggle').removeClass('collapsed');
$('.kundenkarte-tree-children').removeClass('collapsed');

0
js/pathfinding.min.js vendored Normal file → Executable file
View file

0
sql/data_building_types.sql Normal file → Executable file
View file

0
sql/data_busbar_types.sql Normal file → Executable file
View file

0
sql/data_medium_types.sql Normal file → Executable file
View file

0
sql/data_terminal_types.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_anlage_connection.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_anlage_connection.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_audit_log.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_audit_log.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_building_type.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_building_type.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_busbar_type.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_busbar_type.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_carrier.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_carrier.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_connection.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_connection.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_panel.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_type.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_type.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_type_field.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_equipment_type_field.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_medium_type.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_medium_type.sql Normal file → Executable file
View file

View file

@ -6,7 +6,7 @@
ALTER TABLE llx_kundenkarte_societe_system ADD INDEX idx_kundenkarte_societe_system_fk_soc (fk_soc);
ALTER TABLE llx_kundenkarte_societe_system ADD INDEX idx_kundenkarte_societe_system_fk_system (fk_system);
ALTER TABLE llx_kundenkarte_societe_system ADD UNIQUE INDEX uk_kundenkarte_societe_system (fk_soc, fk_system);
ALTER TABLE llx_kundenkarte_societe_system ADD UNIQUE INDEX uk_kundenkarte_societe_system (fk_soc, fk_contact, fk_system);
ALTER TABLE llx_kundenkarte_societe_system ADD CONSTRAINT fk_kundenkarte_societe_system_soc FOREIGN KEY (fk_soc) REFERENCES llx_societe(rowid);
ALTER TABLE llx_kundenkarte_societe_system ADD CONSTRAINT fk_kundenkarte_societe_system_system FOREIGN KEY (fk_system) REFERENCES llx_c_kundenkarte_anlage_system(rowid);

View file

@ -9,6 +9,7 @@ CREATE TABLE llx_kundenkarte_societe_system (
rowid integer AUTO_INCREMENT PRIMARY KEY,
entity integer DEFAULT 1 NOT NULL,
fk_soc integer NOT NULL, -- Customer (llx_societe)
fk_contact integer DEFAULT 0 NOT NULL, -- Contact/Address (0 = thirdparty-level)
fk_system integer NOT NULL, -- System category (llx_c_kundenkarte_anlage_system)
note text, -- Optional notes
date_creation datetime NOT NULL,

View file

@ -4,5 +4,5 @@
-- Add fk_contact column for contact/address specific system assignments
-- ============================================================================
ALTER TABLE llx_kundenkarte_societe_system ADD COLUMN fk_contact integer DEFAULT NULL AFTER fk_soc;
ALTER TABLE llx_kundenkarte_societe_system ADD COLUMN fk_contact integer DEFAULT 0 NOT NULL AFTER fk_soc;
ALTER TABLE llx_kundenkarte_societe_system ADD INDEX idx_kundenkarte_societe_system_fk_contact (fk_contact);

0
sql/llx_kundenkarte_terminal_bridge.key.sql Normal file → Executable file
View file

0
sql/llx_kundenkarte_terminal_bridge.sql Normal file → Executable file
View file

0
sql/update_3.0.0.sql Normal file → Executable file
View file

0
sql/update_3.1.0.sql Normal file → Executable file
View file

0
sql/update_3.2.0.sql Normal file → Executable file
View file

0
sql/update_3.3.0.sql Normal file → Executable file
View file

0
sql/update_3.3.2.sql Normal file → Executable file
View file

18
sql/update_3.4.1.sql Normal file
View file

@ -0,0 +1,18 @@
-- ============================================================================
-- KundenKarte Module Update 3.4.1
-- Fix: UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitern
-- Ohne den Fix kann ein System nicht gleichzeitig auf Kunden- und Kontaktebene
-- existieren (Duplicate entry Fehler).
-- ============================================================================
-- 1. NULL-Werte in fk_contact auf 0 normalisieren (Kunden-Ebene)
UPDATE llx_kundenkarte_societe_system SET fk_contact = 0 WHERE fk_contact IS NULL;
-- 2. Spalte NOT NULL mit Default 0 setzen
ALTER TABLE llx_kundenkarte_societe_system MODIFY COLUMN fk_contact integer DEFAULT 0 NOT NULL;
-- 3. Alten UNIQUE KEY entfernen
ALTER TABLE llx_kundenkarte_societe_system DROP INDEX uk_kundenkarte_societe_system;
-- 4. Neuen UNIQUE KEY mit fk_contact anlegen
ALTER TABLE llx_kundenkarte_societe_system ADD UNIQUE INDEX uk_kundenkarte_societe_system (fk_soc, fk_contact, fk_system);

View file

@ -104,8 +104,8 @@ if ($action == 'add_system' && $permissiontoadd) {
$newSystemId = GETPOSTINT('new_system_id');
if ($newSystemId > 0 && !isset($customerSystems[$newSystemId])) {
$sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_societe_system";
$sql .= " (entity, fk_soc, fk_system, date_creation, fk_user_creat, active)";
$sql .= " VALUES (".$conf->entity.", ".((int) $id).", ".((int) $newSystemId).", NOW(), ".((int) $user->id).", 1)";
$sql .= " (entity, fk_soc, fk_contact, fk_system, date_creation, fk_user_creat, active)";
$sql .= " VALUES (".$conf->entity.", ".((int) $id).", 0, ".((int) $newSystemId).", NOW(), ".((int) $user->id).", 1)";
$result = $db->query($sql);
if ($result) {
setEventMessages($langs->trans('SystemAdded'), null, 'mesgs');