/**
* 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('');
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 += `
`;
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 += `
`;
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);
})();