/** * 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: '', anlageId: null, anlageName: '', // Data panels: [], carriers: [], equipment: [], equipmentTypes: [], // Offline queue offlineQueue: [], isOnline: navigator.onLine, // Current modal state currentCarrierId: null, selectedTypeId: null, }; // ============================================ // 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); showScreen('search'); } // 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', () => showScreen('search')); $('#btn-back-anlagen').on('click', () => showScreen('anlagen')); // Search $('#search-customer').on('input', debounce(handleSearch, 300)); // Customer/Anlage selection $('#customer-list').on('click', '.list-item', handleCustomerSelect); $('#anlagen-list').on('click', '.anlage-card', handleAnlageSelect); // Editor actions $('#btn-add-panel').on('click', () => openModal('add-panel')); $('#btn-save-panel').on('click', handleSavePanel); $('#editor-content').on('click', '.btn-add-carrier', handleAddCarrier); $('#btn-save-carrier').on('click', handleSaveCarrier); $('#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-save-equipment').on('click', handleSaveEquipment); $('#btn-cancel-equipment').on('click', () => closeModal('add-equipment')); // 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', syncOfflineChanges); } // ============================================ // 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; localStorage.removeItem('kundenkarte_pwa_token'); localStorage.removeItem('kundenkarte_pwa_user'); showScreen('login'); } // ============================================ // SCREENS // ============================================ function showScreen(name) { $('.screen').removeClass('active'); $('#screen-' + name).addClass('active'); // Load data if needed if (name === 'search') { $('#search-customer').val('').focus(); $('#customer-list').html('
Suchbegriff eingeben...
'); } } // ============================================ // 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(); App.customerId = id; App.customerName = name; $('#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 && response.anlagen) { renderAnlagenList(response.anlagen); // Cache for offline localStorage.setItem('kundenkarte_anlagen_' + id, JSON.stringify(response.anlagen)); } else { $('#anlagen-list').html('
Keine Anlagen gefunden
'); } } catch (err) { // Try cached const cached = localStorage.getItem('kundenkarte_anlagen_' + id); if (cached) { renderAnlagenList(JSON.parse(cached)); showToast('Offline - Zeige gecachte Daten', 'warning'); } else { $('#anlagen-list').html('
Fehler beim Laden
'); } } } function renderAnlagenList(anlagen) { // Filter nur Anlagen mit Editor const withEditor = anlagen.filter(a => a.has_editor); if (!withEditor.length) { $('#anlagen-list').html('
Keine Anlagen mit Schaltplan-Editor
'); return; } let html = ''; withEditor.forEach(a => { html += `
${escapeHtml(a.label || 'Anlage ' + a.id)}
`; }); $('#anlagen-list').html(html); } async function handleAnlageSelect() { const id = $(this).data('id'); const name = $(this).find('.anlage-card-title').text(); App.anlageId = id; App.anlageName = name; $('#anlage-name').text(name); showScreen('editor'); await loadEditorData(); } // ============================================ // 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 || []; // Cache for offline localStorage.setItem('kundenkarte_data_' + App.anlageId, JSON.stringify({ panels: App.panels, carriers: App.carriers, equipment: App.equipment, types: App.equipmentTypes })); 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 || []; 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)); html += `
${escapeHtml(carrier.label || 'Hutschiene')} ${carrier.total_te || 12} TE
`; carrierEquipment.forEach(eq => { const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type); const typeLabel = type ? (type.label_short || type.ref) : '?'; const fieldVals = eq.field_values ? (typeof eq.field_values === 'string' ? JSON.parse(eq.field_values) : eq.field_values) : {}; const value = fieldVals.ampere ? fieldVals.ampere + 'A' : (fieldVals.characteristic || ''); html += `
${escapeHtml(typeLabel)}
${escapeHtml(value)}
${eq.label ? '
' + escapeHtml(eq.label) + '
' : ''}
`; }); 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'); } } catch (err) { 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; $('.te-btn').removeClass('selected'); $('#carrier-label').val(''); 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'; const data = { action: 'create_carrier', panel_id: App.currentPanelId, total_te: totalTe, label: label }; closeModal('add-carrier'); 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'); } } catch (err) { 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'); } } // ============================================ // EQUIPMENT (AUTOMAT) ACTIONS // ============================================ function handleAddEquipment() { const carrierId = $(this).data('carrier-id'); App.currentCarrierId = carrierId; App.selectedTypeId = null; // Reset modal $('.type-btn').removeClass('selected'); $('#step-type').addClass('active'); $('#step-values').removeClass('active'); $('#equipment-label').val(''); $('#value-fields').html(''); openModal('add-equipment'); } 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); // Show value step $('#step-type').removeClass('active'); $('#step-values').addClass('active'); // Build value fields based on type let html = ''; // Quick select for common values if (type && (type.ref?.includes('LS') || type.label?.includes('Leitungsschutz'))) { html += '

Kennlinie + Ampere:

'; html += '
'; ['B6', 'B10', 'B13', 'B16', 'B20', 'B25', 'B32', 'C6', 'C10', 'C13', 'C16', 'C20', 'C25', 'C32'].forEach(v => { html += ``; }); html += '
'; } else if (type && (type.ref?.includes('FI') || type.label?.includes('RCD'))) { html += '

Ampere:

'; html += '
'; ['25', '40', '63', '80'].forEach(v => { html += ``; }); html += '
'; html += '

Empfindlichkeit:

'; html += '
'; ['30', '100', '300'].forEach(v => { html += ``; }); html += '
'; } $('#value-fields').html(html); // Bind chip clicks $('#value-fields .value-chip').on('click', function() { if ($(this).hasClass('chip-sens')) { $('.chip-sens').removeClass('selected'); } else { $('.value-chip:not(.chip-sens)').removeClass('selected'); } $(this).addClass('selected'); }); } async function handleSaveEquipment() { if (!App.selectedTypeId) { showToast('Bitte Typ wählen', 'error'); return; } const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId); const label = $('#equipment-label').val().trim(); // Collect field values const fieldValues = {}; const selectedChip = $('.value-chip.selected:not(.chip-sens)'); const selectedSens = $('.chip-sens.selected'); if (selectedChip.length) { if (selectedChip.data('char')) fieldValues.characteristic = selectedChip.data('char'); if (selectedChip.data('amp')) fieldValues.ampere = selectedChip.data('amp'); } if (selectedSens.length) { fieldValues.sensitivity = selectedSens.data('sens'); } // Calculate position const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId); let nextPos = 1; carrierEquipment.forEach(e => { const endPos = (parseInt(e.position_te) || 1) + (parseInt(e.width_te) || 1); if (endPos > nextPos) nextPos = endPos; }); 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: label, position_te: nextPos, width_te: type?.width_te || 1, field_values: fieldValues }); renderEditor(); showToast('Automat angelegt'); } } catch (err) { 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'); } } function handleEquipmentClick() { const eqId = $(this).data('equipment-id'); // TODO: Edit/Delete popup showToast('Bearbeiten kommt noch...'); } // ============================================ // 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 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 shorthand function $(selector) { if (typeof selector === 'string') { const elements = document.querySelectorAll(selector); return new ElementCollection(elements); } return new ElementCollection([selector]); } class ElementCollection { constructor(elements) { this.elements = Array.from(elements); this.length = this.elements.length; } on(event, selectorOrHandler, handler) { if (typeof selectorOrHandler === 'function') { // Direct event this.elements.forEach(el => el.addEventListener(event, selectorOrHandler)); } else { // Delegated event this.elements.forEach(el => { el.addEventListener(event, function(e) { const target = e.target.closest(selectorOrHandler); if (target && el.contains(target)) { handler.call(target, e); } }); }); } return this; } addClass(className) { this.elements.forEach(el => el.classList.add(className)); return this; } removeClass(className) { this.elements.forEach(el => el.classList.remove(className)); return this; } hasClass(className) { return this.elements[0]?.classList.contains(className); } html(content) { if (content === undefined) { return this.elements[0]?.innerHTML; } this.elements.forEach(el => el.innerHTML = content); return this; } text(content) { if (content === undefined) { return this.elements[0]?.textContent; } this.elements.forEach(el => el.textContent = content); return this; } val(value) { if (value === undefined) { return this.elements[0]?.value; } this.elements.forEach(el => el.value = value); return this; } data(key) { return this.elements[0]?.dataset[key]; } find(selector) { const found = []; this.elements.forEach(el => { found.push(...el.querySelectorAll(selector)); }); return new ElementCollection(found); } closest(selector) { return new ElementCollection([this.elements[0]?.closest(selector)].filter(Boolean)); } focus() { this.elements[0]?.focus(); return this; } } // ============================================ // START // ============================================ document.addEventListener('DOMContentLoaded', init); })();