Graph: Bearbeitungsmodus, Toolbar-Layout, Contact-Filter

- Nodes standardmäßig gesperrt (autoungrabify), nur per
  "Anordnen"-Button verschiebbar, explizites Speichern/Abbrechen
- Graph-Toolbar unter die System-Tab-Borderlinie verschoben
- Anordnen/Speichern/Abbrechen rechts in Zeile 2 (Spacer)
- Contact-Filter in graph_data.php: Kunden-Ebene zeigt nur
  Elemente ohne Kontaktzuweisung (konsistent mit Baumansicht)
- CSS: display ohne !important damit style="display:none" greift
- Changelog und Sprachdateien aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-22 10:34:44 +01:00
parent 840c0132c3
commit e6b28fe85e
8 changed files with 520 additions and 75 deletions

View file

@ -7,15 +7,18 @@
- Raeume als Compound-Container, Geraete als Nodes darin
- Kabelverbindungen als sichtbare Edges (auch raumuebergreifend)
- Durchgeschleifte Leitungen als gestrichelte Linien
- Zwei Layout-Modi per Knopfdruck:
- **Raeumlich** (cose-bilkent): Geraete gruppiert nach Raeumen
- **Technisch** (dagre): Hierarchischer Stromfluss top-down
- Dagre-Layout: Hierarchischer Stromfluss top-down
- Zoom/Pan/Fit-Controls, Mausrad-Zoom Toggle
- Kabeltyp-Legende mit Farben
- Node-Positionen speicherbar (Drag&Drop → AJAX-Speicherung)
- **Bearbeitungsmodus**: Nodes nur per "Anordnen"-Button verschiebbar,
Positionen per "Speichern"-Button fest, "Abbrechen" setzt zurueck
- Viewport-Persistenz (Zoom/Pan bleibt beim Seitenwechsel)
- Klick auf Node/Edge oeffnet Detail-/Bearbeitungsseite
- Suche als Overlay im Graph-Container (Nodes hervorheben/abdunkeln)
- Kontextmenue (Rechtsklick): Ansehen, Bearbeiten, Kopieren, Loeschen
- PNG-Export des Graphen
- Admin-Setting: Ansichtsmodus (Baum/Graph) in Setup waehlbar
- Toolbar zweizeilig: Aktionen oben, Graph-Steuerung unten
- **Verbindungsformular verbessert**
- Select-Dropdowns zeigen nur Geraete (keine Gebaeude/Raeume)
@ -31,6 +34,8 @@
- `js/cytoscape.min.js`, `js/dagre.min.js`, `js/cytoscape-dagre.js`, `js/cytoscape-cose-bilkent.js` - Bibliotheken
### Bugfixes
- **Contact-Filter im Graph**: Graph zeigte faelschlicherweise Kontakt-Elemente auf Kunden-Ebene
- Fix: `fk_contact` Filter in `graph_data.php` analog zum Baum
- **Verbindung hinzufuegen**: Formular zeigte "Feld erforderlich"-Fehler beim Oeffnen
- Ursache: `action=create` in URL triggerte Handler vor Formular-Anzeige
- Fix: Korrekte Dolibarr-Konvention (create=Formular, add=Verarbeitung)

View file

