/**
* 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 += '';
}
// 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('');
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 += `
`;
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 + (parseInt(eq.width_te) || 1), 0);
const isFull = usedTe >= totalTe;
html += `
`;
// === Zeile 1: Terminals oben (Inputs + Top-Outputs) ===
carrierEquipment.forEach(eq => {
const widthTe = parseInt(eq.width_te) || 1;
const posTe = parseInt(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 = parseInt(eq.width_te) || 1;
const posTe = parseInt(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 = parseInt(eq.width_te) || 1;
const posTe = parseInt(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 += `
Hutschiene hinzufügen
`;
});
$('#editor-content').html(html);
// Load type grid
renderTypeGrid();
}
function renderTypeGrid() {
let html = '';
App.equipmentTypes.forEach(type => {
html += `
⚡
${escapeHtml(type.label_short || type.ref || type.label)}
`;
});
$('#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 Slots ermitteln (1-basiert)
const occupied = {};
carrierEquipment.forEach(eq => {
const pos = parseInt(eq.position_te) || 1;
const w = parseInt(eq.width_te) || 1;
for (let s = pos; s < pos + w; s++) {
occupied[s] = true;
}
});
// Maximale zusammenhängende Lücke
let maxGap = 0, currentGap = 0;
for (let s = 1; s <= totalTe; s++) {
if (!occupied[s]) {
currentGap++;
if (currentGap > maxGap) maxGap = currentGap;
} else {
currentGap = 0;
}
}
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 += `${escapeHtml(field.label)}${req} `;
switch (field.type) {
case 'select':
html += ``;
html += `-- `;
if (field.options) {
field.options.split('|').forEach(opt => {
const selected = (opt === val) ? ' selected' : '';
html += `${escapeHtml(opt)} `;
});
}
html += ` `;
break;
case 'number':
html += ` `;
break;
case 'checkbox':
const checked = val === '1' || val === 'true' ? ' checked' : '';
html += ` ${escapeHtml(field.label)} `;
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 = parseInt(type?.width_te) || 1;
// Belegungsarray erstellen
const occupied = new Array(totalTe + 1).fill(false);
carrierEquipment.forEach(e => {
const pos = parseInt(e.position_te) || 1;
const w = parseInt(e.width_te) || 1;
for (let i = pos; i < pos + w && i <= totalTe; i++) {
occupied[i] = true;
}
});
// Erste Lücke finden die breit genug ist
let nextPos = 0;
for (let i = 1; i <= totalTe - eqWidth + 1; i++) {
let fits = true;
for (let j = 0; j < eqWidth; j++) {
if (occupied[i + j]) { fits = false; break; }
}
if (fits) { nextPos = i; break; }
}
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 += `
`;
});
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 = '-- Kein Typ -- ';
phases.forEach(p => {
const sel = (p === selectedType) ? ' selected' : '';
html += `${p} `;
});
$('#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);