diff --git a/ChangeLog.md b/ChangeLog.md index 549167c..1afd3d0 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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) diff --git a/ajax/graph_data.php b/ajax/graph_data.php index 3b05ef7..6e83b7b 100644 --- a/ajax/graph_data.php +++ b/ajax/graph_data.php @@ -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; } diff --git a/css/kundenkarte.css b/css/kundenkarte.css index 9481ebb..71ce04f 100755 --- a/css/kundenkarte.css +++ b/css/kundenkarte.css @@ -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; } /* ======================================== diff --git a/css/kundenkarte_cytoscape.css b/css/kundenkarte_cytoscape.css index 252a822..3c1f6e9 100644 --- a/css/kundenkarte_cytoscape.css +++ b/css/kundenkarte_cytoscape.css @@ -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; } } diff --git a/js/kundenkarte_cytoscape.js b/js/kundenkarte_cytoscape.js index e96c2dd..f5c4ef9 100644 --- a/js/kundenkarte_cytoscape.js +++ b/js/kundenkarte_cytoscape.js @@ -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 += '