kundenkarte/js/kundenkarte.js
data 71272fa425 fix(schematic): Terminal-Farbpropagierung, Auto-Naming, PWA-Abgänge
- buildTerminalPhaseMap: Schritt 1b - Leitungen mit expliziter Farbe als
  Startpunkte (nur Gerät→Gerät, keine Abgänge)
- buildTerminalPhaseMap: Block-Durchreichung (Top↔Bottom) entfernt
- buildTerminalPhaseMap: Junction-Verbindungen (Terminal→Leitung)
  bidirektional verarbeitet via _connectionById Index
- PWA: Abgangs-Rendering mit Index-Fallback wenn source_terminal_id fehlt
- PWA: Abgangs-Labels max-height 130px, min-height 30px
- Auto-Naming: EquipmentCarrier create/update → 'R' + count
- Auto-Naming: EquipmentPanel update → 'Feld ' + count
- pwa_api.php: Hardcoded Fallbacks 'Feld'/'Hutschiene' entfernt
- pwa.js: Hutschiene Auto-Naming dynamisch aus Panel-Carrier-Anzahl
- kundenkarte.js: Carrier-Dialog Placeholder 'z.B. R1 (automatisch)'
- SW Cache auf v12.5 hochgezählt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 09:57:58 +01:00

15637 lines
734 KiB
JavaScript
Executable file

/**
* KundenKarte Module JavaScript
* Copyright (C) 2026 Alles Watt lauft
*/
(function() {
'use strict';
// Namespace
window.KundenKarte = window.KundenKarte || {};
// ===========================================
// Global Dialog Functions (replacing browser dialogs)
// ===========================================
// Escape HTML helper
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Global alert dialog
KundenKarte.showAlert = function(title, message, onClose) {
$('#kundenkarte-alert-dialog').remove();
var html = '<div id="kundenkarte-alert-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + escapeHtml(title) + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
html += '<p style="margin:0;font-size:14px;">' + escapeHtml(message) + '</p>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;justify-content:flex-end;">';
html += '<button type="button" class="button" id="alert-ok"><i class="fa fa-check"></i> OK</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-alert-dialog').addClass('visible');
$('#alert-ok').focus();
var closeDialog = function() {
$('#kundenkarte-alert-dialog').remove();
$(document).off('keydown.alertDialog');
if (typeof onClose === 'function') onClose();
};
$('#alert-ok, #kundenkarte-alert-dialog .kundenkarte-modal-close').on('click', closeDialog);
$(document).on('keydown.alertDialog', function(e) {
if (e.key === 'Escape' || e.key === 'Enter') closeDialog();
});
};
// Global confirm dialog
KundenKarte.showConfirm = function(title, message, onConfirm, onCancel) {
$('#kundenkarte-confirm-dialog').remove();
var html = '<div id="kundenkarte-confirm-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + escapeHtml(title) + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
html += '<p style="margin:0;font-size:14px;">' + escapeHtml(message) + '</p>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="button" id="confirm-yes" style="background:#c0392b;color:#fff;"><i class="fa fa-check"></i> Ja</button>';
html += '<button type="button" class="button" id="confirm-no"><i class="fa fa-times"></i> Nein</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-confirm-dialog').addClass('visible');
$('#confirm-yes').focus();
$('#confirm-yes').on('click', function() {
$('#kundenkarte-confirm-dialog').remove();
$(document).off('keydown.confirmDialog');
if (typeof onConfirm === 'function') onConfirm();
});
$('#confirm-no, #kundenkarte-confirm-dialog .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-confirm-dialog').remove();
$(document).off('keydown.confirmDialog');
if (typeof onCancel === 'function') onCancel();
});
$(document).on('keydown.confirmDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-confirm-dialog').remove();
$(document).off('keydown.confirmDialog');
if (typeof onCancel === 'function') onCancel();
} else if (e.key === 'Enter') {
$('#kundenkarte-confirm-dialog').remove();
$(document).off('keydown.confirmDialog');
if (typeof onConfirm === 'function') onConfirm();
}
});
};
// Dialog zum Ausbauen mit Datumsauswahl
KundenKarte.showDecommissionDialog = function(anlageId, onSuccess) {
$('#kundenkarte-decommission-dialog').remove();
var today = new Date().toISOString().split('T')[0];
var html = '<div id="kundenkarte-decommission-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3><i class="fa fa-power-off"></i> Element ausbauen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
html += '<p style="margin:0 0 15px 0;font-size:14px;">Element als ausgebaut markieren?</p>';
html += '<label style="display:block;margin-bottom:5px;font-size:13px;color:#aaa;">Ausbau-Datum:</label>';
html += '<input type="date" id="decommission-date" value="' + today + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#2a2a3a;color:#ddd;font-size:14px;">';
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="button" id="decommission-confirm" style="background:#8b4513;color:#fff;"><i class="fa fa-power-off"></i> Ausbauen</button>';
html += '<button type="button" class="button" id="decommission-cancel"><i class="fa fa-times"></i> Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-decommission-dialog').addClass('visible');
$('#decommission-date').focus();
$('#decommission-confirm').on('click', function() {
var dateVal = $('#decommission-date').val();
$('#kundenkarte-decommission-dialog').remove();
$(document).off('keydown.decommDialog');
$.post(baseUrl + '/custom/kundenkarte/ajax/anlage.php', {
action: 'toggle_decommissioned',
anlage_id: anlageId,
date_decommissioned: dateVal,
token: $('input[name="token"]').val() || ''
}, function(res) {
if (res.success) {
if (typeof onSuccess === 'function') {
onSuccess(res);
} else {
location.reload();
}
} else {
alert(res.error || 'Fehler');
}
}, 'json');
});
$('#decommission-cancel, #kundenkarte-decommission-dialog .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-decommission-dialog').remove();
$(document).off('keydown.decommDialog');
});
$(document).on('keydown.decommDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-decommission-dialog').remove();
$(document).off('keydown.decommDialog');
} else if (e.key === 'Enter') {
$('#decommission-confirm').click();
}
});
};
// Global error display with details
KundenKarte.showError = function(title, message, details) {
$('#kundenkarte-error-dialog').remove();
var html = '<div id="kundenkarte-error-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header" style="background:#c0392b;"><h3 style="color:#fff;"><i class="fa fa-exclamation-triangle"></i> ' + escapeHtml(title || 'Fehler') + '</h3>';
html += '<span class="kundenkarte-modal-close" style="color:#fff;">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
html += '<p style="margin:0 0 10px 0;font-size:14px;">' + escapeHtml(message || 'Ein unbekannter Fehler ist aufgetreten.') + '</p>';
if (details) {
html += '<details style="margin-top:10px;"><summary style="cursor:pointer;color:#888;font-size:12px;">Technische Details</summary>';
html += '<pre style="background:#f5f5f5;padding:10px;margin-top:5px;font-size:11px;overflow:auto;max-height:150px;border-radius:4px;">' + escapeHtml(details) + '</pre>';
html += '</details>';
}
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;justify-content:flex-end;">';
html += '<button type="button" class="button" id="error-ok"><i class="fa fa-check"></i> OK</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-error-dialog').addClass('visible');
$('#error-ok').focus();
var closeDialog = function() {
$('#kundenkarte-error-dialog').remove();
$(document).off('keydown.errorDialog');
};
$('#error-ok, #kundenkarte-error-dialog .kundenkarte-modal-close').on('click', closeDialog);
$(document).on('keydown.errorDialog', function(e) {
if (e.key === 'Escape' || e.key === 'Enter') closeDialog();
});
};
// Global success/info notification (non-blocking)
KundenKarte.showNotification = function(message, type) {
type = type || 'success';
var bgColor = type === 'success' ? '#27ae60' : (type === 'warning' ? '#f39c12' : (type === 'error' ? '#e74c3c' : '#3498db'));
var icon = type === 'success' ? 'fa-check' : (type === 'warning' ? 'fa-exclamation' : (type === 'error' ? 'fa-times' : 'fa-info'));
var $note = $('<div class="kundenkarte-notification" style="position:fixed;top:20px;right:20px;background:' + bgColor + ';color:#fff;padding:12px 20px;border-radius:6px;z-index:100003;box-shadow:0 4px 12px rgba(0,0,0,0.3);max-width:400px;"><i class="fa ' + icon + '" style="margin-right:8px;"></i>' + escapeHtml(message) + '</div>');
$('body').append($note);
setTimeout(function() {
$note.fadeOut(300, function() { $(this).remove(); });
}, 3000);
};
// Get base URL for AJAX calls
var baseUrl = (typeof DOL_URL_ROOT !== 'undefined') ? DOL_URL_ROOT : '';
if (!baseUrl) {
// Try to detect from script src
var scripts = document.getElementsByTagName('script');
for (var i = 0; i < scripts.length; i++) {
var src = scripts[i].src;
if (src && src.indexOf('/kundenkarte/js/kundenkarte.js') > -1) {
baseUrl = src.replace('/custom/kundenkarte/js/kundenkarte.js', '').replace(/\?.*$/, '');
break;
}
}
}
/**
* Tree Component
*/
KundenKarte.Tree = {
tooltipTimeout: null,
hideTimeout: null,
currentTooltip: null,
currentItem: null,
draggedNode: null,
isDragging: false,
dropTarget: null,
init: function() {
this.bindEvents();
this.initDragDrop();
this.initCompactMode();
},
bindEvents: function() {
var self = this;
// Toggle tree nodes - MUST use stopImmediatePropagation for delegated handlers on same element
$(document).on('click', '.kundenkarte-tree-toggle', function(e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
var $toggle = $(this);
var $node = $toggle.closest('.kundenkarte-tree-node');
var $children = $node.children('.kundenkarte-tree-children');
$toggle.toggleClass('collapsed');
$children.toggleClass('collapsed');
});
// Expand all nodes
$(document).on('click', '#btn-expand-all', function(e) {
e.preventDefault();
self.expandAll();
});
// Collapse all nodes
$(document).on('click', '#btn-collapse-all', function(e) {
e.preventDefault();
self.collapseAll();
});
// Compact mode toggle
$(document).on('click', '#btn-compact-mode', function(e) {
e.preventDefault();
self.toggleCompactMode();
});
// Ausgebaute Elemente ein-/ausblenden
$(document).on('click', '#btn-toggle-decommissioned', function(e) {
e.preventDefault();
var $btn = $(this);
$btn.toggleClass('active');
var show = $btn.hasClass('active');
$btn.find('i').toggleClass('fa-eye-slash', !show).toggleClass('fa-eye', show);
$('.kundenkarte-tree').toggleClass('show-decommissioned', show);
});
// Ausbauen/Einbauen per Klick
$(document).on('click', '.btn-toggle-decommissioned', function(e) {
e.preventDefault();
e.stopPropagation();
var $btn = $(this);
var anlageId = $btn.data('anlage-id');
if (!anlageId) return;
// Prüfen ob Element bereits ausgebaut ist
var $row = $btn.closest('.decommissioned');
if ($row.length) {
// Wieder einbauen - einfache Bestätigung
KundenKarte.showConfirm('Wieder einbauen', 'Element wieder als eingebaut markieren?', function() {
$.post(baseUrl + '/custom/kundenkarte/ajax/anlage.php', {
action: 'toggle_decommissioned',
anlage_id: anlageId,
token: $('input[name="token"]').val() || ''
}, function(res) {
if (res.success) {
location.reload();
} else {
alert(res.error || 'Fehler');
}
}, 'json');
});
} else {
// Ausbauen - Dialog mit Datumsfeld
KundenKarte.showDecommissionDialog(anlageId);
}
});
// In compact mode, click on item to expand/show details
$(document).on('click', '.kundenkarte-tree.compact-mode .kundenkarte-tree-item', function(e) {
// Don't trigger on action buttons or toggle
if ($(e.target).closest('.kundenkarte-tree-actions, .kundenkarte-tree-toggle, .kundenkarte-tree-files').length) {
return;
}
$(this).toggleClass('expanded');
});
// Hover tooltip on ICON only - show after delay
$(document).on('mouseenter', '.kundenkarte-tooltip-trigger', function(e) {
var $trigger = $(this);
var anlageId = $trigger.data('anlage-id');
if (!anlageId) return;
// Cancel any pending hide
clearTimeout(self.hideTimeout);
self.hideTimeout = null;
self.currentItem = $trigger;
self.tooltipTimeout = setTimeout(function() {
self.showTooltip($trigger, anlageId);
}, 300);
});
// Hide tooltip when leaving icon
$(document).on('mouseleave', '.kundenkarte-tooltip-trigger', function() {
clearTimeout(self.tooltipTimeout);
self.tooltipTimeout = null;
self.currentItem = null;
// Hide after short delay (allows moving to tooltip)
self.hideTimeout = setTimeout(function() {
self.hideTooltip();
}, 100);
});
// Images tooltip on hover
$(document).on('mouseenter', '.kundenkarte-images-trigger', function(e) {
var $trigger = $(this);
var anlageId = $trigger.data('anlage-id');
if (!anlageId) return;
clearTimeout(self.hideTimeout);
self.hideTimeout = null;
self.tooltipTimeout = setTimeout(function() {
self.showImagesPopup($trigger, anlageId);
}, 300);
});
$(document).on('mouseleave', '.kundenkarte-images-trigger', function() {
clearTimeout(self.tooltipTimeout);
self.tooltipTimeout = null;
self.hideTimeout = setTimeout(function() {
self.hideTooltip();
}, 100);
});
// Documents tooltip on hover
$(document).on('mouseenter', '.kundenkarte-docs-trigger', function(e) {
var $trigger = $(this);
var anlageId = $trigger.data('anlage-id');
if (!anlageId) return;
clearTimeout(self.hideTimeout);
self.hideTimeout = null;
self.tooltipTimeout = setTimeout(function() {
self.showDocsPopup($trigger, anlageId);
}, 300);
});
$(document).on('mouseleave', '.kundenkarte-docs-trigger', function() {
clearTimeout(self.tooltipTimeout);
self.tooltipTimeout = null;
self.hideTimeout = setTimeout(function() {
self.hideTooltip();
}, 100);
});
// File badge tooltip on hover (combined images + documents)
$(document).on('mouseenter', '.kundenkarte-tree-file-badge', function(e) {
var $trigger = $(this);
var anlageId = $trigger.data('anlage-id');
if (!anlageId) return;
clearTimeout(self.hideTimeout);
self.hideTimeout = null;
self.tooltipTimeout = setTimeout(function() {
self.showFilePreview($trigger, anlageId);
}, 300);
});
$(document).on('mouseleave', '.kundenkarte-tree-file-badge', function() {
clearTimeout(self.tooltipTimeout);
self.tooltipTimeout = null;
self.hideTimeout = setTimeout(function() {
self.hideTooltip();
}, 100);
});
// Keep tooltip visible when hovering over it
$(document).on('mouseenter', '#kundenkarte-tooltip', function() {
clearTimeout(self.hideTimeout);
self.hideTimeout = null;
});
// Hide when leaving tooltip
$(document).on('mouseleave', '#kundenkarte-tooltip', function() {
self.hideTooltip();
});
// Select item
$(document).on('click', '.kundenkarte-tree-item', function(e) {
if ($(e.target).closest('.kundenkarte-tree-toggle, .kundenkarte-tree-actions, .kundenkarte-tree-files').length) {
return;
}
$('.kundenkarte-tree-item').removeClass('selected');
$(this).addClass('selected');
var anlageId = $(this).data('anlage-id');
if (anlageId) {
$(document).trigger('kundenkarte:element:selected', [anlageId]);
}
});
},
showTooltip: function($item, anlageId) {
var self = this;
// Get tooltip data from data attribute (faster than AJAX)
var tooltipDataStr = $item.attr('data-tooltip');
if (!tooltipDataStr) {
// No tooltip data available - silently return
return;
}
var data;
try {
data = JSON.parse(tooltipDataStr);
} catch(e) {
console.error('Failed to parse tooltip JSON:', e, tooltipDataStr);
return;
}
var html = self.buildTooltipHtml(data);
var $tooltip = $('#kundenkarte-tooltip');
if (!$tooltip.length) {
$tooltip = $('<div id="kundenkarte-tooltip" class="kundenkarte-tooltip"></div>');
$('body').append($tooltip);
}
$tooltip.html(html);
// Position tooltip
var offset = $item.offset();
var itemWidth = $item.outerWidth();
var windowWidth = $(window).width();
var scrollTop = $(window).scrollTop();
// First show to calculate width
$tooltip.css({ visibility: 'hidden', display: 'block' });
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
$tooltip.css({ visibility: '', display: '' });
var left = offset.left + itemWidth + 10;
if (left + tooltipWidth > windowWidth - 20) {
left = offset.left - tooltipWidth - 10;
}
if (left < 10) {
left = 10;
}
var top = offset.top;
// Prevent tooltip from going below viewport
if (top + tooltipHeight > scrollTop + $(window).height() - 20) {
top = scrollTop + $(window).height() - tooltipHeight - 20;
}
$tooltip.css({
top: top,
left: left
}).addClass('visible').show();
self.currentTooltip = $tooltip;
},
hideTooltip: function() {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
var $tooltip = $('#kundenkarte-tooltip');
if ($tooltip.length) {
$tooltip.removeClass('visible').hide();
}
this.currentTooltip = null;
},
showImagesPopup: function($trigger, anlageId) {
var self = this;
// Load images via AJAX - use absolute path
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/anlage_images.php',
data: { anlage_id: anlageId },
dataType: 'json',
success: function(response) {
if (!response.images || response.images.length === 0) {
return;
}
var html = '<div class="kundenkarte-images-popup">';
html += '<div class="kundenkarte-images-grid">';
for (var i = 0; i < response.images.length; i++) {
var img = response.images[i];
html += '<a href="' + img.url + '" target="_blank" class="kundenkarte-images-thumb">';
html += '<img src="' + img.thumb + '" alt="' + self.escapeHtml(img.name) + '">';
html += '</a>';
}
html += '</div>';
html += '</div>';
var $tooltip = $('#kundenkarte-tooltip');
if (!$tooltip.length) {
$tooltip = $('<div id="kundenkarte-tooltip" class="kundenkarte-tooltip"></div>');
$('body').append($tooltip);
}
$tooltip.html(html);
// Position tooltip
var offset = $trigger.offset();
var windowWidth = $(window).width();
var scrollTop = $(window).scrollTop();
$tooltip.css({ visibility: 'hidden', display: 'block' });
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
$tooltip.css({ visibility: '', display: '' });
var left = offset.left + $trigger.outerWidth() + 10;
if (left + tooltipWidth > windowWidth - 20) {
left = offset.left - tooltipWidth - 10;
}
if (left < 10) left = 10;
var top = offset.top;
if (top + tooltipHeight > scrollTop + $(window).height() - 20) {
top = scrollTop + $(window).height() - tooltipHeight - 20;
}
$tooltip.css({ top: top, left: left }).addClass('visible').show();
self.currentTooltip = $tooltip;
}
});
},
showDocsPopup: function($trigger, anlageId) {
var self = this;
// Load documents via AJAX
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/anlage_docs.php',
data: { anlage_id: anlageId },
dataType: 'json',
success: function(response) {
if (!response.docs || response.docs.length === 0) {
return;
}
// Visual document cards with icons
var html = '<div class="kundenkarte-docs-popup">';
html += '<div class="kundenkarte-docs-grid">';
for (var i = 0; i < response.docs.length; i++) {
var doc = response.docs[i];
var iconClass = doc.type === 'pdf' ? 'fa-file-pdf-o' : 'fa-file-text-o';
var iconColor = doc.type === 'pdf' ? '#e74c3c' : '#f39c12';
html += '<a href="' + doc.url + '" target="_blank" class="kundenkarte-docs-card">';
html += '<div class="kundenkarte-docs-card-icon" style="color:' + iconColor + '">';
html += '<i class="fa ' + iconClass + '"></i>';
html += '</div>';
html += '<div class="kundenkarte-docs-card-name">' + self.escapeHtml(doc.name) + '</div>';
html += '</a>';
}
html += '</div>';
html += '</div>';
var $tooltip = $('#kundenkarte-tooltip');
if (!$tooltip.length) {
$tooltip = $('<div id="kundenkarte-tooltip" class="kundenkarte-tooltip"></div>');
$('body').append($tooltip);
}
$tooltip.html(html);
// Position tooltip
var offset = $trigger.offset();
var windowWidth = $(window).width();
var scrollTop = $(window).scrollTop();
$tooltip.css({ visibility: 'hidden', display: 'block' });
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
$tooltip.css({ visibility: '', display: '' });
var left = offset.left + $trigger.outerWidth() + 10;
if (left + tooltipWidth > windowWidth - 20) {
left = offset.left - tooltipWidth - 10;
}
if (left < 10) left = 10;
var top = offset.top;
if (top + tooltipHeight > scrollTop + $(window).height() - 20) {
top = scrollTop + $(window).height() - tooltipHeight - 20;
}
$tooltip.css({ top: top, left: left }).addClass('visible').show();
self.currentTooltip = $tooltip;
}
});
},
showFilePreview: function($trigger, anlageId) {
var self = this;
// Load all files via AJAX
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/file_preview.php',
data: { anlage_id: anlageId },
dataType: 'json',
success: function(response) {
if ((!response.images || response.images.length === 0) &&
(!response.documents || response.documents.length === 0)) {
return;
}
var html = '<div class="kundenkarte-file-preview">';
// Images section with thumbnails
if (response.images && response.images.length > 0) {
html += '<div class="kundenkarte-file-preview-section">';
html += '<div class="kundenkarte-file-preview-title"><i class="fa fa-image"></i> Bilder (' + response.images.length + ')</div>';
html += '<div class="kundenkarte-file-preview-thumbs">';
for (var i = 0; i < response.images.length && i < 6; i++) {
var img = response.images[i];
var pinnedClass = img.is_pinned ? ' is-pinned' : '';
var coverClass = img.is_cover ? ' is-cover' : '';
html += '<a href="' + img.url + '" target="_blank" class="kundenkarte-file-preview-thumb' + pinnedClass + coverClass + '">';
html += '<img src="' + img.thumb_url + '" alt="' + self.escapeHtml(img.name) + '">';
if (img.is_pinned) {
html += '<span class="kundenkarte-file-preview-pin"><i class="fa fa-thumb-tack"></i></span>';
}
html += '</a>';
}
if (response.images.length > 6) {
html += '<span class="kundenkarte-file-preview-more">+' + (response.images.length - 6) + '</span>';
}
html += '</div>';
html += '</div>';
}
// Documents section with icons
if (response.documents && response.documents.length > 0) {
html += '<div class="kundenkarte-file-preview-section">';
html += '<div class="kundenkarte-file-preview-title"><i class="fa fa-file-text-o"></i> Dokumente (' + response.documents.length + ')</div>';
html += '<div class="kundenkarte-file-preview-docs">';
for (var j = 0; j < response.documents.length && j < 5; j++) {
var doc = response.documents[j];
var docPinnedClass = doc.is_pinned ? ' is-pinned' : '';
html += '<a href="' + doc.url + '" target="_blank" class="kundenkarte-file-preview-doc' + docPinnedClass + '">';
html += '<i class="fa ' + doc.icon + '" style="color:' + doc.color + '"></i>';
html += '<span class="kundenkarte-file-preview-doc-name">' + self.escapeHtml(doc.name) + '</span>';
if (doc.is_pinned) {
html += '<i class="fa fa-thumb-tack kundenkarte-file-preview-doc-pin"></i>';
}
html += '</a>';
}
if (response.documents.length > 5) {
html += '<div class="kundenkarte-file-preview-more-docs">+' + (response.documents.length - 5) + ' weitere</div>';
}
html += '</div>';
html += '</div>';
}
html += '</div>';
var $tooltip = $('#kundenkarte-tooltip');
if (!$tooltip.length) {
$tooltip = $('<div id="kundenkarte-tooltip" class="kundenkarte-tooltip"></div>');
$('body').append($tooltip);
}
$tooltip.html(html);
// Position tooltip
var offset = $trigger.offset();
var windowWidth = $(window).width();
var scrollTop = $(window).scrollTop();
$tooltip.css({ visibility: 'hidden', display: 'block' });
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
$tooltip.css({ visibility: '', display: '' });
var left = offset.left + $trigger.outerWidth() + 10;
if (left + tooltipWidth > windowWidth - 20) {
left = offset.left - tooltipWidth - 10;
}
if (left < 10) left = 10;
var top = offset.top;
if (top + tooltipHeight > scrollTop + $(window).height() - 20) {
top = scrollTop + $(window).height() - tooltipHeight - 20;
}
$tooltip.css({ top: top, left: left }).addClass('visible').show();
self.currentTooltip = $tooltip;
}
});
},
buildTooltipHtml: function(data) {
var html = '<div class="kundenkarte-tooltip-header">';
html += '<span class="kundenkarte-tooltip-icon"><i class="fa ' + (data.picto || 'fa-cube') + '"></i></span>';
html += '<div>';
html += '<div class="kundenkarte-tooltip-title">' + this.escapeHtml(data.label || '') + '</div>';
html += '<div class="kundenkarte-tooltip-type">' + this.escapeHtml(data.type || data.type_label || '') + '</div>';
html += '</div></div>';
html += '<div class="kundenkarte-tooltip-fields">';
// Dynamic fields only (from PHP data-tooltip attribute)
if (data.fields) {
for (var key in data.fields) {
if (data.fields.hasOwnProperty(key)) {
var field = data.fields[key];
// Handle header fields as section titles (must span both grid columns)
if (field.type === 'header') {
html += '<span class="kundenkarte-tooltip-field-header">' + this.escapeHtml(field.label) + '</span>';
} else if (field.value) {
html += '<span class="kundenkarte-tooltip-field-label">' + this.escapeHtml(field.label) + ':</span>';
html += '<span class="kundenkarte-tooltip-field-value">' + this.escapeHtml(field.value) + '</span>';
}
}
}
}
html += '</div>';
// Notes (note_html is already sanitized and formatted with <br> by PHP)
if (data.note_html) {
html += '<div class="kundenkarte-tooltip-note" style="margin-top:10px;padding-top:10px;border-top:1px solid #eee;font-size:0.9em;color:#666;">';
html += '<i class="fa fa-sticky-note"></i><br>' + data.note_html;
html += '</div>';
}
// Images (from AJAX)
if (data.images && data.images.length > 0) {
html += '<div class="kundenkarte-tooltip-images">';
for (var i = 0; i < Math.min(data.images.length, 4); i++) {
html += '<img src="' + data.images[i].thumb_url + '" class="kundenkarte-tooltip-thumb" alt="">';
}
html += '</div>';
}
return html;
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
escapeHtmlPreservingBreaks: function(text) {
if (!text) return '';
// Convert <br> to newlines first (for old data with <br> tags)
text = text.replace(/<br\s*\/?>/gi, '\n');
return this.escapeHtml(text);
},
refresh: function(socId, systemId) {
var $container = $('.kundenkarte-tree[data-system="' + systemId + '"]');
if (!$container.length) return;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/anlage_tree.php',
data: { socid: socId, system: systemId },
success: function(html) {
$container.html(html);
}
});
},
// 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');
},
collapseAll: function() {
$('.kundenkarte-tree-toggle').addClass('collapsed');
$('.kundenkarte-tree-children').addClass('collapsed');
},
toggleCompactMode: function() {
var $tree = $('.kundenkarte-tree');
var $btn = $('#btn-compact-mode');
$tree.toggleClass('compact-mode');
$btn.toggleClass('active');
if ($tree.hasClass('compact-mode')) {
$btn.find('span').text('Normal');
$btn.find('i').removeClass('fa-compress').addClass('fa-expand');
// Remove any expanded items
$('.kundenkarte-tree-item.expanded').removeClass('expanded');
// Store preference
localStorage.setItem('kundenkarte_compact_mode', '1');
} else {
$btn.find('span').text('Kompakt');
$btn.find('i').removeClass('fa-expand').addClass('fa-compress');
localStorage.removeItem('kundenkarte_compact_mode');
}
},
initCompactMode: function() {
// Check localStorage for saved preference
if (localStorage.getItem('kundenkarte_compact_mode') === '1') {
this.toggleCompactMode();
}
// Auto-enable on mobile
if (window.innerWidth <= 768 && !localStorage.getItem('kundenkarte_compact_mode_manual')) {
if (!$('.kundenkarte-tree').hasClass('compact-mode')) {
this.toggleCompactMode();
}
}
}
};
/**
* Favorite Products Component
*/
KundenKarte.Favorites = {
init: function() {
this.bindEvents();
},
bindEvents: function() {
// Select all checkbox
$(document).on('change', '#kundenkarte-select-all', function() {
var checked = $(this).prop('checked');
$('.kundenkarte-favorites-table input[type="checkbox"][name="selected_products[]"]').prop('checked', checked);
KundenKarte.Favorites.updateGenerateButton();
});
// Individual checkbox
$(document).on('change', '.kundenkarte-favorites-table input[type="checkbox"][name="selected_products[]"]', function() {
KundenKarte.Favorites.updateGenerateButton();
});
// Save button click
$(document).on('click', '.kundenkarte-qty-save', function(e) {
e.preventDefault();
var $btn = $(this);
var favId = $btn.data('fav-id');
var $input = $('input.kundenkarte-favorites-qty[data-fav-id="' + favId + '"]');
var qtyStr = $input.val().replace(',', '.');
var qty = parseFloat(qtyStr);
if (!isNaN(qty) && qty > 0) {
// Limit to 2 decimal places
qty = Math.round(qty * 100) / 100;
// Format nicely
var display = (qty % 1 === 0) ? qty.toString() : qty.toFixed(2).replace(/\.?0+$/, '');
$input.val(display);
// Visual feedback
$btn.prop('disabled', true);
$btn.find('i').removeClass('fa-save').addClass('fa-spinner fa-spin');
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/favorite_update.php',
method: 'POST',
data: { id: favId, qty: qty, token: $('input[name="token"]').val() },
success: function() {
$btn.find('i').removeClass('fa-spinner fa-spin').addClass('fa-check').css('color', '#0a0');
setTimeout(function() {
$btn.find('i').removeClass('fa-check').addClass('fa-save').css('color', '');
$btn.prop('disabled', false);
}, 1500);
},
error: function() {
$btn.find('i').removeClass('fa-spinner fa-spin').addClass('fa-exclamation-triangle').css('color', '#c00');
setTimeout(function() {
$btn.find('i').removeClass('fa-exclamation-triangle').addClass('fa-save').css('color', '');
$btn.prop('disabled', false);
}, 2000);
}
});
}
});
},
updateGenerateButton: function() {
var count = $('.kundenkarte-favorites-table input[type="checkbox"][name="selected_products[]"]:checked').length;
var $btn = $('#btn-generate-order');
if (count > 0) {
$btn.prop('disabled', false).text($btn.data('text').replace('%d', count));
} else {
$btn.prop('disabled', true).text($btn.data('text-none'));
}
}
};
/**
* System Tabs Component
*/
KundenKarte.SystemTabs = {
init: function() {
this.bindEvents();
},
bindEvents: function() {
$(document).on('click', '.kundenkarte-system-tab:not(.active):not(.kundenkarte-system-tab-add)', function() {
var systemId = $(this).data('system');
KundenKarte.SystemTabs.switchTo(systemId);
});
},
switchTo: function(systemId) {
$('.kundenkarte-system-tab').removeClass('active');
$('.kundenkarte-system-tab[data-system="' + systemId + '"]').addClass('active');
$('.kundenkarte-system-content').hide();
$('.kundenkarte-system-content[data-system="' + systemId + '"]').show();
// Update URL without reload
var url = new URL(window.location.href);
url.searchParams.set('system', systemId);
window.history.replaceState({}, '', url);
}
};
/**
* Icon Picker Component with Custom Icon Upload
*/
KundenKarte.IconPicker = {
// Common FontAwesome icons for installations/technical use
icons: [
// Electrical
'fa-bolt', 'fa-plug', 'fa-power-off', 'fa-charging-station', 'fa-battery-full', 'fa-battery-half',
'fa-car-battery', 'fa-solar-panel', 'fa-sun', 'fa-lightbulb', 'fa-toggle-on', 'fa-toggle-off',
// Network/Internet
'fa-wifi', 'fa-network-wired', 'fa-server', 'fa-database', 'fa-hdd', 'fa-ethernet',
'fa-broadcast-tower', 'fa-satellite-dish', 'fa-satellite', 'fa-signal', 'fa-rss',
// TV/Media
'fa-tv', 'fa-play-circle', 'fa-video', 'fa-film', 'fa-podcast', 'fa-music',
// Temperature/Climate
'fa-thermometer-half', 'fa-temperature-high', 'fa-temperature-low', 'fa-fire', 'fa-fire-alt',
'fa-snowflake', 'fa-wind', 'fa-fan', 'fa-air-freshener',
// Building/Structure
'fa-home', 'fa-building', 'fa-warehouse', 'fa-door-open', 'fa-door-closed', 'fa-archway',
// Devices/Hardware
'fa-microchip', 'fa-memory', 'fa-sim-card', 'fa-sd-card', 'fa-usb', 'fa-desktop', 'fa-laptop',
'fa-mobile-alt', 'fa-tablet-alt', 'fa-keyboard', 'fa-print', 'fa-fax',
// Security
'fa-shield-alt', 'fa-lock', 'fa-unlock', 'fa-key', 'fa-fingerprint', 'fa-eye', 'fa-video',
'fa-bell', 'fa-exclamation-triangle', 'fa-user-shield',
// Objects
'fa-cube', 'fa-cubes', 'fa-box', 'fa-boxes', 'fa-archive', 'fa-toolbox', 'fa-tools', 'fa-wrench',
'fa-cog', 'fa-cogs', 'fa-sliders-h',
// Layout
'fa-th', 'fa-th-large', 'fa-th-list', 'fa-grip-horizontal', 'fa-grip-vertical', 'fa-bars',
'fa-stream', 'fa-layer-group', 'fa-project-diagram', 'fa-share-alt', 'fa-sitemap',
// Arrows/Direction
'fa-exchange-alt', 'fa-arrows-alt', 'fa-expand', 'fa-compress', 'fa-random',
// Misc
'fa-tachometer-alt', 'fa-chart-line', 'fa-chart-bar', 'fa-chart-pie', 'fa-chart-area',
'fa-clock', 'fa-calendar', 'fa-tag', 'fa-tags', 'fa-bookmark', 'fa-star', 'fa-heart',
'fa-check', 'fa-times', 'fa-plus', 'fa-minus', 'fa-info-circle', 'fa-question-circle',
'fa-dot-circle', 'fa-circle', 'fa-square', 'fa-adjust'
],
customIcons: [],
currentInput: null,
currentTab: 'fontawesome',
init: function() {
this.createModal();
this.bindEvents();
},
createModal: function() {
if ($('#kundenkarte-icon-picker-modal').length) return;
var self = this;
var html = '<div id="kundenkarte-icon-picker-modal" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:700px;">';
html += '<div class="kundenkarte-modal-header">';
html += '<h3>Icon auswählen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span>';
html += '</div>';
html += '<div class="kundenkarte-modal-body">';
// Tabs
html += '<div class="kundenkarte-icon-tabs" style="display:flex;gap:5px;margin-bottom:15px;border-bottom:2px solid #e0e0e0;padding-bottom:10px;">';
html += '<button type="button" class="button kundenkarte-icon-tab active" data-tab="fontawesome"><i class="fa fa-font-awesome"></i> Font Awesome</button>';
html += '<button type="button" class="button kundenkarte-icon-tab" data-tab="custom"><i class="fa fa-upload"></i> Eigene Icons</button>';
html += '</div>';
// Font Awesome Tab Content
html += '<div class="kundenkarte-icon-tab-content" data-tab="fontawesome">';
html += '<input type="text" id="kundenkarte-icon-search" class="flat" placeholder="Suchen..." style="width:100%;margin-bottom:10px;">';
html += '<div class="kundenkarte-icon-grid" id="kundenkarte-fa-icons">';
for (var i = 0; i < this.icons.length; i++) {
html += '<div class="kundenkarte-icon-item" data-icon="' + this.icons[i] + '" data-type="fa" title="' + this.icons[i] + '">';
html += '<i class="fa ' + this.icons[i] + '"></i>';
html += '</div>';
}
html += '</div>';
html += '</div>';
// Custom Icons Tab Content
html += '<div class="kundenkarte-icon-tab-content" data-tab="custom" style="display:none;">';
// Upload area
html += '<div class="kundenkarte-icon-upload-area" style="border:2px dashed #ccc;border-radius:8px;padding:20px;text-align:center;margin-bottom:15px;background:#f9f9f9;">';
html += '<i class="fa fa-cloud-upload" style="font-size:32px;color:#888;margin-bottom:10px;display:block;"></i>';
html += '<p style="margin:0 0 10px 0;">Icon hochladen (PNG, JPG, SVG, max 500KB)</p>';
html += '<input type="file" id="kundenkarte-icon-upload" accept=".png,.jpg,.jpeg,.gif,.svg,.webp" style="display:none;">';
html += '<button type="button" class="button" id="kundenkarte-icon-upload-btn"><i class="fa fa-plus"></i> Datei auswählen</button>';
html += '</div>';
// Custom icons grid
html += '<div class="kundenkarte-icon-grid" id="kundenkarte-custom-icons">';
html += '<p class="opacitymedium" style="grid-column:1/-1;text-align:center;">Lade eigene Icons...</p>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '</div>';
$('body').append(html);
},
bindEvents: function() {
var self = this;
// Open picker
$(document).on('click', '.kundenkarte-icon-picker-btn', function(e) {
e.preventDefault();
var inputName = $(this).data('input');
self.currentInput = $('input[name="' + inputName + '"]');
self.open();
});
// Close modal
$(document).on('click', '.kundenkarte-modal-close, #kundenkarte-icon-picker-modal', function(e) {
if (e.target === this || $(e.target).hasClass('kundenkarte-modal-close')) {
self.close();
}
});
// Tab switching
$(document).on('click', '.kundenkarte-icon-tab', function() {
var tab = $(this).data('tab');
self.switchTab(tab);
});
// Select FA icon
$(document).on('click', '.kundenkarte-icon-item[data-type="fa"]', function() {
var icon = $(this).data('icon');
self.selectIcon(icon, 'fa');
});
// Select custom icon
$(document).on('click', '.kundenkarte-icon-item[data-type="custom"]', function() {
var iconUrl = $(this).data('icon');
self.selectIcon(iconUrl, 'custom');
});
// Search filter (FA only)
$(document).on('input', '#kundenkarte-icon-search', function() {
var search = $(this).val().toLowerCase();
$('#kundenkarte-fa-icons .kundenkarte-icon-item').each(function() {
var icon = $(this).data('icon').toLowerCase();
$(this).toggle(icon.indexOf(search) > -1);
});
});
// Upload button click
$(document).on('click', '#kundenkarte-icon-upload-btn', function() {
$('#kundenkarte-icon-upload').click();
});
// File selected
$(document).on('change', '#kundenkarte-icon-upload', function() {
var file = this.files[0];
if (file) {
self.uploadIcon(file);
}
});
// Delete custom icon
$(document).on('click', '.kundenkarte-icon-delete', function(e) {
e.stopPropagation();
var filename = $(this).data('filename');
self.showDeleteConfirm(filename);
});
// ESC key to close
$(document).on('keydown', function(e) {
if (e.key === 'Escape') {
self.close();
}
});
},
switchTab: function(tab) {
this.currentTab = tab;
$('.kundenkarte-icon-tab').removeClass('active');
$('.kundenkarte-icon-tab[data-tab="' + tab + '"]').addClass('active');
$('.kundenkarte-icon-tab-content').hide();
$('.kundenkarte-icon-tab-content[data-tab="' + tab + '"]').show();
if (tab === 'custom') {
this.loadCustomIcons();
}
},
loadCustomIcons: function() {
var self = this;
var $grid = $('#kundenkarte-custom-icons');
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/icon_upload.php',
data: { action: 'list' },
dataType: 'json',
success: function(response) {
$grid.empty();
if (response.icons && response.icons.length > 0) {
self.customIcons = response.icons;
for (var i = 0; i < response.icons.length; i++) {
var icon = response.icons[i];
var html = '<div class="kundenkarte-icon-item kundenkarte-custom-icon-item" data-icon="' + icon.url + '" data-type="custom" title="' + icon.name + '" style="position:relative;">';
html += '<img src="' + icon.url + '" alt="' + icon.name + '" style="max-width:32px;max-height:32px;">';
html += '<span class="kundenkarte-icon-delete" data-filename="' + icon.filename + '" title="Löschen" style="position:absolute;top:-5px;right:-5px;background:#c00;color:#fff;border-radius:50%;width:16px;height:16px;font-size:10px;line-height:16px;text-align:center;cursor:pointer;display:none;">&times;</span>';
html += '</div>';
$grid.append(html);
}
} else {
$grid.html('<p class="opacitymedium" style="grid-column:1/-1;text-align:center;">Noch keine eigenen Icons hochgeladen.</p>');
}
},
error: function() {
$grid.html('<p class="warning" style="grid-column:1/-1;text-align:center;">Fehler beim Laden der Icons.</p>');
}
});
},
uploadIcon: function(file) {
var self = this;
var formData = new FormData();
formData.append('action', 'upload');
formData.append('icon', file);
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/icon_upload.php',
method: 'POST',
data: formData,
processData: false,
contentType: false,
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadCustomIcons();
// Auto-select uploaded icon
setTimeout(function() {
self.selectIcon(response.icon.url, 'custom');
}, 500);
} else {
KundenKarte.showAlert('Fehler', response.error || 'Unbekannter Fehler');
}
},
error: function(xhr) {
var msg = 'Upload fehlgeschlagen';
try {
var resp = JSON.parse(xhr.responseText);
msg = resp.error || msg;
} catch(e) {}
KundenKarte.showAlert('Fehler', msg);
}
});
// Reset input
$('#kundenkarte-icon-upload').val('');
},
deleteIcon: function(filename) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/icon_upload.php',
method: 'POST',
data: { action: 'delete', filename: filename },
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadCustomIcons();
} else {
KundenKarte.showAlert('Fehler', response.error || 'Löschen fehlgeschlagen');
}
}
});
},
showDeleteConfirm: function(filename) {
var self = this;
// Remove any existing confirm dialog
$('#kundenkarte-delete-confirm').remove();
// Create Dolibarr-style confirmation dialog
var html = '<div id="kundenkarte-delete-confirm" class="kundenkarte-confirm-overlay" style="position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:center;justify-content:center;">';
html += '<div class="kundenkarte-confirm-box" style="background:#fff;border-radius:6px;box-shadow:0 4px 20px rgba(0,0,0,0.3);max-width:400px;width:90%;">';
// Header (Dolibarr style)
html += '<div class="kundenkarte-confirm-header" style="background:linear-gradient(to bottom,#f8f8f8,#e8e8e8);border-bottom:1px solid #ccc;padding:12px 15px;border-radius:6px 6px 0 0;">';
html += '<span style="font-weight:bold;font-size:14px;"><i class="fa fa-exclamation-triangle" style="color:#f0ad4e;margin-right:8px;"></i>Bestätigung</span>';
html += '</div>';
// Body
html += '<div class="kundenkarte-confirm-body" style="padding:20px;text-align:center;">';
html += '<p style="margin:0 0 5px 0;font-size:14px;">Möchten Sie dieses Icon wirklich löschen?</p>';
html += '<p style="margin:0;color:#666;font-size:12px;"><code>' + this.escapeHtml(filename) + '</code></p>';
html += '</div>';
// Footer with buttons (Dolibarr style)
html += '<div class="kundenkarte-confirm-footer" style="background:#f5f5f5;border-top:1px solid #ddd;padding:12px 15px;text-align:center;border-radius:0 0 6px 6px;">';
html += '<button type="button" class="button" id="kundenkarte-confirm-yes" style="background:#c9302c;color:#fff;border:1px solid #ac2925;padding:6px 20px;margin-right:10px;cursor:pointer;"><i class="fa fa-check"></i> Ja, löschen</button>';
html += '<button type="button" class="button" id="kundenkarte-confirm-no" style="padding:6px 20px;cursor:pointer;"><i class="fa fa-times"></i> Abbrechen</button>';
html += '</div>';
html += '</div>';
html += '</div>';
$('body').append(html);
// Bind events
$('#kundenkarte-confirm-yes').on('click', function() {
$('#kundenkarte-delete-confirm').remove();
self.deleteIcon(filename);
});
$('#kundenkarte-confirm-no, #kundenkarte-delete-confirm').on('click', function(e) {
if (e.target === this) {
$('#kundenkarte-delete-confirm').remove();
}
});
// ESC to close
$(document).one('keydown.confirmDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-delete-confirm').remove();
}
});
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
selectIcon: function(icon, type) {
if (this.currentInput) {
// For custom icons, store the URL with a prefix to distinguish
var value = (type === 'custom') ? 'img:' + icon : icon;
this.currentInput.val(value);
// Update preview
var $wrapper = this.currentInput.closest('.kundenkarte-icon-picker-wrapper');
var $preview = $wrapper.find('.kundenkarte-icon-preview');
if ($preview.length) {
if (type === 'custom') {
$preview.html('<img src="' + icon + '" style="max-width:20px;max-height:20px;">');
} else {
$preview.html('<i class="fa ' + icon + '"></i>');
}
}
}
this.close();
},
open: function() {
$('#kundenkarte-icon-search').val('');
$('#kundenkarte-fa-icons .kundenkarte-icon-item').show();
this.switchTab('fontawesome');
$('#kundenkarte-icon-picker-modal').addClass('visible');
},
close: function() {
$('#kundenkarte-icon-picker-modal').removeClass('visible');
this.currentInput = null;
}
};
/**
* Dynamic Fields Component
* Loads and renders type-specific fields when creating/editing anlagen
*/
KundenKarte.DynamicFields = {
init: function() {
var self = this;
var $typeSelect = $('select[name="fk_anlage_type"]');
var $container = $('#dynamic_fields');
if (!$typeSelect.length || !$container.length) return;
// Load fields when type changes
$typeSelect.on('change', function() {
self.loadFields($(this).val());
});
// Load initial fields if type is already selected
if ($typeSelect.val()) {
self.loadFields($typeSelect.val());
}
},
loadFields: function(typeId) {
var self = this;
var $container = $('#dynamic_fields');
if (!typeId) {
$container.html('');
return;
}
// Store current type ID for autocomplete
this.currentTypeId = typeId;
// Get anlage_id if editing or copying
var anlageId = $('input[name="anlage_id"]').val() || $('input[name="copy_from"]').val() || 0;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/type_fields.php',
data: { type_id: typeId, anlage_id: anlageId },
dataType: 'json',
success: function(data) {
if (data.fields && data.fields.length > 0) {
var html = '';
data.fields.forEach(function(field) {
if (field.type === 'header') {
// Header row spans both columns with styling
html += '<tr class="liste_titre dynamic-field-row"><th colspan="2" style="padding:8px;">' + KundenKarte.DynamicFields.escapeHtml(field.label) + '</th></tr>';
} else {
html += '<tr class="dynamic-field-row"><td class="titlefield">' + KundenKarte.DynamicFields.escapeHtml(field.label);
if (field.required) html += ' <span class="fieldrequired">*</span>';
html += '</td><td>';
html += KundenKarte.DynamicFields.renderField(field);
html += '</td></tr>';
}
});
$container.html(html);
} else {
$container.html('');
}
}
});
},
currentTypeId: 0,
renderField: function(field) {
var name = 'field_' + field.code;
var value = field.value || '';
var required = field.required ? ' required' : '';
var autocompleteClass = field.autocomplete ? ' kk-autocomplete' : '';
var autocompleteAttrs = field.autocomplete ? ' data-field-code="' + field.code + '" data-type-id="' + this.currentTypeId + '"' : '';
switch (field.type) {
case 'text':
return '<input type="text" name="' + name + '" class="flat minwidth300' + autocompleteClass + '" value="' + this.escapeHtml(value) + '"' + autocompleteAttrs + required + ' autocomplete="off">';
case 'textarea':
return '<textarea name="' + name + '" class="flat minwidth300' + autocompleteClass + '" rows="3"' + autocompleteAttrs + required + ' autocomplete="off">' + this.escapeHtml(value) + '</textarea>';
case 'number':
var attrs = '';
if (field.options) {
var opts = field.options.split('|');
opts.forEach(function(opt) {
var parts = opt.split(':');
if (parts.length === 2) {
attrs += ' ' + parts[0] + '="' + parts[1] + '"';
}
});
}
return '<input type="number" name="' + name + '" class="flat" value="' + this.escapeHtml(value) + '"' + attrs + required + '>';
case 'select':
var html = '<select name="' + name + '" class="flat minwidth200"' + required + '>';
html += '<option value="">-- Auswählen --</option>';
if (field.options) {
var options = field.options.split('|');
options.forEach(function(opt) {
opt = opt.trim();
if (opt === '') return;
var selected = (opt === value.trim()) ? ' selected' : '';
html += '<option value="' + KundenKarte.DynamicFields.escapeHtml(opt) + '"' + selected + '>' + KundenKarte.DynamicFields.escapeHtml(opt) + '</option>';
});
}
html += '</select>';
return html;
case 'date':
var inputId = 'date_' + name.replace(/[^a-zA-Z0-9]/g, '_');
return '<input type="date" name="' + name + '" id="' + inputId + '" class="flat" value="' + this.escapeHtml(value) + '"' + required + '>' +
'<button type="button" class="button buttongen" style="margin-left:4px;padding:2px 6px;font-size:11px;" onclick="document.getElementById(\'' + inputId + '\').value = new Date().toISOString().split(\'T\')[0];" title="Heute">Heute</button>';
case 'checkbox':
var checked = (value === '1' || value === 'true' || value === 'yes') ? ' checked' : '';
return '<input type="checkbox" name="' + name + '" value="1"' + checked + '>';
default:
return '<input type="text" name="' + name + '" class="flat minwidth300" value="' + this.escapeHtml(value) + '"' + required + '>';
}
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
/**
* Equipment Component
* Manages Hutschienen (DIN rail) components with SVG visualization
*/
KundenKarte.Equipment = {
TE_WIDTH: 50, // Width of one TE in pixels (wider for more space)
BLOCK_HEIGHT: 110, // Height of equipment block in pixels
currentCarrierId: null,
currentAnlageId: null,
currentSystemId: null,
draggedEquipment: null,
isSaving: false, // Prevent double-clicks
init: function(anlageId, systemId) {
if (!anlageId) return;
this.currentAnlageId = anlageId;
this.currentSystemId = systemId || 0;
this.bindEvents();
},
bindEvents: function() {
var self = this;
// Add panel button
$(document).on('click', '.kundenkarte-add-panel', function(e) {
e.preventDefault();
var anlageId = $(this).data('anlage-id');
self.showPanelDialog(anlageId);
});
// Edit panel
$(document).on('click', '.kundenkarte-panel-edit', function(e) {
e.preventDefault();
e.stopPropagation();
var panelId = $(this).closest('.kundenkarte-panel').data('panel-id');
self.showPanelDialog(self.currentAnlageId, panelId);
});
// Delete panel
$(document).on('click', '.kundenkarte-panel-delete', function(e) {
e.preventDefault();
e.stopPropagation();
var panelId = $(this).closest('.kundenkarte-panel').data('panel-id');
self.deletePanel(panelId);
});
// Quick-duplicate panel (+ button next to last panel)
$(document).on('click', '.kundenkarte-panel-quickadd', function(e) {
e.preventDefault();
e.stopPropagation();
var panelId = $(this).data('panel-id');
self.duplicatePanel(panelId);
});
// Quick-duplicate carrier (+ button below last carrier)
$(document).on('click', '.kundenkarte-carrier-quickadd', function(e) {
e.preventDefault();
e.stopPropagation();
var carrierId = $(this).data('carrier-id');
self.duplicateCarrier(carrierId);
});
// Add carrier button
$(document).on('click', '.kundenkarte-add-carrier', function(e) {
e.preventDefault();
var anlageId = $(this).data('anlage-id');
var panelId = $(this).data('panel-id') || 0;
self.showCarrierDialog(anlageId, null, panelId);
});
// Edit carrier
$(document).on('click', '.kundenkarte-carrier-edit', function(e) {
e.preventDefault();
e.stopPropagation();
var carrierId = $(this).closest('.kundenkarte-carrier').data('carrier-id');
self.showCarrierDialog(self.currentAnlageId, carrierId);
});
// Delete carrier
$(document).on('click', '.kundenkarte-carrier-delete', function(e) {
e.preventDefault();
e.stopPropagation();
var carrierId = $(this).closest('.kundenkarte-carrier').data('carrier-id');
self.deleteCarrier(carrierId);
});
// Add equipment (click on empty slot or + button)
$(document).on('click', '.kundenkarte-carrier-add-equipment, .kundenkarte-slot-empty', function(e) {
e.preventDefault();
var $carrier = $(this).closest('.kundenkarte-carrier');
var carrierId = $carrier.data('carrier-id');
var position = $(this).data('position') || null;
self.showEquipmentDialog(carrierId, null, position);
});
// Edit equipment (click on block)
$(document).on('click', '.kundenkarte-equipment-block', function(e) {
e.preventDefault();
e.stopPropagation();
var equipmentId = $(this).data('equipment-id');
self.showEquipmentDialog(null, equipmentId);
});
// Quick-add slot (duplicate last equipment into next free position)
$(document).on('click', '.kundenkarte-slot-quickadd', function(e) {
e.preventDefault();
e.stopPropagation();
var equipmentId = $(this).data('equipment-id');
self.duplicateEquipment(equipmentId);
});
// Delete equipment
$(document).on('click', '.kundenkarte-equipment-delete', function(e) {
e.preventDefault();
e.stopPropagation();
var equipmentId = $(this).closest('.kundenkarte-equipment-block').data('equipment-id');
self.deleteEquipment(equipmentId);
});
// Add output connection
$(document).on('click', '.kundenkarte-add-output-btn', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.showOutputDialog(carrierId);
});
// Add rail connection
$(document).on('click', '.kundenkarte-add-rail-btn', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.showRailDialog(carrierId);
});
// Click on rail to edit
$(document).on('click', '.kundenkarte-rail', function(e) {
e.preventDefault();
e.stopPropagation();
var connectionId = $(this).data('connection-id');
// Find carrier ID from connections container or carrier element
var carrierId = $(this).closest('.kundenkarte-connections-container').data('carrier-id') ||
$(this).closest('.kundenkarte-carrier').data('carrier-id');
self.showEditRailDialog(connectionId, carrierId);
});
// Click on output to edit
$(document).on('click', '.kundenkarte-output', function(e) {
e.preventDefault();
e.stopPropagation();
var connectionId = $(this).data('connection-id');
// Find carrier ID from connections container or carrier element
var carrierId = $(this).closest('.kundenkarte-connections-container').data('carrier-id') ||
$(this).closest('.kundenkarte-carrier').data('carrier-id');
self.showEditOutputDialog(connectionId, carrierId);
});
// Click on connection to delete (generic connections)
$(document).on('click', '.kundenkarte-connection', function(e) {
e.preventDefault();
var connectionId = $(this).data('connection-id');
self.deleteConnection(connectionId);
});
// Drag & Drop events
$(document).on('dragstart', '.kundenkarte-equipment-block', function(e) {
self.draggedEquipment = $(this);
$(this).addClass('dragging');
e.originalEvent.dataTransfer.effectAllowed = 'move';
e.originalEvent.dataTransfer.setData('text/plain', $(this).data('equipment-id'));
});
$(document).on('dragend', '.kundenkarte-equipment-block', function() {
$(this).removeClass('dragging');
self.draggedEquipment = null;
$('.kundenkarte-slot-drop-target').removeClass('kundenkarte-slot-drop-target');
});
$(document).on('dragover', '.kundenkarte-slot-empty, .kundenkarte-carrier-slots', function(e) {
e.preventDefault();
e.originalEvent.dataTransfer.dropEffect = 'move';
});
$(document).on('dragenter', '.kundenkarte-slot-empty', function(e) {
e.preventDefault();
$(this).addClass('kundenkarte-slot-drop-target');
});
$(document).on('dragleave', '.kundenkarte-slot-empty', function() {
$(this).removeClass('kundenkarte-slot-drop-target');
});
$(document).on('drop', '.kundenkarte-slot-empty', function(e) {
e.preventDefault();
$(this).removeClass('kundenkarte-slot-drop-target');
if (self.draggedEquipment) {
var equipmentId = self.draggedEquipment.data('equipment-id');
var newPosition = $(this).data('position');
self.moveEquipment(equipmentId, newPosition);
}
});
// Equipment type change in dialog
$(document).on('change', '#equipment_type_id', function() {
self.loadEquipmentTypeFields($(this).val());
});
// Hover tooltip for equipment
$(document).on('mouseenter', '.kundenkarte-equipment-block', function() {
var $block = $(this);
var tooltipData = $block.attr('data-tooltip');
if (tooltipData) {
self.showEquipmentTooltip($block, JSON.parse(tooltipData));
}
});
$(document).on('mouseleave', '.kundenkarte-equipment-block', function() {
self.hideEquipmentTooltip();
});
},
loadCarriers: function(anlageId) {
var self = this;
var $container = $('.kundenkarte-equipment-container[data-anlage-id="' + anlageId + '"]');
if (!$container.length) return;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
data: { action: 'list', anlage_id: anlageId },
dataType: 'json',
success: function(response) {
if (response.success) {
self.renderCarriers($container, response.carriers);
}
}
});
},
renderCarriers: function($container, carriers) {
var self = this;
var html = '';
if (carriers.length === 0) {
html = '<div class="opacitymedium" style="padding:10px;">Keine Hutschienen vorhanden. Klicken Sie auf "Hutschiene hinzufügen".</div>';
} else {
carriers.forEach(function(carrier) {
html += self.renderCarrier(carrier);
});
}
$container.find('.kundenkarte-carriers-list').html(html);
},
renderCarrier: function(carrier) {
var self = this;
var totalWidth = carrier.total_te * this.TE_WIDTH;
var html = '<div class="kundenkarte-carrier" data-carrier-id="' + carrier.id + '">';
// Header
html += '<div class="kundenkarte-carrier-header">';
html += '<span class="kundenkarte-carrier-label">' + this.escapeHtml(carrier.label || 'Hutschiene') + '</span>';
html += '<span class="kundenkarte-carrier-info">' + carrier.used_te + '/' + carrier.total_te + ' TE belegt</span>';
html += '<span class="kundenkarte-carrier-actions">';
html += '<a href="#" class="kundenkarte-carrier-add-equipment" title="Equipment hinzufügen"><i class="fa fa-plus"></i></a>';
html += '<a href="#" class="kundenkarte-carrier-edit" title="Bearbeiten"><i class="fa fa-edit"></i></a>';
html += '<a href="#" class="kundenkarte-carrier-delete" title="Löschen"><i class="fa fa-trash"></i></a>';
html += '</span>';
html += '</div>';
// SVG Rail
html += '<div class="kundenkarte-carrier-svg-container" style="width:' + totalWidth + 'px;">';
html += '<svg class="kundenkarte-carrier-svg" width="' + totalWidth + '" height="' + this.BLOCK_HEIGHT + '" viewBox="0 0 ' + totalWidth + ' ' + this.BLOCK_HEIGHT + '">';
// TE-Raster (ganzzahlige TEs deutlich, 0.5er subtil)
for (var i = 0; i <= carrier.total_te; i++) {
var x = i * this.TE_WIDTH;
html += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="' + this.BLOCK_HEIGHT + '" stroke="#ddd" stroke-width="1.5"/>';
if (i < carrier.total_te) {
var halfX = x + this.TE_WIDTH / 2;
html += '<line x1="' + halfX + '" y1="5" x2="' + halfX + '" y2="' + (this.BLOCK_HEIGHT - 5) + '" stroke="#ddd" stroke-width="0.3" opacity="0.4"/>';
}
}
// Render equipment blocks
if (carrier.equipment) {
carrier.equipment.forEach(function(eq) {
html += self.renderEquipmentBlock(eq);
});
}
html += '</svg>';
// Clickable slots overlay (for adding equipment)
html += '<div class="kundenkarte-carrier-slots">';
var occupiedSlots = this.getOccupiedSlots(carrier.equipment);
for (var pos = 1; pos <= carrier.total_te; pos++) {
if (!occupiedSlots[pos]) {
var slotLeft = (pos - 1) * this.TE_WIDTH;
html += '<div class="kundenkarte-slot-empty" data-position="' + pos + '" style="left:' + slotLeft + 'px;width:' + this.TE_WIDTH + 'px;"></div>';
}
}
html += '</div>';
html += '</div>'; // svg-container
html += '</div>'; // carrier
return html;
},
renderEquipmentBlock: function(equipment) {
var x = (equipment.position_te - 1) * this.TE_WIDTH;
var width = equipment.width_te * this.TE_WIDTH;
var color = equipment.block_color || equipment.type_color || '#3498db';
var label = equipment.block_label || equipment.type_label_short || '';
// Build tooltip data
var tooltipData = {
label: equipment.label,
type: equipment.type_label,
fields: equipment.field_values || {}
};
var html = '<g class="kundenkarte-equipment-block" data-equipment-id="' + equipment.id + '" ';
html += 'data-tooltip=\'' + JSON.stringify(tooltipData).replace(/'/g, "&#39;") + '\' ';
html += 'draggable="true" style="cursor:pointer;">';
// Block rectangle with rounded corners
html += '<rect x="' + (x + 1) + '" y="2" width="' + (width - 2) + '" height="' + (this.BLOCK_HEIGHT - 4) + '" ';
html += 'rx="3" ry="3" fill="' + color + '" stroke="#333" stroke-width="1"/>';
// Label text (centered)
var fontSize = width < 40 ? 11 : 14;
html += '<text x="' + (x + width/2) + '" y="' + (this.BLOCK_HEIGHT/2) + '" ';
html += 'text-anchor="middle" dominant-baseline="middle" ';
html += 'fill="#fff" font-size="' + fontSize + 'px" font-weight="bold">';
html += this.escapeHtml(label);
html += '</text>';
// Duplicate button (+) at the right edge
html += '</g>';
return html;
},
getOccupiedSlots: function(equipment) {
// Range-basierte Belegung (unterstützt Dezimal-TE wie 4.5)
var slots = {};
if (equipment) {
equipment.forEach(function(eq) {
var start = parseFloat(eq.position_te) || 1;
var width = parseFloat(eq.width_te) || 1;
var end = start + width;
// Ganzzahl-Slots markieren die von diesem Equipment überlappt werden
for (var i = Math.floor(start); i < Math.ceil(end); i++) {
slots[i] = true;
}
});
}
return slots;
},
// Panel dialog functions
showPanelDialog: function(anlageId, panelId) {
var self = this;
if ($('#kundenkarte-panel-dialog').length) return;
var isEdit = !!panelId;
var title = isEdit ? 'Feld bearbeiten' : 'Feld hinzufügen';
var panelData = { label: '' };
var showDialog = function(data) {
var html = '<div id="kundenkarte-panel-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + title + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<table class="noborder" style="width:100%;">';
html += '<tr><td class="titlefield">Bezeichnung</td>';
html += '<td><input type="text" name="panel_label" class="flat minwidth200" placeholder="z.B. Feld 1" value="' + self.escapeHtml(data.label) + '"></td></tr>';
html += '</table>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="panel-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="panel-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-panel-dialog').addClass('visible');
$('#panel-save').on('click', function() {
var label = $('input[name="panel_label"]').val();
self.savePanel(anlageId, panelId, label);
});
$('#panel-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-panel-dialog').remove();
});
$(document).on('keydown.panelDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-panel-dialog').remove();
$(document).off('keydown.panelDialog');
}
});
};
if (isEdit) {
var $panel = $('.kundenkarte-panel[data-panel-id="' + panelId + '"]');
panelData.label = $panel.find('.kundenkarte-panel-label').text();
showDialog(panelData);
} else {
showDialog(panelData);
}
},
savePanel: function(anlageId, panelId, label) {
var self = this;
if (self.isSaving) return;
self.isSaving = true;
$('#panel-save').prop('disabled', true);
var action = panelId ? 'update' : 'create';
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: action,
anlage_id: anlageId,
panel_id: panelId,
label: label,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
self.isSaving = false;
if (response.success) {
$('#kundenkarte-panel-dialog').remove();
location.reload();
} else {
$('#panel-save').prop('disabled', false);
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
self.isSaving = false;
$('#panel-save').prop('disabled', false);
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
deletePanel: function(panelId) {
var self = this;
self.showConfirmDialog('Feld löschen', 'Möchten Sie dieses Feld wirklich löschen? Alle Hutschienen und Equipment werden ebenfalls gelöscht.', function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: 'delete',
panel_id: panelId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
self.showAlertDialog('Fehler', response.error);
}
},
error: function() {
self.showAlertDialog('Fehler', 'Netzwerkfehler');
}
});
});
},
duplicatePanel: function(panelId) {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: 'duplicate',
panel_id: panelId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
duplicateCarrier: function(carrierId) {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'duplicate',
carrier_id: carrierId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
showCarrierDialog: function(anlageId, carrierId, panelId) {
var self = this;
// Prevent opening multiple dialogs
if ($('#kundenkarte-carrier-dialog').length) return;
var isEdit = !!carrierId;
var title = isEdit ? 'Hutschiene bearbeiten' : 'Hutschiene hinzufügen';
panelId = panelId || 0;
// Load carrier data if editing
var carrierData = { label: '', total_te: 12, fk_panel: panelId };
var showDialog = function(data, panels) {
var html = '<div id="kundenkarte-carrier-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + title + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<input type="hidden" name="carrier_id" value="' + (carrierId || '') + '">';
html += '<table class="noborder" style="width:100%;">';
html += '<tr><td class="titlefield">Bezeichnung</td>';
html += '<td><input type="text" name="carrier_label" class="flat minwidth200" value="' + self.escapeHtml(data.label) + '"></td></tr>';
html += '<tr><td>Kapazität (TE)</td>';
html += '<td><input type="number" name="carrier_total_te" class="flat" min="1" max="72" value="' + data.total_te + '"></td></tr>';
// Panel dropdown (only show if panels exist)
if (panels && panels.length > 0) {
html += '<tr><td>Feld</td>';
html += '<td><select name="carrier_panel_id" class="flat minwidth200">';
html += '<option value="0">-- Kein Feld (direkt) --</option>';
panels.forEach(function(p) {
var selected = (data.fk_panel == p.id) ? ' selected' : '';
html += '<option value="' + p.id + '"' + selected + '>' + self.escapeHtml(p.label) + '</option>';
});
html += '</select></td></tr>';
}
html += '</table>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="carrier-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="carrier-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-carrier-dialog').addClass('visible');
// Save button
$('#carrier-save').on('click', function() {
var label = $('input[name="carrier_label"]').val();
var totalTe = parseInt($('input[name="carrier_total_te"]').val()) || 12;
var selectedPanelId = $('select[name="carrier_panel_id"]').val() || 0;
self.saveCarrier(anlageId, carrierId, label, totalTe, selectedPanelId);
});
// Close
$('#carrier-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-carrier-dialog').remove();
});
$(document).on('keydown.carrierDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-carrier-dialog').remove();
$(document).off('keydown.carrierDialog');
}
});
};
// Load panels for the anlage
var loadPanelsAndShow = function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
data: { action: 'list', anlage_id: self.currentAnlageId },
dataType: 'json',
success: function(response) {
var panels = response.success ? response.panels : [];
showDialog(carrierData, panels);
},
error: function() {
showDialog(carrierData, []);
}
});
};
if (isEdit) {
// Fetch existing carrier data via AJAX
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
data: { action: 'get', carrier_id: carrierId },
dataType: 'json',
success: function(response) {
if (response.success && response.carrier) {
carrierData.label = response.carrier.label;
carrierData.total_te = response.carrier.total_te;
carrierData.fk_panel = response.carrier.fk_panel || 0;
}
loadPanelsAndShow();
},
error: function() {
// Fallback to DOM data
var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]');
carrierData.label = $carrier.find('.kundenkarte-carrier-label').text();
var infoText = $carrier.find('.kundenkarte-carrier-info').text();
var match = infoText.match(/\/(\d+)/);
if (match) carrierData.total_te = parseInt(match[1]);
loadPanelsAndShow();
}
});
} else {
carrierData.fk_panel = panelId;
loadPanelsAndShow();
}
},
saveCarrier: function(anlageId, carrierId, label, totalTe, panelId) {
var self = this;
// Prevent double-click
if (self.isSaving) return;
self.isSaving = true;
$('#carrier-save').prop('disabled', true);
var action = carrierId ? 'update' : 'create';
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: action,
anlage_id: anlageId,
carrier_id: carrierId,
panel_id: panelId || 0,
label: label,
total_te: totalTe,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
self.isSaving = false;
if (response.success) {
$('#kundenkarte-carrier-dialog').remove();
// Reload page to get fresh PHP-rendered carriers
location.reload();
} else {
$('#carrier-save').prop('disabled', false);
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
self.isSaving = false;
$('#carrier-save').prop('disabled', false);
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
deleteCarrier: function(carrierId) {
var self = this;
self.showConfirmDialog('Hutschiene löschen', 'Möchten Sie diese Hutschiene wirklich löschen? Alle Equipment darauf wird ebenfalls gelöscht.', function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'delete',
carrier_id: carrierId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
self.showAlertDialog('Fehler', response.error);
}
}
});
});
},
showEquipmentDialog: function(carrierId, equipmentId, position) {
var self = this;
// Prevent opening multiple dialogs
if ($('#kundenkarte-equipment-dialog').length) return;
var isEdit = !!equipmentId;
var title = isEdit ? 'Equipment bearbeiten' : 'Equipment hinzufügen';
// First, load equipment types
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_type_fields.php',
data: { type_id: 0 }, // Get types list
dataType: 'json',
success: function() {
self.renderEquipmentDialog(carrierId, equipmentId, position, title, isEdit);
}
});
},
renderEquipmentDialog: function(carrierId, equipmentId, position, title, isEdit) {
var self = this;
var html = '<div id="kundenkarte-equipment-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + title + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<input type="hidden" name="equipment_carrier_id" value="' + (carrierId || '') + '">';
html += '<input type="hidden" name="equipment_id" value="' + (equipmentId || '') + '">';
html += '<input type="hidden" name="equipment_position" value="' + (position || '') + '">';
html += '<table class="noborder" style="width:100%;">';
html += '<tr><td class="titlefield">Typ <span class="fieldrequired">*</span></td>';
html += '<td><select name="equipment_type_id" id="equipment_type_id" class="flat minwidth200">';
html += '<option value="">-- Typ wählen --</option>';
html += '</select></td></tr>';
html += '<tr><td>Bezeichnung</td>';
html += '<td><input type="text" name="equipment_label" class="flat minwidth200"></td></tr>';
html += '<tbody id="equipment-dynamic-fields"></tbody>';
html += '<tr class="protection-fields"><td colspan="2" style="padding-top:15px;border-top:1px solid #444;">';
html += '<strong><i class="fa fa-shield"></i> FI/RCD-Zuordnung</strong></td></tr>';
html += '<tr class="protection-fields"><td>Schutzeinrichtung</td>';
html += '<td><select name="equipment_fk_protection" id="equipment_fk_protection" class="flat minwidth200">';
html += '<option value="">-- Keine --</option>';
html += '</select></td></tr>';
html += '<tr class="protection-fields"><td>Schutzbezeichnung</td>';
html += '<td><input type="text" name="equipment_protection_label" class="flat minwidth200" placeholder="z.B. FI 40A/30mA"></td></tr>';
html += '</table>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="equipment-save"><i class="fa fa-save"></i> Speichern</button> ';
if (isEdit) {
html += '<button type="button" class="button" id="equipment-delete" style="background:#c0392b;color:#fff;"><i class="fa fa-trash"></i> Löschen</button> ';
}
html += '<button type="button" class="button" id="equipment-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-equipment-dialog').addClass('visible');
// Load equipment types into select
self.loadEquipmentTypes(equipmentId);
// Save button
$('#equipment-save').on('click', function() {
self.saveEquipment();
});
// Delete button
$('#equipment-delete').on('click', function() {
$('#kundenkarte-equipment-dialog').remove();
self.showConfirmDialog('Equipment löschen', 'Möchten Sie dieses Equipment wirklich löschen?', function() {
self.deleteEquipment(equipmentId);
});
});
// Close
$('#equipment-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-equipment-dialog').remove();
});
$(document).on('keydown.equipmentDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-equipment-dialog').remove();
$(document).off('keydown.equipmentDialog');
}
});
},
loadEquipmentTypes: function(equipmentId) {
var self = this;
// Get equipment types from admin config (stored in page data or via AJAX)
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_types', system_id: self.currentSystemId },
dataType: 'json',
success: function(response) {
if (response.types) {
var $select = $('#equipment_type_id');
// Clear existing options except first placeholder
$select.find('option:not(:first)').remove();
response.types.forEach(function(type) {
$select.append('<option value="' + type.id + '">' + self.escapeHtml(type.label) + '</option>');
});
// Load protection devices
self.loadProtectionDevices();
// If editing, load equipment data
if (equipmentId) {
self.loadEquipmentData(equipmentId);
}
}
}
});
},
loadProtectionDevices: function() {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_protection_devices', anlage_id: self.currentAnlageId },
dataType: 'json',
success: function(response) {
if (response.success && response.devices) {
var $select = $('#equipment_fk_protection');
$select.find('option:not(:first)').remove();
response.devices.forEach(function(device) {
$select.append('<option value="' + device.id + '">' + self.escapeHtml(device.display_label) + '</option>');
});
}
}
});
},
loadEquipmentData: function(equipmentId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get', equipment_id: equipmentId },
dataType: 'json',
success: function(response) {
if (response.equipment) {
var eq = response.equipment;
$('input[name="equipment_carrier_id"]').val(eq.fk_carrier);
$('input[name="equipment_label"]').val(eq.label);
$('input[name="equipment_position"]').val(eq.position_te);
$('#equipment_type_id').val(eq.type_id).trigger('change');
// Protection fields
if (eq.fk_protection) {
$('#equipment_fk_protection').val(eq.fk_protection);
}
if (eq.protection_label) {
$('input[name="equipment_protection_label"]').val(eq.protection_label);
}
}
}
});
},
loadEquipmentTypeFields: function(typeId) {
var self = this;
var $container = $('#equipment-dynamic-fields');
var equipmentId = $('input[name="equipment_id"]').val();
if (!typeId) {
$container.html('');
return;
}
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_type_fields.php',
data: { type_id: typeId, equipment_id: equipmentId },
dataType: 'json',
success: function(response) {
if (response.success && response.fields) {
var html = '';
response.fields.forEach(function(field) {
html += '<tr><td>' + self.escapeHtml(field.label);
if (field.required) html += ' <span class="fieldrequired">*</span>';
html += '</td><td>';
html += self.renderEquipmentField(field);
html += '</td></tr>';
});
$container.html(html);
} else {
$container.html('');
}
}
});
},
renderEquipmentField: function(field) {
var name = 'eq_field_' + field.code;
var value = field.value || '';
var required = field.required ? ' required' : '';
switch (field.type) {
case 'select':
var html = '<select name="' + name + '" class="flat"' + required + '>';
html += '<option value="">--</option>';
if (field.options) {
field.options.split('|').forEach(function(opt) {
var selected = (opt === value) ? ' selected' : '';
html += '<option value="' + KundenKarte.Equipment.escapeHtml(opt) + '"' + selected + '>' + KundenKarte.Equipment.escapeHtml(opt) + '</option>';
});
}
html += '</select>';
return html;
case 'number':
return '<input type="number" name="' + name + '" class="flat" value="' + this.escapeHtml(value) + '"' + required + '>';
default:
return '<input type="text" name="' + name + '" class="flat minwidth200" value="' + this.escapeHtml(value) + '"' + required + '>';
}
},
saveEquipment: function() {
var self = this;
// Prevent double-click
if (self.isSaving) return;
var carrierId = $('input[name="equipment_carrier_id"]').val();
var equipmentId = $('input[name="equipment_id"]').val();
var typeId = $('#equipment_type_id').val();
var position = $('input[name="equipment_position"]').val();
var label = $('input[name="equipment_label"]').val();
var fkProtection = $('#equipment_fk_protection').val();
var protectionLabel = $('input[name="equipment_protection_label"]').val();
if (!typeId) {
KundenKarte.showAlert('Hinweis', 'Bitte wählen Sie einen Typ.');
return;
}
self.isSaving = true;
$('#equipment-save').prop('disabled', true);
// Collect field values
var fieldValues = {};
$('#equipment-dynamic-fields input, #equipment-dynamic-fields select').each(function() {
var name = $(this).attr('name');
if (name && name.startsWith('eq_field_')) {
var code = name.replace('eq_field_', '');
fieldValues[code] = $(this).val();
}
});
var action = equipmentId ? 'update' : 'create';
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: action,
carrier_id: carrierId,
equipment_id: equipmentId,
type_id: typeId,
label: label,
position_te: position,
fk_protection: fkProtection,
protection_label: protectionLabel,
field_values: JSON.stringify(fieldValues),
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
self.isSaving = false;
if (response.success) {
$('#kundenkarte-equipment-dialog').remove();
// Reload page to get fresh data
location.reload();
} else {
$('#equipment-save').prop('disabled', false);
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
self.isSaving = false;
$('#equipment-save').prop('disabled', false);
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
duplicateEquipment: function(equipmentId) {
var self = this;
if (self.isSaving) return;
self.isSaving = true;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'duplicate',
equipment_id: equipmentId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
self.isSaving = false;
if (response.success) {
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
self.isSaving = false;
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
deleteEquipment: function(equipmentId) {
var self = this;
// No confirm here - handled by caller
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'delete',
equipment_id: equipmentId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
self.showAlertDialog('Fehler', response.error);
}
}
});
},
moveEquipment: function(equipmentId, newPosition) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'move',
equipment_id: equipmentId,
position_te: newPosition,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
}
});
},
showEquipmentTooltip: function($block, data) {
var self = this;
var html = '<div class="kundenkarte-equipment-tooltip-header">';
html += '<strong>' + this.escapeHtml(data.type || '') + '</strong>';
if (data.label) {
html += '<br><span style="color:#666;">' + this.escapeHtml(data.label) + '</span>';
}
html += '</div>';
if (data.fields && Object.keys(data.fields).length > 0) {
html += '<div class="kundenkarte-equipment-tooltip-fields">';
for (var key in data.fields) {
if (data.fields[key]) {
html += '<div><span class="field-label">' + this.escapeHtml(key) + ':</span> ';
html += '<span class="field-value">' + this.escapeHtml(data.fields[key]) + '</span></div>';
}
}
html += '</div>';
}
var $tooltip = $('#kundenkarte-equipment-tooltip');
if (!$tooltip.length) {
$tooltip = $('<div id="kundenkarte-equipment-tooltip" class="kundenkarte-equipment-tooltip"></div>');
$('body').append($tooltip);
}
$tooltip.html(html);
// Position near the block
var offset = $block.offset ? $block.offset() : $(this).offset();
var rect = $block[0].getBoundingClientRect ? $block[0].getBoundingClientRect() : { right: 0, top: 0 };
$tooltip.css({
top: rect.top + window.scrollY + 10,
left: rect.right + window.scrollX + 10
}).addClass('visible');
},
hideEquipmentTooltip: function() {
$('#kundenkarte-equipment-tooltip').removeClass('visible');
},
// ==========================================
// CONNECTION METHODS (Stromverbindungen)
// ==========================================
showOutputDialog: function(carrierId) {
var self = this;
if ($('#kundenkarte-output-dialog').length) return;
// Get equipment on this carrier for dropdown
var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]');
var equipmentOptions = [];
$carrier.find('.kundenkarte-equipment-block').each(function() {
var $block = $(this);
var tooltipData = $block.data('tooltip');
var id = $block.data('equipment-id');
var label = tooltipData ? (tooltipData.label || tooltipData.type || 'Equipment ' + id) : 'Equipment ' + id;
equipmentOptions.push({ id: id, label: label });
});
var html = '<div id="kundenkarte-output-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>Abgang hinzufügen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div class="kundenkarte-connection-form">';
// Equipment selection
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Von Equipment</label>';
html += '<select name="output_equipment_id" id="output_equipment_id" class="flat">';
equipmentOptions.forEach(function(eq) {
html += '<option value="' + eq.id + '">' + self.escapeHtml(eq.label) + '</option>';
});
html += '</select>';
html += '</div>';
html += '</div>';
// Phase selection
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Phasen</label>';
html += '<div class="kundenkarte-phase-buttons">';
html += '<button type="button" class="kundenkarte-phase-btn phase-L1" data-phase="L1">L1</button>';
html += '<button type="button" class="kundenkarte-phase-btn phase-N" data-phase="N">N</button>';
html += '<button type="button" class="kundenkarte-phase-btn active" data-phase="L1N">L1+N</button>';
html += '<button type="button" class="kundenkarte-phase-btn" data-phase="L1L2L3">3P</button>';
html += '<button type="button" class="kundenkarte-phase-btn" data-phase="L1L2L3N">3P+N</button>';
html += '<button type="button" class="kundenkarte-phase-btn" data-phase="L1L2L3NPE">3P+N+PE</button>';
html += '</div>';
html += '<input type="hidden" name="output_connection_type" value="L1N">';
html += '</div>';
html += '</div>';
// Consumer label
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Verbraucher</label>';
html += '<input type="text" name="output_consumer_label" placeholder="z.B. Küche Steckdosen">';
html += '</div>';
html += '</div>';
// Cable info
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Kabeltyp</label>';
html += '<select name="output_cable_type">';
html += '<option value="">--</option>';
html += '<option value="NYM-J">NYM-J</option>';
html += '<option value="NYY-J">NYY-J</option>';
html += '<option value="H07V-K">H07V-K</option>';
html += '<option value="H07V-U">H07V-U</option>';
html += '<option value="H05VV-F">H05VV-F</option>';
html += '</select>';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Querschnitt</label>';
html += '<select name="output_cable_section">';
html += '<option value="">--</option>';
html += '<option value="3x1.5">3x1.5</option>';
html += '<option value="3x2.5">3x2.5</option>';
html += '<option value="5x1.5">5x1.5</option>';
html += '<option value="5x2.5">5x2.5</option>';
html += '<option value="5x4">5x4</option>';
html += '<option value="5x6">5x6</option>';
html += '<option value="5x10">5x10</option>';
html += '</select>';
html += '</div>';
html += '</div>';
html += '</div>'; // form
html += '</div>'; // body
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="output-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="output-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-output-dialog').addClass('visible');
// Phase button handlers
$('#kundenkarte-output-dialog .kundenkarte-phase-btn').on('click', function() {
$('#kundenkarte-output-dialog .kundenkarte-phase-btn').removeClass('active');
$(this).addClass('active');
$('input[name="output_connection_type"]').val($(this).data('phase'));
});
// Save
$('#output-save').on('click', function() {
self.saveOutput(carrierId);
});
// Close
$('#output-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-output-dialog').remove();
});
},
saveOutput: function(carrierId) {
var self = this;
var data = {
action: 'create_output',
carrier_id: carrierId,
equipment_id: $('select[name="output_equipment_id"]').val(),
connection_type: $('input[name="output_connection_type"]').val(),
output_label: $('input[name="output_consumer_label"]').val(),
medium_type: $('select[name="output_cable_type"]').val(),
medium_spec: $('select[name="output_cable_section"]').val(),
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-output-dialog').remove();
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
showRailDialog: function(carrierId) {
var self = this;
if ($('#kundenkarte-rail-dialog').length) return;
// Get carrier info
var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]');
var totalTE = 12;
var infoText = $carrier.find('.kundenkarte-carrier-info').text();
var match = infoText.match(/\/(\d+)/);
if (match) totalTE = parseInt(match[1]);
var html = '<div id="kundenkarte-rail-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3>Sammelschiene hinzufügen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div class="kundenkarte-connection-form">';
// Quick presets row
html += '<div class="form-row" style="margin-bottom:10px;">';
html += '<label style="width:100%;margin-bottom:5px;">Schnellauswahl:</label>';
html += '<div style="display:flex;flex-wrap:wrap;gap:5px;">';
// Electrical presets
html += '<button type="button" class="rail-preset-btn" data-type="L1" data-color="#8B4513" style="padding:3px 8px;font-size:11px;">L1</button>';
html += '<button type="button" class="rail-preset-btn" data-type="L1N" data-color="#3498db" style="padding:3px 8px;font-size:11px;">L1N</button>';
html += '<button type="button" class="rail-preset-btn" data-type="3P" data-color="#2c3e50" style="padding:3px 8px;font-size:11px;">3P</button>';
html += '<button type="button" class="rail-preset-btn" data-type="3P+N" data-color="#2c3e50" style="padding:3px 8px;font-size:11px;">3P+N</button>';
html += '<button type="button" class="rail-preset-btn" data-type="N" data-color="#0066cc" style="padding:3px 8px;font-size:11px;">N</button>';
html += '<button type="button" class="rail-preset-btn" data-type="PE" data-color="#27ae60" style="padding:3px 8px;font-size:11px;">PE</button>';
html += '|';
// Network presets
html += '<button type="button" class="rail-preset-btn" data-type="CAT6" data-color="#9b59b6" style="padding:3px 8px;font-size:11px;">CAT6</button>';
html += '<button type="button" class="rail-preset-btn" data-type="LWL" data-color="#f39c12" style="padding:3px 8px;font-size:11px;">LWL</button>';
html += '<button type="button" class="rail-preset-btn" data-type="Koax" data-color="#e74c3c" style="padding:3px 8px;font-size:11px;">Koax</button>';
html += '</div>';
html += '</div>';
// Connection type (free text)
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Bezeichnung</label>';
html += '<input type="text" name="rail_connection_type" value="" placeholder="z.B. L1N, CAT6, BUS, Klingel">';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Farbe</label>';
html += '<input type="color" name="rail_color" value="#3498db">';
html += '</div>';
html += '</div>';
// Position (above/below)
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Position</label>';
html += '<select name="rail_position">';
html += '<option value="below">Unterhalb (Standard)</option>';
html += '<option value="above">Oberhalb</option>';
html += '</select>';
html += '</div>';
html += '</div>';
// TE range
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Von TE</label>';
html += '<input type="number" name="rail_start_te" value="1" min="1" max="' + totalTE + '">';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Bis TE</label>';
html += '<input type="number" name="rail_end_te" value="' + totalTE + '" min="1" max="' + totalTE + '">';
html += '</div>';
html += '</div>';
// Multi-phase rail options
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Sammelschiene</label>';
html += '<select name="rail_phases">';
html += '<option value="">Einfache Linie</option>';
html += '<option value="L1">L1 (einphasig)</option>';
html += '<option value="L1N">L1+N (einphasig)</option>';
html += '<option value="3P">3P (L1/L2/L3)</option>';
html += '<option value="3P+N">3P+N (L1/L2/L3/N)</option>';
html += '</select>';
html += '</div>';
html += '</div>';
// Excluded positions (for FI switches etc.)
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;">';
html += '<label>Ausgenommene TE (z.B. für FI)</label>';
html += '<input type="text" name="rail_excluded_te" value="" placeholder="z.B. 3,4,7 (kommagetrennt)">';
html += '<small style="color:#666;font-size:11px;">An diesen Positionen wird die Schiene unterbrochen</small>';
html += '</div>';
html += '</div>';
html += '</div>'; // form
html += '</div>'; // body
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="rail-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="rail-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-rail-dialog').addClass('visible');
// Preset button handlers
$('#kundenkarte-rail-dialog .rail-preset-btn').on('click', function() {
var type = $(this).data('type');
$('input[name="rail_connection_type"]').val(type);
$('input[name="rail_color"]').val($(this).data('color'));
// Auto-set rail_phases for electrical presets
var phasesSelect = $('select[name="rail_phases"]');
if (type === 'L1') phasesSelect.val('L1');
else if (type === 'L1N') phasesSelect.val('L1N');
else if (type === '3P') phasesSelect.val('3P');
else if (type === '3P+N') phasesSelect.val('3P+N');
else phasesSelect.val(''); // Simple line for N, PE, network cables
});
// Save
$('#rail-save').on('click', function() {
self.saveRail(carrierId);
});
// Close
$('#rail-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-rail-dialog').remove();
});
},
saveRail: function(carrierId) {
var self = this;
// position_y: -1 = above equipment, 0+ = below equipment
var positionY = $('select[name="rail_position"]').val() === 'above' ? -1 : 0;
var data = {
action: 'create_rail',
carrier_id: carrierId,
connection_type: $('input[name="rail_connection_type"]').val(),
color: $('input[name="rail_color"]').val(),
rail_start_te: $('input[name="rail_start_te"]').val(),
rail_end_te: $('input[name="rail_end_te"]').val(),
rail_phases: $('select[name="rail_phases"]').val(),
excluded_te: $('input[name="rail_excluded_te"]').val(),
position_y: positionY,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-rail-dialog').remove();
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
showEditRailDialog: function(connectionId, carrierId) {
var self = this;
if ($('#kundenkarte-rail-dialog').length) return;
// Load connection data first
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'get', connection_id: connectionId },
dataType: 'json',
success: function(response) {
if (response.success && response.connection) {
self.renderEditRailDialog(connectionId, carrierId, response.connection);
} else {
KundenKarte.showAlert('Fehler', 'Verbindung nicht gefunden');
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
renderEditRailDialog: function(connectionId, carrierId, conn) {
var self = this;
// Get carrier info
var $carrier = $('.kundenkarte-carrier[data-carrier-id="' + carrierId + '"]');
var totalTE = 12;
var infoText = $carrier.find('.kundenkarte-carrier-info').text();
var match = infoText.match(/\/(\d+)/);
if (match) totalTE = parseInt(match[1]);
var isAbove = conn.position_y < 0;
var html = '<div id="kundenkarte-rail-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3>Sammelschiene bearbeiten</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<input type="hidden" name="rail_connection_id" value="' + connectionId + '">';
html += '<div class="kundenkarte-connection-form">';
// Quick presets row
html += '<div class="form-row" style="margin-bottom:10px;">';
html += '<label style="width:100%;margin-bottom:5px;">Schnellauswahl:</label>';
html += '<div style="display:flex;flex-wrap:wrap;gap:5px;">';
html += '<button type="button" class="rail-preset-btn" data-type="L1" data-color="#8B4513" style="padding:3px 8px;font-size:11px;">L1</button>';
html += '<button type="button" class="rail-preset-btn" data-type="L1N" data-color="#3498db" style="padding:3px 8px;font-size:11px;">L1N</button>';
html += '<button type="button" class="rail-preset-btn" data-type="3P" data-color="#2c3e50" style="padding:3px 8px;font-size:11px;">3P</button>';
html += '<button type="button" class="rail-preset-btn" data-type="3P+N" data-color="#2c3e50" style="padding:3px 8px;font-size:11px;">3P+N</button>';
html += '<button type="button" class="rail-preset-btn" data-type="N" data-color="#0066cc" style="padding:3px 8px;font-size:11px;">N</button>';
html += '<button type="button" class="rail-preset-btn" data-type="PE" data-color="#27ae60" style="padding:3px 8px;font-size:11px;">PE</button>';
html += '|';
html += '<button type="button" class="rail-preset-btn" data-type="CAT6" data-color="#9b59b6" style="padding:3px 8px;font-size:11px;">CAT6</button>';
html += '<button type="button" class="rail-preset-btn" data-type="LWL" data-color="#f39c12" style="padding:3px 8px;font-size:11px;">LWL</button>';
html += '<button type="button" class="rail-preset-btn" data-type="Koax" data-color="#e74c3c" style="padding:3px 8px;font-size:11px;">Koax</button>';
html += '</div>';
html += '</div>';
// Connection type (free text)
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Bezeichnung</label>';
html += '<input type="text" name="rail_connection_type" value="' + self.escapeHtml(conn.connection_type || '') + '" placeholder="z.B. L1N, CAT6, BUS, Klingel">';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Farbe</label>';
html += '<input type="color" name="rail_color" value="' + (conn.color || '#3498db') + '">';
html += '</div>';
html += '</div>';
// Position (above/below)
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Position</label>';
html += '<select name="rail_position">';
html += '<option value="below"' + (!isAbove ? ' selected' : '') + '>Unterhalb</option>';
html += '<option value="above"' + (isAbove ? ' selected' : '') + '>Oberhalb</option>';
html += '</select>';
html += '</div>';
html += '</div>';
// TE range
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Von TE</label>';
html += '<input type="number" name="rail_start_te" value="' + (conn.rail_start_te || 1) + '" min="1" max="' + totalTE + '">';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Bis TE</label>';
html += '<input type="number" name="rail_end_te" value="' + (conn.rail_end_te || totalTE) + '" min="1" max="' + totalTE + '">';
html += '</div>';
html += '</div>';
// Multi-phase rail options
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Sammelschiene</label>';
html += '<select name="rail_phases">';
html += '<option value=""' + (!conn.rail_phases ? ' selected' : '') + '>Einfache Linie</option>';
html += '<option value="L1"' + (conn.rail_phases === 'L1' ? ' selected' : '') + '>L1 (einphasig)</option>';
html += '<option value="L1N"' + (conn.rail_phases === 'L1N' ? ' selected' : '') + '>L1+N (einphasig)</option>';
html += '<option value="3P"' + (conn.rail_phases === '3P' ? ' selected' : '') + '>3P (L1/L2/L3)</option>';
html += '<option value="3P+N"' + (conn.rail_phases === '3P+N' ? ' selected' : '') + '>3P+N (L1/L2/L3/N)</option>';
html += '</select>';
html += '</div>';
html += '</div>';
// Excluded positions (for FI switches etc.)
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;">';
html += '<label>Ausgenommene TE (z.B. für FI)</label>';
html += '<input type="text" name="rail_excluded_te" value="' + self.escapeHtml(conn.excluded_te || '') + '" placeholder="z.B. 3,4,7 (kommagetrennt)">';
html += '<small style="color:#666;font-size:11px;">An diesen Positionen wird die Schiene unterbrochen</small>';
html += '</div>';
html += '</div>';
html += '</div>'; // form
html += '</div>'; // body
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="rail-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="rail-delete" style="background:#c0392b;color:#fff;"><i class="fa fa-trash"></i> Löschen</button> ';
html += '<button type="button" class="button" id="rail-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-rail-dialog').addClass('visible');
// Preset button handlers
$('#kundenkarte-rail-dialog .rail-preset-btn').on('click', function() {
var type = $(this).data('type');
$('input[name="rail_connection_type"]').val(type);
$('input[name="rail_color"]').val($(this).data('color'));
// Auto-set rail_phases for electrical presets
var phasesSelect = $('select[name="rail_phases"]');
if (type === 'L1') phasesSelect.val('L1');
else if (type === 'L1N') phasesSelect.val('L1N');
else if (type === '3P') phasesSelect.val('3P');
else if (type === '3P+N') phasesSelect.val('3P+N');
else phasesSelect.val(''); // Simple line for N, PE, network cables
});
// Save
$('#rail-save').on('click', function() {
self.updateRail(connectionId, carrierId);
});
// Delete
$('#rail-delete').on('click', function() {
$('#kundenkarte-rail-dialog').remove();
self.showConfirmDialog('Sammelschiene löschen', 'Möchten Sie diese Sammelschiene wirklich löschen?', function() {
self.doDeleteConnection(connectionId);
});
});
// Close
$('#rail-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-rail-dialog').remove();
});
},
updateRail: function(connectionId, carrierId) {
var self = this;
var positionY = $('select[name="rail_position"]').val() === 'above' ? -1 : 0;
var data = {
action: 'update',
connection_id: connectionId,
carrier_id: carrierId,
connection_type: $('input[name="rail_connection_type"]').val(),
color: $('input[name="rail_color"]').val(),
rail_start_te: $('input[name="rail_start_te"]').val(),
rail_end_te: $('input[name="rail_end_te"]').val(),
rail_phases: $('select[name="rail_phases"]').val(),
excluded_te: $('input[name="rail_excluded_te"]').val(),
position_y: positionY,
is_rail: 1,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-rail-dialog').remove();
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
showEditOutputDialog: function(connectionId, carrierId) {
var self = this;
if ($('#kundenkarte-output-dialog').length) return;
// Load connection data first
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'get', connection_id: connectionId },
dataType: 'json',
success: function(response) {
if (response.success && response.connection) {
self.renderEditOutputDialog(connectionId, carrierId, response.connection);
} else {
KundenKarte.showAlert('Fehler', 'Verbindung nicht gefunden');
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
renderEditOutputDialog: function(connectionId, carrierId, conn) {
var self = this;
var html = '<div id="kundenkarte-output-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3>Abgang bearbeiten</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<input type="hidden" name="output_connection_id" value="' + connectionId + '">';
html += '<input type="hidden" name="output_fk_source" value="' + (conn.fk_source || '') + '">';
html += '<div class="kundenkarte-connection-form">';
// Quick presets
html += '<div class="form-row" style="margin-bottom:10px;">';
html += '<label style="width:100%;margin-bottom:5px;">Schnellauswahl Typ:</label>';
html += '<div style="display:flex;flex-wrap:wrap;gap:5px;">';
html += '<button type="button" class="output-preset-btn" data-type="L1N" data-color="#3498db" style="padding:3px 8px;font-size:11px;">L1N</button>';
html += '<button type="button" class="output-preset-btn" data-type="3P" data-color="#2c3e50" style="padding:3px 8px;font-size:11px;">3P</button>';
html += '<button type="button" class="output-preset-btn" data-type="3P+N" data-color="#2c3e50" style="padding:3px 8px;font-size:11px;">3P+N</button>';
html += '|';
html += '<button type="button" class="output-preset-btn" data-type="CAT6" data-color="#9b59b6" style="padding:3px 8px;font-size:11px;">CAT6</button>';
html += '<button type="button" class="output-preset-btn" data-type="LWL" data-color="#f39c12" style="padding:3px 8px;font-size:11px;">LWL</button>';
html += '</div>';
html += '</div>';
// Output label (consumer)
html += '<div class="form-row">';
html += '<div class="form-group" style="flex:2;">';
html += '<label>Ziel/Verbraucher</label>';
html += '<input type="text" name="output_label" value="' + self.escapeHtml(conn.output_label || '') + '" placeholder="z.B. Küche Steckdosen, Bad Licht">';
html += '</div>';
html += '</div>';
// Connection type and color
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Typ</label>';
html += '<input type="text" name="output_connection_type" value="' + self.escapeHtml(conn.connection_type || '') + '" placeholder="z.B. L1N">';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Farbe</label>';
html += '<input type="color" name="output_color" value="' + (conn.color || '#3498db') + '">';
html += '</div>';
html += '</div>';
// Medium info
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Kabeltyp</label>';
html += '<input type="text" name="output_medium_type" value="' + self.escapeHtml(conn.medium_type || '') + '" placeholder="z.B. NYM-J">';
html += '</div>';
html += '<div class="form-group">';
html += '<label>Querschnitt</label>';
html += '<input type="text" name="output_medium_spec" value="' + self.escapeHtml(conn.medium_spec || '') + '" placeholder="z.B. 3x1.5">';
html += '</div>';
html += '</div>';
// Length
html += '<div class="form-row">';
html += '<div class="form-group">';
html += '<label>Länge</label>';
html += '<input type="text" name="output_medium_length" value="' + self.escapeHtml(conn.medium_length || '') + '" placeholder="z.B. ca. 15m">';
html += '</div>';
html += '</div>';
html += '</div>'; // form
html += '</div>'; // body
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="output-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="output-delete" style="background:#c0392b;color:#fff;"><i class="fa fa-trash"></i> Löschen</button> ';
html += '<button type="button" class="button" id="output-cancel">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-output-dialog').addClass('visible');
// Preset button handlers
$('#kundenkarte-output-dialog .output-preset-btn').on('click', function() {
$('input[name="output_connection_type"]').val($(this).data('type'));
$('input[name="output_color"]').val($(this).data('color'));
});
// Save
$('#output-save').on('click', function() {
self.updateOutput(connectionId, carrierId);
});
// Delete
$('#output-delete').on('click', function() {
$('#kundenkarte-output-dialog').remove();
self.showConfirmDialog('Abgang löschen', 'Möchten Sie diesen Abgang wirklich löschen?', function() {
self.doDeleteConnection(connectionId);
});
});
// Close
$('#output-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-output-dialog').remove();
});
},
updateOutput: function(connectionId, carrierId) {
var self = this;
var data = {
action: 'update',
connection_id: connectionId,
carrier_id: carrierId,
fk_source: $('input[name="output_fk_source"]').val(),
source_terminal: 'output',
connection_type: $('input[name="output_connection_type"]').val(),
color: $('input[name="output_color"]').val(),
output_label: $('input[name="output_label"]').val(),
medium_type: $('input[name="output_medium_type"]').val(),
medium_spec: $('input[name="output_medium_spec"]').val(),
medium_length: $('input[name="output_medium_length"]').val(),
is_rail: 0,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-output-dialog').remove();
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
deleteConnection: function(connectionId) {
var self = this;
self.showConfirmDialog('Verbindung löschen', 'Möchten Sie diese Verbindung wirklich löschen?', function() {
self.doDeleteConnection(connectionId);
});
},
// Internal function to delete connection without confirmation
doDeleteConnection: function(connectionId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'delete',
connection_id: connectionId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
self.showAlertDialog('Fehler', response.error);
}
}
});
},
// Custom confirm dialog (replaces browser confirm())
showConfirmDialog: function(title, message, onConfirm, onCancel) {
var self = this;
// Remove any existing confirm dialog
$('#kundenkarte-confirm-dialog').remove();
var html = '<div id="kundenkarte-confirm-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + self.escapeHtml(title) + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
html += '<p style="margin:0;font-size:14px;">' + self.escapeHtml(message) + '</p>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="button" id="confirm-yes" style="background:#c0392b;color:#fff;"><i class="fa fa-check"></i> Ja</button>';
html += '<button type="button" class="button" id="confirm-no"><i class="fa fa-times"></i> Nein</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-confirm-dialog').addClass('visible');
// Focus yes button
$('#confirm-yes').focus();
// Yes button
$('#confirm-yes').on('click', function() {
$('#kundenkarte-confirm-dialog').remove();
if (typeof onConfirm === 'function') {
onConfirm();
}
});
// No button and close
$('#confirm-no, #kundenkarte-confirm-dialog .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-confirm-dialog').remove();
if (typeof onCancel === 'function') {
onCancel();
}
});
// ESC key
$(document).one('keydown.confirmDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-confirm-dialog').remove();
if (typeof onCancel === 'function') {
onCancel();
}
} else if (e.key === 'Enter') {
$('#kundenkarte-confirm-dialog').remove();
if (typeof onConfirm === 'function') {
onConfirm();
}
}
});
},
// Custom alert dialog (replaces browser alert())
showAlertDialog: function(title, message, onClose) {
var self = this;
// Remove any existing alert dialog
$('#kundenkarte-alert-dialog').remove();
var html = '<div id="kundenkarte-alert-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:400px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + self.escapeHtml(title) + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
html += '<p style="margin:0;font-size:14px;">' + self.escapeHtml(message) + '</p>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;justify-content:flex-end;">';
html += '<button type="button" class="button" id="alert-ok"><i class="fa fa-check"></i> OK</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-alert-dialog').addClass('visible');
$('#alert-ok').focus();
var closeDialog = function() {
$('#kundenkarte-alert-dialog').remove();
$(document).off('keydown.alertDialog');
if (typeof onClose === 'function') {
onClose();
}
};
$('#alert-ok, #kundenkarte-alert-dialog .kundenkarte-modal-close').on('click', closeDialog);
$(document).on('keydown.alertDialog', function(e) {
if (e.key === 'Escape' || e.key === 'Enter') {
closeDialog();
}
});
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// BOM (Bill of Materials) / Stückliste Dialog
showBOMDialog: function() {
var self = this;
var anlageId = this.anlageId;
// Show loading
var html = '<div id="kundenkarte-bom-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:900px;max-height:80vh;">';
html += '<div class="kundenkarte-modal-header" style="background:#9b59b6;">';
html += '<h3 style="color:#fff;"><i class="fa fa-list-alt"></i> Stückliste (BOM)</h3>';
html += '<span class="kundenkarte-modal-close" style="color:#fff;">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;overflow:auto;max-height:calc(80vh - 130px);">';
html += '<div class="bom-loading" style="text-align:center;padding:40px;"><i class="fa fa-spinner fa-spin fa-2x"></i><br>Lade Stückliste...</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer" style="display:flex;justify-content:space-between;">';
html += '<span class="bom-totals" style="color:#888;"></span>';
html += '<button type="button" class="button" id="bom-close"><i class="fa fa-times"></i> Schließen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-bom-dialog').addClass('visible');
// Close handler
$('#bom-close, #kundenkarte-bom-dialog .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-bom-dialog').remove();
});
$(document).on('keydown.bomDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-bom-dialog').remove();
$(document).off('keydown.bomDialog');
}
});
// Load BOM data
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/bom_generator.php',
data: { action: 'generate', anlage_id: anlageId },
dataType: 'json',
success: function(response) {
if (response.success) {
self.renderBOMContent(response);
} else {
$('.bom-loading').html('<div style="color:#e74c3c;"><i class="fa fa-exclamation-triangle"></i> ' + (response.error || 'Fehler beim Laden') + '</div>');
}
},
error: function() {
$('.bom-loading').html('<div style="color:#e74c3c;"><i class="fa fa-exclamation-triangle"></i> AJAX Fehler</div>');
}
});
},
renderBOMContent: function(data) {
var self = this;
var $body = $('#kundenkarte-bom-dialog .kundenkarte-modal-body');
if (!data.summary || data.summary.length === 0) {
$body.html('<div style="text-align:center;padding:40px;color:#888;"><i class="fa fa-info-circle fa-2x"></i><br><br>Keine Materialien im Schaltplan gefunden.<br>Fügen Sie Equipment hinzu oder verknüpfen Sie Produkte.</div>');
return;
}
var html = '';
// Summary table (grouped by product)
html += '<h4 style="margin:0 0 15px 0;color:#9b59b6;"><i class="fa fa-cubes"></i> Zusammenfassung nach Produkt</h4>';
html += '<table class="noborder centpercent" style="font-size:13px;">';
html += '<tr class="liste_titre">';
html += '<th>Referenz</th>';
html += '<th>Bezeichnung</th>';
html += '<th class="right">Menge</th>';
html += '<th class="right">Einzelpreis</th>';
html += '<th class="right">Gesamt</th>';
html += '</tr>';
var totalWithPrice = 0;
var totalQuantity = 0;
data.summary.forEach(function(item, index) {
var rowClass = index % 2 === 0 ? 'oddeven' : 'oddeven';
var hasProduct = item.product_id ? true : false;
var priceCell = hasProduct && item.price ? self.formatPrice(item.price) + ' €' : '-';
var totalCell = hasProduct && item.total ? self.formatPrice(item.total) + ' €' : '-';
html += '<tr class="' + rowClass + '">';
html += '<td>' + self.escapeHtml(item.product_ref || '-') + '</td>';
html += '<td>' + self.escapeHtml(item.product_label || '-');
if (!hasProduct) {
html += ' <span style="color:#e74c3c;font-size:11px;">(kein Produkt verknüpft)</span>';
}
html += '</td>';
html += '<td class="right"><strong>' + item.quantity + '</strong></td>';
html += '<td class="right">' + priceCell + '</td>';
html += '<td class="right">' + totalCell + '</td>';
html += '</tr>';
totalQuantity += item.quantity;
if (hasProduct && item.total) {
totalWithPrice += item.total;
}
});
// Totals row
html += '<tr style="background:#f5f5f5;font-weight:bold;">';
html += '<td colspan="2">Summe</td>';
html += '<td class="right">' + totalQuantity + ' Stück</td>';
html += '<td></td>';
html += '<td class="right">' + self.formatPrice(totalWithPrice) + ' €</td>';
html += '</tr>';
html += '</table>';
// Detailed list (collapsible)
if (data.items && data.items.length > 0) {
html += '<details style="margin-top:20px;">';
html += '<summary style="cursor:pointer;color:#3498db;font-weight:bold;"><i class="fa fa-list"></i> Detailliste (' + data.items.length + ' Einträge)</summary>';
html += '<table class="noborder centpercent" style="font-size:12px;margin-top:10px;">';
html += '<tr class="liste_titre">';
html += '<th>Equipment</th>';
html += '<th>Typ</th>';
html += '<th>Feld/Hutschiene</th>';
html += '<th>Breite</th>';
html += '<th>Produkt</th>';
html += '</tr>';
data.items.forEach(function(item, index) {
var rowClass = index % 2 === 0 ? 'oddeven' : 'oddeven';
html += '<tr class="' + rowClass + '">';
html += '<td>' + self.escapeHtml(item.equipment_label || '-') + '</td>';
html += '<td><span style="color:#888;">' + self.escapeHtml(item.type_ref || '') + '</span> ' + self.escapeHtml(item.type_label || '-') + '</td>';
html += '<td>' + self.escapeHtml(item.panel_label || '-') + ' / ' + self.escapeHtml(item.carrier_label || '-') + '</td>';
html += '<td class="center">' + (item.width_te || 1) + ' TE</td>';
html += '<td>' + (item.product_ref ? self.escapeHtml(item.product_ref) : '<em style="color:#888;">-</em>') + '</td>';
html += '</tr>';
});
html += '</table>';
html += '</details>';
}
// Export buttons
html += '<div style="margin-top:20px;display:flex;gap:10px;">';
html += '<button type="button" class="button bom-copy-clipboard"><i class="fa fa-clipboard"></i> In Zwischenablage kopieren</button>';
html += '</div>';
$body.html(html);
// Update totals in footer
$('.bom-totals').html('<strong>' + totalQuantity + '</strong> Artikel | <strong>' + self.formatPrice(totalWithPrice) + ' €</strong> (geschätzt)');
// Copy to clipboard
$body.find('.bom-copy-clipboard').on('click', function() {
var text = 'Stückliste\n\n';
text += 'Referenz\tBezeichnung\tMenge\tEinzelpreis\tGesamt\n';
data.summary.forEach(function(item) {
text += (item.product_ref || '-') + '\t';
text += (item.product_label || '-') + '\t';
text += item.quantity + '\t';
text += (item.price ? self.formatPrice(item.price) + ' €' : '-') + '\t';
text += (item.total ? self.formatPrice(item.total) + ' €' : '-') + '\n';
});
text += '\nSumme:\t\t' + totalQuantity + ' Stück\t\t' + self.formatPrice(totalWithPrice) + ' €';
navigator.clipboard.writeText(text).then(function() {
KundenKarte.showNotification('In Zwischenablage kopiert', 'success');
}).catch(function() {
KundenKarte.showError('Fehler', 'Kopieren nicht möglich');
});
});
},
formatPrice: function(price) {
if (!price) return '0,00';
return parseFloat(price).toFixed(2).replace('.', ',');
},
// Audit Log Dialog
showAuditLogDialog: function() {
var self = this;
var anlageId = this.anlageId;
var html = '<div id="kundenkarte-audit-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:800px;max-height:80vh;">';
html += '<div class="kundenkarte-modal-header" style="background:#34495e;">';
html += '<h3 style="color:#fff;"><i class="fa fa-history"></i> Änderungsprotokoll</h3>';
html += '<span class="kundenkarte-modal-close" style="color:#fff;">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;overflow:auto;max-height:calc(80vh - 130px);">';
html += '<div class="audit-loading" style="text-align:center;padding:40px;"><i class="fa fa-spinner fa-spin fa-2x"></i><br>Lade Protokoll...</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="audit-close"><i class="fa fa-times"></i> Schließen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
$('#kundenkarte-audit-dialog').addClass('visible');
// Close handler
$('#audit-close, #kundenkarte-audit-dialog .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-audit-dialog').remove();
});
$(document).on('keydown.auditDialog', function(e) {
if (e.key === 'Escape') {
$('#kundenkarte-audit-dialog').remove();
$(document).off('keydown.auditDialog');
}
});
// Load audit data
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/audit_log.php',
data: { action: 'fetch_anlage', anlage_id: anlageId, limit: 100 },
dataType: 'json',
success: function(response) {
if (response.success) {
self.renderAuditContent(response.logs);
} else {
$('.audit-loading').html('<div style="color:#e74c3c;"><i class="fa fa-exclamation-triangle"></i> ' + (response.error || 'Fehler beim Laden') + '</div>');
}
},
error: function() {
$('.audit-loading').html('<div style="color:#e74c3c;"><i class="fa fa-exclamation-triangle"></i> AJAX Fehler</div>');
}
});
},
renderAuditContent: function(logs) {
var self = this;
var $body = $('#kundenkarte-audit-dialog .kundenkarte-modal-body');
if (!logs || logs.length === 0) {
$body.html('<div style="text-align:center;padding:40px;color:#888;"><i class="fa fa-info-circle fa-2x"></i><br><br>Keine Änderungen protokolliert.</div>');
return;
}
var html = '<div class="audit-timeline">';
logs.forEach(function(log) {
html += '<div class="audit-entry" style="display:flex;gap:15px;padding:10px 0;border-bottom:1px solid #eee;">';
html += '<div class="audit-icon" style="flex:0 0 40px;text-align:center;">';
html += '<i class="fa ' + log.action_icon + '" style="font-size:20px;color:' + log.action_color + ';"></i>';
html += '</div>';
html += '<div class="audit-content" style="flex:1;">';
html += '<div class="audit-header" style="display:flex;justify-content:space-between;margin-bottom:5px;">';
html += '<strong style="color:' + log.action_color + ';">' + self.escapeHtml(log.action_label) + '</strong>';
html += '<span style="color:#888;font-size:12px;">' + self.escapeHtml(log.date_action) + '</span>';
html += '</div>';
html += '<div style="color:#666;">';
html += '<span style="background:#f5f5f5;padding:2px 6px;border-radius:3px;font-size:11px;">' + self.escapeHtml(log.object_type_label) + '</span> ';
html += self.escapeHtml(log.object_ref || 'ID ' + log.object_id);
html += '</div>';
if (log.field_changed) {
html += '<div style="margin-top:5px;font-size:12px;color:#888;">';
html += 'Feld: ' + self.escapeHtml(log.field_changed);
if (log.old_value || log.new_value) {
html += ' (<em>' + self.escapeHtml(log.old_value || '-') + '</em> → <em>' + self.escapeHtml(log.new_value || '-') + '</em>)';
}
html += '</div>';
}
if (log.note) {
html += '<div style="margin-top:5px;font-size:12px;font-style:italic;color:#666;">' + self.escapeHtml(log.note) + '</div>';
}
html += '<div style="margin-top:5px;font-size:11px;color:#aaa;">';
html += '<i class="fa fa-user"></i> ' + self.escapeHtml(log.user_name || log.user_login);
html += '</div>';
html += '</div></div>';
});
html += '</div>';
$body.html(html);
}
};
/**
* Connection Editor Component
* Interactive SVG-based connection editor with orthogonal routing
* Supports busbars, multi-phase connections, and drag-drop
*/
KundenKarte.ConnectionEditor = {
TE_WIDTH: 50,
BLOCK_HEIGHT: 110,
BUSBAR_HEIGHT: 25,
BUSBAR_SPACING: 8,
CONNECTION_AREA_HEIGHT: 120,
ENDPOINT_RADIUS: 6,
// Phase colors (German electrical standard)
PHASE_COLORS: {
'L1': '#8B4513', // Brown
'L2': '#000000', // Black
'L3': '#808080', // Gray
'N': '#0066cc', // Blue
'PE': '#27ae60', // Green-Yellow
'LN': '#8B4513', // Single phase output (brown - phase determined by busbar)
'L1N': '#3498db', // L1 + N input (legacy/busbar)
'3P': '#2c3e50', // 3-phase
'3P+N': '#34495e', // 3-phase + neutral
'DATA': '#9b59b6' // Data cable (purple)
},
// State
isExpanded: false,
currentCarrierId: null,
currentAnlageId: null,
connections: [],
busbars: [],
equipment: [],
dragState: null,
selectedConnection: null,
DEBUG: false, // Set to true to enable debug logging
// Conditional logging
log: function() {
if (this.DEBUG && console && console.log) {
console.log.apply(console, arguments);
}
},
init: function(anlageId) {
this.log('ConnectionEditor.init called with anlageId:', anlageId);
if (!anlageId) {
this.log('ConnectionEditor: No anlageId, aborting');
return;
}
this.currentAnlageId = anlageId;
// Restore expanded state from localStorage
var savedState = localStorage.getItem('kundenkarte_connection_editor_expanded');
this.isExpanded = savedState === 'true';
this.log('ConnectionEditor: isExpanded =', this.isExpanded);
this.bindEvents();
this.log('ConnectionEditor: Events bound');
this.renderAllEditors();
this.log('ConnectionEditor: Editors rendered');
},
bindEvents: function() {
var self = this;
// Toggle editor visibility
$(document).on('click', '.kundenkarte-connection-editor-toggle', function(e) {
e.preventDefault();
var $editor = $(this).closest('.kundenkarte-carrier').find('.kundenkarte-connection-editor');
var carrierId = $(this).closest('.kundenkarte-carrier').data('carrier-id');
$editor.toggleClass('expanded');
$(this).find('i').toggleClass('fa-chevron-down fa-chevron-up');
// Save state
self.isExpanded = $editor.hasClass('expanded');
localStorage.setItem('kundenkarte_connection_editor_expanded', self.isExpanded);
if (self.isExpanded) {
self.loadAndRenderEditor(carrierId);
}
});
// Add busbar button
$(document).on('click', '.kundenkarte-add-busbar-btn', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.showBusbarDialog(carrierId);
});
// Add connection button
$(document).on('click', '.kundenkarte-add-conn-btn', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.showConnectionDialog(carrierId);
});
// SVG mouse events for drag-drop connections
$(document).on('mousedown', '.kundenkarte-endpoint', function(e) {
e.preventDefault();
e.stopPropagation();
var $endpoint = $(this);
var carrierId = $endpoint.closest('.kundenkarte-connection-editor').data('carrier-id');
self.startDragConnection(e, $endpoint, carrierId);
});
$(document).on('mousemove', function(e) {
if (self.dragState) {
self.updateDragConnection(e);
}
});
$(document).on('mouseup', function(e) {
if (self.dragState) {
self.endDragConnection(e);
}
});
// Click on busbar to edit
$(document).on('click', '.kundenkarte-busbar-element', function(e) {
e.preventDefault();
e.stopPropagation();
var connectionId = $(this).data('connection-id');
var carrierId = $(this).closest('.kundenkarte-connection-editor').data('carrier-id');
self.showEditBusbarDialog(connectionId, carrierId);
});
// Click on connection line to edit/delete
$(document).on('click', '.kundenkarte-connection-path', function(e) {
e.preventDefault();
e.stopPropagation();
var connectionId = $(this).data('connection-id');
var carrierId = $(this).closest('.kundenkarte-connection-editor').data('carrier-id');
self.showEditConnectionDialog(connectionId, carrierId);
});
// Hover effects
$(document).on('mouseenter', '.kundenkarte-endpoint', function() {
$(this).addClass('hover');
});
$(document).on('mouseleave', '.kundenkarte-endpoint', function() {
$(this).removeClass('hover');
});
},
renderAllEditors: function() {
var self = this;
$('.kundenkarte-carrier').each(function() {
var carrierId = $(this).data('carrier-id');
var $editor = $(this).find('.kundenkarte-connection-editor');
// Editor is already rendered via PHP, just need to initialize state
if ($editor.length) {
if (self.isExpanded) {
$editor.addClass('expanded');
$(this).find('.kundenkarte-connection-editor-toggle i').removeClass('fa-chevron-down').addClass('fa-chevron-up');
self.loadAndRenderEditor(carrierId);
}
}
});
},
loadAndRenderEditor: function(carrierId) {
var self = this;
// Load equipment for this carrier
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'list', carrier_id: carrierId },
dataType: 'json',
success: function(response) {
if (response.success) {
self.equipment = response.equipment || [];
// Load connections
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'list', carrier_id: carrierId },
dataType: 'json',
success: function(connResponse) {
if (connResponse.success) {
self.connections = connResponse.connections || [];
self.busbars = self.connections.filter(function(c) { return c.is_rail == 1; });
self.renderEditor(carrierId);
}
}
});
}
}
});
},
renderEditor: function(carrierId) {
var self = this;
var $editor = $('.kundenkarte-connection-editor[data-carrier-id="' + carrierId + '"]');
var $svg = $editor.find('.kundenkarte-connection-svg');
var totalTE = parseInt($editor.data('total-te')) || 12;
var totalWidth = totalTE * this.TE_WIDTH;
// Reset busbar positions
this.busbarPositions = {};
var svgContent = '';
// Defs for markers and gradients
svgContent += '<defs>';
svgContent += '<marker id="arrowhead-' + carrierId + '" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">';
svgContent += '<polygon points="0 0, 10 3.5, 0 7" fill="#888"/>';
svgContent += '</marker>';
// Phase gradients
Object.keys(this.PHASE_COLORS).forEach(function(phase) {
svgContent += '<linearGradient id="grad-' + phase + '-' + carrierId + '" x1="0%" y1="0%" x2="0%" y2="100%">';
svgContent += '<stop offset="0%" style="stop-color:' + self.PHASE_COLORS[phase] + ';stop-opacity:1" />';
svgContent += '<stop offset="100%" style="stop-color:' + self.darkenColor(self.PHASE_COLORS[phase], 20) + ';stop-opacity:1" />';
svgContent += '</linearGradient>';
});
svgContent += '</defs>';
// If no busbars exist, show a demo
if (this.busbars.length === 0 && this.equipment.length > 0) {
svgContent += this.renderDemoView(carrierId, totalTE, totalWidth);
$svg.html(svgContent);
$svg.attr('height', 200);
return;
}
// Background grid
for (var i = 0; i <= totalTE; i++) {
var x = i * this.TE_WIDTH;
svgContent += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="' + this.CONNECTION_AREA_HEIGHT + '" stroke="#333" stroke-width="0.5" stroke-dasharray="2,2"/>';
}
// Render busbars
var busbarY = 15;
var busbarsByPosition = {};
this.busbars.forEach(function(busbar, index) {
var posKey = busbar.position_y < 0 ? 'above' : busbar.position_y;
if (!busbarsByPosition[posKey]) busbarsByPosition[posKey] = [];
busbarsByPosition[posKey].push(busbar);
});
Object.keys(busbarsByPosition).forEach(function(posKey) {
busbarsByPosition[posKey].forEach(function(busbar, idx) {
svgContent += self.renderBusbar(busbar, carrierId, busbarY + (idx * (self.BUSBAR_HEIGHT + self.BUSBAR_SPACING)));
});
busbarY += busbarsByPosition[posKey].length * (self.BUSBAR_HEIGHT + self.BUSBAR_SPACING);
});
// Render equipment endpoints
var equipmentY = busbarY + 20;
// Store equipment positions for connection routing
this.equipmentPositions = {};
this.equipment.forEach(function(eq) {
var width = eq.width_te * self.TE_WIDTH;
var centerX = (eq.position_te - 1) * self.TE_WIDTH + width / 2;
self.equipmentPositions[eq.id] = {
x: centerX,
left: (eq.position_te - 1) * self.TE_WIDTH,
right: eq.position_te * self.TE_WIDTH + (eq.width_te - 1) * self.TE_WIDTH,
inputY: equipmentY - 15,
outputY: equipmentY + 15,
centerY: equipmentY
};
});
// Render non-busbar connections FIRST (behind equipment)
// But route them around blocks, not through them
var regularConnections = this.connections.filter(function(c) { return c.is_rail != 1; });
// Connection layer - rendered below equipment layer
svgContent += '<g class="kundenkarte-connections-layer">';
regularConnections.forEach(function(conn, index) {
svgContent += self.renderOrthogonalConnection(conn, carrierId, equipmentY, index);
});
svgContent += '</g>';
// Equipment layer - rendered on top
svgContent += '<g class="kundenkarte-equipment-layer">';
this.equipment.forEach(function(eq) {
svgContent += self.renderEquipmentEndpoints(eq, equipmentY, carrierId);
});
svgContent += '</g>';
// Render drag preview line (hidden by default) - on top of everything
svgContent += '<path id="drag-preview-' + carrierId + '" class="kundenkarte-drag-preview" d="" fill="none" stroke="#3498db" stroke-width="2" stroke-dasharray="5,5" style="display:none;"/>';
$svg.html(svgContent);
$svg.attr('height', equipmentY + 60);
},
renderBusbar: function(busbar, carrierId, y) {
var self = this;
var startX = (busbar.rail_start_te - 1) * this.TE_WIDTH + 5;
var endX = busbar.rail_end_te * this.TE_WIDTH - 5;
var color = busbar.color || this.PHASE_COLORS[busbar.connection_type] || '#3498db';
var phases = busbar.rail_phases || '';
var excludedTE = (busbar.excluded_te || '').split(',').map(function(t) { return parseInt(t.trim()); }).filter(function(t) { return !isNaN(t); });
var html = '<g class="kundenkarte-busbar-element" data-connection-id="' + busbar.id + '">';
// Determine phase list
var phaseList = [];
if (phases === '3P+N') {
phaseList = ['L1', 'L2', 'L3', 'N'];
} else if (phases === '3P') {
phaseList = ['L1', 'L2', 'L3'];
} else if (phases === 'L1N') {
phaseList = ['L1', 'N'];
} else if (phases === 'L1') {
phaseList = ['L1'];
} else {
phaseList = [busbar.connection_type || 'L1'];
}
var phaseHeight = phaseList.length > 1 ? 6 : (this.BUSBAR_HEIGHT - 8);
var phaseSpacing = phaseList.length > 1 ? 2 : 0;
var totalPhaseHeight = phaseList.length * phaseHeight + (phaseList.length - 1) * phaseSpacing;
var phaseStartY = y + (this.BUSBAR_HEIGHT - totalPhaseHeight) / 2;
// Draw each phase line
phaseList.forEach(function(phase, idx) {
var phaseY = phaseStartY + idx * (phaseHeight + phaseSpacing);
var phaseColor = self.PHASE_COLORS[phase] || color;
// Draw the phase line with gaps for excluded TE
html += self.renderBusbarLine(startX, endX, phaseY, phaseHeight, phaseColor, excludedTE, carrierId, busbar.id, phase);
// Phase label on the left
html += '<text x="' + (startX - 3) + '" y="' + (phaseY + phaseHeight/2 + 3) + '" ';
html += 'text-anchor="end" fill="' + phaseColor + '" font-size="9" font-weight="bold">';
html += phase;
html += '</text>';
});
// Connection endpoints for EACH phase at EACH TE position
for (var te = busbar.rail_start_te; te <= busbar.rail_end_te; te++) {
if (excludedTE.indexOf(te) === -1) {
var epX = (te - 0.5) * this.TE_WIDTH;
phaseList.forEach(function(phase, idx) {
var epY = phaseStartY + idx * (phaseHeight + phaseSpacing) + phaseHeight;
var phaseColor = self.PHASE_COLORS[phase] || color;
html += '<circle class="kundenkarte-endpoint kundenkarte-busbar-endpoint kundenkarte-phase-endpoint" ';
html += 'data-type="busbar-phase" data-connection-id="' + busbar.id + '" data-te="' + te + '" data-phase="' + phase + '" ';
html += 'cx="' + epX + '" cy="' + epY + '" r="4" ';
html += 'fill="' + phaseColor + '" stroke="#fff" stroke-width="1"/>';
});
}
}
// Busbar type label (bottom right)
html += '<text x="' + (endX - 5) + '" y="' + (y + this.BUSBAR_HEIGHT - 2) + '" ';
html += 'text-anchor="end" fill="#888" font-size="8">';
html += this.escapeHtml(busbar.connection_type || '');
html += '</text>';
html += '</g>';
// Store busbar phase positions for connection routing
if (!this.busbarPositions) this.busbarPositions = {};
this.busbarPositions[busbar.id] = {
phases: phaseList,
startTE: busbar.rail_start_te,
endTE: busbar.rail_end_te,
y: y,
phaseStartY: phaseStartY,
phaseHeight: phaseHeight,
phaseSpacing: phaseSpacing
};
return html;
},
renderBusbarLine: function(startX, endX, y, height, color, excludedTE, carrierId, busbarId, phase) {
var html = '';
var segments = [];
var currentStart = startX;
var teWidth = this.TE_WIDTH;
// Calculate segments with gaps
for (var te = Math.ceil(startX / teWidth) + 1; te <= Math.floor(endX / teWidth); te++) {
if (excludedTE.indexOf(te) !== -1) {
if (currentStart < (te - 1) * teWidth + teWidth/2) {
segments.push({ start: currentStart, end: (te - 1) * teWidth + teWidth/4 });
}
currentStart = (te - 1) * teWidth + teWidth * 0.75;
}
}
segments.push({ start: currentStart, end: endX });
segments.forEach(function(seg) {
html += '<rect x="' + seg.start + '" y="' + y + '" width="' + (seg.end - seg.start) + '" height="' + height + '" ';
html += 'fill="' + color + '" rx="1" ry="1" class="kundenkarte-busbar-line" data-phase="' + (phase || '') + '"/>';
});
return html;
},
renderEquipmentEndpoints: function(eq, baseY, carrierId) {
var x = (eq.position_te - 0.5) * this.TE_WIDTH;
var width = eq.width_te * this.TE_WIDTH;
var centerX = (eq.position_te - 1) * this.TE_WIDTH + width / 2;
var color = eq.block_color || eq.type_color || '#3498db';
var html = '<g class="kundenkarte-equipment-endpoints" data-equipment-id="' + eq.id + '">';
// Equipment representation
html += '<rect x="' + ((eq.position_te - 1) * this.TE_WIDTH + 2) + '" y="' + (baseY - 8) + '" ';
html += 'width="' + (width - 4) + '" height="16" rx="3" ry="3" fill="' + color + '" stroke="#333" stroke-width="1"/>';
// Label
html += '<text x="' + centerX + '" y="' + (baseY + 4) + '" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">';
html += this.escapeHtml(eq.type_label_short || eq.label || '');
html += '</text>';
// Input endpoint (top)
html += '<circle class="kundenkarte-endpoint kundenkarte-equipment-input" ';
html += 'data-type="equipment-input" data-equipment-id="' + eq.id + '" ';
html += 'cx="' + centerX + '" cy="' + (baseY - 15) + '" r="' + this.ENDPOINT_RADIUS + '" ';
html += 'fill="#fff" stroke="' + color + '" stroke-width="2"/>';
// Output endpoint (bottom)
html += '<circle class="kundenkarte-endpoint kundenkarte-equipment-output" ';
html += 'data-type="equipment-output" data-equipment-id="' + eq.id + '" ';
html += 'cx="' + centerX + '" cy="' + (baseY + 15) + '" r="' + this.ENDPOINT_RADIUS + '" ';
html += 'fill="#fff" stroke="' + color + '" stroke-width="2"/>';
html += '</g>';
return html;
},
renderOrthogonalConnection: function(conn, carrierId, equipmentY, connectionIndex) {
var color = conn.color || this.PHASE_COLORS[conn.connection_type] || '#888888';
var sourcePos = this.equipmentPositions[conn.fk_source];
var targetPos = this.equipmentPositions[conn.fk_target];
if (!sourcePos || !targetPos) return '';
var sourceX = sourcePos.x;
var targetX = targetPos.x;
var sourceY = conn.source_terminal === 'input' ? sourcePos.inputY : sourcePos.outputY;
var targetY = conn.target_terminal === 'input' ? targetPos.inputY : targetPos.outputY;
// Create orthogonal path that routes AROUND blocks, not through them
var path = this.createOrthogonalPath(sourceX, sourceY, targetX, targetY, connectionIndex, equipmentY);
var html = '<g class="kundenkarte-connection-group" data-connection-id="' + conn.id + '">';
// Connection line with shadow for visibility
html += '<path class="kundenkarte-connection-path-shadow" ';
html += 'd="' + path + '" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="5" ';
html += 'stroke-linecap="round" stroke-linejoin="round"/>';
// Main connection line
html += '<path class="kundenkarte-connection-path" data-connection-id="' + conn.id + '" ';
html += 'd="' + path + '" fill="none" stroke="' + color + '" stroke-width="2.5" ';
html += 'stroke-linecap="round" stroke-linejoin="round"/>';
// Label if exists - positioned along the routing path
if (conn.output_label) {
var fullLabel = conn.output_label;
if (conn.output_location) fullLabel += ' · ' + conn.output_location;
var labelY = equipmentY + 35 + (connectionIndex * 12);
var labelX = (sourceX + targetX) / 2;
var rectWidth = Math.max(50, fullLabel.length * 5 + 10);
html += '<rect x="' + (labelX - rectWidth/2) + '" y="' + (labelY - 8) + '" width="' + rectWidth + '" height="12" rx="2" fill="#1e1e1e" opacity="0.9"/>';
html += '<text x="' + labelX + '" y="' + (labelY + 2) + '" text-anchor="middle" fill="#ccc" font-size="9">';
html += this.escapeHtml(conn.output_label);
if (conn.output_location) {
html += '<tspan font-style="italic" fill="#888"> · ' + this.escapeHtml(conn.output_location) + '</tspan>';
}
html += '</text>';
}
html += '</g>';
return html;
},
createOrthogonalPath: function(x1, y1, x2, y2, connectionIndex, equipmentY) {
// Offset for multiple connections to avoid overlap
var routeOffset = (connectionIndex || 0) * 8;
// Route connections BELOW the equipment blocks to keep them visible
var routeY = equipmentY + 30 + routeOffset;
// If source and target are the same terminal type, route around
var bothOutputs = y1 > equipmentY && y2 > equipmentY;
var bothInputs = y1 < equipmentY && y2 < equipmentY;
if (Math.abs(x1 - x2) < 10) {
// Vertically aligned - straight line
return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
}
if (bothOutputs) {
// Both from output - route below
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + routeY +
' L ' + x2 + ' ' + routeY +
' L ' + x2 + ' ' + y2;
}
if (bothInputs) {
// Both from input - route above
var routeYAbove = equipmentY - 30 - routeOffset;
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + routeYAbove +
' L ' + x2 + ' ' + routeYAbove +
' L ' + x2 + ' ' + y2;
}
// Mixed terminals (output to input or vice versa)
// Route to the side and then up/down
if (y1 > y2) {
// Going up (output to input)
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + routeY +
' L ' + x2 + ' ' + routeY +
' L ' + x2 + ' ' + y2;
} else {
// Going down (input to output)
var routeYAbove2 = equipmentY - 30 - routeOffset;
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + routeYAbove2 +
' L ' + x2 + ' ' + routeYAbove2 +
' L ' + x2 + ' ' + y2;
}
},
// Drag and drop connection creation
startDragConnection: function(e, $endpoint, carrierId) {
var $svg = $endpoint.closest('svg');
var offset = $svg.offset();
var svgPoint = this.getSVGPoint($svg[0], e.clientX, e.clientY);
this.dragState = {
carrierId: carrierId,
startEndpoint: $endpoint,
startX: parseFloat($endpoint.attr('cx')),
startY: parseFloat($endpoint.attr('cy')),
startType: $endpoint.data('type'),
startId: $endpoint.data('equipment-id') || $endpoint.data('connection-id'),
currentX: svgPoint.x,
currentY: svgPoint.y
};
$endpoint.addClass('dragging');
$('#drag-preview-' + carrierId).show();
},
updateDragConnection: function(e) {
if (!this.dragState) return;
var $svg = $('#drag-preview-' + this.dragState.carrierId).closest('svg');
var svgPoint = this.getSVGPoint($svg[0], e.clientX, e.clientY);
this.dragState.currentX = svgPoint.x;
this.dragState.currentY = svgPoint.y;
var path = this.createOrthogonalPath(
this.dragState.startX,
this.dragState.startY,
this.dragState.currentX,
this.dragState.currentY
);
$('#drag-preview-' + this.dragState.carrierId).attr('d', path);
},
endDragConnection: function(e) {
if (!this.dragState) return;
var $targetEndpoint = $(e.target).closest('.kundenkarte-endpoint');
if ($targetEndpoint.length && !$targetEndpoint.is(this.dragState.startEndpoint)) {
// Valid drop target
this.createConnection(
this.dragState.carrierId,
this.dragState.startType,
this.dragState.startId,
$targetEndpoint.data('type'),
$targetEndpoint.data('equipment-id') || $targetEndpoint.data('connection-id'),
$targetEndpoint.data('te')
);
}
// Cleanup
this.dragState.startEndpoint.removeClass('dragging');
$('#drag-preview-' + this.dragState.carrierId).hide().attr('d', '');
this.dragState = null;
},
getSVGPoint: function(svg, clientX, clientY) {
var pt = svg.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
var svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
return { x: svgP.x, y: svgP.y };
},
createConnection: function(carrierId, sourceType, sourceId, targetType, targetId, targetTE) {
var self = this;
var data = {
action: 'create',
carrier_id: carrierId,
token: $('input[name="token"]').val()
};
// Determine source and target based on types
if (sourceType.indexOf('equipment') !== -1) {
data.fk_source = sourceId;
data.source_terminal = sourceType === 'equipment-input' ? 'input' : 'output';
} else if (sourceType === 'busbar') {
data.fk_source = null;
data.source_terminal = 'busbar-' + sourceId;
}
if (targetType.indexOf('equipment') !== -1) {
data.fk_target = targetId;
data.target_terminal = targetType === 'equipment-input' ? 'input' : 'output';
} else if (targetType === 'busbar') {
data.fk_target = null;
data.target_terminal = 'busbar-' + targetId + '-te' + targetTE;
}
data.connection_type = 'L1N';
data.color = this.PHASE_COLORS['L1N'];
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadAndRenderEditor(carrierId);
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
// Dialogs
showBusbarDialog: function(carrierId) {
var self = this;
if ($('#kundenkarte-busbar-dialog').length) return;
var $editor = $('.kundenkarte-connection-editor[data-carrier-id="' + carrierId + '"]');
var totalTE = parseInt($editor.data('total-te')) || 12;
var html = '<div id="kundenkarte-busbar-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>Sammelschiene hinzufügen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div class="kundenkarte-connection-form">';
// Quick presets
html += '<div class="form-row" style="margin-bottom:15px;">';
html += '<label style="width:100%;margin-bottom:8px;font-weight:bold;">Schnellauswahl:</label>';
html += '<div style="display:flex;flex-wrap:wrap;gap:8px;">';
var presets = [
{ type: 'L1', color: '#8B4513', phases: 'L1' },
{ type: 'L1N', color: '#3498db', phases: 'L1N' },
{ type: '3P', color: '#2c3e50', phases: '3P' },
{ type: '3P+N', color: '#34495e', phases: '3P+N' },
{ type: 'N', color: '#0066cc', phases: '' },
{ type: 'PE', color: '#27ae60', phases: '' }
];
presets.forEach(function(p) {
html += '<button type="button" class="busbar-preset-btn" data-type="' + p.type + '" data-color="' + p.color + '" data-phases="' + p.phases + '" ';
html += 'style="padding:8px 16px;background:' + p.color + ';color:#fff;border:none;border-radius:4px;cursor:pointer;font-weight:bold;">';
html += p.type + '</button>';
});
html += '</div></div>';
// Type and color
html += '<div class="form-row">';
html += '<div class="form-group"><label>Bezeichnung</label>';
html += '<input type="text" name="busbar_type" value="L1N" placeholder="z.B. L1N, 3P, PE"></div>';
html += '<div class="form-group"><label>Farbe</label>';
html += '<input type="color" name="busbar_color" value="#3498db"></div>';
html += '</div>';
// Range
html += '<div class="form-row">';
html += '<div class="form-group"><label>Von TE</label>';
html += '<input type="number" name="busbar_start" value="1" min="1" max="' + totalTE + '"></div>';
html += '<div class="form-group"><label>Bis TE</label>';
html += '<input type="number" name="busbar_end" value="' + totalTE + '" min="1" max="' + totalTE + '"></div>';
html += '</div>';
// Phases
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Phasendarstellung</label>';
html += '<select name="busbar_phases">';
html += '<option value="">Einfache Linie</option>';
html += '<option value="L1N">L1+N (2 Linien)</option>';
html += '<option value="3P">3P (L1/L2/L3)</option>';
html += '<option value="3P+N">3P+N (L1/L2/L3/N)</option>';
html += '</select></div>';
html += '</div>';
// Excluded
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Ausgenommene TE (kommagetrennt)</label>';
html += '<input type="text" name="busbar_excluded" placeholder="z.B. 3,4 für FI-Lücke"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="busbar-save"><i class="fa fa-save"></i> Erstellen</button> ';
html += '<button type="button" class="button" id="busbar-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
$('#kundenkarte-busbar-dialog').addClass('visible');
// Preset handlers
$('.busbar-preset-btn').on('click', function() {
$('input[name="busbar_type"]').val($(this).data('type'));
$('input[name="busbar_color"]').val($(this).data('color'));
$('select[name="busbar_phases"]').val($(this).data('phases'));
});
// Save
$('#busbar-save').on('click', function() {
self.saveBusbar(carrierId);
});
// Close
$('#busbar-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-busbar-dialog').remove();
});
},
saveBusbar: function(carrierId) {
var self = this;
var data = {
action: 'create_rail',
carrier_id: carrierId,
connection_type: $('input[name="busbar_type"]').val(),
color: $('input[name="busbar_color"]').val(),
rail_start_te: $('input[name="busbar_start"]').val(),
rail_end_te: $('input[name="busbar_end"]').val(),
rail_phases: $('select[name="busbar_phases"]').val(),
excluded_te: $('input[name="busbar_excluded"]').val(),
position_y: 0,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-busbar-dialog').remove();
self.loadAndRenderEditor(carrierId);
} else {
KundenKarte.showAlert('Fehler', response.error);
}
},
error: function() {
KundenKarte.showAlert('Fehler', 'Netzwerkfehler');
}
});
},
showEditBusbarDialog: function(connectionId, carrierId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'get', connection_id: connectionId },
dataType: 'json',
success: function(response) {
if (response.success && response.connection) {
self.renderEditBusbarDialog(connectionId, carrierId, response.connection);
}
}
});
},
renderEditBusbarDialog: function(connectionId, carrierId, conn) {
var self = this;
var $editor = $('.kundenkarte-connection-editor[data-carrier-id="' + carrierId + '"]');
var totalTE = parseInt($editor.data('total-te')) || 12;
var html = '<div id="kundenkarte-busbar-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>Sammelschiene bearbeiten</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div class="kundenkarte-connection-form">';
html += '<input type="hidden" name="busbar_id" value="' + connectionId + '">';
// Type and color
html += '<div class="form-row">';
html += '<div class="form-group"><label>Bezeichnung</label>';
html += '<input type="text" name="busbar_type" value="' + this.escapeHtml(conn.connection_type || '') + '"></div>';
html += '<div class="form-group"><label>Farbe</label>';
html += '<input type="color" name="busbar_color" value="' + (conn.color || '#3498db') + '"></div>';
html += '</div>';
// Range
html += '<div class="form-row">';
html += '<div class="form-group"><label>Von TE</label>';
html += '<input type="number" name="busbar_start" value="' + (conn.rail_start_te || 1) + '" min="1" max="' + totalTE + '"></div>';
html += '<div class="form-group"><label>Bis TE</label>';
html += '<input type="number" name="busbar_end" value="' + (conn.rail_end_te || totalTE) + '" min="1" max="' + totalTE + '"></div>';
html += '</div>';
// Phases
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Phasendarstellung</label>';
html += '<select name="busbar_phases">';
html += '<option value=""' + (!conn.rail_phases ? ' selected' : '') + '>Einfache Linie</option>';
html += '<option value="L1N"' + (conn.rail_phases === 'L1N' ? ' selected' : '') + '>L1+N</option>';
html += '<option value="3P"' + (conn.rail_phases === '3P' ? ' selected' : '') + '>3P</option>';
html += '<option value="3P+N"' + (conn.rail_phases === '3P+N' ? ' selected' : '') + '>3P+N</option>';
html += '</select></div>';
html += '</div>';
// Excluded
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Ausgenommene TE</label>';
html += '<input type="text" name="busbar_excluded" value="' + this.escapeHtml(conn.excluded_te || '') + '"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="busbar-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="busbar-delete" style="background:#c0392b;color:#fff;"><i class="fa fa-trash"></i> Löschen</button> ';
html += '<button type="button" class="button" id="busbar-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
$('#kundenkarte-busbar-dialog').addClass('visible');
// Save
$('#busbar-save').on('click', function() {
self.updateBusbar(connectionId, carrierId);
});
// Delete
$('#busbar-delete').on('click', function() {
$('#kundenkarte-busbar-dialog').remove();
self.deleteConnection(connectionId, carrierId);
});
// Close
$('#busbar-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-busbar-dialog').remove();
});
},
updateBusbar: function(connectionId, carrierId) {
var self = this;
var data = {
action: 'update',
connection_id: connectionId,
carrier_id: carrierId,
connection_type: $('input[name="busbar_type"]').val(),
color: $('input[name="busbar_color"]').val(),
rail_start_te: $('input[name="busbar_start"]').val(),
rail_end_te: $('input[name="busbar_end"]').val(),
rail_phases: $('select[name="busbar_phases"]').val(),
excluded_te: $('input[name="busbar_excluded"]').val(),
is_rail: 1,
position_y: 0,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-busbar-dialog').remove();
self.loadAndRenderEditor(carrierId);
} else {
KundenKarte.showAlert('Fehler', response.error);
}
}
});
},
showConnectionDialog: function(carrierId) {
var self = this;
if ($('#kundenkarte-conn-dialog').length) return;
var html = '<div id="kundenkarte-conn-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>Verbindung hinzufügen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div class="kundenkarte-connection-form">';
// Source equipment
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Von (Quelle)</label>';
html += '<select name="conn_source">';
html += '<option value="">-- Auswählen --</option>';
self.equipment.forEach(function(eq) {
html += '<option value="' + eq.id + '">' + self.escapeHtml(eq.label || eq.type_label || 'Equipment ' + eq.id) + '</option>';
});
html += '</select></div>';
html += '</div>';
// Source terminal
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Anschluss (Quelle)</label>';
html += '<select name="conn_source_terminal">';
html += '<option value="output">Ausgang (unten)</option>';
html += '<option value="input">Eingang (oben)</option>';
html += '</select></div>';
html += '</div>';
// Target equipment
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Nach (Ziel)</label>';
html += '<select name="conn_target">';
html += '<option value="">-- Auswählen --</option>';
self.equipment.forEach(function(eq) {
html += '<option value="' + eq.id + '">' + self.escapeHtml(eq.label || eq.type_label || 'Equipment ' + eq.id) + '</option>';
});
html += '</select></div>';
html += '</div>';
// Target terminal
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Anschluss (Ziel)</label>';
html += '<select name="conn_target_terminal">';
html += '<option value="input">Eingang (oben)</option>';
html += '<option value="output">Ausgang (unten)</option>';
html += '</select></div>';
html += '</div>';
// Connection type
html += '<div class="form-row">';
html += '<div class="form-group"><label>Typ</label>';
html += '<input type="text" name="conn_type" value="L1N" placeholder="z.B. L1N, 3P"></div>';
html += '<div class="form-group"><label>Farbe</label>';
html += '<input type="color" name="conn_color" value="#3498db"></div>';
html += '</div>';
// Label
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Beschriftung (optional)</label>';
html += '<input type="text" name="conn_label" placeholder="z.B. Küche"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="conn-save"><i class="fa fa-save"></i> Erstellen</button> ';
html += '<button type="button" class="button" id="conn-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
$('#kundenkarte-conn-dialog').addClass('visible');
// Save
$('#conn-save').on('click', function() {
self.saveConnection(carrierId);
});
// Close
$('#conn-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-conn-dialog').remove();
});
},
saveConnection: function(carrierId) {
var self = this;
var sourceId = $('select[name="conn_source"]').val();
var targetId = $('select[name="conn_target"]').val();
if (!sourceId || !targetId) {
KundenKarte.showAlert('Hinweis', 'Bitte Quelle und Ziel auswählen');
return;
}
var data = {
action: 'create',
carrier_id: carrierId,
fk_source: sourceId,
source_terminal: $('select[name="conn_source_terminal"]').val(),
fk_target: targetId,
target_terminal: $('select[name="conn_target_terminal"]').val(),
connection_type: $('input[name="conn_type"]').val(),
color: $('input[name="conn_color"]').val(),
output_label: $('input[name="conn_label"]').val(),
is_rail: 0,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-conn-dialog').remove();
self.loadAndRenderEditor(carrierId);
} else {
KundenKarte.showAlert('Fehler', response.error);
}
}
});
},
showEditConnectionDialog: function(connectionId, carrierId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'get', connection_id: connectionId },
dataType: 'json',
success: function(response) {
if (response.success && response.connection) {
self.renderEditConnectionDialog(connectionId, carrierId, response.connection);
}
}
});
},
renderEditConnectionDialog: function(connectionId, carrierId, conn) {
var self = this;
var html = '<div id="kundenkarte-conn-dialog" class="kundenkarte-modal">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>Verbindung bearbeiten</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div class="kundenkarte-connection-form">';
html += '<input type="hidden" name="conn_id" value="' + connectionId + '">';
// Connection type
html += '<div class="form-row">';
html += '<div class="form-group"><label>Typ</label>';
html += '<input type="text" name="conn_type" value="' + this.escapeHtml(conn.connection_type || '') + '"></div>';
html += '<div class="form-group"><label>Farbe</label>';
html += '<input type="color" name="conn_color" value="' + (conn.color || '#3498db') + '"></div>';
html += '</div>';
// Label
html += '<div class="form-row">';
html += '<div class="form-group" style="width:100%;"><label>Beschriftung</label>';
html += '<input type="text" name="conn_label" value="' + this.escapeHtml(conn.output_label || '') + '"></div>';
html += '</div>';
html += '</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="conn-save"><i class="fa fa-save"></i> Speichern</button> ';
html += '<button type="button" class="button" id="conn-delete" style="background:#c0392b;color:#fff;"><i class="fa fa-trash"></i> Löschen</button> ';
html += '<button type="button" class="button" id="conn-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
$('#kundenkarte-conn-dialog').addClass('visible');
// Save
$('#conn-save').on('click', function() {
self.updateConnection(connectionId, carrierId, conn);
});
// Delete
$('#conn-delete').on('click', function() {
$('#kundenkarte-conn-dialog').remove();
self.deleteConnection(connectionId, carrierId);
});
// Close
$('#conn-cancel, .kundenkarte-modal-close').on('click', function() {
$('#kundenkarte-conn-dialog').remove();
});
},
updateConnection: function(connectionId, carrierId, originalConn) {
var self = this;
var data = {
action: 'update',
connection_id: connectionId,
carrier_id: carrierId,
fk_source: originalConn.fk_source,
source_terminal: originalConn.source_terminal,
fk_target: originalConn.fk_target,
target_terminal: originalConn.target_terminal,
connection_type: $('input[name="conn_type"]').val(),
color: $('input[name="conn_color"]').val(),
output_label: $('input[name="conn_label"]').val(),
is_rail: 0,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#kundenkarte-conn-dialog').remove();
self.loadAndRenderEditor(carrierId);
} else {
KundenKarte.showAlert('Fehler', response.error);
}
}
});
},
deleteConnection: function(connectionId, carrierId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'delete',
connection_id: connectionId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadAndRenderEditor(carrierId);
} else {
self.showMessage(response.error || 'Fehler beim Löschen', 'error');
}
}
});
},
// Demo view when no busbars exist
renderDemoView: function(carrierId, totalTE, totalWidth) {
var self = this;
var html = '';
var BUSBAR_Y = 15;
var BUSBAR_HEIGHT = this.BUSBAR_HEIGHT;
var phaseList = ['L1', 'L2', 'L3', 'N'];
var phaseHeight = 6;
var phaseSpacing = 2;
var totalPhaseHeight = phaseList.length * phaseHeight + (phaseList.length - 1) * phaseSpacing;
var phaseStartY = BUSBAR_Y + (BUSBAR_HEIGHT - totalPhaseHeight) / 2;
// Demo background overlay
html += '<rect x="0" y="0" width="' + totalWidth + '" height="200" fill="#1a1a2e" opacity="0.3"/>';
// Demo label
html += '<text x="' + (totalWidth / 2) + '" y="10" text-anchor="middle" fill="#888" font-size="10" font-style="italic">';
html += 'Demo-Ansicht - Klicken Sie auf "Sammelschiene" um eine echte Schiene hinzuzufügen';
html += '</text>';
// Background grid for TE positions
for (var i = 0; i <= totalTE; i++) {
var x = i * this.TE_WIDTH;
html += '<line x1="' + x + '" y1="15" x2="' + x + '" y2="180" stroke="#333" stroke-width="0.5" stroke-dasharray="2,2"/>';
// TE number label
if (i < totalTE) {
html += '<text x="' + (x + this.TE_WIDTH / 2) + '" y="190" text-anchor="middle" fill="#555" font-size="8">' + (i + 1) + '</text>';
}
}
// Render demo 3P+N busbar
html += '<g class="kundenkarte-demo-busbar" opacity="0.8">';
// Busbar background
html += '<rect x="20" y="' + BUSBAR_Y + '" width="' + (totalWidth - 40) + '" height="' + BUSBAR_HEIGHT + '" ';
html += 'fill="#2d2d44" rx="3" ry="3" stroke="#555" stroke-width="1" stroke-dasharray="4,2"/>';
// Draw each phase line
phaseList.forEach(function(phase, idx) {
var phaseY = phaseStartY + idx * (phaseHeight + phaseSpacing);
var phaseColor = self.PHASE_COLORS[phase];
// Phase line
html += '<rect x="25" y="' + phaseY + '" width="' + (totalWidth - 50) + '" height="' + phaseHeight + '" ';
html += 'fill="' + phaseColor + '" rx="1" ry="1" opacity="0.9"/>';
// Phase label on the left
html += '<text x="18" y="' + (phaseY + phaseHeight / 2 + 3) + '" ';
html += 'text-anchor="end" fill="' + phaseColor + '" font-size="9" font-weight="bold">';
html += phase;
html += '</text>';
// Connection endpoints for each TE
for (var te = 1; te <= totalTE; te++) {
var epX = (te - 0.5) * self.TE_WIDTH;
var epY = phaseY + phaseHeight;
html += '<circle class="kundenkarte-endpoint kundenkarte-demo-endpoint" ';
html += 'cx="' + epX + '" cy="' + epY + '" r="4" ';
html += 'fill="' + phaseColor + '" stroke="#fff" stroke-width="1" opacity="0.7"/>';
}
});
// Busbar type label
html += '<text x="' + (totalWidth - 25) + '" y="' + (BUSBAR_Y + BUSBAR_HEIGHT - 2) + '" ';
html += 'text-anchor="end" fill="#888" font-size="8">3P+N</text>';
html += '</g>'; // End demo busbar group
// Equipment visualization layer
var equipmentY = BUSBAR_Y + BUSBAR_HEIGHT + 40;
html += '<g class="kundenkarte-demo-equipment">';
// Draw existing equipment with connection endpoints
this.equipment.forEach(function(eq) {
var width = eq.width_te * self.TE_WIDTH;
var centerX = (eq.position_te - 1) * self.TE_WIDTH + width / 2;
var color = eq.block_color || eq.type_color || '#3498db';
// Equipment block
html += '<rect x="' + ((eq.position_te - 1) * self.TE_WIDTH + 2) + '" y="' + (equipmentY - 8) + '" ';
html += 'width="' + (width - 4) + '" height="16" rx="3" ry="3" fill="' + color + '" stroke="#333" stroke-width="1"/>';
// Label
html += '<text x="' + centerX + '" y="' + (equipmentY + 4) + '" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">';
html += self.escapeHtml(eq.type_label_short || eq.label || '');
html += '</text>';
// Input endpoint (top)
html += '<circle class="kundenkarte-endpoint kundenkarte-equipment-input" ';
html += 'data-type="equipment-input" data-equipment-id="' + eq.id + '" ';
html += 'cx="' + centerX + '" cy="' + (equipmentY - 15) + '" r="' + self.ENDPOINT_RADIUS + '" ';
html += 'fill="#fff" stroke="' + color + '" stroke-width="2"/>';
// Output endpoint (bottom)
html += '<circle class="kundenkarte-endpoint kundenkarte-equipment-output" ';
html += 'data-type="equipment-output" data-equipment-id="' + eq.id + '" ';
html += 'cx="' + centerX + '" cy="' + (equipmentY + 15) + '" r="' + self.ENDPOINT_RADIUS + '" ';
html += 'fill="#fff" stroke="' + color + '" stroke-width="2"/>';
// Draw demo connections from phases to equipment
// Distribute phases across equipment
var phaseIndex = (eq.position_te - 1) % 3; // Rotate through L1, L2, L3
var phase = phaseList[phaseIndex];
var phaseColor = self.PHASE_COLORS[phase];
var phaseY = phaseStartY + phaseIndex * (phaseHeight + phaseSpacing) + phaseHeight;
// Demo connection line
html += '<path class="kundenkarte-demo-connection" ';
html += 'd="M ' + centerX + ' ' + phaseY + ' L ' + centerX + ' ' + (equipmentY - 15) + '" ';
html += 'fill="none" stroke="' + phaseColor + '" stroke-width="2" stroke-dasharray="4,2" opacity="0.6"/>';
// Phase indicator text below equipment
html += '<text x="' + centerX + '" y="' + (equipmentY + 30) + '" text-anchor="middle" fill="' + phaseColor + '" font-size="8" font-weight="bold">';
html += phase;
html += '</text>';
});
html += '</g>'; // End demo equipment group
// Legend
html += '<g class="kundenkarte-demo-legend" transform="translate(10, 160)">';
html += '<text x="0" y="0" fill="#888" font-size="9">Legende:</text>';
phaseList.forEach(function(phase, idx) {
var x = 60 + idx * 50;
html += '<rect x="' + x + '" y="-8" width="12" height="8" fill="' + self.PHASE_COLORS[phase] + '" rx="1"/>';
html += '<text x="' + (x + 16) + '" y="0" fill="' + self.PHASE_COLORS[phase] + '" font-size="9" font-weight="bold">' + phase + '</text>';
});
html += '</g>';
return html;
},
// Helpers
darkenColor: function(color, percent) {
var num = parseInt(color.replace('#', ''), 16);
var amt = Math.round(2.55 * percent);
var R = (num >> 16) - amt;
var G = (num >> 8 & 0x00FF) - amt;
var B = (num & 0x0000FF) - amt;
return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +
(G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
(B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1);
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
/**
* SchematicEditor - Interaktiver Schaltplan-Editor
*
* Features:
* - Hutschienen als feste Zeilen
* - Equipment-Blöcke verschiebbar auf Hutschiene
* - Konfigurierbare Terminals pro Equipment-Typ
* - Klick-Verbindungen: Ausgang klicken → Eingang klicken
* - Rechtsklick zum Löschen
* - Orthogonale Verbindungspfade
*/
KundenKarte.SchematicEditor = {
// Constants (40% larger for better visibility)
TE_WIDTH: 56,
RAIL_HEIGHT: 14, // Hutschiene Höhe
RAIL_SPACING: 380, // Abstand zwischen Hutschienen (erhöht für Routing-Zone)
BLOCK_HEIGHT: 112,
TERMINAL_RADIUS: 8,
GRID_SIZE: 14,
MIN_TOP_MARGIN: 100, // Minimaler Platz oben für Panel (inkl. Hauptsammelschienen)
MAIN_BUSBAR_HEIGHT: 60, // Höhe des Hauptsammelschienen-Bereichs (L1,L2,L3,N,PE)
BLOCK_INNER_OFFSET: 80, // Zusätzlicher Offset für Blöcke innerhalb des Panels
BOTTOM_MARGIN: 100, // Platz unten für Abgänge + Routing
PANEL_GAP: 56, // Abstand zwischen Panels
PANEL_PADDING: 28, // Innenabstand Panel
CONNECTION_ROUTE_SPACE: 17, // Platz pro Verbindungsroute
OUTPUT_ZONE_HEIGHT: 130, // Platz für Abgänge (lineLength max 120 + Label)
ROUTING_ZONE_HEIGHT: 60, // Dedizierte Zone für Leitungsführung zwischen Carriern
// Colors
COLORS: {
rail: '#666',
railBg: '#444',
block: '#3498db',
input: '#e74c3c',
output: '#27ae60',
connection: '#f1c40f',
selected: '#9b59b6',
hover: '#fff',
grid: '#2a2a2a',
panelBg: '#1e1e1e',
panelBorder: '#333'
},
PHASE_COLORS: {
'L1': '#8B4513',
'L2': '#1a1a1a',
'L3': '#666666',
'N': '#0066cc',
'PE': '#27ae60',
'LN': '#8B4513', // Single phase output (brown)
'3P+N': '#34495e', // 3-phase + neutral
'DATA': '#9b59b6' // Data cable (purple)
},
// State
anlageId: null,
panels: [],
carriers: [],
equipment: [],
connections: [],
bridges: [], // Terminal bridges (Brücken zwischen Klemmen)
busbarTypes: [], // Sammelschienen-Typen aus Datenbank
selectedTerminal: null,
dragState: null,
isInitialized: false,
isLoading: false,
svgElement: null,
scale: 1,
// Performance: Index Maps für O(1) Lookups statt O(n) find()
_equipmentById: {},
_carrierById: {},
_connectionById: {},
DEBUG: false, // Set to true to enable console.log
// Manual wire drawing mode
wireDrawMode: false,
wireDrawPoints: [], // Array of {x, y} points for current wire
wireDrawSourceEq: null, // Source equipment ID
wireDrawSourceTerm: null, // Source terminal ID
wireDrawFromJunction: false, // Drawing from junction on existing wire
wireDrawJunctionConn: null, // Connection ID we're branching from
wireDrawJunctionPoint: null, // {x, y} junction point on existing wire
wireExtendMode: false, // Extending an existing connection
wireExtendConnId: null, // Connection ID being extended
wireExtendExistingPoints: [], // Existing path points of connection being extended
WIRE_GRID_SIZE: 25, // Snap grid size in pixels (larger = fewer points)
// Wire segment dragging
wireDragMode: false, // Currently dragging a wire segment
wireDragConnId: null, // Connection ID being dragged
wireDragSegmentIndex: null, // Index of segment being dragged (0-based)
wireDragIsHorizontal: null, // true = horizontal segment (drag Y), false = vertical (drag X)
wireDragStartPos: null, // Starting mouse position
wireDragOriginalPoints: [], // Original path points before drag
// Display settings (persisted in localStorage)
displaySettings: {
phaseColors: true, // Color wires by phase (L1=brown, L2=black, L3=gray, N=blue, PE=green)
wireWidth: 'normal', // 'thin', 'normal', 'thick'
showJunctions: true, // Show dots at wire junctions
terminalStyle: 'circle' // 'circle', 'square', 'ring'
},
// Load display settings from localStorage
loadDisplaySettings: function() {
try {
var saved = localStorage.getItem('kundenkarte_schematic_settings');
if (saved) {
var parsed = JSON.parse(saved);
this.displaySettings = Object.assign({}, this.displaySettings, parsed);
}
} catch (e) {
console.warn('Could not load schematic settings:', e);
}
},
// Save display settings to localStorage
saveDisplaySettings: function() {
try {
localStorage.setItem('kundenkarte_schematic_settings', JSON.stringify(this.displaySettings));
} catch (e) {
console.warn('Could not save schematic settings:', e);
}
},
// Get wire width in pixels
getWireWidth: function() {
switch (this.displaySettings.wireWidth) {
case 'thin': return 2;
case 'thick': return 5;
default: return 3.5;
}
},
// Get wire color based on phase (if phaseColors enabled)
getWireColor: function(connectionType) {
if (!this.displaySettings.phaseColors) {
return '#f1c40f'; // Default yellow
}
// Extract phase from connection type
var phase = connectionType ? connectionType.toUpperCase() : '';
if (phase.indexOf('L1') !== -1) return '#8B4513'; // Brown
if (phase.indexOf('L2') !== -1) return '#1a1a1a'; // Black
if (phase.indexOf('L3') !== -1) return '#808080'; // Gray
if (phase.indexOf('PE') !== -1) return '#00aa00'; // Green
if (phase.indexOf('N') !== -1) return '#0066cc'; // Blue
if (phase === '3P' || phase === 'L1L2L3') return '#9b59b6'; // Purple for 3-phase
return '#f1c40f'; // Default yellow
},
// Parse phase configuration into repeating labels array
parsePhaseLabels: function(phases) {
if (!phases) return [];
var p = phases.toUpperCase();
// Common configurations
if (p === '3P' || p === 'L1L2L3') return ['L1', 'L2', 'L3'];
if (p === '3P+N' || p === '3PN') return ['L1', 'L2', 'L3', 'N'];
if (p === '3P+N+PE' || p === '3PNPE') return ['L1', 'L2', 'L3', 'N', 'PE'];
if (p === 'L1N' || p === 'L1+N') return ['L1', 'N'];
if (p === 'L1') return ['L1'];
if (p === 'L2') return ['L2'];
if (p === 'L3') return ['L3'];
if (p === 'N') return ['N'];
if (p === 'PE') return ['PE'];
// Try to split by + or comma
if (p.indexOf('+') !== -1) return p.split('+');
if (p.indexOf(',') !== -1) return p.split(',');
// Single phase/label
return [phases];
},
// Get connection color for a specific terminal (if connected)
getTerminalConnectionColor: function(equipmentId, terminalId) {
var eqId = String(equipmentId);
var termId = String(terminalId);
// Zuerst Terminal-Color-Map prüfen (vom Eingangs-Anschlusspunkt propagierte Farbe)
if (this._terminalColorMap && this._terminalColorMap[eqId] && this._terminalColorMap[eqId][termId]) {
return this._terminalColorMap[eqId][termId];
}
if (!this.connections || this.connections.length === 0) {
return null;
}
// Find connection where this terminal is source or target
var conn = this.connections.find(function(c) {
var isSource = String(c.fk_source) === eqId && String(c.source_terminal_id) === termId;
var isTarget = String(c.fk_target) === eqId && String(c.target_terminal_id) === termId;
return isSource || isTarget;
});
if (conn) {
// Return explicit color or derive from connection_type
if (conn.color) return conn.color;
if (conn.connection_type) return this.getWireColor(conn.connection_type);
// Fallback: return a default wire color
return '#f1c40f'; // Yellow as default connected color
}
return null; // No connection found
},
// Get terminal shape SVG based on style setting
getTerminalShape: function(radius, fillColor) {
var style = this.displaySettings.terminalStyle || 'circle';
// Add invisible hit area (larger than visible terminal) for easier clicking
var hitAreaRadius = 30; // 30px hit area for easy clicking
var hitArea = '<circle r="' + hitAreaRadius + '" fill="rgba(0,0,0,0.001)" class="schematic-terminal-hitarea" style="pointer-events:all;"/>';
switch (style) {
case 'ring':
// Hollow circle
return hitArea + '<circle r="' + radius + '" fill="none" stroke="' + fillColor + '" stroke-width="2" class="schematic-terminal-circle" style="pointer-events:none;"/>';
case 'dot':
// Smaller solid dot
return hitArea + '<circle r="' + (radius * 0.6) + '" fill="' + fillColor + '" stroke="#fff" stroke-width="1.5" class="schematic-terminal-circle" style="pointer-events:none;"/>';
case 'square':
// Square shape
var size = radius * 1.5;
return hitArea + '<rect x="' + (-size/2) + '" y="' + (-size/2) + '" width="' + size + '" height="' + size + '" fill="' + fillColor + '" stroke="#fff" stroke-width="2" class="schematic-terminal-circle" style="pointer-events:none;"/>';
case 'circle':
default:
// Default filled circle
return hitArea + '<circle r="' + radius + '" fill="' + fillColor + '" stroke="#fff" stroke-width="2" class="schematic-terminal-circle" style="pointer-events:none;"/>';
}
},
// Show settings dialog
showSettingsDialog: function() {
var self = this;
// Remove existing dialog
$('.schematic-settings-dialog').remove();
var html = '<div class="schematic-settings-dialog" style="' +
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
'background:#2d2d44;border:2px solid #3498db;border-radius:8px;' +
'padding:20px;z-index:100003;min-width:320px;box-shadow:0 10px 40px rgba(0,0,0,0.5);">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">';
html += '<h3 style="margin:0;color:#fff;"><i class="fa fa-cog"></i> Anzeigeoptionen</h3>';
html += '<button class="settings-close" style="background:none;border:none;color:#888;font-size:20px;cursor:pointer;">&times;</button>';
html += '</div>';
// Phase colors
html += '<div style="margin-bottom:15px;">';
html += '<label style="display:flex;align-items:center;gap:10px;color:#fff;cursor:pointer;">';
html += '<input type="checkbox" class="setting-phase-colors" ' + (this.displaySettings.phaseColors ? 'checked' : '') + ' style="width:18px;height:18px;">';
html += '<span>Phasenfarben (L1=braun, L2=schwarz, L3=grau, N=blau, PE=grün)</span>';
html += '</label>';
html += '</div>';
// Junction dots
html += '<div style="margin-bottom:15px;">';
html += '<label style="display:flex;align-items:center;gap:10px;color:#fff;cursor:pointer;">';
html += '<input type="checkbox" class="setting-junctions" ' + (this.displaySettings.showJunctions ? 'checked' : '') + ' style="width:18px;height:18px;">';
html += '<span>Abzweigpunkte anzeigen</span>';
html += '</label>';
html += '</div>';
// Wire width
html += '<div style="margin-bottom:15px;">';
html += '<label style="color:#aaa;display:block;margin-bottom:5px;">Leitungsstärke:</label>';
html += '<div style="display:flex;gap:10px;">';
['thin', 'normal', 'thick'].forEach(function(w) {
var labels = { thin: 'Dünn', normal: 'Normal', thick: 'Dick' };
var checked = self.displaySettings.wireWidth === w ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:5px;color:#fff;cursor:pointer;">';
html += '<input type="radio" name="wire-width" value="' + w + '" ' + checked + '>';
html += '<span>' + labels[w] + '</span>';
html += '</label>';
});
html += '</div>';
html += '</div>';
// Terminal style
html += '<div style="margin-bottom:20px;">';
html += '<label style="color:#aaa;display:block;margin-bottom:5px;">Terminal-Form:</label>';
html += '<div style="display:flex;gap:10px;">';
['circle', 'square', 'ring'].forEach(function(s) {
var labels = { circle: '● Kreis', square: '■ Quadrat', ring: '○ Ring' };
var checked = self.displaySettings.terminalStyle === s ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:5px;color:#fff;cursor:pointer;">';
html += '<input type="radio" name="terminal-style" value="' + s + '" ' + checked + '>';
html += '<span>' + labels[s] + '</span>';
html += '</label>';
});
html += '</div>';
html += '</div>';
// Buttons
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button class="settings-apply" style="padding:8px 20px;background:#3498db;color:#fff;border:none;border-radius:4px;cursor:pointer;"><i class="fa fa-check"></i> Anwenden</button>';
html += '</div>';
html += '</div>';
// Overlay
html += '<div class="schematic-settings-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:100002;"></div>';
$('body').append(html);
// Event handlers
$('.settings-close, .schematic-settings-overlay').on('click', function() {
$('.schematic-settings-dialog, .schematic-settings-overlay').remove();
});
$('.settings-apply').on('click', function() {
// Collect settings
self.displaySettings.phaseColors = $('.setting-phase-colors').is(':checked');
self.displaySettings.showJunctions = $('.setting-junctions').is(':checked');
self.displaySettings.wireWidth = $('input[name="wire-width"]:checked').val() || 'normal';
self.displaySettings.terminalStyle = $('input[name="terminal-style"]:checked').val() || 'circle';
// Save and apply
self.saveDisplaySettings();
self.render();
// Close dialog
$('.schematic-settings-dialog, .schematic-settings-overlay').remove();
self.showMessage('Einstellungen gespeichert', 'success');
});
},
// Default terminal configs for common types
// Terminals are bidirectional - no strict input/output
DEFAULT_TERMINALS: {
'LS': { terminals: [{id: 't1', label: '●', pos: 'top'}, {id: 't2', label: '●', pos: 'bottom'}] },
'FI': { terminals: [{id: 't1', label: 'L1', pos: 'top'}, {id: 't2', label: 'L2', pos: 'top'}, {id: 't3', label: 'L3', pos: 'top'}, {id: 't4', label: 'N', pos: 'top'}, {id: 't5', label: 'L1', pos: 'bottom'}, {id: 't6', label: 'L2', pos: 'bottom'}, {id: 't7', label: 'L3', pos: 'bottom'}, {id: 't8', label: 'N', pos: 'bottom'}] },
'FI4P': { terminals: [{id: 't1', label: 'L1', pos: 'top'}, {id: 't2', label: 'L2', pos: 'top'}, {id: 't3', label: 'L3', pos: 'top'}, {id: 't4', label: 'N', pos: 'top'}, {id: 't5', label: 'L1', pos: 'bottom'}, {id: 't6', label: 'L2', pos: 'bottom'}, {id: 't7', label: 'L3', pos: 'bottom'}, {id: 't8', label: 'N', pos: 'bottom'}] },
'SCHUETZ': { terminals: [{id: 't1', label: 'A1', pos: 'top'}, {id: 't2', label: '1', pos: 'top'}, {id: 't3', label: 'A2', pos: 'bottom'}, {id: 't4', label: '2', pos: 'bottom'}] },
'KLEMME': { terminals: [{id: 't1', label: '●', pos: 'top'}, {id: 't2', label: '●', pos: 'bottom'}] },
'LS3P': { terminals: [{id: 't1', label: 'L1', pos: 'top'}, {id: 't2', label: 'L2', pos: 'top'}, {id: 't3', label: 'L3', pos: 'top'}, {id: 't4', label: 'L1', pos: 'bottom'}, {id: 't5', label: 'L2', pos: 'bottom'}, {id: 't6', label: 'L3', pos: 'bottom'}] }
},
// Performance: Build index maps for O(1) lookups
buildIndexes: function() {
var self = this;
this._equipmentById = {};
this._carrierById = {};
this._connectionById = {};
this.equipment.forEach(function(eq) {
self._equipmentById[eq.id] = eq;
});
this.carriers.forEach(function(c) {
self._carrierById[c.id] = c;
});
this.connections.forEach(function(conn) {
self._connectionById[conn.id] = conn;
});
},
// Terminal-Phasen-Map: Ordnet jedem Terminal die richtige Phase und Farbe zu
// Propagation: Anschlusspunkte → Leitungen → Block-Durchreichung → Busbar-Verteilung
// WICHTIG: Nur Terminals die tatsächlich über Verbindungen erreichbar sind bekommen Farben
// Die Farbe wird vom Eingangs-Anschlusspunkt bestimmt (conn.color), nicht vom Phasennamen
buildTerminalPhaseMap: function() {
var self = this;
this._terminalPhaseMap = {}; // {eqId: {termId: "L1"}} - Phase-Name für Busbar-Logik
this._terminalColorMap = {}; // {eqId: {termId: "#hex"}} - Tatsächliche Farbe für Rendering
// Hilfsfunktion: Phase + Farbe setzen, gibt true zurück wenn neu gesetzt
var setPhase = function(eqId, termId, phase, color) {
if (!self._terminalPhaseMap[eqId]) self._terminalPhaseMap[eqId] = {};
if (!self._terminalColorMap[eqId]) self._terminalColorMap[eqId] = {};
if (self._terminalPhaseMap[eqId][termId]) return false; // Schon gesetzt
self._terminalPhaseMap[eqId][termId] = phase;
self._terminalColorMap[eqId][termId] = color || self.getWireColor(phase);
return true;
};
// Hilfsfunktion: Phase + Farbe überschreiben (für Busbar-Verteilung)
var forcePhase = function(eqId, termId, phase, color) {
if (!self._terminalPhaseMap[eqId]) self._terminalPhaseMap[eqId] = {};
if (!self._terminalColorMap[eqId]) self._terminalColorMap[eqId] = {};
if (self._terminalPhaseMap[eqId][termId] === phase) return false;
self._terminalPhaseMap[eqId][termId] = phase;
self._terminalColorMap[eqId][termId] = color || self.getWireColor(phase);
return true;
};
// Farbe für ein Terminal holen
var getColor = function(eqId, termId) {
return (self._terminalColorMap[eqId] || {})[termId] || null;
};
// --- Schritt 1: Anschlusspunkte (Inputs) als Startpunkte ---
// Connections ohne fk_source = Einspeisungen
// Die Farbe vom Eingang (conn.color) bestimmt alle verbundenen Terminals
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return;
if (conn.fk_source) return; // Hat Source → kein Anschlusspunkt
if (!conn.fk_target || !conn.target_terminal_id) return;
var phase = (conn.connection_type || '').toUpperCase();
if (!self.PHASE_COLORS[phase]) return; // Nur echte Phasen (L1, L2, L3, N, PE)
// Eingangsfarbe: conn.color hat Vorrang, sonst Standard-Phasenfarbe
var inputColor = conn.color || self.getWireColor(phase);
setPhase(conn.fk_target, conn.target_terminal_id, phase, inputColor);
});
// --- Schritt 1b: Leitungen mit expliziter Farbe als Startpunkte ---
// Nur echte Verbindungen zwischen zwei Geräten (fk_source + fk_target),
// KEINE Abgänge (fk_target=NULL) — Abgang-Terminals bekommen keine Farbe
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return;
if (!conn.color) return;
if (!conn.fk_source || !conn.fk_target) return; // Nur Gerät→Gerät, keine Abgänge
var phase = (conn.connection_type || '').toUpperCase();
if (!self.PHASE_COLORS[phase]) return;
var connColor = conn.color;
if (conn.source_terminal_id) {
setPhase(conn.fk_source, conn.source_terminal_id, phase, connColor);
}
if (conn.target_terminal_id) {
setPhase(conn.fk_target, conn.target_terminal_id, phase, connColor);
}
});
// --- Iterativ propagieren bis keine Änderungen mehr ---
var changed = true;
var iterations = 0;
var maxIterations = 20;
while (changed && iterations++ < maxIterations) {
changed = false;
// Leitungen propagieren (Phase + Farbe von einem Ende zum anderen)
// Keine Block-Durchreichung (top↔bottom): Farben nur durch explizite Verbindungen
self.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return;
if (!conn.fk_source || !conn.fk_target) return;
if (!conn.source_terminal_id || !conn.target_terminal_id) return;
var srcPhase = (self._terminalPhaseMap[conn.fk_source] || {})[conn.source_terminal_id];
var tgtPhase = (self._terminalPhaseMap[conn.fk_target] || {})[conn.target_terminal_id];
if (srcPhase && !tgtPhase) {
var srcColor = getColor(conn.fk_source, conn.source_terminal_id);
if (setPhase(conn.fk_target, conn.target_terminal_id, srcPhase, srcColor)) changed = true;
} else if (tgtPhase && !srcPhase) {
var tgtColor = getColor(conn.fk_target, conn.target_terminal_id);
if (setPhase(conn.fk_source, conn.source_terminal_id, tgtPhase, tgtColor)) changed = true;
}
});
// Junction-Verbindungen (Terminal→Leitung): Farbe bidirektional übertragen
self.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return;
if (!conn.junction_connection_id) return;
if (!conn.fk_source || !conn.source_terminal_id) return;
var jConn = self._connectionById[String(conn.junction_connection_id)];
if (!jConn) return;
var srcPhase = (self._terminalPhaseMap[conn.fk_source] || {})[conn.source_terminal_id];
// Farbe der Ziel-Leitung ermitteln (aus einem ihrer Endpoints)
var jPhase = null, jColor = null;
if (jConn.fk_source && jConn.source_terminal_id) {
jPhase = (self._terminalPhaseMap[jConn.fk_source] || {})[jConn.source_terminal_id];
jColor = getColor(jConn.fk_source, jConn.source_terminal_id);
}
if (!jPhase && jConn.fk_target && jConn.target_terminal_id) {
jPhase = (self._terminalPhaseMap[jConn.fk_target] || {})[jConn.target_terminal_id];
jColor = getColor(jConn.fk_target, jConn.target_terminal_id);
}
// Ziel-Leitung hat Phase → Source-Terminal übernimmt sie
if (jPhase && !srcPhase) {
if (setPhase(conn.fk_source, conn.source_terminal_id, jPhase, jColor)) changed = true;
}
// Source-Terminal hat Phase → auf Endpoints der Ziel-Leitung übertragen
if (srcPhase && !jPhase) {
var srcColor = getColor(conn.fk_source, conn.source_terminal_id);
if (jConn.fk_source && jConn.source_terminal_id &&
!(self._terminalPhaseMap[jConn.fk_source] || {})[jConn.source_terminal_id]) {
if (setPhase(jConn.fk_source, jConn.source_terminal_id, srcPhase, srcColor)) changed = true;
}
if (jConn.fk_target && jConn.target_terminal_id &&
!(self._terminalPhaseMap[jConn.fk_target] || {})[jConn.target_terminal_id]) {
if (setPhase(jConn.fk_target, jConn.target_terminal_id, srcPhase, srcColor)) changed = true;
}
}
});
// Busbar-Verteilung: Nur Phasen verteilen die tatsächlich eingespeist sind
self.connections.forEach(function(busbar) {
if (parseInt(busbar.is_rail) !== 1) return;
var carrier = self.getCarrierById(busbar.fk_carrier);
if (!carrier) return;
var railStart = parseInt(busbar.rail_start_te) || 1;
var railEnd = parseInt(busbar.rail_end_te) || railStart;
var posY = parseInt(busbar.position_y) || 0;
var targetPos = (posY === 0) ? 'top' : 'bottom';
// Phase-Labels ermitteln
var phaseLabels;
if (busbar.phases_config && Array.isArray(busbar.phases_config) && busbar.phases_config.length > 0) {
phaseLabels = busbar.phases_config;
} else {
var phases = busbar.rail_phases || busbar.connection_type || '';
phaseLabels = self.parsePhaseLabels(phases);
}
if (phaseLabels.length === 0) return;
// Sammle welche Phasen tatsächlich eingespeist sind + deren Farbe
var fedPhases = {};
var fedColors = {}; // Phase → Farbe vom Eingangs-Anschlusspunkt
self.equipment.forEach(function(eq) {
var eqCarrierId = eq.carrier_id || eq.fk_carrier;
if (String(eqCarrierId) !== String(busbar.fk_carrier)) return;
var eqPosTE = parseFloat(eq.position_te) || 1;
var eqWidthTE = parseFloat(eq.width_te) || 1;
if (!(eqPosTE < railEnd + 1 && railStart < eqPosTE + eqWidthTE)) return;
var terminals = self.getTerminals(eq);
terminals.filter(function(t) { return t.pos === targetPos; }).forEach(function(term) {
var phase = (self._terminalPhaseMap[eq.id] || {})[term.id];
if (phase) {
fedPhases[phase] = true;
// Farbe vom einspeisenden Terminal übernehmen
if (!fedColors[phase]) {
fedColors[phase] = getColor(eq.id, term.id);
}
}
});
});
// Keine eingespeisten Phasen → Busbar inaktiv
if (Object.keys(fedPhases).length === 0) return;
// Excluded TEs
var excludedTEs = [];
if (busbar.excluded_te) {
excludedTEs = busbar.excluded_te.split(',').map(function(t) {
return parseInt(t.trim());
}).filter(function(t) { return !isNaN(t); });
}
// Busbar-Muster auf Equipment-Terminals verteilen,
// aber NUR für Phasen die tatsächlich eingespeist sind
// Die Farbe kommt vom Eingangs-Anschlusspunkt (fedColors)
self.equipment.forEach(function(eq) {
var eqCarrierId = eq.carrier_id || eq.fk_carrier;
if (String(eqCarrierId) !== String(busbar.fk_carrier)) return;
var eqPosTE = parseFloat(eq.position_te) || 1;
var eqWidthTE = parseFloat(eq.width_te) || 1;
if (!(eqPosTE < railEnd + 1 && railStart < eqPosTE + eqWidthTE)) return;
var terminals = self.getTerminals(eq);
var posTerminals = terminals.filter(function(t) { return t.pos === targetPos; });
posTerminals.forEach(function(term, idx) {
var teIndex = term.col !== undefined ? term.col : (idx % eqWidthTE);
var absoluteTE = Math.round(eqPosTE + teIndex);
if (excludedTEs.indexOf(absoluteTE) !== -1) return;
if (absoluteTE < railStart || absoluteTE > railEnd) return;
var teOffset = absoluteTE - railStart;
var phase = phaseLabels[teOffset % phaseLabels.length];
// Nur verteilen wenn diese Phase eingespeist ist
if (!fedPhases[phase]) return;
// Farbe vom Eingang, Fallback auf Standard-Phasenfarbe
var phaseColor = fedColors[phase] || self.getWireColor(phase);
if (forcePhase(eq.id, term.id, phase, phaseColor)) changed = true;
});
});
});
}
// --- Leitungsfarben-Map: Leitungen die mit Eingängen verbunden sind ---
// Farbe wird von der Terminal-Color-Map genommen (= Eingangsfarbe)
this._connectionColorMap = {};
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return;
// Anschlusspunkte (kein fk_source, kein path_data) überspringen
if (!conn.fk_source && !conn.path_data) return;
// Abgänge (fk_source, kein fk_target, kein path_data) behalten ihre Farbe
if (conn.fk_source && !conn.fk_target && !conn.path_data) return;
// Farbe vom Source- oder Target-Terminal nehmen (aus _terminalColorMap)
var color = null;
if (conn.fk_source && conn.source_terminal_id) {
color = getColor(conn.fk_source, conn.source_terminal_id);
}
if (!color && conn.fk_target && conn.target_terminal_id) {
color = getColor(conn.fk_target, conn.target_terminal_id);
}
if (color) {
self._connectionColorMap[conn.id] = color;
}
});
this.log('buildTerminalPhaseMap: ' + Object.keys(this._terminalPhaseMap).length +
' Equipment, ' + Object.keys(this._connectionColorMap).length +
' Leitungen mit Phasen-Zuordnung (' + iterations + ' Iterationen)');
},
// O(1) lookup helpers
getEquipmentById: function(id) {
return id ? this._equipmentById[id] : null;
},
getCarrierById: function(id) {
return id ? this._carrierById[id] : null;
},
getConnectionById: function(id) {
return id ? this._connectionById[id] : null;
},
// Conditional logging (only when DEBUG is true)
log: function() {
if (this.DEBUG && console && console.log) {
console.log.apply(console, arguments);
}
},
init: function(anlageId) {
if (!anlageId) {
console.error('SchematicEditor.init: No anlageId provided!');
return;
}
this.anlageId = anlageId;
this.loadDisplaySettings();
this.loadBusbarTypes();
this.bindEvents();
this.loadData();
},
// Load busbar types from database
loadBusbarTypes: function() {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/busbar_types.php',
data: { action: 'list', system_id: 0 }, // 0 = Alle Systeme
dataType: 'json',
async: false, // Synchron laden damit Typen sofort verfügbar sind
success: function(response) {
if (response.success && response.types) {
self.busbarTypes = response.types;
self.log('Loaded ' + self.busbarTypes.length + ' busbar types');
}
},
error: function() {
console.warn('Could not load busbar types');
}
});
},
bindEvents: function() {
var self = this;
// Terminal click - only used in wire draw mode
$(document).off('click.terminal').on('click.terminal', '.schematic-terminal', function(e) {
e.preventDefault();
e.stopPropagation();
var $clicked = $(this);
console.log('Terminal clicked:', $clicked.data('equipment-id'), $clicked.data('terminal-id'), 'element:', this);
// Only handle in manual wire draw mode
if (self.wireDrawMode) {
self.handleTerminalClick($clicked);
}
});
// Block drag - track if dragged to distinguish from click
$(document).off('mousedown.blockDrag').on('mousedown.blockDrag', '.schematic-block', function(e) {
// Im Zeichenmodus kein Block-Drag
if (self.wireDrawMode) return;
if ($(e.target).hasClass('schematic-terminal')) return;
e.preventDefault();
self.blockDragStartPos = { x: e.clientX, y: e.clientY };
self.blockWasDragged = false;
self.clickedEquipmentId = $(this).data('equipment-id');
self.startDragBlock($(this), e);
});
$(document).off('mousemove.blockDrag').on('mousemove.blockDrag', function(e) {
if (self.dragState && self.dragState.type === 'block') {
// Check if moved more than 5px - then it's a drag, not a click
if (self.blockDragStartPos) {
var dx = Math.abs(e.clientX - self.blockDragStartPos.x);
var dy = Math.abs(e.clientY - self.blockDragStartPos.y);
if (dx > 5 || dy > 5) {
self.blockWasDragged = true;
}
}
self.updateDragBlock(e);
}
});
$(document).off('mouseup.blockDrag').on('mouseup.blockDrag', function(e) {
var equipmentId = self.clickedEquipmentId;
if (self.dragState && self.dragState.type === 'block') {
self.endDragBlock(e);
}
// If not dragged, show popup
if (!self.blockWasDragged && equipmentId) {
self.showEquipmentPopup(equipmentId, e.clientX, e.clientY);
}
self.blockDragStartPos = null;
self.blockWasDragged = false;
self.clickedEquipmentId = null;
});
// Block hover - show tooltip with field info
$(document).off('mouseenter.blockHover').on('mouseenter.blockHover', '.schematic-block', function(e) {
if (self.dragState) return; // Don't show tooltip while dragging
var equipmentId = $(this).data('equipment-id');
self.showBlockTooltip(equipmentId, e);
});
$(document).off('mousemove.blockHover').on('mousemove.blockHover', '.schematic-block', function(e) {
if (self.dragState) return;
self.updateBlockTooltipPosition(e);
});
$(document).off('mouseleave.blockHover').on('mouseleave.blockHover', '.schematic-block', function() {
self.hideBlockTooltip();
});
// Hide popups when clicking elsewhere
$(document).off('mousedown.hidePopup').on('mousedown.hidePopup', function(e) {
// Don't hide if clicking on popup buttons
if ($(e.target).closest('.schematic-connection-popup, .schematic-equipment-popup, .schematic-carrier-popup').length) {
return;
}
// Don't hide if clicking on SVG elements (handlers will handle it)
if ($(e.target).closest('svg').length) {
return;
}
self.hideConnectionPopup();
self.hideEquipmentPopup();
self.hideCarrierPopup();
});
// Clear all connections
$(document).off('click.clearConns').on('click.clearConns', '.schematic-clear-connections', function(e) {
e.preventDefault();
KundenKarte.showConfirm('Alle löschen', 'Alle Verbindungen wirklich löschen?', function() {
self.clearAllConnections();
});
});
// Straighten connections (remove diagonals, orthogonal only)
$(document).off('click.straightenConns').on('click.straightenConns', '.schematic-straighten-connections', function(e) {
e.preventDefault();
self.straightenConnections();
});
// BOM (Bill of Materials) / Stückliste
$(document).off('click.bomGenerate').on('click.bomGenerate', '.schematic-bom-generate', function(e) {
e.preventDefault();
self.showBOMDialog();
});
// Audit Log
$(document).off('click.auditLog').on('click.auditLog', '.schematic-audit-log', function(e) {
e.preventDefault();
self.showAuditLogDialog();
});
// Escape key - no longer needed for auto-selection, wire draw has its own handler
// Zoom with mouse wheel
$(document).off('wheel.schematicZoom').on('wheel.schematicZoom', '.schematic-editor-canvas', function(e) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
var delta = e.originalEvent.deltaY > 0 ? -0.1 : 0.1;
self.setZoom(self.scale + delta);
}
});
// Zoom buttons
$(document).off('click.zoomIn').on('click.zoomIn', '.schematic-zoom-in', function(e) {
e.preventDefault();
self.setZoom(self.scale + 0.1);
});
$(document).off('click.zoomOut').on('click.zoomOut', '.schematic-zoom-out', function(e) {
e.preventDefault();
self.setZoom(self.scale - 0.1);
});
$(document).off('click.zoomReset').on('click.zoomReset', '.schematic-zoom-reset', function(e) {
e.preventDefault();
self.setZoom(1);
});
$(document).off('click.zoomFit').on('click.zoomFit', '.schematic-zoom-fit', function(e) {
e.preventDefault();
self.zoomToFit();
});
// Settings button
$(document).off('click.schematicSettings').on('click.schematicSettings', '.schematic-settings-btn', function(e) {
e.preventDefault();
self.showSettingsDialog();
});
// ======================================
// Keyboard Shortcuts
// ======================================
$(document).off('keydown.schematicShortcuts').on('keydown.schematicShortcuts', function(e) {
// Only handle shortcuts when we're in the schematic editor
if (!$('.schematic-editor-canvas').length) return;
// Don't handle if user is typing in an input/textarea
if ($(e.target).is('input, textarea, select, [contenteditable]')) return;
// Ctrl+S / Cmd+S - Save (reload data to ensure fresh state)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
self.showSaveIndicator();
return;
}
// Escape - Close all popups, cancel current wire (NOT exit draw mode)
if (e.key === 'Escape') {
e.preventDefault();
// Close all popups
$('.schematic-equipment-popup, .schematic-carrier-popup, .schematic-connection-popup, .schematic-busbar-popup, .schematic-bridge-popup').remove();
// Cancel current wire drawing only (keep draw mode active)
if (self.wireDrawMode && (self.wireDrawSourceEq || self.wireDrawFromJunction)) {
self.cleanupWireDrawState(true);
self.showMessage('Leitung abgebrochen - weiter zeichnen', 'info');
}
return;
}
// Delete/Backspace - Delete selected equipment (if popup is open)
if (e.key === 'Delete' || e.key === 'Backspace') {
var $popup = $('.schematic-equipment-popup');
if ($popup.length) {
e.preventDefault();
var equipmentId = $popup.data('equipment-id');
if (equipmentId) {
$popup.remove();
self.deleteEquipment(equipmentId);
}
}
return;
}
// + / = - Zoom in
if (e.key === '+' || e.key === '=') {
e.preventDefault();
self.setZoom(self.scale + 0.1);
return;
}
// - - Zoom out
if (e.key === '-') {
e.preventDefault();
self.setZoom(self.scale - 0.1);
return;
}
// 0 - Reset zoom
if (e.key === '0' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
self.setZoom(1);
return;
}
// F - Fit to screen
if (e.key === 'f' || e.key === 'F') {
e.preventDefault();
self.zoomToFit();
return;
}
// R - Reload/Refresh data
if (e.key === 'r' || e.key === 'R') {
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
self.loadData();
return;
}
}
});
// Bridge click - show delete popup
$(document).off('click.bridge').on('click.bridge', '.schematic-bridge', function(e) {
e.preventDefault();
e.stopPropagation();
var bridgeId = $(this).data('bridge-id');
self.showBridgePopup(bridgeId, e.clientX, e.clientY);
});
// Busbar click - show edit/delete popup (only if not dragging)
$(document).off('click.busbar').on('click.busbar', '.schematic-busbar', function(e) {
if (self.busbarDragging) return; // Don't show popup if we just finished dragging
e.preventDefault();
e.stopPropagation();
var connectionId = $(this).data('connection-id');
self.showBusbarPopup(connectionId, e.clientX, e.clientY);
});
// Busbar drag - allow repositioning by dragging (keeps width, can move to other carriers)
$(document).off('mousedown.busbarDrag').on('mousedown.busbarDrag', '.schematic-busbar', function(e) {
if (e.button !== 0) return; // Only left mouse button
e.preventDefault();
e.stopPropagation();
var $busbar = $(this);
var connectionId = $busbar.data('connection-id');
var conn = self.connections.find(function(c) { return String(c.id) === String(connectionId); });
if (!conn) return;
// Get carrier for this busbar
var carrier = self.carriers.find(function(c) { return String(c.id) === String(conn.fk_carrier); });
if (!carrier) return;
// Calculate busbar width in TE (this should stay constant)
var originalStartTE = parseInt(conn.rail_start_te) || 1;
var originalEndTE = parseInt(conn.rail_end_te) || 1;
var busbarWidthTE = originalEndTE - originalStartTE + 1;
self.busbarDragData = {
connectionId: connectionId,
conn: conn,
originalCarrier: carrier,
currentCarrier: carrier,
startX: e.clientX,
startY: e.clientY,
originalStartTE: originalStartTE,
originalEndTE: originalEndTE,
busbarWidthTE: busbarWidthTE, // Keep width constant
moved: false
};
$busbar.css('cursor', 'grabbing');
});
$(document).off('mousemove.busbarDrag').on('mousemove.busbarDrag', function(e) {
if (!self.busbarDragData) return;
var data = self.busbarDragData;
var dx = e.clientX - data.startX;
var dy = e.clientY - data.startY;
// Only start dragging after 5px movement
if (!data.moved && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
data.moved = true;
self.busbarDragging = true;
// Find which carrier the mouse is over (based on Y position)
var svgRect = $(self.svgElement)[0].getBoundingClientRect();
var mouseY = e.clientY - svgRect.top;
var targetCarrier = data.originalCarrier;
// Check all carriers to find which one we're over
self.carriers.forEach(function(carrier) {
if (typeof carrier._y !== 'undefined') {
var carrierTop = carrier._y - self.BLOCK_HEIGHT / 2 - 50;
var carrierBottom = carrier._y + self.BLOCK_HEIGHT / 2 + 50;
if (mouseY >= carrierTop && mouseY <= carrierBottom) {
targetCarrier = carrier;
}
}
});
data.currentCarrier = targetCarrier;
// Calculate new start TE based on horizontal movement relative to target carrier
var mouseX = e.clientX - svgRect.left;
var relativeX = mouseX - targetCarrier._x;
var newStartTE = Math.round(relativeX / self.TE_WIDTH) + 1;
// Keep width constant - calculate new end based on fixed width
var newEndTE = newStartTE + data.busbarWidthTE - 1;
// Clamp to carrier bounds (shift if needed to fit)
var totalTE = parseFloat(targetCarrier.total_te) || 12;
if (newStartTE < 1) {
newStartTE = 1;
newEndTE = newStartTE + data.busbarWidthTE - 1;
}
if (newEndTE > totalTE) {
newEndTE = totalTE;
newStartTE = Math.max(1, newEndTE - data.busbarWidthTE + 1);
}
// Update visual preview
var $busbar = $('.schematic-busbar[data-connection-id="' + data.connectionId + '"]');
var startX = targetCarrier._x + (newStartTE - 1) * self.TE_WIDTH;
var width = data.busbarWidthTE * self.TE_WIDTH;
// Calculate new Y position based on target carrier
var posY = parseInt(data.conn.position_y) || 0;
var railCenterY = targetCarrier._y + self.RAIL_HEIGHT / 2;
var blockTop = railCenterY - self.BLOCK_HEIGHT / 2;
var blockBottom = railCenterY + self.BLOCK_HEIGHT / 2;
var busbarHeight = 24;
var newBusbarY;
if (posY === 0) {
newBusbarY = blockTop - busbarHeight - 25;
} else {
newBusbarY = blockBottom + 25;
}
$busbar.find('rect').each(function(idx) {
$(this).attr('x', startX);
$(this).attr('width', width);
// Update Y position (with shadow offset for second rect)
var yOffset = idx === 0 ? 2 : 0; // Shadow has +2 offset
$(this).attr('y', newBusbarY + yOffset);
});
// Store new positions for drop
data.newStartTE = newStartTE;
data.newEndTE = newEndTE;
data.newCarrierId = targetCarrier.id;
});
$(document).off('mouseup.busbarDrag').on('mouseup.busbarDrag', function(e) {
if (!self.busbarDragData) return;
var data = self.busbarDragData;
self.busbarDragData = null;
$('.schematic-busbar').css('cursor', 'pointer');
if (data.moved && data.newStartTE && data.newEndTE) {
// Save new position (and possibly new carrier)
self.updateBusbarPosition(data.connectionId, data.newStartTE, data.newEndTE, data.newCarrierId);
}
// Clear drag flag after a short delay (to prevent click popup)
setTimeout(function() {
self.busbarDragging = false;
}, 100);
});
// Rail (Hutschiene) click - show popup for editing
$(document).off('click.rail').on('click.rail', '.schematic-rail', function(e) {
e.preventDefault();
e.stopPropagation();
var carrierId = $(this).data('carrier-id');
if (carrierId) {
self.showCarrierPopup(carrierId, e.clientX, e.clientY);
}
});
// Panel label click - show panel popup
$(document).off('click.panelLabel').on('click.panelLabel', '.schematic-panel-label', function(e) {
e.preventDefault();
e.stopPropagation();
var panelId = $(this).data('panel-id');
if (panelId) {
self.showPanelPopup(panelId, e.clientX, e.clientY);
}
});
// Terminal right-click - show output/cable dialog (not in wire draw mode)
$(document).off('contextmenu.terminal').on('contextmenu.terminal', '.schematic-terminal', function(e) {
e.preventDefault();
e.stopPropagation();
// Im Zeichenmodus: Rechtsklick bricht nur aktuelle Leitung ab, Modus bleibt
if (self.wireDrawMode) {
if (self.wireDrawSourceEq) {
// Aktuelle Zeichnung abbrechen, Modus bleibt
self.cleanupWireDrawState(true);
self.showMessage('Leitung abgebrochen - weiter zeichnen', 'info');
}
return;
}
var $terminal = $(this);
var eqId = $terminal.data('equipment-id');
var termId = $terminal.data('terminal-id');
self.showOutputDialog(eqId, termId, e.clientX, e.clientY);
});
// Toggle manual wire draw mode
$(document).off('click.toggleWireDraw').on('click.toggleWireDraw', '.schematic-wire-draw-toggle', function(e) {
e.preventDefault();
self.toggleWireDrawMode();
});
// SVG click for wire drawing - add waypoint
$(document).off('click.wireDrawSvg').on('click.wireDrawSvg', '.schematic-editor-canvas svg', function(e) {
if (!self.wireDrawMode) return;
// Nach Wire-Drag keinen Waypoint setzen
if (self._wireDragJustEnded) return;
// Only add waypoints after source terminal is selected
if (!self.wireDrawSourceEq) {
// Show hint if clicking on canvas without selecting terminal first
if (!$(e.target).closest('.schematic-terminal').length) {
self.showMessage('Zuerst ein START-Terminal anklicken!', 'warning');
}
return;
}
// Don't add point if clicking on terminal or block
if ($(e.target).closest('.schematic-terminal, .schematic-block').length) return;
var svg = self.svgElement;
var pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
var svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
// Snap to nearest terminal-aligned grid point
var snapped = self.snapToTerminalGrid(svgP.x, svgP.y);
self.wireDrawPoints.push({x: snapped.x, y: snapped.y});
self.updateWirePreview();
self.log('Wire point added:', snapped.x, snapped.y, 'Total points:', self.wireDrawPoints.length);
});
// SVG mousemove for wire preview - show cursor and preview line
$(document).off('mousemove.wireDraw').on('mousemove.wireDraw', '.schematic-editor-canvas svg', function(e) {
if (!self.wireDrawMode) return;
var svg = self.svgElement;
var pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
var svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
// Snap to nearest terminal-aligned grid point
var snapped = self.snapToTerminalGrid(svgP.x, svgP.y);
// Always update cursor position (even before source is selected)
self.updateWirePreviewCursor(snapped.x, snapped.y);
});
// Right-click to cancel current wire drawing (NOT exit draw mode)
$(document).off('contextmenu.wireDraw').on('contextmenu.wireDraw', '.schematic-editor-canvas svg', function(e) {
if (!self.wireDrawMode) return;
// Skip if click was on terminal (handled by terminal handler)
if ($(e.target).closest('.schematic-terminal').length) return;
e.preventDefault();
if (self.wireDrawSourceEq || self.wireDrawFromJunction) {
// Cancel current line, keep draw mode active
self.cleanupWireDrawState(true);
self.showMessage('Leitung abgebrochen - weiter zeichnen', 'info');
}
});
// Escape to cancel current wire (NOT exit draw mode)
$(document).off('keydown.wireDraw').on('keydown.wireDraw', function(e) {
if (e.key === 'Escape' && self.wireDrawMode && (self.wireDrawSourceEq || self.wireDrawFromJunction)) {
self.cleanupWireDrawState(true);
self.showMessage('Leitung abgebrochen - weiter zeichnen', 'info');
}
});
},
showBusbarPopup: function(connectionId, x, y) {
var self = this;
this.hideBusbarPopup();
var conn = this.connections.find(function(c) { return String(c.id) === String(connectionId); });
if (!conn) return;
var html = '<div class="schematic-busbar-popup" style="position:fixed;left:' + x + 'px;top:' + y + 'px;background:#2d2d44;border:1px solid #555;border-radius:6px;padding:10px;z-index:100002;min-width:150px;">';
html += '<div style="color:#fff;font-weight:bold;margin-bottom:8px;">Sammelschiene</div>';
html += '<div style="color:#aaa;font-size:12px;margin-bottom:8px;">' + (conn.rail_phases || conn.connection_type || 'Unbekannt') + '</div>';
html += '<div style="display:flex;gap:8px;">';
html += '<button class="busbar-edit-btn" data-id="' + connectionId + '" style="flex:1;background:#3498db;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer;">Bearbeiten</button>';
html += '<button class="busbar-delete-btn" data-id="' + connectionId + '" style="flex:1;background:#e74c3c;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer;">Löschen</button>';
html += '</div></div>';
$('body').append(html);
$('.busbar-edit-btn').on('click', function() {
var id = $(this).data('id');
self.hideBusbarPopup();
self.showEditBusbarDialog(id);
});
$('.busbar-delete-btn').on('click', function() {
var id = $(this).data('id');
self.hideBusbarPopup();
self.deleteBusbar(id);
});
// Close on click outside
setTimeout(function() {
$(document).one('click', function() {
self.hideBusbarPopup();
});
}, 100);
},
hideBusbarPopup: function() {
$('.schematic-busbar-popup').remove();
},
// Show a brief save/refresh indicator
showSaveIndicator: function() {
var self = this;
var $indicator = $('<div class="schematic-save-indicator" style="position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#27ae60;color:#fff;padding:10px 20px;border-radius:6px;z-index:100003;font-weight:bold;box-shadow:0 4px 12px rgba(0,0,0,0.3);"><i class="fa fa-check"></i> Gespeichert</div>');
$('body').append($indicator);
// Reload data to ensure fresh state
this.loadData();
setTimeout(function() {
$indicator.fadeOut(300, function() { $(this).remove(); });
}, 1500);
},
// Cancel wire draw mode
cancelWireDraw: function() {
this.wireDrawMode = false;
this.wireDrawPoints = [];
this.wireDrawSourceEq = null;
this.wireDrawSourceTerm = null;
// Remove any temporary wire preview
$('.schematic-wire-preview').remove();
// Remove the preview path if exists
if (this.svgElement) {
$(this.svgElement).find('.temp-wire-path').remove();
}
},
showBridgePopup: function(bridgeId, x, y) {
var self = this;
this.hideBridgePopup();
var bridge = this.bridges.find(function(b) { return String(b.id) === String(bridgeId); });
if (!bridge) return;
var html = '<div class="schematic-bridge-popup" style="position:fixed;left:' + x + 'px;top:' + y + 'px;background:#2d2d44;border:1px solid #555;border-radius:6px;padding:10px;z-index:100002;min-width:150px;">';
html += '<div style="color:#fff;font-weight:bold;margin-bottom:8px;">Brücke</div>';
html += '<div style="color:#aaa;font-size:12px;margin-bottom:8px;">TE ' + bridge.start_te + ' - ' + bridge.end_te + ' (' + bridge.terminal_side + ')</div>';
html += '<div style="display:flex;gap:8px;">';
html += '<button class="bridge-delete-btn" data-id="' + bridgeId + '" style="flex:1;background:#e74c3c;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer;">Löschen</button>';
html += '</div></div>';
$('body').append(html);
$('.bridge-delete-btn').on('click', function() {
var id = $(this).data('id');
self.hideBridgePopup();
self.deleteBridge(id);
});
// Close on click outside
setTimeout(function() {
$(document).one('click', function() {
self.hideBridgePopup();
});
}, 100);
},
hideBridgePopup: function() {
$('.schematic-bridge-popup').remove();
},
deleteBridge: function(bridgeId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'delete_bridge',
bridge_id: bridgeId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Brücke gelöscht', 'success');
// Remove from local data
self.bridges = self.bridges.filter(function(b) { return String(b.id) !== String(bridgeId); });
self.render();
} else {
self.showMessage(response.error || 'Fehler beim Löschen', 'error');
}
}
});
},
createBridge: function(carrierId, startTE, endTE, terminalSide, terminalRow, color) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'create_bridge',
anlage_id: this.anlageId,
carrier_id: carrierId,
start_te: startTE,
end_te: endTE,
terminal_side: terminalSide || 'top',
terminal_row: terminalRow || 0,
color: color || '#e74c3c',
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Brücke erstellt', 'success');
// Add to local data
self.bridges.push(response.bridge);
self.render();
} else {
self.showMessage(response.error || 'Fehler beim Erstellen', 'error');
}
}
});
},
showCreateBridgeDialog: function(carrierId, defaultStartTE, defaultEndTE) {
var self = this;
var carrier = this.carriers.find(function(c) { return String(c.id) === String(carrierId); });
if (!carrier) return;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:300px;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Brücke erstellen</h3>';
// Start TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Von TE:</label>';
html += '<input type="number" class="dialog-bridge-start" value="' + (defaultStartTE || 1) + '" min="1" max="' + carrier.total_te + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// End TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bis TE:</label>';
html += '<input type="number" class="dialog-bridge-end" value="' + (defaultEndTE || 2) + '" min="1" max="' + carrier.total_te + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Terminal Side
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Seite:</label>';
html += '<select class="dialog-bridge-side" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '<option value="top">Oben</option>';
html += '<option value="bottom">Unten</option>';
html += '</select></div>';
// Terminal Row
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Reihe (0=erste):</label>';
html += '<input type="number" class="dialog-bridge-row" value="0" min="0" max="5" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Color
html += '<div style="margin-bottom:15px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Farbe:</label>';
html += '<input type="color" class="dialog-bridge-color" value="#e74c3c" style="width:100%;padding:4px;border:1px solid #555;border-radius:4px;background:#1e1e1e;height:36px;box-sizing:border-box;"/></div>';
// Buttons
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button class="dialog-cancel" style="padding:8px 16px;border:1px solid #555;border-radius:4px;background:#444;color:#fff;cursor:pointer;">Abbrechen</button>';
html += '<button class="dialog-save" style="padding:8px 16px;border:none;border-radius:4px;background:#27ae60;color:#fff;cursor:pointer;">Erstellen</button>';
html += '</div></div>';
$('body').append(html);
$('.dialog-cancel').on('click', function() {
$('.schematic-dialog-overlay, .schematic-dialog').remove();
});
$('.dialog-save').on('click', function() {
var startTE = parseInt($('.dialog-bridge-start').val()) || 1;
var endTE = parseInt($('.dialog-bridge-end').val()) || 2;
var side = $('.dialog-bridge-side').val();
var row = parseInt($('.dialog-bridge-row').val()) || 0;
var color = $('.dialog-bridge-color').val();
$('.schematic-dialog-overlay, .schematic-dialog').remove();
self.createBridge(carrierId, startTE, endTE, side, row, color);
});
// Close on Escape
$(document).on('keydown.dialog', function(e) {
if (e.key === 'Escape') {
$('.schematic-dialog-overlay, .schematic-dialog').remove();
$(document).off('keydown.dialog');
}
});
},
showCarrierPopup: function(carrierId, x, y) {
var self = this;
this.hideCarrierPopup();
var carrier = this.carriers.find(function(c) { return String(c.id) === String(carrierId); });
if (!carrier) return;
// Check if carrier has equipment
var carrierEquipment = this.equipment.filter(function(e) { return String(e.fk_carrier) === String(carrierId); });
var isEmpty = carrierEquipment.length === 0;
var deleteStyle = isEmpty ? 'background:#e74c3c;' : 'background:#888;';
var html = '<div class="schematic-carrier-popup" style="position:fixed;left:' + x + 'px;top:' + y + 'px;background:#2d2d44;border:1px solid #555;border-radius:6px;padding:10px;z-index:100002;min-width:180px;">';
html += '<div style="color:#fff;font-weight:bold;margin-bottom:8px;">Hutschiene</div>';
html += '<div style="color:#aaa;font-size:12px;margin-bottom:8px;">' + this.escapeHtml(carrier.label || 'Ohne Name') + ' (' + carrier.total_te + ' TE)</div>';
html += '<div style="display:flex;flex-direction:column;gap:8px;">';
html += '<div style="display:flex;gap:8px;">';
html += '<button class="carrier-edit-btn" data-id="' + carrierId + '" style="flex:1;background:#3498db;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer;">Bearbeiten</button>';
html += '<button class="carrier-delete-btn" data-id="' + carrierId + '" data-empty="' + (isEmpty ? '1' : '0') + '" data-count="' + carrierEquipment.length + '" style="flex:1;' + deleteStyle + 'color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer;">Löschen';
if (!isEmpty) html += ' <span style="font-size:10px;">(' + carrierEquipment.length + ')</span>';
html += '</button>';
html += '</div>';
html += '<button class="carrier-add-bridge-btn" data-id="' + carrierId + '" style="background:#f39c12;color:#fff;border:none;border-radius:4px;padding:6px 10px;cursor:pointer;"><i class="fa fa-link"></i> Brücke hinzufügen</button>';
html += '</div></div>';
$('body').append(html);
$('.carrier-edit-btn').on('click', function() {
var id = $(this).data('id');
self.hideCarrierPopup();
self.showEditCarrierDialog(id);
});
$('.carrier-delete-btn').on('click', function() {
var id = $(this).data('id');
var isEmpty = $(this).data('empty') === 1 || $(this).data('empty') === '1';
var count = $(this).data('count');
self.hideCarrierPopup();
if (isEmpty) {
self.deleteCarrier(id);
} else {
KundenKarte.showConfirm(
'Hutschiene löschen',
'Hutschiene "' + (carrier.label || 'Ohne Name') + '" mit ' + count + ' Geräten wirklich löschen? Alle Geräte werden ebenfalls gelöscht!',
function() {
self.deleteCarrier(id);
}
);
}
});
$('.carrier-add-bridge-btn').on('click', function() {
var id = $(this).data('id');
self.hideCarrierPopup();
self.showCreateBridgeDialog(id, 1, 2);
});
// Close on click outside
setTimeout(function() {
$(document).one('click', function() {
self.hideCarrierPopup();
});
}, 100);
},
hideCarrierPopup: function() {
$('.schematic-carrier-popup').remove();
},
showEditCarrierDialog: function(carrierId) {
var self = this;
var carrier = this.carriers.find(function(c) { return String(c.id) === String(carrierId); });
if (!carrier) return;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:300px;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Hutschiene bearbeiten</h3>';
// Label
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bezeichnung:</label>';
html += '<input type="text" class="dialog-carrier-label" value="' + this.escapeHtml(carrier.label || '') + '" placeholder="z.B. H1" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Total TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Breite (TE):</label>';
html += '<input type="number" class="dialog-carrier-te" value="' + (carrier.total_te || 12) + '" min="1" max="100" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Buttons
html += '<div style="display:flex;gap:10px;margin-top:15px;">';
html += '<button class="dialog-carrier-save" style="flex:1;background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px;cursor:pointer;font-weight:bold;">Speichern</button>';
html += '<button class="dialog-carrier-cancel" style="flex:1;background:#555;color:#fff;border:none;border-radius:4px;padding:10px;cursor:pointer;">Abbrechen</button>';
html += '</div></div>';
$('body').append(html);
$('.dialog-carrier-save').on('click', function() {
var label = $('.dialog-carrier-label').val();
var totalTe = parseInt($('.dialog-carrier-te').val()) || 12;
self.updateCarrier(carrierId, label, totalTe);
self.closeDialog();
});
$('.dialog-carrier-cancel, .schematic-dialog-overlay').on('click', function() {
self.closeDialog();
});
},
updateCarrier: function(carrierId, label, totalTe) {
var self = this;
var baseUrl = $('body').data('base-url') || '';
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'update',
carrier_id: carrierId,
label: label,
total_te: totalTe,
token: KundenKarte.token
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadData();
self.showMessage('Hutschiene aktualisiert', 'success');
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
}
},
error: function() {
self.showMessage('Fehler beim Speichern', 'error');
}
});
},
deleteCarrier: function(carrierId) {
var self = this;
var baseUrl = $('body').data('base-url') || '';
KundenKarte.showConfirm('Hutschiene löschen', 'Diese Hutschiene und alle Equipments darauf wirklich löschen?', function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'delete',
carrier_id: carrierId,
token: KundenKarte.token
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadData();
self.showMessage('Hutschiene gelöscht', 'success');
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
}
},
error: function() {
self.showMessage('Fehler beim Löschen', 'error');
}
});
});
},
showEditBusbarDialog: function(connectionId) {
var self = this;
var conn = this.connections.find(function(c) { return String(c.id) === String(connectionId); });
if (!conn) return;
var carrier = this.carriers.find(function(c) { return String(c.id) === String(conn.fk_carrier); });
var totalTE = carrier ? (parseFloat(carrier.total_te) || 12) : 12;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:400px;max-height:90vh;overflow-y:auto;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Sammelschiene bearbeiten</h3>';
// Busbar type selection from database
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Typ:</label>';
html += '<select class="dialog-busbar-type" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
var currentTypeId = conn.fk_busbar_type;
if (this.busbarTypes && this.busbarTypes.length > 0) {
this.busbarTypes.forEach(function(type) {
var selected = (String(type.id) === String(currentTypeId)) ? ' selected' : '';
html += '<option value="' + type.id + '"' + selected;
html += ' data-phases="' + self.escapeHtml(type.phases || '') + '"';
html += ' data-color="' + self.escapeHtml(type.default_color || type.color || '#e74c3c') + '"';
html += ' data-num-lines="' + (type.num_lines || 1) + '"';
html += ' data-colors="' + self.escapeHtml(type.color || '') + '"';
html += ' data-position="' + self.escapeHtml(type.position_default || 'below') + '"';
html += '>' + self.escapeHtml(type.label);
if (type.label_short && type.label_short !== type.label) {
html += ' (' + self.escapeHtml(type.label_short) + ')';
}
html += '</option>';
});
} else {
// Fallback wenn keine Typen geladen
var phases = conn.rail_phases || conn.connection_type || 'L1';
html += '<option value="0" data-phases="' + self.escapeHtml(phases) + '" selected>' + self.escapeHtml(phases) + '</option>';
}
html += '</select></div>';
// Start TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Von TE:</label>';
html += '<input type="number" class="dialog-busbar-start" value="' + (conn.rail_start_te || 1) + '" min="1" max="' + totalTE + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// End TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bis TE:</label>';
html += '<input type="number" class="dialog-busbar-end" value="' + (conn.rail_end_te || totalTE) + '" min="1" max="' + totalTE + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Position (above/below)
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Position:</label>';
html += '<select class="dialog-busbar-position" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
var posY = parseInt(conn.position_y) || 0;
html += '<option value="0"' + (posY === 0 ? ' selected' : '') + '>Oben</option>';
html += '<option value="1"' + (posY === 1 ? ' selected' : '') + '>Unten</option>';
html += '</select></div>';
// Excluded TEs
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">TEs auslassen (z.B. 3,5,7):</label>';
html += '<input type="text" class="dialog-busbar-excluded" value="' + (conn.excluded_te || '') + '" placeholder="Kommagetrennte TE-Nummern" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Color preview (read-only, set from type)
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Farbe (aus Typ):</label>';
html += '<div class="dialog-busbar-color-preview" style="height:30px;border-radius:4px;border:1px solid #555;background:' + (conn.color || '#e74c3c') + ';"></div></div>';
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="dialog-save" style="background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Speichern</button>';
html += '</div></div>';
$('body').append(html);
// Update color preview based on type selection
function updateFromType() {
var $selected = $('.dialog-busbar-type option:selected');
var color = $selected.data('color') || '#e74c3c';
var colors = $selected.data('colors') || color;
// Show multi-color preview if multiple colors
var colorArr = colors.split(',');
if (colorArr.length > 1) {
var gradient = 'linear-gradient(to right, ' + colorArr.join(', ') + ')';
$('.dialog-busbar-color-preview').css('background', gradient);
} else {
$('.dialog-busbar-color-preview').css('background', color);
}
}
$('.dialog-busbar-type').on('change', updateFromType);
updateFromType(); // Initial update
$('.dialog-cancel, .schematic-dialog-overlay').on('click', function() {
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-save').on('click', function() {
var $selected = $('.dialog-busbar-type option:selected');
var typeId = $('.dialog-busbar-type').val();
var phases = $selected.data('phases') || 'L1';
var color = $selected.data('colors') || $selected.data('color') || '#e74c3c';
var startTE = parseInt($('.dialog-busbar-start').val()) || 1;
var endTE = parseInt($('.dialog-busbar-end').val()) || totalTE;
var newPosY = parseInt($('.dialog-busbar-position').val()) || 0;
var excludedTE = $('.dialog-busbar-excluded').val() || '';
self.updateBusbar(connectionId, typeId, phases, startTE, endTE, newPosY, color, excludedTE);
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
},
updateBusbar: function(connectionId, typeId, phases, startTE, endTE, positionY, color, excludedTE) {
var self = this;
// Get phases_config from busbar type if available
var phasesConfig = null;
if (typeId && this.busbarTypes) {
var busbarType = this.busbarTypes.find(function(t) { return String(t.id) === String(typeId); });
if (busbarType && busbarType.phases_config) {
phasesConfig = busbarType.phases_config;
}
}
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: connectionId,
fk_busbar_type: typeId || 0,
rail_start_te: startTE,
rail_end_te: endTE,
rail_phases: phases,
position_y: positionY,
color: color,
excluded_te: excludedTE,
connection_type: phases,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Sammelschiene aktualisiert', 'success');
// Update local data
var conn = self.connections.find(function(c) { return String(c.id) === String(connectionId); });
if (conn) {
conn.fk_busbar_type = typeId;
conn.rail_start_te = startTE;
conn.rail_end_te = endTE;
conn.rail_phases = phases;
conn.position_y = positionY;
conn.color = color;
conn.excluded_te = excludedTE;
conn.connection_type = phases;
conn.phases_config = phasesConfig;
}
self.render();
} else {
self.showMessage(response.error || 'Fehler beim Aktualisieren', 'error');
}
}
});
},
deleteBusbar: function(connectionId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'delete',
connection_id: connectionId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Sammelschiene gelöscht', 'success');
// Remove from local data
self.connections = self.connections.filter(function(c) { return String(c.id) !== String(connectionId); });
self.render();
} else {
self.showMessage(response.error || 'Fehler beim Löschen', 'error');
}
}
});
},
loadData: function() {
var self = this;
// Prevent multiple simultaneous loads (race condition fix)
if (this.isLoading) {
return;
}
this.isLoading = true;
// Load panels with carriers for this anlage
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
data: { action: 'list_with_carriers', anlage_id: this.anlageId },
dataType: 'json',
success: function(response) {
if (response.success) {
self.panels = response.panels || [];
// Flatten carriers from all panels
self.carriers = [];
self.panels.forEach(function(panel) {
if (panel.carriers) {
panel.carriers.forEach(function(c) {
c.panel_id = panel.id;
self.carriers.push(c);
});
}
});
self.loadEquipment();
} else {
self.isLoading = false;
}
},
error: function(xhr, status, error) {
self.isLoading = false;
// Show visible error
$('.schematic-editor-canvas').html('<div style="padding:20px;color:#e74c3c;">AJAX Fehler beim Laden: ' + error + '</div>');
}
});
},
loadEquipment: function() {
var self = this;
var promises = [];
this.equipment = [];
this.carriers.forEach(function(carrier) {
var promise = $.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'list', carrier_id: carrier.id },
dataType: 'json'
}).then(function(response) {
if (response.success && response.equipment) {
response.equipment.forEach(function(eq) {
eq.carrier_id = carrier.id;
eq.panel_id = carrier.panel_id;
self.equipment.push(eq);
});
}
});
promises.push(promise);
});
$.when.apply($, promises).then(function() {
self.loadConnections();
}).fail(function() {
self.isLoading = false;
});
},
loadConnections: function() {
var self = this;
// Load connections and bridges in parallel
var connectionsLoaded = false;
var bridgesLoaded = false;
var checkComplete = function() {
if (connectionsLoaded && bridgesLoaded) {
// Build index maps for O(1) lookups (performance optimization)
self.buildIndexes();
// Terminal-Phasen-Map aufbauen (Busbar → Equipment → Durchreichung)
self.buildTerminalPhaseMap();
// Initialize canvas now that all data is loaded
if (!self.isInitialized) {
self.initCanvas();
} else {
self.render();
}
// Reset loading flag
self.isLoading = false;
// Refresh wire grid if wire draw mode is active
// (equipment positions may have changed after render)
if (self.wireDrawMode) {
self.showWireGrid();
}
}
};
// Load connections
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'list_all', anlage_id: this.anlageId },
dataType: 'json',
success: function(response) {
if (response.success) {
self.connections = response.connections || [];
// Restore any manually drawn paths
if (self._manualPaths) {
self.connections.forEach(function(conn) {
if (self._manualPaths[conn.id]) {
conn._manualPath = self._manualPaths[conn.id];
}
});
}
}
connectionsLoaded = true;
checkComplete();
},
error: function() {
connectionsLoaded = true;
checkComplete();
}
});
// Load bridges
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
data: { action: 'list_bridges', anlage_id: this.anlageId },
dataType: 'json',
success: function(response) {
if (response.success) {
self.bridges = response.bridges || [];
}
bridgesLoaded = true;
checkComplete();
},
error: function() {
bridgesLoaded = true;
checkComplete();
}
});
},
calculateLayout: function() {
var self = this;
// Use FIXED top margin to prevent layout shifts when connections change
// This ensures path_data coordinates remain valid after saving
// Allocate space for up to 10 routing lanes (enough for most schematics)
var maxRoutingLanes = 10;
this.panelTopMargin = this.MIN_TOP_MARGIN;
this.calculatedTopMargin = this.panelTopMargin + this.BLOCK_INNER_OFFSET + maxRoutingLanes * this.CONNECTION_ROUTE_SPACE;
// Calculate canvas size based on panels
// Panels nebeneinander, Hutschienen pro Panel untereinander
var totalWidth = this.PANEL_PADDING;
var maxPanelHeight = 0;
this.panels.forEach(function(panel) {
var panelCarriers = self.carriers.filter(function(c) { return c.panel_id == panel.id; });
var maxTE = 12;
panelCarriers.forEach(function(c) {
if ((c.total_te || 12) > maxTE) maxTE = c.total_te;
});
// Panel width includes: left margin for label (60px) + rail overhang (30px) + TE width + rail overhang (30px) + padding
var panelWidth = 60 + 30 + maxTE * self.TE_WIDTH + 30 + self.PANEL_PADDING;
// Panel height calculation:
// - calculatedTopMargin: space for main busbars at top
// - BLOCK_HEIGHT/2: half block above first rail
// - (carriers-1) * RAIL_SPACING: spacing between rails
// - BLOCK_HEIGHT/2: half block below last rail
// - BOTTOM_MARGIN: margin at bottom
// Simplified: calculatedTopMargin + BLOCK_HEIGHT + (carriers * RAIL_SPACING) + BOTTOM_MARGIN
var carrierCount = Math.max(panelCarriers.length, 1);
var panelHeight = self.calculatedTopMargin + self.BLOCK_HEIGHT + (carrierCount * self.RAIL_SPACING) + self.BOTTOM_MARGIN;
panel._x = totalWidth;
panel._width = panelWidth;
panel._height = panelHeight;
totalWidth += panelWidth + self.PANEL_GAP;
if (panelHeight > maxPanelHeight) maxPanelHeight = panelHeight;
});
totalWidth = Math.max(totalWidth, 400);
var totalHeight = Math.max(maxPanelHeight, 300);
// Fallback: wenn keine Panels, basierend auf Carriers
if (this.panels.length === 0 && this.carriers.length > 0) {
var fallbackMaxTE = 12;
this.carriers.forEach(function(c) {
if ((c.total_te || 12) > fallbackMaxTE) fallbackMaxTE = c.total_te;
});
totalWidth = fallbackMaxTE * self.TE_WIDTH + 150;
totalHeight = self.calculatedTopMargin + self.BLOCK_HEIGHT + (this.carriers.length * self.RAIL_SPACING) + self.BOTTOM_MARGIN;
}
// SVG mindestens so breit wie der Container (ohne zu skalieren)
var $canvas = $('.schematic-editor-canvas');
var containerWidth = $canvas.length ? ($canvas.innerWidth() - 30) : 800;
this.layoutWidth = Math.max(totalWidth, containerWidth, 800);
this.layoutHeight = Math.max(totalHeight, 400);
},
initCanvas: function() {
var $canvas = $('.schematic-editor-canvas');
if (!$canvas.length) {
return;
}
// Loading message
$canvas.html('<div style="padding:20px;color:#888;">Initialisiere Schaltplan-Editor...</div>');
// Calculate layout first
this.calculateLayout();
// Create SVG - flexible width with viewBox for content scaling
// data-darkreader-mode="disabled" tells Dark Reader to skip this element
var svg = '<svg class="schematic-svg" data-darkreader-mode="disabled" width="' + this.layoutWidth + '" height="' + this.layoutHeight + '" xmlns="http://www.w3.org/2000/svg">';
// Defs for markers and patterns
svg += '<defs>';
svg += '<pattern id="grid" width="' + this.GRID_SIZE + '" height="' + this.GRID_SIZE + '" patternUnits="userSpaceOnUse">';
svg += '<path d="M ' + this.GRID_SIZE + ' 0 L 0 0 0 ' + this.GRID_SIZE + '" fill="none" stroke="' + this.COLORS.grid + '" stroke-width="0.5"/>';
svg += '</pattern>';
svg += '<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#888"/></marker>';
svg += '</defs>';
// Background grid
svg += '<rect width="100%" height="100%" fill="url(#grid)"/>';
// Layers (order matters: back to front)
// Connections BEHIND blocks so wires "go through" blocks visually
svg += '<g class="schematic-rails-layer"></g>';
svg += '<g class="schematic-connections-layer"></g>'; // Leitungen hinter Blöcken
svg += '<g class="schematic-busbars-layer"></g>'; // Phasenschienen hinter Blöcken (Tap-Lines unter Blöcken)
svg += '<g class="schematic-blocks-layer"></g>';
svg += '<g class="schematic-terminals-layer"></g>';
// Connection preview line
svg += '<line class="schematic-connection-preview" x1="0" y1="0" x2="0" y2="0" stroke="#3498db" stroke-width="2" stroke-dasharray="5,5" style="display:none;"/>';
svg += '</svg>';
// Wrap SVG in zoom wrapper for coordinated scaling with controls
$canvas.html('<div class="schematic-zoom-wrapper" style="transform-origin:0 0;position:relative;">' + svg + '</div>');
this.svgElement = $canvas.find('.schematic-svg')[0];
this.isInitialized = true;
// Render content
this.render();
},
render: function() {
if (!this.isInitialized) return;
// Recalculate layout (panels may have changed)
this.calculateLayout();
// Update SVG size
var $svg = $(this.svgElement);
$svg.attr('width', this.layoutWidth);
$svg.attr('height', this.layoutHeight);
this.renderRails();
this.buildTerminalPhaseMap();
this.renderBlocks();
this.renderBridges();
this.renderBusbars();
this.renderConnections();
this.renderControls();
// Adjust SVG height to fit all content
this.adjustSvgHeight();
},
// Dynamically adjust SVG height based on actual content
adjustSvgHeight: function() {
var $svg = $(this.svgElement);
if (!$svg.length) return;
// Find the lowest Y position of all rendered elements
var maxY = this.layoutHeight;
// Check all busbar positions
var self = this;
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) {
var carrier = self.carriers.find(function(c) { return String(c.id) === String(conn.fk_carrier); });
if (carrier && carrier._y !== undefined) {
var posY = parseInt(conn.position_y) || 0;
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
var blockBottom = railCenterY + self.BLOCK_HEIGHT / 2;
var busbarY = posY === 0 ? railCenterY - self.BLOCK_HEIGHT / 2 - 60 : blockBottom + 60;
if (busbarY + 40 > maxY) maxY = busbarY + 40;
}
}
});
// Check output/input connections (Abgänge)
$svg.find('.schematic-output-label, .schematic-input-label').each(function() {
var y = parseFloat($(this).attr('y')) || 0;
if (y + 30 > maxY) maxY = y + 30;
});
// Add padding
maxY += 40;
// Update SVG height if needed
if (maxY > this.layoutHeight) {
this.layoutHeight = maxY;
$svg.attr('height', maxY);
// Also update panel backgrounds
this.panels.forEach(function(panel) {
if (panel._height < maxY - self.panelTopMargin) {
panel._height = maxY;
}
});
}
},
renderRails: function() {
var self = this;
var $layer = $(this.svgElement).find('.schematic-rails-layer');
$layer.empty();
var html = '';
// Render by panels (nebeneinander)
if (this.panels.length > 0) {
this.panels.forEach(function(panel) {
var panelX = panel._x || self.PANEL_PADDING;
var panelCarriers = self.carriers.filter(function(c) { return c.panel_id == panel.id; });
// Panel background - starts at panel top margin (before block offset)
if (panel._width && panel._height) {
var panelTop = self.panelTopMargin;
var panelContentHeight = panel._height - self.panelTopMargin;
html += '<rect class="schematic-panel-bg" x="' + (panelX - 14) + '" y="' + (panelTop - 14) + '" ';
html += 'width="' + (panel._width + 28) + '" height="' + (panelContentHeight + 28) + '" ';
html += 'fill="' + self.COLORS.panelBg + '" stroke="' + self.COLORS.panelBorder + '" stroke-width="1.5" rx="6"/>';
// Panel label - klickbar für Bearbeitung
html += '<text class="schematic-panel-label" data-panel-id="' + panel.id + '" x="' + (panelX + panel._width / 2) + '" y="' + (panelTop - 28) + '" ';
html += 'text-anchor="middle" fill="#aaa" font-size="17" font-weight="bold" style="cursor:pointer;">';
html += self.escapeHtml(panel.label || 'Feld');
html += '</text>';
}
// Carriers untereinander im Panel
// Layout: [LEFT_MARGIN for label+overhang] [TE slots] [RIGHT overhang+padding]
panelCarriers.forEach(function(carrier, carrierIdx) {
// Hutschiene is centered, blocks are centered on the Hutschiene
// Calculate rail position: TOP_MARGIN + half block height + carrier spacing
var railY = self.calculatedTopMargin + (self.BLOCK_HEIGHT / 2) + carrierIdx * self.RAIL_SPACING;
var width = (carrier.total_te || 12) * self.TE_WIDTH;
// x position: panelX + left margin (60px for label) + overhang (30px)
var x = panelX + 60 + 30;
// Store carrier position (rail Y position)
carrier._y = railY;
carrier._x = x;
// Rail overhang on both sides for label visibility
var railOverhang = 30;
// Rail background - Hutschiene (mit Überstand links und rechts)
html += '<rect class="schematic-rail-bg" x="' + (x - railOverhang) + '" y="' + railY + '" width="' + (width + railOverhang * 2) + '" height="' + self.RAIL_HEIGHT + '" ';
html += 'fill="' + self.COLORS.railBg + '" rx="2"/>';
// Rail (mit Überstand links und rechts) - klickbar für Bearbeitung
html += '<rect class="schematic-rail" data-carrier-id="' + carrier.id + '" x="' + (x - railOverhang) + '" y="' + (railY + 1) + '" width="' + (width + railOverhang * 2) + '" height="' + (self.RAIL_HEIGHT - 2) + '" ';
html += 'fill="' + self.COLORS.rail + '" rx="1" style="cursor:pointer;"/>';
// Rail label (links vom Überstand, centered with rail)
html += '<text x="' + (x - railOverhang - 7) + '" y="' + (railY + self.RAIL_HEIGHT / 2 + 4) + '" text-anchor="end" fill="#888" font-size="13">';
html += self.escapeHtml(carrier.label || 'H' + (carrierIdx + 1));
html += '</text>';
// TE-Markierungen auf der Schiene (ganzzahlige TEs deutlich, 0.5er-Zwischenmarken subtil)
var totalTECount = carrier.total_te || 12;
for (var te = 0; te <= totalTECount; te++) {
var teX = x + te * self.TE_WIDTH;
html += '<line x1="' + teX + '" y1="' + railY + '" x2="' + teX + '" y2="' + (railY + self.RAIL_HEIGHT) + '" stroke="#999" stroke-width="1.5"/>';
// 0.5-TE Zwischenmarkierung
if (te < totalTECount) {
var halfX = teX + self.TE_WIDTH / 2;
html += '<line x1="' + halfX + '" y1="' + (railY + 3) + '" x2="' + halfX + '" y2="' + (railY + self.RAIL_HEIGHT - 3) + '" stroke="#555" stroke-width="0.5"/>';
}
}
});
});
} else {
// Fallback: Carriers ohne Panels
this.carriers.forEach(function(carrier, idx) {
var blockTop = self.calculatedTopMargin + idx * self.RAIL_SPACING;
var railY = blockTop + self.BLOCK_HEIGHT + 10;
var width = (carrier.total_te || 12) * self.TE_WIDTH;
var x = self.PANEL_PADDING + 50;
carrier._y = railY;
carrier._x = x;
carrier._blockTop = blockTop;
// Rail overhang on both sides
var railOverhang = 30;
html += '<rect class="schematic-rail-bg" x="' + (x - railOverhang) + '" y="' + railY + '" width="' + (width + railOverhang * 2) + '" height="' + self.RAIL_HEIGHT + '" ';
html += 'fill="' + self.COLORS.railBg + '" rx="2"/>';
html += '<rect class="schematic-rail" data-carrier-id="' + carrier.id + '" x="' + (x - railOverhang) + '" y="' + (railY + 1) + '" width="' + (width + railOverhang * 2) + '" height="' + (self.RAIL_HEIGHT - 2) + '" ';
html += 'fill="' + self.COLORS.rail + '" rx="1" style="cursor:pointer;"/>';
html += '<text x="' + (x - railOverhang - 14) + '" y="' + (railY + self.RAIL_HEIGHT / 2 + 4) + '" text-anchor="end" fill="#888" font-size="15">';
html += self.escapeHtml(carrier.label || 'Hutschiene ' + (idx + 1));
html += '</text>';
// TE-Markierungen (ganzzahlige TEs deutlich, 0.5er subtil)
var totalTECount = carrier.total_te || 12;
for (var te = 0; te <= totalTECount; te++) {
var teX = x + te * self.TE_WIDTH;
html += '<line x1="' + teX + '" y1="' + railY + '" x2="' + teX + '" y2="' + (railY + self.RAIL_HEIGHT) + '" stroke="#999" stroke-width="1.5"/>';
if (te < totalTECount) {
var halfX = teX + self.TE_WIDTH / 2;
html += '<line x1="' + halfX + '" y1="' + (railY + 3) + '" x2="' + halfX + '" y2="' + (railY + self.RAIL_HEIGHT - 3) + '" stroke="#555" stroke-width="0.5"/>';
}
}
});
}
$layer.html(html);
},
renderBlocks: function() {
var self = this;
var $layer = $(this.svgElement).find('.schematic-blocks-layer');
var $terminalLayer = $(this.svgElement).find('.schematic-terminals-layer');
$layer.empty();
$terminalLayer.empty();
var blockHtml = '';
var terminalHtml = '';
this.equipment.forEach(function(eq) {
// Use index lookup O(1) instead of find() O(n)
var carrier = self.getCarrierById(eq.carrier_id);
if (!carrier) {
return;
}
if (typeof carrier._x === 'undefined' || carrier._x === null) {
self.log(' Equipment #' + eq.id + ' (' + eq.label + '): Carrier has no _x position set, skipping');
return;
}
var blockWidth = (eq.width_te || 1) * self.TE_WIDTH - 4;
var blockHeight = self.BLOCK_HEIGHT;
var x = parseFloat(carrier._x) + ((parseFloat(eq.position_te) || 1) - 1) * self.TE_WIDTH + 2;
// Position blocks centered on the rail (Hutschiene)
// Rail is at carrier._y, block center should align with rail center
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
var y = railCenterY - blockHeight / 2;
// Store position
eq._x = x;
eq._y = y;
eq._width = blockWidth;
eq._height = blockHeight;
var color = eq.block_color || eq.type_color || self.COLORS.block;
// Block group
blockHtml += '<g class="schematic-block" data-equipment-id="' + eq.id + '" data-carrier-id="' + carrier.id + '" transform="translate(' + x + ',' + y + ')">';
// Check if we have a block image
var hasBlockImage = eq.type_block_image && eq.type_block_image_url;
if (hasBlockImage) {
// Render image instead of colored rectangle
// Use xMidYMid slice to fill the block and crop if needed
blockHtml += '<image class="schematic-block-image" href="' + eq.type_block_image_url + '" ';
blockHtml += 'x="0" y="0" width="' + blockWidth + '" height="' + blockHeight + '" preserveAspectRatio="xMidYMid slice"/>';
// Border around the image block
blockHtml += '<rect class="schematic-block-bg" width="' + blockWidth + '" height="' + blockHeight + '" ';
blockHtml += 'fill="none" stroke="#333" stroke-width="1" rx="2"/>';
} else {
// Block background with gradient (original behavior)
blockHtml += '<rect class="schematic-block-bg" width="' + blockWidth + '" height="' + blockHeight + '" ';
blockHtml += 'fill="' + color + '" stroke="#222" stroke-width="1.5" rx="4"/>';
// Build block text lines
var lines = [];
// Line 1: Type label (e.g., LS1P, FI4P)
lines.push({
text: self.escapeHtml(eq.type_label_short || eq.type_ref || ''),
fontSize: 13,
fontWeight: 'bold',
color: '#fff'
});
// Line 2: show_on_block fields (e.g., B16, 40A 30mA)
var blockFieldValues = [];
if (eq.type_fields && eq.field_values) {
eq.type_fields.forEach(function(field) {
if (field.show_on_block && eq.field_values[field.field_code]) {
var val = eq.field_values[field.field_code];
// Add unit for known fields
if (field.field_code === 'ampere') {
val = val + 'A';
} else if (field.field_code === 'sensitivity') {
val = val + 'mA';
}
blockFieldValues.push(val);
}
});
}
if (blockFieldValues.length > 0) {
lines.push({
text: blockFieldValues.join(' '),
fontSize: 11,
fontWeight: 'bold',
color: '#fff'
});
}
// Line 3: Custom label (e.g., R2.1 or "Licht Küche")
if (eq.label) {
lines.push({
text: self.escapeHtml(eq.label),
fontSize: 10,
fontWeight: 'normal',
color: 'rgba(255,255,255,0.8)'
});
}
// Calculate vertical positioning for centered text
var totalLineHeight = 0;
lines.forEach(function(line) {
totalLineHeight += line.fontSize + 2; // fontSize + 2px spacing
});
var startY = (blockHeight - totalLineHeight) / 2 + lines[0].fontSize;
// Render text lines
var currentY = startY;
lines.forEach(function(line) {
blockHtml += '<text x="' + (blockWidth / 2) + '" y="' + currentY + '" text-anchor="middle" ';
blockHtml += 'fill="' + line.color + '" font-size="' + line.fontSize + '" font-weight="' + line.fontWeight + '">';
blockHtml += line.text;
blockHtml += '</text>';
currentY += line.fontSize + 3;
});
// Richtungspfeile (flow_direction)
var flowDir = eq.type_flow_direction;
if (flowDir) {
var arrowX = blockWidth - 12;
var arrowY1, arrowY2, arrowY3;
if (flowDir === 'top_to_bottom') {
// Pfeil nach unten ↓
arrowY1 = 10;
arrowY2 = blockHeight - 10;
blockHtml += '<line x1="' + arrowX + '" y1="' + arrowY1 + '" x2="' + arrowX + '" y2="' + arrowY2 + '" stroke="rgba(255,255,255,0.6)" stroke-width="2"/>';
blockHtml += '<polygon points="' + (arrowX - 5) + ',' + (arrowY2 - 8) + ' ' + arrowX + ',' + arrowY2 + ' ' + (arrowX + 5) + ',' + (arrowY2 - 8) + '" fill="rgba(255,255,255,0.6)"/>';
} else if (flowDir === 'bottom_to_top') {
// Pfeil nach oben ↑
arrowY1 = blockHeight - 10;
arrowY2 = 10;
blockHtml += '<line x1="' + arrowX + '" y1="' + arrowY1 + '" x2="' + arrowX + '" y2="' + arrowY2 + '" stroke="rgba(255,255,255,0.6)" stroke-width="2"/>';
blockHtml += '<polygon points="' + (arrowX - 5) + ',' + (arrowY2 + 8) + ' ' + arrowX + ',' + arrowY2 + ' ' + (arrowX + 5) + ',' + (arrowY2 + 8) + '" fill="rgba(255,255,255,0.6)"/>';
}
}
}
// Schutzgruppen-Markierung (FI/RCD)
if (eq.fk_protection) {
// Geschütztes Equipment: farbiger Balken unten (wie PWA)
var protColor = self.getProtectionColor(eq.fk_protection);
blockHtml += '<rect x="0" y="' + (blockHeight - 4) + '" width="' + blockWidth + '" height="4" ';
blockHtml += 'fill="' + protColor + '" opacity="0.9" pointer-events="none"/>';
}
// Schutzgerät selbst: linker Balken
var isProtectionDevice = self.equipment.some(function(e) { return e.fk_protection == eq.id; });
if (isProtectionDevice) {
var deviceColor = self.getProtectionColor(eq.id);
blockHtml += '<rect x="-4" y="0" width="4" height="' + blockHeight + '" ';
blockHtml += 'fill="' + deviceColor + '" rx="2" opacity="0.9"/>';
}
blockHtml += '</g>';
// Terminals (bidirectional)
// WICHTIG: Terminals werden im festen TE-Raster platziert
// Jeder Terminal nimmt 1 TE ein - ein 3TE Block mit 3 Terminals
// sieht genauso aus wie 3 einzelne 1TE Blöcke
var terminals = self.getTerminals(eq);
var topTerminals = terminals.filter(function(t) { return t.pos === 'top'; });
var bottomTerminals = terminals.filter(function(t) { return t.pos === 'bottom'; });
var widthTE = parseFloat(eq.width_te) || 1;
// Busbar-Abdeckung prüfen (Terminals unter Phasenschiene verstecken)
// Terminal-Position aus Equipment-Typ (both, top_only, bottom_only)
var terminalPos = eq.type_terminal_position || 'both';
var showTopTerminals = (terminalPos === 'both' || terminalPos === 'top_only');
var showBottomTerminals = (terminalPos === 'both' || terminalPos === 'bottom_only');
// Top terminals - im TE-Raster platziert (hide if busbar covers this equipment or terminal_position)
// Now supports stacked terminals with 'row' property (for Reihenklemmen)
var defaultTerminalColor = '#666'; // Grau wie die Hutschienen (wenn keine Verbindung)
var terminalSpacing = 14; // Vertical spacing between stacked terminals
if (showTopTerminals) {
// Group terminals by column (teIndex) for stacking
var topTerminalsByCol = {};
topTerminals.forEach(function(term, idx) {
var teIndex = term.col !== undefined ? term.col : (idx % widthTE);
if (!topTerminalsByCol[teIndex]) topTerminalsByCol[teIndex] = [];
topTerminalsByCol[teIndex].push(term);
});
Object.keys(topTerminalsByCol).forEach(function(colKey) {
var colTerminals = topTerminalsByCol[colKey];
var teIndex = parseInt(colKey);
var tx = x + (teIndex * self.TE_WIDTH) + (self.TE_WIDTH / 2);
colTerminals.forEach(function(term, rowIdx) {
var row = term.row !== undefined ? term.row : rowIdx;
var ty = y - 7 - (row * terminalSpacing);
// Get connection color for this terminal (or default gray)
var terminalColor = self.getTerminalConnectionColor(eq.id, term.id) || defaultTerminalColor;
terminalHtml += '<g class="schematic-terminal" ';
terminalHtml += 'data-equipment-id="' + eq.id + '" data-terminal-id="' + term.id + '" ';
terminalHtml += 'transform="translate(' + tx + ',' + ty + ')">';
terminalHtml += self.getTerminalShape(self.TERMINAL_RADIUS, terminalColor);
// Label - position depends on whether there are stacked terminals
var labelText = self.escapeHtml(term.label);
var labelWidth = labelText.length * 6 + 6;
if (colTerminals.length > 1) {
// Stacked: place label to the left of the terminal
terminalHtml += '<rect x="' + (-labelWidth - 8) + '" y="-7" width="' + labelWidth + '" height="14" rx="3" fill="rgba(0,0,0,0.85)" style="pointer-events:none;"/>';
terminalHtml += '<text x="' + (-labelWidth/2 - 8) + '" y="3" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold" style="pointer-events:none;">' + labelText + '</text>';
} else {
// Single: place label below in block
terminalHtml += '<rect x="' + (-labelWidth/2) + '" y="14" width="' + labelWidth + '" height="14" rx="3" fill="rgba(0,0,0,0.85)" style="pointer-events:none;"/>';
terminalHtml += '<text y="24" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold" style="pointer-events:none;">' + labelText + '</text>';
}
terminalHtml += '</g>';
});
});
}
// Bottom terminals - im TE-Raster platziert (hide if busbar covers bottom)
// Now supports stacked terminals with 'row' property
if (showBottomTerminals) {
// Group terminals by column (teIndex) for stacking
var bottomTerminalsByCol = {};
bottomTerminals.forEach(function(term, idx) {
var teIndex = term.col !== undefined ? term.col : (idx % widthTE);
if (!bottomTerminalsByCol[teIndex]) bottomTerminalsByCol[teIndex] = [];
bottomTerminalsByCol[teIndex].push(term);
});
Object.keys(bottomTerminalsByCol).forEach(function(colKey) {
var colTerminals = bottomTerminalsByCol[colKey];
var teIndex = parseInt(colKey);
var tx = x + (teIndex * self.TE_WIDTH) + (self.TE_WIDTH / 2);
colTerminals.forEach(function(term, rowIdx) {
var row = term.row !== undefined ? term.row : rowIdx;
var ty = y + blockHeight + 7 + (row * terminalSpacing);
// Get connection color for this terminal (or default gray)
var terminalColor = self.getTerminalConnectionColor(eq.id, term.id) || defaultTerminalColor;
terminalHtml += '<g class="schematic-terminal" ';
terminalHtml += 'data-equipment-id="' + eq.id + '" data-terminal-id="' + term.id + '" ';
terminalHtml += 'transform="translate(' + tx + ',' + ty + ')">';
terminalHtml += self.getTerminalShape(self.TERMINAL_RADIUS, terminalColor);
// Label - position depends on whether there are stacked terminals
var labelText = self.escapeHtml(term.label);
var labelWidth = labelText.length * 6 + 6;
if (colTerminals.length > 1) {
// Stacked: place label to the right of the terminal
terminalHtml += '<rect x="8" y="-7" width="' + labelWidth + '" height="14" rx="3" fill="rgba(0,0,0,0.85)" style="pointer-events:none;"/>';
terminalHtml += '<text x="' + (8 + labelWidth/2) + '" y="3" text-anchor="middle" fill="#fff" font-size="9" font-weight="bold" style="pointer-events:none;">' + labelText + '</text>';
} else {
// Single: place label above in block
terminalHtml += '<rect x="' + (-labelWidth/2) + '" y="-25" width="' + labelWidth + '" height="14" rx="3" fill="rgba(0,0,0,0.85)" style="pointer-events:none;"/>';
terminalHtml += '<text y="-15" text-anchor="middle" fill="#fff" font-size="10" font-weight="bold" style="pointer-events:none;">' + labelText + '</text>';
}
terminalHtml += '</g>';
});
});
}
});
$layer.html(blockHtml);
$terminalLayer.html(terminalHtml);
},
renderBridges: function() {
// Render terminal bridges (Brücken zwischen Reihenklemmen)
var self = this;
// Create or get bridge layer (between terminals and busbars)
var $bridgeLayer = $(this.svgElement).find('.schematic-bridges-layer');
if ($bridgeLayer.length === 0) {
// Insert after terminals layer
var $terminalLayer = $(this.svgElement).find('.schematic-terminals-layer');
$terminalLayer.after('<g class="schematic-bridges-layer"></g>');
$bridgeLayer = $(this.svgElement).find('.schematic-bridges-layer');
}
$bridgeLayer.empty();
if (!this.bridges || this.bridges.length === 0) {
return;
}
var html = '';
this.bridges.forEach(function(bridge) {
// Find carrier for this bridge
var carrier = self.carriers.find(function(c) {
return String(c.id) === String(bridge.fk_carrier);
});
if (!carrier || typeof carrier._x === 'undefined') {
return;
}
// Calculate bridge positions
var startTE = parseInt(bridge.start_te) || 1;
var endTE = parseInt(bridge.end_te) || startTE;
var terminalSide = bridge.terminal_side || 'top';
var terminalRow = parseInt(bridge.terminal_row) || 0;
var color = bridge.color || '#e74c3c';
// X positions for bridge endpoints (center of each TE)
var x1 = carrier._x + (startTE - 1) * self.TE_WIDTH + self.TE_WIDTH / 2;
var x2 = carrier._x + (endTE - 1) * self.TE_WIDTH + self.TE_WIDTH / 2;
// Y position based on carrier and terminal side
var railY = carrier._y;
var blockTop = railY + self.RAIL_HEIGHT / 2 - self.BLOCK_HEIGHT / 2;
var blockBottom = blockTop + self.BLOCK_HEIGHT;
var terminalSpacing = 14;
var y;
if (terminalSide === 'top') {
// Bridge above block, at terminal level
y = blockTop - 7 - (terminalRow * terminalSpacing);
} else {
// Bridge below block, at terminal level
y = blockBottom + 7 + (terminalRow * terminalSpacing);
}
// Bridge is a horizontal bar connecting terminals
var bridgeHeight = 6;
html += '<g class="schematic-bridge" data-bridge-id="' + bridge.id + '" data-carrier-id="' + carrier.id + '">';
// Bridge bar (horizontal rectangle)
html += '<rect x="' + x1 + '" y="' + (y - bridgeHeight/2) + '" ';
html += 'width="' + (x2 - x1) + '" height="' + bridgeHeight + '" ';
html += 'fill="' + color + '" stroke="#333" stroke-width="1" rx="2" ';
html += 'style="cursor:pointer;" class="schematic-bridge-bar"/>';
// Connection points at each end
html += '<circle cx="' + x1 + '" cy="' + y + '" r="4" fill="' + color + '" stroke="#333" stroke-width="1"/>';
html += '<circle cx="' + x2 + '" cy="' + y + '" r="4" fill="' + color + '" stroke="#333" stroke-width="1"/>';
// Optional label
if (bridge.label) {
var labelX = (x1 + x2) / 2;
html += '<text x="' + labelX + '" y="' + (y - bridgeHeight/2 - 3) + '" ';
html += 'text-anchor="middle" fill="#333" font-size="9" font-weight="bold">';
html += self.escapeHtml(bridge.label);
html += '</text>';
}
html += '</g>';
});
$bridgeLayer.html(html);
},
renderBusbars: function() {
// Render Sammelschienen (busbars) - connections with is_rail=1
var self = this;
var $layer = $(this.svgElement).find('.schematic-busbars-layer');
$layer.empty();
var html = '';
var renderedCount = 0;
// First, group busbars by carrier and position for stacking
var busbarsByCarrierAndPos = {};
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) !== 1) return;
var key = conn.fk_carrier + '_' + (parseInt(conn.position_y) || 0);
if (!busbarsByCarrierAndPos[key]) busbarsByCarrierAndPos[key] = [];
busbarsByCarrierAndPos[key].push(conn);
});
this.connections.forEach(function(conn) {
// Only process rails/busbars
if (parseInt(conn.is_rail) !== 1) {
return;
}
// Get carrier for this busbar (O(1) lookup)
var carrier = self.getCarrierById(conn.fk_carrier);
if (!carrier || typeof carrier._x === 'undefined') {
self.log(' Busbar #' + conn.id + ': No carrier found or carrier has no position');
return;
}
// Busbar spans from rail_start_te to rail_end_te
var startTE = parseInt(conn.rail_start_te) || 1;
var endTE = parseInt(conn.rail_end_te) || startTE + 1;
// Calculate pixel positions
var startX = carrier._x + (startTE - 1) * self.TE_WIDTH;
var endX = carrier._x + endTE * self.TE_WIDTH;
var width = endX - startX;
// Position busbar above or below the blocks based on position_y
// position_y: 0 = above (top), 1 = below (bottom)
var posY = parseInt(conn.position_y) || 0;
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
var blockTop = railCenterY - self.BLOCK_HEIGHT / 2;
var blockBottom = railCenterY + self.BLOCK_HEIGHT / 2;
// Count how many busbars are already at this position (to stack them)
var key = conn.fk_carrier + '_' + posY;
var carrierBusbars = busbarsByCarrierAndPos[key] || [];
var busbarIndex = carrierBusbars.indexOf(conn);
if (busbarIndex < 0) busbarIndex = 0;
// Busbar height and position - stack multiple busbars
var busbarHeight = 24;
var busbarSpacing = busbarHeight + 20; // Space between stacked busbars (including label space)
var busbarY;
if (posY === 0) {
// Above blocks - stack upwards
busbarY = blockTop - busbarHeight - 25 - (busbarIndex * busbarSpacing);
} else {
// Below blocks - stack downwards
busbarY = blockBottom + 25 + (busbarIndex * busbarSpacing);
}
// Farbe(n) aus Connection - kann komma-getrennt sein bei Mehrphasen-Busbars
var colorRaw = conn.color || self.PHASE_COLORS[conn.connection_type] || '#e74c3c';
var colors = colorRaw.split(',').map(function(c) { return c.trim(); });
var primaryColor = colors[0]; // Erste Farbe für den Balken
// Draw busbar as rounded rectangle
html += '<g class="schematic-busbar" data-connection-id="' + conn.id + '">';
// Shadow
html += '<rect x="' + startX + '" y="' + (busbarY + 2) + '" ';
html += 'width="' + width + '" height="' + busbarHeight + '" ';
html += 'rx="2" fill="rgba(0,0,0,0.3)"/>';
// Main bar
html += '<rect x="' + startX + '" y="' + busbarY + '" ';
html += 'width="' + width + '" height="' + busbarHeight + '" ';
html += 'rx="2" fill="#666" stroke="#222" stroke-width="1" style="cursor:pointer;"/>';
// Parse excluded TEs
var excludedTEs = [];
if (conn.excluded_te) {
excludedTEs = conn.excluded_te.split(',').map(function(t) { return parseInt(t.trim()); }).filter(function(t) { return !isNaN(t); });
}
// Determine phase labels - use phases_config from busbar type if available
var phases = conn.rail_phases || conn.connection_type || '';
var phaseLabels;
if (conn.phases_config && Array.isArray(conn.phases_config) && conn.phases_config.length > 0) {
// Use configured phase labels from busbar type admin settings
phaseLabels = conn.phases_config;
} else {
// Fall back to parsing phases string
phaseLabels = self.parsePhaseLabels(phases);
}
// Draw taps per TE (not per block) - each TE gets its own connection point
// Phase index counts ALL TEs from start (including excluded ones) for correct phase assignment
for (var te = startTE; te <= endTE; te++) {
// Calculate phase for this TE position (based on position from start, not rendered count)
var teOffset = te - startTE; // 0-based offset from start
var currentPhase = phaseLabels.length > 0 ? phaseLabels[teOffset % phaseLabels.length] : '';
// Skip excluded TEs (but phase still counts)
if (excludedTEs.indexOf(te) !== -1) continue;
// Calculate X position for this TE - center of the TE slot
// TE 1 starts at carrier._x, so center of TE 1 is at carrier._x + TE_WIDTH/2
// TE n center is at carrier._x + (n-1) * TE_WIDTH + TE_WIDTH/2
var teX = carrier._x + (te - 1) * self.TE_WIDTH + self.TE_WIDTH / 2;
// Draw tap line from busbar to block/terminal area
var tapStartY = posY === 0 ? busbarY + busbarHeight : busbarY;
var tapEndY;
if (posY === 0) {
tapEndY = blockTop - 7;
} else {
tapEndY = blockBottom + 7;
}
html += '<line x1="' + teX + '" y1="' + tapStartY + '" ';
html += 'x2="' + teX + '" y2="' + tapEndY + '" ';
html += 'stroke="#666" stroke-width="2" stroke-linecap="round"/>';
// Phase label at this TE connection
if (currentPhase) {
// Position label on the busbar
var phaseLabelY = busbarY + busbarHeight / 2 + 4;
html += '<text x="' + teX + '" y="' + phaseLabelY + '" ';
html += 'text-anchor="middle" fill="#fff" font-size="9" font-weight="bold">';
html += self.escapeHtml(currentPhase);
html += '</text>';
}
}
// Badge with phase name at the END of the busbar
if (phases) {
var badgeWidth = phases.length * 7 + 10;
var badgeX = endX + 5;
var badgeY = busbarY + (busbarHeight - 16) / 2;
html += '<rect x="' + badgeX + '" y="' + badgeY + '" width="' + badgeWidth + '" height="16" rx="3" ';
html += 'fill="#2d2d44" stroke="#666" stroke-width="1"/>';
html += '<text class="busbar-label" x="' + (badgeX + badgeWidth / 2) + '" y="' + (badgeY + 12) + '" ';
html += 'text-anchor="middle" fill="#fff" font-size="10" font-weight="bold">';
html += self.escapeHtml(phases);
html += '</text>';
}
html += '</g>';
renderedCount++;
self.log(' Busbar #' + conn.id + ': Rendered from TE ' + startTE + ' to ' + endTE + ' on carrier ' + carrier.id +
', x=' + startX + ', y=' + busbarY + ', width=' + width + ', color=' + primaryColor);
});
$layer.html(html);
},
renderConnections: function() {
var self = this;
var $layer = $(this.svgElement).find('.schematic-connections-layer');
$layer.empty();
var htmlBack = ''; // Leitungen (unten)
var htmlFront = ''; // Abgänge + Eingänge (oben)
var renderedCount = 0;
// Get display settings
var wireWidth = this.getWireWidth();
var shadowWidth = wireWidth + 4;
var hoverWidth = wireWidth + 1.5;
// Abgang-Bereiche vorab berechnen (für Badge-Kollisionserkennung)
this._outputAreas = [];
this.connections.forEach(function(conn) {
if (!conn.fk_target && !conn.path_data && conn.fk_source) {
var eq = self.getEquipmentById(conn.fk_source);
if (!eq) return;
var terms = self.getTerminals(eq);
var termId = conn.source_terminal_id || 't2';
var pos = self.getTerminalPosition(eq, termId, terms);
if (!pos) return;
var labelText = conn.output_label || '';
if (conn.output_location) labelText += ' · ' + conn.output_location;
var cableText = ((conn.medium_type || '') + ' ' + (conn.medium_spec || '')).trim();
var maxLen = Math.max(labelText.length, cableText.length);
var lineLen = Math.min(120, Math.max(50, maxLen * 6 + 20));
var goUp = pos.isTop;
self._outputAreas.push({
x: pos.x - 25,
y: goUp ? (pos.y - lineLen - 5) : pos.y,
w: 50,
h: lineLen + 10
});
}
});
this.connections.forEach(function(conn, connIndex) {
// Check is_rail as integer (PHP may return string "1" or "0")
if (parseInt(conn.is_rail) === 1) {
return;
}
// Use index lookup O(1) instead of find() O(n)
var sourceEq = self.getEquipmentById(conn.fk_source);
var targetEq = self.getEquipmentById(conn.fk_target);
// Farbe: Phase-Propagation > gespeicherte Farbe > Phase-Farbe
var color = (self._connectionColorMap && self._connectionColorMap[conn.id])
|| conn.color || self.getWireColor(conn.connection_type);
// ========================================
// ABGANG (Output) - source exists, no target
// Direction depends on terminal: top terminal = line goes UP, bottom terminal = line goes DOWN
// BUT: If path_data exists, render as normal wire with that path (junction to another wire)
// ========================================
if (sourceEq && !conn.fk_target && !conn.path_data) {
var sourceTerminals = self.getTerminals(sourceEq);
var sourceTermId = conn.source_terminal_id || 't2';
var sourcePos = self.getTerminalPosition(sourceEq, sourceTermId, sourceTerminals);
var isBundled = conn.bundled_terminals === 'all';
if (!sourcePos) {
// Fallback: center bottom of equipment
sourcePos = {
x: sourceEq._x + (sourceEq._width || sourceEq.width_te * self.TE_WIDTH) / 2,
y: sourceEq._y + (sourceEq._height || 60) + 5,
isTop: false
};
}
// For bundled terminals: calculate span across all terminals with same position
var bundleWidth = 0;
var bundleCenterX = sourcePos.x;
if (isBundled && sourceTerminals && sourceTerminals.length > 1) {
// Find all terminals with same position (top/bottom)
var samePosTerm = sourceTerminals.filter(function(t) {
return t.pos === (sourcePos.isTop ? 'top' : 'bottom');
});
if (samePosTerm.length > 1) {
var positions = samePosTerm.map(function(t) {
var pos = self.getTerminalPosition(sourceEq, t.id, sourceTerminals);
return pos ? pos.x : bundleCenterX;
});
var minX = Math.min.apply(null, positions);
var maxX = Math.max.apply(null, positions);
bundleWidth = maxX - minX;
bundleCenterX = (minX + maxX) / 2;
}
}
// Calculate line length based on label text
var labelText = conn.output_label || '';
if (conn.output_location) labelText += ' · ' + conn.output_location;
var cableText = (conn.medium_type || '') + ' ' + (conn.medium_spec || '');
var maxTextLen = Math.max(labelText.length, cableText.trim().length);
var lineLength = Math.min(120, Math.max(50, maxTextLen * 6 + 20));
// Direction: top terminal = UP, bottom terminal = DOWN
var goingUp = sourcePos.isTop;
var startY = sourcePos.y;
var endY = goingUp ? (startY - lineLength) : (startY + lineLength);
// Use bundle center for bundled connections
var lineX = isBundled && bundleWidth > 0 ? bundleCenterX : sourcePos.x;
// Draw vertical line
var path = 'M ' + lineX + ' ' + startY + ' L ' + lineX + ' ' + endY;
// Abgänge in htmlFront (über Leitungen)
htmlFront += '<g class="schematic-output-group' + (isBundled ? ' bundled' : '') + '" data-connection-id="' + conn.id + '" style="cursor:pointer;">';
// For bundled: draw horizontal bar connecting all terminals
if (isBundled && bundleWidth > 0) {
var barY = startY + (goingUp ? -5 : 5);
htmlFront += '<line x1="' + (bundleCenterX - bundleWidth/2) + '" y1="' + barY + '" ';
htmlFront += 'x2="' + (bundleCenterX + bundleWidth/2) + '" y2="' + barY + '" ';
htmlFront += 'stroke="' + color + '" stroke-width="' + (wireWidth + 1) + '" stroke-linecap="round"/>';
}
// Invisible hit area for clicking
var hitY = goingUp ? endY : startY;
var hitWidth = isBundled && bundleWidth > 0 ? Math.max(40, bundleWidth + 20) : 40;
htmlFront += '<rect x="' + (lineX - hitWidth/2) + '" y="' + hitY + '" width="' + hitWidth + '" height="' + lineLength + '" fill="transparent"/>';
// Connection line
htmlFront += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlFront += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round"/>';
// Arrow at end (pointing away from equipment)
var arrowSize = isBundled && bundleWidth > 0 ? 8 : 5;
if (goingUp) {
// Arrow pointing UP
htmlFront += '<polygon points="' + (lineX - arrowSize) + ',' + (endY + 6) + ' ' + lineX + ',' + endY + ' ' + (lineX + arrowSize) + ',' + (endY + 6) + '" fill="' + color + '"/>';
} else {
// Arrow pointing DOWN
htmlFront += '<polygon points="' + (lineX - arrowSize) + ',' + (endY - 6) + ' ' + lineX + ',' + endY + ' ' + (lineX + arrowSize) + ',' + (endY - 6) + '" fill="' + color + '"/>';
}
// Labels - vertical text on both sides
var labelY = (startY + endY) / 2;
// Left side: Bezeichnung (output_label) + Räumlichkeit
if (conn.output_label) {
htmlFront += '<text x="' + (lineX - 10) + '" y="' + labelY + '" ';
htmlFront += 'text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" ';
htmlFront += 'transform="rotate(-90 ' + (lineX - 10) + ' ' + labelY + ')">';
htmlFront += self.escapeHtml(conn.output_label);
if (conn.output_location) {
htmlFront += '<tspan font-size="9" font-weight="normal" font-style="italic" fill="#999"> · ' + self.escapeHtml(conn.output_location) + '</tspan>';
}
htmlFront += '</text>';
}
// Right side: Kabeltyp + Größe
var cableInfo = '';
if (conn.medium_type) cableInfo = conn.medium_type;
if (conn.medium_spec) cableInfo += ' ' + conn.medium_spec;
if (cableInfo) {
htmlFront += '<text x="' + (lineX + 10) + '" y="' + labelY + '" ';
htmlFront += 'text-anchor="middle" fill="#888" font-size="10" ';
htmlFront += 'transform="rotate(-90 ' + (lineX + 10) + ' ' + labelY + ')">';
htmlFront += self.escapeHtml(cableInfo.trim());
htmlFront += '</text>';
}
// Phase type at end of line
if (conn.connection_type) {
var phaseY = goingUp ? (endY - 10) : (endY + 14);
htmlFront += '<text x="' + lineX + '" y="' + phaseY + '" ';
htmlFront += 'text-anchor="middle" fill="' + color + '" font-size="11" font-weight="bold">';
htmlFront += conn.connection_type;
htmlFront += '</text>';
}
htmlFront += '</g>';
renderedCount++;
return;
}
// ========================================
// JUNCTION CONNECTION - branch from existing wire
// No source equipment but has path_data (manually drawn from junction point)
// ========================================
if (!conn.fk_source && targetEq && conn.path_data) {
var targetTerminals = self.getTerminals(targetEq);
var targetTermId = conn.target_terminal_id || 't1';
var targetPos = self.getTerminalPosition(targetEq, targetTermId, targetTerminals);
if (!targetPos) return;
// Use the manually drawn path
var path = conn.path_data;
// Junction connections use phase colors
var junctionColor = color;
// Extract first point from path for junction marker
var pathMatch = path.match(/M\s*([\d.-]+)\s*([\d.-]+)/);
var junctionX = pathMatch ? parseFloat(pathMatch[1]) : 0;
var junctionY = pathMatch ? parseFloat(pathMatch[2]) : 0;
htmlBack += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
htmlBack += '<g class="schematic-junction-group" data-connection-id="' + conn.id + '">';
htmlBack += '<path class="schematic-connection-hitarea" d="' + path + '" ';
htmlBack += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
htmlBack += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlBack += 'fill="none" stroke="' + junctionColor + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
// Junction marker (dot at start point)
if (self.displaySettings.showJunctions) {
htmlBack += '<circle cx="' + junctionX + '" cy="' + junctionY + '" r="5" fill="' + junctionColor + '" stroke="#fff" stroke-width="1.5"/>';
}
// Label if present
if (conn.output_label) {
var labelWidth = Math.min(conn.output_label.length * 8 + 16, 120);
var labelHeight = 22;
// Find safe position that doesn't overlap equipment
var labelPos = self.findSafeLabelPosition(path, labelWidth, labelHeight);
var labelX = labelPos ? labelPos.x : (junctionX + targetPos.x) / 2;
var labelY = labelPos ? labelPos.y : (junctionY + targetPos.y) / 2;
// Badge with solid background and border for visibility
// Text always white for readability (e.g. L2 black wire)
htmlBack += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
htmlBack += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
htmlBack += 'fill="#1a1a1a" stroke="' + junctionColor + '" stroke-width="1.5"/>';
htmlBack += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlBack += self.escapeHtml(conn.output_label);
htmlBack += '</text>';
}
htmlBack += '</g>';
renderedCount++;
return;
}
// ========================================
// ANSCHLUSSPUNKT (Input) - no source, target exists, NO path_data
// Draw a line coming FROM ABOVE into the terminal
// All Anschlusspunkte use a uniform color (light blue)
// ========================================
if (!conn.fk_source && targetEq) {
var targetTerminals = self.getTerminals(targetEq);
var targetTermId = conn.target_terminal_id || 't1';
var targetPos = self.getTerminalPosition(targetEq, targetTermId, targetTerminals);
if (!targetPos) return;
// Farbe: gespeichert > Phase-Farbe > Fallback hellblau
var inputColor = conn.color || self.PHASE_COLORS[conn.connection_type] || '#4fc3f7';
var isTop = targetPos.isTop;
// Linienlänge basierend auf Label
var inputLabel = conn.output_label || '';
var inputLineLength = Math.min(80, Math.max(45, inputLabel.length * 5 + 30));
// Richtung: Top-Terminal = Linie von oben, Bottom-Terminal = Linie von unten
var startY = isTop ? (targetPos.y - inputLineLength) : (targetPos.y + inputLineLength);
var path = 'M ' + targetPos.x + ' ' + startY + ' L ' + targetPos.x + ' ' + targetPos.y;
// Eingänge in htmlFront (über Leitungen)
htmlFront += '<g class="schematic-input-group" data-connection-id="' + conn.id + '" style="cursor:pointer;">';
// Invisible hit area
var hitY = isTop ? startY : targetPos.y;
htmlFront += '<rect x="' + (targetPos.x - 20) + '" y="' + hitY + '" width="40" height="' + inputLineLength + '" fill="transparent"/>';
// Verbindungslinie
htmlFront += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlFront += 'fill="none" stroke="' + inputColor + '" stroke-width="' + wireWidth + '" stroke-linecap="round"/>';
// Kreis am externen Ende (Quell-Indikator)
htmlFront += '<circle cx="' + targetPos.x + '" cy="' + startY + '" r="6" fill="' + inputColor + '" stroke="#fff" stroke-width="2"/>';
// Pfeil ins Terminal (Richtung abhängig von Position)
if (isTop) {
// Pfeil nach unten ins Top-Terminal
htmlFront += '<polygon points="' + (targetPos.x - 5) + ',' + (targetPos.y - 8) + ' ' + targetPos.x + ',' + (targetPos.y - 2) + ' ' + (targetPos.x + 5) + ',' + (targetPos.y - 8) + '" fill="' + inputColor + '"/>';
} else {
// Pfeil nach oben ins Bottom-Terminal
htmlFront += '<polygon points="' + (targetPos.x - 5) + ',' + (targetPos.y + 8) + ' ' + targetPos.x + ',' + (targetPos.y + 2) + ' ' + (targetPos.x + 5) + ',' + (targetPos.y + 8) + '" fill="' + inputColor + '"/>';
}
// Badge am Ende der Linie: Bezeichnung wenn vorhanden, sonst Phase
var badgeLabel = conn.output_label ? self.escapeHtml(conn.output_label) : (conn.connection_type || 'L1');
var phaseBadgeWidth = Math.max(badgeLabel.length * 9 + 12, 30);
var phaseBadgeHeight = 22;
var phaseBadgeX = targetPos.x - phaseBadgeWidth / 2;
var phaseBadgeY = isTop ? (startY - phaseBadgeHeight - 8) : (startY + 8);
htmlFront += '<rect x="' + phaseBadgeX + '" y="' + phaseBadgeY + '" ';
htmlFront += 'width="' + phaseBadgeWidth + '" height="' + phaseBadgeHeight + '" rx="4" ';
htmlFront += 'fill="' + inputColor + '" stroke="#fff" stroke-width="1"/>';
htmlFront += '<text x="' + targetPos.x + '" y="' + (phaseBadgeY + 16) + '" ';
htmlFront += 'text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlFront += badgeLabel;
htmlFront += '</text>';
htmlFront += '</g>';
renderedCount++;
return;
}
// ========================================
// LEITUNG ZU LEITUNG - source exists, no target, but has path_data
// Wire ends at another wire (junction), rendered with stored path
// ========================================
if (sourceEq && !conn.fk_target && conn.path_data) {
// Use the manually drawn path
var path = conn.path_data;
// Extract last point for junction marker
var pathPoints = path.match(/[ML]\s*([\d.-]+)\s*([\d.-]+)/g);
var lastMatch = pathPoints && pathPoints.length > 0 ? pathPoints[pathPoints.length - 1].match(/([\d.-]+)\s*([\d.-]+)/) : null;
var junctionX = lastMatch ? parseFloat(lastMatch[1]) : 0;
var junctionY = lastMatch ? parseFloat(lastMatch[2]) : 0;
htmlBack += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
htmlBack += '<g class="schematic-connection-group" data-connection-id="' + conn.id + '">';
htmlBack += '<path class="schematic-connection-hitarea" d="' + path + '" ';
htmlBack += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
htmlBack += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlBack += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
// Junction marker (dot at end point where it connects to other wire)
htmlBack += '<circle cx="' + junctionX + '" cy="' + junctionY + '" r="4" fill="' + color + '" stroke="#fff" stroke-width="1.5"/>';
// Label if present
if (conn.output_label) {
var labelWidth = Math.min(conn.output_label.length * 8 + 16, 120);
var labelHeight = 22;
var labelPos = self.findSafeLabelPosition(path, labelWidth, labelHeight);
var labelX = labelPos ? labelPos.x : junctionX;
var labelY = labelPos ? labelPos.y : junctionY - 20;
htmlBack += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
htmlBack += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
htmlBack += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1.5"/>';
htmlBack += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlBack += self.escapeHtml(conn.output_label);
htmlBack += '</text>';
}
htmlBack += '</g>';
renderedCount++;
return;
}
// ========================================
// NORMAL CONNECTION - both source and target exist
// ========================================
if (!sourceEq || !targetEq) {
return;
}
var sourceTerminals = self.getTerminals(sourceEq);
var targetTerminals = self.getTerminals(targetEq);
var sourceTermId = conn.source_terminal_id || 't2';
var targetTermId = conn.target_terminal_id || 't1';
var sourcePos = self.getTerminalPosition(sourceEq, sourceTermId, sourceTerminals);
var targetPos = self.getTerminalPosition(targetEq, targetTermId, targetTerminals);
if (!sourcePos || !targetPos) return;
var routeOffset = connIndex * 8;
var path;
if (conn.path_data) {
path = conn.path_data;
} else {
path = self.createOrthogonalPath(sourcePos, targetPos, routeOffset, sourceEq, targetEq);
}
htmlBack += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
htmlBack += '<g class="schematic-connection-group" data-connection-id="' + conn.id + '">';
htmlBack += '<path class="schematic-connection-hitarea" d="' + path + '" ';
htmlBack += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
htmlBack += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlBack += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
if (conn.output_label) {
var labelWidth = Math.min(conn.output_label.length * 8 + 16, 120);
var labelHeight = 22;
var labelPos = self.findSafeLabelPosition(path, labelWidth, labelHeight);
if (!labelPos) {
labelPos = self.getPathMidpoint(path);
}
var labelX = labelPos ? labelPos.x : (sourcePos.x + targetPos.x) / 2;
var labelY = labelPos ? labelPos.y : (sourcePos.y + targetPos.y) / 2;
htmlBack += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
htmlBack += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
htmlBack += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1.5"/>';
htmlBack += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlBack += self.escapeHtml(conn.output_label);
htmlBack += '</text>';
}
if (conn.connection_type && !conn.output_label) {
var typeWidth = conn.connection_type.length * 9 + 14;
var typeHeight = 18;
var typePos = self.findSafeLabelPosition(path, typeWidth, typeHeight);
var typeX = typePos ? typePos.x : (sourcePos.x + targetPos.x) / 2;
var typeY = typePos ? typePos.y : (sourcePos.y + targetPos.y) / 2;
htmlBack += '<rect class="connection-type-bg" x="' + (typeX - typeWidth/2) + '" y="' + (typeY - 10) + '" ';
htmlBack += 'width="' + typeWidth + '" height="' + typeHeight + '" rx="3" ';
htmlBack += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1"/>';
htmlBack += '<text x="' + typeX + '" y="' + (typeY + 4) + '" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">';
htmlBack += conn.connection_type;
htmlBack += '</text>';
}
htmlBack += '</g>';
renderedCount++;
});
// Leitungen zuerst (hinten), dann Abgänge/Eingänge (vorne)
$layer.html(htmlBack + htmlFront);
// Bind click events to SVG connection elements (must be done after rendering)
var self = this;
// Normal connections
$layer.find('.schematic-connection-group').each(function() {
var $group = $(this);
var connId = $group.data('connection-id');
var $visiblePath = $group.find('.schematic-connection');
this.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Nach Wire-Drag kein Click-Event auswerten
if (self._wireDragJustEnded) return;
// If we're in wire draw mode with a source terminal selected,
// create a junction: connection ends at the click point on the wire
if (self.wireDrawMode && self.wireDrawSourceEq && connId) {
var conn = self.connections.find(function(c) { return c.id == connId; });
if (conn) {
// Get click position relative to SVG
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var rawClickX = e.clientX - svgRect.left;
var rawClickY = e.clientY - svgRect.top;
// Snap click point to the nearest point on the wire
var snappedPoint = self.snapToWirePath(conn.path_data, rawClickX, rawClickY);
var clickX = Math.round(snappedPoint.x);
var clickY = Math.round(snappedPoint.y);
var sourceEqId = self.wireDrawSourceEq;
var sourceTermId = self.wireDrawSourceTerm;
// Build path: source terminal → existing points → click point on wire
var pathPoints = self.wireDrawPoints.slice();
// Add click point with orthogonal correction
if (pathPoints.length > 0) {
var lastPt = pathPoints[pathPoints.length - 1];
if (lastPt.x !== clickX && lastPt.y !== clickY) {
// Insert intermediate point for 90° bend
pathPoints.push({x: lastPt.x, y: clickY});
}
}
pathPoints.push({x: clickX, y: clickY});
// Remove redundant points (points on same line as neighbors)
pathPoints = self.cleanupPathPoints(pathPoints);
// Build path string
var pathData = '';
for (var i = 0; i < pathPoints.length; i++) {
var pt = pathPoints[i];
pathData += (i === 0 ? 'M ' : 'L ') + pt.x + ' ' + pt.y + ' ';
}
// Store connection values to inherit from clicked wire
self._inheritFromConnection = conn;
self._pendingPathData = pathData.trim() || null;
// Clear source terminal but keep draw mode active
self.cleanupWireDrawState(true);
// Leitung endet an der angeklickten Leitung (kein Ziel-Terminal)
self.showConnectionLabelDialog(sourceEqId, sourceTermId, null, null);
return;
}
}
// Normal click: show popup
if (connId) {
self.showConnectionPopup(connId, e.clientX, e.clientY);
}
});
this.addEventListener('mouseenter', function() {
$visiblePath.attr('stroke-width', hoverWidth);
});
this.addEventListener('mouseleave', function() {
$visiblePath.attr('stroke-width', wireWidth);
});
// Wire-Segment-Drag (nur im Zeichnungsmodus, Shift+Klick)
this.addEventListener('mousedown', function(e) {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
if (!self.wireDrawMode) return;
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var zoom = self.scale || 1;
var clickX = (e.clientX - svgRect.left) / zoom;
var clickY = (e.clientY - svgRect.top) / zoom;
if (self.startWireDrag(connId, clickX, clickY)) {
var moveHandler = function(moveE) {
self.handleWireDragMove((moveE.clientX - svgRect.left) / zoom, (moveE.clientY - svgRect.top) / zoom);
};
var upHandler = function(upE) {
self.finishWireDrag((upE.clientX - svgRect.left) / zoom, (upE.clientY - svgRect.top) / zoom);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
}
});
this.style.cursor = 'pointer';
});
// Abgang (Output) groups - click to edit or junction
$layer.find('.schematic-output-group').each(function() {
var $group = $(this);
var connId = $group.data('connection-id');
var $visiblePath = $group.find('.schematic-connection');
this.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Nach Wire-Drag kein Click-Event auswerten
if (self._wireDragJustEnded) return;
// If in wire draw mode with source terminal, create junction at click point
if (self.wireDrawMode && self.wireDrawSourceEq && connId) {
var conn = self.connections.find(function(c) { return c.id == connId; });
if (conn) {
// Get click position relative to SVG
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var rawClickX = e.clientX - svgRect.left;
var rawClickY = e.clientY - svgRect.top;
// Abgänge are vertical lines - snap X to the line's X coordinate
// Get source equipment position for the output
var outputEq = self.equipment.find(function(eq) { return eq.id == conn.fk_source; });
var clickX, clickY;
if (outputEq && conn.source_terminal_id) {
var terminals = self.getTerminals(outputEq);
var termPos = self.getTerminalPosition(outputEq, conn.source_terminal_id, terminals);
if (termPos) {
// Snap X to terminal X (vertical line), keep Y from click
clickX = Math.round(termPos.x);
clickY = Math.round(rawClickY);
} else {
clickX = Math.round(rawClickX);
clickY = Math.round(rawClickY);
}
} else {
clickX = Math.round(rawClickX);
clickY = Math.round(rawClickY);
}
var sourceEqId = self.wireDrawSourceEq;
var sourceTermId = self.wireDrawSourceTerm;
// Build path ending at click point
var pathPoints = self.wireDrawPoints.slice();
if (pathPoints.length > 0) {
var lastPt = pathPoints[pathPoints.length - 1];
if (lastPt.x !== clickX && lastPt.y !== clickY) {
pathPoints.push({x: lastPt.x, y: clickY});
}
}
pathPoints.push({x: clickX, y: clickY});
// Remove redundant points
pathPoints = self.cleanupPathPoints(pathPoints);
var pathData = '';
for (var i = 0; i < pathPoints.length; i++) {
var pt = pathPoints[i];
pathData += (i === 0 ? 'M ' : 'L ') + pt.x + ' ' + pt.y + ' ';
}
self._inheritFromConnection = conn;
self._pendingPathData = pathData.trim() || null;
self.cleanupWireDrawState(true);
// Leitung endet an Abgang
self.showConnectionLabelDialog(sourceEqId, sourceTermId, null, null);
return;
}
}
if (connId) {
self.showConnectionPopup(connId, e.clientX, e.clientY);
}
});
this.addEventListener('mouseenter', function() {
$visiblePath.attr('stroke-width', hoverWidth);
});
this.addEventListener('mouseleave', function() {
$visiblePath.attr('stroke-width', wireWidth);
});
// Wire-Drag für Abgänge (nur im Zeichnungsmodus, Shift+Klick)
this.addEventListener('mousedown', function(e) {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
if (!self.wireDrawMode) return;
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var zoom = self.scale || 1;
var clickX = (e.clientX - svgRect.left) / zoom;
var clickY = (e.clientY - svgRect.top) / zoom;
if (self.startWireDrag(connId, clickX, clickY)) {
var moveHandler = function(moveE) {
self.handleWireDragMove((moveE.clientX - svgRect.left) / zoom, (moveE.clientY - svgRect.top) / zoom);
};
var upHandler = function(upE) {
self.finishWireDrag((upE.clientX - svgRect.left) / zoom, (upE.clientY - svgRect.top) / zoom);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
}
});
});
// Anschlusspunkt (Input) groups - click to edit or junction
$layer.find('.schematic-input-group').each(function() {
var $group = $(this);
var connId = $group.data('connection-id');
var $visiblePath = $group.find('.schematic-connection');
this.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Nach Wire-Drag kein Click-Event auswerten
if (self._wireDragJustEnded) return;
// If in wire draw mode with source terminal, connect to input's target
if (self.wireDrawMode && self.wireDrawSourceEq && connId) {
var conn = self.connections.find(function(c) { return c.id == connId; });
if (conn && conn.fk_target) {
var sourceEqId = self.wireDrawSourceEq;
var sourceTermId = self.wireDrawSourceTerm;
var targetEqId = conn.fk_target;
var targetTermId = conn.target_terminal_id || 'input';
// Build path with existing points
var pathPoints = self.wireDrawPoints.slice();
var targetEq = self.equipment.find(function(eq) { return eq.id == targetEqId; });
if (targetEq) {
var terminals = self.getTerminals(targetEq);
var targetPos = self.getTerminalPosition(targetEq, targetTermId, terminals);
if (targetPos) {
pathPoints.push({x: targetPos.x, y: targetPos.y});
}
}
var pathData = '';
for (var i = 0; i < pathPoints.length; i++) {
pathData += (i === 0 ? 'M' : 'L') + ' ' + pathPoints[i].x + ' ' + pathPoints[i].y + ' ';
}
self._inheritFromConnection = conn;
self._pendingPathData = pathData.trim() || null;
self.cleanupWireDrawState(true);
self.showConnectionLabelDialog(sourceEqId, sourceTermId, targetEqId, targetTermId);
return;
}
}
if (connId) {
self.showConnectionPopup(connId, e.clientX, e.clientY);
}
});
this.addEventListener('mouseenter', function() {
$visiblePath.attr('stroke-width', hoverWidth);
});
this.addEventListener('mouseleave', function() {
$visiblePath.attr('stroke-width', wireWidth);
});
// Wire-Drag für Anschlusspunkte (nur im Zeichnungsmodus, Shift+Klick)
this.addEventListener('mousedown', function(e) {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
if (!self.wireDrawMode) return;
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var zoom = self.scale || 1;
var clickX = (e.clientX - svgRect.left) / zoom;
var clickY = (e.clientY - svgRect.top) / zoom;
if (self.startWireDrag(connId, clickX, clickY)) {
var moveHandler = function(moveE) {
self.handleWireDragMove((moveE.clientX - svgRect.left) / zoom, (moveE.clientY - svgRect.top) / zoom);
};
var upHandler = function(upE) {
self.finishWireDrag((upE.clientX - svgRect.left) / zoom, (upE.clientY - svgRect.top) / zoom);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
}
});
});
// Junction (Branch) groups - click to edit
$layer.find('.schematic-junction-group').each(function() {
var $group = $(this);
var connId = $group.data('connection-id');
var $visiblePath = $group.find('.schematic-connection');
this.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Nach Wire-Drag kein Click-Event auswerten
if (self._wireDragJustEnded) return;
if (connId) {
self.showConnectionPopup(connId, e.clientX, e.clientY);
}
});
this.addEventListener('mouseenter', function() {
$visiblePath.attr('stroke-width', hoverWidth);
});
this.addEventListener('mouseleave', function() {
$visiblePath.attr('stroke-width', wireWidth);
});
// Wire-Drag für Junctions (nur im Zeichnungsmodus, Shift+Klick)
this.addEventListener('mousedown', function(e) {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
if (!self.wireDrawMode) return;
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var zoom = self.scale || 1;
var clickX = (e.clientX - svgRect.left) / zoom;
var clickY = (e.clientY - svgRect.top) / zoom;
if (self.startWireDrag(connId, clickX, clickY)) {
var moveHandler = function(moveE) {
self.handleWireDragMove((moveE.clientX - svgRect.left) / zoom, (moveE.clientY - svgRect.top) / zoom);
};
var upHandler = function(upE) {
self.finishWireDrag((upE.clientX - svgRect.left) / zoom, (upE.clientY - svgRect.top) / zoom);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
}
});
this.style.cursor = 'pointer';
});
},
renderControls: function() {
var self = this;
var $canvas = $('.schematic-editor-canvas');
// Remove existing controls
$canvas.find('.schematic-controls').remove();
// Find wrapper or canvas for control placement
var $wrapper = $canvas.find('.schematic-zoom-wrapper');
var $controlsParent = $wrapper.length ? $wrapper : $canvas;
// Create controls container (positioned absolute over SVG)
var $controls = $('<div class="schematic-controls" style="position:absolute;top:0;left:0;pointer-events:none;"></div>');
// Button style
var btnStyle = 'pointer-events:auto;width:28px;height:28px;border-radius:50%;border:2px solid #3498db;' +
'background:#2d2d44;color:#3498db;font-size:18px;font-weight:bold;cursor:pointer;' +
'display:flex;align-items:center;justify-content:center;transition:all 0.2s;';
var btnHoverStyle = 'background:#3498db;color:#fff;';
// Add Panel button (right of last panel)
if (this.panels.length > 0) {
var lastPanel = this.panels[this.panels.length - 1];
var addPanelX = (lastPanel._x || 0) + (lastPanel._width || 200) + 20;
var addPanelY = this.panelTopMargin + 50;
var $addPanel = $('<button class="schematic-add-panel" title="Neues Feld hinzufügen" style="position:absolute;left:' + addPanelX + 'px;top:' + addPanelY + 'px;' + btnStyle + '">+</button>');
$controls.append($addPanel);
// Copy Panel button (if more than one panel exists)
if (this.panels.length >= 1) {
var $copyPanel = $('<button class="schematic-copy-panel" data-panel-id="' + lastPanel.id + '" title="Feld kopieren" style="position:absolute;left:' + (addPanelX) + 'px;top:' + (addPanelY + 40) + 'px;' + btnStyle + '"><i class="fas fa-copy" style="font-size:12px;"></i></button>');
$controls.append($copyPanel);
}
} else {
// No panels - show add panel button
var $addPanel = $('<button class="schematic-add-panel" title="Neues Feld hinzufügen" style="position:absolute;left:50px;top:50px;' + btnStyle + '">+</button>');
$controls.append($addPanel);
}
// Add Carrier button (below each panel's last carrier, or at top if no carriers)
this.panels.forEach(function(panel) {
var panelCarriers = self.carriers.filter(function(c) { return c.panel_id == panel.id; });
var lastCarrier = panelCarriers[panelCarriers.length - 1];
if (lastCarrier && lastCarrier._y) {
var addCarrierY = lastCarrier._y + self.RAIL_HEIGHT + 30;
var addCarrierX = (panel._x || 0) + 60;
var $addCarrier = $('<button class="schematic-add-carrier" data-panel-id="' + panel.id + '" title="Hutschiene hinzufügen" style="position:absolute;left:' + addCarrierX + 'px;top:' + addCarrierY + 'px;' + btnStyle + '">+</button>');
$controls.append($addCarrier);
// Copy Carrier button
if (panelCarriers.length >= 1) {
var $copyCarrier = $('<button class="schematic-copy-carrier" data-carrier-id="' + lastCarrier.id + '" data-panel-id="' + panel.id + '" title="Hutschiene kopieren" style="position:absolute;left:' + (addCarrierX + 35) + 'px;top:' + addCarrierY + 'px;' + btnStyle + '"><i class="fas fa-copy" style="font-size:12px;"></i></button>');
$controls.append($copyCarrier);
}
} else {
// No carriers in this panel - show add carrier button at top
var addCarrierY = self.calculatedTopMargin + 20;
var addCarrierX = (panel._x || 0) + 60;
var $addCarrier = $('<button class="schematic-add-carrier" data-panel-id="' + panel.id + '" title="Hutschiene hinzufügen" style="position:absolute;left:' + addCarrierX + 'px;top:' + addCarrierY + 'px;' + btnStyle + '">+</button>');
$controls.append($addCarrier);
// Delete empty panel button
var $deletePanel = $('<button class="schematic-delete-panel" data-panel-id="' + panel.id + '" title="Leeres Feld löschen" style="position:absolute;left:' + (addCarrierX + 40) + 'px;top:' + addCarrierY + 'px;' + btnStyle + 'color:#e74c3c !important;"><i class="fa fa-trash" style="font-size:12px;"></i></button>');
$controls.append($deletePanel);
}
});
// Add Equipment button & Copy button (next to last equipment on each carrier)
this.carriers.forEach(function(carrier) {
var carrierEquipment = self.equipment.filter(function(e) { return String(e.carrier_id) === String(carrier.id); });
// Check if carrier has position data (use typeof to allow 0 values)
if (typeof carrier._x !== 'undefined' && typeof carrier._y !== 'undefined') {
// Belegte Ranges ermitteln (Dezimal-TE-Unterstützung)
var totalTE = parseFloat(carrier.total_te) || 12;
var lastEquipment = null;
var lastEndPos = 0;
var ranges = [];
carrierEquipment.forEach(function(eq) {
var pos = parseFloat(eq.position_te) || 1;
var w = parseFloat(eq.width_te) || 1;
var endPos = pos + w;
ranges.push({ start: pos, end: endPos });
if (endPos > lastEndPos) {
lastEndPos = endPos;
lastEquipment = eq;
}
});
ranges.sort(function(a, b) { return a.start - b.start; });
// Maximale zusammenhängende Lücke berechnen
var maxGap = 0;
var gapPos = 1;
var railEnd = totalTE + 1;
ranges.forEach(function(r) {
var gap = r.start - gapPos;
if (gap > maxGap) maxGap = gap;
if (r.end > gapPos) gapPos = r.end;
});
var endGap = railEnd - gapPos;
if (endGap > maxGap) maxGap = endGap;
// Carrier-Objekt merkt sich maximale Lücke für Typ-Filter
carrier._maxGap = maxGap;
// Calculate button positions - place buttons on the LEFT side of the carrier
// Rail label is at about carrier._x - 44, so buttons need to be further left
var btnX = carrier._x - 100; // Left of the carrier and rail label
var btnY = carrier._y - self.BLOCK_HEIGHT / 2 + 10; // Aligned with blocks
if (maxGap > 0) {
// Add Equipment button - positioned left of carrier
var $addEquipment = $('<button class="schematic-add-equipment" data-carrier-id="' + carrier.id + '" title="Equipment hinzufügen" style="position:absolute;left:' + btnX + 'px;top:' + btnY + 'px;' + btnStyle + '">+</button>');
$controls.append($addEquipment);
// Copy Equipment button (below the + button)
if (lastEquipment) {
var copyBtnY = btnY + 30;
var $copyEquipment = $('<button class="schematic-copy-equipment" data-equipment-id="' + lastEquipment.id + '" data-carrier-id="' + carrier.id + '" title="Letztes Equipment kopieren" style="position:absolute;left:' + btnX + 'px;top:' + copyBtnY + 'px;' + btnStyle + '"><i class="fas fa-copy" style="font-size:12px;"></i></button>');
$controls.append($copyEquipment);
}
}
// Add Busbar button (below copy button or + button) - now blue and round like other buttons
var busbarBtnX = btnX;
var busbarBtnY = btnY + (lastEquipment ? 60 : 30);
var $addBusbar = $('<button class="schematic-add-busbar" data-carrier-id="' + carrier.id + '" title="Sammelschiene hinzufügen" style="position:absolute;left:' + busbarBtnX + 'px;top:' + busbarBtnY + 'px;' + btnStyle + '"><i class="fas fa-grip-lines" style="font-size:10px;"></i></button>');
$controls.append($addBusbar);
} else {
self.log('Carrier ' + carrier.id + ' (' + carrier.label + '): Missing _x or _y position');
}
});
// Append controls to wrapper (if exists) or canvas
$canvas.css('position', 'relative');
$controlsParent.css('position', 'relative').append($controls);
// Bind control events
this.bindControlEvents();
},
bindControlEvents: function() {
var self = this;
// Add Panel
$(document).off('click.addPanel').on('click.addPanel', '.schematic-add-panel', function(e) {
e.preventDefault();
self.showAddPanelDialog();
});
// Copy Panel
$(document).off('click.copyPanel').on('click.copyPanel', '.schematic-copy-panel', function(e) {
e.preventDefault();
var panelId = $(this).data('panel-id');
self.duplicatePanel(panelId);
});
// Delete empty Panel
$(document).off('click.deletePanel').on('click.deletePanel', '.schematic-delete-panel', function(e) {
e.preventDefault();
var panelId = $(this).data('panel-id');
self.deletePanel(panelId);
});
// Add Carrier
$(document).off('click.addCarrier').on('click.addCarrier', '.schematic-add-carrier', function(e) {
e.preventDefault();
var panelId = $(this).data('panel-id');
self.showAddCarrierDialog(panelId);
});
// Copy Carrier
$(document).off('click.copyCarrier').on('click.copyCarrier', '.schematic-copy-carrier', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.duplicateCarrier(carrierId);
});
// Add Equipment
$(document).off('click.addEquipment').on('click.addEquipment', '.schematic-add-equipment', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
// Maximale Lücke vom Carrier holen für Typ-Filter
var carrier = self.carriers.find(function(c) { return String(c.id) === String(carrierId); });
var maxGap = carrier ? (carrier._maxGap || 99) : 99;
self.showAddEquipmentDialog(carrierId, maxGap);
});
// Copy Equipment (single copy)
$(document).off('click.copyEquipment').on('click.copyEquipment', '.schematic-copy-equipment', function(e) {
e.preventDefault();
var equipmentId = $(this).data('equipment-id');
self.duplicateSingleEquipment(equipmentId);
});
// Add Busbar (Sammelschiene)
$(document).off('click.addBusbar').on('click.addBusbar', '.schematic-add-busbar', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.showAddBusbarDialog(carrierId);
});
// Hover effects
$('.schematic-controls button').hover(
function() { $(this).css({ background: '#3498db', color: '#fff' }); },
function() { $(this).css({ background: '#2d2d44', color: '#3498db' }); }
);
// Special hover for busbar button
$('.schematic-add-busbar').hover(
function() { $(this).css({ background: '#e74c3c', color: '#fff' }); },
function() { $(this).css({ background: '#2d2d44', color: '#e74c3c' }); }
);
},
showAddPanelDialog: function() {
var self = this;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:300px;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Neues Feld hinzufügen</h3>';
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bezeichnung:</label>';
html += '<input type="text" class="dialog-panel-label" value="Feld ' + (this.panels.length + 1) + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="dialog-save" style="background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Erstellen</button>';
html += '</div></div>';
$('body').append(html);
$('.dialog-cancel, .schematic-dialog-overlay').on('click', function() {
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-save').on('click', function() {
var label = $('.dialog-panel-label').val();
self.createPanel(label);
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
},
createPanel: function(label) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: 'create',
anlage_id: this.anlageId,
label: label,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Feld erstellt', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
duplicatePanel: function(panelId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: 'duplicate',
panel_id: panelId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Feld kopiert', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
showPanelPopup: function(panelId, x, y) {
var self = this;
var panel = this.panels.find(function(p) { return String(p.id) === String(panelId); });
if (!panel) return;
// Check if panel has carriers
var panelCarriers = this.carriers.filter(function(c) { return String(c.panel_id) === String(panelId); });
var isEmpty = panelCarriers.length === 0;
// Remove existing popups
this.hideConnectionPopup();
this.hideEquipmentPopup();
$('.schematic-panel-popup').remove();
var html = '<div class="schematic-panel-popup" style="' +
'position:fixed;left:' + x + 'px;top:' + y + 'px;' +
'background:#2d2d44;border:1px solid #555;border-radius:6px;' +
'box-shadow:0 4px 15px rgba(0,0,0,0.5);z-index:100001;min-width:160px;overflow:hidden;">';
html += '<div class="panel-popup-header" style="padding:10px 12px;border-bottom:1px solid #444;color:#fff;font-weight:bold;">';
html += '<i class="fa fa-th-large" style="margin-right:8px;color:#3498db;"></i>' + this.escapeHtml(panel.label || 'Feld');
html += '</div>';
// Edit button
html += '<div class="panel-popup-item panel-popup-edit" style="padding:10px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;color:#fff;border-bottom:1px solid #444;">';
html += '<i class="fa fa-edit" style="color:#f39c12;"></i><span>Bearbeiten</span>';
html += '</div>';
// Delete button (only for empty panels or with confirmation)
var deleteStyle = isEmpty ? 'color:#e74c3c;' : 'color:#888;';
html += '<div class="panel-popup-item panel-popup-delete" style="padding:10px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;' + deleteStyle + '">';
html += '<i class="fa fa-trash"></i><span>Löschen</span>';
if (!isEmpty) html += '<span style="font-size:10px;margin-left:auto;">(' + panelCarriers.length + ' Hutschienen)</span>';
html += '</div>';
html += '</div>';
$('body').append(html);
// Position adjustment if off-screen
var $popup = $('.schematic-panel-popup');
var popupWidth = $popup.outerWidth();
var popupHeight = $popup.outerHeight();
if (x + popupWidth > $(window).width()) {
$popup.css('left', (x - popupWidth) + 'px');
}
if (y + popupHeight > $(window).height()) {
$popup.css('top', (y - popupHeight) + 'px');
}
// Hover effects
$('.panel-popup-item').hover(
function() { $(this).css('background', '#3a3a5a'); },
function() { $(this).css('background', 'transparent'); }
);
// Edit click
$('.panel-popup-edit').on('click', function() {
$('.schematic-panel-popup').remove();
self.editPanel(panelId);
});
// Delete click
$('.panel-popup-delete').on('click', function() {
$('.schematic-panel-popup').remove();
if (isEmpty) {
self.deletePanel(panelId);
} else {
// Confirm deletion of non-empty panel
KundenKarte.showConfirm(
'Feld löschen',
'Feld "' + panel.label + '" mit ' + panelCarriers.length + ' Hutschienen wirklich löschen? Alle Hutschienen und Equipment werden ebenfalls gelöscht!',
function() {
self.deletePanel(panelId);
}
);
}
});
// Click outside to close
setTimeout(function() {
$(document).one('click', function(e) {
if (!$(e.target).closest('.schematic-panel-popup').length) {
$('.schematic-panel-popup').remove();
}
});
}, 100);
},
editPanel: function(panelId) {
var self = this;
var panel = this.panels.find(function(p) { return String(p.id) === String(panelId); });
if (!panel) return;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:300px;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Feld bearbeiten</h3>';
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bezeichnung:</label>';
html += '<input type="text" class="dialog-panel-label" value="' + this.escapeHtml(panel.label || '') + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="dialog-save" style="background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Speichern</button>';
html += '</div></div>';
$('body').append(html);
$('.dialog-panel-label').focus().select();
$('.dialog-cancel, .schematic-dialog-overlay').on('click', function() {
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-save').on('click', function() {
var newLabel = $('.dialog-panel-label').val().trim();
if (newLabel) {
self.updatePanel(panelId, newLabel);
}
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-panel-label').on('keypress', function(e) {
if (e.which === 13) {
$('.dialog-save').click();
}
});
},
updatePanel: function(panelId, label) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: 'update',
panel_id: panelId,
label: label,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Feld aktualisiert', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
deletePanel: function(panelId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
method: 'POST',
data: {
action: 'delete',
panel_id: panelId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Feld gelöscht', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
showAddCarrierDialog: function(panelId) {
var self = this;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:300px;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Neue Hutschiene hinzufügen</h3>';
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bezeichnung:</label>';
html += '<input type="text" class="dialog-carrier-label" value="" placeholder="z.B. R1 (automatisch)" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Teilungseinheiten (TE):</label>';
html += '<input type="number" class="dialog-carrier-te" value="12" min="1" max="48" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="dialog-save" style="background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Erstellen</button>';
html += '</div></div>';
$('body').append(html);
$('.dialog-cancel, .schematic-dialog-overlay').on('click', function() {
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-save').on('click', function() {
var label = $('.dialog-carrier-label').val();
var totalTE = parseInt($('.dialog-carrier-te').val()) || 12;
self.createCarrier(panelId, label, totalTE);
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
},
createCarrier: function(panelId, label, totalTE) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'create',
anlage_id: this.anlageId,
panel_id: panelId,
label: label,
total_te: totalTE,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Hutschiene erstellt', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
duplicateCarrier: function(carrierId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'duplicate',
carrier_id: carrierId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Hutschiene kopiert', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
showAddBusbarDialog: function(carrierId) {
var self = this;
var carrier = this.carriers.find(function(c) { return String(c.id) === String(carrierId); });
var totalTE = carrier ? (parseFloat(carrier.total_te) || 12) : 12;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:400px;max-height:90vh;overflow-y:auto;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;">Sammelschiene hinzufügen</h3>';
// Busbar type selection from database
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Typ:</label>';
html += '<select class="dialog-busbar-type" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
if (this.busbarTypes && this.busbarTypes.length > 0) {
this.busbarTypes.forEach(function(type, idx) {
var selected = idx === 0 ? ' selected' : '';
html += '<option value="' + type.id + '"' + selected;
html += ' data-phases="' + self.escapeHtml(type.phases || '') + '"';
html += ' data-color="' + self.escapeHtml(type.default_color || type.color || '#e74c3c') + '"';
html += ' data-num-lines="' + (type.num_lines || 1) + '"';
html += ' data-colors="' + self.escapeHtml(type.color || '') + '"';
html += ' data-position="' + self.escapeHtml(type.position_default || 'below') + '"';
html += '>' + self.escapeHtml(type.label);
if (type.label_short && type.label_short !== type.label) {
html += ' (' + self.escapeHtml(type.label_short) + ')';
}
html += '</option>';
});
} else {
// Fallback wenn keine Typen geladen
html += '<option value="0" data-phases="L1" data-color="#e74c3c">L1 (Phase 1)</option>';
html += '<option value="0" data-phases="L2" data-color="#2ecc71">L2 (Phase 2)</option>';
html += '<option value="0" data-phases="L3" data-color="#9b59b6">L3 (Phase 3)</option>';
html += '<option value="0" data-phases="3P" data-color="#e74c3c" selected>3-phasig</option>';
html += '<option value="0" data-phases="N" data-color="#3498db">N (Neutralleiter)</option>';
html += '<option value="0" data-phases="PE" data-color="#f1c40f">PE (Schutzleiter)</option>';
}
html += '</select></div>';
// Start TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Von TE:</label>';
html += '<input type="number" class="dialog-busbar-start" value="1" min="1" max="' + totalTE + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// End TE
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bis TE:</label>';
html += '<input type="number" class="dialog-busbar-end" value="' + totalTE + '" min="1" max="' + totalTE + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Position (above/below) - pre-selected from type
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Position:</label>';
html += '<select class="dialog-busbar-position" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
html += '<option value="above">Oben</option>';
html += '<option value="below">Unten</option>';
html += '</select></div>';
// Excluded TEs
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">TEs auslassen (z.B. 3,5,7):</label>';
html += '<input type="text" class="dialog-busbar-excluded" placeholder="Kommagetrennte TE-Nummern" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Color preview (read-only, set from type)
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Farbe (aus Typ):</label>';
html += '<div class="dialog-busbar-color-preview" style="height:30px;border-radius:4px;border:1px solid #555;background:#e74c3c;"></div></div>';
html += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="dialog-save" style="background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Erstellen</button>';
html += '</div></div>';
$('body').append(html);
// Update color and position based on type selection
function updateFromType() {
var $selected = $('.dialog-busbar-type option:selected');
var color = $selected.data('color') || '#e74c3c';
var colors = $selected.data('colors') || color;
var position = $selected.data('position') || 'below';
// Show multi-color preview if multiple colors
var colorArr = colors.split(',');
if (colorArr.length > 1) {
var gradient = 'linear-gradient(to right, ' + colorArr.join(', ') + ')';
$('.dialog-busbar-color-preview').css('background', gradient);
} else {
$('.dialog-busbar-color-preview').css('background', color);
}
$('.dialog-busbar-position').val(position);
}
$('.dialog-busbar-type').on('change', updateFromType);
updateFromType(); // Initial update
$('.dialog-cancel, .schematic-dialog-overlay').on('click', function() {
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-save').on('click', function() {
var $selected = $('.dialog-busbar-type option:selected');
var typeId = $('.dialog-busbar-type').val();
var phases = $selected.data('phases') || 'L1';
var color = $selected.data('colors') || $selected.data('color') || '#e74c3c';
var numLines = $selected.data('num-lines') || 1;
var startTE = parseInt($('.dialog-busbar-start').val()) || 1;
var endTE = parseInt($('.dialog-busbar-end').val()) || totalTE;
var position = $('.dialog-busbar-position').val();
var excludedTE = $('.dialog-busbar-excluded').val() || '';
console.log('=== BUSBAR CREATE ===', {carrierId: carrierId, typeId: typeId, phases: phases});
self.createBusbar(carrierId, phases, startTE, endTE, position, color, excludedTE, numLines, typeId);
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
},
createBusbar: function(carrierId, phases, startTE, endTE, position, color, excludedTE, numLines, typeId) {
var self = this;
console.log('createBusbar called:', carrierId, phases, startTE, endTE);
// Convert position string to number for backend (0=above, 1=below)
var positionY = (position === 'below') ? 1 : 0;
numLines = numLines || 1;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'create_rail',
carrier_id: carrierId,
rail_start_te: startTE,
rail_end_te: endTE,
rail_phases: phases,
position_y: positionY,
color: color,
excluded_te: excludedTE,
connection_type: phases,
num_lines: numLines,
fk_busbar_type: typeId || 0,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Sammelschiene erstellt', 'success');
// Add to local connections and re-render
self.connections.push({
id: response.connection_id,
is_rail: 1,
fk_carrier: carrierId,
rail_start_te: startTE,
rail_end_te: endTE,
rail_phases: phases,
position_y: positionY,
color: color,
excluded_te: excludedTE,
connection_type: phases,
num_lines: numLines
});
self.render();
} else {
self.showMessage(response.error || 'Fehler beim Erstellen', 'error');
console.error('Busbar create error:', response);
}
},
error: function(xhr, status, error) {
self.showMessage('AJAX Fehler: ' + error, 'error');
console.error('Busbar AJAX error:', status, error, xhr.responseText);
}
});
},
updateBusbarPosition: function(connectionId, newStartTE, newEndTE, newCarrierId) {
var self = this;
var data = {
action: 'update_rail_position',
connection_id: connectionId,
rail_start_te: newStartTE,
rail_end_te: newEndTE,
token: $('input[name="token"]').val()
};
// Add carrier_id if provided (for moving to different carrier/panel)
if (newCarrierId) {
data.carrier_id = newCarrierId;
}
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
// Update local connection data
var conn = self.connections.find(function(c) { return String(c.id) === String(connectionId); });
if (conn) {
conn.rail_start_te = newStartTE;
conn.rail_end_te = newEndTE;
if (newCarrierId) {
conn.fk_carrier = newCarrierId;
}
}
self.render();
} else {
self.showMessage(response.error || 'Fehler beim Verschieben', 'error');
self.render(); // Re-render to reset visual position
}
},
error: function() {
self.showMessage('Fehler beim Verschieben', 'error');
self.render();
}
});
},
showAddEquipmentDialog: function(carrierId, maxGap) {
var self = this;
maxGap = maxGap || 99;
// Load equipment types first
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_types', system_id: 1 },
dataType: 'json',
success: function(response) {
if (response.success) {
self.showEquipmentTypeSelector(carrierId, response.types, maxGap);
}
}
});
},
showEquipmentTypeSelector: function(carrierId, types, maxGap) {
var self = this;
// Kategorisiere Equipment-Typen (category kommt aus DB)
var categories = {
'schutz': { label: 'Schutzgeräte', icon: 'fa-shield', items: [] },
'automat': { label: 'Leitungsschutz', icon: 'fa-bolt', items: [] },
'steuerung': { label: 'Steuerung & Sonstiges', icon: 'fa-cog', items: [] },
'klemme': { label: 'Klemmen', icon: 'fa-th-list', items: [] }
};
// Sortiere Typen in Kategorien (nur wenn Breite in verfügbare Lücke passt)
maxGap = maxGap || 99;
types.forEach(function(t) {
var typeWidth = parseFloat(t.width_te) || 1;
if (typeWidth > maxGap) return; // Passt nicht in verfügbare Lücke
var cat = t.category || 'steuerung';
if (categories[cat]) {
categories[cat].items.push(t);
} else {
categories.steuerung.items.push(t);
}
});
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:400px;max-height:80vh;overflow-y:auto;">';
html += '<h3 style="margin:0 0 15px 0;color:#fff;"><i class="fa fa-plus-circle"></i> Equipment hinzufügen</h3>';
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Typ:</label>';
html += '<select class="dialog-equipment-type" style="width:100%;padding:10px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;font-size:14px;">';
// Render kategorisiert
var categoryOrder = ['automat', 'schutz', 'steuerung', 'klemme'];
categoryOrder.forEach(function(catKey) {
var cat = categories[catKey];
if (cat.items.length > 0) {
html += '<optgroup label="── ' + cat.label + ' ──" style="color:#888;font-weight:bold;">';
cat.items.forEach(function(t) {
var icon = t.picto || 'fa fa-cube';
var shortLabel = t.label_short || t.label.substring(0, 6);
html += '<option value="' + t.id + '" data-width="' + t.width_te + '" data-icon="' + icon + '">';
html += self.escapeHtml(t.label) + ' [' + shortLabel + '] (' + t.width_te + ' TE)';
html += '</option>';
});
html += '</optgroup>';
}
});
html += '</select></div>';
// Container für typ-spezifische Felder
html += '<div class="dialog-type-fields" style="margin-bottom:12px;"></div>';
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Bezeichnung (optional):</label>';
html += '<input type="text" class="dialog-equipment-label" value="" placeholder="z.B. Küche Licht, Bad Steckdosen..." style="width:100%;padding:10px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Produktauswahl mit Autocomplete
html += '<div style="margin-bottom:12px;"><label style="display:block;color:#aaa;margin-bottom:4px;">Produkt (optional):</label>';
html += '<input type="hidden" class="dialog-product-id" value=""/>';
html += '<div style="position:relative;">';
html += '<input type="text" class="dialog-product-search" placeholder="Produkt suchen..." autocomplete="off" style="width:100%;padding:10px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/>';
html += '<span class="dialog-product-clear" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);cursor:pointer;color:#888;display:none;">&times;</span>';
html += '</div>';
html += '</div>';
html += '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:15px;">';
html += '<button type="button" class="dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 20px;cursor:pointer;"><i class="fa fa-times"></i> Abbrechen</button>';
html += '<button type="button" class="dialog-save" style="background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 20px;cursor:pointer;font-weight:bold;"><i class="fa fa-plus"></i> Erstellen</button>';
html += '</div></div>';
$('body').append(html);
// Felder für gewählten Typ laden
var allTypes = types;
$('.dialog-equipment-type').on('change', function() {
var typeId = $(this).val();
var selectedType = allTypes.find(function(t) { return String(t.id) === String(typeId); });
self.loadTypeFields(typeId, selectedType, '.dialog-type-fields', {});
}).trigger('change');
// Produktsuche mit jQuery UI Autocomplete
self.initProductAutocomplete('.dialog-product-search', '.dialog-product-id', '.dialog-product-clear');
$('.dialog-cancel, .schematic-dialog-overlay').on('click', function() {
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
$('.dialog-save').on('click', function() {
var typeId = $('.dialog-equipment-type').val();
var label = $('.dialog-equipment-label').val();
var productId = $('.dialog-product-id').val();
// Sammle Feldwerte
var fieldValues = {};
$('.dialog-type-fields').find('input, select').each(function() {
var code = $(this).data('field-code');
if (code) {
fieldValues[code] = $(this).val();
}
});
self.createEquipment(carrierId, typeId, label, fieldValues, productId);
$('.schematic-dialog, .schematic-dialog-overlay').remove();
});
// Enter-Taste zum Speichern
$('.dialog-equipment-label').on('keypress', function(e) {
if (e.which === 13) {
$('.dialog-save').click();
}
});
},
createEquipment: function(carrierId, typeId, label, fieldValues, productId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'create',
carrier_id: carrierId,
type_id: typeId,
label: label,
field_values: JSON.stringify(fieldValues || {}),
fk_product: productId || 0,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Equipment erstellt', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
// Lädt und rendert typ-spezifische Felder
loadTypeFields: function(typeId, typeData, containerSelector, existingValues) {
var self = this;
var $container = $(containerSelector);
$container.empty();
if (!typeId) return;
// Lade Felder vom Server
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_type_fields', type_id: typeId },
dataType: 'json',
success: function(response) {
if (response.success && response.fields && response.fields.length > 0) {
var html = '';
response.fields.forEach(function(field) {
var value = existingValues[field.field_code] || '';
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">' + self.escapeHtml(field.field_label);
if (field.required == 1) html += ' <span style="color:#e74c3c;">*</span>';
html += '</label>';
if (field.field_type === 'select' && field.field_options) {
html += '<select data-field-code="' + field.field_code + '" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
html += '<option value="">-- Wählen --</option>';
var options = field.field_options.split('|');
options.forEach(function(opt) {
opt = opt.trim();
if (opt === '') return;
var selected = (opt === value.trim()) ? ' selected' : '';
html += '<option value="' + self.escapeHtml(opt) + '"' + selected + '>' + self.escapeHtml(opt) + '</option>';
});
html += '</select>';
} else {
html += '<input type="text" data-field-code="' + field.field_code + '" value="' + self.escapeHtml(value) + '" ';
html += 'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/>';
}
html += '</div>';
});
$container.html(html);
}
}
});
},
// Initialisiert jQuery UI Autocomplete für Produktsuche
initProductAutocomplete: function(searchSelector, idSelector, clearSelector, initialProductId) {
var self = this;
var $search = $(searchSelector);
var $id = $(idSelector);
var $clear = $(clearSelector);
// Autocomplete initialisieren - nutzt unseren eigenen AJAX-Endpoint
$search.autocomplete({
minLength: 2,
delay: 300,
appendTo: $search.closest('.schematic-dialog, .schematic-edit-dialog'),
source: function(request, response) {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
dataType: 'json',
data: {
action: 'get_products',
search: request.term,
limit: 20
},
success: function(data) {
if (data.success && data.products && data.products.length > 0) {
response(data.products.map(function(item) {
return {
label: item.display,
value: item.display,
id: item.id
};
}));
} else {
response([]);
}
},
error: function() {
response([]);
}
});
},
select: function(event, ui) {
if (ui.item && ui.item.id) {
$id.val(ui.item.id);
$search.val(ui.item.value);
$clear.show();
}
return false;
},
focus: function(event, ui) {
return false;
}
});
// CSS für Autocomplete-Dropdown im Dark Mode
$search.autocomplete('widget').addClass('kundenkarte-autocomplete');
// Clear Button
$clear.on('click', function() {
$search.val('');
$id.val('');
$clear.hide();
});
// Wenn Input geleert wird, auch ID leeren
$search.on('input', function() {
if (!$(this).val()) {
$id.val('');
$clear.hide();
}
});
// Vorhandenes Produkt laden
if (initialProductId && initialProductId > 0) {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_product', product_id: initialProductId },
dataType: 'json',
success: function(data) {
if (data.success && data.product) {
$search.val(data.product.display);
$id.val(initialProductId);
$clear.show();
}
}
});
}
},
duplicateSingleEquipment: function(equipmentId) {
var self = this;
// Find original equipment to get carrier info
var originalEq = this.equipment.find(function(e) { return String(e.id) === String(equipmentId); });
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'duplicate',
equipment_id: equipmentId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success && response.equipment) {
self.showMessage('Equipment kopiert', 'success');
// Add new equipment to local array instead of reloading everything
var newEq = response.equipment;
newEq.carrier_id = originalEq ? originalEq.carrier_id : newEq.fk_carrier;
newEq.panel_id = originalEq ? originalEq.panel_id : null;
self.equipment.push(newEq);
self.render();
} else if (response.success) {
// Fallback if no equipment data returned
self.showMessage('Equipment kopiert', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Kein Platz mehr', 'warning');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
// Check if an equipment position is covered by a busbar
// Returns: { top: boolean, bottom: boolean } indicating which terminals are covered
isEquipmentCoveredByBusbar: function(eq) {
var eqCarrierId = eq.carrier_id || eq.fk_carrier;
var eqPosTE = parseFloat(eq.position_te) || 1;
var eqWidthTE = parseFloat(eq.width_te) || 1;
var eqEndTE = eqPosTE + eqWidthTE; // Half-open range: [eqPosTE, eqEndTE)
var result = { top: false, bottom: false };
this.connections.forEach(function(conn) {
// Must be a rail/busbar
if (!conn.is_rail || conn.is_rail === '0' || conn.is_rail === 0) return;
if (parseInt(conn.is_rail) !== 1) return;
// Busbar must be on same carrier
if (String(conn.fk_carrier) !== String(eqCarrierId)) return;
var railStart = parseInt(conn.rail_start_te) || 1;
var railEnd = parseInt(conn.rail_end_te) || railStart;
var positionY = parseInt(conn.position_y) || 0;
// Overlap: [eqPosTE, eqEndTE) vs [railStart, railEnd+1)
var overlaps = eqPosTE < railEnd + 1 && railStart < eqEndTE;
if (!overlaps) return;
// Determine if busbar is above (position_y = 0) or below (position_y > 0)
if (positionY === 0) {
result.top = true;
} else {
result.bottom = true;
}
});
return result;
},
// Legacy compatibility - returns true if top terminals are covered
isEquipmentCoveredByBusbarLegacy: function(eq) {
var result = this.isEquipmentCoveredByBusbar(eq);
return result.top;
},
// Schutzgruppen-Farbe basierend auf Protection-Device-ID
_protectionColorCache: {},
getProtectionColor: function(protectionId) {
if (!protectionId) return null;
if (this._protectionColorCache[protectionId]) return this._protectionColorCache[protectionId];
var colors = ['#e74c3c', '#3498db', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4', '#ff5722'];
var idx = Object.keys(this._protectionColorCache).length % colors.length;
this._protectionColorCache[protectionId] = colors[idx];
return colors[idx];
},
getTerminals: function(eq) {
// Try to parse terminals_config from equipment type
if (eq.terminals_config) {
try {
// Literale \r\n bereinigen (falls DB-Daten kaputt)
var configStr = eq.terminals_config.replace(/\\r\\n|\\r|\\n/g, ' ').replace(/\\t/g, '');
var config = JSON.parse(configStr);
// Handle both old format (inputs/outputs) and new format (terminals)
if (config.terminals) {
return config.terminals;
} else if (config.inputs || config.outputs) {
// Convert old format to new
var terminals = [];
if (config.inputs) {
config.inputs.forEach(function(t) {
terminals.push({ id: t.id, label: t.label, pos: 'top' });
});
}
if (config.outputs) {
config.outputs.forEach(function(t) {
terminals.push({ id: t.id, label: t.label, pos: 'bottom' });
});
}
return terminals;
}
} catch (e) {}
}
// Look up by type ref
var typeRef = (eq.type_ref || '').toUpperCase();
if (this.DEFAULT_TERMINALS[typeRef] && this.DEFAULT_TERMINALS[typeRef].terminals) {
return this.DEFAULT_TERMINALS[typeRef].terminals;
}
// Check for FI pattern
if (typeRef.indexOf('FI') !== -1 || typeRef.indexOf('RCD') !== -1) {
if (typeRef.indexOf('4P') !== -1) {
return this.DEFAULT_TERMINALS['FI4P'].terminals;
}
return this.DEFAULT_TERMINALS['FI'].terminals;
}
// Default: 1 top, 1 bottom
return this.DEFAULT_TERMINALS['LS'].terminals;
},
// Get total terminal count for equipment (used for bundled terminals feature)
getTerminalCount: function(eq) {
var terminals = this.getTerminals(eq);
return terminals ? terminals.length : 2;
},
getTerminalPosition: function(eq, terminalId, terminals) {
// Find the terminal
var terminal = null;
var termIndex = 0;
var samePosList = [];
// Map legacy terminal names to positions
var legacyMap = {
'in': 'top', 'input': 'top', 'in_L': 'top', 'in_N': 'top',
'out': 'bottom', 'output': 'bottom', 'out_L': 'bottom', 'out_N': 'bottom'
};
for (var i = 0; i < terminals.length; i++) {
if (terminals[i].id === terminalId) {
terminal = terminals[i];
break;
}
}
// If not found by exact match, try legacy mapping
if (!terminal && legacyMap[terminalId]) {
var targetPos = legacyMap[terminalId];
for (var k = 0; k < terminals.length; k++) {
if (terminals[k].pos === targetPos) {
terminal = terminals[k];
break;
}
}
}
if (!terminal) {
// Default: use first bottom terminal for 'out', first top for anything else
var defaultPos = (terminalId && (terminalId.indexOf('out') !== -1 || terminalId === 't2')) ? 'bottom' : 'top';
for (var m = 0; m < terminals.length; m++) {
if (terminals[m].pos === defaultPos) {
terminal = terminals[m];
break;
}
}
if (!terminal) {
terminal = terminals[0] || { pos: 'top' };
}
}
// Get all terminals with same position
for (var j = 0; j < terminals.length; j++) {
if (terminals[j].pos === terminal.pos) {
if (terminals[j].id === terminal.id) termIndex = samePosList.length;
samePosList.push(terminals[j]);
}
}
var isTop = terminal.pos === 'top';
var widthTE = parseFloat(eq.width_te) || 1;
// Terminal im festen TE-Raster platzieren
// Use terminal.col if defined, otherwise fall back to index
var teIndex = terminal.col !== undefined ? terminal.col : (termIndex % widthTE);
var x = eq._x + (teIndex * this.TE_WIDTH) + (this.TE_WIDTH / 2);
// For stacked terminals, add row offset
var rowOffset = terminal.row !== undefined ? terminal.row * 14 : 0;
var y = isTop ? (eq._y - 5 - rowOffset) : (eq._y + eq._height + 5 + rowOffset);
return { x: x, y: y, isTop: isTop };
},
createOrthogonalPath: function(source, target, routeOffset, sourceEq, targetEq) {
var x1 = source.x, y1 = source.y;
var x2 = target.x, y2 = target.y;
// Connection index for spreading
var connIndex = Math.floor(routeOffset / 8);
// Use pathfinding with obstacle avoidance
if (typeof PF !== 'undefined') {
try {
var path = this.createPathfindingRoute(x1, y1, x2, y2, connIndex);
if (path) return path;
} catch (e) {
console.error('Pathfinding error:', e);
}
}
// Fallback to simple route
return this.createSimpleRoute(x1, y1, x2, y2, source.isTop, target.isTop, routeOffset);
},
// Remove redundant points that lie on the same line as their neighbors
cleanupPathPoints: function(points) {
if (points.length < 3) return points;
var cleaned = [points[0]];
for (var i = 1; i < points.length - 1; i++) {
var prev = cleaned[cleaned.length - 1];
var curr = points[i];
var next = points[i + 1];
// Check if curr is on the same line as prev and next
var sameHorizontal = (prev.y === curr.y && curr.y === next.y);
var sameVertical = (prev.x === curr.x && curr.x === next.x);
// Only keep point if it's a corner (direction change)
if (!sameHorizontal && !sameVertical) {
cleaned.push(curr);
}
}
// Always add the last point
cleaned.push(points[points.length - 1]);
return cleaned;
},
// Snap a point to the nearest point on a wire path
snapToWirePath: function(pathData, clickX, clickY) {
if (!pathData) return {x: clickX, y: clickY};
// Parse path to get points
var points = [];
var commands = pathData.match(/[MLHVZ][^MLHVZ]*/gi);
if (!commands) return {x: clickX, y: clickY};
var currentX = 0, currentY = 0;
commands.forEach(function(cmd) {
var type = cmd[0].toUpperCase();
var coords = cmd.slice(1).trim().split(/[\s,]+/).map(parseFloat);
if (type === 'M' || type === 'L') {
currentX = coords[0];
currentY = coords[1];
points.push({x: currentX, y: currentY});
} else if (type === 'H') {
currentX = coords[0];
points.push({x: currentX, y: currentY});
} else if (type === 'V') {
currentY = coords[0];
points.push({x: currentX, y: currentY});
}
});
if (points.length < 2) return {x: clickX, y: clickY};
// Find nearest point on any segment
var nearestPoint = {x: clickX, y: clickY};
var minDist = Infinity;
for (var i = 1; i < points.length; i++) {
var p1 = points[i - 1];
var p2 = points[i];
// Project click point onto line segment
var dx = p2.x - p1.x;
var dy = p2.y - p1.y;
var lenSq = dx * dx + dy * dy;
var t = 0;
if (lenSq > 0) {
t = ((clickX - p1.x) * dx + (clickY - p1.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t)); // Clamp to segment
}
var projX = p1.x + t * dx;
var projY = p1.y + t * dy;
var dist = Math.sqrt((clickX - projX) * (clickX - projX) + (clickY - projY) * (clickY - projY));
if (dist < minDist) {
minDist = dist;
nearestPoint = {x: projX, y: projY};
}
}
return nearestPoint;
},
// Get the midpoint of an SVG path for label positioning
getPathMidpoint: function(pathData) {
if (!pathData) return null;
// Parse path commands (M, L, H, V)
var points = [];
var currentX = 0, currentY = 0;
// Match all path commands
var commands = pathData.match(/[MLHVZ][^MLHVZ]*/gi);
if (!commands) return null;
commands.forEach(function(cmd) {
var type = cmd[0].toUpperCase();
var coords = cmd.slice(1).trim().split(/[\s,]+/).map(parseFloat);
if (type === 'M' || type === 'L') {
currentX = coords[0];
currentY = coords[1];
points.push({x: currentX, y: currentY});
} else if (type === 'H') {
currentX = coords[0];
points.push({x: currentX, y: currentY});
} else if (type === 'V') {
currentY = coords[0];
points.push({x: currentX, y: currentY});
}
});
if (points.length < 2) return null;
// Calculate total path length
var totalLength = 0;
var segments = [];
for (var i = 1; i < points.length; i++) {
var dx = points[i].x - points[i-1].x;
var dy = points[i].y - points[i-1].y;
var len = Math.sqrt(dx*dx + dy*dy);
segments.push({start: points[i-1], end: points[i], length: len});
totalLength += len;
}
// Find the point at half the total length
var halfLength = totalLength / 2;
var accumulated = 0;
for (var j = 0; j < segments.length; j++) {
var seg = segments[j];
if (accumulated + seg.length >= halfLength) {
// Midpoint is in this segment
var t = (halfLength - accumulated) / seg.length;
return {
x: seg.start.x + t * (seg.end.x - seg.start.x),
y: seg.start.y + t * (seg.end.y - seg.start.y)
};
}
accumulated += seg.length;
}
// Fallback: return geometric center
return {
x: (points[0].x + points[points.length-1].x) / 2,
y: (points[0].y + points[points.length-1].y) / 2
};
},
// Check if a label rectangle overlaps with any equipment block
labelOverlapsEquipment: function(labelX, labelY, labelWidth, labelHeight) {
var self = this;
var padding = 5; // Safety margin
// Label bounding box
var lx1 = labelX - labelWidth / 2 - padding;
var ly1 = labelY - labelHeight / 2 - padding;
var lx2 = labelX + labelWidth / 2 + padding;
var ly2 = labelY + labelHeight / 2 + padding;
for (var i = 0; i < this.equipment.length; i++) {
var eq = this.equipment[i];
if (typeof eq._x === 'undefined' || typeof eq._y === 'undefined') continue;
var eqWidth = (parseFloat(eq.width_te) || 1) * this.TE_WIDTH;
var eqHeight = eq._height || this.BLOCK_HEIGHT;
// Equipment bounding box
var ex1 = eq._x;
var ey1 = eq._y;
var ex2 = eq._x + eqWidth;
var ey2 = eq._y + eqHeight;
// Check for overlap
if (!(lx2 < ex1 || lx1 > ex2 || ly2 < ey1 || ly1 > ey2)) {
return eq; // Return the overlapping equipment
}
}
// Auch gegen Abgang-Bereiche prüfen
if (this._outputAreas) {
for (var j = 0; j < this._outputAreas.length; j++) {
var oa = this._outputAreas[j];
if (!(lx2 < oa.x || lx1 > oa.x + oa.w || ly2 < oa.y || ly1 > oa.y + oa.h)) {
return { _isOutput: true }; // Abgang-Bereich überlappt
}
}
}
return null; // No overlap
},
// Find a safe label position along a path that doesn't overlap equipment
findSafeLabelPosition: function(pathData, labelWidth, labelHeight) {
var self = this;
if (!pathData) return null;
// Parse path to get all points
var points = [];
var commands = pathData.match(/[ML]\s*[\d.-]+\s*[\d.-]+/gi);
if (!commands) return null;
commands.forEach(function(cmd) {
var coords = cmd.match(/[\d.-]+/g);
if (coords && coords.length >= 2) {
points.push({ x: parseFloat(coords[0]), y: parseFloat(coords[1]) });
}
});
if (points.length < 2) return null;
// Positionen entlang des Pfades testen (Mitte bevorzugt, dann alternierend)
var percentages = [0.5, 0.3, 0.7, 0.2, 0.8, 0.1, 0.9];
// Calculate total path length and segment lengths
var totalLength = 0;
var segments = [];
for (var i = 1; i < points.length; i++) {
var dx = points[i].x - points[i-1].x;
var dy = points[i].y - points[i-1].y;
var len = Math.sqrt(dx*dx + dy*dy);
segments.push({ start: points[i-1], end: points[i], length: len });
totalLength += len;
}
// Try each percentage
for (var p = 0; p < percentages.length; p++) {
var targetLength = totalLength * percentages[p];
var accumulated = 0;
for (var j = 0; j < segments.length; j++) {
var seg = segments[j];
if (accumulated + seg.length >= targetLength) {
var t = (targetLength - accumulated) / seg.length;
var testX = seg.start.x + t * (seg.end.x - seg.start.x);
var testY = seg.start.y + t * (seg.end.y - seg.start.y);
// Check if this position is safe
if (!this.labelOverlapsEquipment(testX, testY, labelWidth, labelHeight)) {
return { x: testX, y: testY };
}
break;
}
accumulated += seg.length;
}
}
// All positions overlap - try to find position outside equipment
// Return the midpoint but offset away from nearest equipment
var midPos = this.getPathMidpoint(pathData);
if (!midPos) return null;
var overlappingEq = this.labelOverlapsEquipment(midPos.x, midPos.y, labelWidth, labelHeight);
if (overlappingEq) {
// Move label away from the equipment
var eqCenterY = overlappingEq._y + (overlappingEq._height || this.BLOCK_HEIGHT) / 2;
// Move above or below the equipment
if (midPos.y < eqCenterY) {
// Label is above center, move it further up
midPos.y = overlappingEq._y - labelHeight / 2 - 10;
} else {
// Label is below center, move it further down
midPos.y = overlappingEq._y + (overlappingEq._height || this.BLOCK_HEIGHT) + labelHeight / 2 + 10;
}
}
return midPos;
},
// Pathfinding-based routing with obstacle avoidance and connection spreading
// Improved version using A* with better orthogonal routing
createPathfindingRoute: function(x1, y1, x2, y2, connIndex) {
var self = this;
var GRID_SIZE = 14; // Larger grid = faster but less precise (TE_WIDTH / 4)
var PADDING = 100;
var BLOCK_MARGIN = 1;
var WIRE_SPREAD = 8; // Pixels between parallel wires
// Spread parallel wires horizontally
var spreadDir = (connIndex % 2 === 0) ? 1 : -1;
var xSpread = spreadDir * Math.ceil(connIndex / 2) * WIRE_SPREAD;
// Find bounds of all equipment
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
this.equipment.forEach(function(eq) {
if (eq._x !== undefined && eq._y !== undefined) {
minX = Math.min(minX, eq._x);
maxX = Math.max(maxX, eq._x + (eq._width || self.TE_WIDTH * eq.width_te));
minY = Math.min(minY, eq._y);
maxY = Math.max(maxY, eq._y + (eq._height || self.BLOCK_HEIGHT));
}
});
// Include start and end points with padding
minX = Math.min(minX, x1, x2) - PADDING;
maxX = Math.max(maxX, x1, x2) + PADDING;
minY = Math.min(minY, y1, y2) - PADDING;
maxY = Math.max(maxY, y1, y2) + PADDING;
// Create grid
var gridWidth = Math.ceil((maxX - minX) / GRID_SIZE);
var gridHeight = Math.ceil((maxY - minY) / GRID_SIZE);
// Limit grid size for performance
if (gridWidth > 200) gridWidth = 200;
if (gridHeight > 200) gridHeight = 200;
var grid = new PF.Grid(gridWidth, gridHeight);
// Mark ALL equipment as obstacles
this.equipment.forEach(function(eq) {
if (eq._x === undefined || eq._y === undefined) return;
var eqWidth = eq._width || (self.TE_WIDTH * (parseFloat(eq.width_te) || 1));
var eqHeight = eq._height || self.BLOCK_HEIGHT;
var eqX1 = Math.floor((eq._x - minX) / GRID_SIZE);
var eqY1 = Math.floor((eq._y - minY) / GRID_SIZE);
var eqX2 = Math.ceil((eq._x + eqWidth - minX) / GRID_SIZE);
var eqY2 = Math.ceil((eq._y + eqHeight - minY) / GRID_SIZE);
// Add margin around blocks
eqX1 = Math.max(0, eqX1 - BLOCK_MARGIN);
eqY1 = Math.max(0, eqY1 - BLOCK_MARGIN);
eqX2 = Math.min(gridWidth - 1, eqX2 + BLOCK_MARGIN);
eqY2 = Math.min(gridHeight - 1, eqY2 + BLOCK_MARGIN);
for (var gx = eqX1; gx <= eqX2; gx++) {
for (var gy = eqY1; gy <= eqY2; gy++) {
if (gx >= 0 && gx < gridWidth && gy >= 0 && gy < gridHeight) {
grid.setWalkableAt(gx, gy, false);
}
}
}
});
// Mark busbars as obstacles (Phasenschienen)
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) !== 1) return;
var carrier = self.carriers.find(function(c) {
return String(c.id) === String(conn.fk_carrier);
});
if (!carrier || carrier._x === undefined) return;
var startTE = parseInt(conn.rail_start_te) || 1;
var endTE = parseInt(conn.rail_end_te) || 12;
var posY = parseInt(conn.position_y) || 0;
var busbarX = carrier._x + (startTE - 1) * self.TE_WIDTH;
var busbarWidth = (endTE - startTE + 1) * self.TE_WIDTH;
// Calculate Y position based on position_y (0=top, 1=bottom)
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
var blockTop = railCenterY - self.BLOCK_HEIGHT / 2;
var blockBottom = railCenterY + self.BLOCK_HEIGHT / 2;
var busbarHeight = 24;
var busbarY = posY === 0 ? blockTop - busbarHeight - 25 : blockBottom + 25;
var bbX1 = Math.floor((busbarX - minX) / GRID_SIZE);
var bbY1 = Math.floor((busbarY - minY) / GRID_SIZE);
var bbX2 = Math.ceil((busbarX + busbarWidth - minX) / GRID_SIZE);
var bbY2 = Math.ceil((busbarY + busbarHeight - minY) / GRID_SIZE);
bbX1 = Math.max(0, bbX1 - BLOCK_MARGIN);
bbY1 = Math.max(0, bbY1 - BLOCK_MARGIN);
bbX2 = Math.min(gridWidth - 1, bbX2 + BLOCK_MARGIN);
bbY2 = Math.min(gridHeight - 1, bbY2 + BLOCK_MARGIN);
for (var bx = bbX1; bx <= bbX2; bx++) {
for (var by = bbY1; by <= bbY2; by++) {
if (bx >= 0 && bx < gridWidth && by >= 0 && by < gridHeight) {
grid.setWalkableAt(bx, by, false);
}
}
}
});
// Apply spread to separate parallel wires
var x1Spread = x1 + xSpread;
var x2Spread = x2 + xSpread;
// Convert pixel coords to grid coords
var startX = Math.round((x1Spread - minX) / GRID_SIZE);
var startY = Math.round((y1 - minY) / GRID_SIZE);
var endX = Math.round((x2Spread - minX) / GRID_SIZE);
var endY = Math.round((y2 - minY) / GRID_SIZE);
// Clamp to grid bounds
startX = Math.max(0, Math.min(gridWidth - 1, startX));
startY = Math.max(0, Math.min(gridHeight - 1, startY));
endX = Math.max(0, Math.min(gridWidth - 1, endX));
endY = Math.max(0, Math.min(gridHeight - 1, endY));
// Create walkable corridors from terminals
for (var dx = -2; dx <= 2; dx++) {
for (var dy = -2; dy <= 2; dy++) {
var sx = startX + dx;
var sy = startY + dy;
var ex = endX + dx;
var ey = endY + dy;
if (sx >= 0 && sx < gridWidth && sy >= 0 && sy < gridHeight) {
grid.setWalkableAt(sx, sy, true);
}
if (ex >= 0 && ex < gridWidth && ey >= 0 && ey < gridHeight) {
grid.setWalkableAt(ex, ey, true);
}
}
}
// Use A* finder for better paths (JumpPoint can miss some routes)
var finder = new PF.AStarFinder({
allowDiagonal: false,
heuristic: PF.Heuristic.manhattan
});
var path = finder.findPath(startX, startY, endX, endY, grid.clone());
// If no path found, use simple fallback
if (!path || path.length === 0) {
return null; // Let caller use createSimpleRoute
}
// Simplify path to orthogonal corners only
var simplified = this.makeOrthogonal(path);
// Convert to pixel coordinates
var pixelPath = [];
for (var i = 0; i < simplified.length; i++) {
pixelPath.push({
x: minX + simplified[i][0] * GRID_SIZE,
y: minY + simplified[i][1] * GRID_SIZE
});
}
// Build SVG path with clean orthogonal lines
var svgPath = 'M ' + x1 + ' ' + y1;
// First vertical segment from source terminal
if (pixelPath.length > 0) {
var firstPt = pixelPath[0];
// Go vertical first to routing space
svgPath += ' L ' + x1 + ' ' + firstPt.y;
svgPath += ' L ' + firstPt.x + ' ' + firstPt.y;
// Add intermediate waypoints
for (var j = 1; j < pixelPath.length; j++) {
svgPath += ' L ' + pixelPath[j].x + ' ' + pixelPath[j].y;
}
// Last vertical to target
var lastPt = pixelPath[pixelPath.length - 1];
svgPath += ' L ' + x2 + ' ' + lastPt.y;
}
svgPath += ' L ' + x2 + ' ' + y2;
return svgPath;
},
// Simplify path to corner points only (removes redundant points on same line)
makeOrthogonal: function(path) {
if (path.length < 3) return path;
var result = [path[0]];
var prevDir = null;
for (var i = 1; i < path.length; i++) {
var dx = path[i][0] - path[i-1][0];
var dy = path[i][1] - path[i-1][1];
var dir = (dx !== 0) ? 'h' : 'v';
if (prevDir !== null && dir !== prevDir) {
result.push(path[i-1]);
}
prevDir = dir;
}
result.push(path[path.length - 1]);
return result;
},
// Simple fallback routing
createSimpleRoute: function(x1, y1, x2, y2, sourceIsTop, targetIsTop, routeOffset) {
var MARGIN = 25 + (routeOffset || 0);
// Simple L-shaped or U-shaped route
if (sourceIsTop && targetIsTop) {
var routeY = Math.min(y1, y2) - MARGIN;
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + routeY +
' L ' + x2 + ' ' + routeY +
' L ' + x2 + ' ' + y2;
} else if (!sourceIsTop && !targetIsTop) {
var routeYBot = Math.max(y1, y2) + MARGIN;
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + routeYBot +
' L ' + x2 + ' ' + routeYBot +
' L ' + x2 + ' ' + y2;
} else {
// Mixed - route through middle
var midY = (y1 + y2) / 2;
return 'M ' + x1 + ' ' + y1 +
' L ' + x1 + ' ' + midY +
' L ' + x2 + ' ' + midY +
' L ' + x2 + ' ' + y2;
}
},
// Handle terminal click - only in wire draw mode
handleTerminalClick: function($terminal) {
var eqId = $terminal.data('equipment-id');
var termId = $terminal.data('terminal-id');
this.log('handleTerminalClick called - eqId:', eqId, 'termId:', termId);
this.log('State: wireDrawMode:', this.wireDrawMode, 'wireExtendMode:', this.wireExtendMode, 'wireDrawSourceEq:', this.wireDrawSourceEq);
// Only works in manual wire draw mode
if (!this.wireDrawMode) return;
// In extend mode, ANY terminal click finishes the drawing
// (user wants to extend the wire to this terminal)
if (this.wireExtendMode) {
this.log('Extend mode: finishing wire to terminal', eqId, termId);
this.finishWireDrawing(eqId, termId);
return;
}
if (!this.wireDrawSourceEq) {
// First terminal - start drawing
this.wireDrawSourceEq = eqId;
this.wireDrawSourceTerm = termId;
this.wireDrawPoints = [];
// Get terminal position as first point
var eq = this.equipment.find(function(e) { return String(e.id) === String(eqId); });
if (eq) {
var terminals = this.getTerminals(eq);
var termPos = this.getTerminalPosition(eq, termId, terminals);
if (termPos) {
this.wireDrawPoints.push({x: termPos.x, y: termPos.y});
}
}
$terminal.find('.schematic-terminal-circle').attr('stroke', '#ff0').attr('stroke-width', '3');
this.showMessage('Rasterpunkte klicken, Rechtsklick/ESC = Abbruch, gleiches Terminal = Abbruch', 'info');
this.showWireGrid();
} else if (eqId === this.wireDrawSourceEq && termId === this.wireDrawSourceTerm) {
// Same terminal clicked again - cancel drawing
this.cancelWireDrawing();
} else {
// Different terminal - finish and save
this.finishWireDrawing(eqId, termId);
}
},
// Wire draw mode functions
toggleWireDrawMode: function() {
this.wireDrawMode = !this.wireDrawMode;
var $btn = $('.schematic-wire-draw-toggle');
if (this.wireDrawMode) {
$btn.addClass('active').css('background', '#27ae60');
this.showMessage('Manueller Zeichenmodus: Klicke Terminal oder Leitung für Startpunkt', 'info');
this.showWireGrid();
// Set crosshair on ENTIRE SVG in draw mode
$(this.svgElement).css('cursor', 'crosshair');
// Also on all child elements to prevent override
$(this.svgElement).find('*').css('cursor', 'inherit');
} else {
$btn.removeClass('active').css('background', '');
this.cancelWireDrawing();
this.hideWireGrid();
this.showMessage('Automatischer Modus aktiviert', 'info');
}
},
// Start drawing from a junction point on an existing connection
startJunctionDraw: function(connId, event) {
var self = this;
var conn = this.connections.find(function(c) { return String(c.id) === String(connId); });
if (!conn) return;
// Get click position in SVG coordinates
var svg = this.svgElement;
var pt = svg.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
var svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
// Find nearest point on the connection path
var junctionPoint = this.findNearestPointOnConnection(conn, svgPt.x, svgPt.y);
if (!junctionPoint) {
this.showMessage('Konnte Abzweigpunkt nicht berechnen', 'error');
return;
}
// Store junction info
this.wireDrawFromJunction = true;
this.wireDrawJunctionConn = connId;
this.wireDrawJunctionPoint = junctionPoint;
this.wireDrawPoints = [{x: junctionPoint.x, y: junctionPoint.y}];
// Visual feedback - draw a marker at junction point
this.showJunctionMarker(junctionPoint);
this.showMessage('Abzweigung von Leitung: Rasterpunkte klicken, dann Ziel-Terminal', 'info');
},
// Finish drawing a wire that ends at a junction on an existing connection
// (Terminal -> Wire junction)
finishWireToJunction: function(connId, event) {
var self = this;
var conn = this.connections.find(function(c) { return String(c.id) === String(connId); });
if (!conn) return;
// Get click position in SVG coordinates
var svg = this.svgElement;
var pt = svg.createSVGPoint();
pt.x = event.clientX;
pt.y = event.clientY;
var svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
// Find nearest point on the connection path
var junctionPoint = this.findNearestPointOnConnection(conn, svgPt.x, svgPt.y);
if (!junctionPoint) {
this.showMessage('Konnte Verbindungspunkt nicht berechnen', 'error');
return;
}
// Add junction point to path
this.wireDrawPoints.push({x: junctionPoint.x, y: junctionPoint.y});
// Build path string from points
var pathData = '';
for (var i = 0; i < this.wireDrawPoints.length; i++) {
pathData += (i === 0 ? 'M' : 'L') + ' ' + this.wireDrawPoints[i].x + ' ' + this.wireDrawPoints[i].y + ' ';
}
// Show dialog to configure the connection
// Note: cleanup is done inside the dialog handlers (save/cancel)
this.showTerminalToJunctionDialog(
this.wireDrawSourceEq,
this.wireDrawSourceTerm,
connId,
junctionPoint,
pathData.trim()
);
},
// Dialog for creating a connection from terminal to junction on existing wire
showTerminalToJunctionDialog: function(sourceEqId, sourceTermId, targetConnId, junctionPoint, pathData) {
var self = this;
// Remove any existing dialog first
$('#schematic-conn-dialog').remove();
var sourceEq = this.equipment.find(function(e) { return String(e.id) === String(sourceEqId); });
var targetConn = this.connections.find(function(c) { return String(c.id) === String(targetConnId); });
var targetLabel = targetConn ? (targetConn.output_label || targetConn.connection_type || 'Leitung') : 'Leitung';
var html = '<div id="schematic-conn-dialog" class="kundenkarte-modal visible">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3><i class="fa fa-code-fork"></i> Verbindung zur Leitung</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div style="margin-bottom:15px;padding:10px;background:#2a2a2a;border-radius:4px;">';
html += '<strong style="color:#3498db;">' + this.escapeHtml((sourceEq ? sourceEq.label || sourceEq.type_label_short : 'Gerät')) + '</strong>';
html += ' <i class="fa fa-arrow-right" style="color:#888;margin:0 10px;"></i> ';
html += '<strong style="color:#f39c12;"><i class="fa fa-code-fork"></i> ' + this.escapeHtml(targetLabel) + '</strong>';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Bezeichnung / Stromkreis</label>';
html += '<input type="text" id="conn-label" class="flat" style="width:100%;padding:8px;" placeholder="z.B. Küche Steckdosen, Bad Licht, L1">';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Typ (optional)</label>';
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
var typePresets = ['L1', 'L2', 'L3', 'N', 'PE', 'L1N', '3P'];
typePresets.forEach(function(t) {
var color = self.PHASE_COLORS[t] || self.COLORS.connection;
html += '<button type="button" class="conn-type-btn" data-type="' + t + '" data-color="' + color + '" ';
html += 'style="padding:5px 12px;background:' + color + ';color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:12px;">';
html += t + '</button>';
});
html += '</div>';
html += '<input type="hidden" id="conn-type" value="L1N">';
html += '<input type="hidden" id="conn-color" value="' + this.COLORS.connection + '">';
html += '</div>';
html += '<div class="form-group">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Kabel (optional)</label>';
html += '<div style="display:flex;gap:8px;">';
html += '<input type="text" id="conn-medium" class="flat" style="flex:1;padding:8px;" placeholder="z.B. NYM-J 3x1.5">';
html += '<input type="text" id="conn-length" class="flat" style="width:80px;padding:8px;" placeholder="Länge">';
html += '</div>';
html += '</div>';
// Bundled terminals checkbox - only show if source equipment has >1 terminal
var sourceTerminalCount = sourceEq ? this.getTerminalCount(sourceEq) : 1;
if (sourceTerminalCount > 1) {
html += '<div class="form-group" style="margin-top:12px;">';
html += '<label style="display:flex;align-items:center;gap:8px;color:#aaa;cursor:pointer;">';
html += '<input type="checkbox" id="conn-bundle-all" style="width:18px;height:18px;">';
html += '<span>Alle Terminals bündeln <small style="color:#888;">(' + sourceTerminalCount + ' Terminals)</small></span>';
html += '</label>';
html += '</div>';
}
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="conn-save"><i class="fa fa-check"></i> Erstellen</button> ';
html += '<button type="button" class="button" id="conn-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
// Type preset buttons
$('.conn-type-btn').on('click', function() {
$('.conn-type-btn').css('outline', 'none');
$(this).css('outline', '2px solid #fff');
$('#conn-type').val($(this).data('type'));
$('#conn-color').val($(this).data('color'));
});
$('#conn-save').on('click', function() {
var label = $('#conn-label').val().trim();
var connType = $('#conn-type').val();
var color = $('#conn-color').val();
var medium = $('#conn-medium').val();
var length = $('#conn-length').val();
var bundleAll = $('#conn-bundle-all').is(':checked');
// Create connection: terminal -> junction on wire
self.saveJunctionConnection({
anlage_id: self.anlageId,
fk_source: sourceEqId,
source_terminal_id: sourceTermId,
fk_target: null, // No target equipment
target_terminal_id: null,
output_label: label,
connection_type: connType,
color: color,
medium_type: medium,
medium_length: length,
path_data: pathData,
bundled_terminals: bundleAll ? 'all' : '',
junction_connection_id: targetConnId,
junction_x: junctionPoint.x,
junction_y: junctionPoint.y
});
$('#schematic-conn-dialog').remove();
// Cleanup after dialog closes - keep draw mode active
self.cleanupWireDrawState(true);
});
$('#conn-cancel, .kundenkarte-modal-close').on('click', function() {
$('#schematic-conn-dialog').remove();
// Cleanup after dialog closes - keep draw mode active
self.cleanupWireDrawState(true);
});
},
// Find nearest point on a connection path
findNearestPointOnConnection: function(conn, clickX, clickY) {
var pathData = conn.path_data;
if (!pathData) {
// No manual path - calculate from terminals
var sourceEq = conn.fk_source ? this.equipment.find(function(e) { return String(e.id) === String(conn.fk_source); }) : null;
var targetEq = conn.fk_target ? this.equipment.find(function(e) { return String(e.id) === String(conn.fk_target); }) : null;
// For outputs (no target), create path from source going down/up
if (sourceEq && !targetEq) {
var sourceTerminals = this.getTerminals(sourceEq);
var sourcePos = this.getTerminalPosition(sourceEq, conn.source_terminal_id || 't2', sourceTerminals);
if (!sourcePos) return null;
// Create a simple vertical path for output
var direction = sourcePos.position === 'top' ? -1 : 1;
var endY = sourcePos.y + (direction * 60);
pathData = 'M ' + sourcePos.x + ' ' + sourcePos.y + ' L ' + sourcePos.x + ' ' + endY;
} else if (sourceEq && targetEq) {
var sourceTerminals = this.getTerminals(sourceEq);
var targetTerminals = this.getTerminals(targetEq);
var sourcePos = this.getTerminalPosition(sourceEq, conn.source_terminal_id || 't2', sourceTerminals);
var targetPos = this.getTerminalPosition(targetEq, conn.target_terminal_id || 't1', targetTerminals);
if (!sourcePos || !targetPos) return null;
pathData = this.createOrthogonalPath(sourcePos, targetPos, 0, sourceEq, targetEq);
} else {
return null;
}
}
// Parse path data to get segments
var points = this.parsePathToPoints(pathData);
if (points.length < 2) return null;
// Find nearest point on any segment
var nearestPoint = null;
var minDist = Infinity;
for (var i = 0; i < points.length - 1; i++) {
var p1 = points[i];
var p2 = points[i + 1];
var closest = this.nearestPointOnSegment(p1, p2, clickX, clickY);
var dist = Math.sqrt(Math.pow(closest.x - clickX, 2) + Math.pow(closest.y - clickY, 2));
if (dist < minDist) {
minDist = dist;
nearestPoint = closest;
}
}
// Snap to grid
if (nearestPoint) {
var gridSize = 5;
nearestPoint.x = Math.round(nearestPoint.x / gridSize) * gridSize;
nearestPoint.y = Math.round(nearestPoint.y / gridSize) * gridSize;
}
return nearestPoint;
},
// Parse SVG path data to array of points
parsePathToPoints: function(pathData) {
var points = [];
var commands = pathData.match(/[ML]\s*[\d.-]+\s*[\d.-]+/gi) || [];
commands.forEach(function(cmd) {
var parts = cmd.match(/[\d.-]+/g);
if (parts && parts.length >= 2) {
points.push({
x: parseFloat(parts[0]),
y: parseFloat(parts[1])
});
}
});
return points;
},
// Find nearest point on a line segment
nearestPointOnSegment: function(p1, p2, px, py) {
var dx = p2.x - p1.x;
var dy = p2.y - p1.y;
var lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) return {x: p1.x, y: p1.y};
var t = Math.max(0, Math.min(1, ((px - p1.x) * dx + (py - p1.y) * dy) / lengthSq));
return {
x: p1.x + t * dx,
y: p1.y + t * dy
};
},
// Show visual marker at junction point
showJunctionMarker: function(point) {
var svgNS = 'http://www.w3.org/2000/svg';
var existing = this.svgElement.querySelector('.junction-marker');
if (existing) existing.remove();
var marker = document.createElementNS(svgNS, 'g');
marker.setAttribute('class', 'junction-marker');
// Pulsing circle
var circle = document.createElementNS(svgNS, 'circle');
circle.setAttribute('cx', point.x);
circle.setAttribute('cy', point.y);
circle.setAttribute('r', '8');
circle.setAttribute('fill', '#f39c12');
circle.setAttribute('stroke', '#fff');
circle.setAttribute('stroke-width', '2');
circle.style.animation = 'junctionPulse 1s infinite';
marker.appendChild(circle);
// Add pulse animation style if not exists (opacity + stroke only - NO scale to avoid position shift)
if (!document.getElementById('junction-pulse-style')) {
var style = document.createElement('style');
style.id = 'junction-pulse-style';
style.textContent = '@keyframes junctionPulse { 0%, 100% { opacity: 1; stroke-width: 2px; } 50% { opacity: 0.6; stroke-width: 4px; } }';
document.head.appendChild(style);
}
this.svgElement.appendChild(marker);
},
showWireGrid: function() {
this.log('showWireGrid called');
if (!this.svgElement) {
this.log('No SVG element found!');
return;
}
// Remove existing wire grid
var existing = this.svgElement.querySelector('.schematic-wire-grid-layer');
if (existing) existing.remove();
var self = this;
var svgNS = 'http://www.w3.org/2000/svg';
var gridLayer = document.createElementNS(svgNS, 'g');
gridLayer.setAttribute('class', 'schematic-wire-grid-layer');
gridLayer.setAttribute('pointer-events', 'none');
// Collect all terminal positions and routing lines
this.wireGridPoints = [];
var pointCount = 0;
// SVG-Bereich ermitteln
var svgWidth = parseInt(this.svgElement.getAttribute('width')) || 800;
var svgHeight = parseInt(this.svgElement.getAttribute('height')) || 600;
// X- und Y-Positionen direkt aus Equipment-Terminals berechnen
// (Exakt wie getTerminalPosition() es macht)
var xPositions = [];
var yPositions = [];
var minX = Infinity, maxX = 0;
var minY = Infinity, maxY = 0;
this.equipment.forEach(function(eq) {
if (typeof eq._x === 'undefined' || typeof eq._y === 'undefined') return;
var widthTE = parseFloat(eq.width_te) || 1;
var terminals = self.getTerminals(eq);
// Alle Terminals dieses Equipment durchgehen
var topTerminals = terminals.filter(function(t) { return t.pos === 'top'; });
var bottomTerminals = terminals.filter(function(t) { return t.pos === 'bottom'; });
// Top-Terminals
for (var i = 0; i < topTerminals.length; i++) {
var teIndex = i % widthTE;
var x = eq._x + (teIndex * self.TE_WIDTH) + (self.TE_WIDTH / 2);
var y = eq._y - 5;
if (xPositions.indexOf(x) === -1) xPositions.push(x);
if (yPositions.indexOf(y) === -1) yPositions.push(y);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
}
// Bottom-Terminals
for (var j = 0; j < bottomTerminals.length; j++) {
var teIdx = j % widthTE;
var xb = eq._x + (teIdx * self.TE_WIDTH) + (self.TE_WIDTH / 2);
var yb = eq._y + (eq._height || self.BLOCK_HEIGHT) + 5;
if (xPositions.indexOf(xb) === -1) xPositions.push(xb);
if (yPositions.indexOf(yb) === -1) yPositions.push(yb);
if (xb < minX) minX = xb;
if (xb > maxX) maxX = xb;
if (yb > maxY) maxY = yb;
}
});
// Rand-Raster links und rechts (alle 20px)
if (minX !== Infinity) {
for (var leftX = minX - 30; leftX > 30; leftX -= 20) {
if (xPositions.indexOf(leftX) === -1) xPositions.push(leftX);
}
for (var rightX = maxX + 30; rightX < svgWidth - 30; rightX += 20) {
if (xPositions.indexOf(rightX) === -1) xPositions.push(rightX);
}
}
// Routing-Bereich ober- und unterhalb der Blöcke
// Sortiere Carriers nach Y-Position für Zwischen-Berechnung
var sortedCarriers = this.carriers.slice().sort(function(a, b) {
return (a._y || 0) - (b._y || 0);
});
sortedCarriers.forEach(function(carrier, carrierIdx) {
if (typeof carrier._y === 'undefined') return;
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
var blockTop = railCenterY - self.BLOCK_HEIGHT / 2;
var blockBottom = railCenterY + self.BLOCK_HEIGHT / 2;
// Raster oberhalb (Sammelschienen-Bereich) - erweitert für Outputs oben
for (var offsetY = 20; offsetY < self.OUTPUT_ZONE_HEIGHT + self.ROUTING_ZONE_HEIGHT; offsetY += 15) {
var routeY = blockTop - offsetY;
if (routeY > 20 && yPositions.indexOf(routeY) === -1) yPositions.push(routeY);
}
// Raster unterhalb (Abgang-Bereich) - erweitert für Outputs unten
for (var offsetY2 = 20; offsetY2 < self.OUTPUT_ZONE_HEIGHT + self.ROUTING_ZONE_HEIGHT; offsetY2 += 15) {
var routeY2 = blockBottom + offsetY2;
if (routeY2 < svgHeight - 20 && yPositions.indexOf(routeY2) === -1) yPositions.push(routeY2);
}
// Routing-Zone ZWISCHEN zwei aufeinanderfolgenden Carriern
if (carrierIdx < sortedCarriers.length - 1) {
var nextCarrier = sortedCarriers[carrierIdx + 1];
if (typeof nextCarrier._y !== 'undefined') {
var nextBlockTop = nextCarrier._y + self.RAIL_HEIGHT / 2 - self.BLOCK_HEIGHT / 2;
var routingZoneStart = blockBottom + 10;
var routingZoneEnd = nextBlockTop - 10;
// Fülle die Zone zwischen den Carriern mit Grid-Punkten
for (var zoneY = routingZoneStart; zoneY < routingZoneEnd; zoneY += 15) {
if (yPositions.indexOf(zoneY) === -1) yPositions.push(zoneY);
}
}
}
});
// Sort positions
xPositions.sort(function(a, b) { return a - b; });
yPositions.sort(function(a, b) { return a - b; });
// PERFORMANCE: Only store snap points, don't create visual DOM elements
// This reduces from potentially 5000+ DOM elements to just a data array
xPositions.forEach(function(x) {
yPositions.forEach(function(y) {
self.wireGridPoints.push({x: x, y: y});
pointCount++;
});
});
self.log('Wire grid: ' + xPositions.length + ' x-positions, ' + yPositions.length + ' y-positions = ' + pointCount + ' snap points');
// Create magnetic cursor indicator (shows nearest grid point)
var magnetCursor = document.createElementNS(svgNS, 'circle');
magnetCursor.setAttribute('class', 'wire-grid-magnet');
magnetCursor.setAttribute('r', '6');
magnetCursor.setAttribute('fill', 'none');
magnetCursor.setAttribute('stroke', '#27ae60');
magnetCursor.setAttribute('stroke-width', '2');
magnetCursor.setAttribute('opacity', '0');
magnetCursor.setAttribute('pointer-events', 'none');
gridLayer.appendChild(magnetCursor);
// Insert grid layer BEFORE terminals layer so terminals remain clickable
var terminalsLayer = this.svgElement.querySelector('.schematic-terminals-layer');
if (terminalsLayer) {
this.svgElement.insertBefore(gridLayer, terminalsLayer);
} else {
this.svgElement.appendChild(gridLayer);
}
self.log('Wire grid created with ' + pointCount + ' snap points (no DOM elements)');
// Setup magnetic snap behavior
this.setupMagneticSnap();
},
setupMagneticSnap: function() {
var self = this;
var $svg = $(this.svgElement);
var magnetRadius = 25; // Snap-Radius in SVG-Einheiten
// Remove old handler if exists
$svg.off('mousemove.magneticSnap');
$svg.on('mousemove.magneticSnap', function(e) {
if (!self.wireDrawMode) return;
// KORREKT: SVG-Koordinaten über matrixTransform
var svg = self.svgElement;
var pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
var svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
var x = svgP.x;
var y = svgP.y;
// Find nearest grid point from terminal-aligned points
var nearestX = x, nearestY = y;
var minDist = Infinity;
if (self.wireGridPoints && self.wireGridPoints.length > 0) {
self.wireGridPoints.forEach(function(gpt) {
var d = Math.sqrt(Math.pow(x - gpt.x, 2) + Math.pow(y - gpt.y, 2));
if (d < minDist) {
minDist = d;
nearestX = gpt.x;
nearestY = gpt.y;
}
});
}
var magnetCursor = self.svgElement.querySelector('.wire-grid-magnet');
if (magnetCursor) {
if (minDist <= magnetRadius) {
// Show magnetic indicator at nearest grid point
magnetCursor.setAttribute('cx', nearestX);
magnetCursor.setAttribute('cy', nearestY);
magnetCursor.setAttribute('opacity', '1');
// Store snapped position for click handling
self.magnetSnappedPos = {x: nearestX, y: nearestY};
} else {
magnetCursor.setAttribute('opacity', '0');
self.magnetSnappedPos = null;
}
}
});
},
// Snap coordinates to nearest terminal-aligned grid point
snapToTerminalGrid: function(x, y) {
var self = this;
// If we have pre-calculated grid points, find nearest
if (this.wireGridPoints && this.wireGridPoints.length > 0) {
var nearestX = x, nearestY = y;
var minDist = Infinity;
this.wireGridPoints.forEach(function(pt) {
var d = Math.sqrt(Math.pow(x - pt.x, 2) + Math.pow(y - pt.y, 2));
if (d < minDist) {
minDist = d;
nearestX = pt.x;
nearestY = pt.y;
}
});
return {x: nearestX, y: nearestY};
}
// Fallback: snap to TE centers for X, and nearest terminal Y
var nearestX = x, nearestY = y;
// Find nearest TE center X
var minDistX = Infinity;
this.carriers.forEach(function(carrier) {
if (typeof carrier._x === 'undefined') return;
var totalTE = carrier.total_te || 12;
for (var te = 1; te <= totalTE; te++) {
var teX = carrier._x + (te - 1) * self.TE_WIDTH + (self.TE_WIDTH / 2);
var dx = Math.abs(x - teX);
if (dx < minDistX) {
minDistX = dx;
nearestX = teX;
}
}
});
// Find nearest terminal Y
var minDistY = Infinity;
this.equipment.forEach(function(eq) {
if (typeof eq._y === 'undefined') return;
var topY = eq._y - 7;
var bottomY = eq._y + (eq._height || self.BLOCK_HEIGHT) + 7;
var dyTop = Math.abs(y - topY);
var dyBottom = Math.abs(y - bottomY);
if (dyTop < minDistY) {
minDistY = dyTop;
nearestY = topY;
}
if (dyBottom < minDistY) {
minDistY = dyBottom;
nearestY = bottomY;
}
});
return {x: nearestX, y: nearestY};
},
hideWireGrid: function() {
if (this.svgElement) {
var grid = this.svgElement.querySelector('.schematic-wire-grid-layer');
if (grid) grid.remove();
}
// Remove magnetic snap handler
$(this.svgElement).off('mousemove.magneticSnap');
this.magnetSnappedPos = null;
this.wireGridPoints = null;
this.log('Wire grid hidden');
},
updateWirePreview: function() {
var svgNS = 'http://www.w3.org/2000/svg';
var preview = this.svgElement.querySelector('.wire-draw-preview');
if (!preview) {
// Create preview path using native DOM (jQuery doesn't work with SVG namespaces)
preview = document.createElementNS(svgNS, 'path');
preview.setAttribute('class', 'wire-draw-preview');
preview.setAttribute('fill', 'none');
preview.setAttribute('stroke', '#27ae60');
preview.setAttribute('stroke-width', '3');
this.svgElement.appendChild(preview); // Add to end of SVG (on top)
}
if (this.wireDrawPoints.length < 1) {
preview.setAttribute('d', '');
return;
}
// Build path from all points
var d = 'M ' + this.wireDrawPoints[0].x + ' ' + this.wireDrawPoints[0].y;
for (var i = 1; i < this.wireDrawPoints.length; i++) {
d += ' L ' + this.wireDrawPoints[i].x + ' ' + this.wireDrawPoints[i].y;
}
preview.setAttribute('d', d);
this.log('Wire preview updated:', d);
},
updateWirePreviewCursor: function(x, y) {
var svgNS = 'http://www.w3.org/2000/svg';
var cursor = this.svgElement.querySelector('.wire-draw-cursor');
var cursorDot = this.svgElement.querySelector('.wire-draw-cursor-dot');
var cursorLine = this.svgElement.querySelector('.wire-draw-cursor-line');
if (!cursor) {
// Create cursor elements using native DOM
cursor = document.createElementNS(svgNS, 'circle');
cursor.setAttribute('class', 'wire-draw-cursor');
cursor.setAttribute('r', '8');
cursor.setAttribute('fill', 'none');
cursor.setAttribute('stroke', '#27ae60');
cursor.setAttribute('stroke-width', '2');
this.svgElement.appendChild(cursor);
cursorDot = document.createElementNS(svgNS, 'circle');
cursorDot.setAttribute('class', 'wire-draw-cursor-dot');
cursorDot.setAttribute('r', '3');
cursorDot.setAttribute('fill', '#27ae60');
this.svgElement.appendChild(cursorDot);
cursorLine = document.createElementNS(svgNS, 'line');
cursorLine.setAttribute('class', 'wire-draw-cursor-line');
cursorLine.setAttribute('stroke', '#27ae60');
cursorLine.setAttribute('stroke-width', '2');
cursorLine.setAttribute('stroke-dasharray', '5,5');
cursorLine.setAttribute('opacity', '0.7');
this.svgElement.appendChild(cursorLine);
}
// Update cursor position (green snap indicator)
cursor.setAttribute('cx', x);
cursor.setAttribute('cy', y);
cursorDot.setAttribute('cx', x);
cursorDot.setAttribute('cy', y);
// Draw line from last point to cursor (if we have points)
if (this.wireDrawPoints.length > 0) {
var lastPt = this.wireDrawPoints[this.wireDrawPoints.length - 1];
cursorLine.setAttribute('x1', lastPt.x);
cursorLine.setAttribute('y1', lastPt.y);
cursorLine.setAttribute('x2', x);
cursorLine.setAttribute('y2', y);
cursorLine.style.display = '';
} else {
// No points yet - hide line
cursorLine.style.display = 'none';
}
},
cancelWireDrawing: function() {
this.cleanupWireDrawState();
this.showMessage('Zeichnung abgebrochen', 'info');
},
finishWireDrawing: function(targetEqId, targetTermId) {
var self = this;
this.log('finishWireDrawing called - targetEqId:', targetEqId, 'targetTermId:', targetTermId);
this.log('State: wireExtendMode:', this.wireExtendMode, 'wireExtendConnId:', this.wireExtendConnId);
// Get target terminal position
var eq = this.equipment.find(function(e) { return String(e.id) === String(targetEqId); });
if (eq) {
var terminals = this.getTerminals(eq);
var termPos = this.getTerminalPosition(eq, targetTermId, terminals);
if (termPos) {
this.wireDrawPoints.push({x: termPos.x, y: termPos.y});
}
}
// Handle EXTEND mode - update existing connection
if (this.wireExtendMode && this.wireExtendConnId) {
// Combine existing points with new points (skip first point of new since it's the join point)
var allPoints = this.wireExtendExistingPoints.concat(this.wireDrawPoints.slice(1));
// Build combined path
var pathData = '';
for (var i = 0; i < allPoints.length; i++) {
pathData += (i === 0 ? 'M' : 'L') + ' ' + allPoints[i].x + ' ' + allPoints[i].y + ' ';
}
// Update the existing connection with new path and target
this.updateExtendedConnection(this.wireExtendConnId, targetEqId, targetTermId, pathData.trim());
// Extend mode: don't keep draw mode
this.cleanupWireDrawState(false);
return;
}
// Build path string from points
var pathData = '';
for (var i = 0; i < this.wireDrawPoints.length; i++) {
pathData += (i === 0 ? 'M' : 'L') + ' ' + this.wireDrawPoints[i].x + ' ' + this.wireDrawPoints[i].y + ' ';
}
// Handle junction source vs terminal source
if (this.wireDrawFromJunction) {
// Junction source - inherit from source connection
this._inheritFromConnection = this.wireDrawJunctionConn;
this._pendingPathData = pathData.trim();
// Junction connections have no source equipment (fk_source = NULL)
this.showConnectionLabelDialog(null, null, targetEqId, targetTermId);
} else {
// Normal terminal source
this._pendingPathData = pathData.trim();
this.showConnectionLabelDialog(
this.wireDrawSourceEq,
this.wireDrawSourceTerm,
targetEqId,
targetTermId
);
}
// Cleanup - keep draw mode active for drawing multiple connections
this.cleanupWireDrawState(true);
},
cleanupWireDrawState: function(keepDrawMode) {
this.wireDrawSourceEq = null;
this.wireDrawSourceTerm = null;
this.wireDrawFromJunction = false;
this.wireDrawJunctionConn = null;
this.wireDrawJunctionPoint = null;
this.wireDrawPoints = [];
// Extend mode cleanup
this.wireExtendMode = false;
this.wireExtendConnId = null;
this.wireExtendExistingPoints = [];
// Remove preview elements
var elements = this.svgElement.querySelectorAll('.wire-draw-preview, .wire-draw-cursor, .wire-draw-cursor-dot, .wire-draw-cursor-line, .junction-marker, .extend-start-marker');
elements.forEach(function(el) { el.remove(); });
// Reset wire draw mode only if not keeping it
if (!keepDrawMode) {
this.wireDrawMode = false;
var $btn = $('.schematic-wire-draw-toggle');
$btn.removeClass('active').css('background', '');
$(this.svgElement).find('.schematic-terminal-circle').attr('stroke', '#fff').attr('stroke-width', '2');
// Reset SVG and all child cursors
$(this.svgElement).css('cursor', '');
$(this.svgElement).find('*').css('cursor', '');
} else {
// Keep draw mode active - just reset terminal highlights
$(this.svgElement).find('.schematic-terminal-circle').attr('stroke', '#fff').attr('stroke-width', '2');
this.showMessage('Zeichenmodus aktiv - nächste Verbindung zeichnen', 'info');
}
},
// ========================================
// Wire Segment Dragging
// ========================================
/**
* Parse SVG path data to array of points
*/
parsePathToPoints: function(pathData) {
if (!pathData) return [];
var points = [];
var regex = /([ML])\s*(-?[\d.]+)\s+(-?[\d.]+)/g;
var match;
while ((match = regex.exec(pathData)) !== null) {
points.push({
x: parseFloat(match[2]),
y: parseFloat(match[3])
});
}
return points;
},
/**
* Convert points array back to SVG path string
*/
pointsToPath: function(points) {
if (!points || points.length === 0) return '';
var path = '';
for (var i = 0; i < points.length; i++) {
path += (i === 0 ? 'M ' : 'L ') + Math.round(points[i].x) + ' ' + Math.round(points[i].y) + ' ';
}
return path.trim();
},
/**
* Find which segment of a path was clicked
* Returns: { index: segmentIndex, isHorizontal: bool } or null
*/
findClickedSegment: function(points, clickX, clickY, threshold) {
threshold = threshold || 15;
for (var i = 0; i < points.length - 1; i++) {
var p1 = points[i];
var p2 = points[i + 1];
// Check if click is near this segment
var isHorizontal = Math.abs(p1.y - p2.y) < 2; // Horizontal if Y values are same
var isVertical = Math.abs(p1.x - p2.x) < 2; // Vertical if X values are same
if (isHorizontal) {
// Horizontal segment - check if click is within X range and near Y
var minX = Math.min(p1.x, p2.x);
var maxX = Math.max(p1.x, p2.x);
if (clickX >= minX - threshold && clickX <= maxX + threshold &&
Math.abs(clickY - p1.y) <= threshold) {
return { index: i, isHorizontal: true };
}
} else if (isVertical) {
// Vertical segment - check if click is within Y range and near X
var minY = Math.min(p1.y, p2.y);
var maxY = Math.max(p1.y, p2.y);
if (clickY >= minY - threshold && clickY <= maxY + threshold &&
Math.abs(clickX - p1.x) <= threshold) {
return { index: i, isHorizontal: false };
}
} else {
// Diagonales Segment - Punkt-zu-Linie Distanz prüfen
var dx = p2.x - p1.x;
var dy = p2.y - p1.y;
var lengthSq = dx * dx + dy * dy;
// Projektion t auf Segment [0, 1]
var t = Math.max(0, Math.min(1, ((clickX - p1.x) * dx + (clickY - p1.y) * dy) / lengthSq));
var nearX = p1.x + t * dx;
var nearY = p1.y + t * dy;
var dist = Math.sqrt(Math.pow(clickX - nearX, 2) + Math.pow(clickY - nearY, 2));
if (dist <= threshold) {
// Diagonale Segmente werden als "horizontal" behandelt wenn Y-Anteil größer,
// sonst als "vertikal" - damit die Drag-Richtung sinnvoll ist
var diagIsHorizontal = Math.abs(dy) < Math.abs(dx);
return { index: i, isHorizontal: diagIsHorizontal };
}
}
}
return null;
},
/**
* Pfad-Daten für eine Connection generieren wenn path_data fehlt.
* Erzeugt den Pfad aus den aktuellen Terminal-Positionen.
* Für 2-Punkt-Pfade (einfache Linien) werden Zwischenpunkte eingefügt,
* damit mittlere Segmente gedraggt werden können.
*/
generatePathDataForDrag: function(conn) {
var sourceEq = this.getEquipmentById(conn.fk_source);
var targetEq = this.getEquipmentById(conn.fk_target);
var pathData = null;
if (sourceEq && targetEq) {
// Normale Verbindung (Source → Target)
var sourceTerminals = this.getTerminals(sourceEq);
var targetTerminals = this.getTerminals(targetEq);
var sourcePos = this.getTerminalPosition(sourceEq, conn.source_terminal_id || 't2', sourceTerminals);
var targetPos = this.getTerminalPosition(targetEq, conn.target_terminal_id || 't1', targetTerminals);
if (!sourcePos || !targetPos) return null;
pathData = this.createOrthogonalPath(sourcePos, targetPos, 0, sourceEq, targetEq);
} else if (sourceEq && !conn.fk_target) {
// Abgang (Output) - vertikale Linie vom Terminal weg
var sourceTerminals = this.getTerminals(sourceEq);
var sourceTermId = conn.source_terminal_id || 't2';
var sourcePos = this.getTerminalPosition(sourceEq, sourceTermId, sourceTerminals);
if (!sourcePos) return null;
var isBundled = conn.bundled_terminals === 'all';
var lineX = sourcePos.x;
// Bei gebündelten Terminals: Mitte berechnen
if (isBundled && sourceTerminals && sourceTerminals.length > 1) {
var samePosTerm = sourceTerminals.filter(function(t) {
return t.pos === (sourcePos.isTop ? 'top' : 'bottom');
});
if (samePosTerm.length > 1) {
var positions = samePosTerm.map(function(t) {
var pos = this.getTerminalPosition(sourceEq, t.id, sourceTerminals);
return pos ? pos.x : lineX;
}.bind(this));
lineX = (Math.min.apply(null, positions) + Math.max.apply(null, positions)) / 2;
}
}
var labelText = conn.output_label || '';
if (conn.output_location) labelText += ' · ' + conn.output_location;
var cableText = (conn.medium_type || '') + ' ' + (conn.medium_spec || '');
var maxTextLen = Math.max(labelText.length, cableText.trim().length);
var lineLength = Math.min(120, Math.max(50, maxTextLen * 6 + 20));
var goingUp = sourcePos.isTop;
var endY = goingUp ? (sourcePos.y - lineLength) : (sourcePos.y + lineLength);
pathData = 'M ' + lineX + ' ' + sourcePos.y + ' L ' + lineX + ' ' + endY;
} else if (!conn.fk_source && targetEq) {
// Anschlusspunkt (Input) - Linie von außen ins Terminal
var targetTerminals = this.getTerminals(targetEq);
var targetTermId = conn.target_terminal_id || 't1';
var targetPos = this.getTerminalPosition(targetEq, targetTermId, targetTerminals);
if (!targetPos) return null;
var inputLabel = conn.output_label || '';
var inputLineLength = Math.min(80, Math.max(45, inputLabel.length * 5 + 30));
var startY = targetPos.isTop ? (targetPos.y - inputLineLength) : (targetPos.y + inputLineLength);
pathData = 'M ' + targetPos.x + ' ' + startY + ' L ' + targetPos.x + ' ' + targetPos.y;
}
return pathData;
},
/**
* Einen 2-Punkt-Pfad in einen 4-Punkt-Pfad umwandeln,
* damit die mittleren Segmente gedraggt werden können.
* Aus einer Linie A→B wird: A → Knick1 → Knick2 → B
*/
ensureDraggablePath: function(points) {
if (points.length >= 4) return points;
if (points.length === 2) {
var p1 = points[0];
var p2 = points[1];
var isVertical = Math.abs(p1.x - p2.x) < 2;
var isHorizontal = Math.abs(p1.y - p2.y) < 2;
if (isVertical) {
// Vertikale Linie → Z-Form mit horizontalem Mittelsegment
var midY1 = p1.y + (p2.y - p1.y) * 0.33;
var midY2 = p1.y + (p2.y - p1.y) * 0.66;
return [
{ x: p1.x, y: p1.y },
{ x: p1.x, y: midY1 },
{ x: p1.x, y: midY2 },
{ x: p2.x, y: p2.y }
];
} else if (isHorizontal) {
// Horizontale Linie → Z-Form mit vertikalem Mittelsegment
var midX1 = p1.x + (p2.x - p1.x) * 0.33;
var midX2 = p1.x + (p2.x - p1.x) * 0.66;
return [
{ x: p1.x, y: p1.y },
{ x: midX1, y: p1.y },
{ x: midX2, y: p2.y },
{ x: p2.x, y: p2.y }
];
} else {
// Diagonale Linie → Z-Form mit Knick in der Mitte
var midY = (p1.y + p2.y) / 2;
return [
{ x: p1.x, y: p1.y },
{ x: p1.x, y: midY },
{ x: p2.x, y: midY },
{ x: p2.x, y: p2.y }
];
}
}
if (points.length === 3) {
// 3 Punkte = 2 Segmente, nur 1 mittleres Segment aber das ist index 0 oder 1
// Problem: index 0 und length-2 (=1) sind gesperrt
// Lösung: Zwischenpunkt in das längere Segment einfügen
var seg0Len = Math.abs(points[1].x - points[0].x) + Math.abs(points[1].y - points[0].y);
var seg1Len = Math.abs(points[2].x - points[1].x) + Math.abs(points[2].y - points[1].y);
if (seg0Len >= seg1Len) {
// Erstes Segment teilen
var mid = {
x: (points[0].x + points[1].x) / 2,
y: (points[0].y + points[1].y) / 2
};
return [points[0], mid, points[1], points[2]];
} else {
// Zweites Segment teilen
var mid = {
x: (points[1].x + points[2].x) / 2,
y: (points[1].y + points[2].y) / 2
};
return [points[0], points[1], mid, points[2]];
}
}
return points;
},
/**
* Start dragging a wire segment.
* Shift+Linksklick oder Mittelklick auf einem Leitungssegment.
* Horizontale Segmente werden vertikal verschoben,
* vertikale Segmente werden horizontal verschoben.
* Start- und End-Segmente (an Terminals) bleiben fixiert.
*/
startWireDrag: function(connId, clickX, clickY) {
var conn = this.connections.find(function(c) { return c.id == connId; });
if (!conn) {
console.warn('[WireDrag] Connection nicht gefunden:', connId);
return false;
}
console.log('[WireDrag] Start für Connection #' + connId, 'click:', clickX, clickY, 'fk_source:', conn.fk_source, 'fk_target:', conn.fk_target);
// Pfad-Daten ermitteln - bevorzugt aus dem tatsächlich gerenderten SVG lesen
var pathData = conn.path_data;
if (!pathData) {
// Zuerst den gerenderten SVG-Pfad aus dem DOM lesen (stimmt mit Anzeige überein)
var $svgPath = $(this.svgElement).find('.schematic-connection[data-connection-id="' + connId + '"]');
if ($svgPath.length > 0) {
pathData = $svgPath.attr('d');
console.log('[WireDrag] Pfad aus DOM gelesen:', pathData);
}
// Fallback: Pfad generieren
if (!pathData) {
pathData = this.generatePathDataForDrag(conn);
console.log('[WireDrag] Pfad generiert:', pathData);
}
if (!pathData) {
console.warn('[WireDrag] Pfad konnte nicht generiert werden');
this.showMessage('Pfad konnte nicht generiert werden', 'warning');
return false;
}
conn.path_data = pathData;
}
var points = this.parsePathToPoints(pathData);
console.log('[WireDrag] Punkte geparst:', points.length, JSON.stringify(points));
// Sicherstellen dass genug Punkte für draggbare Segmente vorhanden sind
points = this.ensureDraggablePath(points);
console.log('[WireDrag] Nach ensureDraggablePath:', points.length, JSON.stringify(points));
if (points.length < 4) {
console.warn('[WireDrag] Zu wenige Punkte:', points.length);
return false;
}
var segment = this.findClickedSegment(points, clickX, clickY);
if (!segment) {
console.warn('[WireDrag] Kein Segment gefunden bei', clickX, clickY);
return false;
}
console.log('[WireDrag] Segment gefunden:', segment.index, '/', (points.length - 2), 'horizontal:', segment.isHorizontal);
// Segmente an Terminals fixiert → nur sperren wenn tatsächlich ein Terminal da ist
var isFirstSegment = (segment.index === 0);
var isLastSegment = (segment.index === points.length - 2);
var hasSource = conn.fk_source && conn.fk_source != '0';
var hasTarget = conn.fk_target && conn.fk_target != '0';
if (isFirstSegment && hasSource && isLastSegment && hasTarget) {
console.warn('[WireDrag] Einziges Segment, beide Enden fixiert');
this.showMessage('Segment kann nicht verschoben werden', 'warning');
return false;
}
if (isFirstSegment && hasSource) {
console.warn('[WireDrag] Erstes Segment, am Source-Terminal fixiert');
this.showMessage('Start-Segment ist am Terminal fixiert', 'warning');
return false;
}
if (isLastSegment && hasTarget) {
console.warn('[WireDrag] Letztes Segment, am Target-Terminal fixiert');
this.showMessage('End-Segment ist am Terminal fixiert', 'warning');
return false;
}
this.wireDragMode = true;
this.wireDragConnId = connId;
this.wireDragSegmentIndex = segment.index;
this.wireDragIsHorizontal = segment.isHorizontal;
this.wireDragStartPos = { x: clickX, y: clickY };
this.wireDragOriginalPoints = JSON.parse(JSON.stringify(points));
// Visuelles Feedback - Cursor zeigt Verschiebungsrichtung
$(this.svgElement).css('cursor', segment.isHorizontal ? 'ns-resize' : 'ew-resize');
this.log('Wire drag gestartet - Segment:', segment.index, 'horizontal:', segment.isHorizontal);
return true;
},
/**
* Handle mouse move during wire drag.
* Verschiebt das angeklickte Segment und aktualisiert den SVG-Pfad live.
* Die angrenzenden Segmente strecken/kürzen sich automatisch mit.
*/
handleWireDragMove: function(mouseX, mouseY) {
if (!this.wireDragMode) return;
var points = JSON.parse(JSON.stringify(this.wireDragOriginalPoints));
var segIdx = this.wireDragSegmentIndex;
if (this.wireDragIsHorizontal) {
// Horizontales Segment vertikal verschieben
var deltaY = mouseY - this.wireDragStartPos.y;
var newY = points[segIdx].y + deltaY;
newY = Math.round(newY / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
points[segIdx].y = newY;
points[segIdx + 1].y = newY;
} else {
// Vertikales Segment horizontal verschieben
var deltaX = mouseX - this.wireDragStartPos.x;
var newX = points[segIdx].x + deltaX;
newX = Math.round(newX / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
points[segIdx].x = newX;
points[segIdx + 1].x = newX;
}
// SVG-Pfad aktualisieren (alle Elemente mit dieser Connection-ID)
var newPath = this.pointsToPath(points);
var connIdStr = this.wireDragConnId;
var $svg = $(this.svgElement);
// Sichtbare Linie, Hitarea und Shadow aktualisieren (unabhängig vom Group-Typ)
$svg.find('.schematic-connection[data-connection-id="' + connIdStr + '"]').attr('d', newPath);
$svg.find('.schematic-connection-hitarea[data-connection-id="' + connIdStr + '"]').attr('d', newPath);
$svg.find('.schematic-connection-shadow[data-connection-id="' + connIdStr + '"]').attr('d', newPath);
// Auch Shadow ohne data-Attribut (direkt vor der Group) aktualisieren
var $group = $svg.find('[data-connection-id="' + connIdStr + '"]').closest('g');
$group.prev('.schematic-connection-shadow').attr('d', newPath);
},
/**
* Terminal-Position für eine Connection ermitteln.
* Gibt {x, y} zurück für den Ankerpunkt am Terminal.
*/
getConnectionTerminalAnchor: function(conn, side) {
var eq, terminals, termId, pos;
if (side === 'source') {
eq = this.getEquipmentById(conn.fk_source);
if (!eq) return null;
terminals = this.getTerminals(eq);
termId = conn.source_terminal_id || 't2';
pos = this.getTerminalPosition(eq, termId, terminals);
} else {
eq = this.getEquipmentById(conn.fk_target);
if (!eq) return null;
terminals = this.getTerminals(eq);
termId = conn.target_terminal_id || 't1';
pos = this.getTerminalPosition(eq, termId, terminals);
}
return pos ? { x: pos.x, y: pos.y } : null;
},
/**
* Prüft ob ein Punkt auf einem Segment liegt (mit Toleranz).
*/
isPointOnSegment: function(point, segStart, segEnd, tolerance) {
tolerance = tolerance || 5;
var isHoriz = Math.abs(segStart.y - segEnd.y) < 2;
var isVert = Math.abs(segStart.x - segEnd.x) < 2;
if (isHoriz) {
var minX = Math.min(segStart.x, segEnd.x);
var maxX = Math.max(segStart.x, segEnd.x);
return Math.abs(point.y - segStart.y) <= tolerance &&
point.x >= minX - tolerance && point.x <= maxX + tolerance;
} else if (isVert) {
var minY = Math.min(segStart.y, segEnd.y);
var maxY = Math.max(segStart.y, segEnd.y);
return Math.abs(point.x - segStart.x) <= tolerance &&
point.y >= minY - tolerance && point.y <= maxY + tolerance;
}
return false;
},
/**
* Prüft ob ein Punkt auf irgendeinem Segment eines Pfades liegt.
* Gibt true zurück wenn der Punkt auf dem Pfad liegt.
*/
isPointOnPath: function(point, pathPoints, tolerance) {
tolerance = tolerance || 5;
for (var i = 0; i < pathPoints.length - 1; i++) {
if (this.isPointOnSegment(point, pathPoints[i], pathPoints[i + 1], tolerance)) {
return true;
}
}
return false;
},
/**
* Finde den nächstliegenden Punkt auf einem Pfad.
* Gibt {x, y} zurück.
*/
nearestPointOnPath: function(point, pathPoints) {
var best = null;
var bestDist = Infinity;
for (var i = 0; i < pathPoints.length - 1; i++) {
var nearest = this.nearestPointOnSegment(pathPoints[i], pathPoints[i + 1], point.x, point.y);
var dist = Math.sqrt(Math.pow(nearest.x - point.x, 2) + Math.pow(nearest.y - point.y, 2));
if (dist < bestDist) {
bestDist = dist;
best = nearest;
}
}
return best || point;
},
/**
* Finde alle Connections deren Start- oder Endpunkt auf dem alten
* Segment lag (vor der Verschiebung).
* Gibt Array von {conn, pointIndex, point} zurück.
*/
/**
* Finde alle Connections deren freier Endpunkt (ohne Terminal) auf
* irgendeinem Segment des alten Pfads lag.
*/
findJunctionsOnPath: function(draggedConnId, oldPathPoints) {
var self = this;
var junctions = [];
var tolerance = 15;
this.connections.forEach(function(conn) {
if (conn.id == draggedConnId) return;
if (parseInt(conn.is_rail) === 1) return;
if (!conn.path_data) return;
var points = self.parsePathToPoints(conn.path_data);
if (points.length < 2) return;
var hasSource = conn.fk_source && conn.fk_source != '0';
var hasTarget = conn.fk_target && conn.fk_target != '0';
// Erster Punkt (Start) prüfen - nur wenn kein Terminal am Start
if (!hasSource) {
var firstOnPath = self.isPointOnPath(points[0], oldPathPoints, tolerance);
console.log('[WireDrag] Junction-Check Conn #' + conn.id + ' Start (' +
Math.round(points[0].x) + ',' + Math.round(points[0].y) + ') auf Pfad:', firstOnPath);
if (firstOnPath) {
junctions.push({ conn: conn, pointIndex: 0, point: points[0] });
}
}
// Letzter Punkt (Ende) prüfen - nur wenn kein Terminal am Ende
var lastIdx = points.length - 1;
if (!hasTarget) {
var lastOnPath = self.isPointOnPath(points[lastIdx], oldPathPoints, tolerance);
console.log('[WireDrag] Junction-Check Conn #' + conn.id + ' Ende (' +
Math.round(points[lastIdx].x) + ',' + Math.round(points[lastIdx].y) + ') auf Pfad:', lastOnPath);
if (lastOnPath) {
junctions.push({ conn: conn, pointIndex: lastIdx, point: points[lastIdx] });
}
}
});
console.log('[WireDrag] findJunctionsOnPath: ' + junctions.length + ' gefunden von ' +
self.connections.filter(function(c) { return c.id != draggedConnId && !parseInt(c.is_rail) && c.path_data; }).length + ' geprüft');
return junctions;
},
/**
* Junction-Punkte aktualisieren.
* Wenn der Junction-Punkt auf dem gezogenen Segment lag → Delta direkt anwenden.
* Wenn er auf einem anderen Segment lag → Projektion auf den neuen Pfad.
* @param {Array} junctions - Gefundene Junctions
* @param {Array} newPathPoints - Neuer Pfad (nach Verschiebung)
* @param {Object} dragInfo - {oldSegStart, oldSegEnd, isHorizontal, delta}
*/
updateJunctionPoints: function(junctions, newPathPoints, dragInfo) {
var self = this;
junctions.forEach(function(junc) {
var points = self.parsePathToPoints(junc.conn.path_data);
if (!points[junc.pointIndex]) return;
// Alte Position kopieren (juncPoint ist Referenz → wird sonst überschrieben)
var oldX = points[junc.pointIndex].x;
var oldY = points[junc.pointIndex].y;
var moved = false;
console.log('[WireDrag] Junction #' + junc.conn.id + ' Pfad vorher:', junc.conn.path_data,
'pointIndex:', junc.pointIndex, 'fk_source:', junc.conn.fk_source, 'fk_target:', junc.conn.fk_target);
// Zuerst prüfen: Lag der Punkt auf dem gezogenen Segment?
if (dragInfo && self.isPointOnSegment({x: oldX, y: oldY}, dragInfo.oldSegStart, dragInfo.oldSegEnd, 12)) {
// Punkt lag auf dem gezogenen Segment → gleichen Delta anwenden
if (dragInfo.isHorizontal) {
points[junc.pointIndex].y = Math.round(oldY + dragInfo.delta);
} else {
points[junc.pointIndex].x = Math.round(oldX + dragInfo.delta);
}
moved = true;
console.log('[WireDrag] Junction #' + junc.conn.id + ' Delta angewandt:',
oldX + ',' + oldY, '→',
points[junc.pointIndex].x + ',' + points[junc.pointIndex].y);
} else {
// Punkt lag auf anderem Segment → prüfen ob noch auf neuem Pfad
if (self.isPointOnPath({x: oldX, y: oldY}, newPathPoints, 12)) {
// Punkt liegt noch auf der Leitung → stehen lassen
console.log('[WireDrag] Junction #' + junc.conn.id + ' bleibt stehen (noch auf Leitung)',
oldX + ',' + oldY);
return;
}
// Nicht mehr auf Leitung → auf nächsten Punkt projizieren
var projected = self.nearestPointOnPath({x: oldX, y: oldY}, newPathPoints);
points[junc.pointIndex].x = Math.round(projected.x);
points[junc.pointIndex].y = Math.round(projected.y);
moved = true;
console.log('[WireDrag] Junction #' + junc.conn.id + ' projiziert:',
oldX + ',' + oldY, '→',
points[junc.pointIndex].x + ',' + points[junc.pointIndex].y);
}
if (!moved) return;
// Rechtwinkligkeit erhalten: angrenzenden Punkt anpassen
var adjIdx = (junc.pointIndex === 0) ? 1 : junc.pointIndex - 1;
if (points[adjIdx]) {
if (dragInfo.isHorizontal) {
// Y wurde verschoben - wenn angrenzendes Segment horizontal war, Y mitziehen
if (Math.abs(oldY - points[adjIdx].y) < 3) {
console.log('[WireDrag] Junction #' + junc.conn.id + ' adj[' + adjIdx + '] Y angepasst:',
points[adjIdx].y, '→', points[junc.pointIndex].y);
points[adjIdx].y = points[junc.pointIndex].y;
}
} else {
// X wurde verschoben - wenn angrenzendes Segment vertikal war, X mitziehen
if (Math.abs(oldX - points[adjIdx].x) < 3) {
console.log('[WireDrag] Junction #' + junc.conn.id + ' adj[' + adjIdx + '] X angepasst:',
points[adjIdx].x, '→', points[junc.pointIndex].x);
points[adjIdx].x = points[junc.pointIndex].x;
}
}
}
var newPath = self.pointsToPath(points);
// Lokal aktualisieren
junc.conn.path_data = newPath;
// SVG direkt aktualisieren (damit es sofort sichtbar ist)
var $svg = $(self.svgElement);
$svg.find('.schematic-connection[data-connection-id="' + junc.conn.id + '"]').attr('d', newPath);
$svg.find('.schematic-connection-hitarea[data-connection-id="' + junc.conn.id + '"]').attr('d', newPath);
$svg.find('.schematic-connection-shadow[data-connection-id="' + junc.conn.id + '"]').attr('d', newPath);
// Auf Server speichern
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: junc.conn.id,
path_data: newPath,
token: $('input[name="token"]').val()
}
});
});
},
/**
* Finish wire drag and save.
* Verankert Start/End-Punkte an den Terminal-Positionen,
* zieht Junction-Leitungen mit und speichert den neuen Pfad.
*/
finishWireDrag: function(mouseX, mouseY) {
if (!this.wireDragMode) return;
var points = JSON.parse(JSON.stringify(this.wireDragOriginalPoints));
var segIdx = this.wireDragSegmentIndex;
// Kompletten alten Pfad merken (VOR Verschiebung) für Junction-Suche
var oldPoints = JSON.parse(JSON.stringify(points));
var delta;
if (this.wireDragIsHorizontal) {
var deltaY = mouseY - this.wireDragStartPos.y;
var newY = points[segIdx].y + deltaY;
newY = Math.round(newY / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
delta = newY - points[segIdx].y;
points[segIdx].y = newY;
points[segIdx + 1].y = newY;
} else {
var deltaX = mouseX - this.wireDragStartPos.x;
var newX = points[segIdx].x + deltaX;
newX = Math.round(newX / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
delta = newX - points[segIdx].x;
points[segIdx].x = newX;
points[segIdx + 1].x = newX;
}
// Ankerpunkte an Terminals fixieren
var conn = this.connections.find(function(c) { return c.id == this.wireDragConnId; }.bind(this));
if (conn) {
if (conn.fk_source) {
var sourceAnchor = this.getConnectionTerminalAnchor(conn, 'source');
if (sourceAnchor && points.length > 0) {
points[0].x = sourceAnchor.x;
points[0].y = sourceAnchor.y;
}
}
if (conn.fk_target) {
var targetAnchor = this.getConnectionTerminalAnchor(conn, 'target');
if (targetAnchor && points.length > 1) {
points[points.length - 1].x = targetAnchor.x;
points[points.length - 1].y = targetAnchor.y;
}
}
}
// Junction-Leitungen finden die irgendwo auf dem alten Pfad lagen
var dragInfo = {
oldSegStart: { x: oldPoints[segIdx].x, y: oldPoints[segIdx].y },
oldSegEnd: { x: oldPoints[segIdx + 1].x, y: oldPoints[segIdx + 1].y },
isHorizontal: this.wireDragIsHorizontal,
delta: delta
};
var junctions = this.findJunctionsOnPath(this.wireDragConnId, oldPoints);
console.log('[WireDrag] Junctions auf altem Pfad gefunden:', junctions.length, 'delta:', delta,
'Segment:', JSON.stringify(dragInfo.oldSegStart), '→', JSON.stringify(dragInfo.oldSegEnd));
if (junctions.length > 0 && delta !== 0) {
this.updateJunctionPoints(junctions, points, dragInfo);
}
// Redundante Punkte bereinigen
points = this.cleanupPathPoints(points);
var newPath = this.pointsToPath(points);
var self = this;
// Auf Server speichern
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: this.wireDragConnId,
path_data: newPath,
token: $('input[name="token"]').val()
},
success: function(response) {
if (response.success) {
var conn = self.connections.find(function(c) { return c.id == self.wireDragConnId; });
if (conn) {
conn.path_data = newPath;
}
self.showMessage('Leitung verschoben', 'success');
self.renderConnections();
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
self.renderConnections();
}
self.cancelWireDrag();
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
self.renderConnections();
self.cancelWireDrag();
}
});
},
/**
* Cancel wire drag
*/
cancelWireDrag: function() {
this.wireDragMode = false;
this.wireDragConnId = null;
this.wireDragSegmentIndex = null;
this.wireDragIsHorizontal = null;
this.wireDragStartPos = null;
this.wireDragOriginalPoints = [];
$(this.svgElement).css('cursor', '');
// Flag damit das nachfolgende click-Event ignoriert wird
this._wireDragJustEnded = true;
var self = this;
setTimeout(function() { self._wireDragJustEnded = false; }, 100);
},
// Update an existing connection with extended path
updateExtendedConnection: function(connId, newTargetEqId, newTargetTermId, newPathData) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: connId,
fk_target: newTargetEqId,
target_terminal_id: newTargetTermId,
path_data: newPathData,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Leitung verlängert', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
// Dialog for creating a connection from a junction point
showJunctionConnectionDialog: function(sourceConnId, junctionPoint, targetEqId, targetTermId, pathData) {
var self = this;
// Get source connection info for context
var sourceConn = this.connections.find(function(c) { return String(c.id) === String(sourceConnId); });
var targetEq = this.equipment.find(function(e) { return String(e.id) === String(targetEqId); });
var sourceLabel = sourceConn ? (sourceConn.output_label || sourceConn.connection_type || 'Leitung') : 'Leitung';
var html = '<div id="schematic-conn-dialog" class="kundenkarte-modal visible">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3><i class="fa fa-code-fork"></i> Abzweigung erstellen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div style="margin-bottom:15px;padding:10px;background:#2a2a2a;border-radius:4px;">';
html += '<strong style="color:#f39c12;"><i class="fa fa-code-fork"></i> Abzweig von: ' + this.escapeHtml(sourceLabel) + '</strong>';
html += ' <i class="fa fa-arrow-right" style="color:#888;margin:0 10px;"></i> ';
html += '<strong style="color:#27ae60;">' + this.escapeHtml((targetEq ? targetEq.label || targetEq.type_label_short : 'Gerät')) + '</strong>';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Bezeichnung / Stromkreis</label>';
html += '<input type="text" id="conn-label" class="flat" style="width:100%;padding:8px;" placeholder="z.B. Küche Steckdosen">';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Typ</label>';
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
var typePresets = ['L1', 'L2', 'L3', 'N', 'PE', 'L1N', '3P'];
// Pre-select the same type as source connection
var defaultType = sourceConn ? sourceConn.connection_type : 'L1N';
typePresets.forEach(function(t) {
var color = self.PHASE_COLORS[t] || self.COLORS.connection;
var selected = (t === defaultType) ? 'outline:2px solid #fff;' : '';
html += '<button type="button" class="conn-type-btn" data-type="' + t + '" data-color="' + color + '" ';
html += 'style="padding:5px 12px;background:' + color + ';color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:12px;' + selected + '">';
html += t + '</button>';
});
html += '</div>';
html += '<input type="hidden" id="conn-type" value="' + (defaultType || 'L1N') + '">';
html += '<input type="hidden" id="conn-color" value="' + (self.PHASE_COLORS[defaultType] || self.COLORS.connection) + '">';
html += '</div>';
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="conn-save"><i class="fa fa-check"></i> Erstellen</button> ';
html += '<button type="button" class="button" id="conn-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
// Event handlers
$('.kundenkarte-modal-close, #conn-cancel').on('click', function() {
$('#schematic-conn-dialog').remove();
});
$('.conn-type-btn').on('click', function() {
$('.conn-type-btn').css('outline', 'none');
$(this).css('outline', '2px solid #fff');
$('#conn-type').val($(this).data('type'));
$('#conn-color').val($(this).data('color'));
});
$('#conn-save').on('click', function() {
var label = $('#conn-label').val().trim();
var connType = $('#conn-type').val();
var color = $('#conn-color').val();
// Create connection via AJAX - junction connections have no fk_source
// but we store junction info in path_data and source_connection_id
self.saveJunctionConnection({
anlage_id: self.anlageId,
fk_source: null, // No source equipment
source_terminal_id: null,
fk_target: targetEqId,
target_terminal_id: targetTermId,
output_label: label,
connection_type: connType,
color: color,
path_data: pathData,
junction_connection_id: sourceConnId,
junction_x: junctionPoint.x,
junction_y: junctionPoint.y
});
$('#schematic-conn-dialog').remove();
});
},
saveJunctionConnection: function(data) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'create',
fk_source: data.fk_source,
source_terminal_id: data.source_terminal_id,
fk_target: data.fk_target,
target_terminal_id: data.target_terminal_id,
connection_type: data.connection_type || 'L1N',
color: data.color || self.COLORS.connection,
output_label: data.output_label || '',
medium_type: data.medium_type || '',
medium_length: data.medium_length || '',
path_data: data.path_data || '',
bundled_terminals: data.bundled_terminals || '',
// Junction-specific fields
junction_connection_id: data.junction_connection_id || '',
junction_x: data.junction_x || '',
junction_y: data.junction_y || '',
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Abzweigung erstellt', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
}
},
error: function() {
self.showMessage('Verbindungsfehler', 'error');
}
});
},
showConnectionLabelDialogWithPath: function(sourceEqId, sourceTermId, targetEqId, targetTermId, pathData) {
// Store path data for later use when creating connection
this._pendingPathData = pathData;
this.showConnectionLabelDialog(sourceEqId, sourceTermId, targetEqId, targetTermId);
},
showConnectionLabelDialog: function(sourceEqId, sourceTermId, targetEqId, targetTermId) {
var self = this;
// Find equipment labels for context
var sourceEq = this.equipment.find(function(e) { return e.id == sourceEqId; });
var targetEq = this.equipment.find(function(e) { return e.id == targetEqId; });
// Check if we should inherit from a specific connection (clicked wire)
var existingConn = this._inheritFromConnection || null;
this._inheritFromConnection = null; // Clear after use
// If no specific connection, look for existing connections to target
if (!existingConn && targetEqId && this.connections && this.connections.length > 0) {
// Look for connections where target is this equipment (incoming)
existingConn = this.connections.find(function(c) {
return c.fk_target == targetEqId;
});
// If not found, look for connections from target (outgoing)
if (!existingConn) {
existingConn = this.connections.find(function(c) {
return c.fk_source == targetEqId;
});
}
}
// Default values or inherited from existing connection
var defaultType = existingConn && existingConn.connection_type ? existingConn.connection_type : 'L1N';
var defaultColor = existingConn && existingConn.color ? existingConn.color : this.COLORS.connection;
var defaultMedium = existingConn && existingConn.medium_type ? existingConn.medium_type : '';
var defaultLength = existingConn && existingConn.medium_length ? existingConn.medium_length : '';
var html = '<div id="schematic-conn-dialog" class="kundenkarte-modal visible">';
html += '<div class="kundenkarte-modal-content" style="max-width:450px;">';
html += '<div class="kundenkarte-modal-header"><h3>Verbindung erstellen</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body">';
html += '<div style="margin-bottom:15px;padding:10px;background:#2a2a2a;border-radius:4px;">';
html += '<strong style="color:#3498db;">' + this.escapeHtml((sourceEq ? sourceEq.label || sourceEq.type_label_short : 'Gerät')) + '</strong>';
html += ' <i class="fa fa-arrow-right" style="color:#888;margin:0 10px;"></i> ';
html += '<strong style="color:#27ae60;">' + this.escapeHtml((targetEq ? targetEq.label || targetEq.type_label_short : 'Gerät')) + '</strong>';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Bezeichnung / Stromkreis</label>';
html += '<input type="text" id="conn-label" class="flat" style="width:100%;padding:8px;" placeholder="z.B. Küche Steckdosen, Bad Licht, L1">';
html += '</div>';
html += '<div class="form-group" style="margin-bottom:12px;">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Typ (optional)</label>';
html += '<div style="display:flex;gap:8px;flex-wrap:wrap;">';
var typePresets = ['L1', 'L2', 'L3', 'N', 'PE', 'L1N', '3P'];
typePresets.forEach(function(t) {
var color = self.PHASE_COLORS[t] || self.COLORS.connection;
var isSelected = (t === defaultType);
var outlineStyle = isSelected ? 'outline:2px solid #fff;' : '';
html += '<button type="button" class="conn-type-btn" data-type="' + t + '" data-color="' + color + '" ';
html += 'style="padding:5px 12px;background:' + color + ';color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:12px;' + outlineStyle + '">';
html += t + '</button>';
});
html += '</div>';
html += '<input type="hidden" id="conn-type" value="' + self.escapeHtml(defaultType) + '">';
html += '<input type="hidden" id="conn-color" value="' + self.escapeHtml(defaultColor) + '">';
html += '</div>';
html += '<div class="form-group">';
html += '<label style="display:block;margin-bottom:5px;color:#aaa;">Kabel (optional)</label>';
html += '<div style="display:flex;gap:8px;">';
html += '<input type="text" id="conn-medium" class="flat" style="flex:1;padding:8px;" placeholder="z.B. NYM-J 3x1.5" value="' + self.escapeHtml(defaultMedium) + '">';
html += '<input type="text" id="conn-length" class="flat" style="width:80px;padding:8px;" placeholder="Länge" value="' + self.escapeHtml(defaultLength) + '">';
html += '</div>';
html += '</div>';
// Bundled terminals checkbox - only show if source equipment has >1 terminal
var sourceTerminalCount = sourceEq ? this.getTerminalCount(sourceEq) : 1;
if (sourceTerminalCount > 1) {
html += '<div class="form-group" style="margin-top:12px;">';
html += '<label style="display:flex;align-items:center;gap:8px;color:#aaa;cursor:pointer;">';
html += '<input type="checkbox" id="conn-bundle-all" style="width:18px;height:18px;">';
html += '<span>Alle Terminals bündeln <small style="color:#888;">(z.B. 3-Phasen Abgang, ' + sourceTerminalCount + ' Terminals)</small></span>';
html += '</label>';
html += '</div>';
}
html += '</div>';
html += '<div class="kundenkarte-modal-footer">';
html += '<button type="button" class="button" id="conn-save"><i class="fa fa-check"></i> Erstellen</button> ';
html += '<button type="button" class="button" id="conn-cancel">Abbrechen</button>';
html += '</div></div></div>';
$('body').append(html);
// Type preset buttons
$('.conn-type-btn').on('click', function() {
$('.conn-type-btn').css('outline', 'none');
$(this).css('outline', '2px solid #fff');
$('#conn-type').val($(this).data('type'));
$('#conn-color').val($(this).data('color'));
});
// Save
$('#conn-save').on('click', function() {
var label = $('#conn-label').val();
var connType = $('#conn-type').val();
var color = $('#conn-color').val();
var medium = $('#conn-medium').val();
var length = $('#conn-length').val();
var bundleAll = $('#conn-bundle-all').is(':checked');
// Check if there's a manually drawn path
var pathData = self._pendingPathData || null;
self._pendingPathData = null;
self.createConnection(sourceEqId, sourceTermId, targetEqId, targetTermId, {
label: label,
type: connType,
color: color,
medium: medium,
length: length,
pathData: pathData,
bundledTerminals: bundleAll ? 'all' : ''
});
$('#schematic-conn-dialog').remove();
self.cancelSelection();
});
// Cancel
$('#conn-cancel, .kundenkarte-modal-close').on('click', function() {
$('#schematic-conn-dialog').remove();
self.cancelSelection();
});
// Focus label input
setTimeout(function() { $('#conn-label').focus(); }, 100);
},
updateConnectionPreview: function(e) {
if (!this.selectedTerminal) return;
var $svg = $(this.svgElement);
var offset = $svg.offset();
var x = e.pageX - offset.left;
var y = e.pageY - offset.top;
var $terminal = this.selectedTerminal.element;
var transform = $terminal.attr('transform');
var match = transform.match(/translate\(([^,]+),([^)]+)\)/);
if (match) {
var startX = parseFloat(match[1]);
var startY = parseFloat(match[2]);
var $preview = $svg.find('.schematic-connection-preview');
$preview.attr({
x1: startX,
y1: startY,
x2: x,
y2: y
});
}
},
cancelSelection: function() {
if (this.selectedTerminal) {
this.selectedTerminal.element.find('.schematic-terminal-circle')
.attr('stroke', '#fff')
.attr('stroke-width', '1.5');
}
this.selectedTerminal = null;
$('.schematic-connection-preview').hide();
this.hideMessage();
},
createConnection: function(sourceEqId, sourceTermId, targetEqId, targetTermId, options) {
var self = this;
options = options || {};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'create',
fk_source: sourceEqId,
source_terminal_id: sourceTermId,
fk_target: targetEqId,
target_terminal_id: targetTermId,
connection_type: options.type || 'L1N',
color: options.color || self.COLORS.connection,
output_label: options.label || '',
medium_type: options.medium || '',
medium_length: options.length || '',
path_data: options.pathData || '', // Save manual path to database
bundled_terminals: options.bundledTerminals || '', // 'all' if all terminals bundled
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Verbindung erstellt!', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + response.error, 'error');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
deleteConnection: function(connId) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'delete',
connection_id: connId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.hideConnectionPopup();
self.showMessage('Verbindung gelöscht', 'success');
self.loadConnections();
}
}
});
},
// Show terminal context menu - choice between Anschlusspunkt (input) and Abgang (output)
showOutputDialog: function(eqId, termId, x, y) {
var self = this;
// Remove any existing popup
this.hideConnectionPopup();
this.hideEquipmentPopup();
$('.schematic-terminal-menu').remove();
$('.schematic-output-dialog').remove();
// Check for existing connections on this terminal
var existingInput = this.connections.find(function(c) {
return !c.fk_source && c.fk_target == eqId && c.target_terminal_id === termId;
});
var existingOutput = this.connections.find(function(c) {
return c.fk_source == eqId && c.source_terminal_id === termId && !c.fk_target;
});
// Show context menu
var html = '<div class="schematic-terminal-menu" style="' +
'position:fixed;left:' + x + 'px;top:' + y + 'px;' +
'background:#2d2d44;border:1px solid #555;border-radius:6px;' +
'box-shadow:0 4px 15px rgba(0,0,0,0.5);z-index:100001;min-width:180px;overflow:hidden;">';
// Anschlusspunkt (Input) option
html += '<div class="terminal-menu-item terminal-menu-input" style="' +
'padding:12px 15px;cursor:pointer;display:flex;align-items:center;gap:10px;' +
'border-bottom:1px solid #444;color:#fff;' + (existingInput ? 'background:#1a4a1a;' : '') + '">';
html += '<i class="fas fa-arrow-down" style="color:#f39c12;"></i>';
html += '<span>Anschlusspunkt (L1/L2/L3)</span>';
if (existingInput) html += '<i class="fas fa-check" style="margin-left:auto;color:#27ae60;"></i>';
html += '</div>';
// Abgang (Output) option
html += '<div class="terminal-menu-item terminal-menu-output" style="' +
'padding:12px 15px;cursor:pointer;display:flex;align-items:center;gap:10px;color:#fff;' +
(existingOutput ? 'background:#1a4a1a;' : '') + '">';
html += '<i class="fas fa-arrow-up" style="color:#3498db;"></i>';
html += '<span>Abgang (Verbraucher/N)</span>';
if (existingOutput) html += '<i class="fas fa-check" style="margin-left:auto;color:#27ae60;"></i>';
html += '</div>';
html += '</div>';
$('body').append(html);
// Hover effect
$('.terminal-menu-item').hover(
function() { $(this).css('background', '#3a3a5a'); },
function() {
var $item = $(this);
if ($item.hasClass('terminal-menu-input') && existingInput) {
$item.css('background', '#1a4a1a');
} else if ($item.hasClass('terminal-menu-output') && existingOutput) {
$item.css('background', '#1a4a1a');
} else {
$item.css('background', '');
}
}
);
// Click handlers
$('.terminal-menu-input').on('click', function() {
$('.schematic-terminal-menu').remove();
self.showInputDialog(eqId, termId, x, y, existingInput);
});
$('.terminal-menu-output').on('click', function() {
$('.schematic-terminal-menu').remove();
self.showAbgangDialog(eqId, termId, x, y, existingOutput);
});
// Close on click outside
setTimeout(function() {
$(document).one('click', function() {
$('.schematic-terminal-menu').remove();
});
}, 100);
// Close on Escape
$(document).one('keydown.terminalMenu', function(e) {
if (e.key === 'Escape') {
$('.schematic-terminal-menu').remove();
}
});
},
// Show dialog for creating an INPUT connection (Anschlusspunkt for L1, L2, L3)
showInputDialog: function(eqId, termId, x, y, existingInput) {
var self = this;
$('.schematic-output-dialog').remove();
// Vorausgewählte Phase und Farbe
var defaultPhase = existingInput ? existingInput.connection_type || 'L1' : 'L1';
var defaultColor = existingInput && existingInput.color ? existingInput.color : (self.PHASE_COLORS[defaultPhase] || '#8B4513');
var html = '<div class="schematic-output-dialog" style="' +
'position:fixed;left:' + x + 'px;top:' + y + 'px;' +
'background:#2d2d44;border:1px solid #555;border-radius:8px;padding:15px;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.5);z-index:100001;min-width:280px;">';
html += '<h4 style="margin:0 0 12px 0;color:#fff;font-size:14px;">' +
'<i class="fas fa-arrow-down" style="color:#f39c12;"></i> Anschlusspunkt (Eingang)</h4>';
// Phase-Buttons (wie im Leitungs-Dialog)
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:5px;">Phase:</label>';
html += '<div style="display:flex;gap:6px;flex-wrap:wrap;">';
var inputPhases = ['L1', 'L2', 'L3', 'N', 'PE', '3P', '3P+N'];
inputPhases.forEach(function(p) {
var color = self.PHASE_COLORS[p] || '#f1c40f';
var isSelected = (p === defaultPhase);
html += '<button type="button" class="input-phase-btn" data-phase="' + p + '" data-color="' + color + '" ';
html += 'style="padding:6px 12px;background:' + color + ';color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:bold;';
if (isSelected) html += 'outline:2px solid #fff;';
// Schwarzer Text für helle Farben (N=blau ist ok, aber für Kontrast)
if (p === 'L3') html += 'text-shadow:0 0 3px rgba(0,0,0,0.8);';
html += '">' + p + '</button>';
});
html += '</div>';
html += '<input type="hidden" class="input-phase-val" value="' + self.escapeHtml(defaultPhase) + '">';
html += '<input type="hidden" class="input-color-val" value="' + self.escapeHtml(defaultColor) + '">';
html += '</div>';
// Bezeichnung (optional)
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Bezeichnung (optional):</label>';
html += '<input type="text" class="input-label" placeholder="z.B. Zähler, EVU" value="' +
self.escapeHtml(existingInput ? existingInput.output_label || '' : '') + '" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '</div>';
// Buttons
html += '<div style="display:flex;gap:8px;justify-content:flex-end;">';
if (existingInput) {
html += '<button type="button" class="input-delete-btn" data-id="' + existingInput.id + '" ' +
'style="background:#e74c3c;color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;">' +
'<i class="fa fa-trash"></i></button>';
}
html += '<button type="button" class="input-cancel-btn" ' +
'style="background:#555;color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="input-save-btn" ' +
'style="background:#f39c12;color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;">' +
'<i class="fa fa-check"></i> ' + (existingInput ? 'Aktualisieren' : 'Erstellen') + '</button>';
html += '</div></div>';
$('body').append(html);
// Phase-Button Klick-Handler
$('.input-phase-btn').on('click', function() {
$('.input-phase-btn').css('outline', 'none');
$(this).css('outline', '2px solid #fff');
$('.input-phase-val').val($(this).data('phase'));
$('.input-color-val').val($(this).data('color'));
});
$('.input-cancel-btn').on('click', function() { $('.schematic-output-dialog').remove(); });
$('.input-delete-btn').on('click', function() {
var id = $(this).data('id');
$('.schematic-output-dialog').remove();
self.deleteConnection(id);
});
$('.input-save-btn').on('click', function() {
var phase = $('.input-phase-val').val();
var color = $('.input-color-val').val();
var label = $('.input-label').val();
if (existingInput) {
self.updateInput(existingInput.id, phase, label, color);
} else {
self.createInput(eqId, termId, phase, label, color);
}
$('.schematic-output-dialog').remove();
});
$(document).one('keydown.inputDialog', function(e) {
if (e.key === 'Escape') $('.schematic-output-dialog').remove();
});
// Dialog-Position anpassen
setTimeout(function() {
var $dialog = $('.schematic-output-dialog');
var dw = $dialog.outerWidth(), dh = $dialog.outerHeight();
var ww = $(window).width(), wh = $(window).height();
if (x + dw > ww - 10) $dialog.css('left', (ww - dw - 10) + 'px');
if (y + dh > wh - 10) $dialog.css('top', (wh - dh - 10) + 'px');
}, 10);
},
// Show dialog for creating an OUTPUT connection (Abgang for Verbraucher, N)
showAbgangDialog: function(eqId, termId, x, y, existingOutput) {
var self = this;
$('.schematic-output-dialog').remove();
// First load medium types from database
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/medium_types.php',
data: { action: 'list_grouped', system_id: 0 },
dataType: 'json',
success: function(response) {
self.renderAbgangDialog(eqId, termId, x, y, existingOutput, response.success ? response.groups : []);
},
error: function() {
// Fallback with empty types
self.renderAbgangDialog(eqId, termId, x, y, existingOutput, []);
}
});
},
// Render the Abgang dialog with loaded medium types
renderAbgangDialog: function(eqId, termId, x, y, existingOutput, mediumGroups) {
var self = this;
var html = '<div class="schematic-output-dialog" style="' +
'position:fixed;left:' + x + 'px;top:' + y + 'px;' +
'background:#2d2d44;border:1px solid #555;border-radius:8px;padding:15px;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.5);z-index:100001;min-width:320px;max-width:400px;">';
html += '<h4 style="margin:0 0 12px 0;color:#fff;font-size:14px;">' +
'<i class="fas fa-arrow-up" style="color:#3498db;"></i> Abgang (Ausgang)</h4>';
// Bezeichnung (Label)
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Bezeichnung (Verbraucher):</label>';
html += '<input type="text" class="output-label" placeholder="z.B. Küche Steckdosen" value="' +
self.escapeHtml(existingOutput ? existingOutput.output_label || '' : '') + '" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '</div>';
// Räumlichkeit
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Räumlichkeit:</label>';
html += '<input type="text" class="output-location" placeholder="z.B. Küche, Bad OG, Keller" value="' +
self.escapeHtml(existingOutput ? existingOutput.output_location || '' : '') + '" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '</div>';
// Kabeltyp (from database)
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Kabeltyp:</label>';
html += '<select class="output-cable-type" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
html += '<option value="">-- Auswählen --</option>';
if (mediumGroups && mediumGroups.length > 0) {
mediumGroups.forEach(function(group) {
html += '<optgroup label="' + self.escapeHtml(group.category_label) + '">';
group.types.forEach(function(t) {
var selected = existingOutput && existingOutput.medium_type === t.ref ? ' selected' : '';
var specs = t.available_specs && t.available_specs.length > 0 ? ' data-specs=\'' + JSON.stringify(t.available_specs) + '\'' : '';
var defSpec = t.default_spec ? ' data-default="' + self.escapeHtml(t.default_spec) + '"' : '';
html += '<option value="' + self.escapeHtml(t.ref) + '"' + selected + specs + defSpec + '>' +
self.escapeHtml(t.label) + '</option>';
});
html += '</optgroup>';
});
} else {
// Fallback
['NYM-J', 'NYY-J', 'H07V-K', 'CAT6', 'CAT7'].forEach(function(t) {
var selected = existingOutput && existingOutput.medium_type === t ? ' selected' : '';
html += '<option value="' + t + '"' + selected + '>' + t + '</option>';
});
}
html += '</select></div>';
// Spezifikation (Querschnitt) - dynamic based on selected cable type
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Querschnitt/Typ:</label>';
html += '<select class="output-cable-spec" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
html += '<option value="">-- Zuerst Kabeltyp wählen --</option>';
html += '</select></div>';
// Länge
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Länge (m):</label>';
html += '<input type="text" class="output-length" placeholder="z.B. 15, ca. 20" value="' +
self.escapeHtml(existingOutput ? existingOutput.medium_length || '' : '') + '" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '</div>';
// Phase type
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Phase/Anschluss:</label>';
html += '<select class="output-phase-type" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
['LN', 'N', '3P+N', 'PE', 'DATA'].forEach(function(p) {
var selected = existingOutput && existingOutput.connection_type === p ? ' selected' : '';
html += '<option value="' + p + '"' + selected + '>' + p + '</option>';
});
html += '</select></div>';
// Alle Terminals auf dieser Seite bündeln (nur bei >1 Terminal auf gleicher Seite)
var eq = self.equipment.find(function(e) { return e.id == eqId; });
var terminals = eq ? self.getTerminals(eq) : [];
// Terminal-Seite ermitteln (top oder bottom)
var clickedTerm = terminals.find(function(t) { return t.id === termId; });
var termSide = clickedTerm ? clickedTerm.pos : 'bottom';
var sideTerminals = terminals.filter(function(t) { return t.pos === termSide; });
var sideCount = sideTerminals.length;
if (sideCount > 1) {
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:flex;align-items:center;gap:8px;color:#ccc;cursor:pointer;">';
html += '<input type="checkbox" class="output-bundle-all"' +
(existingOutput && existingOutput.bundled_terminals === 'all' ? ' checked' : '') + '>';
html += '<span>Alle ' + sideCount + ' Klemmen bündeln (Drehstrom-Verbraucher)</span>';
html += '</label></div>';
}
// Buttons
html += '<div style="display:flex;gap:8px;justify-content:flex-end;">';
if (existingOutput) {
html += '<button type="button" class="output-delete-btn" data-id="' + existingOutput.id + '" ' +
'style="background:#e74c3c;color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;">' +
'<i class="fa fa-trash"></i></button>';
}
html += '<button type="button" class="output-cancel-btn" ' +
'style="background:#555;color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;">Abbrechen</button>';
html += '<button type="button" class="output-save-btn" ' +
'style="background:#3498db;color:#fff;border:none;border-radius:4px;padding:8px 14px;cursor:pointer;">' +
'<i class="fa fa-check"></i> ' + (existingOutput ? 'Aktualisieren' : 'Erstellen') + '</button>';
html += '</div></div>';
$('body').append(html);
$('.output-label').focus();
// Update specs dropdown when cable type changes
$('.output-cable-type').on('change', function() {
var $opt = $(this).find('option:selected');
var specs = $opt.data('specs');
var defSpec = $opt.data('default');
var $specSelect = $('.output-cable-spec');
$specSelect.empty();
if (specs && specs.length > 0) {
specs.forEach(function(s) {
var selected = (existingOutput && existingOutput.medium_spec === s) || (!existingOutput && s === defSpec) ? ' selected' : '';
$specSelect.append('<option value="' + self.escapeHtml(s) + '"' + selected + '>' + self.escapeHtml(s) + '</option>');
});
} else {
$specSelect.append('<option value="">--</option>');
}
});
// Trigger change to populate specs if editing
if (existingOutput && existingOutput.medium_type) {
$('.output-cable-type').val(existingOutput.medium_type).trigger('change');
if (existingOutput.medium_spec) {
$('.output-cable-spec').val(existingOutput.medium_spec);
}
}
$('.output-cancel-btn').on('click', function() { $('.schematic-output-dialog').remove(); });
$('.output-delete-btn').on('click', function() {
var id = $(this).data('id');
$('.schematic-output-dialog').remove();
self.deleteConnection(id);
});
$('.output-save-btn').on('click', function() {
var label = $('.output-label').val();
var location = $('.output-location').val();
var cableType = $('.output-cable-type').val();
var cableSpec = $('.output-cable-spec').val();
var cableLength = $('.output-length').val();
var phaseType = $('.output-phase-type').val();
var bundled = $('.output-bundle-all').is(':checked') ? 'all' : '';
if (existingOutput) {
self.updateOutput(existingOutput.id, label, location, cableType, cableSpec, phaseType, cableLength, bundled);
} else {
self.createOutput(eqId, termId, label, location, cableType, cableSpec, phaseType, cableLength, bundled);
}
$('.schematic-output-dialog').remove();
});
$(document).one('keydown.outputDialog', function(e) {
if (e.key === 'Escape') $('.schematic-output-dialog').remove();
});
// Adjust position
setTimeout(function() {
var $dialog = $('.schematic-output-dialog');
var dw = $dialog.outerWidth(), dh = $dialog.outerHeight();
var ww = $(window).width(), wh = $(window).height();
if (x + dw > ww - 10) $dialog.css('left', (ww - dw - 10) + 'px');
if (y + dh > wh - 10) $dialog.css('top', (wh - dh - 10) + 'px');
}, 10);
},
// Create INPUT connection (external source -> equipment terminal)
createInput: function(eqId, termId, phase, label, color) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'create',
fk_source: '', // NULL = external input
source_terminal_id: '',
fk_target: eqId,
target_terminal_id: termId,
connection_type: phase,
color: color || '',
output_label: label,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Anschlusspunkt erstellt', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + response.error, 'error');
}
}
});
},
// Update INPUT connection
updateInput: function(connId, phase, label, color) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: connId,
connection_type: phase,
color: color || '',
output_label: label,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Anschlusspunkt aktualisiert', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + response.error, 'error');
}
}
});
},
// Create a new cable output (no target, fk_target = NULL)
createOutput: function(eqId, termId, label, location, cableType, cableSpec, phaseType, cableLength, bundledTerminals) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'create',
fk_source: eqId,
source_terminal_id: termId,
fk_target: '', // NULL target = output/endpoint
target_terminal_id: '',
connection_type: phaseType || 'L1N',
output_label: label,
output_location: location || '',
medium_type: cableType,
medium_spec: cableSpec,
medium_length: cableLength || '',
bundled_terminals: bundledTerminals || '',
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Abgang erstellt', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + response.error, 'error');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
// Update existing output
updateOutput: function(connId, label, location, cableType, cableSpec, phaseType, cableLength, bundledTerminals) {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: connId,
connection_type: phaseType || 'L1N',
output_label: label,
output_location: location || '',
medium_type: cableType,
medium_spec: cableSpec,
medium_length: cableLength || '',
bundled_terminals: bundledTerminals || '',
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Abgang aktualisiert', 'success');
self.loadConnections();
} else {
self.showMessage('Fehler: ' + response.error, 'error');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
showConnectionPopup: function(connId, x, y) {
var self = this;
this.log('showConnectionPopup called with connId:', connId);
// Remove any existing popup
this.hideConnectionPopup();
// Find connection data (convert to int for comparison)
var connIdInt = parseInt(connId);
var conn = this.connections.find(function(c) { return parseInt(c.id) === connIdInt; });
this.log('Found connection:', conn ? conn.id : null);
if (!conn) {
console.warn('Connection not found for id:', connId);
return;
}
// Create popup - use very high z-index to ensure visibility
this.log('Creating popup at position:', x, y);
var popupHtml = '<div class="schematic-connection-popup" data-connection-id="' + connId + '" style="' +
'position:fixed !important;left:' + x + 'px !important;top:' + y + 'px !important;' +
'background:#2d2d44 !important;border:2px solid #3498db !important;border-radius:6px;padding:10px;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.8);z-index:2147483647 !important;display:flex !important;gap:8px;' +
'visibility:visible !important;opacity:1 !important;">';
// Edit button
popupHtml += '<button type="button" class="schematic-popup-edit" style="' +
'background:#3498db;color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;gap:5px;">' +
'<i class="fas fa-edit"></i> Bearbeiten</button>';
// Extend/Continue drawing button - only show if connection has path_data or target
this.log('Extend button check - path_data:', !!conn.path_data, 'fk_target:', conn.fk_target);
if (conn.path_data || conn.fk_target) {
this.log('Adding extend button to popup');
popupHtml += '<button type="button" class="schematic-popup-extend" style="' +
'background:#27ae60;color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;gap:5px;">' +
'<i class="fas fa-pencil-alt"></i> Weiterzeichnen</button>';
}
// Delete button
popupHtml += '<button type="button" class="schematic-popup-delete" style="' +
'background:#e74c3c;color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;gap:5px;">' +
'<i class="fas fa-trash"></i> Löschen</button>';
popupHtml += '</div>';
$('body').append(popupHtml);
this.log('Popup created');
// Bind button events
$('.schematic-popup-edit').on('click', function(e) {
e.stopPropagation();
self.editConnection(connId);
});
$('.schematic-popup-delete').on('click', function(e) {
e.stopPropagation();
self.hideConnectionPopup();
self.deleteConnection(connId);
});
$('.schematic-popup-extend').on('click', function(e) {
self.log('Extend button clicked for connId:', connId);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// Start extend mode FIRST, then hide popup
// This ensures the state is set before any other handlers might fire
self.log('Calling startExtendConnection');
self.startExtendConnection(connId);
self.log('Now hiding popup');
self.hideConnectionPopup();
});
// Adjust position if popup goes off screen
var $popup = $('.schematic-connection-popup');
var popupWidth = $popup.outerWidth();
var popupHeight = $popup.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
if (x + popupWidth > windowWidth - 10) {
$popup.css('left', (x - popupWidth) + 'px');
}
if (y + popupHeight > windowHeight - 10) {
$popup.css('top', (y - popupHeight) + 'px');
}
},
hideConnectionPopup: function() {
$('.schematic-connection-popup').remove();
},
// Start extending an existing connection (continue drawing from its end point)
startExtendConnection: function(connId) {
this.log('startExtendConnection called with connId:', connId);
var self = this;
var conn = this.connections.find(function(c) { return String(c.id) === String(connId); });
this.log('Found connection:', conn ? conn.id : null);
if (!conn) {
this.showMessage('Verbindung nicht gefunden', 'error');
return;
}
// Find the end point of the connection
// IMPORTANT: If path_data exists, always use its LAST point as the visual end
// This is where the line actually ends visually (even if equipment has moved)
var endPoint = null;
var endTerminalId = null;
var endEquipmentId = null;
var pathEndPoint = null;
if (conn.path_data) {
// Parse path_data to get the last point - this is the VISUAL endpoint
var points = this.parsePathToPoints(conn.path_data);
if (points.length > 0) {
pathEndPoint = points[points.length - 1];
endPoint = pathEndPoint;
this.log('Using path_data endpoint:', endPoint);
}
}
// Track target equipment/terminal for reference, but DON'T override visual endpoint
if (conn.fk_target) {
var targetEq = this.equipment.find(function(e) { return String(e.id) === String(conn.fk_target); });
if (targetEq) {
var terminals = this.getTerminals(targetEq);
var termPos = this.getTerminalPosition(targetEq, conn.target_terminal_id || 't1', terminals);
if (termPos) {
endTerminalId = conn.target_terminal_id || 't1';
endEquipmentId = conn.fk_target;
// Only use target position if we don't have a path endpoint
if (!pathEndPoint) {
endPoint = { x: termPos.x, y: termPos.y };
this.log('Using target terminal position (no path):', endPoint);
} else {
this.log('Have path_data, NOT overriding with target terminal position');
}
}
}
}
if (!endPoint) {
this.showMessage('Kann Endpunkt nicht ermitteln', 'error');
return;
}
// Store existing path points for extension
var existingPoints = [];
if (conn.path_data) {
existingPoints = this.parsePathToPoints(conn.path_data);
}
// Enter wire draw mode in "extend" mode
this.wireDrawMode = true;
this.wireExtendMode = true;
this.wireExtendConnId = connId;
this.wireExtendExistingPoints = existingPoints;
this.wireDrawPoints = [endPoint];
this.wireDrawSourceEq = endEquipmentId;
this.wireDrawSourceTerm = endTerminalId;
// Activate wire draw UI
var $btn = $('.schematic-wire-draw-toggle');
$btn.addClass('active').css('background', '#f39c12'); // Orange for extend mode
// Set crosshair on entire SVG in extend mode
$(this.svgElement).css('cursor', 'crosshair');
$(this.svgElement).find('*').css('cursor', 'inherit');
// Show start marker at end point
this.showExtendStartMarker(endPoint);
this.showWireGrid();
this.log('Extend mode started - wireDrawMode:', this.wireDrawMode, 'wireExtendMode:', this.wireExtendMode);
this.log('Starting point:', endPoint);
this.showMessage('Weiterzeichnen: Rasterpunkte klicken, dann Ziel-Terminal', 'info');
},
// Show marker at the start point when extending a connection
showExtendStartMarker: function(point) {
var svgNS = 'http://www.w3.org/2000/svg';
var existing = this.svgElement.querySelector('.extend-start-marker');
if (existing) existing.remove();
var marker = document.createElementNS(svgNS, 'g');
marker.setAttribute('class', 'extend-start-marker');
// Pulsing square (different from junction marker)
var rect = document.createElementNS(svgNS, 'rect');
rect.setAttribute('x', point.x - 6);
rect.setAttribute('y', point.y - 6);
rect.setAttribute('width', '12');
rect.setAttribute('height', '12');
rect.setAttribute('fill', '#f39c12');
rect.setAttribute('stroke', '#fff');
rect.setAttribute('stroke-width', '2');
rect.setAttribute('rx', '2');
rect.style.animation = 'junctionPulse 1s infinite';
marker.appendChild(rect);
this.svgElement.appendChild(marker);
},
editConnection: function(connId) {
var self = this;
// Hide popup first
this.hideConnectionPopup();
// Find connection data
var conn = this.connections.find(function(c) { return c.id == connId; });
if (!conn) return;
// Abgang → showAbgangDialog mit existingOutput
if (conn.fk_source && !conn.fk_target && !conn.path_data) {
var eqId = conn.fk_source;
var termId = conn.source_terminal_id || 't2';
this.showAbgangDialog(eqId, termId, window.innerWidth / 2 - 160, window.innerHeight / 2 - 150, conn);
return;
}
// Eingang → showInputDialog mit existingInput
if (!conn.fk_source && conn.fk_target && !conn.path_data) {
var eqId = conn.fk_target;
var termId = conn.target_terminal_id || 't1';
this.showInputDialog(eqId, termId, window.innerWidth / 2 - 140, window.innerHeight / 2 - 100, conn);
return;
}
// Normale Verbindung → Standard-Edit-Dialog
var dialogHtml = '<div class="schematic-edit-dialog" style="' +
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
'background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;' +
'box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:10001;min-width:350px;">';
dialogHtml += '<h3 style="margin:0 0 15px 0;color:#fff;font-size:16px;">Verbindung bearbeiten</h3>';
// Phase/Typ als farbige Buttons
var currentType = conn.connection_type || '';
var currentColor = conn.color || self.PHASE_COLORS[currentType] || '#3498db';
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:5px;">Phase/Typ:</label>';
dialogHtml += '<div style="display:flex;gap:6px;flex-wrap:wrap;">';
['L1', 'L2', 'L3', 'N', 'PE', 'LN', '3P', '3P+N', 'DATA'].forEach(function(t) {
var btnColor = self.PHASE_COLORS[t] || '#f1c40f';
var isSelected = (t === currentType);
dialogHtml += '<button type="button" class="edit-type-btn" data-type="' + t + '" data-color="' + btnColor + '" ';
dialogHtml += 'style="padding:6px 10px;background:' + btnColor + ';color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:bold;';
if (isSelected) dialogHtml += 'outline:2px solid #fff;';
if (t === 'L3') dialogHtml += 'text-shadow:0 0 3px rgba(0,0,0,0.8);';
dialogHtml += '">' + t + '</button>';
});
dialogHtml += '</div>';
dialogHtml += '<input type="hidden" class="edit-connection-type" value="' + self.escapeHtml(currentType) + '">';
dialogHtml += '<input type="hidden" class="edit-connection-color" value="' + self.escapeHtml(currentColor) + '">';
dialogHtml += '</div>';
// Output label
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Bezeichnung:</label>';
dialogHtml += '<input type="text" class="edit-output-label" value="' + self.escapeHtml(conn.output_label || '') + '" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Medium type
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Medium:</label>';
dialogHtml += '<input type="text" class="edit-medium-type" value="' + self.escapeHtml(conn.medium_type || '') + '" ' +
'placeholder="z.B. NYM-J, CAT6" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Medium spec
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Spezifikation:</label>';
dialogHtml += '<input type="text" class="edit-medium-spec" value="' + self.escapeHtml(conn.medium_spec || '') + '" ' +
'placeholder="z.B. 3x1.5mm²" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Medium length
dialogHtml += '<div style="margin-bottom:15px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Länge:</label>';
dialogHtml += '<input type="text" class="edit-medium-length" value="' + self.escapeHtml(conn.medium_length || '') + '" ' +
'placeholder="z.B. 5m" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Buttons
dialogHtml += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
dialogHtml += '<button type="button" class="edit-dialog-cancel" style="' +
'background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
dialogHtml += '<button type="button" class="edit-dialog-save" style="' +
'background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Speichern</button>';
dialogHtml += '</div>';
dialogHtml += '</div>';
// Overlay
var overlayHtml = '<div class="schematic-edit-overlay" style="' +
'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10000;"></div>';
$('body').append(overlayHtml).append(dialogHtml);
// Bind events
// Phase-Button Klick-Handler
$('.edit-type-btn').on('click', function() {
$('.edit-type-btn').css('outline', 'none');
$(this).css('outline', '2px solid #fff');
$('.edit-connection-type').val($(this).data('type'));
$('.edit-connection-color').val($(this).data('color'));
});
$('.edit-dialog-cancel, .schematic-edit-overlay').on('click', function() {
self.closeEditDialog();
});
$('.edit-dialog-save').on('click', function() {
self.saveConnectionEdit(connId);
});
// Enter key to save
$('.schematic-edit-dialog input').on('keypress', function(e) {
if (e.which === 13) {
self.saveConnectionEdit(connId);
}
});
},
closeEditDialog: function() {
$('.schematic-edit-dialog, .schematic-edit-overlay').remove();
},
saveConnectionEdit: function(connId) {
var self = this;
var data = {
action: 'update',
connection_id: connId,
connection_type: $('.edit-connection-type').val(),
output_label: $('.edit-output-label').val(),
color: $('.edit-connection-color').val(),
medium_type: $('.edit-medium-type').val(),
medium_spec: $('.edit-medium-spec').val(),
medium_length: $('.edit-medium-length').val(),
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
self.closeEditDialog();
self.showMessage('Verbindung aktualisiert', 'success');
self.loadConnections();
} else {
self.showMessage(response.error || 'Fehler beim Speichern', 'error');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
clearAllConnections: function() {
var self = this;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'clear_all',
anlage_id: this.anlageId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.connections = [];
self.renderConnections();
self.showMessage('Alle Verbindungen gelöscht', 'success');
}
}
});
},
// Straighten all connections - convert diagonal lines to orthogonal (90° only)
// Avoids obstacles (equipment, carriers, busbars) and spreads parallel wires
straightenConnections: function() {
var self = this;
var connectionsToUpdate = [];
// Build obstacle list from equipment, carriers, busbars
var obstacles = this.getObstacles();
// Track existing wire segments to avoid overlapping
var existingSegments = [];
var WIRE_SPREAD = 10; // Pixels between parallel wires
// Find connections with path_data that have diagonal segments
this.connections.forEach(function(conn, connIndex) {
if (!conn.path_data) return;
var straightened = self.straightenPath(conn.path_data, obstacles, existingSegments, WIRE_SPREAD);
if (straightened !== conn.path_data) {
connectionsToUpdate.push({
id: conn.id,
oldPath: conn.path_data,
newPath: straightened
});
}
// Add this wire's segments to existing segments for next iterations
var segments = self.getPathSegments(straightened || conn.path_data);
segments.forEach(function(seg) {
existingSegments.push(seg);
});
});
if (connectionsToUpdate.length === 0) {
this.showMessage('Alle Leitungen sind bereits orthogonal (rechte Winkel)', 'info');
return;
}
// Update each connection
var updated = 0;
var total = connectionsToUpdate.length;
this.showMessage('Begradige ' + total + ' Leitungen...', 'info');
connectionsToUpdate.forEach(function(item) {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: item.id,
path_data: item.newPath,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
updated++;
if (updated === total) {
self.loadConnections();
self.showMessage(total + ' Leitungen begradigt', 'success');
}
},
error: function() {
updated++;
if (updated === total) {
self.loadConnections();
}
}
});
});
},
// Get all obstacles as bounding boxes
getObstacles: function() {
var self = this;
var obstacles = [];
var padding = 5; // Safety margin around obstacles
// Equipment blocks
this.equipment.forEach(function(eq) {
if (typeof eq._x === 'undefined' || typeof eq._y === 'undefined') return;
var width = (parseFloat(eq.width_te) || 1) * self.TE_WIDTH;
var height = eq._height || self.BLOCK_HEIGHT;
obstacles.push({
x: eq._x - padding,
y: eq._y - padding,
width: width + padding * 2,
height: height + padding * 2,
type: 'equipment'
});
});
// Carriers (Hutschienen)
this.carriers.forEach(function(carrier) {
if (typeof carrier._x === 'undefined' || typeof carrier._y === 'undefined') return;
var width = (carrier.total_te || 12) * self.TE_WIDTH + 60; // Include overhangs
obstacles.push({
x: carrier._x - 30 - padding,
y: carrier._y - padding,
width: width + padding * 2,
height: self.RAIL_HEIGHT + padding * 2,
type: 'carrier'
});
});
// Busbars (Phasenschienen) - connections with is_rail=1
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) !== 1) return;
var carrier = self.carriers.find(function(c) { return String(c.id) === String(conn.fk_carrier); });
if (!carrier || typeof carrier._x === 'undefined') return;
var startTE = parseInt(conn.rail_start_te) || 1;
var endTE = parseInt(conn.rail_end_te) || (carrier.total_te || 12);
var x = carrier._x + (startTE - 1) * self.TE_WIDTH;
var width = (endTE - startTE + 1) * self.TE_WIDTH;
// Position above or below carrier
var posY = parseInt(conn.position_y) || 0;
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
var y = posY === 0 ? railCenterY - self.BLOCK_HEIGHT / 2 - 50 : railCenterY + self.BLOCK_HEIGHT / 2 + 30;
obstacles.push({
x: x - padding,
y: y - padding,
width: width + padding * 2,
height: 20 + padding * 2,
type: 'busbar'
});
});
// Output/Input Labels (Abgänge) - connections without target (fk_target = null)
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return; // Skip busbars
if (conn.fk_target) return; // Skip equipment-to-equipment connections
if (!conn.fk_source) return; // Skip if no source
// This is an output (Abgang) - find the source equipment
var sourceEq = self.equipment.find(function(e) { return String(e.id) === String(conn.fk_source); });
if (!sourceEq || typeof sourceEq._x === 'undefined') return;
// Calculate label position based on terminal - with safety checks
var terminals = self.getTerminals ? self.getTerminals(sourceEq) : null;
if (!terminals || !terminals.length) return;
var termPos = self.getTerminalPosition ? self.getTerminalPosition(sourceEq, conn.source_terminal_id, terminals) : null;
if (!termPos) return;
// Label is below or above the terminal
var labelWidth = 60;
var labelHeight = 20;
var labelX = termPos.x - labelWidth / 2;
var labelY = termPos.isTop ? termPos.y - 30 - labelHeight : termPos.y + 10;
obstacles.push({
x: labelX - padding,
y: labelY - padding,
width: labelWidth + padding * 2,
height: labelHeight + padding * 2,
type: 'output_label'
});
});
// Input Labels (Anschlusspunkte) - connections without source (fk_source = null)
this.connections.forEach(function(conn) {
if (parseInt(conn.is_rail) === 1) return;
if (conn.fk_source) return; // Skip if has source
if (!conn.fk_target) return; // Skip if no target
var targetEq = self.equipment.find(function(e) { return String(e.id) === String(conn.fk_target); });
if (!targetEq || typeof targetEq._x === 'undefined') return;
var terminals = self.getTerminals ? self.getTerminals(targetEq) : null;
if (!terminals || !terminals.length) return;
var termPos = self.getTerminalPosition ? self.getTerminalPosition(targetEq, conn.target_terminal_id, terminals) : null;
if (!termPos) return;
var labelWidth = 40;
var labelHeight = 20;
var labelX = termPos.x - labelWidth / 2;
var labelY = termPos.isTop ? termPos.y - 25 - labelHeight : termPos.y + 5;
obstacles.push({
x: labelX - padding,
y: labelY - padding,
width: labelWidth + padding * 2,
height: labelHeight + padding * 2,
type: 'input_label'
});
});
return obstacles;
},
// Check if a line segment intersects with a rectangle
lineIntersectsRect: function(x1, y1, x2, y2, rect) {
// Quick bounding box check
var minX = Math.min(x1, x2);
var maxX = Math.max(x1, x2);
var minY = Math.min(y1, y2);
var maxY = Math.max(y1, y2);
// No intersection if line bounding box doesn't overlap rect
if (maxX < rect.x || minX > rect.x + rect.width) return false;
if (maxY < rect.y || minY > rect.y + rect.height) return false;
// For horizontal lines
if (Math.abs(y1 - y2) < 1) {
return y1 >= rect.y && y1 <= rect.y + rect.height &&
!(maxX < rect.x || minX > rect.x + rect.width);
}
// For vertical lines
if (Math.abs(x1 - x2) < 1) {
return x1 >= rect.x && x1 <= rect.x + rect.width &&
!(maxY < rect.y || minY > rect.y + rect.height);
}
// For diagonal lines, check if any rect edge is crossed
return true; // Simplified - diagonals will be straightened anyway
},
// Find a path around an obstacle
findPathAroundObstacle: function(start, end, obstacle, obstacles) {
var self = this;
var margin = 15;
// Four possible routes around the obstacle
var routes = [
// Go above
[
{ x: start.x, y: obstacle.y - margin },
{ x: end.x, y: obstacle.y - margin }
],
// Go below
[
{ x: start.x, y: obstacle.y + obstacle.height + margin },
{ x: end.x, y: obstacle.y + obstacle.height + margin }
],
// Go left
[
{ x: obstacle.x - margin, y: start.y },
{ x: obstacle.x - margin, y: end.y }
],
// Go right
[
{ x: obstacle.x + obstacle.width + margin, y: start.y },
{ x: obstacle.x + obstacle.width + margin, y: end.y }
]
];
// Find the shortest valid route
var bestRoute = null;
var bestLength = Infinity;
routes.forEach(function(route) {
// Check if this route crosses any obstacle
var valid = true;
var waypoints = [start].concat(route).concat([end]);
for (var i = 0; i < waypoints.length - 1 && valid; i++) {
var p1 = waypoints[i];
var p2 = waypoints[i + 1];
obstacles.forEach(function(obs) {
if (self.lineIntersectsRect(p1.x, p1.y, p2.x, p2.y, obs)) {
valid = false;
}
});
}
if (valid) {
// Calculate total length
var length = 0;
for (var j = 0; j < waypoints.length - 1; j++) {
var dx = waypoints[j + 1].x - waypoints[j].x;
var dy = waypoints[j + 1].y - waypoints[j].y;
length += Math.sqrt(dx * dx + dy * dy);
}
if (length < bestLength) {
bestLength = length;
bestRoute = route;
}
}
});
return bestRoute || routes[0]; // Fallback to first route
},
// Extract line segments from a path string for collision detection
getPathSegments: function(pathData) {
if (!pathData) return [];
var segments = [];
var parts = pathData.trim().split(/\s+/);
var points = [];
var i = 0;
while (i < parts.length) {
var cmd = parts[i];
if (cmd === 'M' || cmd === 'L') {
var x = parseFloat(parts[i + 1]);
var y = parseFloat(parts[i + 2]);
if (!isNaN(x) && !isNaN(y)) {
points.push({ x: x, y: y });
}
i += 3;
} else if (!isNaN(parseFloat(cmd))) {
var x2 = parseFloat(parts[i]);
var y2 = parseFloat(parts[i + 1]);
if (!isNaN(x2) && !isNaN(y2)) {
points.push({ x: x2, y: y2 });
}
i += 2;
} else {
i++;
}
}
// Create segments from consecutive points
for (var j = 1; j < points.length; j++) {
var p1 = points[j - 1];
var p2 = points[j];
// Determine if horizontal or vertical
var isHorizontal = Math.abs(p1.y - p2.y) < 2;
var isVertical = Math.abs(p1.x - p2.x) < 2;
segments.push({
x1: Math.min(p1.x, p2.x),
y1: Math.min(p1.y, p2.y),
x2: Math.max(p1.x, p2.x),
y2: Math.max(p1.y, p2.y),
isHorizontal: isHorizontal,
isVertical: isVertical
});
}
return segments;
},
// Check if two segments overlap (parallel and on same line)
segmentsOverlap: function(seg1, seg2, tolerance) {
tolerance = tolerance || 5;
// Both must be same orientation
if (seg1.isHorizontal && seg2.isHorizontal) {
// Check if same Y (within tolerance)
if (Math.abs(seg1.y1 - seg2.y1) <= tolerance) {
// Check X overlap
return !(seg1.x2 < seg2.x1 || seg2.x2 < seg1.x1);
}
} else if (seg1.isVertical && seg2.isVertical) {
// Check if same X (within tolerance)
if (Math.abs(seg1.x1 - seg2.x1) <= tolerance) {
// Check Y overlap
return !(seg1.y2 < seg2.y1 || seg2.y2 < seg1.y1);
}
}
return false;
},
// Convert a path with diagonal segments to orthogonal, avoiding obstacles
straightenPath: function(pathData, obstacles, existingSegments, wireSpread) {
var self = this;
if (!pathData) return pathData;
obstacles = obstacles || [];
existingSegments = existingSegments || [];
wireSpread = wireSpread || 10;
// Parse path data: "M x y L x y L x y ..."
var parts = pathData.trim().split(/\s+/);
var points = [];
var i = 0;
while (i < parts.length) {
var cmd = parts[i];
if (cmd === 'M' || cmd === 'L') {
var x = parseFloat(parts[i + 1]);
var y = parseFloat(parts[i + 2]);
if (!isNaN(x) && !isNaN(y)) {
points.push({ x: x, y: y });
}
i += 3;
} else if (!isNaN(parseFloat(cmd))) {
var x2 = parseFloat(parts[i]);
var y2 = parseFloat(parts[i + 1]);
if (!isNaN(x2) && !isNaN(y2)) {
points.push({ x: x2, y: y2 });
}
i += 2;
} else {
i++;
}
}
if (points.length < 2) return pathData;
// Process each segment
var newPoints = [points[0]];
for (var j = 1; j < points.length; j++) {
var prev = newPoints[newPoints.length - 1];
var curr = points[j];
var dx = Math.abs(curr.x - prev.x);
var dy = Math.abs(curr.y - prev.y);
// If diagonal, add intermediate point(s)
if (dx > 2 && dy > 2) {
// Try horizontal-first approach
var midPoint1 = { x: curr.x, y: prev.y };
// Try vertical-first approach
var midPoint2 = { x: prev.x, y: curr.y };
// Check which approach avoids obstacles better
var hFirstBlocked = false;
var vFirstBlocked = false;
obstacles.forEach(function(obs) {
// Horizontal first: prev -> midPoint1 -> curr
if (self.lineIntersectsRect(prev.x, prev.y, midPoint1.x, midPoint1.y, obs) ||
self.lineIntersectsRect(midPoint1.x, midPoint1.y, curr.x, curr.y, obs)) {
hFirstBlocked = true;
}
// Vertical first: prev -> midPoint2 -> curr
if (self.lineIntersectsRect(prev.x, prev.y, midPoint2.x, midPoint2.y, obs) ||
self.lineIntersectsRect(midPoint2.x, midPoint2.y, curr.x, curr.y, obs)) {
vFirstBlocked = true;
}
});
if (!hFirstBlocked) {
newPoints.push(midPoint1);
} else if (!vFirstBlocked) {
newPoints.push(midPoint2);
} else {
// Both blocked - find route around obstacles
var blockingObs = obstacles.find(function(obs) {
return self.lineIntersectsRect(prev.x, prev.y, midPoint1.x, midPoint1.y, obs) ||
self.lineIntersectsRect(midPoint1.x, midPoint1.y, curr.x, curr.y, obs);
});
if (blockingObs) {
var detour = self.findPathAroundObstacle(prev, curr, blockingObs, obstacles);
detour.forEach(function(wp) {
newPoints.push(wp);
});
} else {
// Fallback: use horizontal-first
newPoints.push(midPoint1);
}
}
} else {
// Already orthogonal, but check for obstacles
var blocked = false;
var blockingObstacle = null;
obstacles.forEach(function(obs) {
if (self.lineIntersectsRect(prev.x, prev.y, curr.x, curr.y, obs)) {
blocked = true;
blockingObstacle = obs;
}
});
if (blocked && blockingObstacle) {
// Route around this obstacle
var detour = self.findPathAroundObstacle(prev, curr, blockingObstacle, obstacles);
detour.forEach(function(wp) {
newPoints.push(wp);
});
}
}
newPoints.push(curr);
}
// Step 1: Snap all points to grid (5px resolution)
var gridSize = 5;
var snappedPoints = newPoints.map(function(p) {
return {
x: Math.round(p.x / gridSize) * gridSize,
y: Math.round(p.y / gridSize) * gridSize
};
});
// Step 2: Remove duplicate consecutive points
var dedupedPoints = [snappedPoints[0]];
for (var k = 1; k < snappedPoints.length; k++) {
var last = dedupedPoints[dedupedPoints.length - 1];
var current = snappedPoints[k];
if (last.x !== current.x || last.y !== current.y) {
dedupedPoints.push(current);
}
}
// Step 3: Remove colinear points (points on a straight line between neighbors)
// If A-B-C are colinear (same x or same y), remove B
var cleanedPoints = [dedupedPoints[0]];
for (var m = 1; m < dedupedPoints.length - 1; m++) {
var prev = cleanedPoints[cleanedPoints.length - 1];
var curr = dedupedPoints[m];
var next = dedupedPoints[m + 1];
// Check if prev-curr-next are colinear (horizontal or vertical)
var isHorizontalLine = (prev.y === curr.y && curr.y === next.y);
var isVerticalLine = (prev.x === curr.x && curr.x === next.x);
// If not colinear, keep the point
if (!isHorizontalLine && !isVerticalLine) {
cleanedPoints.push(curr);
}
}
// Always add the last point
if (dedupedPoints.length > 1) {
cleanedPoints.push(dedupedPoints[dedupedPoints.length - 1]);
}
// Step 4: Ensure all segments are strictly orthogonal (fix any remaining T-pieces)
var finalPoints = [cleanedPoints[0]];
for (var n = 1; n < cleanedPoints.length; n++) {
var prevPt = finalPoints[finalPoints.length - 1];
var currPt = cleanedPoints[n];
var dx = Math.abs(currPt.x - prevPt.x);
var dy = Math.abs(currPt.y - prevPt.y);
// If both differ, add intermediate point
if (dx > 0 && dy > 0) {
// Choose based on direction preference (horizontal first)
finalPoints.push({ x: currPt.x, y: prevPt.y });
}
finalPoints.push(currPt);
}
// Step 5: Check for overlapping segments with existing wires and offset
if (existingSegments.length > 0 && wireSpread > 0) {
var offsetPoints = [finalPoints[0]];
for (var q = 1; q < finalPoints.length; q++) {
var p1 = offsetPoints[offsetPoints.length - 1];
var p2 = finalPoints[q];
// Create segment for this line
var newSeg = {
x1: Math.min(p1.x, p2.x),
y1: Math.min(p1.y, p2.y),
x2: Math.max(p1.x, p2.x),
y2: Math.max(p1.y, p2.y),
isHorizontal: Math.abs(p1.y - p2.y) < 2,
isVertical: Math.abs(p1.x - p2.x) < 2
};
// Check if this segment overlaps with any existing segment
var needsOffset = false;
var offsetDirection = 1;
var offsetCount = 0;
existingSegments.forEach(function(existingSeg) {
if (self.segmentsOverlap(newSeg, existingSeg, wireSpread - 1)) {
needsOffset = true;
offsetCount++;
}
});
if (needsOffset) {
// Offset this segment
var offset = wireSpread * offsetCount;
if (newSeg.isHorizontal) {
// Offset Y for horizontal segments
p1 = { x: p1.x, y: p1.y + offset };
p2 = { x: p2.x, y: p2.y + offset };
} else if (newSeg.isVertical) {
// Offset X for vertical segments
p1 = { x: p1.x + offset, y: p1.y };
p2 = { x: p2.x + offset, y: p2.y };
}
// Update last point if changed
offsetPoints[offsetPoints.length - 1] = p1;
}
offsetPoints.push(p2);
}
finalPoints = offsetPoints;
}
// Rebuild path string
var newPath = 'M ' + finalPoints[0].x + ' ' + finalPoints[0].y;
for (var p = 1; p < finalPoints.length; p++) {
newPath += ' L ' + finalPoints[p].x + ' ' + finalPoints[p].y;
}
return newPath;
},
// Equipment Popup functions
showEquipmentPopup: function(equipmentId, x, y) {
var self = this;
// Remove any existing popup
this.hideEquipmentPopup();
this.hideConnectionPopup();
// Find equipment data
var eq = this.equipment.find(function(e) { return parseInt(e.id) === parseInt(equipmentId); });
if (!eq) {
console.warn('Equipment not found for id:', equipmentId);
return;
}
// Create popup
var popupHtml = '<div class="schematic-equipment-popup" data-equipment-id="' + equipmentId + '" style="' +
'position:fixed;left:' + x + 'px;top:' + y + 'px;' +
'background:#2d2d44;border:2px solid #3498db;border-radius:6px;padding:10px;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.6);z-index:2147483647;display:flex;flex-direction:column;gap:8px;min-width:150px;">';
// Equipment info
popupHtml += '<div style="color:#fff;font-size:12px;border-bottom:1px solid #555;padding-bottom:6px;margin-bottom:2px;">';
popupHtml += '<strong>' + self.escapeHtml(eq.type_label || eq.label || 'Equipment') + '</strong>';
if (eq.label && eq.type_label) {
popupHtml += '<br><span style="color:#aaa;">' + self.escapeHtml(eq.label) + '</span>';
}
// Produkt-Info anzeigen wenn vorhanden
if (eq.fk_product && eq.product_ref) {
popupHtml += '<div style="margin-top:6px;padding-top:6px;border-top:1px solid #444;">';
popupHtml += '<span style="color:#3498db;"><i class="fa fa-cube"></i> ' + self.escapeHtml(eq.product_ref) + '</span>';
if (eq.product_label) {
popupHtml += '<br><span style="color:#888;font-size:11px;">' + self.escapeHtml(eq.product_label) + '</span>';
}
popupHtml += '</div>';
}
popupHtml += '</div>';
// Buttons row
popupHtml += '<div style="display:flex;gap:8px;">';
// Edit button
popupHtml += '<button type="button" class="schematic-eq-popup-edit" style="' +
'flex:1;background:#3498db;color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;gap:5px;">' +
'<i class="fas fa-edit"></i> Bearbeiten</button>';
// Delete button
popupHtml += '<button type="button" class="schematic-eq-popup-delete" style="' +
'flex:1;background:#e74c3c;color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;gap:5px;">' +
'<i class="fas fa-trash"></i> Löschen</button>';
popupHtml += '</div></div>';
$('body').append(popupHtml);
// Bind button events
$('.schematic-eq-popup-edit').on('click', function(e) {
e.stopPropagation();
self.editEquipment(equipmentId);
});
$('.schematic-eq-popup-delete').on('click', function(e) {
e.stopPropagation();
self.hideEquipmentPopup();
self.deleteEquipment(equipmentId);
});
// Adjust position if popup goes off screen
var $popup = $('.schematic-equipment-popup');
var popupWidth = $popup.outerWidth();
var popupHeight = $popup.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
if (x + popupWidth > windowWidth - 10) {
$popup.css('left', (x - popupWidth) + 'px');
}
if (y + popupHeight > windowHeight - 10) {
$popup.css('top', (y - popupHeight) + 'px');
}
},
hideEquipmentPopup: function() {
$('.schematic-equipment-popup').remove();
},
// Block Hover Tooltip mit show_in_hover Feldern
showBlockTooltip: function(equipmentId, e) {
var self = this;
// Hide any existing tooltip
this.hideBlockTooltip();
// Find equipment data
var eq = this.equipment.find(function(equ) { return parseInt(equ.id) === parseInt(equipmentId); });
if (!eq) return;
// Build tooltip content
var html = '<div id="schematic-block-tooltip" style="' +
'position:fixed;pointer-events:none;' +
'background:linear-gradient(135deg, #2d2d44 0%, #1e1e2e 100%);' +
'border:1px solid #3498db;border-radius:8px;padding:12px 14px;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.5);z-index:100000;' +
'min-width:180px;max-width:300px;font-size:12px;color:#fff;">';
// Header: Type + Label
html += '<div style="font-weight:bold;font-size:13px;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #444;">';
html += '<span style="color:#3498db;">' + self.escapeHtml(eq.type_label || eq.type_ref || '') + '</span>';
if (eq.label) {
html += '<br><span style="color:#aaa;font-weight:normal;font-size:11px;">' + self.escapeHtml(eq.label) + '</span>';
}
html += '</div>';
// Show fields with show_in_hover = 1
var hasHoverFields = false;
if (eq.type_fields && eq.field_values) {
eq.type_fields.forEach(function(field) {
if (field.show_in_hover && eq.field_values[field.field_code]) {
hasHoverFields = true;
var val = eq.field_values[field.field_code];
// Add units for known fields
if (field.field_code === 'ampere') val += ' A';
else if (field.field_code === 'sensitivity') val += ' mA';
else if (field.field_code === 'voltage') val += ' V';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:4px;">';
html += '<span style="color:#888;">' + self.escapeHtml(field.field_label) + ':</span>';
html += '<span style="color:#fff;font-weight:500;">' + self.escapeHtml(val) + '</span>';
html += '</div>';
}
});
}
// Position info
html += '<div style="display:flex;justify-content:space-between;margin-bottom:4px;">';
html += '<span style="color:#888;">Position:</span>';
html += '<span style="color:#fff;">' + (eq.position_te || 1) + ' TE</span>';
html += '</div>';
html += '<div style="display:flex;justify-content:space-between;margin-bottom:4px;">';
html += '<span style="color:#888;">Breite:</span>';
html += '<span style="color:#fff;">' + (eq.width_te || 1) + ' TE</span>';
html += '</div>';
// Product info if assigned
if (eq.fk_product && eq.product_ref) {
html += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid #444;">';
html += '<div style="display:flex;align-items:center;gap:6px;color:#27ae60;">';
html += '<i class="fa fa-cube"></i>';
html += '<span style="font-weight:500;">' + self.escapeHtml(eq.product_ref) + '</span>';
html += '</div>';
if (eq.product_label) {
html += '<div style="color:#888;font-size:11px;margin-top:2px;">' + self.escapeHtml(eq.product_label) + '</div>';
}
html += '</div>';
}
html += '</div>';
$('body').append(html);
this.updateBlockTooltipPosition(e);
},
updateBlockTooltipPosition: function(e) {
var $tooltip = $('#schematic-block-tooltip');
if (!$tooltip.length) return;
var x = e.clientX + 15;
var y = e.clientY + 15;
// Keep tooltip on screen
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
if (x + tooltipWidth > windowWidth - 10) {
x = e.clientX - tooltipWidth - 10;
}
if (y + tooltipHeight > windowHeight - 10) {
y = e.clientY - tooltipHeight - 10;
}
$tooltip.css({ left: x + 'px', top: y + 'px' });
},
hideBlockTooltip: function() {
$('#schematic-block-tooltip').remove();
},
showCarrierPopup: function(carrierId, x, y) {
var self = this;
// Remove any existing popup
this.hideCarrierPopup();
this.hideEquipmentPopup();
this.hideConnectionPopup();
// Find carrier data
var carrier = this.carriers.find(function(c) { return parseInt(c.id) === parseInt(carrierId); });
if (!carrier) {
console.warn('Carrier not found for id:', carrierId);
return;
}
// Check if carrier has equipment
var carrierEquipment = this.equipment.filter(function(e) { return parseInt(e.fk_carrier) === parseInt(carrierId); });
var isEmpty = carrierEquipment.length === 0;
var deleteStyle = isEmpty ? 'background:#e74c3c;' : 'background:#888;';
// Create popup
var popupHtml = '<div class="schematic-carrier-popup" data-carrier-id="' + carrierId + '" style="' +
'position:fixed;left:' + x + 'px;top:' + y + 'px;' +
'background:#2d2d44;border:2px solid #f39c12;border-radius:6px;padding:10px;' +
'box-shadow:0 4px 20px rgba(0,0,0,0.6);z-index:2147483647;display:flex;flex-direction:column;gap:8px;min-width:180px;">';
// Carrier info
popupHtml += '<div style="color:#fff;font-size:12px;border-bottom:1px solid #555;padding-bottom:6px;margin-bottom:2px;">';
popupHtml += '<strong>' + self.escapeHtml(carrier.label || 'Hutschiene') + '</strong>';
popupHtml += '<br><span style="color:#aaa;">' + (carrier.total_te || 12) + ' TE</span>';
popupHtml += '</div>';
// Buttons row
popupHtml += '<div style="display:flex;gap:8px;">';
// Edit button
popupHtml += '<button type="button" class="schematic-carrier-popup-edit" style="' +
'flex:1;background:#f39c12;color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;gap:5px;">' +
'<i class="fas fa-edit"></i> Bearbeiten</button>';
// Delete button
popupHtml += '<button type="button" class="schematic-carrier-popup-delete" data-empty="' + (isEmpty ? '1' : '0') + '" data-count="' + carrierEquipment.length + '" style="' +
'flex:1;' + deleteStyle + 'color:#fff;border:none;border-radius:4px;padding:8px 12px;' +
'cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;gap:5px;">' +
'<i class="fas fa-trash"></i> Löschen';
if (!isEmpty) popupHtml += ' <span style="font-size:10px;">(' + carrierEquipment.length + ')</span>';
popupHtml += '</button>';
popupHtml += '</div></div>';
$('body').append(popupHtml);
// Bind button events
$('.schematic-carrier-popup-edit').on('click', function(e) {
e.stopPropagation();
self.editCarrier(carrierId);
});
$('.schematic-carrier-popup-delete').on('click', function(e) {
e.stopPropagation();
var isEmpty = $(this).data('empty') === 1 || $(this).data('empty') === '1';
var count = $(this).data('count');
self.hideCarrierPopup();
if (isEmpty) {
self.deleteCarrier(carrierId);
} else {
KundenKarte.showConfirm(
'Hutschiene löschen',
'Hutschiene "' + (carrier.label || 'Ohne Name') + '" mit ' + count + ' Geräten wirklich löschen? Alle Geräte werden ebenfalls gelöscht!',
function() {
self.deleteCarrier(carrierId);
}
);
}
});
// Adjust position if popup goes off screen
var $popup = $('.schematic-carrier-popup');
var popupWidth = $popup.outerWidth();
var popupHeight = $popup.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
if (x + popupWidth > windowWidth - 10) {
$popup.css('left', (x - popupWidth) + 'px');
}
if (y + popupHeight > windowHeight - 10) {
$popup.css('top', (y - popupHeight) + 'px');
}
},
hideCarrierPopup: function() {
$('.schematic-carrier-popup').remove();
},
editCarrier: function(carrierId) {
var self = this;
// Hide popup first
this.hideCarrierPopup();
// Find carrier data
var carrier = this.carriers.find(function(c) { return parseInt(c.id) === parseInt(carrierId); });
if (!carrier) return;
// Load panels for dropdown
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_panel.php',
data: { action: 'list', anlage_id: self.anlageId },
dataType: 'json',
success: function(response) {
var panels = response.success ? response.panels : [];
self.showEditCarrierDialog(carrier, panels);
}
});
},
showEditCarrierDialog: function(carrier, panels) {
var self = this;
// Remove existing dialog
$('.schematic-edit-overlay').remove();
var html = '<div class="schematic-edit-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:2147483647;display:flex;align-items:center;justify-content:center;">';
html += '<div class="schematic-edit-dialog" style="background:#2d2d44;border:2px solid #f39c12;border-radius:8px;padding:20px;min-width:350px;max-width:450px;">';
html += '<h3 style="color:#fff;margin:0 0 15px 0;padding-bottom:10px;border-bottom:1px solid #555;">Hutschiene bearbeiten</h3>';
html += '<table style="width:100%;border-collapse:collapse;">';
// Label
html += '<tr><td style="color:#aaa;padding:8px 0;">Bezeichnung</td>';
html += '<td><input type="text" class="carrier-edit-label" value="' + self.escapeHtml(carrier.label || '') + '" style="width:100%;padding:8px;background:#1a1a2e;border:1px solid #555;border-radius:4px;color:#fff;"></td></tr>';
// Total TE
html += '<tr><td style="color:#aaa;padding:8px 0;">Kapazität (TE)</td>';
html += '<td><input type="number" class="carrier-edit-total-te" value="' + (carrier.total_te || 12) + '" min="1" max="72" style="width:100%;padding:8px;background:#1a1a2e;border:1px solid #555;border-radius:4px;color:#fff;"></td></tr>';
// Panel dropdown
if (panels && panels.length > 0) {
html += '<tr><td style="color:#aaa;padding:8px 0;">Feld</td>';
html += '<td><select class="carrier-edit-panel" style="width:100%;padding:8px;background:#1a1a2e;border:1px solid #555;border-radius:4px;color:#fff;">';
html += '<option value="0">-- Kein Feld --</option>';
panels.forEach(function(p) {
var selected = (carrier.panel_id == p.id) ? ' selected' : '';
html += '<option value="' + p.id + '"' + selected + '>' + self.escapeHtml(p.label) + '</option>';
});
html += '</select></td></tr>';
}
html += '</table>';
// Buttons
html += '<div style="margin-top:20px;display:flex;gap:10px;justify-content:flex-end;">';
html += '<button type="button" class="edit-dialog-save" style="background:#f39c12;color:#fff;border:none;border-radius:4px;padding:10px 20px;cursor:pointer;font-size:14px;"><i class="fas fa-save"></i> Speichern</button>';
html += '<button type="button" class="edit-dialog-cancel" style="background:#555;color:#fff;border:none;border-radius:4px;padding:10px 20px;cursor:pointer;font-size:14px;">Abbrechen</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
// Save handler
$('.edit-dialog-save').on('click', function() {
var label = $('.carrier-edit-label').val();
var totalTe = parseInt($('.carrier-edit-total-te').val()) || 12;
var panelId = parseInt($('.carrier-edit-panel').val()) || 0;
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'update',
carrier_id: carrier.id,
label: label,
total_te: totalTe,
panel_id: panelId
},
dataType: 'json',
success: function(response) {
if (response.success) {
$('.schematic-edit-overlay').remove();
self.loadData(); // Reload all data
} else {
alert('Fehler: ' + (response.error || 'Unbekannter Fehler'));
}
}
});
});
// Cancel handler
$('.edit-dialog-cancel, .schematic-edit-overlay').on('click', function(e) {
if (e.target === this) {
$('.schematic-edit-overlay').remove();
}
});
},
deleteCarrier: function(carrierId) {
var self = this;
// Find carrier data for confirmation
var carrier = this.carriers.find(function(c) { return parseInt(c.id) === parseInt(carrierId); });
var carrierLabel = carrier ? (carrier.label || 'Hutschiene') : 'Hutschiene';
KundenKarte.showConfirm('Hutschiene löschen', 'Hutschiene "' + carrierLabel + '" wirklich löschen? Alle darauf platzierten Geräte werden ebenfalls gelöscht!', function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'delete',
carrier_id: carrierId,
token: KundenKarte.token
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadData();
self.showMessage('Hutschiene gelöscht', 'success');
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
}
},
error: function() {
self.showMessage('Fehler beim Löschen', 'error');
}
});
});
},
editEquipment: function(equipmentId) {
var self = this;
// Hide popup first
this.hideEquipmentPopup();
// Find equipment data
var eq = this.equipment.find(function(e) { return parseInt(e.id) === parseInt(equipmentId); });
if (!eq) return;
// Load equipment types for dropdown
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_types', system_id: 1 },
dataType: 'json',
success: function(response) {
if (response.success) {
self.showEditEquipmentDialog(eq, response.types);
}
}
});
},
showEditEquipmentDialog: function(eq, types) {
var self = this;
var dialogHtml = '<div class="schematic-edit-overlay" style="' +
'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
dialogHtml += '<div class="schematic-edit-dialog" style="' +
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
'background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;' +
'box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:100001;min-width:350px;">';
dialogHtml += '<h3 style="margin:0 0 15px 0;color:#fff;font-size:16px;">Equipment bearbeiten</h3>';
// Equipment type
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Typ:</label>';
dialogHtml += '<select class="edit-equipment-type" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
types.forEach(function(t) {
var selected = (parseInt(t.id) === parseInt(eq.type_id)) ? ' selected' : '';
dialogHtml += '<option value="' + t.id + '" data-width="' + t.width_te + '"' + selected + '>' + self.escapeHtml(t.label) + ' (' + t.width_te + ' TE)</option>';
});
dialogHtml += '</select></div>';
// Container für typ-spezifische Felder
dialogHtml += '<div class="edit-type-fields" style="margin-bottom:12px;"></div>';
// Label
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Bezeichnung:</label>';
dialogHtml += '<input type="text" class="edit-equipment-label" value="' + self.escapeHtml(eq.label || '') + '" ' +
'placeholder="z.B. Küche Licht" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Position
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Position (TE):</label>';
dialogHtml += '<input type="number" class="edit-equipment-position" value="' + (eq.position_te || 1) + '" min="0.1" step="0.1" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Produktauswahl mit Autocomplete
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Produkt:</label>';
dialogHtml += '<input type="hidden" class="edit-product-id" value="' + (eq.fk_product || '') + '"/>';
dialogHtml += '<div style="position:relative;">';
dialogHtml += '<input type="text" class="edit-product-search" placeholder="Produkt suchen..." autocomplete="off" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/>';
dialogHtml += '<span class="edit-product-clear" style="position:absolute;right:10px;top:50%;transform:translateY(-50%);cursor:pointer;color:#888;display:none;">&times;</span>';
dialogHtml += '</div>';
dialogHtml += '</div>';
// FI/RCD-Zuordnung
dialogHtml += '<div style="margin-bottom:12px;padding-top:12px;border-top:1px solid #444;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;"><i class="fa fa-shield" style="color:#e67e22;"></i> Schutzgerät (FI/RCD):</label>';
dialogHtml += '<select class="edit-equipment-protection" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
dialogHtml += '<option value="">-- Keins --</option>';
dialogHtml += '</select></div>';
// Buttons
dialogHtml += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
dialogHtml += '<button type="button" class="edit-dialog-cancel" style="' +
'background:#555;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Abbrechen</button>';
dialogHtml += '<button type="button" class="edit-dialog-save" style="' +
'background:#27ae60;color:#fff;border:none;border-radius:4px;padding:10px 16px;cursor:pointer;">Speichern</button>';
dialogHtml += '</div>';
dialogHtml += '</div>';
$('body').append(dialogHtml);
// Bind events
$('.edit-dialog-cancel, .schematic-edit-overlay').on('click', function() {
$('.schematic-edit-dialog, .schematic-edit-overlay').remove();
});
$('.edit-dialog-save').on('click', function() {
self.saveEquipmentEdit(eq.id);
});
// Enter key to save
$('.schematic-edit-dialog input').on('keypress', function(e) {
if (e.which === 13) {
self.saveEquipmentEdit(eq.id);
}
});
// Lade Felder für aktuellen Typ
var existingValues = {};
try {
existingValues = eq.field_values ? (typeof eq.field_values === 'string' ? JSON.parse(eq.field_values) : eq.field_values) : {};
} catch(e) { existingValues = {}; }
var allTypes = types;
self.loadTypeFields(eq.type_id, null, '.edit-type-fields', existingValues);
// Bei Typ-Änderung Felder neu laden
$('.edit-equipment-type').on('change', function() {
var typeId = $(this).val();
self.loadTypeFields(typeId, null, '.edit-type-fields', {});
});
// Produktsuche mit Autocomplete initialisieren
self.initProductAutocomplete('.edit-product-search', '.edit-product-id', '.edit-product-clear', eq.fk_product);
// Protection Devices (FI/RCD) laden
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_protection_devices', anlage_id: self.anlageId },
dataType: 'json',
success: function(response) {
if (response.success && response.devices) {
var $select = $('.edit-equipment-protection');
response.devices.forEach(function(device) {
var selected = (eq.fk_protection && parseInt(device.id) === parseInt(eq.fk_protection)) ? ' selected' : '';
$select.append('<option value="' + device.id + '"' + selected + '>' + self.escapeHtml(device.display_label) + '</option>');
});
}
}
});
},
saveEquipmentEdit: function(equipmentId) {
var self = this;
// Sammle Feldwerte
var fieldValues = {};
$('.edit-type-fields').find('input, select').each(function() {
var code = $(this).data('field-code');
if (code) {
fieldValues[code] = $(this).val();
}
});
var data = {
action: 'update',
equipment_id: equipmentId,
type_id: $('.edit-equipment-type').val(),
label: $('.edit-equipment-label').val(),
position_te: $('.edit-equipment-position').val(),
field_values: JSON.stringify(fieldValues),
fk_product: $('.edit-product-id').val() || 0,
fk_protection: $('.edit-equipment-protection').val() || 0,
token: $('input[name="token"]').val()
};
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('.schematic-edit-dialog, .schematic-edit-overlay').remove();
self.showMessage('Equipment aktualisiert', 'success');
self.loadData();
} else {
self.showMessage(response.error || 'Fehler beim Speichern', 'error');
}
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
}
});
},
deleteEquipment: function(equipmentId) {
var self = this;
this.hideEquipmentPopup();
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'delete',
equipment_id: equipmentId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.showMessage('Equipment gelöscht', 'success');
// Remove from local array instead of reloading everything
self.equipment = self.equipment.filter(function(e) {
return String(e.id) !== String(equipmentId);
});
// Also remove connections involving this equipment
self.connections = self.connections.filter(function(c) {
return String(c.fk_source) !== String(equipmentId) &&
String(c.fk_target) !== String(equipmentId);
});
self.render();
} else {
self.showMessage(response.error || 'Fehler', 'error');
}
}
});
},
// Block dragging
startDragBlock: function($block, e) {
var eqId = $block.data('equipment-id');
var carrierId = $block.data('carrier-id');
var eq = this.equipment.find(function(e) { return String(e.id) === String(eqId); });
var carrier = this.carriers.find(function(c) { return String(c.id) === String(carrierId); });
if (!eq || !carrier) {
this.log('startDragBlock: Equipment or carrier not found', eqId, carrierId);
return;
}
if (typeof carrier._x === 'undefined') {
this.log('startDragBlock: Carrier has no _x position');
return;
}
var $svg = $(this.svgElement);
var offset = $svg.offset();
this.dragState = {
type: 'block',
equipmentId: eqId,
equipment: eq,
carrier: carrier,
element: $block,
startX: e.pageX,
startY: e.pageY,
originalTE: parseFloat(eq.position_te) || 1,
originalX: parseFloat(carrier._x) + ((parseFloat(eq.position_te) || 1) - 1) * this.TE_WIDTH + 2,
originalY: eq._y
};
$block.addClass('dragging');
},
updateDragBlock: function(e) {
if (!this.dragState || this.dragState.type !== 'block') return;
var dx = e.pageX - this.dragState.startX;
var dy = e.pageY - this.dragState.startY;
// Find target carrier based on mouse position (supports cross-panel drag)
var $svg = $(this.svgElement);
var svgOffset = $svg.offset();
var mouseY = (e.pageY - svgOffset.top) / this.scale;
var mouseX = (e.pageX - svgOffset.left) / this.scale;
// Find closest carrier (checks both Y and X for cross-panel support)
var targetCarrier = this.findClosestCarrier(mouseY, mouseX);
if (!targetCarrier) targetCarrier = this.dragState.carrier;
// TE-Position auf Ziel-Carrier berechnen (0.1er-Snap für Dezimal-Breiten)
var relativeX = mouseX - targetCarrier._x;
var rawTE = relativeX / this.TE_WIDTH + 1;
var newTE = Math.round(rawTE * 10) / 10;
// Auf gültigen Bereich auf ZIEL-Carrier begrenzen
var maxTE = (parseFloat(targetCarrier.total_te) || 12) - (parseFloat(this.dragState.equipment.width_te) || 1) + 1;
newTE = Math.max(1, Math.min(newTE, Math.round(maxTE * 10) / 10));
// Calculate visual position
var newX, newY;
if (targetCarrier.id === this.dragState.carrier.id) {
// Same carrier - use offset from original
var teOffset = newTE - this.dragState.originalTE;
newX = this.dragState.originalX + teOffset * this.TE_WIDTH;
newY = this.dragState.originalY;
} else {
// Different carrier - calculate new position
newX = parseFloat(targetCarrier._x) + (newTE - 1) * this.TE_WIDTH + 2;
// Block Y position: carrier Y - half block height (block sits on rail)
newY = parseFloat(targetCarrier._y) - this.BLOCK_HEIGHT / 2;
}
if (!isNaN(newX) && !isNaN(newY)) {
this.dragState.element.attr('transform', 'translate(' + newX + ',' + newY + ')');
}
// Store target info
this.dragState.newTE = newTE;
this.dragState.targetCarrier = targetCarrier;
// Highlight target carrier
this.highlightTargetCarrier(targetCarrier.id);
},
findClosestCarrier: function(mouseY, mouseX) {
var self = this;
var closest = null;
var closestDist = Infinity;
this.carriers.forEach(function(carrier) {
if (typeof carrier._y === 'undefined' || typeof carrier._x === 'undefined') return;
var carrierY = parseFloat(carrier._y);
var carrierX = parseFloat(carrier._x);
var carrierWidth = (carrier.total_te || 12) * self.TE_WIDTH;
// Calculate distance - prioritize Y but also check X is within carrier bounds
var distY = Math.abs(mouseY - carrierY);
// Check if mouseX is within carrier X range (with some tolerance)
var tolerance = 50;
var inXRange = mouseX >= (carrierX - tolerance) && mouseX <= (carrierX + carrierWidth + tolerance);
// Use combined distance for carriers in range, otherwise penalize
var dist = inXRange ? distY : distY + 1000;
if (dist < closestDist) {
closestDist = dist;
closest = carrier;
}
});
// Only return if within reasonable distance (half rail spacing)
if (closestDist < this.RAIL_SPACING / 2) {
return closest;
}
return null;
},
highlightTargetCarrier: function(carrierId) {
// Remove previous highlights
$('.schematic-rail').removeClass('drop-target');
// Add highlight to target
$('.schematic-rail[data-carrier-id="' + carrierId + '"]').addClass('drop-target');
},
endDragBlock: function(e) {
if (!this.dragState || this.dragState.type !== 'block') return;
var newTE = this.dragState.newTE || this.dragState.originalTE;
var element = this.dragState.element;
var targetCarrier = this.dragState.targetCarrier || this.dragState.carrier;
var originalCarrier = this.dragState.carrier;
// Remove highlight
$('.schematic-rail').removeClass('drop-target');
// Check if carrier changed or position changed
var carrierChanged = String(targetCarrier.id) !== String(originalCarrier.id);
var positionChanged = newTE !== this.dragState.originalTE;
this.log('endDragBlock: carrierChanged=', carrierChanged, 'positionChanged=', positionChanged,
'newTE=', newTE, 'originalTE=', this.dragState.originalTE,
'targetCarrier=', targetCarrier.id, 'originalCarrier=', originalCarrier.id);
if (carrierChanged) {
// Move to different carrier
this.moveEquipmentToCarrier(this.dragState.equipment.id, targetCarrier.id, newTE);
} else if (positionChanged) {
// Same carrier, different position
this.updateEquipmentPosition(this.dragState.equipment.id, newTE);
} else {
// No change, reset visual position
this.log('No change detected, resetting position');
element.attr('transform', 'translate(' + this.dragState.originalX + ',' + this.dragState.originalY + ')');
}
element.removeClass('dragging');
this.dragState = null;
},
moveEquipmentToCarrier: function(eqId, newCarrierId, newTE) {
var self = this;
this.log('moveEquipmentToCarrier:', eqId, 'to carrier', newCarrierId, 'position', newTE);
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'move_to_carrier',
equipment_id: eqId,
carrier_id: newCarrierId,
position_te: newTE,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
self.log('moveEquipmentToCarrier response:', response);
if (response.success) {
self.showMessage('Equipment verschoben', 'success');
self.loadData(); // Reload to update all positions
} else {
self.showMessage(response.error || 'Fehler beim Verschieben', 'error');
self.render();
}
},
error: function(xhr, status, error) {
self.log('moveEquipmentToCarrier error:', status, error, xhr.responseText);
self.showMessage('Netzwerkfehler', 'error');
self.render();
}
});
},
updateEquipmentPosition: function(eqId, newTE) {
var self = this;
this.log('updateEquipmentPosition:', eqId, newTE);
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
method: 'POST',
data: {
action: 'update_position',
equipment_id: eqId,
position_te: newTE,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
// Update local data and re-render
var eq = self.equipment.find(function(e) { return String(e.id) === String(eqId); });
if (eq) {
eq.position_te = newTE;
}
// Full re-render to ensure carrier positions are set
self.render();
} else {
// Revert visual position on error
self.showMessage(response.error || 'Position nicht verfügbar', 'error');
self.render();
}
},
error: function() {
// Revert on error
self.showMessage('Netzwerkfehler', 'error');
self.render();
}
});
},
// Zoom functions
setZoom: function(newScale) {
// Clamp zoom level between 0.25 and 2.0
this.scale = Math.max(0.25, Math.min(2.0, newScale));
// Apply transform to wrapper (SVG + controls)
var $wrapper = $('.schematic-zoom-wrapper');
if ($wrapper.length) {
$wrapper.css('transform', 'scale(' + this.scale + ')');
$wrapper.css('transform-origin', '0 0');
// Update container size to account for scaling
var $svg = $wrapper.find('.schematic-svg');
if ($svg.length) {
var actualWidth = $svg.attr('width') * this.scale;
var actualHeight = $svg.attr('height') * this.scale;
$wrapper.parent().css({
'min-width': actualWidth + 'px',
'min-height': actualHeight + 'px'
});
}
}
// Update zoom display
this.updateZoomDisplay();
},
zoomToFit: function() {
if (!this.svgElement) return;
var $canvas = $('.schematic-editor-canvas');
var $svg = $(this.svgElement);
var canvasWidth = $canvas.width() - 40; // Padding
var canvasHeight = $canvas.height() - 40;
var svgWidth = parseFloat($svg.attr('width')) || 800;
var svgHeight = parseFloat($svg.attr('height')) || 600;
// Calculate scale to fit both dimensions
var scaleX = canvasWidth / svgWidth;
var scaleY = canvasHeight / svgHeight;
var newScale = Math.min(scaleX, scaleY, 1); // Don't zoom in beyond 100%
this.setZoom(newScale);
},
updateZoomDisplay: function() {
var percentage = Math.round(this.scale * 100);
$('.schematic-zoom-level').text(percentage + '%');
// Show zoom level in status bar
this.showMessage('Zoom: ' + percentage + '%', 'info');
},
showMessage: function(text, type) {
var $msg = $('.schematic-message');
if (!$msg.length) return; // Statuszeile wird in PHP erstellt
// Clear any pending hide timeout
if (this.messageTimeout) {
clearTimeout(this.messageTimeout);
this.messageTimeout = null;
}
// Atomarer Klassen-Wechsel - kein Flackern durch removeClass/addClass
$msg.attr('class', 'schematic-message ' + type).text(text);
if (type === 'success' || type === 'error') {
this.messageTimeout = setTimeout(function() {
$msg.attr('class', 'schematic-message info').text('Bereit');
}, 2500);
}
},
hideMessage: function() {
var $msg = $('.schematic-message');
$msg.attr('class', 'schematic-message info').text('Bereit');
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
// Initialize on DOM ready
$(document).ready(function() {
KundenKarte.Tree.init();
KundenKarte.Favorites.init();
KundenKarte.SystemTabs.init();
KundenKarte.IconPicker.init();
KundenKarte.DynamicFields.init();
// Initialize Equipment if container exists
var $equipmentContainer = $('.kundenkarte-equipment-container');
if ($equipmentContainer.length) {
var anlageId = $equipmentContainer.data('anlage-id');
var systemId = $equipmentContainer.data('system-id');
KundenKarte.Equipment.init(anlageId, systemId);
// Initialize Schematic Editor
KundenKarte.SchematicEditor.init(anlageId);
}
// Initialize Anlage Connection handlers
KundenKarte.AnlageConnection.init();
});
// ===========================================
// Anlage Connections (cable connections between tree elements)
// ===========================================
KundenKarte.AnlageConnection = {
baseUrl: '',
init: function() {
var self = this;
this.baseUrl = (typeof baseUrl !== 'undefined') ? baseUrl : '';
this.tooltipTimer = null;
// Create tooltip container if not exists
if ($('#kundenkarte-conn-tooltip').length === 0) {
$('body').append('<div id="kundenkarte-conn-tooltip" class="kundenkarte-conn-tooltip"></div>');
}
// Add connection button
$(document).on('click', '.anlage-connection-add', function(e) {
e.preventDefault();
var anlageId = $(this).data('anlage-id');
var socId = $(this).data('soc-id');
var systemId = $(this).data('system-id');
self.showConnectionDialog(anlageId, socId, systemId, null);
});
// Edit connection (old style)
$(document).on('click', '.anlage-connection-edit', function(e) {
e.preventDefault();
var connId = $(this).data('id');
self.loadAndEditConnection(connId);
});
// Delete connection (old style)
$(document).on('click', '.anlage-connection-delete', function(e) {
e.preventDefault();
var connId = $(this).data('id');
self.deleteConnection(connId);
});
// Hover on connection row - show tooltip
$(document).on('mouseenter', '.kundenkarte-tree-conn', function(e) {
var $el = $(this);
var tooltipData = $el.data('conn-tooltip');
if (tooltipData) {
self.showConnTooltip(e, tooltipData);
}
});
$(document).on('mousemove', '.kundenkarte-tree-conn', function(e) {
self.moveConnTooltip(e);
});
$(document).on('mouseleave', '.kundenkarte-tree-conn', function() {
self.hideConnTooltip();
});
},
showConnTooltip: function(e, data) {
var $tooltip = $('#kundenkarte-conn-tooltip');
var html = '<div class="kundenkarte-conn-tooltip-header">';
html += '<i class="fa fa-plug"></i>';
html += '<span class="conn-route">' + this.escapeHtml(data.source || '') + ' → ' + this.escapeHtml(data.target || '') + '</span>';
html += '</div>';
html += '<div class="kundenkarte-conn-tooltip-fields">';
if (data.medium_type) {
html += '<span class="field-label">Kabeltyp:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.medium_type) + '</span>';
}
if (data.medium_spec) {
html += '<span class="field-label">Querschnitt:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.medium_spec) + '</span>';
}
if (data.medium_length) {
html += '<span class="field-label">Länge:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.medium_length) + '</span>';
}
if (data.medium_color) {
html += '<span class="field-label">Farbe:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.medium_color) + '</span>';
}
if (data.route_description) {
html += '<span class="field-label">Route:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.route_description) + '</span>';
}
if (data.installation_date) {
html += '<span class="field-label">Installiert:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.installation_date) + '</span>';
}
if (data.label) {
html += '<span class="field-label">Beschreibung:</span>';
html += '<span class="field-value">' + this.escapeHtml(data.label) + '</span>';
}
html += '</div>';
html += '<div class="kundenkarte-conn-tooltip-hint"><i class="fa fa-mouse-pointer"></i>Klicken zum Bearbeiten</div>';
$tooltip.html(html);
this.moveConnTooltip(e);
$tooltip.addClass('visible');
},
moveConnTooltip: function(e) {
var $tooltip = $('#kundenkarte-conn-tooltip');
var x = e.pageX + 15;
var y = e.pageY + 10;
// Keep tooltip in viewport
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
var windowWidth = $(window).width();
var windowHeight = $(window).height();
var scrollTop = $(window).scrollTop();
if (x + tooltipWidth > windowWidth - 20) {
x = e.pageX - tooltipWidth - 15;
}
if (y + tooltipHeight > scrollTop + windowHeight - 20) {
y = e.pageY - tooltipHeight - 10;
}
$tooltip.css({ left: x, top: y });
},
hideConnTooltip: function() {
$('#kundenkarte-conn-tooltip').removeClass('visible');
},
loadAndEditConnection: function(connId) {
var self = this;
this.log('loadAndEditConnection called with connId:', connId);
$.ajax({
url: this.baseUrl + '/custom/kundenkarte/ajax/anlage_connection.php',
data: { action: 'get', connection_id: connId },
dataType: 'json',
success: function(response) {
self.log('Connection fetch response:', response);
if (response.success) {
// Get soc_id and system_id from the tree container or add button
var $tree = $('.kundenkarte-tree');
var socId = $tree.data('socid');
var systemId = $tree.data('system');
self.log('From tree element - socId:', socId, 'systemId:', systemId, '$tree.length:', $tree.length);
// Fallback to add button if tree doesn't have data
if (!socId) {
var $btn = $('.anlage-connection-add').first();
socId = $btn.data('soc-id');
systemId = $btn.data('system-id');
self.log('From fallback button - socId:', socId, 'systemId:', systemId);
}
self.showConnectionDialog(response.connection.fk_source, socId, systemId, response.connection);
} else {
KundenKarte.showAlert('Fehler', response.error || 'Fehler beim Laden');
}
}
});
},
showConnectionDialog: function(anlageId, socId, systemId, existingConn) {
var self = this;
this.log('showConnectionDialog called:', {anlageId: anlageId, socId: socId, systemId: systemId, existingConn: existingConn});
// Ensure systemId is a number (0 if not set)
systemId = systemId || 0;
// First load anlagen list and medium types
$.when(
$.ajax({
url: this.baseUrl + '/custom/kundenkarte/ajax/anlage.php',
data: { action: 'tree', socid: socId, system_id: systemId },
dataType: 'json'
}),
$.ajax({
url: this.baseUrl + '/custom/kundenkarte/ajax/medium_types.php',
data: { action: 'list_grouped', system_id: systemId },
dataType: 'json'
})
).done(function(anlagenResp, mediumResp) {
self.log('AJAX responses:', {anlagenResp: anlagenResp, mediumResp: mediumResp});
var anlagen = anlagenResp[0].success ? anlagenResp[0].tree : [];
var mediumGroups = mediumResp[0].success ? mediumResp[0].groups : [];
self.log('Parsed data:', {anlagen: anlagen, mediumGroups: mediumGroups});
self.renderConnectionDialog(anlageId, anlagen, mediumGroups, existingConn);
});
},
flattenTree: function(tree, result, prefix) {
result = result || [];
prefix = prefix || '';
for (var i = 0; i < tree.length; i++) {
var node = tree[i];
result.push({
id: node.id,
label: prefix + (node.label || node.ref),
ref: node.ref
});
if (node.children && node.children.length > 0) {
this.flattenTree(node.children, result, prefix + ' ');
}
}
return result;
},
renderConnectionDialog: function(anlageId, anlagenTree, mediumGroups, existingConn) {
var self = this;
this.log('renderConnectionDialog - anlagenTree:', anlagenTree);
var anlagen = this.flattenTree(anlagenTree);
this.log('renderConnectionDialog - flattened anlagen:', anlagen);
// Remove existing dialog
$('#anlage-connection-dialog').remove();
var isEdit = existingConn !== null;
var title = isEdit ? 'Verbindung bearbeiten' : 'Neue Verbindung';
var html = '<div id="anlage-connection-dialog" class="kundenkarte-modal visible">';
html += '<div class="kundenkarte-modal-content" style="max-width:500px;">';
html += '<div class="kundenkarte-modal-header"><h3>' + title + '</h3>';
html += '<span class="kundenkarte-modal-close">&times;</span></div>';
html += '<div class="kundenkarte-modal-body" style="padding:20px;">';
// Source
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Von (Quelle):</label>';
html += '<select class="conn-source flat" style="width:100%;">';
for (var i = 0; i < anlagen.length; i++) {
var sel = (existingConn && parseInt(existingConn.fk_source) === parseInt(anlagen[i].id)) || (!existingConn && parseInt(anlagen[i].id) === parseInt(anlageId)) ? ' selected' : '';
html += '<option value="' + anlagen[i].id + '"' + sel + '>' + self.escapeHtml(anlagen[i].label) + '</option>';
}
html += '</select></div>';
// Target
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Nach (Ziel):</label>';
html += '<select class="conn-target flat" style="width:100%;">';
html += '<option value="">-- Ziel wählen --</option>';
for (var i = 0; i < anlagen.length; i++) {
var sel = (existingConn && parseInt(existingConn.fk_target) === parseInt(anlagen[i].id)) ? ' selected' : '';
html += '<option value="' + anlagen[i].id + '"' + sel + '>' + self.escapeHtml(anlagen[i].label) + '</option>';
}
html += '</select></div>';
// Medium type
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Kabeltyp:</label>';
html += '<select class="conn-medium-type flat" style="width:100%;">';
html += '<option value="">-- Freitext oder wählen --</option>';
for (var g = 0; g < mediumGroups.length; g++) {
html += '<optgroup label="' + self.escapeHtml(mediumGroups[g].category_label) + '">';
var types = mediumGroups[g].types;
for (var t = 0; t < types.length; t++) {
var sel = (existingConn && existingConn.fk_medium_type == types[t].id) ? ' selected' : '';
html += '<option value="' + types[t].id + '" data-specs=\'' + JSON.stringify(types[t].available_specs || []) + '\' data-default="' + self.escapeHtml(types[t].default_spec || '') + '"' + sel + '>' + self.escapeHtml(types[t].label) + '</option>';
}
html += '</optgroup>';
}
html += '</select>';
html += '<input type="text" class="conn-medium-text flat" placeholder="oder Freitext eingeben" style="width:100%;margin-top:5px;" value="' + self.escapeHtml(existingConn ? existingConn.medium_type_text || '' : '') + '">';
html += '</div>';
// Spec
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Querschnitt/Typ:</label>';
html += '<select class="conn-spec flat" style="width:100%;"><option value="">--</option></select>';
html += '<input type="text" class="conn-spec-text flat" placeholder="oder Freitext" style="width:100%;margin-top:5px;" value="' + self.escapeHtml(existingConn ? existingConn.medium_spec || '' : '') + '">';
html += '</div>';
// Length
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Länge:</label>';
html += '<input type="text" class="conn-length flat" placeholder="z.B. 15m, ca. 20m" style="width:100%;" value="' + self.escapeHtml(existingConn ? existingConn.medium_length || '' : '') + '">';
html += '</div>';
// Label
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Bezeichnung (optional):</label>';
html += '<input type="text" class="conn-label flat" placeholder="z.B. Zuleitung HAK" style="width:100%;" value="' + self.escapeHtml(existingConn ? existingConn.label || '' : '') + '">';
html += '</div>';
// Route description
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:block;font-weight:bold;margin-bottom:3px;">Verlegungsweg (optional):</label>';
html += '<textarea class="conn-route flat" rows="2" style="width:100%;" placeholder="z.B. Erdverlegung, Kabelkanal links">' + self.escapeHtml(existingConn ? existingConn.route_description || '' : '') + '</textarea>';
html += '</div>';
html += '</div>'; // modal-body
// Footer
html += '<div class="kundenkarte-modal-footer" style="display:flex;justify-content:flex-end;gap:10px;">';
html += '<button type="button" class="button conn-cancel">Abbrechen</button>';
html += '<button type="button" class="button button-primary conn-save"><i class="fa fa-check"></i> ' + (isEdit ? 'Aktualisieren' : 'Erstellen') + '</button>';
html += '</div>';
html += '</div></div>';
$('body').append(html);
// Spec dropdown update
$('.conn-medium-type').on('change', function() {
var $opt = $(this).find('option:selected');
var specs = $opt.data('specs') || [];
var defSpec = $opt.data('default') || '';
var $specSelect = $('.conn-spec');
$specSelect.empty().append('<option value="">--</option>');
for (var i = 0; i < specs.length; i++) {
var sel = (existingConn && existingConn.medium_spec === specs[i]) || (!existingConn && specs[i] === defSpec) ? ' selected' : '';
$specSelect.append('<option value="' + self.escapeHtml(specs[i]) + '"' + sel + '>' + self.escapeHtml(specs[i]) + '</option>');
}
});
// Trigger to populate specs if editing
if (existingConn && existingConn.fk_medium_type) {
$('.conn-medium-type').trigger('change');
if (existingConn.medium_spec) {
$('.conn-spec').val(existingConn.medium_spec);
}
}
// Close handlers
$('.conn-cancel, #anlage-connection-dialog .kundenkarte-modal-close').on('click', function() {
$('#anlage-connection-dialog').remove();
});
// Save handler
$('.conn-save').on('click', function() {
var data = {
fk_source: $('.conn-source').val(),
fk_target: $('.conn-target').val(),
fk_medium_type: $('.conn-medium-type').val(),
medium_type_text: $('.conn-medium-text').val(),
medium_spec: $('.conn-spec').val() || $('.conn-spec-text').val(),
medium_length: $('.conn-length').val(),
label: $('.conn-label').val(),
route_description: $('.conn-route').val(),
token: $('input[name="token"]').val()
};
if (!data.fk_target) {
KundenKarte.showAlert('Fehler', 'Bitte wählen Sie ein Ziel aus.');
return;
}
if (isEdit) {
data.action = 'update';
data.connection_id = existingConn.id;
} else {
data.action = 'create';
}
$.ajax({
url: self.baseUrl + '/custom/kundenkarte/ajax/anlage_connection.php',
method: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.success) {
$('#anlage-connection-dialog').remove();
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error || 'Speichern fehlgeschlagen');
}
}
});
});
},
deleteConnection: function(connId) {
var self = this;
KundenKarte.showConfirm('Verbindung löschen', 'Möchten Sie diese Verbindung wirklich löschen?', function(confirmed) {
if (confirmed) {
$.ajax({
url: self.baseUrl + '/custom/kundenkarte/ajax/anlage_connection.php',
method: 'POST',
data: {
action: 'delete',
connection_id: connId,
token: $('input[name="token"]').val()
},
dataType: 'json',
success: function(response) {
if (response.success) {
location.reload();
} else {
KundenKarte.showAlert('Fehler', response.error || 'Löschen fehlgeschlagen');
}
}
});
}
});
},
escapeHtml: function(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};
// ===========================================
// File Upload Dropzone
// ===========================================
KundenKarte.initFileDropzone = function() {
var dropzone = document.getElementById('fileDropzone');
var fileInput = document.getElementById('fileInput');
var selectedFilesDiv = document.getElementById('selectedFiles');
var uploadBtn = document.getElementById('uploadBtn');
var fileCountSpan = document.getElementById('fileCount');
var form = document.getElementById('fileUploadForm');
if (!dropzone || !fileInput) return;
var selectedFiles = [];
// Click to open file dialog
dropzone.addEventListener('click', function(e) {
if (e.target.tagName !== 'A') {
fileInput.click();
}
});
// Drag & Drop handlers
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(eventName) {
dropzone.addEventListener(eventName, function(e) {
e.preventDefault();
e.stopPropagation();
});
});
['dragenter', 'dragover'].forEach(function(eventName) {
dropzone.addEventListener(eventName, function() {
dropzone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function(eventName) {
dropzone.addEventListener(eventName, function() {
dropzone.classList.remove('dragover');
});
});
dropzone.addEventListener('drop', function(e) {
var files = e.dataTransfer.files;
handleFiles(files);
});
fileInput.addEventListener('change', function() {
handleFiles(this.files);
});
function handleFiles(files) {
for (var i = 0; i < files.length; i++) {
var file = files[i];
// Check for duplicates
var isDuplicate = selectedFiles.some(function(f) {
return f.name === file.name && f.size === file.size;
});
if (!isDuplicate) {
selectedFiles.push(file);
}
}
updateFileList();
}
function updateFileList() {
selectedFilesDiv.innerHTML = '';
if (selectedFiles.length === 0) {
selectedFilesDiv.style.display = 'none';
uploadBtn.style.display = 'none';
return;
}
selectedFilesDiv.style.display = 'flex';
uploadBtn.style.display = 'inline-block';
fileCountSpan.textContent = selectedFiles.length;
selectedFiles.forEach(function(file, index) {
var fileDiv = document.createElement('div');
fileDiv.className = 'kundenkarte-dropzone-file';
var icon = getFileIcon(file.name);
fileDiv.innerHTML = '<i class="fa ' + icon + '"></i> ' +
'<span>' + KundenKarte.AnlageConnection.escapeHtml(file.name) + '</span>' +
'<span class="remove-file" data-index="' + index + '"><i class="fa fa-times"></i></span>';
selectedFilesDiv.appendChild(fileDiv);
});
// Add remove handlers
selectedFilesDiv.querySelectorAll('.remove-file').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var idx = parseInt(this.getAttribute('data-index'));
selectedFiles.splice(idx, 1);
updateFileList();
});
});
// Update file input with DataTransfer
updateFileInput();
}
function updateFileInput() {
var dt = new DataTransfer();
selectedFiles.forEach(function(file) {
dt.items.add(file);
});
fileInput.files = dt.files;
}
function getFileIcon(filename) {
var ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].indexOf(ext) !== -1) {
return 'fa-image';
} else if (ext === 'pdf') {
return 'fa-file-pdf-o';
} else if (['doc', 'docx'].indexOf(ext) !== -1) {
return 'fa-file-word-o';
} else if (['xls', 'xlsx'].indexOf(ext) !== -1) {
return 'fa-file-excel-o';
}
return 'fa-file-o';
}
};
// Auto-init on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', KundenKarte.initFileDropzone);
} else {
KundenKarte.initFileDropzone();
}
// ===========================================
// Field Autocomplete
// ===========================================
KundenKarte.FieldAutocomplete = {
baseUrl: '',
activeInput: null,
dropdown: null,
debounceTimer: null,
init: function(baseUrl) {
this.baseUrl = baseUrl || '';
this.createDropdown();
this.bindEvents();
},
createDropdown: function() {
// Create dropdown element if not exists
if (!document.getElementById('kk-autocomplete-dropdown')) {
var dropdown = document.createElement('div');
dropdown.id = 'kk-autocomplete-dropdown';
dropdown.className = 'kk-autocomplete-dropdown';
dropdown.style.cssText = 'display:none;position:absolute;z-index:10000;background:#2d2d2d;border:1px solid #555;border-radius:4px;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,0.3);';
document.body.appendChild(dropdown);
this.dropdown = dropdown;
} else {
this.dropdown = document.getElementById('kk-autocomplete-dropdown');
}
},
bindEvents: function() {
var self = this;
// Delegate events for autocomplete inputs
document.addEventListener('input', function(e) {
if (e.target.classList.contains('kk-autocomplete')) {
self.onInput(e.target);
}
});
document.addEventListener('focus', function(e) {
if (e.target.classList.contains('kk-autocomplete')) {
self.onFocus(e.target);
}
}, true);
document.addEventListener('blur', function(e) {
if (e.target.classList.contains('kk-autocomplete')) {
// Delay to allow click on dropdown item
setTimeout(function() {
self.hideDropdown();
}, 200);
}
}, true);
document.addEventListener('keydown', function(e) {
if (e.target.classList.contains('kk-autocomplete') && self.dropdown.style.display !== 'none') {
self.onKeydown(e);
}
});
// Click on dropdown item
this.dropdown.addEventListener('click', function(e) {
var item = e.target.closest('.kk-autocomplete-item');
if (item) {
self.selectItem(item);
}
});
},
onInput: function(input) {
var self = this;
this.activeInput = input;
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(function() {
self.fetchSuggestions(input);
}, 150);
},
onFocus: function(input) {
this.activeInput = input;
if (input.value.length >= 1) {
this.fetchSuggestions(input);
}
},
onKeydown: function(e) {
var items = this.dropdown.querySelectorAll('.kk-autocomplete-item');
var activeItem = this.dropdown.querySelector('.kk-autocomplete-item.active');
var activeIndex = -1;
items.forEach(function(item, idx) {
if (item.classList.contains('active')) activeIndex = idx;
});
if (e.key === 'ArrowDown') {
e.preventDefault();
if (activeIndex < items.length - 1) {
if (activeItem) activeItem.classList.remove('active');
items[activeIndex + 1].classList.add('active');
items[activeIndex + 1].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (activeIndex > 0) {
if (activeItem) activeItem.classList.remove('active');
items[activeIndex - 1].classList.add('active');
items[activeIndex - 1].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'Enter' && activeItem) {
e.preventDefault();
this.selectItem(activeItem);
} else if (e.key === 'Escape') {
this.hideDropdown();
}
},
fetchSuggestions: function(input) {
var self = this;
var fieldCode = input.getAttribute('data-field-code');
var typeId = input.getAttribute('data-type-id') || 0;
var query = input.value;
if (!fieldCode) return;
$.ajax({
url: this.baseUrl + '/custom/kundenkarte/ajax/field_autocomplete.php',
method: 'GET',
data: {
action: 'suggest',
field_code: fieldCode,
type_id: typeId,
query: query
},
dataType: 'json',
success: function(response) {
if (response.suggestions && response.suggestions.length > 0) {
self.showSuggestions(input, response.suggestions, query);
} else {
self.hideDropdown();
}
}
});
},
showSuggestions: function(input, suggestions, query) {
var self = this;
var rect = input.getBoundingClientRect();
this.dropdown.innerHTML = '';
this.dropdown.style.top = (rect.bottom + window.scrollY) + 'px';
this.dropdown.style.left = rect.left + 'px';
this.dropdown.style.width = rect.width + 'px';
this.dropdown.style.display = 'block';
suggestions.forEach(function(suggestion, idx) {
var item = document.createElement('div');
item.className = 'kk-autocomplete-item';
if (idx === 0) item.classList.add('active');
item.style.cssText = 'padding:8px 12px;cursor:pointer;color:#e0e0e0;border-bottom:1px solid #444;';
item.setAttribute('data-value', suggestion);
// Highlight matching text
var html = self.highlightMatch(suggestion, query);
item.innerHTML = html;
self.dropdown.appendChild(item);
});
// Hover effect
this.dropdown.querySelectorAll('.kk-autocomplete-item').forEach(function(item) {
item.addEventListener('mouseenter', function() {
self.dropdown.querySelectorAll('.kk-autocomplete-item').forEach(function(i) {
i.classList.remove('active');
});
item.classList.add('active');
});
});
},
highlightMatch: function(text, query) {
if (!query) return text;
var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<strong style="color:#3498db;">$1</strong>');
},
selectItem: function(item) {
var value = item.getAttribute('data-value');
if (this.activeInput) {
this.activeInput.value = value;
// Trigger change event
var event = new Event('change', { bubbles: true });
this.activeInput.dispatchEvent(event);
}
this.hideDropdown();
},
hideDropdown: function() {
if (this.dropdown) {
this.dropdown.style.display = 'none';
}
}
};
// Auto-init autocomplete
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
KundenKarte.FieldAutocomplete.init('');
});
} else {
KundenKarte.FieldAutocomplete.init('');
}
})();