/** * 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, // 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); $('#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 += `
${escapeHtml(c.name)}
${escapeHtml(c.address || '')}
`; }); $('#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('
'); 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('
Keine Anlagen gefunden
'); } } 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('
Fehler beim Laden
'); } } } // ============================================ // CUSTOMER SEARCH // ============================================ async function handleSearch() { const query = $('#search-customer').val().trim(); if (query.length < 2) { $('#customer-list').html('
Mindestens 2 Zeichen eingeben...
'); return; } $('#customer-list').html('
'); 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('
Keine Kunden gefunden
'); } } catch (err) { $('#customer-list').html('
Fehler bei der Suche
'); } } function renderCustomerList(customers) { if (!customers.length) { $('#customer-list').html('
Keine Kunden gefunden
'); return; } let html = ''; customers.forEach(c => { html += `
${escapeHtml(c.name)}
${escapeHtml(c.town || '')}
`; }); $('#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('
'); 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('
Keine Anlagen gefunden
'); } } 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('
Fehler beim Laden
'); } } } function renderAnlagenList(anlagen, contacts) { let html = ''; // Kontakt-Adressen (Gebäude/Standorte) als Liste if (contacts && contacts.length) { html += '
'; contacts.forEach(c => { const subtitle = [c.address, c.town].filter(Boolean).join(', '); html += `
${escapeHtml(c.name)}
${subtitle ? '
' + escapeHtml(subtitle) + '
' : ''}
${c.anlage_count}
`; }); html += '
'; } // Kunden-Anlagen (ohne Kontaktzuweisung) als Baum darunter if (anlagen && anlagen.length) { if (contacts && contacts.length && App.customerAddress) { html += `
${escapeHtml(App.customerName)} – ${escapeHtml(App.customerAddress)}
`; } html += renderTreeNodes(anlagen, 0); } if (!html) { $('#anlagen-list').html('
Keine Anlagen gefunden
'); 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 = '
'; a.fields.forEach(f => { const style = f.color ? ` style="background:${f.color}"` : ''; fieldsHtml += `${escapeHtml(f.value)}`; }); fieldsHtml += '
'; } // Icons je nach Typ let iconSvg; if (isEquipment) { // Schaltschrank/Verteiler iconSvg = ''; } else if (isStructure) { // Gebäude/Raum iconSvg = ''; } else { // Endgerät iconSvg = ''; } html += `
`; html += `
`; // Toggle-Chevron (nur bei Kindern) if (hasChildren) { html += ''; } else { html += ''; } // Icon html += `
${iconSvg}
`; // Inhalt html += '
'; html += `
${escapeHtml(a.label || 'Anlage ' + a.id)}
`; if (a.type) html += `
${escapeHtml(a.type)}
`; html += fieldsHtml; html += '
'; // Editor-Pfeil nur bei Equipment-Containern if (isEquipment) { html += ''; } html += '
'; // pwa-tree-row // Kinder (eingeklappt) if (hasChildren) { html += '
'; html += renderTreeNodes(a.children, level + 1); html += '
'; } html += '
'; // 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('
'); 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('
Keine Anlagen
'); } } catch (err) { $list.html('
Fehler beim Laden
'); } } // ============================================ // EDITOR // ============================================ async function loadEditorData() { $('#editor-content').html('
Lade Daten...
'); 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('
Fehler beim Laden
'); } } } /** * 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(``); }); } function renderEditor() { if (!App.panels.length) { $('#editor-content').html('
Noch keine Felder angelegt.
Tippe auf "+ Feld" um zu beginnen.
'); return; } let html = ''; App.panels.forEach(panel => { const panelCarriers = App.carriers.filter(c => c.fk_panel == panel.id); html += `
${escapeHtml(panel.label || 'Feld ' + panel.id)}
`; 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 += `
${escapeHtml(carrier.label || 'Hutschiene')} ${usedTe}/${totalTe} TE
`; // === 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 += ``; if (bundledTop.output_label) { html += `${escapeHtml(bundledTop.output_label)}`; if (cableInfo) html += `${escapeHtml(cableInfo)}`; html += ``; } html += ``; } 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 += ``; html += `${escapeHtml(topOut.output_label)}`; if (cableInfo) html += `${escapeHtml(cableInfo)}`; html += ``; html += ``; } else { html += ``; } } // 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 += ``; } } }); // === 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 += ``; html += ``; html += `${escapeHtml(bundledTop.connection_type || '')}`; html += ``; } // 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 += ``; html += ``; html += `${escapeHtml(topOut.connection_type || '')}`; html += ``; } else if (inp) { const phaseColor = inp.color || getPhaseColor(inp.connection_type); html += ``; html += ``; html += `${escapeHtml(inp.connection_type || '')}`; html += ``; } else { // Leerer Terminal - neutral, Position "top" html += ``; html += ``; html += ``; } } // 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 += ``; } }); // === 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 += `
${escapeHtml(typeLabel)} ${showBlockFields ? `${escapeHtml(blockFields)}` : ''} ${escapeHtml(eqLabel)}
`; }); // +-Button in letzter Spalte (auto), Zeile 3 html += ` `; // === 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 += ``; html += ``; html += `${escapeHtml(bundledBottom.connection_type || '')}`; html += ``; } // 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 += ``; html += ``; html += `${escapeHtml(out.connection_type || '')}`; html += ``; } else if (inp) { const phaseColor = inp.color || getPhaseColor(inp.connection_type); html += ``; html += ``; html += `${escapeHtml(inp.connection_type || '')}`; html += ``; } else { // Leerer Terminal - neutral, Position "bottom" html += ``; html += ``; html += ``; } } // 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 += ``; } }); // === 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 += ``; if (bundledBottom.output_label) { html += `${escapeHtml(bundledBottom.output_label)}`; if (cableInfo) html += `${escapeHtml(cableInfo)}`; html += ``; } html += ``; } 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 += ``; html += `${escapeHtml(out.output_label)}`; if (cableInfo) html += `${escapeHtml(cableInfo)}`; html += ``; html += ``; } else { html += ``; } } // 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 += ``; } } }); html += `
`; }); html += `
`; }); $('#editor-content').html(html); // Render connection lines (SVG overlay) renderConnectionLines(); // Load type grid renderTypeGrid(); } /** * Render SVG connection lines from path_data * Only shows connections that were manually drawn on the website */ function renderConnectionLines() { if (!App.connections || App.connections.length === 0) { return; } // 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); // 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 = $(''); $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); // Schatten-Pfad für bessere Sichtbarkeit svgContent += ``; // Hauptpfad svgContent += ``; }); $svg.html(svgContent); }); } 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 += `
${escapeHtml(categoryLabels[cat] || cat)}
`; groups[cat].forEach(type => { html += ` `; }); }); $('#type-grid').html(html); } // ============================================ // 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('
'); 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('
Offline - Felder nicht verfügbar
'); } 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 ? ' *' : ''; const val = field.value || ''; html += `
`; html += ``; switch (field.type) { case 'select': html += ``; break; case 'number': html += ``; break; case 'checkbox': const checked = val === '1' || val === 'true' ? ' checked' : ''; html += ``; break; case 'textarea': html += ``; break; default: // text html += ``; } html += `
`; }); $('#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 += '
'; html += '
Werte
'; html += '
'; 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 += `
${escapeHtml(fm.label)} ${escapeHtml(String(val))}
`; }); } else { // Fallback: Code als Label for (const [key, val] of Object.entries(eq.field_values)) { if (val === '' || val === null || val === undefined) continue; html += `
${escapeHtml(key)} ${escapeHtml(String(val))}
`; } } html += '
'; } // Abgänge (Outputs) const outputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id) : []; if (outputs.length) { html += '
'; html += '
Abgänge
'; html += '
'; 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 ? '▲' : '▼'; html += `
${escapeHtml(label)}
${meta ? '
' + escapeHtml(meta) + '
' : ''}
${arrow}
`; }); html += '
'; } // Einspeisungen (Inputs) const inputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id) : []; if (inputs.length) { html += '
'; html += '
Einspeisungen
'; html += '
'; inputs.forEach(i => { const color = i.color || getPhaseColor(i.connection_type); const label = i.output_label || i.connection_type || 'Einspeisung'; html += `
${escapeHtml(label)}
`; }); html += '
'; } // 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 += '
'; html += '
Verbindungen
'; html += '
'; // 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 += `
→ ${escapeHtml(targetLabel)}
${escapeHtml(c.connection_type || '')}
`; }); // 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 += `
← ${escapeHtml(sourceLabel)}
${escapeHtml(c.connection_type || '')}
`; }); html += '
'; } // 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 += '
'; html += '
Schutzeinrichtung
'; html += '
'; html += `
${escapeHtml(protLabel)}
${escapeHtml(protTypeLabel)} ${escapeHtml(protectionEq.block_label || '')}
`; html += '
'; } } // 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 += '
'; html += '
Schützt
'; html += '
'; protectedEquipment.forEach(pe => { const peLabel = pe.label || pe.block_label || 'Equipment'; html += `
${escapeHtml(peLabel)}
`; }); html += '
'; } // Position-Info const carrier = App.carriers.find(c => c.id == eq.fk_carrier); html += '
'; html += '
Position
'; html += '
'; if (carrier) { html += `
Hutschiene ${escapeHtml(carrier.label || 'Hutschiene')}
`; } html += `
TE-Position ${eq.position_te || '–'} (${eq.width_te || 1} TE breit)
`; html += '
'; if (!html) { html = '

Keine Details vorhanden

'; } $('#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 = ``; 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 = `${escapeHtml(out.output_label)}`; if (cableInfo) labelHtml += `
${escapeHtml(cableInfo.trim())}`; labelHtml += `
`; } else { labelHtml = `${escapeHtml(out.connection_type || '')}`; } // 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(''); } // 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 = ''; phases.forEach(p => { const sel = (p === selectedType) ? ' selected' : ''; html += ``; }); $('#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 = `
Anschlusspunkt (L1/L2/L3)
Abgang (Verbraucher)
`; $('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 = ''; if (groups && groups.length > 0) { groups.forEach(group => { html += ``; 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 += ``; }); html += ''; }); } else { // Fallback auf statische Liste ['NYM-J', 'NYY-J', 'H07V-K', 'CAT6', 'CAT7'].forEach(t => { const selected = (selectedValue === t) ? ' selected' : ''; html += ``; }); } $('#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 = ''; if (specs && specs.length > 0) { specs.forEach(spec => { const selected = (spec === defaultSpec) ? ' selected' : ''; html += ``; }); } else { html = ''; } $('#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(''); // 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);