kundenkarte/js/pwa.js
data 6b3b6d7e95 feat(schematic): Terminal-Farben, Leitungen hinter Blöcken, Zeichenmodus v11.0
Terminal-Farben nach Verbindung:
- Terminals zeigen Farbe der angeschlossenen Leitung
- Grau = keine Verbindung, farbig = Leitung angeschlossen
- Neue Hilfsfunktion getTerminalConnectionColor()

Leitungen hinter Blöcken:
- Layer-Reihenfolge geändert: connections vor blocks
- Professionelleres Erscheinungsbild

Zeichenmodus-Verbesserungen:
- Rechtsklick/Escape bricht nur Linie ab, nicht Modus
- Crosshair-Cursor überall im SVG während Zeichenmodus
- 30px Hit-Area für bessere Klickbarkeit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 13:44:52 +01:00

3081 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* KundenKarte PWA - Mobile Schaltschrank-Dokumentation
* Offline-First App für Elektriker
*/
(function($) {
'use strict';
// ============================================
// APP STATE
// ============================================
const App = {
// Auth
token: null,
user: null,
// Current selection
customerId: null,
customerName: '',
customerAddress: '',
anlageId: null,
anlageName: '',
// Data
panels: [],
carriers: [],
equipment: [],
equipmentTypes: [],
outputs: [],
inputs: [],
connections: [],
fieldMeta: {},
// Offline queue
offlineQueue: [],
isOnline: navigator.onLine,
// Display settings
showConnectionLines: false, // Leitungen standardmäßig ausgeblendet
// Current modal state
currentCarrierId: null,
editCarrierId: null, // null = Add-Modus, ID = Edit-Modus (Hutschiene)
selectedTypeId: null,
editEquipmentId: null, // null = Add-Modus, ID = Edit-Modus
confirmCallback: null, // Callback für Bestätigungsdialog
editConnectionId: null, // null = Neu, ID = Edit
connectionEquipmentId: null, // Equipment für aktuelle Connection
connectionDirection: 'output', // 'output' oder 'input'
mediumTypes: null, // Kabeltypen aus DB (gecacht)
cachedTypeFields: null, // Equipment-Felder Cache
protectionDevices: [], // FI/RCD-Geräte für aktuelle Anlage
};
// ============================================
// INIT
// ============================================
function init() {
// Register Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('[PWA] Service Worker registered'))
.catch(err => console.error('[PWA] SW registration failed:', err));
}
// Check online status
window.addEventListener('online', () => {
App.isOnline = true;
hideOfflineBar();
syncOfflineChanges();
});
window.addEventListener('offline', () => {
App.isOnline = false;
showOfflineBar();
});
// Offline-Bar anzeigen falls nicht online
if (!navigator.onLine) {
showOfflineBar();
}
// Check stored auth
const storedToken = localStorage.getItem('kundenkarte_pwa_token');
const storedUser = localStorage.getItem('kundenkarte_pwa_user');
if (storedToken && storedUser) {
App.token = storedToken;
App.user = JSON.parse(storedUser);
// Letzten Zustand wiederherstellen
const lastState = JSON.parse(sessionStorage.getItem('kundenkarte_pwa_state') || 'null');
if (lastState && lastState.screen) {
if (lastState.customerId) {
App.customerId = lastState.customerId;
App.customerName = lastState.customerName || '';
App.customerAddress = lastState.customerAddress || '';
$('#customer-name').text(App.customerName);
}
if (lastState.anlageId) {
App.anlageId = lastState.anlageId;
App.anlageName = lastState.anlageName || '';
$('#anlage-name').text(App.anlageName);
}
// Screen wiederherstellen inkl. vollständiger History-Stack
// Damit Zurück-Button auch nach App-Suspend korrekt funktioniert
if (lastState.screen === 'editor' && App.anlageId) {
history.replaceState({ screen: 'search' }, '', '#search');
history.pushState({ screen: 'anlagen' }, '', '#anlagen');
showScreen('editor');
loadEditorData();
} else if (lastState.screen === 'anlagen' && App.customerId) {
history.replaceState({ screen: 'search' }, '', '#search');
showScreen('anlagen');
reloadAnlagen();
} else {
showScreen('search');
}
} else {
showScreen('search');
}
}
// Initialen History-State setzen (nur wenn kein Session-Restore)
if (!sessionStorage.getItem('kundenkarte_pwa_state')) {
history.replaceState({ screen: $('.screen.active').attr('id')?.replace('screen-', '') || 'login' }, '');
}
// Load offline queue
const storedQueue = localStorage.getItem('kundenkarte_offline_queue');
if (storedQueue) {
App.offlineQueue = JSON.parse(storedQueue);
updateSyncBadge();
}
// Bind events
bindEvents();
}
// ============================================
// EVENTS
// ============================================
function bindEvents() {
// Login
$('#login-form').on('submit', handleLogin);
$('#btn-logout').on('click', handleLogout);
// Navigation
$('#btn-back-search').on('click', () => history.back());
$('#btn-back-anlagen').on('click', () => history.back());
// Browser/Hardware Zurück-Button
window.addEventListener('popstate', function(e) {
// Wenn ein Modal offen ist: Modal schließen statt navigieren
const $activeModal = $('.modal.active');
if ($activeModal.length) {
$activeModal.removeClass('active');
// Aktuellen State wieder pushen (Navigation verhindern)
const currentScreen = $('.screen.active').attr('id')?.replace('screen-', '') || 'search';
history.pushState({ screen: currentScreen }, '', '#' + currentScreen);
return;
}
if (e.state && e.state.screen) {
showScreen(e.state.screen, true);
// Anlagen-Liste nachladen falls leer (z.B. nach Seiten-Refresh)
if (e.state.screen === 'anlagen' && App.customerId && !$('#anlagen-list').children('.pwa-tree-node, .contact-list, .contact-group').length) {
reloadAnlagen();
}
} else {
// Kein State = zurück zum Anfang
const activeScreen = App.token ? 'search' : 'login';
showScreen(activeScreen, true);
}
});
// Search
$('#search-customer').on('input', debounce(handleSearch, 300));
// Customer/Anlage selection
$('#customer-list').on('click', '.list-item', handleCustomerSelect);
$('#anlagen-list').on('click', '.pwa-tree-row', handleTreeNodeClick);
$('#anlagen-list').on('click', '.contact-group-header', handleContactGroupClick);
// Editor actions
$('#btn-add-panel').on('click', () => openModal('add-panel'));
$('#btn-save-panel').on('click', handleSavePanel);
$('#btn-toggle-wires').on('click', handleToggleWires);
$('#editor-content').on('click', '.btn-add-carrier', handleAddCarrier);
$('#editor-content').on('click', '.carrier-header', handleCarrierClick);
$('#btn-save-carrier').on('click', handleSaveCarrier);
$('#btn-delete-carrier').on('click', handleDeleteCarrierConfirm);
$('#editor-content').on('click', '.btn-add-equipment', handleAddEquipment);
$('#editor-content').on('click', '.equipment-block', handleEquipmentClick);
// Equipment modal
$('#type-grid').on('click', '.type-btn', handleTypeSelect);
$('#btn-eq-back').on('click', () => showEquipmentStep('type'));
$('#btn-save-equipment').on('click', handleSaveEquipment);
$('#btn-cancel-equipment').on('click', () => closeModal('add-equipment'));
$('#btn-delete-equipment').on('click', handleDeleteEquipmentConfirm);
// Equipment Detail Bottom-Sheet
$('#btn-detail-edit').on('click', openEditFromDetail);
$('#btn-detail-close').on('click', () => $('#sheet-equipment-detail').removeClass('active'));
$('#sheet-equipment-detail .sheet-overlay').on('click', () => $('#sheet-equipment-detail').removeClass('active'));
// Terminal/Connection - Klick auf einzelne Klemme (inkl. leere Terminals)
$('#editor-content').on('click', '.terminal-point, .terminal-empty', handleTerminalClick);
// Terminal-Labels anklickbar zum Bearbeiten
$('#editor-content').on('click', '.terminal-label-cell:not(.empty)', handleTerminalLabelClick);
$('#btn-save-connection').on('click', handleSaveConnection);
$('#btn-delete-connection').on('click', handleDeleteConnectionConfirm);
// Abgangsseite-Buttons
$('#conn-side-grid').on('click', '.side-btn', function() {
$('.side-btn').removeClass('selected');
$(this).addClass('selected');
});
// Medium-Type Change -> Spezifikationen laden
$('#conn-medium-type').on('change', handleMediumTypeChange);
// Bestätigungsdialog
$('#btn-confirm-ok').on('click', function() {
closeModal('confirm');
if (App.confirmCallback) {
App.confirmCallback();
App.confirmCallback = null;
}
});
// TE buttons
$('.te-btn').on('click', function() {
$('.te-btn').removeClass('selected');
$(this).addClass('selected');
});
// Modal close
$('.modal-close').on('click', function() {
$(this).closest('.modal').removeClass('active');
});
// Sync button
$('#btn-sync').on('click', handleRefresh);
}
// ============================================
// AUTH
// ============================================
async function handleLogin(e) {
e.preventDefault();
const user = $('#login-user').val().trim();
const pass = $('#login-pass').val();
if (!user || !pass) {
$('#login-error').text('Bitte Benutzername und Passwort eingeben');
return;
}
$('#login-error').text('');
try {
const response = await apiCall('pwa_auth.php', {
action: 'login',
username: user,
password: pass
});
if (response.success) {
App.token = response.token;
App.user = response.user;
localStorage.setItem('kundenkarte_pwa_token', response.token);
localStorage.setItem('kundenkarte_pwa_user', JSON.stringify(response.user));
showScreen('search');
} else {
$('#login-error').text(response.error || 'Login fehlgeschlagen');
}
} catch (err) {
$('#login-error').text('Verbindungsfehler');
}
}
function handleLogout() {
App.token = null;
App.user = null;
App.customerId = null;
App.customerName = '';
App.anlageId = null;
App.anlageName = '';
localStorage.removeItem('kundenkarte_pwa_token');
localStorage.removeItem('kundenkarte_pwa_user');
sessionStorage.removeItem('kundenkarte_pwa_state');
showScreen('login');
}
// ============================================
// SCREENS
// ============================================
function showScreen(name, skipHistory) {
$('.screen').removeClass('active');
$('#screen-' + name).addClass('active');
// Browser-History für Zurück-Button
if (!skipHistory) {
history.pushState({ screen: name }, '', '#' + name);
}
// State speichern für Refresh-Wiederherstellung
saveState(name);
// Zuletzt bearbeitete Kunden laden wenn Search-Screen
if (name === 'search') {
loadRecentCustomers();
}
}
// Zustand in sessionStorage speichern
function saveState(screen) {
const state = {
screen: screen || 'search',
customerId: App.customerId,
customerName: App.customerName,
customerAddress: App.customerAddress,
anlageId: App.anlageId,
anlageName: App.anlageName
};
sessionStorage.setItem('kundenkarte_pwa_state', JSON.stringify(state));
}
// ============================================
// ZULETZT BEARBEITETE KUNDEN
// ============================================
const MAX_RECENT_CUSTOMERS = 5;
function addToRecentCustomers(id, name, address) {
let recent = JSON.parse(localStorage.getItem('kundenkarte_recent_customers') || '[]');
// Entferne den Kunden falls schon vorhanden (wird neu an den Anfang gesetzt)
recent = recent.filter(c => c.id !== id);
// Füge an den Anfang hinzu
recent.unshift({
id: id,
name: name,
address: address,
timestamp: Date.now()
});
// Begrenze auf MAX_RECENT_CUSTOMERS
recent = recent.slice(0, MAX_RECENT_CUSTOMERS);
localStorage.setItem('kundenkarte_recent_customers', JSON.stringify(recent));
}
function loadRecentCustomers() {
const recent = JSON.parse(localStorage.getItem('kundenkarte_recent_customers') || '[]');
if (recent.length === 0) {
$('#recent-customers').addClass('hidden');
return;
}
$('#recent-customers').removeClass('hidden');
let html = '';
recent.forEach(c => {
html += `
<div class="list-item" data-id="${c.id}">
<div class="list-item-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
</div>
<div class="list-item-content">
<div class="list-item-title">${escapeHtml(c.name)}</div>
<div class="list-item-subtitle">${escapeHtml(c.address || '')}</div>
</div>
</div>
`;
});
$('#recent-list').html(html);
// Click-Handler für die Kunden
$('#recent-list .list-item').on('click', handleCustomerSelect);
}
// Anlagen-Liste für aktuellen Kunden neu laden
async function reloadAnlagen() {
if (!App.customerId) return;
$('#anlagen-list').html('<div class="loading-container"><div class="spinner"></div></div>');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_anlagen',
customer_id: App.customerId
});
if (response.success) {
renderAnlagenList(response.anlagen, response.contacts || []);
localStorage.setItem('kundenkarte_anlagen_' + App.customerId, JSON.stringify({
anlagen: response.anlagen,
contacts: response.contacts || []
}));
} else {
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
}
} catch (err) {
// Gecachte Daten verwenden
const cached = localStorage.getItem('kundenkarte_anlagen_' + App.customerId);
if (cached) {
const data = JSON.parse(cached);
renderAnlagenList(data.anlagen || data, data.contacts || []);
showToast('Offline - Zeige gecachte Daten', 'warning');
} else {
$('#anlagen-list').html('<div class="list-empty">Fehler beim Laden</div>');
}
}
}
// ============================================
// CUSTOMER SEARCH
// ============================================
async function handleSearch() {
const query = $('#search-customer').val().trim();
if (query.length < 2) {
$('#customer-list').html('<div class="list-empty">Mindestens 2 Zeichen eingeben...</div>');
return;
}
$('#customer-list').html('<div class="loading-container"><div class="spinner"></div></div>');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'search_customers',
query: query
});
if (response.success && response.customers) {
renderCustomerList(response.customers);
} else {
$('#customer-list').html('<div class="list-empty">Keine Kunden gefunden</div>');
}
} catch (err) {
$('#customer-list').html('<div class="list-empty">Fehler bei der Suche</div>');
}
}
function renderCustomerList(customers) {
if (!customers.length) {
$('#customer-list').html('<div class="list-empty">Keine Kunden gefunden</div>');
return;
}
let html = '';
customers.forEach(c => {
html += `
<div class="list-item" data-id="${c.id}">
<div class="list-item-icon">
<svg viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/></svg>
</div>
<div class="list-item-content">
<div class="list-item-title">${escapeHtml(c.name)}</div>
<div class="list-item-subtitle">${escapeHtml(c.town || '')}</div>
</div>
<svg class="list-item-arrow" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div>
`;
});
$('#customer-list').html(html);
}
// ============================================
// CUSTOMER & ANLAGE SELECTION
// ============================================
async function handleCustomerSelect() {
const id = $(this).data('id');
const name = $(this).find('.list-item-title').text();
const address = $(this).find('.list-item-subtitle').text();
App.customerId = id;
App.customerName = name;
App.customerAddress = address;
$('#customer-name').text(name);
// Zu "Zuletzt bearbeitet" hinzufügen
addToRecentCustomers(id, name, address);
showScreen('anlagen');
$('#anlagen-list').html('<div class="loading-container"><div class="spinner"></div></div>');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_anlagen',
customer_id: id
});
if (response.success) {
renderAnlagenList(response.anlagen, response.contacts || []);
// Cache für Offline
localStorage.setItem('kundenkarte_anlagen_' + id, JSON.stringify({
anlagen: response.anlagen,
contacts: response.contacts || []
}));
} else {
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
}
} catch (err) {
// Try cached
const cached = localStorage.getItem('kundenkarte_anlagen_' + id);
if (cached) {
const data = JSON.parse(cached);
renderAnlagenList(data.anlagen || data, data.contacts || []);
showToast('Offline - Zeige gecachte Daten', 'warning');
} else {
$('#anlagen-list').html('<div class="list-empty">Fehler beim Laden</div>');
}
}
}
function renderAnlagenList(anlagen, contacts) {
let html = '';
// Kontakt-Adressen (Gebäude/Standorte) als Liste
if (contacts && contacts.length) {
html += '<div class="contact-list">';
contacts.forEach(c => {
const subtitle = [c.address, c.town].filter(Boolean).join(', ');
html += `
<div class="contact-group" data-contact-id="${c.id}" data-customer-id="${App.customerId}">
<div class="contact-group-header">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
<div class="contact-group-info">
<div class="contact-group-name">${escapeHtml(c.name)}</div>
${subtitle ? '<div class="contact-group-address">' + escapeHtml(subtitle) + '</div>' : ''}
</div>
<span class="contact-group-count">${c.anlage_count}</span>
<svg class="contact-group-chevron" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div>
<div class="contact-anlagen-list"></div>
</div>
`;
});
html += '</div>';
}
// Kunden-Anlagen (ohne Kontaktzuweisung) als Baum darunter
if (anlagen && anlagen.length) {
if (contacts && contacts.length && App.customerAddress) {
html += `<div class="anlagen-section-label">${escapeHtml(App.customerName)} ${escapeHtml(App.customerAddress)}</div>`;
}
html += renderTreeNodes(anlagen, 0);
}
if (!html) {
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
return;
}
$('#anlagen-list').html(html);
}
// Baum-Knoten rekursiv rendern
function renderTreeNodes(nodes, level) {
let html = '';
nodes.forEach(a => {
const hasChildren = a.children && a.children.length > 0;
const isEquipment = a.can_have_equipment;
const isStructure = a.can_have_children && !isEquipment;
// Typ-Klasse für farbliche Unterscheidung
let typeClass = 'node-leaf';
if (isEquipment) typeClass = 'node-equipment';
else if (isStructure) typeClass = 'node-structure';
// Feld-Badges
let fieldsHtml = '';
if (a.fields && a.fields.length) {
fieldsHtml = '<div class="anlage-card-fields">';
a.fields.forEach(f => {
const style = f.color ? ` style="background:${f.color}"` : '';
fieldsHtml += `<span class="anlage-field-badge"${style}>${escapeHtml(f.value)}</span>`;
});
fieldsHtml += '</div>';
}
// Icons je nach Typ
let iconSvg;
if (isEquipment) {
// Schaltschrank/Verteiler
iconSvg = '<svg viewBox="0 0 24 24"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 7H7v2h2V7zm0 4H7v2h2v-2zm0 4H7v2h2v-2zm8-8h-6v2h6V7zm0 4h-6v2h6v-2zm0 4h-6v2h6v-2z"/></svg>';
} else if (isStructure) {
// Gebäude/Raum
iconSvg = '<svg viewBox="0 0 24 24"><path d="M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3zm0 2.84L18 12v7h-2v-6H8v6H6v-7l6-6.16z"/></svg>';
} else {
// Endgerät
iconSvg = '<svg viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg>';
}
html += `<div class="pwa-tree-node ${typeClass}${hasChildren ? ' has-children' : ''}" data-id="${a.id}" data-level="${level}">`;
html += `<div class="pwa-tree-row" style="padding-left:${12 + level * 20}px">`;
// Toggle-Chevron (nur bei Kindern)
if (hasChildren) {
html += '<svg class="pwa-tree-toggle" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
} else {
html += '<span class="pwa-tree-toggle-spacer"></span>';
}
// Icon
html += `<div class="pwa-tree-icon ${typeClass}">${iconSvg}</div>`;
// Inhalt
html += '<div class="pwa-tree-content">';
html += `<div class="pwa-tree-label">${escapeHtml(a.label || 'Anlage ' + a.id)}</div>`;
if (a.type) html += `<div class="pwa-tree-type">${escapeHtml(a.type)}</div>`;
html += fieldsHtml;
html += '</div>';
// Editor-Pfeil nur bei Equipment-Containern
if (isEquipment) {
html += '<svg class="pwa-tree-open" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
}
html += '</div>'; // pwa-tree-row
// Kinder (eingeklappt)
if (hasChildren) {
html += '<div class="pwa-tree-children">';
html += renderTreeNodes(a.children, level + 1);
html += '</div>';
}
html += '</div>'; // pwa-tree-node
});
return html;
}
// Baum-Knoten aufklappen/zuklappen
function handleTreeNodeClick(e) {
const $node = $(this).closest('.pwa-tree-node');
// Bei Klick auf Editor-Pfeil → Editor öffnen
if ($(e.target).closest('.pwa-tree-open').length) {
openAnlageEditor($node.data('id'), $node.find('> .pwa-tree-row .pwa-tree-label').first().text());
return;
}
// Bei Equipment-Containern: Klick auf Content öffnet Editor
if ($node.hasClass('node-equipment') && !$(e.target).closest('.pwa-tree-toggle').length) {
openAnlageEditor($node.data('id'), $node.find('> .pwa-tree-row .pwa-tree-label').first().text());
return;
}
// Toggle Kinder
if ($node.hasClass('has-children')) {
$node.toggleClass('expanded');
}
}
async function openAnlageEditor(id, name) {
App.anlageId = id;
App.anlageName = name;
$('#anlage-name').text(name);
showScreen('editor');
await loadEditorData();
}
// ============================================
// CONTACT GROUP EXPAND/COLLAPSE
// ============================================
async function handleContactGroupClick() {
const $group = $(this).closest('.contact-group');
const $list = $group.find('.contact-anlagen-list');
const contactId = $group.data('contact-id');
const customerId = $group.data('customer-id');
// Toggle anzeigen/verstecken
if ($group.hasClass('expanded')) {
$group.removeClass('expanded');
return;
}
$group.addClass('expanded');
$list.html('<div class="loading-container"><div class="spinner small"></div></div>');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_contact_anlagen',
customer_id: customerId,
contact_id: contactId
});
if (response.success && response.anlagen && response.anlagen.length) {
$list.html(renderTreeNodes(response.anlagen, 0));
} else {
$list.html('<div class="list-empty small">Keine Anlagen</div>');
}
} catch (err) {
$list.html('<div class="list-empty small">Fehler beim Laden</div>');
}
}
// ============================================
// EDITOR
// ============================================
async function loadEditorData() {
$('#editor-content').html('<div class="loading-container"><div class="spinner"></div><div class="text-muted">Lade Daten...</div></div>');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_anlage_data',
anlage_id: App.anlageId
});
if (response.success) {
App.panels = response.panels || [];
App.carriers = response.carriers || [];
App.equipment = response.equipment || [];
App.equipmentTypes = response.types || [];
App.outputs = response.outputs || [];
App.inputs = response.inputs || [];
App.connections = response.connections || [];
App.fieldMeta = response.field_meta || {};
// Cache for offline
localStorage.setItem('kundenkarte_data_' + App.anlageId, JSON.stringify({
panels: App.panels,
carriers: App.carriers,
equipment: App.equipment,
types: App.equipmentTypes,
outputs: App.outputs,
inputs: App.inputs,
connections: App.connections,
fieldMeta: App.fieldMeta
}));
// Protection devices laden (FI/RCD)
await loadProtectionDevices();
renderEditor();
}
} catch (err) {
// Try cached
const cached = localStorage.getItem('kundenkarte_data_' + App.anlageId);
if (cached) {
const data = JSON.parse(cached);
App.panels = data.panels || [];
App.carriers = data.carriers || [];
App.equipment = data.equipment || [];
App.equipmentTypes = data.types || [];
App.outputs = data.outputs || [];
App.inputs = data.inputs || [];
App.connections = data.connections || [];
App.fieldMeta = data.fieldMeta || {};
renderEditor();
showToast('Offline - Zeige gecachte Daten', 'warning');
} else {
$('#editor-content').html('<div class="list-empty">Fehler beim Laden</div>');
}
}
}
/**
* Lädt FI/RCD-Schutzgeräte für die aktuelle Anlage
*/
async function loadProtectionDevices() {
if (!App.anlageId || !App.isOnline) return;
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_protection_devices',
anlage_id: App.anlageId
});
if (response.success) {
App.protectionDevices = response.devices || [];
}
} catch (err) {
// Kein Fehler anzeigen - Dropdown bleibt leer
}
}
/**
* Befüllt das Protection-Dropdown im Equipment-Dialog
*/
function populateProtectionDropdown(selectedId) {
const $select = $('#equipment-protection');
$select.find('option:not(:first)').remove();
App.protectionDevices.forEach(device => {
const selected = device.id == selectedId ? ' selected' : '';
$select.append(`<option value="${device.id}"${selected}>${escapeHtml(device.display_label)}</option>`);
});
}
function renderEditor() {
if (!App.panels.length) {
$('#editor-content').html('<div class="list-empty">Noch keine Felder angelegt.<br>Tippe auf "+ Feld" um zu beginnen.</div>');
return;
}
let html = '';
App.panels.forEach(panel => {
const panelCarriers = App.carriers.filter(c => c.fk_panel == panel.id);
html += `
<div class="panel-card" data-panel-id="${panel.id}">
<div class="panel-header">
<div class="panel-title">${escapeHtml(panel.label || 'Feld ' + panel.id)}</div>
</div>
<div class="panel-body">
`;
panelCarriers.forEach(carrier => {
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrier.id);
carrierEquipment.sort((a, b) => (a.position_te || 0) - (b.position_te || 0));
const totalTe = parseInt(carrier.total_te) || 12;
const usedTe = carrierEquipment.reduce((sum, eq) => sum + (parseFloat(eq.width_te) || 1), 0);
const isFull = usedTe >= totalTe;
html += `
<div class="carrier-item" data-carrier-id="${carrier.id}">
<div class="carrier-header">
<span class="carrier-label">${escapeHtml(carrier.label || 'Hutschiene')}</span>
<span class="carrier-te">${usedTe}/${totalTe} TE</span>
</div>
<div class="carrier-content" style="grid-template-columns: repeat(${totalTe}, 1fr) auto">
`;
// === Zeile 1: Abgang-Labels OBEN (nur wenn Abgang oben ist) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln (Fallback auf widthTe)
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const topTerminalCount = getTerminalCount(type, 'top', widthTe);
// Gebündelter Abgang? (alle Terminals eines breiten Equipment belegt)
const bundledTop = eqTopOutputs.find(o => o.bundled_terminals === 'all');
if (bundledTop && widthTe > 1) {
// Gebündeltes Label (nur Text, OHNE Pfeil) in Zeile 1
const gridColStyle = posTe > 0
? `grid-row:1; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:1; grid-column: span ${widthTe}`;
const cableInfo = buildCableInfo(bundledTop);
html += `<span class="terminal-label-cell label-row-top bundled-label" style="${gridColStyle}" data-connection-id="${bundledTop.id}" data-equipment-id="${eq.id}" data-direction="output">`;
if (bundledTop.output_label) {
html += `<span class="terminal-label">${escapeHtml(bundledTop.output_label)}`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
}
html += `</span>`;
} else {
// Normale einzelne Labels pro Terminal - nur für tatsächliche Terminals
for (let t = 0; t < topTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const topOut = eqTopOutputs[t] || null;
if (topOut && topOut.output_label && (!topOut.bundled_terminals || widthTe <= 1)) {
const cableInfo = buildCableInfo(topOut);
html += `<span class="terminal-label-cell label-row-top" style="${style}" data-connection-id="${topOut.id}" data-equipment-id="${eq.id}" data-direction="output">`;
html += `<span class="terminal-label">${escapeHtml(topOut.output_label)}`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
html += `</span>`;
} else {
html += `<span class="terminal-label-cell empty label-row-top" style="${style}"></span>`;
}
}
// Leere Zellen für restliche TE-Breite
for (let t = topTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `<span class="terminal-label-cell empty label-row-top" style="${style}"></span>`;
}
}
});
// === Zeile 2: Terminal-Punkte OBEN (direkt am Equipment) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && i.target_terminal_id === 't1') : [];
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const topTerminalCount = getTerminalCount(type, 'top', widthTe);
// Gebündelter Abgang?
const bundledTop = eqTopOutputs.find(o => o.bundled_terminals === 'all');
// Nur so viele Terminal-Punkte wie tatsächlich konfiguriert
for (let t = 0; t < topTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:2;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const inp = eqInputs[t] || null;
const topOut = bundledTop || eqTopOutputs[t] || null;
if (bundledTop && widthTe > 1) {
// Gebündelter Abgang: Pfeil nur beim ersten Terminal, Rest leer
if (t === 0) {
const phaseColor = bundledTop.color || getPhaseColor(bundledTop.connection_type);
const bundledStyle = posTe > 0
? `grid-row:2; grid-column: ${posTe} / span ${topTerminalCount}`
: `grid-row:2; grid-column: span ${topTerminalCount}`;
html += `<span class="terminal-point terminal-output terminal-row-top bundled-output" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="top" data-connection-id="${bundledTop.id}" style="${bundledStyle}">`;
html += `<span class="terminal-arrow terminal-arrow-up" style="--arrow-color:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(bundledTop.connection_type || '')}</span>`;
html += `</span>`;
}
// Restliche Terminals überspringen (grid-column: span hat sie schon)
} else if (topOut && (!topOut.bundled_terminals || widthTe <= 1)) {
// Normaler Top-Output ODER bundled bei 1 TE (Bundle macht bei 1 TE keinen Unterschied)
const phaseColor = topOut.color || getPhaseColor(topOut.connection_type);
html += `<span class="terminal-point terminal-output terminal-row-top" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="top" data-connection-id="${topOut.id}" style="${style}">`;
html += `<span class="terminal-arrow terminal-arrow-up" style="--arrow-color:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(topOut.connection_type || '')}</span>`;
html += `</span>`;
} else if (inp) {
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
html += `<span class="terminal-point terminal-input terminal-row-top" data-equipment-id="${eq.id}" data-direction="input" data-terminal-position="top" data-connection-id="${inp.id}" style="${style}">`;
html += `<span class="terminal-dot" style="background:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(inp.connection_type || '')}</span>`;
html += `</span>`;
} else {
// Leerer Terminal - neutral, Position "top"
html += `<span class="terminal-point terminal-empty terminal-row-top" data-equipment-id="${eq.id}" data-terminal-position="top" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
html += `</span>`;
}
}
// Leere Zellen für restliche TE-Breite (ohne Terminal-Punkte)
for (let t = topTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:2;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `<span class="terminal-point terminal-empty no-terminal terminal-row-top" style="${style}"></span>`;
}
});
// === Zeile 3: Equipment-Blöcke ===
// Ermittle welche Equipment als Schutzgeräte dienen (werden von anderen referenziert)
const protectionDeviceIds = new Set();
carrierEquipment.forEach(eq => {
if (eq.fk_protection) protectionDeviceIds.add(eq.fk_protection);
});
carrierEquipment.forEach(eq => {
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const typeLabel = type?.label_short || type?.ref || '';
const blockColor = eq.block_color || type?.color || '#3498db';
const eqLabel = eq.label || '';
const blockFields = eq.block_label || '';
const showBlockFields = blockFields && blockFields !== typeLabel && blockFields !== (type?.ref || '');
const gridCol = posTe > 0
? `grid-row:3; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:3; grid-column: span ${widthTe}`;
// Schutzgruppen-Darstellung
let protectionStyle = '';
let protectionClass = '';
// 1. Ist dieses Equipment ein Schutzgerät? (wird von anderen referenziert)
const isProtectionDevice = protectionDeviceIds.has(eq.id);
if (isProtectionDevice) {
const deviceColor = getProtectionColor(eq.id);
protectionStyle = `border-left: 4px solid ${deviceColor};`;
protectionClass = ' is-protection-device';
}
// 2. Ist dieses Equipment einem Schutzgerät zugeordnet?
if (eq.fk_protection) {
const protectionColor = getProtectionColor(eq.fk_protection);
protectionStyle += `border-bottom: 3px solid ${protectionColor};`;
protectionClass += ' has-protection';
}
html += `
<div class="equipment-block${protectionClass}" data-equipment-id="${eq.id}" data-protection-id="${eq.fk_protection || ''}" style="background:${blockColor}; ${protectionStyle} ${gridCol}">
<span class="equipment-block-type">${escapeHtml(typeLabel)}</span>
${showBlockFields ? `<span class="equipment-block-value">${escapeHtml(blockFields)}</span>` : ''}
<span class="equipment-block-label">${escapeHtml(eqLabel)}</span>
</div>
`;
});
// +-Button in letzter Spalte (auto), Zeile 3
html += `
<button class="btn-add-equipment${isFull ? ' disabled' : ''}" data-carrier-id="${carrier.id}"${isFull ? ' disabled' : ''} style="grid-row:3; grid-column:-1">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
`;
// === Zeile 4: Terminal-Punkte UNTEN (direkt am Equipment) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : [];
const eqBottomInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && i.target_terminal_id === 't2') : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const bottomTerminalCount = getTerminalCount(type, 'bottom', widthTe);
// Gebündelter Abgang?
const bundledBottom = eqBottomOutputs.find(o => o.bundled_terminals === 'all');
// Nur so viele Terminal-Punkte wie tatsächlich konfiguriert
for (let t = 0; t < bottomTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:4;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const out = bundledBottom || eqBottomOutputs[t] || null;
const inp = eqBottomInputs[t] || null;
if (bundledBottom && widthTe > 1) {
// Gebündelter Abgang: Pfeil nur beim ersten Terminal, Rest leer
if (t === 0) {
const phaseColor = bundledBottom.color || getPhaseColor(bundledBottom.connection_type);
const bundledStyle = posTe > 0
? `grid-row:4; grid-column: ${posTe} / span ${bottomTerminalCount}`
: `grid-row:4; grid-column: span ${bottomTerminalCount}`;
html += `<span class="terminal-point terminal-output terminal-row-bottom bundled-output" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="bottom" data-connection-id="${bundledBottom.id}" style="${bundledStyle}">`;
html += `<span class="terminal-arrow terminal-arrow-down" style="--arrow-color:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(bundledBottom.connection_type || '')}</span>`;
html += `</span>`;
}
// Restliche Terminals überspringen (grid-column: span hat sie schon)
} else if (out && (!out.bundled_terminals || widthTe <= 1)) {
// Normaler Abgang ODER bundled bei 1 TE (Bundle macht bei 1 TE keinen Unterschied)
const phaseColor = out.color || getPhaseColor(out.connection_type);
html += `<span class="terminal-point terminal-output terminal-row-bottom" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="bottom" data-connection-id="${out.id}" style="${style}">`;
html += `<span class="terminal-arrow terminal-arrow-down" style="--arrow-color:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(out.connection_type || '')}</span>`;
html += `</span>`;
} else if (inp) {
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
html += `<span class="terminal-point terminal-input terminal-row-bottom" data-equipment-id="${eq.id}" data-direction="input" data-terminal-position="bottom" data-connection-id="${inp.id}" style="${style}">`;
html += `<span class="terminal-dot" style="background:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(inp.connection_type || '')}</span>`;
html += `</span>`;
} else {
// Leerer Terminal - neutral, Position "bottom"
html += `<span class="terminal-point terminal-empty terminal-row-bottom" data-equipment-id="${eq.id}" data-terminal-position="bottom" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
html += `</span>`;
}
}
// Leere Zellen für restliche TE-Breite (ohne Terminal-Punkte)
for (let t = bottomTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:4;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `<span class="terminal-point terminal-empty no-terminal terminal-row-bottom" style="${style}"></span>`;
}
});
// === Zeile 5: Abgang-Labels UNTEN (nur wenn Abgang unten ist) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const bottomTerminalCount = getTerminalCount(type, 'bottom', widthTe);
// Gebündelter Abgang?
const bundledBottom = eqBottomOutputs.find(o => o.bundled_terminals === 'all');
if (bundledBottom && widthTe > 1) {
// Gebündeltes Label (nur Text, OHNE Pfeil) in Zeile 5
const gridColStyle = posTe > 0
? `grid-row:5; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:5; grid-column: span ${widthTe}`;
const cableInfo = buildCableInfo(bundledBottom);
html += `<span class="terminal-label-cell label-row-bottom bundled-label" style="${gridColStyle}" data-connection-id="${bundledBottom.id}" data-equipment-id="${eq.id}" data-direction="output">`;
if (bundledBottom.output_label) {
html += `<span class="terminal-label">${escapeHtml(bundledBottom.output_label)}`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
}
html += `</span>`;
} else {
// Normale einzelne Labels pro Terminal - nur für tatsächliche Terminals
for (let t = 0; t < bottomTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:5;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const out = eqBottomOutputs[t] || null;
if (out && out.output_label && (!out.bundled_terminals || widthTe <= 1)) {
const cableInfo = buildCableInfo(out);
html += `<span class="terminal-label-cell label-row-bottom" style="${style}" data-connection-id="${out.id}" data-equipment-id="${eq.id}" data-direction="output">`;
html += `<span class="terminal-label">${escapeHtml(out.output_label)}`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
html += `</span>`;
} else {
html += `<span class="terminal-label-cell empty label-row-bottom" style="${style}"></span>`;
}
}
// Leere Zellen für restliche TE-Breite
for (let t = bottomTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:5;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `<span class="terminal-label-cell empty label-row-bottom" style="${style}"></span>`;
}
}
});
html += `</div></div>`;
});
html += `
<button class="btn-add-carrier" data-panel-id="${panel.id}">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Hutschiene hinzufügen
</button>
</div>
</div>
`;
});
$('#editor-content').html(html);
// Render connection lines (SVG overlay)
renderConnectionLines();
// Load type grid
renderTypeGrid();
}
/**
* Render SVG connection lines between equipment
* PWA uses different layout than desktop, so we calculate positions dynamically
*/
function renderConnectionLines() {
// Remove existing SVG overlays first
$('.connection-lines-svg').remove();
// Only render if setting is enabled
if (!App.showConnectionLines) {
return;
}
if (!App.connections || App.connections.length === 0) {
return;
}
// Desktop reference dimensions
const DESKTOP_TE_WIDTH = 56;
// Für jede Hutschiene ein SVG-Overlay erstellen
$('.carrier-card').each(function() {
const $carrier = $(this);
const carrierId = $carrier.find('.carrier-header').data('carrier-id');
const $content = $carrier.find('.carrier-content');
if (!$content.length) return;
// Equipment dieser Hutschiene finden
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrierId);
const equipmentIds = carrierEquipment.map(e => e.id);
// Carrier-Daten für Total-TE
const carrier = App.carriers.find(c => c.id == carrierId);
const totalTe = carrier ? (parseInt(carrier.total_te) || 12) : 12;
// PWA TE-Breite berechnen
const carrierWidth = $content.width();
const pwaTeWidth = carrierWidth / (totalTe + 1); // +1 für den Add-Button
// Scale factor: PWA-Breite / Desktop-Breite
const scaleX = pwaTeWidth / DESKTOP_TE_WIDTH;
const scaleY = scaleX * 0.8; // Y etwas weniger skalieren (PWA ist kompakter)
// Verbindungen filtern die zu dieser Hutschiene gehören
const carrierConnections = App.connections.filter(c =>
equipmentIds.includes(parseInt(c.fk_source)) ||
equipmentIds.includes(parseInt(c.fk_target))
);
if (carrierConnections.length === 0) return;
// SVG-Container erstellen falls nicht vorhanden
let $svg = $carrier.find('.connection-lines-svg');
if (!$svg.length) {
$svg = $('<svg class="connection-lines-svg"></svg>');
$carrier.css('position', 'relative');
$carrier.append($svg);
}
// SVG-Inhalt generieren
let svgContent = '';
carrierConnections.forEach(conn => {
if (!conn.path_data) return;
const color = conn.color || getPhaseColor(conn.connection_type);
// Transform path_data coordinates to PWA scale
const scaledPath = transformPathData(conn.path_data, scaleX, scaleY);
// Schatten-Pfad für bessere Sichtbarkeit
svgContent += `<path class="connection-shadow" d="${scaledPath}" />`;
// Hauptpfad
svgContent += `<path class="connection-line" d="${scaledPath}" style="stroke:${color}" data-connection-id="${conn.id}" />`;
// Label falls vorhanden
if (conn.output_label) {
const labelPos = getPathMidpoint(scaledPath);
if (labelPos) {
const labelWidth = Math.min(conn.output_label.length * 6 + 10, 80);
svgContent += `<rect x="${labelPos.x - labelWidth/2}" y="${labelPos.y - 8}" width="${labelWidth}" height="16" rx="3" fill="#1a1a1a" stroke="${color}" stroke-width="1"/>`;
svgContent += `<text x="${labelPos.x}" y="${labelPos.y + 4}" text-anchor="middle" fill="${color}" font-size="10" font-weight="bold">${escapeHtml(conn.output_label)}</text>`;
}
}
});
$svg.html(svgContent);
});
}
/**
* Transform path data coordinates by scale factors
*/
function transformPathData(pathData, scaleX, scaleY) {
if (!pathData) return '';
// Parse and transform coordinates
return pathData.replace(/([ML])\s*([\d.-]+)\s+([\d.-]+)/gi, function(match, cmd, x, y) {
const newX = (parseFloat(x) * scaleX).toFixed(1);
const newY = (parseFloat(y) * scaleY).toFixed(1);
return `${cmd} ${newX} ${newY}`;
});
}
/**
* Get midpoint of a path for label positioning
*/
function getPathMidpoint(pathData) {
if (!pathData) return null;
const points = [];
const regex = /[ML]\s*([\d.-]+)\s+([\d.-]+)/gi;
let match;
while ((match = regex.exec(pathData)) !== null) {
points.push({ x: parseFloat(match[1]), y: parseFloat(match[2]) });
}
if (points.length < 2) return null;
// Calculate midpoint along path
let totalLength = 0;
const segments = [];
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i-1].x;
const dy = points[i].y - points[i-1].y;
const len = Math.sqrt(dx*dx + dy*dy);
segments.push({ start: points[i-1], end: points[i], length: len });
totalLength += len;
}
const halfLength = totalLength / 2;
let accumulated = 0;
for (const seg of segments) {
if (accumulated + seg.length >= halfLength) {
const 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;
}
return { x: (points[0].x + points[points.length-1].x) / 2, y: (points[0].y + points[points.length-1].y) / 2 };
}
function renderTypeGrid() {
const categoryLabels = {
'automat': 'Leitungsschutz',
'schutz': 'Schutzgeräte',
'steuerung': 'Steuerung & Sonstiges',
'klemme': 'Klemmen'
};
const categoryOrder = ['automat', 'schutz', 'steuerung', 'klemme'];
// Typen nach Kategorie gruppieren
const groups = {};
App.equipmentTypes.forEach(type => {
const cat = type.category || 'steuerung';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(type);
});
let html = '';
categoryOrder.forEach(cat => {
if (!groups[cat] || !groups[cat].length) return;
html += `<div class="type-grid-category">${escapeHtml(categoryLabels[cat] || cat)}</div>`;
groups[cat].forEach(type => {
html += `
<button class="type-btn" data-type-id="${type.id}" data-width="${type.width_te || 1}">
<div class="type-btn-icon" style="color:${type.color || '#3498db'}">⚡</div>
<div class="type-btn-label">${escapeHtml(type.label_short || type.ref || type.label)}</div>
</button>
`;
});
});
$('#type-grid').html(html);
}
// ============================================
// WIRE DISPLAY TOGGLE
// ============================================
function handleToggleWires() {
App.showConnectionLines = !App.showConnectionLines;
// Update button appearance
const $btn = $('#btn-toggle-wires');
if (App.showConnectionLines) {
$btn.addClass('active');
$btn.attr('title', 'Leitungen ausblenden');
} else {
$btn.removeClass('active');
$btn.attr('title', 'Leitungen einblenden');
}
// Re-render connection lines
renderConnectionLines();
}
// ============================================
// PANEL (FELD) ACTIONS
// ============================================
async function handleSavePanel() {
const label = $('#panel-label').val().trim() || 'Feld ' + (App.panels.length + 1);
const data = {
action: 'create_panel',
anlage_id: App.anlageId,
label: label
};
closeModal('add-panel');
$('#panel-label').val('');
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.panels.push({ id: response.panel_id, label: label });
renderEditor();
showToast('Feld angelegt');
} else {
showToast(response.error || 'Fehler beim Anlegen', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
// Optimistic UI
App.panels.push({ id: 'temp_' + Date.now(), label: label });
renderEditor();
showToast('Feld wird synchronisiert...', 'warning');
}
}
// ============================================
// CARRIER (HUTSCHIENE) ACTIONS
// ============================================
function handleAddCarrier() {
const panelId = $(this).data('panel-id');
App.currentPanelId = panelId;
App.editCarrierId = null;
$('.te-btn').removeClass('selected');
$('#carrier-label').val('');
$('#carrier-modal-title').text('Hutschiene hinzufügen');
$('#btn-save-carrier').text('Hinzufügen');
$('#btn-delete-carrier').addClass('hidden');
openModal('add-carrier');
}
function handleCarrierClick() {
const carrierId = $(this).closest('.carrier-item').data('carrier-id');
const carrier = App.carriers.find(c => c.id == carrierId);
if (!carrier) return;
App.editCarrierId = carrierId;
App.currentPanelId = carrier.fk_panel;
// TE-Button vorselektieren
$('.te-btn').removeClass('selected');
$(`.te-btn[data-te="${carrier.total_te}"]`).addClass('selected');
$('#carrier-label').val(carrier.label || '');
$('#carrier-modal-title').text('Hutschiene bearbeiten');
$('#btn-save-carrier').text('Speichern');
$('#btn-delete-carrier').removeClass('hidden');
openModal('add-carrier');
}
async function handleSaveCarrier() {
const teBtn = $('.te-btn.selected');
if (!teBtn.length) {
showToast('Bitte Größe wählen', 'error');
return;
}
const totalTe = parseInt(teBtn.data('te'));
const label = $('#carrier-label').val().trim() || 'Hutschiene';
closeModal('add-carrier');
if (App.editCarrierId) {
// Update
const data = {
action: 'update_carrier',
carrier_id: App.editCarrierId,
total_te: totalTe,
label: label
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const carrier = App.carriers.find(c => c.id == App.editCarrierId);
if (carrier) {
carrier.total_te = totalTe;
carrier.label = label;
}
renderEditor();
showToast('Hutschiene aktualisiert', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
const carrier = App.carriers.find(c => c.id == App.editCarrierId);
if (carrier) {
carrier.total_te = totalTe;
carrier.label = label;
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
} else {
// Neu anlegen
const data = {
action: 'create_carrier',
panel_id: App.currentPanelId,
total_te: totalTe,
label: label
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.carriers.push({
id: response.carrier_id,
fk_panel: App.currentPanelId,
total_te: totalTe,
label: label
});
renderEditor();
showToast('Hutschiene angelegt');
} else {
showToast(response.error || 'Fehler beim Anlegen', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
App.carriers.push({
id: 'temp_' + Date.now(),
fk_panel: App.currentPanelId,
total_te: totalTe,
label: label
});
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
}
App.editCarrierId = null;
}
function handleDeleteCarrierConfirm() {
const carrierId = App.editCarrierId;
if (!carrierId) return;
const carrier = App.carriers.find(c => c.id == carrierId);
const eqCount = App.equipment.filter(e => e.fk_carrier == carrierId).length;
const msg = eqCount > 0
? `"${carrier?.label || 'Hutschiene'}" mit ${eqCount} Automat${eqCount > 1 ? 'en' : ''} wirklich löschen?`
: `"${carrier?.label || 'Hutschiene'}" wirklich löschen?`;
$('#confirm-title').text('Hutschiene löschen?');
$('#confirm-message').text(msg);
App.confirmCallback = () => deleteCarrier(carrierId);
closeModal('add-carrier');
openModal('confirm');
}
async function deleteCarrier(carrierId) {
const data = {
action: 'delete_carrier',
carrier_id: carrierId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId);
App.carriers = App.carriers.filter(c => c.id != carrierId);
renderEditor();
showToast('Hutschiene gelöscht', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId);
App.carriers = App.carriers.filter(c => c.id != carrierId);
renderEditor();
}
} else {
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId);
App.carriers = App.carriers.filter(c => c.id != carrierId);
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editCarrierId = null;
}
// ============================================
// EQUIPMENT (AUTOMAT) ACTIONS
// ============================================
/**
* Maximale zusammenhängende Lücke auf einem Carrier berechnen
*/
function getMaxGap(carrierId) {
const carrier = App.carriers.find(c => c.id == carrierId);
if (!carrier) return 0;
const totalTe = parseInt(carrier.total_te) || 12;
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrierId);
// Belegte Ranges ermitteln (1-basiert, Dezimal-Breiten)
const ranges = carrierEquipment.map(eq => ({
start: parseFloat(eq.position_te) || 1,
end: (parseFloat(eq.position_te) || 1) + (parseFloat(eq.width_te) || 1)
})).sort((a, b) => a.start - b.start);
// Maximale zusammenhängende Lücke
let maxGap = 0;
let pos = 1;
const railEnd = totalTe + 1;
for (const range of ranges) {
const gap = range.start - pos;
if (gap > maxGap) maxGap = gap;
if (range.end > pos) pos = range.end;
}
// Lücke nach letztem Element
const endGap = railEnd - pos;
if (endGap > maxGap) maxGap = endGap;
return maxGap;
}
function handleAddEquipment() {
const carrierId = $(this).data('carrier-id');
App.currentCarrierId = carrierId;
App.selectedTypeId = null;
App.editEquipmentId = null;
// Add-Modus: Titel, Typ-Grid freigeben
$('#equipment-modal-title').text('Automat hinzufügen');
$('#btn-save-equipment').text('Speichern');
$('#btn-delete-equipment').addClass('hidden');
$('#type-grid .type-btn').removeClass('selected');
// Typ-Buttons nach verfügbarem Platz filtern
const maxGap = getMaxGap(carrierId);
$('#type-grid .type-btn').each(function() {
const w = parseInt($(this).data('width')) || 1;
if (w > maxGap) {
$(this).addClass('disabled').prop('disabled', true);
} else {
$(this).removeClass('disabled').prop('disabled', false);
}
});
// Schritt 1 zeigen
showEquipmentStep('type');
openModal('add-equipment');
}
async function handleTypeSelect() {
$('.type-btn').removeClass('selected');
$(this).addClass('selected');
App.selectedTypeId = $(this).data('type-id');
const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId);
// Titel für Schritt 2
$('#eq-fields-title').text(type?.label_short || type?.label || 'Werte');
// Felder vom Server laden
await loadTypeFields(App.selectedTypeId, App.editEquipmentId);
// Protection-Dropdown befüllen (leer für neues Equipment)
if (!App.editEquipmentId) {
populateProtectionDropdown(null);
}
// Zu Schritt 2 wechseln
showEquipmentStep('fields');
}
/**
* Wechselt zwischen Schritt 1 (Typ) und Schritt 2 (Felder)
*/
function showEquipmentStep(step) {
if (step === 'type') {
$('#eq-step-type').removeClass('hidden');
$('#eq-step-fields').addClass('hidden');
} else {
$('#eq-step-type').addClass('hidden');
$('#eq-step-fields').removeClass('hidden');
}
}
/**
* Lädt Felder für einen Equipment-Typ vom Server
*/
async function loadTypeFields(typeId, equipmentId) {
$('#eq-dynamic-fields').html('<div class="loading-container"><div class="spinner small"></div></div>');
if (!App.isOnline) {
// Offline: Gecachte Felder verwenden falls vorhanden
const cached = App.cachedTypeFields && App.cachedTypeFields[typeId];
if (cached) {
renderDynamicFields(cached);
} else {
$('#eq-dynamic-fields').html('<div class="list-empty small">Offline - Felder nicht verfügbar</div>');
}
return;
}
try {
const data = { action: 'get_type_fields', type_id: typeId };
if (equipmentId) data.equipment_id = equipmentId;
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
// Felder cachen für Offline
if (!App.cachedTypeFields) App.cachedTypeFields = {};
App.cachedTypeFields[typeId] = response.fields;
renderDynamicFields(response.fields);
} else {
$('#eq-dynamic-fields').html('');
}
} catch (err) {
$('#eq-dynamic-fields').html('');
}
}
/**
* Rendert dynamische Felder basierend auf field_type aus der DB
*/
function renderDynamicFields(fields) {
if (!fields || !fields.length) {
$('#eq-dynamic-fields').html('');
$('#equipment-label').focus();
return;
}
let html = '';
fields.forEach(field => {
const req = field.required ? ' <span class="field-required">*</span>' : '';
const val = field.value || '';
html += `<div class="form-group">`;
html += `<label>${escapeHtml(field.label)}${req}</label>`;
switch (field.type) {
case 'select':
html += `<select name="eq_field_${field.code}" class="form-select">`;
html += `<option value="">--</option>`;
if (field.options) {
field.options.split('|').forEach(opt => {
opt = opt.trim();
if (!opt) return;
const selected = (opt === val.trim()) ? ' selected' : '';
html += `<option value="${escapeHtml(opt)}"${selected}>${escapeHtml(opt)}</option>`;
});
}
html += `</select>`;
break;
case 'number':
html += `<input type="number" name="eq_field_${field.code}" class="form-input" value="${escapeHtml(val)}">`;
break;
case 'checkbox':
const checked = val === '1' || val === 'true' ? ' checked' : '';
html += `<label class="checkbox-label"><input type="checkbox" name="eq_field_${field.code}" value="1"${checked}> ${escapeHtml(field.label)}</label>`;
break;
case 'textarea':
html += `<textarea name="eq_field_${field.code}" class="form-input" rows="3">${escapeHtml(val)}</textarea>`;
break;
default: // text
html += `<input type="text" name="eq_field_${field.code}" class="form-input" value="${escapeHtml(val)}">`;
}
html += `</div>`;
});
$('#eq-dynamic-fields').html(html);
}
/**
* Sammelt Feldwerte aus den dynamischen Formularfeldern
*/
function collectFieldValues() {
const fieldValues = {};
$('#eq-dynamic-fields [name^="eq_field_"]').each(function() {
const code = $(this).attr('name').replace('eq_field_', '');
if ($(this).is(':checkbox')) {
fieldValues[code] = $(this).is(':checked') ? '1' : '0';
} else {
const val = $(this).val();
if (val) fieldValues[code] = val;
}
});
return fieldValues;
}
async function handleSaveEquipment() {
if (!App.selectedTypeId) {
showToast('Bitte Typ wählen', 'error');
return;
}
// Pflichtfelder prüfen
let valid = true;
$('#eq-dynamic-fields select[name], #eq-dynamic-fields input[name]').each(function() {
const $field = $(this);
const $group = $field.closest('.form-group');
if ($group.find('.field-required').length && !$field.val()) {
$field.addClass('field-error');
valid = false;
} else {
$field.removeClass('field-error');
}
});
if (!valid) {
showToast('Pflichtfelder ausfüllen', 'error');
return;
}
const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId);
const label = $('#equipment-label').val().trim();
const fieldValues = collectFieldValues();
if (App.editEquipmentId) {
await saveEquipmentUpdate(label, fieldValues);
} else {
await saveEquipmentCreate(type, label, fieldValues);
}
}
/**
* Neuen Automaten anlegen
*/
async function saveEquipmentCreate(type, label, fieldValues) {
// Nächste freie Position berechnen (Lücken berücksichtigen)
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId);
const carrier = App.carriers.find(c => c.id == App.currentCarrierId);
const totalTe = parseInt(carrier?.total_te) || 12;
const eqWidth = parseFloat(type?.width_te) || 1;
// Belegte Ranges ermitteln (Dezimal-TE-Unterstützung)
const ranges = carrierEquipment.map(e => ({
start: parseFloat(e.position_te) || 1,
end: (parseFloat(e.position_te) || 1) + (parseFloat(e.width_te) || 1)
})).sort((a, b) => a.start - b.start);
// Erste Lücke finden die breit genug ist
let nextPos = 0;
let pos = 1;
const railEnd = totalTe + 1;
for (const range of ranges) {
if (pos + eqWidth <= range.start + 0.001) {
nextPos = pos;
break;
}
if (range.end > pos) pos = range.end;
}
if (nextPos === 0 && pos + eqWidth <= railEnd + 0.001) {
nextPos = pos;
}
if (nextPos === 0) {
showToast('Kein Platz frei', 'error');
return;
}
const fkProtection = parseInt($('#equipment-protection').val()) || 0;
const data = {
action: 'create_equipment',
carrier_id: App.currentCarrierId,
type_id: App.selectedTypeId,
label: label,
position_te: nextPos,
field_values: JSON.stringify(fieldValues),
fk_protection: fkProtection
};
closeModal('add-equipment');
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.equipment.push({
id: response.equipment_id,
fk_carrier: App.currentCarrierId,
fk_equipment_type: App.selectedTypeId,
label: response.label || label,
position_te: nextPos,
width_te: type?.width_te || 1,
field_values: fieldValues,
block_label: response.block_label || '',
block_color: response.block_color || type?.color || '',
fk_protection: fkProtection || null
});
renderEditor();
showToast('Automat angelegt', 'success');
} else {
showToast(response.error || 'Fehler beim Speichern', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
App.equipment.push({
id: 'temp_' + Date.now(),
fk_carrier: App.currentCarrierId,
fk_equipment_type: App.selectedTypeId,
label: label,
position_te: nextPos,
width_te: type?.width_te || 1,
field_values: fieldValues
});
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
}
/**
* Bestehenden Automaten aktualisieren
*/
async function saveEquipmentUpdate(label, fieldValues) {
const fkProtection = parseInt($('#equipment-protection').val()) || 0;
const data = {
action: 'update_equipment',
equipment_id: App.editEquipmentId,
label: label,
field_values: JSON.stringify(fieldValues),
fk_protection: fkProtection
};
closeModal('add-equipment');
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
// Lokale Daten aktualisieren
const eq = App.equipment.find(e => e.id == App.editEquipmentId);
if (eq) {
eq.label = label;
eq.field_values = fieldValues;
eq.block_label = response.block_label || '';
eq.block_color = response.block_color || eq.block_color;
eq.fk_protection = fkProtection || null;
}
renderEditor();
showToast('Automat aktualisiert', 'success');
} else {
showToast(response.error || 'Fehler beim Aktualisieren', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
// Optimistic UI
const eq = App.equipment.find(e => e.id == App.editEquipmentId);
if (eq) {
eq.label = label;
eq.field_values = fieldValues;
eq.fk_protection = fkProtection || null;
}
renderEditor();
}
} else {
queueOfflineAction(data);
const eq = App.equipment.find(e => e.id == App.editEquipmentId);
if (eq) {
eq.label = label;
eq.field_values = fieldValues;
eq.fk_protection = fkProtection || null;
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editEquipmentId = null;
}
function handleEquipmentClick() {
const eqId = $(this).data('equipment-id');
const eq = App.equipment.find(e => e.id == eqId);
if (!eq) return;
showEquipmentDetail(eq);
}
/**
* Equipment-Detail Bottom-Sheet anzeigen
*/
function showEquipmentDetail(eq) {
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const typeLabel = type?.label || type?.ref || 'Equipment';
const typeLabelShort = type?.label_short || type?.ref || '?';
const typeColor = eq.block_color || type?.color || '#3498db';
// Header
$('#detail-type-badge').css('background', typeColor).text(typeLabelShort);
$('#detail-title').text(eq.label || 'Automat ' + eq.id);
$('#detail-type-name').text(typeLabel);
// Body zusammenbauen
let html = '';
// Feldwerte mit Labels aus Feld-Metadaten
if (eq.field_values && Object.keys(eq.field_values).length) {
const typeMeta = App.fieldMeta ? App.fieldMeta[eq.fk_equipment_type] : null;
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Werte</div>';
html += '<div class="detail-field-list">';
if (typeMeta && typeMeta.length) {
// Felder in der konfigurierten Reihenfolge anzeigen
typeMeta.forEach(function(fm) {
const val = eq.field_values[fm.code];
if (val === '' || val === null || val === undefined) return;
html += `<div class="detail-field-row">
<span class="detail-field-label">${escapeHtml(fm.label)}</span>
<span class="detail-field-value">${escapeHtml(String(val))}</span>
</div>`;
});
} else {
// Fallback: Code als Label
for (const [key, val] of Object.entries(eq.field_values)) {
if (val === '' || val === null || val === undefined) continue;
html += `<div class="detail-field-row">
<span class="detail-field-label">${escapeHtml(key)}</span>
<span class="detail-field-value">${escapeHtml(String(val))}</span>
</div>`;
}
}
html += '</div></div>';
}
// Abgänge (Outputs)
const outputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id) : [];
if (outputs.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Abgänge</div>';
html += '<div class="detail-conn-list">';
outputs.forEach(o => {
const color = o.color || getPhaseColor(o.connection_type);
const label = o.output_label || o.connection_type || 'Abgang';
const meta = [o.medium_type, o.medium_spec, o.medium_length].filter(Boolean).join(' · ');
const arrow = o.is_top ? '&#9650;' : '&#9660;';
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${color}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">${escapeHtml(label)}</div>
${meta ? '<div class="detail-conn-meta">' + escapeHtml(meta) + '</div>' : ''}
</div>
<span class="detail-conn-arrow">${arrow}</span>
</div>`;
});
html += '</div></div>';
}
// Einspeisungen (Inputs)
const inputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id) : [];
if (inputs.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Einspeisungen</div>';
html += '<div class="detail-conn-list">';
inputs.forEach(i => {
const color = i.color || getPhaseColor(i.connection_type);
const label = i.output_label || i.connection_type || 'Einspeisung';
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${color}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">${escapeHtml(label)}</div>
</div>
<span class="detail-conn-arrow">&#9660;</span>
</div>`;
});
html += '</div></div>';
}
// Verbindungen zu anderen Equipment (connections mit path_data von Website)
const connectionsFrom = App.connections ? App.connections.filter(c => c.fk_source == eq.id) : [];
const connectionsTo = App.connections ? App.connections.filter(c => c.fk_target == eq.id) : [];
if (connectionsFrom.length || connectionsTo.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Verbindungen</div>';
html += '<div class="detail-conn-list">';
// Verbindungen VON diesem Equipment
connectionsFrom.forEach(c => {
const targetEq = App.equipment.find(e => e.id == c.fk_target);
const targetLabel = targetEq?.label || targetEq?.block_label || 'Equipment ' + c.fk_target;
const color = c.color || getPhaseColor(c.connection_type);
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${color}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">→ ${escapeHtml(targetLabel)}</div>
<div class="detail-conn-meta">${escapeHtml(c.connection_type || '')}</div>
</div>
</div>`;
});
// Verbindungen ZU diesem Equipment
connectionsTo.forEach(c => {
const sourceEq = App.equipment.find(e => e.id == c.fk_source);
const sourceLabel = sourceEq?.label || sourceEq?.block_label || 'Equipment ' + c.fk_source;
const color = c.color || getPhaseColor(c.connection_type);
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${color}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">← ${escapeHtml(sourceLabel)}</div>
<div class="detail-conn-meta">${escapeHtml(c.connection_type || '')}</div>
</div>
</div>`;
});
html += '</div></div>';
}
// Schutzgerät-Zuordnung (fk_protection)
if (eq.fk_protection) {
const protectionEq = App.equipment.find(e => e.id == eq.fk_protection);
const protectionColor = getProtectionColor(eq.fk_protection);
if (protectionEq) {
const protLabel = protectionEq.label || protectionEq.block_label || 'Schutzgerät';
const protType = App.equipmentTypes.find(t => t.id == protectionEq.fk_equipment_type);
const protTypeLabel = protType?.label_short || protType?.label || '';
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Schutzeinrichtung</div>';
html += '<div class="detail-conn-list">';
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${protectionColor}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">${escapeHtml(protLabel)}</div>
<div class="detail-conn-meta">${escapeHtml(protTypeLabel)} ${escapeHtml(protectionEq.block_label || '')}</div>
</div>
</div>`;
html += '</div></div>';
}
}
// Geschützte Geräte (wenn dieses Equipment ein Schutzgerät ist)
const protectedEquipment = App.equipment.filter(e => e.fk_protection == eq.id);
if (protectedEquipment.length) {
const protectionColor = getProtectionColor(eq.id);
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Schützt</div>';
html += '<div class="detail-conn-list">';
protectedEquipment.forEach(pe => {
const peLabel = pe.label || pe.block_label || 'Equipment';
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${protectionColor}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">${escapeHtml(peLabel)}</div>
</div>
</div>`;
});
html += '</div></div>';
}
// Position-Info
const carrier = App.carriers.find(c => c.id == eq.fk_carrier);
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Position</div>';
html += '<div class="detail-field-list">';
if (carrier) {
html += `<div class="detail-field-row">
<span class="detail-field-label">Hutschiene</span>
<span class="detail-field-value">${escapeHtml(carrier.label || 'Hutschiene')}</span>
</div>`;
}
html += `<div class="detail-field-row">
<span class="detail-field-label">TE-Position</span>
<span class="detail-field-value">${eq.position_te || ''} (${eq.width_te || 1} TE breit)</span>
</div>`;
html += '</div></div>';
if (!html) {
html = '<p class="text-muted text-center">Keine Details vorhanden</p>';
}
$('#detail-body').html(html);
// Bearbeiten-Button: Equipment-ID merken
App.detailEquipmentId = eq.id;
$('#sheet-equipment-detail').addClass('active');
}
/**
* Detail-Sheet schließen und Edit-Modal öffnen
*/
async function openEditFromDetail() {
const eqId = App.detailEquipmentId;
$('#sheet-equipment-detail').removeClass('active');
const eq = App.equipment.find(e => e.id == eqId);
if (!eq) return;
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
App.editEquipmentId = eqId;
App.currentCarrierId = eq.fk_carrier;
App.selectedTypeId = eq.fk_equipment_type;
$('#eq-fields-title').text(type?.label_short || type?.label || 'Bearbeiten');
$('#btn-save-equipment').text('Aktualisieren');
$('#btn-delete-equipment').removeClass('hidden');
$('#equipment-label').val(eq.label || '');
await loadTypeFields(eq.fk_equipment_type, eqId);
// Protection-Dropdown befüllen mit aktuellem Wert
populateProtectionDropdown(eq.fk_protection);
showEquipmentStep('fields');
openModal('add-equipment');
}
/**
* Bestätigungsdialog vor Equipment-Löschung
*/
function handleDeleteEquipmentConfirm() {
const eqId = App.editEquipmentId;
if (!eqId) return;
const eq = App.equipment.find(e => e.id == eqId);
const type = App.equipmentTypes.find(t => t.id == eq?.fk_equipment_type);
const typeName = type?.label_short || type?.ref || 'Automat';
const eqLabel = eq?.label ? ` "${eq.label}"` : '';
$('#confirm-title').text('Automat löschen?');
$('#confirm-message').text(`${typeName}${eqLabel} wirklich löschen?`);
// Callback für OK-Button
App.confirmCallback = () => deleteEquipment(eqId);
closeModal('add-equipment');
openModal('confirm');
}
/**
* Equipment löschen (nach Bestätigung)
*/
async function deleteEquipment(eqId) {
const data = {
action: 'delete_equipment',
equipment_id: eqId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.equipment = App.equipment.filter(e => e.id != eqId);
// Zugehörige Abgänge entfernen
App.outputs = App.outputs.filter(o => o.fk_source != eqId);
renderEditor();
showToast('Automat gelöscht', 'success');
} else {
showToast(response.error || 'Fehler beim Löschen', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.id != eqId);
App.outputs = App.outputs.filter(o => o.fk_source != eqId);
renderEditor();
}
} else {
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.id != eqId);
App.outputs = App.outputs.filter(o => o.fk_source != eqId);
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editEquipmentId = null;
}
/**
* Output-Terminal HTML erzeugen (Pfeil + Labels)
* @param {object} out - Connection-Objekt
* @param {string} phaseColor - Farbe der Phase
* @param {string} dir - 'up' oder 'down'
*/
function renderOutputLabel(out, phaseColor, dir) {
// Pfeil statt Punkt - Pfeil immer am Automaten (zwischen Block und Label)
const arrowClass = dir === 'up' ? 'terminal-arrow-up' : 'terminal-arrow-down';
const arrowHtml = `<span class="terminal-arrow ${arrowClass}" style="--arrow-color:${phaseColor}"></span>`;
let labelHtml = '';
if (out.output_label) {
let cableInfo = '';
if (out.medium_type) cableInfo = out.medium_type;
if (out.medium_spec) cableInfo += ' ' + out.medium_spec;
if (out.medium_length) cableInfo += ' (' + out.medium_length + ')';
labelHtml = `<span class="terminal-label">${escapeHtml(out.output_label)}`;
if (cableInfo) labelHtml += `<br><small class="cable-info">${escapeHtml(cableInfo.trim())}</small>`;
labelHtml += `</span>`;
} else {
labelHtml = `<span class="terminal-phase">${escapeHtml(out.connection_type || '')}</span>`;
}
// Oben: Label zuerst, dann Pfeil (Pfeil zeigt zum Automaten darunter)
// Unten: Pfeil zuerst, dann Label (Pfeil zeigt zum Automaten darüber)
if (dir === 'up') {
return labelHtml + arrowHtml;
}
return arrowHtml + labelHtml;
}
// ============================================
// CONNECTION (TERMINAL) ACTIONS
// ============================================
/**
* Kabel-Info aus Connection zusammenbauen
*/
function buildCableInfo(conn) {
const parts = [];
if (conn.medium_type) parts.push(conn.medium_type);
if (conn.medium_spec) parts.push(conn.medium_spec);
if (conn.medium_length) parts.push('(' + conn.medium_length + ')');
return parts.join(' ');
}
/**
* Click-Handler für Terminal-Labels (zum Bearbeiten)
*/
function handleTerminalLabelClick(e) {
e.stopPropagation();
const $cell = $(this);
const connId = $cell.data('connection-id');
const eqId = $cell.data('equipment-id');
const direction = $cell.data('direction') || 'output';
if (!connId) return;
// Connection aus App-State finden
const conn = direction === 'input'
? App.inputs.find(i => i.id == connId)
: App.outputs.find(o => o.id == connId);
if (!conn) return;
// Terminal-Position ermitteln
const terminalPosition = conn.is_top ? 'top' : 'bottom';
// Connection-Bearbeitungsmodus mit vorhandenen Daten
openEditConnectionDialog(eqId, direction, terminalPosition, conn);
}
/**
* Connection-Dialog im Bearbeitungsmodus öffnen
*/
async function openEditConnectionDialog(eqId, direction, terminalPosition, conn) {
App.connectionEquipmentId = eqId;
App.connectionDirection = direction;
App.connectionTerminalPosition = terminalPosition;
App.editConnectionId = conn.id;
renderTypeSelect(direction, conn.connection_type || '');
$('#connection-modal-title').text(direction === 'input' ? 'Anschlusspunkt bearbeiten' : 'Abgang bearbeiten');
$('#btn-delete-connection').removeClass('hidden');
$('#conn-color').val(conn.color || '#3498db');
$('#conn-label').val(conn.output_label || '');
$('#conn-medium-length').val(conn.medium_length || '');
// Medium-Typen laden und Select befüllen
await loadMediumTypes();
renderMediumTypeSelect(conn.medium_type || '');
// Medium-Spec laden falls Typ gewählt
if (conn.medium_type) {
// Trigger change um Specs zu laden, dann Wert setzen
handleMediumTypeChange();
if (conn.medium_spec) {
$('#conn-medium-spec').val(conn.medium_spec);
}
} else {
$('#conn-medium-spec').html('<option value="">-- Zuerst Kabeltyp wählen --</option>');
}
// Side-Button auf aktuelle Terminal-Position setzen
setSideButton(terminalPosition);
// Side-Buttons immer zeigen
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
const eq = App.equipment ? App.equipment.find(e => e.id == eqId) : null;
const type = eq ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
// terminalPosition kommt bereits als Parameter
const terminalCount = getTerminalCount(type, terminalPosition, parseFloat(eq?.width_te) || 1);
if (direction === 'output' && terminalCount > 1) {
$('#conn-bundle-fields').removeClass('hidden');
$('#conn-bundle-all').prop('checked', conn.bundled_terminals === 'all');
} else {
$('#conn-bundle-fields').addClass('hidden');
}
openModal('connection');
}
// Phasen-Optionen wie auf der Website
const INPUT_PHASES = ['L1', 'L2', 'L3', '3P', '3P+N', 'PE'];
const OUTPUT_PHASES = ['LN', 'N', '3P+N', 'PE', 'DATA'];
/**
* Terminal-Anzahl aus Equipment-Typ ermitteln
* @param {object} type - Equipment-Typ mit terminals_config
* @param {string} position - 'top' oder 'bottom'
* @param {number} fallback - Fallback-Wert (normalerweise width_te)
* @returns {number} Anzahl der Terminals
*/
function getTerminalCount(type, position, fallback) {
if (!type || !type.terminals_config) return fallback;
try {
const config = typeof type.terminals_config === 'string'
? JSON.parse(type.terminals_config)
: type.terminals_config;
if (config.terminals && Array.isArray(config.terminals)) {
return config.terminals.filter(t => t.pos === position).length;
}
} catch (e) {
// JSON-Parse-Fehler ignorieren
}
return fallback;
}
/**
* Phasenfarbe ermitteln (DIN VDE Farben)
*/
function getPhaseColor(type) {
const colors = {
'L1': '#8B4513', 'L2': '#1a1a1a', 'L3': '#666',
'N': '#0066cc', 'PE': '#27ae60',
'LN': '#8B4513', // Phase+Neutral - braun wie L1
'L1N': '#8B4513', 'L2N': '#1a1a1a', 'L3N': '#666', // Legacy
'3P': '#e74c3c', '3P+N': '#e74c3c', 'DATA': '#9b59b6'
};
return colors[type] || '#888';
}
/**
* Schutzgruppen-Farbe ermitteln (eindeutig pro protection_id)
*/
const protectionColorCache = {};
function getProtectionColor(protectionId) {
if (!protectionId) return null;
if (protectionColorCache[protectionId]) return protectionColorCache[protectionId];
// Helle, gut sichtbare Farben für Schutzgruppen
const colors = [
'#e74c3c', // Rot
'#3498db', // Blau
'#f39c12', // Orange
'#9b59b6', // Lila
'#1abc9c', // Türkis
'#e91e63', // Pink
'#00bcd4', // Cyan
'#ff5722', // Deep Orange
];
const idx = Object.keys(protectionColorCache).length % colors.length;
protectionColorCache[protectionId] = colors[idx];
return colors[idx];
}
/**
* Abgangsseite-Button setzen
*/
function setSideButton(side) {
$('.side-btn').removeClass('selected');
$(`.side-btn[data-side="${side}"]`).addClass('selected');
}
/**
* Gewählte Abgangsseite auslesen
*/
function getSelectedSide() {
return $('.side-btn.selected').data('side') || 'bottom';
}
/**
* Typ-Select je nach Richtung befüllen (wie Website)
*/
function renderTypeSelect(direction, selectedType) {
const phases = direction === 'input' ? INPUT_PHASES : OUTPUT_PHASES;
let html = '<option value="">-- Kein Typ --</option>';
phases.forEach(p => {
const sel = (p === selectedType) ? ' selected' : '';
html += `<option value="${p}"${sel}>${p}</option>`;
});
$('#conn-type').html(html);
}
/**
* Klick auf Terminal-Zelle
* Bei vorhandener Verbindung: direkt bearbeiten
* Bei leerem Terminal: Kontextmenü mit Wahl Input/Output
*/
function handleTerminalClick(e) {
e.stopPropagation();
const $point = $(this);
const eqId = $point.data('equipment-id');
const terminalPosition = $point.data('terminal-position'); // 'top' oder 'bottom'
const connId = $point.data('connection-id');
// Bestehende Verbindung? -> Direkt bearbeiten
if (connId) {
const direction = $point.data('direction');
App.connectionEquipmentId = eqId;
App.connectionDirection = direction;
App.connectionTerminalPosition = terminalPosition;
App.editConnectionId = connId;
$('#connection-modal-title').text('Verbindung bearbeiten');
$('#btn-delete-connection').removeClass('hidden');
const conn = direction === 'input'
? App.inputs.find(i => i.id == connId)
: App.outputs.find(o => o.id == connId);
if (conn) {
renderTypeSelect(direction, conn.connection_type);
$('#conn-color').val(conn.color || '#3498db');
$('#conn-label').val(conn.output_label || '');
$('#conn-medium-length').val(conn.medium_length || '');
// Medium-Typen laden und Select befüllen
loadMediumTypes().then(() => {
renderMediumTypeSelect(conn.medium_type || '');
// Spezifikation Select befüllen basierend auf gewähltem Typ
handleMediumTypeChange();
// Gespeicherte Spezifikation setzen
if (conn.medium_spec) {
$('#conn-medium-spec').val(conn.medium_spec);
}
});
// Terminal-Position aus gespeicherter Verbindung
const connIsTop = conn.is_top || (conn.target_terminal_id === 't1');
setSideButton(connIsTop ? 'top' : 'bottom');
}
// Side-Buttons immer zeigen (Automaten haben keine feste Richtung)
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
const eq = App.equipment ? App.equipment.find(e => e.id == eqId) : null;
const type = eq ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
const connIsTop = conn && (conn.is_top || conn.target_terminal_id === 't1');
const termCount = getTerminalCount(type, connIsTop ? 'top' : 'bottom', parseFloat(eq?.width_te) || 1);
if (direction === 'output' && termCount > 1) {
$('#conn-bundle-fields').removeClass('hidden');
$('#conn-bundle-all').prop('checked', conn && conn.bundled_terminals === 'all');
} else {
$('#conn-bundle-fields').addClass('hidden');
}
openModal('connection');
} else {
// Leerer Terminal -> Kontextmenü anzeigen
showTerminalContextMenu(e, eqId, terminalPosition);
}
}
/**
* Kontextmenü für leere Terminals: Wahl zwischen Anschlusspunkt und Abgang
*/
function showTerminalContextMenu(e, eqId, terminalPosition) {
// Altes Menü entfernen
$('.terminal-context-menu').remove();
const x = e.touches ? e.touches[0].clientX : e.clientX;
const y = e.touches ? e.touches[0].clientY : e.clientY;
const html = `
<div class="terminal-context-menu" style="position:fixed;left:${x}px;top:${y}px;z-index:10001;">
<div class="tcm-item tcm-input" data-type="input">
<span class="tcm-icon" style="color:#f39c12;">▼</span>
<span>Anschlusspunkt (L1/L2/L3)</span>
</div>
<div class="tcm-item tcm-output" data-type="output">
<span class="tcm-icon" style="color:#3498db;">▲</span>
<span>Abgang (Verbraucher)</span>
</div>
</div>
`;
$('body').append(html);
// Click Handler
$('.tcm-item').on('click', function() {
const direction = $(this).data('type');
$('.terminal-context-menu').remove();
openConnectionDialog(eqId, direction, terminalPosition);
});
// Schließen bei Klick außerhalb
setTimeout(() => {
$(document).one('click', () => $('.terminal-context-menu').remove());
}, 10);
}
/**
* Medium-Typen (Kabeltypen) aus DB laden und cachen
*/
async function loadMediumTypes() {
if (App.mediumTypes) return App.mediumTypes;
try {
// Nutze pwa_api.php für Token-basierte Authentifizierung
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_medium_types'
});
console.log('[PWA] loadMediumTypes response:', response);
if (response.success && response.groups) {
App.mediumTypes = response.groups;
// Cache für Offline
localStorage.setItem('kundenkarte_medium_types', JSON.stringify(response.groups));
return response.groups;
} else {
console.warn('[PWA] loadMediumTypes: no groups in response');
}
} catch (err) {
console.error('[PWA] loadMediumTypes error:', err);
// Fallback auf Cache
const cached = localStorage.getItem('kundenkarte_medium_types');
if (cached) {
App.mediumTypes = JSON.parse(cached);
return App.mediumTypes;
}
}
// Fallback auf statische Liste
return null;
}
/**
* Medium-Type Select befüllen
*/
function renderMediumTypeSelect(selectedValue) {
const groups = App.mediumTypes;
let html = '<option value="">-- Auswählen --</option>';
if (groups && groups.length > 0) {
groups.forEach(group => {
html += `<optgroup label="${escapeHtml(group.category_label)}">`;
group.types.forEach(t => {
const selected = (selectedValue === t.ref) ? ' selected' : '';
const specs = t.available_specs ? ` data-specs='${JSON.stringify(t.available_specs)}'` : '';
const defSpec = t.default_spec ? ` data-default="${escapeHtml(t.default_spec)}"` : '';
html += `<option value="${escapeHtml(t.ref)}"${selected}${specs}${defSpec}>${escapeHtml(t.label)}</option>`;
});
html += '</optgroup>';
});
} else {
// Fallback auf statische Liste
['NYM-J', 'NYY-J', 'H07V-K', 'CAT6', 'CAT7'].forEach(t => {
const selected = (selectedValue === t) ? ' selected' : '';
html += `<option value="${t}"${selected}>${t}</option>`;
});
}
$('#conn-medium-type').html(html);
}
/**
* Medium-Type Change Handler - Spezifikationen laden
*/
function handleMediumTypeChange() {
const $option = $('#conn-medium-type option:selected');
const specs = $option.data('specs');
const defaultSpec = $option.data('default');
let html = '<option value="">-- Auswählen --</option>';
if (specs && specs.length > 0) {
specs.forEach(spec => {
const selected = (spec === defaultSpec) ? ' selected' : '';
html += `<option value="${escapeHtml(spec)}"${selected}>${escapeHtml(spec)}</option>`;
});
} else {
html = '<option value="">-- Keine Auswahl --</option>';
}
$('#conn-medium-spec').html(html);
}
/**
* Verbindungs-Dialog öffnen (nach Auswahl Input/Output)
*/
async function openConnectionDialog(eqId, direction, terminalPosition) {
App.connectionEquipmentId = eqId;
App.connectionDirection = direction;
App.connectionTerminalPosition = terminalPosition;
App.editConnectionId = null;
renderTypeSelect(direction, '');
$('#connection-modal-title').text(direction === 'input' ? 'Anschlusspunkt' : 'Abgang');
$('#btn-delete-connection').addClass('hidden');
$('#conn-color').val('#3498db');
$('#conn-label').val('');
$('#conn-medium-length').val('');
// Medium-Typen laden und Select befüllen
await loadMediumTypes();
renderMediumTypeSelect('');
$('#conn-medium-spec').html('<option value="">-- Zuerst Kabeltyp wählen --</option>');
// Side-Button auf aktuelle Terminal-Position setzen
setSideButton(terminalPosition || 'bottom');
// Side-Buttons immer zeigen (Automaten haben keine feste Richtung)
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
const eq = App.equipment ? App.equipment.find(e => e.id == eqId) : null;
const type = eq ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
const termCount = getTerminalCount(type, terminalPosition || 'bottom', parseFloat(eq?.width_te) || 1);
if (direction === 'output' && termCount > 1) {
$('#conn-bundle-fields').removeClass('hidden');
$('#conn-bundle-all').prop('checked', false);
} else {
$('#conn-bundle-fields').addClass('hidden');
}
openModal('connection');
}
/**
* Connection speichern (Neu oder Update)
*/
async function handleSaveConnection() {
const connectionType = $('#conn-type').val() || '';
const color = $('#conn-color').val() || '#3498db';
const outputLabel = $('#conn-label').val().trim();
const isOutput = App.connectionDirection === 'output';
const mediumType = isOutput ? ($('#conn-medium-type').val().trim() || '') : '';
const mediumSpec = isOutput ? ($('#conn-medium-spec').val().trim() || '') : '';
const mediumLength = isOutput ? ($('#conn-medium-length').val().trim() || '') : '';
const bundledTerminals = isOutput && $('#conn-bundle-all').is(':checked') ? 'all' : '';
// Terminal-Position: t1=oben, t2=unten (gilt für Input UND Output)
// Bei Bearbeitung: Side-Button-Auswahl verwenden, sonst die ursprüngliche Position
const terminalPosition = App.editConnectionId ? getSelectedSide() : (App.connectionTerminalPosition || 'bottom');
const isTop = terminalPosition === 'top';
const terminalId = isTop ? 't1' : 't2';
// Für Output: source_terminal
const sourceTerminalId = isOutput ? terminalId : '';
const sourceTerminal = isOutput ? (isTop ? 'top' : 'output') : '';
// Für Input: target_terminal
const targetTerminalId = !isOutput ? terminalId : '';
closeModal('connection');
if (App.editConnectionId) {
// Update
const data = {
action: 'update_connection',
connection_id: App.editConnectionId,
connection_type: connectionType,
color: color,
output_label: outputLabel,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
source_terminal: sourceTerminal,
source_terminal_id: sourceTerminalId,
bundled_terminals: bundledTerminals
};
const updateLocal = (conn) => {
if (!conn) return;
conn.connection_type = connectionType;
conn.color = color;
conn.output_label = outputLabel;
conn.medium_type = mediumType;
conn.medium_spec = mediumSpec;
conn.medium_length = mediumLength;
conn.bundled_terminals = bundledTerminals;
if (isOutput) {
conn.is_top = isTop;
conn.source_terminal_id = sourceTerminalId;
}
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const list = App.connectionDirection === 'input' ? App.inputs : App.outputs;
updateLocal(list.find(c => c.id == App.editConnectionId));
renderEditor();
showToast('Verbindung aktualisiert', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
queueOfflineAction(data);
showToast('Wird synchronisiert...', 'warning');
}
} else {
queueOfflineAction(data);
const list = App.connectionDirection === 'input' ? App.inputs : App.outputs;
updateLocal(list.find(c => c.id == App.editConnectionId));
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
} else {
// Neu anlegen
const data = {
action: 'create_connection',
equipment_id: App.connectionEquipmentId,
direction: App.connectionDirection,
connection_type: connectionType,
color: color,
output_label: outputLabel,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
source_terminal: sourceTerminal,
source_terminal_id: sourceTerminalId,
target_terminal_id: targetTerminalId,
bundled_terminals: bundledTerminals
};
const newConnBase = {
connection_type: connectionType,
color: color,
output_label: outputLabel,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
bundled_terminals: bundledTerminals,
is_top: isTop,
source_terminal_id: sourceTerminalId,
target_terminal_id: targetTerminalId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const newConn = Object.assign({ id: response.connection_id }, newConnBase);
if (App.connectionDirection === 'input') {
newConn.fk_target = App.connectionEquipmentId;
App.inputs.push(newConn);
} else {
newConn.fk_source = App.connectionEquipmentId;
App.outputs.push(newConn);
}
renderEditor();
showToast('Verbindung angelegt', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
queueOfflineAction(data);
showToast('Wird synchronisiert...', 'warning');
}
} else {
queueOfflineAction(data);
const newConn = Object.assign({ id: 'temp_' + Date.now() }, newConnBase);
if (App.connectionDirection === 'input') {
newConn.fk_target = App.connectionEquipmentId;
App.inputs.push(newConn);
} else {
newConn.fk_source = App.connectionEquipmentId;
App.outputs.push(newConn);
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
}
App.editConnectionId = null;
}
/**
* Connection löschen (mit Bestätigung)
*/
function handleDeleteConnectionConfirm() {
const connId = App.editConnectionId;
if (!connId) return;
$('#confirm-title').text('Verbindung löschen?');
$('#confirm-message').text('Diese Verbindung wirklich löschen?');
App.confirmCallback = () => deleteConnection(connId);
closeModal('connection');
openModal('confirm');
}
async function deleteConnection(connId) {
const data = {
action: 'delete_connection',
connection_id: connId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.outputs = App.outputs.filter(o => o.id != connId);
App.inputs = App.inputs.filter(i => i.id != connId);
renderEditor();
showToast('Verbindung gelöscht', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
queueOfflineAction(data);
App.outputs = App.outputs.filter(o => o.id != connId);
App.inputs = App.inputs.filter(i => i.id != connId);
renderEditor();
}
} else {
queueOfflineAction(data);
App.outputs = App.outputs.filter(o => o.id != connId);
App.inputs = App.inputs.filter(i => i.id != connId);
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editConnectionId = null;
}
// ============================================
// OFFLINE SYNC
// ============================================
function queueOfflineAction(data) {
data._timestamp = Date.now();
App.offlineQueue.push(data);
localStorage.setItem('kundenkarte_offline_queue', JSON.stringify(App.offlineQueue));
updateSyncBadge();
}
function updateSyncBadge() {
const count = App.offlineQueue.length;
const $badge = $('#sync-badge');
if (count > 0) {
$badge.text(count).removeClass('hidden');
} else {
$badge.addClass('hidden');
}
}
async function handleRefresh() {
// Zuerst Offline-Queue syncen falls vorhanden
if (App.offlineQueue.length && App.isOnline) {
await syncOfflineChanges();
}
// Dann Daten neu laden
showToast('Aktualisiere...');
await loadEditorData();
}
async function syncOfflineChanges() {
if (!App.offlineQueue.length) {
showToast('Alles synchronisiert');
return;
}
if (!App.isOnline) {
showToast('Offline - Sync nicht möglich', 'error');
return;
}
showToast('Synchronisiere...');
const queue = [...App.offlineQueue];
let successCount = 0;
for (const data of queue) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
successCount++;
// Remove from queue
const idx = App.offlineQueue.findIndex(q => q._timestamp === data._timestamp);
if (idx > -1) App.offlineQueue.splice(idx, 1);
}
} catch (err) {
console.error('Sync failed for:', data, err);
}
}
localStorage.setItem('kundenkarte_offline_queue', JSON.stringify(App.offlineQueue));
updateSyncBadge();
if (successCount === queue.length) {
showToast('Alle Änderungen synchronisiert', 'success');
// Reload data
loadEditorData();
} else {
showToast(`${successCount}/${queue.length} synchronisiert`, 'warning');
}
}
// ============================================
// API HELPER
// ============================================
async function apiCall(endpoint, data = {}) {
const url = window.MODULE_URL + '/' + endpoint;
// Add token
if (App.token) {
data.token = App.token;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(data)
});
if (!response.ok) {
throw new Error('Network error');
}
return response.json();
}
// ============================================
// UI HELPERS
// ============================================
function openModal(name) {
$('#modal-' + name).addClass('active');
}
function closeModal(name) {
$('#modal-' + name).removeClass('active');
}
function showToast(message, type = '') {
const $toast = $('#toast');
$toast.text(message).removeClass('success error warning visible').addClass(type);
setTimeout(() => $toast.addClass('visible'), 10);
setTimeout(() => $toast.removeClass('visible'), 3000);
}
function showOfflineBar() {
// Status-Indikator entfernt - nur Toast-Nachricht
showToast('Offline', 'warning');
}
function hideOfflineBar() {
// Status-Indikator entfernt
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// jQuery wird als $ Parameter der IIFE übergeben
// ============================================
// START
// ============================================
document.addEventListener('DOMContentLoaded', init);
})(jQuery);