@ -65,7 +65,7 @@ if ($resFields) {
// Hierarchie kommt aus fk_parent (wie im Baum)
$sql = "SELECT a.rowid, a.label, a.fk_parent, a.fk_system, a.fk_anlage_type,";
$sql .= " a.field_values, a.fk_contact, a.graph_x, a.graph_y,";
$sql .= " t.label as type_label, t.picto as type_picto,";
$sql .= " t.label as type_label, t.picto as type_picto, t.color as type_color,";
$sql .= " s.code as system_code, s.label as system_label,";
$sql .= " ts.code as type_system_code,";
$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files f WHERE f.fk_anlage = a.rowid AND f.file_type IN ('image/jpeg','image/png','image/gif','image/webp')) as image_count,";
@ -78,6 +78,9 @@ $sql .= " WHERE a.fk_soc = ".(int)$socId;
$sql .= " AND s.code != 'GLOBAL'";
if ($contactId > 0) {
$sql .= " AND a.fk_contact = ".(int)$contactId;
} else {
// Auf Kunden-Ebene nur Elemente ohne Kontaktzuweisung (wie im Baum)
$sql .= " AND (a.fk_contact IS NULL OR a.fk_contact = 0)";
}
$sql .= " AND a.status = 1";
$sql .= " ORDER BY a.fk_parent, a.rang, a.rowid";
@ -99,6 +102,7 @@ if ($resql) {
'label' => $obj->label,
'type_label' => $obj->type_label ?: '',
'type_picto' => $obj->type_picto ?: '',
'type_color' => $obj->type_color ?: '',
'system_code' => $obj->system_code ?: '',
'system_label' => $obj->system_label ?: '',
'fk_parent' => (int) $obj->fk_parent,
@ -133,13 +137,14 @@ if ($resql) {
'value' => $val,
'display' => $display,
'color' => isset($fm['color']) ? $fm['color'] : '',
'type' => isset($fm['type']) ? $fm['type'] : 'text',
);
}
$nodeData['fields'] = $fields;
}
}
// Compound-Parent aus fk_parent (wie im Baum)
// Compound-Parent aus fk_parent (Eltern-Kind-Verschachtelung)
if ($obj->fk_parent > 0) {
$nodeData['parent'] = 'n_'.$obj->fk_parent;
}

View file

