/** * 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: [], // 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 }; // ============================================ // 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(); }); // 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 $('#editor-content').on('click', '.terminal-point', handleTerminalClick); $('#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'); }); // 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); } // 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)); } // 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); 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 || []; // 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 })); 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 || []; renderEditor(); showToast('Offline - Zeige gecachte Daten', 'warning'); } else { $('#editor-content').html('
Fehler beim Laden
'); } } } 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: Terminals oben (Inputs + Top-Outputs) === 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) : []; const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : []; for (let t = 0; t < widthTe; t++) { const colPos = posTe > 0 ? posTe + t : 0; const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`; const inp = eqInputs[t] || null; const topOut = eqTopOutputs[t] || null; if (topOut) { // Top-Output: Pfeil nach oben ▲ const phaseColor = topOut.color || getPhaseColor(topOut.connection_type); html += ``; html += renderOutputLabel(topOut, phaseColor, 'up'); html += ``; } else if (inp) { const phaseColor = inp.color || getPhaseColor(inp.connection_type); html += ``; html += ``; html += `${escapeHtml(inp.connection_type || '')}`; html += ``; } else { html += ``; html += ``; html += ``; } } }); // === Zeile 2: Equipment-Blöcke === 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:2; grid-column: ${posTe} / span ${widthTe}` : `grid-row:2; grid-column: span ${widthTe}`; html += `
${escapeHtml(typeLabel)} ${showBlockFields ? `${escapeHtml(blockFields)}` : ''} ${escapeHtml(eqLabel)}
`; }); // +-Button in letzter Spalte (auto), Zeile 2 html += ` `; // === Zeile 3: Output-Terminals unten (Standard-Abgänge) === 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) : []; for (let t = 0; t < widthTe; t++) { const colPos = posTe > 0 ? posTe + t : 0; const style = `grid-row:3;${colPos > 0 ? ' grid-column:' + colPos : ''}`; const out = eqBottomOutputs[t] || null; if (out) { const phaseColor = out.color || getPhaseColor(out.connection_type); html += ``; html += renderOutputLabel(out, phaseColor, 'down'); html += ``; } else { html += ``; html += ``; html += ``; } } }); html += `
`; }); html += `
`; }); $('#editor-content').html(html); // Load type grid renderTypeGrid(); } function renderTypeGrid() { let html = ''; App.equipmentTypes.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); // 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 data = { action: 'create_equipment', carrier_id: App.currentCarrierId, type_id: App.selectedTypeId, label: label, position_te: nextPos, field_values: JSON.stringify(fieldValues) }; 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 || '' }); 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 data = { action: 'update_equipment', equipment_id: App.editEquipmentId, label: label, field_values: JSON.stringify(fieldValues) }; 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; } 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; } renderEditor(); } } else { queueOfflineAction(data); const eq = App.equipment.find(e => e.id == App.editEquipmentId); if (eq) { eq.label = label; eq.field_values = fieldValues; } 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 if (eq.field_values && Object.keys(eq.field_values).length) { html += '
'; html += '
Werte
'; html += '
'; 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 += '
'; } // 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); 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 // ============================================ // Phasen-Optionen wie auf der Website const INPUT_PHASES = ['L1', 'L2', 'L3', '3P', '3P+N', 'PE']; const OUTPUT_PHASES = ['L1N', 'L2N', 'L3N', 'N', '3P+N', 'PE', 'DATA']; /** * Phasenfarbe ermitteln (DIN VDE Farben) */ function getPhaseColor(type) { const colors = { 'L1': '#8B4513', 'L2': '#1a1a1a', 'L3': '#666', 'N': '#0066cc', 'PE': '#27ae60', 'L1N': '#8B4513', 'L2N': '#1a1a1a', 'L3N': '#666', '3P': '#e74c3c', '3P+N': '#e74c3c', 'DATA': '#9b59b6' }; return colors[type] || '#888'; } /** * 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 (Input oder Output) */ function handleTerminalClick(e) { e.stopPropagation(); const $point = $(this); const eqId = $point.data('equipment-id'); const direction = $point.data('direction'); const connId = $point.data('connection-id'); App.connectionEquipmentId = eqId; App.connectionDirection = direction; // Typ-Select befüllen renderTypeSelect(direction, ''); if (connId) { // Bearbeiten 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-type').val(conn.medium_type || ''); $('#conn-medium-spec').val(conn.medium_spec || ''); $('#conn-medium-length').val(conn.medium_length || ''); setSideButton(conn.is_top ? 'top' : 'bottom'); } } else { // Neu anlegen App.editConnectionId = null; $('#connection-modal-title').text(direction === 'input' ? 'Einspeisung' : 'Abgang'); $('#btn-delete-connection').addClass('hidden'); $('#conn-color').val('#3498db'); $('#conn-label').val(''); $('#conn-medium-type').val(''); $('#conn-medium-spec').val(''); $('#conn-medium-length').val(''); setSideButton('bottom'); } // Medium-Felder nur bei Abgang zeigen $('#conn-output-fields').toggle(direction === 'output'); 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 isTop = isOutput && getSelectedSide() === 'top'; // source_terminal_id wie Website: t1=oben, t2=unten const sourceTerminalId = isOutput ? (isTop ? 't1' : 't2') : ''; const sourceTerminal = isOutput ? (isTop ? 'top' : 'output') : ''; 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 }; 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; 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 }; const newConnBase = { connection_type: connectionType, color: color, output_label: outputLabel, medium_type: mediumType, medium_spec: mediumSpec, medium_length: mediumLength, is_top: isTop, source_terminal_id: sourceTerminalId }; 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() { $('#offline-indicator').removeClass('hidden'); } function hideOfflineBar() { $('#offline-indicator').addClass('hidden'); } 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);