@ -618,6 +618,21 @@ body.kundenkarte-drag-active * {
vertical-align: middle !important;
}
/* System hinzufügen Button - kleiner, knapp über der Linie */
.kundenkarte-add-system-btn {
font-size: 11px !important;
padding: 4px 10px !important;
margin-left: auto !important;
align-self: flex-end !important;
margin-bottom: -1px !important;
height: auto !important;
line-height: 1.3 !important;
}
.kundenkarte-add-system-btn i {
font-size: 9px !important;
}
.kundenkarte-system-tab {
padding: 8px 16px !important;
border: 1px solid #555 !important;
@ -643,7 +658,7 @@ body.kundenkarte-drag-active * {
}
.kundenkarte-system-tab-icon {
font-size: 16px !important;
font-size: 14px !important;
}
/* ========================================

View file

@ -33,27 +33,37 @@
}
}
/* Toolbar: Aktionen links, Zoom rechts */
/* Toolbar: Zweizeilig */
.kundenkarte-graph-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
gap: 8px;
}
.kundenkarte-graph-actions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 0;
}
.kundenkarte-graph-actions .button {
white-space: nowrap;
}
.kundenkarte-graph-zoom-controls {
.kundenkarte-graph-toolbar-row {
display: flex;
gap: 4px;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* Alle Buttons in der Graph-Toolbar einheitlich */
.kundenkarte-graph-toolbar .button,
.kundenkarte-graph-toolbar button.button {
white-space: nowrap;
display: inline-flex;
align-items: center !important;
gap: 4px !important;
padding: 6px 12px !important;
font-size: 12px !important;
height: 30px !important;
box-sizing: border-box !important;
}
/* Spacer: schiebt Anordnen/Speichern/Abbrechen nach rechts */
.kundenkarte-graph-toolbar-spacer {
flex: 1;
}
#btn-graph-wheel-zoom.active {
@ -62,6 +72,33 @@
border-color: var(--butactionbg, #4390dc) !important;
}
/* Bearbeitungsmodus: Container-Rahmen */
#kundenkarte-graph-container.graph-edit-mode {
border-color: #5a9a6a;
box-shadow: 0 0 0 2px rgba(90, 154, 106, 0.3);
}
/* Speichern-Button grün, Abbrechen-Button rot */
.btn-graph-save {
background: #2e7d32 !important;
border-color: #2e7d32 !important;
color: #fff !important;
}
.btn-graph-save:hover {
background: #388e3c !important;
}
.btn-graph-cancel {
background: #c62828 !important;
border-color: #c62828 !important;
color: #fff !important;
}
.btn-graph-cancel:hover {
background: #d32f2f !important;
}
/* Legende */
.kundenkarte-graph-legend {
display: flex;
@ -199,31 +236,98 @@
margin-bottom: 16px;
}
/* Kontextmenü (Rechtsklick auf Node) */
.kundenkarte-graph-contextmenu {
position: absolute;
z-index: 200;
background: var(--colorbacktabcard1, #1e2a3a);
border: 1px solid var(--inputbordercolor, #3a6a8e);
border-radius: 6px;
padding: 4px 0;
min-width: 160px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
}
.kundenkarte-graph-contextmenu .ctx-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
color: var(--colortext, #ddd);
text-decoration: none;
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.kundenkarte-graph-contextmenu .ctx-item:hover {
background: var(--butactionbg, #4390dc);
color: #fff;
}
.kundenkarte-graph-contextmenu .ctx-item i {
width: 16px;
text-align: center;
font-size: 12px;
}
.kundenkarte-graph-contextmenu .ctx-delete {
color: #e57373;
}
.kundenkarte-graph-contextmenu .ctx-delete:hover {
background: #c62828;
color: #fff;
}
/* Suchfeld als Overlay im Graph-Container */
.kundenkarte-graph-search-floating {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
}
.kundenkarte-graph-search-floating input {
padding: 6px 10px 6px 28px;
border: 1px solid var(--inputbordercolor, #3a3a3a);
border-radius: 4px;
background: var(--colorbackbody, #1d1e20);
color: var(--colortext, #ddd);
font-size: 12px;
width: 180px;
opacity: 0.7;
transition: opacity 0.2s, width 0.2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 8px center;
}
.kundenkarte-graph-search-floating input:focus {
outline: none;
border-color: var(--butactionbg, #4390dc);
opacity: 1;
width: 220px;
}
/* Mobile Anpassungen */
@media (max-width: 768px) {
.kundenkarte-graph-toolbar {
flex-wrap: wrap;
.kundenkarte-graph-toolbar-row {
gap: 4px;
}
.kundenkarte-graph-actions {
flex: 1 1 100%;
order: 2;
.kundenkarte-graph-toolbar .button,
.kundenkarte-graph-toolbar button.button {
padding: 8px 8px !important;
font-size: 11px !important;
height: 28px !important;
}
.kundenkarte-graph-actions .button {
flex: 1;
text-align: center;
padding: 10px 8px !important;
font-size: 12px !important;
.kundenkarte-graph-search-floating input {
width: 140px;
}
.kundenkarte-graph-zoom-controls {
order: 1;
margin-left: auto;
}
.kundenkarte-graph-zoom-controls .button {
padding: 10px 8px !important;
font-size: 12px !important;
.kundenkarte-graph-search-floating input:focus {
width: 160px;
}
}

View file

@ -61,12 +61,20 @@
ajaxUrl: '',
saveUrl: '',
moduleUrl: '',
pageUrl: '',
systemId: 0,
socId: 0,
contactId: 0,
isContact: false,
canEdit: false,
canDelete: false,
tooltipEl: null,
contextMenuEl: null,
contextMenuNodeId: null,
wheelZoomEnabled: false,
hasPositions: false,
editMode: false,
_savedPositions: {},
_saveTimer: null,
_dirtyNodes: {},
_viewportTimer: null,
@ -80,7 +88,9 @@
this.isContact = config.isContact || false;
this.createTooltipElement();
this.initContextMenu();
this.bindZoomButtons();
this.bindSearch();
this.loadGraphData();
},
@ -280,7 +290,7 @@
var f = n.data.fields[i];
var v = f.value;
if (!v || v === '') continue;
if (v === '1' && f.display === 'badge') v = '\u2713';
if (f.type === 'checkbox' && (v === '1' || v === 'true')) v = '\u2713';
if (f.display === 'parentheses') {
parenParts.push(v);
@ -305,6 +315,15 @@
}
}
// Datei-Indikatoren
var fileInfo = [];
if (n.data.image_count > 0) fileInfo.push('\ud83d\uddbc ' + n.data.image_count);
if (n.data.doc_count > 0) fileInfo.push('\ud83d\udcc4 ' + n.data.doc_count);
if (fileInfo.length > 0) {
if (badgeLines.length === 0) lines.push('─────────────');
lines.push(fileInfo.join(' '));
}
n.data.display_label = lines.join('\n');
}
@ -350,6 +369,7 @@
userZoomingEnabled: false,
userPanningEnabled: true,
boxSelectionEnabled: false,
autoungrabify: true,
layout: { name: 'preset' }
});
@ -385,11 +405,25 @@
this.bindGraphEvents();
},
/**
* Dolibarr CSS-Variablen auslesen (Fallback für nicht-gesetzte Werte)
*/
getCssVar: function(name, fallback) {
try {
var val = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return val || fallback;
} catch(e) {
return fallback;
}
},
/**
* Cytoscape Stylesheet
*/
getStylesheet: function() {
var faFont = '"Font Awesome 5 Free", "FontAwesome", sans-serif';
// Dolibarr-Schriftfarbe verwenden
var textColor = this.getCssVar('--colortext', '#dcdcdc');
return [
// Räume/Gebäude (Compound-Container)
@ -400,7 +434,7 @@
'background-color': '#2a2b2d',
'background-opacity': 1,
'border-width': 2,
'border-color': '#4390dc',
'border-color': function(node) { return node.data('type_color') || '#4390dc'; },
'border-style': 'dashed',
'padding': '25px',
'label': 'data(display_label)',
@ -409,7 +443,7 @@
'text-valign': 'top',
'text-halign': 'center',
'font-size': '13px',
'color': '#ffffff',
'color': textColor,
'text-margin-y': -8,
'text-background-color': '#2a2b2d',
'text-background-opacity': 0.95,
@ -429,7 +463,7 @@
'padding': '14px',
'background-color': '#2d4a3a',
'border-width': 2,
'border-color': '#5a9a6a',
'border-color': function(node) { return node.data('type_color') || '#5a9a6a'; },
'label': 'data(display_label)',
'font-family': faFont,
'font-weight': 'bold',
@ -437,7 +471,7 @@
'text-halign': 'center',
'text-justification': 'left',
'font-size': '11px',
'color': '#ffffff',
'color': textColor,
'text-wrap': 'wrap',
'text-max-width': '220px',
'line-height': 1.4
@ -491,10 +525,122 @@
'border-color': '#d4944a',
'border-width': 3
}
},
// Suche: abgedunkelte Nodes
{
selector: '.search-dimmed',
style: {
'opacity': 0.2
}
},
// Suche: hervorgehobene Treffer
{
selector: '.search-highlight',
style: {
'border-color': '#e8a838',
'border-width': 3,
'opacity': 1
}
}
];
},
/**
* Kontextmenü initialisieren
*/
initContextMenu: function() {
var self = this;
this.contextMenuEl = document.getElementById('kundenkarte-graph-contextmenu');
if (!this.contextMenuEl) return;
// Klick auf Menü-Eintrag
$(this.contextMenuEl).on('click', '.ctx-item', function(e) {
e.preventDefault();
var action = $(this).data('action');
var anlageId = self.contextMenuNodeId;
if (!anlageId) return;
var baseUrl = self.pageUrl + '?id=' + self.socId + '&system=' + self.systemId;
switch (action) {
case 'view':
window.location.href = baseUrl + '&action=view&anlage_id=' + anlageId;
break;
case 'edit':
window.location.href = baseUrl + '&action=edit&anlage_id=' + anlageId;
break;
case 'copy':
window.location.href = baseUrl + '&action=copy&anlage_id=' + anlageId;
break;
case 'delete':
window.location.href = baseUrl + '&action=delete&anlage_id=' + anlageId;
break;
case 'add-child':
window.location.href = baseUrl + '&action=create&parent_id=' + anlageId;
break;
}
self.hideContextMenu();
});
// Klick außerhalb schließt Menü
$(document).on('click', function() {
self.hideContextMenu();
});
},
showContextMenu: function(node, renderedPos) {
if (!this.contextMenuEl) return;
this.contextMenuNodeId = node.data('id').replace('n_', '');
var container = document.getElementById(this.containerId);
var rect = container.getBoundingClientRect();
var x = rect.left + renderedPos.x;
var y = rect.top + renderedPos.y + window.scrollY;
// Nicht über Bildschirmrand hinaus
this.contextMenuEl.style.display = 'block';
var menuW = this.contextMenuEl.offsetWidth;
var menuH = this.contextMenuEl.offsetHeight;
if (x + menuW > window.innerWidth) x = window.innerWidth - menuW - 10;
if (y + menuH > window.scrollY + window.innerHeight) y = y - menuH;
this.contextMenuEl.style.left = x + 'px';
this.contextMenuEl.style.top = y + 'px';
},
hideContextMenu: function() {
if (this.contextMenuEl) {
this.contextMenuEl.style.display = 'none';
this.contextMenuNodeId = null;
}
},
/**
* Suchfunktion binden
*/
bindSearch: function() {
var self = this;
$(document).on('input', '#kundenkarte-graph-search', function() {
var query = $(this).val().toLowerCase().trim();
if (!self.cy) return;
if (query === '') {
// Alle Nodes wieder normal anzeigen
self.cy.nodes().removeClass('search-dimmed search-highlight');
return;
}
self.cy.nodes().forEach(function(node) {
var label = (node.data('label') || '').toLowerCase();
var typeLabel = (node.data('type_label') || '').toLowerCase();
if (label.indexOf(query) !== -1 || typeLabel.indexOf(query) !== -1) {
node.removeClass('search-dimmed').addClass('search-highlight');
} else {
node.removeClass('search-highlight').addClass('search-dimmed');
}
});
});
},
/**
* Graph-Events binden
*/
@ -539,6 +685,20 @@
self.hideTooltip();
});
// Rechtsklick → Kontextmenü
this.cy.on('cxttap', 'node', function(evt) {
evt.originalEvent.preventDefault();
self.hideTooltip();
self.showContextMenu(evt.target, evt.renderedPosition);
});
// Klick auf Canvas schließt Kontextmenü
this.cy.on('tap', function(evt) {
if (evt.target === self.cy) {
self.hideContextMenu();
}
});
// Drag: Begrenzung auf sichtbaren Bereich
this.cy.on('drag', 'node', function(evt) {
var node = evt.target;
@ -553,8 +713,9 @@
}
});
// Drag: Snap-to-Grid + Position merken (inkl. Kinder bei Compound-Nodes)
// Drag: Snap-to-Grid + Position merken (nur im Bearbeitungsmodus)
this.cy.on('dragfree', 'node', function(evt) {
if (!self.editMode) return;
var node = evt.target;
var pos = node.position();
var grid = self.gridSize;
@ -584,11 +745,6 @@
});
}
// Debounced speichern (500ms nach letztem Drag)
if (self._saveTimer) clearTimeout(self._saveTimer);
self._saveTimer = setTimeout(function() {
self.savePositions();
}, 500);
});
// Viewport-Änderungen speichern (Pan + Zoom)
@ -656,6 +812,106 @@
e.preventDefault();
self.resetLayout();
});
// Bearbeitungsmodus: Anordnen ein/aus
$(document).on('click', '#btn-graph-edit-mode', function(e) {
e.preventDefault();
self.enterEditMode();
});
// Bearbeitungsmodus: Speichern
$(document).on('click', '#btn-graph-save-positions', function(e) {
e.preventDefault();
self.savePositions();
self.exitEditMode();
});
// Bearbeitungsmodus: Abbrechen
$(document).on('click', '#btn-graph-cancel-edit', function(e) {
e.preventDefault();
self.cancelEditMode();
});
// PNG-Export
$(document).on('click', '#btn-graph-export-png', function(e) {
e.preventDefault();
if (!self.cy) return;
var png64 = self.cy.png({
output: 'base64uri',
bg: getComputedStyle(document.documentElement).getPropertyValue('--colorbackbody').trim() || '#1d1e20',
full: true,
scale: 2
});
var link = document.createElement('a');
link.download = 'graph_export.png';
link.href = png64;
link.click();
});
},
/**
* Bearbeitungsmodus aktivieren - Nodes werden verschiebbar
*/
enterEditMode: function() {
if (!this.cy || this.editMode) return;
this.editMode = true;
this._dirtyNodes = {};
// Aktuelle Positionen sichern (für Abbrechen)
this._savedPositions = {};
var self = this;
this.cy.nodes().forEach(function(node) {
var pos = node.position();
self._savedPositions[node.data('id')] = { x: pos.x, y: pos.y };
});
// Nodes verschiebbar machen
this.cy.autoungrabify(false);
// Buttons umschalten
$('#btn-graph-edit-mode').hide();
$('#btn-graph-save-positions, #btn-graph-cancel-edit').show();
// Visuelles Feedback: Container-Rahmen
$('#' + this.containerId).addClass('graph-edit-mode');
},
/**
* Bearbeitungsmodus beenden (nach Speichern)
*/
exitEditMode: function() {
if (!this.cy) return;
this.editMode = false;
this._savedPositions = {};
// Nodes wieder sperren
this.cy.autoungrabify(true);
// Buttons zurückschalten
$('#btn-graph-edit-mode').show();
$('#btn-graph-save-positions, #btn-graph-cancel-edit').hide();
// Visuelles Feedback entfernen
$('#' + this.containerId).removeClass('graph-edit-mode');
},
/**
* Bearbeitungsmodus abbrechen - Positionen zurücksetzen
*/
cancelEditMode: function() {
if (!this.cy) return;
// Positionen zurücksetzen
var self = this;
this.cy.nodes().forEach(function(node) {
var saved = self._savedPositions[node.data('id')];
if (saved) {
node.position({ x: saved.x, y: saved.y });
}
});
this._dirtyNodes = {};
this.exitEditMode();
},
/**
@ -755,7 +1011,7 @@
var f = data.fields[i];
if (!f.value || f.value === '') continue;
var val = f.value;
if (val === '1') val = '\u2713';
if (f.type === 'checkbox' && (val === '1' || val === 'true')) val = '\u2713';
html += '<div class="tooltip-field">';
html += '<span class="tooltip-field-label">' + this.escapeHtml(f.label) + '</span>';
if (f.display === 'badge') {
@ -839,6 +1095,10 @@
KundenKarte.Graph.ajaxUrl = container.getAttribute('data-ajax-url') || '';
KundenKarte.Graph.saveUrl = container.getAttribute('data-save-url') || '';
KundenKarte.Graph.moduleUrl = container.getAttribute('data-module-url') || '';
KundenKarte.Graph.pageUrl = container.getAttribute('data-page-url') || '';
KundenKarte.Graph.systemId = parseInt(container.getAttribute('data-systemid')) || 0;
KundenKarte.Graph.canEdit = container.getAttribute('data-can-edit') === '1';
KundenKarte.Graph.canDelete = container.getAttribute('data-can-delete') === '1';
KundenKarte.Graph.init({
socId: parseInt(container.getAttribute('data-socid')) || 0,
contactId: parseInt(container.getAttribute('data-contactid')) || 0,

View file

@ -539,3 +539,9 @@ NoConnections = Keine Verbindungen vorhanden
ConnectionCreated = Verbindung erstellt
ConnectionUpdated = Verbindung aktualisiert
ConnectionDeleted = Verbindung geloescht
# Graph-Ansicht
TreeView = Baumansicht
GraphView = Graph-Ansicht
GraphLoading = Graph wird geladen...
SearchPlaceholder = Suchen...

View file

@ -287,3 +287,9 @@ BuildingLevelWing = Wing
BuildingLevelCorridor = Corridor / Hallway
BuildingLevelRoom = Room
BuildingLevelArea = Area / Zone
# Graph View
TreeView = Tree View
GraphView = Graph View
GraphLoading = Loading graph...
SearchPlaceholder = Search...

View file

@ -366,8 +366,12 @@ if ($action == 'togglepin' && $permissiontoadd) {
$title = $langs->trans('TechnicalInstallations').' - '.$object->name;
// Ansichtsmodus (Admin-Setting)
$viewMode = getDolGlobalString('KUNDENKARTE_DEFAULT_VIEW', 'tree');
// Ansichtsmodus: URL-Parameter hat Vorrang, sonst Admin-Setting
$defaultView = getDolGlobalString('KUNDENKARTE_DEFAULT_VIEW', 'tree');
$viewMode = GETPOST('view', 'aZ09');
if (!in_array($viewMode, array('tree', 'graph'))) {
$viewMode = $defaultView;
}
$jsFiles = array('/kundenkarte/js/kundenkarte.js?v='.time());
$cssFiles = array('/kundenkarte/css/kundenkarte.css?v='.time());
@ -459,36 +463,27 @@ if ($permissiontoadd) {
// Get systems not yet enabled for this customer
$availableSystems = array_diff_key($allSystems, $customerSystems);
if (!empty($availableSystems)) {
print '<button type="button" class="button small" style="margin-left:auto;" onclick="document.getElementById(\'add-system-form\').style.display=\'block\';">';
print '<button type="button" class="button small kundenkarte-add-system-btn" onclick="document.getElementById(\'add-system-form\').style.display=\'block\';">';
print '<i class="fa fa-plus"></i> '.$langs->trans('AddSystem');
print '</button>';
}
}
print '</div>';
// Expand/Collapse buttons (only in tree view, not in create/edit/view/copy)
// Steuerungs-Buttons (nur wenn kein Formular aktiv)
$isTreeView = !in_array($action, array('create', 'edit', 'view', 'copy'));
if ($isTreeView) {
if ($viewMode === 'graph') {
// Graph Controls: Aktionen + Zoom
print '<div class="kundenkarte-graph-toolbar">';
if ($user->hasRight('kundenkarte', 'write')) {
print '<div class="kundenkarte-graph-actions">';
print '<a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&action=create&system='.$systemId.'"><i class="fa fa-plus"></i> '.$langs->trans('AddElement').'</a>';
print '<a class="button small" href="'.dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?socid='.$id.'&system_id='.$systemId.'&action=create"><i class="fa fa-plug"></i> '.$langs->trans('AddConnection').'</a>';
print '</div>';
}
print '<div class="kundenkarte-graph-zoom-controls">';
print '<button type="button" class="button small" id="btn-graph-reset-layout" title="Layout zurücksetzen"><i class="fa fa-undo"></i></button>';
print '<button type="button" class="button small" id="btn-graph-wheel-zoom" title="Mausrad-Zoom ein"><i class="fa fa-mouse-pointer"></i></button>';
print '<button type="button" class="button small" id="btn-graph-zoom-in" title="Zoom +"><i class="fa fa-search-plus"></i></button>';
print '<button type="button" class="button small" id="btn-graph-zoom-out" title="Zoom -"><i class="fa fa-search-minus"></i></button>';
print '<button type="button" class="button small" id="btn-graph-fit" title="Standard-Zoom"><i class="fa fa-crosshairs"></i></button>';
print '</div>';
print '</div>';
} else {
$toggleView = ($viewMode === 'graph') ? 'tree' : 'graph';
$toggleUrl = $_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&view='.$toggleView;
$toggleIcon = ($viewMode === 'graph') ? 'fa-list' : 'fa-sitemap';
$toggleLabel = ($viewMode === 'graph') ? $langs->trans('TreeView') : $langs->trans('GraphView');
if ($viewMode !== 'graph') {
// Baumansicht: Controls auf gleicher Zeile wie System-Tabs (im Wrapper)
print '<div class="kundenkarte-tree-controls">';
// Compact mode toggle (visible on mobile)
print '<a class="button small" href="'.$toggleUrl.'" title="'.$toggleLabel.'">';
print '<i class="fa '.$toggleIcon.'"></i> '.$toggleLabel;
print '</a>';
print '<button type="button" class="kundenkarte-view-toggle" id="btn-compact-mode" title="Kompakte Ansicht">';
print '<i class="fa fa-compress"></i> <span>Kompakt</span>';
print '</button>';
@ -510,6 +505,35 @@ if ($isTreeView) {
print '</div>'; // End kundenkarte-system-tabs-wrapper
// Graph-Toolbar: UNTER der System-Tab-Borderlinie
if ($isTreeView && $viewMode === 'graph') {
print '<div class="kundenkarte-graph-toolbar">';
// Zeile 1: Ansicht-Wechsel + Aktionen
print '<div class="kundenkarte-graph-toolbar-row">';
print '<a class="button small" href="'.$toggleUrl.'" title="'.$toggleLabel.'"><i class="fa '.$toggleIcon.'"></i> '.$toggleLabel.'</a>';
if ($user->hasRight('kundenkarte', 'write')) {
print '<a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&action=create&system='.$systemId.'"><i class="fa fa-plus"></i> '.$langs->trans('AddElement').'</a>';
print '<a class="button small" href="'.dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?socid='.$id.'&system_id='.$systemId.'&action=create"><i class="fa fa-plug"></i> '.$langs->trans('AddConnection').'</a>';
}
print '</div>';
// Zeile 2: Graph-Steuerung (Anordnen rechts)
print '<div class="kundenkarte-graph-toolbar-row">';
print '<button type="button" class="button small" id="btn-graph-reset-layout" title="Layout zurücksetzen"><i class="fa fa-refresh"></i> Layout</button>';
print '<button type="button" class="button small" id="btn-graph-wheel-zoom" title="Mausrad-Zoom"><i class="fa fa-arrows"></i> Scroll</button>';
print '<button type="button" class="button small" id="btn-graph-zoom-in" title="Vergrößern"><i class="fa fa-search-plus"></i></button>';
print '<button type="button" class="button small" id="btn-graph-zoom-out" title="Verkleinern"><i class="fa fa-search-minus"></i></button>';
print '<button type="button" class="button small" id="btn-graph-fit" title="Einpassen"><i class="fa fa-crosshairs"></i> Fit</button>';
print '<button type="button" class="button small" id="btn-graph-export-png" title="PNG exportieren"><i class="fa fa-download"></i> PNG</button>';
if ($permissiontoadd) {
print '<span class="kundenkarte-graph-toolbar-spacer"></span>';
print '<button type="button" class="button small" id="btn-graph-edit-mode" title="Elemente anordnen"><i class="fa fa-hand-paper-o"></i> Anordnen</button>';
print '<button type="button" class="button small btn-graph-save" id="btn-graph-save-positions" title="Positionen speichern" style="display:none;"><i class="fa fa-check"></i> Speichern</button>';
print '<button type="button" class="button small btn-graph-cancel" id="btn-graph-cancel-edit" title="Abbrechen" style="display:none;"><i class="fa fa-times"></i> Abbrechen</button>';
}
print '</div>';
print '</div>';
}
// Add system form (hidden by default)
if ($permissiontoadd && !empty($availableSystems)) {
print '<div id="add-system-form" class="kundenkarte-add-system-form" style="display:none;margin-bottom:15px;">';
@ -1043,15 +1067,35 @@ if (empty($customerSystems)) {
$graphModuleUrl = dol_buildpath('/kundenkarte', 1);
print '<div class="kundenkarte-graph-wrapper">';
print '<div class="kundenkarte-graph-search-floating">';
print '<input type="text" id="kundenkarte-graph-search" placeholder="'.$langs->trans('SearchPlaceholder').'" autocomplete="off">';
print '</div>';
print '<div id="kundenkarte-graph-container"';
print ' data-ajax-url="'.dol_escape_htmltag($graphAjaxUrl).'"';
print ' data-save-url="'.dol_escape_htmltag($graphSaveUrl).'"';
print ' data-module-url="'.dol_escape_htmltag($graphModuleUrl).'"';
print ' data-socid="'.$id.'"';
print ' data-systemid="'.$systemId.'"';
print ' data-can-edit="'.($permissiontoadd ? '1' : '0').'"';
print ' data-can-delete="'.($permissiontodelete ? '1' : '0').'"';
print ' data-page-url="'.dol_escape_htmltag($_SERVER['PHP_SELF']).'"';
print '>';
print '<div class="kundenkarte-graph-loading"><i class="fa fa-spinner fa-spin"></i> '.$langs->trans('GraphLoading').'</div>';
print '</div>';
// Kontextmenü (Rechtsklick auf Node)
print '<div id="kundenkarte-graph-contextmenu" class="kundenkarte-graph-contextmenu" style="display:none;">';
print '<a class="ctx-item ctx-view" data-action="view"><i class="fa fa-eye"></i> '.$langs->trans('View').'</a>';
if ($permissiontoadd) {
print '<a class="ctx-item ctx-add-child" data-action="add-child"><i class="fa fa-plus"></i> '.$langs->trans('AddChild').'</a>';
print '<a class="ctx-item ctx-edit" data-action="edit"><i class="fa fa-edit"></i> '.$langs->trans('Edit').'</a>';
print '<a class="ctx-item ctx-copy" data-action="copy"><i class="fa fa-copy"></i> '.$langs->trans('Copy').'</a>';
}
if ($permissiontodelete) {
print '<a class="ctx-item ctx-delete" data-action="delete"><i class="fa fa-trash"></i> '.$langs->trans('Delete').'</a>';
}
print '</div>';
// Legende - wird dynamisch vom JS befüllt (Kabeltypen mit Farben)
print '<div id="kundenkarte-graph-legend" class="kundenkarte-graph-legend"></div>';
print '</div>';