/**
* KundenKarte PWA - Mobile Schaltschrank-Dokumentation
* Offline-First App für Elektriker
*/
(function($) {
'use strict';
// ============================================
// APP STATE
// ============================================
const App = {
// Auth
token: null,
user: null,
// Current selection
customerId: null,
customerName: '',
customerAddress: '',
anlageId: null,
anlageName: '',
// Data
panels: [],
carriers: [],
equipment: [],
equipmentTypes: [],
outputs: [],
inputs: [],
connections: [],
fieldMeta: {},
// Offline queue
offlineQueue: [],
isOnline: navigator.onLine,
// Display settings
showConnectionLines: false, // Leitungen standardmäßig ausgeblendet
// Current modal state
currentCarrierId: null,
editCarrierId: null, // null = Add-Modus, ID = Edit-Modus (Hutschiene)
selectedTypeId: null,
editEquipmentId: null, // null = Add-Modus, ID = Edit-Modus
confirmCallback: null, // Callback für Bestätigungsdialog
editConnectionId: null, // null = Neu, ID = Edit
connectionEquipmentId: null, // Equipment für aktuelle Connection
connectionDirection: 'output', // 'output' oder 'input'
mediumTypes: null, // Kabeltypen aus DB (gecacht)
cachedTypeFields: null, // Equipment-Felder Cache
protectionDevices: [], // FI/RCD-Geräte für aktuelle Anlage
};
// ============================================
// INIT
// ============================================
function init() {
// Register Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('[PWA] Service Worker registered'))
.catch(err => console.error('[PWA] SW registration failed:', err));
}
// Check online status
window.addEventListener('online', () => {
App.isOnline = true;
hideOfflineBar();
syncOfflineChanges();
});
window.addEventListener('offline', () => {
App.isOnline = false;
showOfflineBar();
});
// Offline-Bar anzeigen falls nicht online
if (!navigator.onLine) {
showOfflineBar();
}
// Check stored auth
const storedToken = localStorage.getItem('kundenkarte_pwa_token');
const storedUser = localStorage.getItem('kundenkarte_pwa_user');
if (storedToken && storedUser) {
App.token = storedToken;
App.user = JSON.parse(storedUser);
// Letzten Zustand wiederherstellen
const lastState = JSON.parse(sessionStorage.getItem('kundenkarte_pwa_state') || 'null');
if (lastState && lastState.screen) {
if (lastState.customerId) {
App.customerId = lastState.customerId;
App.customerName = lastState.customerName || '';
App.customerAddress = lastState.customerAddress || '';
$('#customer-name').text(App.customerName);
}
if (lastState.anlageId) {
App.anlageId = lastState.anlageId;
App.anlageName = lastState.anlageName || '';
$('#anlage-name').text(App.anlageName);
}
// Screen wiederherstellen inkl. vollständiger History-Stack
// Damit Zurück-Button auch nach App-Suspend korrekt funktioniert
if (lastState.screen === 'editor' && App.anlageId) {
history.replaceState({ screen: 'search' }, '', '#search');
history.pushState({ screen: 'anlagen' }, '', '#anlagen');
showScreen('editor');
loadEditorData();
} else if (lastState.screen === 'anlagen' && App.customerId) {
history.replaceState({ screen: 'search' }, '', '#search');
showScreen('anlagen');
reloadAnlagen();
} else {
showScreen('search');
}
} else {
showScreen('search');
}
}
// Initialen History-State setzen (nur wenn kein Session-Restore)
if (!sessionStorage.getItem('kundenkarte_pwa_state')) {
history.replaceState({ screen: $('.screen.active').attr('id')?.replace('screen-', '') || 'login' }, '');
}
// Load offline queue
const storedQueue = localStorage.getItem('kundenkarte_offline_queue');
if (storedQueue) {
App.offlineQueue = JSON.parse(storedQueue);
updateSyncBadge();
}
// Bind events
bindEvents();
}
// ============================================
// EVENTS
// ============================================
function bindEvents() {
// Login
$('#login-form').on('submit', handleLogin);
$('#btn-logout').on('click', handleLogout);
// Navigation
$('#btn-back-search').on('click', () => history.back());
$('#btn-back-anlagen').on('click', () => history.back());
// Browser/Hardware Zurück-Button
window.addEventListener('popstate', function(e) {
// Wenn ein Modal offen ist: Modal schließen statt navigieren
const $activeModal = $('.modal.active');
if ($activeModal.length) {
$activeModal.removeClass('active');
// Aktuellen State wieder pushen (Navigation verhindern)
const currentScreen = $('.screen.active').attr('id')?.replace('screen-', '') || 'search';
history.pushState({ screen: currentScreen }, '', '#' + currentScreen);
return;
}
if (e.state && e.state.screen) {
showScreen(e.state.screen, true);
// Anlagen-Liste nachladen falls leer (z.B. nach Seiten-Refresh)
if (e.state.screen === 'anlagen' && App.customerId && !$('#anlagen-list').children('.pwa-tree-node, .contact-list, .contact-group').length) {
reloadAnlagen();
}
} else {
// Kein State = zurück zum Anfang
const activeScreen = App.token ? 'search' : 'login';
showScreen(activeScreen, true);
}
});
// Search
$('#search-customer').on('input', debounce(handleSearch, 300));
// Customer/Anlage selection
$('#customer-list').on('click', '.list-item', handleCustomerSelect);
$('#anlagen-list').on('click', '.pwa-tree-row', handleTreeNodeClick);
$('#anlagen-list').on('click', '.contact-group-header', handleContactGroupClick);
// Editor actions
$('#btn-add-panel').on('click', () => openModal('add-panel'));
$('#btn-save-panel').on('click', handleSavePanel);
$('#btn-toggle-wires').on('click', handleToggleWires);
$('#editor-content').on('click', '.btn-add-carrier', handleAddCarrier);
$('#editor-content').on('click', '.carrier-header', handleCarrierClick);
$('#btn-save-carrier').on('click', handleSaveCarrier);
$('#btn-delete-carrier').on('click', handleDeleteCarrierConfirm);
$('#editor-content').on('click', '.btn-add-equipment', handleAddEquipment);
$('#editor-content').on('click', '.equipment-block', handleEquipmentClick);
// Equipment modal
$('#type-grid').on('click', '.type-btn', handleTypeSelect);
$('#btn-eq-back').on('click', () => showEquipmentStep('type'));
$('#btn-save-equipment').on('click', handleSaveEquipment);
$('#btn-cancel-equipment').on('click', () => closeModal('add-equipment'));
$('#btn-delete-equipment').on('click', handleDeleteEquipmentConfirm);
// Equipment Detail Bottom-Sheet
$('#btn-detail-edit').on('click', openEditFromDetail);
$('#btn-detail-close').on('click', () => $('#sheet-equipment-detail').removeClass('active'));
$('#sheet-equipment-detail .sheet-overlay').on('click', () => $('#sheet-equipment-detail').removeClass('active'));
// Terminal/Connection - Klick auf einzelne Klemme (inkl. leere Terminals)
$('#editor-content').on('click', '.terminal-point, .terminal-empty', handleTerminalClick);
// Terminal-Labels anklickbar zum Bearbeiten
$('#editor-content').on('click', '.terminal-label-cell:not(.empty)', handleTerminalLabelClick);
$('#btn-save-connection').on('click', handleSaveConnection);
$('#btn-delete-connection').on('click', handleDeleteConnectionConfirm);
// Abgangsseite-Buttons
$('#conn-side-grid').on('click', '.side-btn', function() {
$('.side-btn').removeClass('selected');
$(this).addClass('selected');
});
// Medium-Type Change -> Spezifikationen laden
$('#conn-medium-type').on('change', handleMediumTypeChange);
// Bestätigungsdialog
$('#btn-confirm-ok').on('click', function() {
closeModal('confirm');
if (App.confirmCallback) {
App.confirmCallback();
App.confirmCallback = null;
}
});
// TE buttons
$('.te-btn').on('click', function() {
$('.te-btn').removeClass('selected');
$(this).addClass('selected');
});
// Modal close
$('.modal-close').on('click', function() {
$(this).closest('.modal').removeClass('active');
});
// Sync button
$('#btn-sync').on('click', handleRefresh);
}
// ============================================
// AUTH
// ============================================
async function handleLogin(e) {
e.preventDefault();
const user = $('#login-user').val().trim();
const pass = $('#login-pass').val();
if (!user || !pass) {
$('#login-error').text('Bitte Benutzername und Passwort eingeben');
return;
}
$('#login-error').text('');
try {
const response = await apiCall('pwa_auth.php', {
action: 'login',
username: user,
password: pass
});
if (response.success) {
App.token = response.token;
App.user = response.user;
localStorage.setItem('kundenkarte_pwa_token', response.token);
localStorage.setItem('kundenkarte_pwa_user', JSON.stringify(response.user));
showScreen('search');
} else {
$('#login-error').text(response.error || 'Login fehlgeschlagen');
}
} catch (err) {
$('#login-error').text('Verbindungsfehler');
}
}
function handleLogout() {
App.token = null;
App.user = null;
App.customerId = null;
App.customerName = '';
App.anlageId = null;
App.anlageName = '';
localStorage.removeItem('kundenkarte_pwa_token');
localStorage.removeItem('kundenkarte_pwa_user');
sessionStorage.removeItem('kundenkarte_pwa_state');
showScreen('login');
}
// ============================================
// SCREENS
// ============================================
function showScreen(name, skipHistory) {
$('.screen').removeClass('active');
$('#screen-' + name).addClass('active');
// Browser-History für Zurück-Button
if (!skipHistory) {
history.pushState({ screen: name }, '', '#' + name);
}
// State speichern für Refresh-Wiederherstellung
saveState(name);
// Zuletzt bearbeitete Kunden laden wenn Search-Screen
if (name === 'search') {
loadRecentCustomers();
}
}
// Zustand in sessionStorage speichern
function saveState(screen) {
const state = {
screen: screen || 'search',
customerId: App.customerId,
customerName: App.customerName,
customerAddress: App.customerAddress,
anlageId: App.anlageId,
anlageName: App.anlageName
};
sessionStorage.setItem('kundenkarte_pwa_state', JSON.stringify(state));
}
// ============================================
// ZULETZT BEARBEITETE KUNDEN
// ============================================
const MAX_RECENT_CUSTOMERS = 5;
function addToRecentCustomers(id, name, address) {
let recent = JSON.parse(localStorage.getItem('kundenkarte_recent_customers') || '[]');
// Entferne den Kunden falls schon vorhanden (wird neu an den Anfang gesetzt)
recent = recent.filter(c => c.id !== id);
// Füge an den Anfang hinzu
recent.unshift({
id: id,
name: name,
address: address,
timestamp: Date.now()
});
// Begrenze auf MAX_RECENT_CUSTOMERS
recent = recent.slice(0, MAX_RECENT_CUSTOMERS);
localStorage.setItem('kundenkarte_recent_customers', JSON.stringify(recent));
}
function loadRecentCustomers() {
const recent = JSON.parse(localStorage.getItem('kundenkarte_recent_customers') || '[]');
if (recent.length === 0) {
$('#recent-customers').addClass('hidden');
return;
}
$('#recent-customers').removeClass('hidden');
let html = '';
recent.forEach(c => {
html += `
${escapeHtml(c.name)}
${escapeHtml(c.address || '')}
`;
});
$('#recent-list').html(html);
// Click-Handler für die Kunden
$('#recent-list .list-item').on('click', handleCustomerSelect);
}
// Anlagen-Liste für aktuellen Kunden neu laden
async function reloadAnlagen() {
if (!App.customerId) return;
$('#anlagen-list').html('');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_anlagen',
customer_id: App.customerId
});
if (response.success) {
renderAnlagenList(response.anlagen, response.contacts || []);
localStorage.setItem('kundenkarte_anlagen_' + App.customerId, JSON.stringify({
anlagen: response.anlagen,
contacts: response.contacts || []
}));
} else {
$('#anlagen-list').html('Keine Anlagen gefunden
');
}
} catch (err) {
// Gecachte Daten verwenden
const cached = localStorage.getItem('kundenkarte_anlagen_' + App.customerId);
if (cached) {
const data = JSON.parse(cached);
renderAnlagenList(data.anlagen || data, data.contacts || []);
showToast('Offline - Zeige gecachte Daten', 'warning');
} else {
$('#anlagen-list').html('Fehler beim Laden
');
}
}
}
// ============================================
// CUSTOMER SEARCH
// ============================================
async function handleSearch() {
const query = $('#search-customer').val().trim();
if (query.length < 2) {
$('#customer-list').html('Mindestens 2 Zeichen eingeben...
');
return;
}
$('#customer-list').html('');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'search_customers',
query: query
});
if (response.success && response.customers) {
renderCustomerList(response.customers);
} else {
$('#customer-list').html('Keine Kunden gefunden
');
}
} catch (err) {
$('#customer-list').html('Fehler bei der Suche
');
}
}
function renderCustomerList(customers) {
if (!customers.length) {
$('#customer-list').html('Keine Kunden gefunden
');
return;
}
let html = '';
customers.forEach(c => {
html += `
${escapeHtml(c.name)}
${escapeHtml(c.town || '')}
`;
});
$('#customer-list').html(html);
}
// ============================================
// CUSTOMER & ANLAGE SELECTION
// ============================================
async function handleCustomerSelect() {
const id = $(this).data('id');
const name = $(this).find('.list-item-title').text();
const address = $(this).find('.list-item-subtitle').text();
App.customerId = id;
App.customerName = name;
App.customerAddress = address;
$('#customer-name').text(name);
// Zu "Zuletzt bearbeitet" hinzufügen
addToRecentCustomers(id, name, address);
showScreen('anlagen');
$('#anlagen-list').html('');
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_anlagen',
customer_id: id
});
if (response.success) {
renderAnlagenList(response.anlagen, response.contacts || []);
// Cache für Offline
localStorage.setItem('kundenkarte_anlagen_' + id, JSON.stringify({
anlagen: response.anlagen,
contacts: response.contacts || []
}));
} else {
$('#anlagen-list').html('Keine Anlagen gefunden
');
}
} catch (err) {
// Try cached
const cached = localStorage.getItem('kundenkarte_anlagen_' + id);
if (cached) {
const data = JSON.parse(cached);
renderAnlagenList(data.anlagen || data, data.contacts || []);
showToast('Offline - Zeige gecachte Daten', 'warning');
} else {
$('#anlagen-list').html('Fehler beim Laden
');
}
}
}
function renderAnlagenList(anlagen, contacts) {
let html = '';
// Kontakt-Adressen (Gebäude/Standorte) als Liste
if (contacts && contacts.length) {
html += '';
}
// 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 || [];
App.connections = response.connections || [];
App.fieldMeta = response.field_meta || {};
// Cache for offline
localStorage.setItem('kundenkarte_data_' + App.anlageId, JSON.stringify({
panels: App.panels,
carriers: App.carriers,
equipment: App.equipment,
types: App.equipmentTypes,
outputs: App.outputs,
inputs: App.inputs,
connections: App.connections,
fieldMeta: App.fieldMeta
}));
// Protection devices laden (FI/RCD)
await loadProtectionDevices();
renderEditor();
}
} catch (err) {
// Try cached
const cached = localStorage.getItem('kundenkarte_data_' + App.anlageId);
if (cached) {
const data = JSON.parse(cached);
App.panels = data.panels || [];
App.carriers = data.carriers || [];
App.equipment = data.equipment || [];
App.equipmentTypes = data.types || [];
App.outputs = data.outputs || [];
App.inputs = data.inputs || [];
App.connections = data.connections || [];
App.fieldMeta = data.fieldMeta || {};
renderEditor();
showToast('Offline - Zeige gecachte Daten', 'warning');
} else {
$('#editor-content').html('Fehler beim Laden
');
}
}
}
/**
* Lädt FI/RCD-Schutzgeräte für die aktuelle Anlage
*/
async function loadProtectionDevices() {
if (!App.anlageId || !App.isOnline) return;
try {
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_protection_devices',
anlage_id: App.anlageId
});
if (response.success) {
App.protectionDevices = response.devices || [];
}
} catch (err) {
// Kein Fehler anzeigen - Dropdown bleibt leer
}
}
/**
* Befüllt das Protection-Dropdown im Equipment-Dialog
*/
function populateProtectionDropdown(selectedId) {
const $select = $('#equipment-protection');
$select.find('option:not(:first)').remove();
App.protectionDevices.forEach(device => {
const selected = device.id == selectedId ? ' selected' : '';
$select.append(`${escapeHtml(device.display_label)} `);
});
}
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 + (parseFloat(eq.width_te) || 1), 0);
const isFull = usedTe >= totalTe;
html += `
`;
// === Zeile 1: Abgang-Labels OBEN (nur wenn Abgang oben ist) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln (Fallback auf widthTe)
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const topTerminalCount = getTerminalCount(type, 'top', widthTe);
// Gebündelter Abgang? (alle Terminals eines breiten Equipment belegt)
const bundledTop = eqTopOutputs.find(o => o.bundled_terminals === 'all');
if (bundledTop && widthTe > 1) {
// Gebündeltes Label (nur Text, OHNE Pfeil) in Zeile 1
const gridColStyle = posTe > 0
? `grid-row:1; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:1; grid-column: span ${widthTe}`;
const cableInfo = buildCableInfo(bundledTop);
html += `
`;
if (bundledTop.output_label) {
html += `${escapeHtml(bundledTop.output_label)}`;
if (cableInfo) html += `${escapeHtml(cableInfo)} `;
html += ` `;
}
html += ` `;
} else {
// Normale einzelne Labels pro Terminal - nur für tatsächliche Terminals
for (let t = 0; t < topTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const topOut = eqTopOutputs[t] || null;
if (topOut && topOut.output_label && (!topOut.bundled_terminals || widthTe <= 1)) {
const cableInfo = buildCableInfo(topOut);
html += `
`;
html += `${escapeHtml(topOut.output_label)}`;
if (cableInfo) html += `${escapeHtml(cableInfo)} `;
html += ` `;
html += ` `;
} else {
html += `
`;
}
}
// Leere Zellen für restliche TE-Breite
for (let t = topTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `
`;
}
}
});
// === Zeile 2: Terminal-Punkte OBEN (direkt am Equipment) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && i.target_terminal_id === 't1') : [];
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const topTerminalCount = getTerminalCount(type, 'top', widthTe);
// Gebündelter Abgang?
const bundledTop = eqTopOutputs.find(o => o.bundled_terminals === 'all');
// Nur so viele Terminal-Punkte wie tatsächlich konfiguriert
for (let t = 0; t < topTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:2;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const inp = eqInputs[t] || null;
const topOut = bundledTop || eqTopOutputs[t] || null;
if (bundledTop && widthTe > 1) {
// Gebündelter Abgang: Pfeil nur beim ersten Terminal, Rest leer
if (t === 0) {
const phaseColor = bundledTop.color || getPhaseColor(bundledTop.connection_type);
const bundledStyle = posTe > 0
? `grid-row:2; grid-column: ${posTe} / span ${topTerminalCount}`
: `grid-row:2; grid-column: span ${topTerminalCount}`;
html += `
`;
html += ` `;
html += `${escapeHtml(bundledTop.connection_type || '')} `;
html += ` `;
}
// Restliche Terminals überspringen (grid-column: span hat sie schon)
} else if (topOut && (!topOut.bundled_terminals || widthTe <= 1)) {
// Normaler Top-Output ODER bundled bei 1 TE (Bundle macht bei 1 TE keinen Unterschied)
const phaseColor = topOut.color || getPhaseColor(topOut.connection_type);
html += `
`;
html += ` `;
html += `${escapeHtml(topOut.connection_type || '')} `;
html += ` `;
} else if (inp) {
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
html += `
`;
html += ` `;
html += `${escapeHtml(inp.connection_type || '')} `;
html += ` `;
} else {
// Leerer Terminal - neutral, Position "top"
html += `
`;
html += ` `;
html += ` `;
}
}
// Leere Zellen für restliche TE-Breite (ohne Terminal-Punkte)
for (let t = topTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:2;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `
`;
}
});
// === Zeile 3: Equipment-Blöcke ===
// Ermittle welche Equipment als Schutzgeräte dienen (werden von anderen referenziert)
const protectionDeviceIds = new Set();
carrierEquipment.forEach(eq => {
if (eq.fk_protection) protectionDeviceIds.add(eq.fk_protection);
});
carrierEquipment.forEach(eq => {
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const typeLabel = type?.label_short || type?.ref || '';
const blockColor = eq.block_color || type?.color || '#3498db';
const eqLabel = eq.label || '';
const blockFields = eq.block_label || '';
const showBlockFields = blockFields && blockFields !== typeLabel && blockFields !== (type?.ref || '');
const gridCol = posTe > 0
? `grid-row:3; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:3; grid-column: span ${widthTe}`;
// Schutzgruppen-Darstellung
let protectionStyle = '';
let protectionClass = '';
// 1. Ist dieses Equipment ein Schutzgerät? (wird von anderen referenziert)
const isProtectionDevice = protectionDeviceIds.has(eq.id);
if (isProtectionDevice) {
const deviceColor = getProtectionColor(eq.id);
protectionStyle = `border-left: 4px solid ${deviceColor};`;
protectionClass = ' is-protection-device';
}
// 2. Ist dieses Equipment einem Schutzgerät zugeordnet?
if (eq.fk_protection) {
const protectionColor = getProtectionColor(eq.fk_protection);
protectionStyle += `border-bottom: 3px solid ${protectionColor};`;
protectionClass += ' has-protection';
}
html += `
${escapeHtml(typeLabel)}
${showBlockFields ? `${escapeHtml(blockFields)} ` : ''}
${escapeHtml(eqLabel)}
`;
});
// +-Button in letzter Spalte (auto), Zeile 3
html += `
`;
// === Zeile 4: Terminal-Punkte UNTEN (direkt am Equipment) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : [];
const eqBottomInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && i.target_terminal_id === 't2') : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const bottomTerminalCount = getTerminalCount(type, 'bottom', widthTe);
// Gebündelter Abgang?
const bundledBottom = eqBottomOutputs.find(o => o.bundled_terminals === 'all');
// Nur so viele Terminal-Punkte wie tatsächlich konfiguriert
for (let t = 0; t < bottomTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:4;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const out = bundledBottom || eqBottomOutputs[t] || null;
const inp = eqBottomInputs[t] || null;
if (bundledBottom && widthTe > 1) {
// Gebündelter Abgang: Pfeil nur beim ersten Terminal, Rest leer
if (t === 0) {
const phaseColor = bundledBottom.color || getPhaseColor(bundledBottom.connection_type);
const bundledStyle = posTe > 0
? `grid-row:4; grid-column: ${posTe} / span ${bottomTerminalCount}`
: `grid-row:4; grid-column: span ${bottomTerminalCount}`;
html += `
`;
html += ` `;
html += `${escapeHtml(bundledBottom.connection_type || '')} `;
html += ` `;
}
// Restliche Terminals überspringen (grid-column: span hat sie schon)
} else if (out && (!out.bundled_terminals || widthTe <= 1)) {
// Normaler Abgang ODER bundled bei 1 TE (Bundle macht bei 1 TE keinen Unterschied)
const phaseColor = out.color || getPhaseColor(out.connection_type);
html += `
`;
html += ` `;
html += `${escapeHtml(out.connection_type || '')} `;
html += ` `;
} else if (inp) {
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
html += `
`;
html += ` `;
html += `${escapeHtml(inp.connection_type || '')} `;
html += ` `;
} else {
// Leerer Terminal - neutral, Position "bottom"
html += `
`;
html += ` `;
html += ` `;
}
}
// Leere Zellen für restliche TE-Breite (ohne Terminal-Punkte)
for (let t = bottomTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:4;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `
`;
}
});
// === Zeile 5: Abgang-Labels UNTEN (nur wenn Abgang unten ist) ===
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const bottomTerminalCount = getTerminalCount(type, 'bottom', widthTe);
// Gebündelter Abgang?
const bundledBottom = eqBottomOutputs.find(o => o.bundled_terminals === 'all');
if (bundledBottom && widthTe > 1) {
// Gebündeltes Label (nur Text, OHNE Pfeil) in Zeile 5
const gridColStyle = posTe > 0
? `grid-row:5; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:5; grid-column: span ${widthTe}`;
const cableInfo = buildCableInfo(bundledBottom);
html += `
`;
if (bundledBottom.output_label) {
html += `${escapeHtml(bundledBottom.output_label)}`;
if (cableInfo) html += `${escapeHtml(cableInfo)} `;
html += ` `;
}
html += ` `;
} else {
// Normale einzelne Labels pro Terminal - nur für tatsächliche Terminals
for (let t = 0; t < bottomTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:5;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const out = eqBottomOutputs[t] || null;
if (out && out.output_label && (!out.bundled_terminals || widthTe <= 1)) {
const cableInfo = buildCableInfo(out);
html += `
`;
html += `${escapeHtml(out.output_label)}`;
if (cableInfo) html += `${escapeHtml(cableInfo)} `;
html += ` `;
html += ` `;
} else {
html += `
`;
}
}
// Leere Zellen für restliche TE-Breite
for (let t = bottomTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:5;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
html += `
`;
}
}
});
html += `
`;
});
html += `
Hutschiene hinzufügen
`;
});
$('#editor-content').html(html);
// Render connection lines (SVG overlay)
renderConnectionLines();
// Load type grid
renderTypeGrid();
}
/**
* Render SVG connection lines between equipment
* PWA uses different layout than desktop, so we calculate positions dynamically
*/
function renderConnectionLines() {
// Remove existing SVG overlays first
$('.connection-lines-svg').remove();
// Only render if setting is enabled
if (!App.showConnectionLines) {
return;
}
if (!App.connections || App.connections.length === 0) {
return;
}
// Desktop reference dimensions
const DESKTOP_TE_WIDTH = 56;
// Für jede Hutschiene ein SVG-Overlay erstellen
$('.carrier-card').each(function() {
const $carrier = $(this);
const carrierId = $carrier.find('.carrier-header').data('carrier-id');
const $content = $carrier.find('.carrier-content');
if (!$content.length) return;
// Equipment dieser Hutschiene finden
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrierId);
const equipmentIds = carrierEquipment.map(e => e.id);
// Carrier-Daten für Total-TE
const carrier = App.carriers.find(c => c.id == carrierId);
const totalTe = carrier ? (parseInt(carrier.total_te) || 12) : 12;
// PWA TE-Breite berechnen
const carrierWidth = $content.width();
const pwaTeWidth = carrierWidth / (totalTe + 1); // +1 für den Add-Button
// Scale factor: PWA-Breite / Desktop-Breite
const scaleX = pwaTeWidth / DESKTOP_TE_WIDTH;
const scaleY = scaleX * 0.8; // Y etwas weniger skalieren (PWA ist kompakter)
// Verbindungen filtern die zu dieser Hutschiene gehören
const carrierConnections = App.connections.filter(c =>
equipmentIds.includes(parseInt(c.fk_source)) ||
equipmentIds.includes(parseInt(c.fk_target))
);
if (carrierConnections.length === 0) return;
// SVG-Container erstellen falls nicht vorhanden
let $svg = $carrier.find('.connection-lines-svg');
if (!$svg.length) {
$svg = $(' ');
$carrier.css('position', 'relative');
$carrier.append($svg);
}
// SVG-Inhalt generieren
let svgContent = '';
carrierConnections.forEach(conn => {
if (!conn.path_data) return;
const color = conn.color || getPhaseColor(conn.connection_type);
// Transform path_data coordinates to PWA scale
const scaledPath = transformPathData(conn.path_data, scaleX, scaleY);
// Schatten-Pfad für bessere Sichtbarkeit
svgContent += ` `;
// Hauptpfad
svgContent += ` `;
// Label falls vorhanden
if (conn.output_label) {
const labelPos = getPathMidpoint(scaledPath);
if (labelPos) {
const labelWidth = Math.min(conn.output_label.length * 6 + 10, 80);
svgContent += ` `;
svgContent += `${escapeHtml(conn.output_label)} `;
}
}
});
$svg.html(svgContent);
});
}
/**
* Transform path data coordinates by scale factors
*/
function transformPathData(pathData, scaleX, scaleY) {
if (!pathData) return '';
// Parse and transform coordinates
return pathData.replace(/([ML])\s*([\d.-]+)\s+([\d.-]+)/gi, function(match, cmd, x, y) {
const newX = (parseFloat(x) * scaleX).toFixed(1);
const newY = (parseFloat(y) * scaleY).toFixed(1);
return `${cmd} ${newX} ${newY}`;
});
}
/**
* Get midpoint of a path for label positioning
*/
function getPathMidpoint(pathData) {
if (!pathData) return null;
const points = [];
const regex = /[ML]\s*([\d.-]+)\s+([\d.-]+)/gi;
let match;
while ((match = regex.exec(pathData)) !== null) {
points.push({ x: parseFloat(match[1]), y: parseFloat(match[2]) });
}
if (points.length < 2) return null;
// Calculate midpoint along path
let totalLength = 0;
const segments = [];
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i-1].x;
const dy = points[i].y - points[i-1].y;
const len = Math.sqrt(dx*dx + dy*dy);
segments.push({ start: points[i-1], end: points[i], length: len });
totalLength += len;
}
const halfLength = totalLength / 2;
let accumulated = 0;
for (const seg of segments) {
if (accumulated + seg.length >= halfLength) {
const t = (halfLength - accumulated) / seg.length;
return {
x: seg.start.x + t * (seg.end.x - seg.start.x),
y: seg.start.y + t * (seg.end.y - seg.start.y)
};
}
accumulated += seg.length;
}
return { x: (points[0].x + points[points.length-1].x) / 2, y: (points[0].y + points[points.length-1].y) / 2 };
}
function renderTypeGrid() {
const categoryLabels = {
'automat': 'Leitungsschutz',
'schutz': 'Schutzgeräte',
'steuerung': 'Steuerung & Sonstiges',
'klemme': 'Klemmen'
};
const categoryOrder = ['automat', 'schutz', 'steuerung', 'klemme'];
// Typen nach Kategorie gruppieren
const groups = {};
App.equipmentTypes.forEach(type => {
const cat = type.category || 'steuerung';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(type);
});
let html = '';
categoryOrder.forEach(cat => {
if (!groups[cat] || !groups[cat].length) return;
html += `${escapeHtml(categoryLabels[cat] || cat)}
`;
groups[cat].forEach(type => {
html += `
⚡
${escapeHtml(type.label_short || type.ref || type.label)}
`;
});
});
$('#type-grid').html(html);
}
// ============================================
// WIRE DISPLAY TOGGLE
// ============================================
function handleToggleWires() {
App.showConnectionLines = !App.showConnectionLines;
// Update button appearance
const $btn = $('#btn-toggle-wires');
if (App.showConnectionLines) {
$btn.addClass('active');
$btn.attr('title', 'Leitungen ausblenden');
} else {
$btn.removeClass('active');
$btn.attr('title', 'Leitungen einblenden');
}
// Re-render connection lines
renderConnectionLines();
}
// ============================================
// PANEL (FELD) ACTIONS
// ============================================
async function handleSavePanel() {
const label = $('#panel-label').val().trim() || 'Feld ' + (App.panels.length + 1);
const data = {
action: 'create_panel',
anlage_id: App.anlageId,
label: label
};
closeModal('add-panel');
$('#panel-label').val('');
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.panels.push({ id: response.panel_id, label: label });
renderEditor();
showToast('Feld angelegt');
} else {
showToast(response.error || 'Fehler beim Anlegen', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
// Optimistic UI
App.panels.push({ id: 'temp_' + Date.now(), label: label });
renderEditor();
showToast('Feld wird synchronisiert...', 'warning');
}
}
// ============================================
// CARRIER (HUTSCHIENE) ACTIONS
// ============================================
function handleAddCarrier() {
const panelId = $(this).data('panel-id');
App.currentPanelId = panelId;
App.editCarrierId = null;
$('.te-btn').removeClass('selected');
$('#carrier-label').val('');
$('#carrier-modal-title').text('Hutschiene hinzufügen');
$('#btn-save-carrier').text('Hinzufügen');
$('#btn-delete-carrier').addClass('hidden');
openModal('add-carrier');
}
function handleCarrierClick() {
const carrierId = $(this).closest('.carrier-item').data('carrier-id');
const carrier = App.carriers.find(c => c.id == carrierId);
if (!carrier) return;
App.editCarrierId = carrierId;
App.currentPanelId = carrier.fk_panel;
// TE-Button vorselektieren
$('.te-btn').removeClass('selected');
$(`.te-btn[data-te="${carrier.total_te}"]`).addClass('selected');
$('#carrier-label').val(carrier.label || '');
$('#carrier-modal-title').text('Hutschiene bearbeiten');
$('#btn-save-carrier').text('Speichern');
$('#btn-delete-carrier').removeClass('hidden');
openModal('add-carrier');
}
async function handleSaveCarrier() {
const teBtn = $('.te-btn.selected');
if (!teBtn.length) {
showToast('Bitte Größe wählen', 'error');
return;
}
const totalTe = parseInt(teBtn.data('te'));
const label = $('#carrier-label').val().trim() || 'Hutschiene';
closeModal('add-carrier');
if (App.editCarrierId) {
// Update
const data = {
action: 'update_carrier',
carrier_id: App.editCarrierId,
total_te: totalTe,
label: label
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const carrier = App.carriers.find(c => c.id == App.editCarrierId);
if (carrier) {
carrier.total_te = totalTe;
carrier.label = label;
}
renderEditor();
showToast('Hutschiene aktualisiert', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
const carrier = App.carriers.find(c => c.id == App.editCarrierId);
if (carrier) {
carrier.total_te = totalTe;
carrier.label = label;
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
} else {
// Neu anlegen
const data = {
action: 'create_carrier',
panel_id: App.currentPanelId,
total_te: totalTe,
label: label
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.carriers.push({
id: response.carrier_id,
fk_panel: App.currentPanelId,
total_te: totalTe,
label: label
});
renderEditor();
showToast('Hutschiene angelegt');
} else {
showToast(response.error || 'Fehler beim Anlegen', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
App.carriers.push({
id: 'temp_' + Date.now(),
fk_panel: App.currentPanelId,
total_te: totalTe,
label: label
});
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
}
App.editCarrierId = null;
}
function handleDeleteCarrierConfirm() {
const carrierId = App.editCarrierId;
if (!carrierId) return;
const carrier = App.carriers.find(c => c.id == carrierId);
const eqCount = App.equipment.filter(e => e.fk_carrier == carrierId).length;
const msg = eqCount > 0
? `"${carrier?.label || 'Hutschiene'}" mit ${eqCount} Automat${eqCount > 1 ? 'en' : ''} wirklich löschen?`
: `"${carrier?.label || 'Hutschiene'}" wirklich löschen?`;
$('#confirm-title').text('Hutschiene löschen?');
$('#confirm-message').text(msg);
App.confirmCallback = () => deleteCarrier(carrierId);
closeModal('add-carrier');
openModal('confirm');
}
async function deleteCarrier(carrierId) {
const data = {
action: 'delete_carrier',
carrier_id: carrierId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId);
App.carriers = App.carriers.filter(c => c.id != carrierId);
renderEditor();
showToast('Hutschiene gelöscht', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId);
App.carriers = App.carriers.filter(c => c.id != carrierId);
renderEditor();
}
} else {
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId);
App.carriers = App.carriers.filter(c => c.id != carrierId);
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editCarrierId = null;
}
// ============================================
// EQUIPMENT (AUTOMAT) ACTIONS
// ============================================
/**
* Maximale zusammenhängende Lücke auf einem Carrier berechnen
*/
function getMaxGap(carrierId) {
const carrier = App.carriers.find(c => c.id == carrierId);
if (!carrier) return 0;
const totalTe = parseInt(carrier.total_te) || 12;
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrierId);
// Belegte Ranges ermitteln (1-basiert, Dezimal-Breiten)
const ranges = carrierEquipment.map(eq => ({
start: parseFloat(eq.position_te) || 1,
end: (parseFloat(eq.position_te) || 1) + (parseFloat(eq.width_te) || 1)
})).sort((a, b) => a.start - b.start);
// Maximale zusammenhängende Lücke
let maxGap = 0;
let pos = 1;
const railEnd = totalTe + 1;
for (const range of ranges) {
const gap = range.start - pos;
if (gap > maxGap) maxGap = gap;
if (range.end > pos) pos = range.end;
}
// Lücke nach letztem Element
const endGap = railEnd - pos;
if (endGap > maxGap) maxGap = endGap;
return maxGap;
}
function handleAddEquipment() {
const carrierId = $(this).data('carrier-id');
App.currentCarrierId = carrierId;
App.selectedTypeId = null;
App.editEquipmentId = null;
// Add-Modus: Titel, Typ-Grid freigeben
$('#equipment-modal-title').text('Automat hinzufügen');
$('#btn-save-equipment').text('Speichern');
$('#btn-delete-equipment').addClass('hidden');
$('#type-grid .type-btn').removeClass('selected');
// Typ-Buttons nach verfügbarem Platz filtern
const maxGap = getMaxGap(carrierId);
$('#type-grid .type-btn').each(function() {
const w = parseInt($(this).data('width')) || 1;
if (w > maxGap) {
$(this).addClass('disabled').prop('disabled', true);
} else {
$(this).removeClass('disabled').prop('disabled', false);
}
});
// Schritt 1 zeigen
showEquipmentStep('type');
openModal('add-equipment');
}
async function handleTypeSelect() {
$('.type-btn').removeClass('selected');
$(this).addClass('selected');
App.selectedTypeId = $(this).data('type-id');
const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId);
// Titel für Schritt 2
$('#eq-fields-title').text(type?.label_short || type?.label || 'Werte');
// Felder vom Server laden
await loadTypeFields(App.selectedTypeId, App.editEquipmentId);
// Protection-Dropdown befüllen (leer für neues Equipment)
if (!App.editEquipmentId) {
populateProtectionDropdown(null);
}
// Zu Schritt 2 wechseln
showEquipmentStep('fields');
}
/**
* Wechselt zwischen Schritt 1 (Typ) und Schritt 2 (Felder)
*/
function showEquipmentStep(step) {
if (step === 'type') {
$('#eq-step-type').removeClass('hidden');
$('#eq-step-fields').addClass('hidden');
} else {
$('#eq-step-type').addClass('hidden');
$('#eq-step-fields').removeClass('hidden');
}
}
/**
* Lädt Felder für einen Equipment-Typ vom Server
*/
async function loadTypeFields(typeId, equipmentId) {
$('#eq-dynamic-fields').html('');
if (!App.isOnline) {
// Offline: Gecachte Felder verwenden falls vorhanden
const cached = App.cachedTypeFields && App.cachedTypeFields[typeId];
if (cached) {
renderDynamicFields(cached);
} else {
$('#eq-dynamic-fields').html('Offline - Felder nicht verfügbar
');
}
return;
}
try {
const data = { action: 'get_type_fields', type_id: typeId };
if (equipmentId) data.equipment_id = equipmentId;
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
// Felder cachen für Offline
if (!App.cachedTypeFields) App.cachedTypeFields = {};
App.cachedTypeFields[typeId] = response.fields;
renderDynamicFields(response.fields);
} else {
$('#eq-dynamic-fields').html('');
}
} catch (err) {
$('#eq-dynamic-fields').html('');
}
}
/**
* Rendert dynamische Felder basierend auf field_type aus der DB
*/
function renderDynamicFields(fields) {
if (!fields || !fields.length) {
$('#eq-dynamic-fields').html('');
$('#equipment-label').focus();
return;
}
let html = '';
fields.forEach(field => {
const req = field.required ? ' * ' : '';
const val = field.value || '';
html += ``;
html += `${escapeHtml(field.label)}${req} `;
switch (field.type) {
case 'select':
html += ``;
html += `-- `;
if (field.options) {
field.options.split('|').forEach(opt => {
opt = opt.trim();
if (!opt) return;
const selected = (opt === val.trim()) ? ' 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 = parseFloat(type?.width_te) || 1;
// Belegte Ranges ermitteln (Dezimal-TE-Unterstützung)
const ranges = carrierEquipment.map(e => ({
start: parseFloat(e.position_te) || 1,
end: (parseFloat(e.position_te) || 1) + (parseFloat(e.width_te) || 1)
})).sort((a, b) => a.start - b.start);
// Erste Lücke finden die breit genug ist
let nextPos = 0;
let pos = 1;
const railEnd = totalTe + 1;
for (const range of ranges) {
if (pos + eqWidth <= range.start + 0.001) {
nextPos = pos;
break;
}
if (range.end > pos) pos = range.end;
}
if (nextPos === 0 && pos + eqWidth <= railEnd + 0.001) {
nextPos = pos;
}
if (nextPos === 0) {
showToast('Kein Platz frei', 'error');
return;
}
const fkProtection = parseInt($('#equipment-protection').val()) || 0;
const data = {
action: 'create_equipment',
carrier_id: App.currentCarrierId,
type_id: App.selectedTypeId,
label: label,
position_te: nextPos,
field_values: JSON.stringify(fieldValues),
fk_protection: fkProtection
};
closeModal('add-equipment');
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.equipment.push({
id: response.equipment_id,
fk_carrier: App.currentCarrierId,
fk_equipment_type: App.selectedTypeId,
label: response.label || label,
position_te: nextPos,
width_te: type?.width_te || 1,
field_values: fieldValues,
block_label: response.block_label || '',
block_color: response.block_color || type?.color || '',
fk_protection: fkProtection || null
});
renderEditor();
showToast('Automat angelegt', 'success');
} else {
showToast(response.error || 'Fehler beim Speichern', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
}
} else {
queueOfflineAction(data);
App.equipment.push({
id: 'temp_' + Date.now(),
fk_carrier: App.currentCarrierId,
fk_equipment_type: App.selectedTypeId,
label: label,
position_te: nextPos,
width_te: type?.width_te || 1,
field_values: fieldValues
});
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
}
/**
* Bestehenden Automaten aktualisieren
*/
async function saveEquipmentUpdate(label, fieldValues) {
const fkProtection = parseInt($('#equipment-protection').val()) || 0;
const data = {
action: 'update_equipment',
equipment_id: App.editEquipmentId,
label: label,
field_values: JSON.stringify(fieldValues),
fk_protection: fkProtection
};
closeModal('add-equipment');
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
// Lokale Daten aktualisieren
const eq = App.equipment.find(e => e.id == App.editEquipmentId);
if (eq) {
eq.label = label;
eq.field_values = fieldValues;
eq.block_label = response.block_label || '';
eq.block_color = response.block_color || eq.block_color;
eq.fk_protection = fkProtection || null;
}
renderEditor();
showToast('Automat aktualisiert', 'success');
} else {
showToast(response.error || 'Fehler beim Aktualisieren', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
// Optimistic UI
const eq = App.equipment.find(e => e.id == App.editEquipmentId);
if (eq) {
eq.label = label;
eq.field_values = fieldValues;
eq.fk_protection = fkProtection || null;
}
renderEditor();
}
} else {
queueOfflineAction(data);
const eq = App.equipment.find(e => e.id == App.editEquipmentId);
if (eq) {
eq.label = label;
eq.field_values = fieldValues;
eq.fk_protection = fkProtection || null;
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editEquipmentId = null;
}
function handleEquipmentClick() {
const eqId = $(this).data('equipment-id');
const eq = App.equipment.find(e => e.id == eqId);
if (!eq) return;
showEquipmentDetail(eq);
}
/**
* Equipment-Detail Bottom-Sheet anzeigen
*/
function showEquipmentDetail(eq) {
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const typeLabel = type?.label || type?.ref || 'Equipment';
const typeLabelShort = type?.label_short || type?.ref || '?';
const typeColor = eq.block_color || type?.color || '#3498db';
// Header
$('#detail-type-badge').css('background', typeColor).text(typeLabelShort);
$('#detail-title').text(eq.label || 'Automat ' + eq.id);
$('#detail-type-name').text(typeLabel);
// Body zusammenbauen
let html = '';
// Feldwerte mit Labels aus Feld-Metadaten
if (eq.field_values && Object.keys(eq.field_values).length) {
const typeMeta = App.fieldMeta ? App.fieldMeta[eq.fk_equipment_type] : null;
html += '';
html += '
Werte
';
html += '
';
if (typeMeta && typeMeta.length) {
// Felder in der konfigurierten Reihenfolge anzeigen
typeMeta.forEach(function(fm) {
const val = eq.field_values[fm.code];
if (val === '' || val === null || val === undefined) return;
html += `
${escapeHtml(fm.label)}
${escapeHtml(String(val))}
`;
});
} else {
// Fallback: Code als Label
for (const [key, val] of Object.entries(eq.field_values)) {
if (val === '' || val === null || val === undefined) continue;
html += `
${escapeHtml(key)}
${escapeHtml(String(val))}
`;
}
}
html += '
';
}
// Abgänge (Outputs)
const outputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id) : [];
if (outputs.length) {
html += '';
html += '
Abgänge
';
html += '
';
outputs.forEach(o => {
const color = o.color || getPhaseColor(o.connection_type);
const label = o.output_label || o.connection_type || 'Abgang';
const meta = [o.medium_type, o.medium_spec, o.medium_length].filter(Boolean).join(' · ');
const arrow = o.is_top ? '▲' : '▼';
html += `
${escapeHtml(label)}
${meta ? '
' + escapeHtml(meta) + '
' : ''}
${arrow}
`;
});
html += '
';
}
// Einspeisungen (Inputs)
const inputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id) : [];
if (inputs.length) {
html += '';
html += '
Einspeisungen
';
html += '
';
inputs.forEach(i => {
const color = i.color || getPhaseColor(i.connection_type);
const label = i.output_label || i.connection_type || 'Einspeisung';
html += `
`;
});
html += '
';
}
// Verbindungen zu anderen Equipment (connections mit path_data von Website)
const connectionsFrom = App.connections ? App.connections.filter(c => c.fk_source == eq.id) : [];
const connectionsTo = App.connections ? App.connections.filter(c => c.fk_target == eq.id) : [];
if (connectionsFrom.length || connectionsTo.length) {
html += '';
html += '
Verbindungen
';
html += '
';
// Verbindungen VON diesem Equipment
connectionsFrom.forEach(c => {
const targetEq = App.equipment.find(e => e.id == c.fk_target);
const targetLabel = targetEq?.label || targetEq?.block_label || 'Equipment ' + c.fk_target;
const color = c.color || getPhaseColor(c.connection_type);
html += `
→ ${escapeHtml(targetLabel)}
${escapeHtml(c.connection_type || '')}
`;
});
// Verbindungen ZU diesem Equipment
connectionsTo.forEach(c => {
const sourceEq = App.equipment.find(e => e.id == c.fk_source);
const sourceLabel = sourceEq?.label || sourceEq?.block_label || 'Equipment ' + c.fk_source;
const color = c.color || getPhaseColor(c.connection_type);
html += `
← ${escapeHtml(sourceLabel)}
${escapeHtml(c.connection_type || '')}
`;
});
html += '
';
}
// Schutzgerät-Zuordnung (fk_protection)
if (eq.fk_protection) {
const protectionEq = App.equipment.find(e => e.id == eq.fk_protection);
const protectionColor = getProtectionColor(eq.fk_protection);
if (protectionEq) {
const protLabel = protectionEq.label || protectionEq.block_label || 'Schutzgerät';
const protType = App.equipmentTypes.find(t => t.id == protectionEq.fk_equipment_type);
const protTypeLabel = protType?.label_short || protType?.label || '';
html += '';
html += '
Schutzeinrichtung
';
html += '
';
html += `
${escapeHtml(protLabel)}
${escapeHtml(protTypeLabel)} ${escapeHtml(protectionEq.block_label || '')}
`;
html += '
';
}
}
// Geschützte Geräte (wenn dieses Equipment ein Schutzgerät ist)
const protectedEquipment = App.equipment.filter(e => e.fk_protection == eq.id);
if (protectedEquipment.length) {
const protectionColor = getProtectionColor(eq.id);
html += '';
html += '
Schützt
';
html += '
';
protectedEquipment.forEach(pe => {
const peLabel = pe.label || pe.block_label || 'Equipment';
html += `
`;
});
html += '
';
}
// Position-Info
const carrier = App.carriers.find(c => c.id == eq.fk_carrier);
html += '';
html += '
Position
';
html += '
';
if (carrier) {
html += `
Hutschiene
${escapeHtml(carrier.label || 'Hutschiene')}
`;
}
html += `
TE-Position
${eq.position_te || '–'} (${eq.width_te || 1} TE breit)
`;
html += '
';
if (!html) {
html = 'Keine Details vorhanden
';
}
$('#detail-body').html(html);
// Bearbeiten-Button: Equipment-ID merken
App.detailEquipmentId = eq.id;
$('#sheet-equipment-detail').addClass('active');
}
/**
* Detail-Sheet schließen und Edit-Modal öffnen
*/
async function openEditFromDetail() {
const eqId = App.detailEquipmentId;
$('#sheet-equipment-detail').removeClass('active');
const eq = App.equipment.find(e => e.id == eqId);
if (!eq) return;
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
App.editEquipmentId = eqId;
App.currentCarrierId = eq.fk_carrier;
App.selectedTypeId = eq.fk_equipment_type;
$('#eq-fields-title').text(type?.label_short || type?.label || 'Bearbeiten');
$('#btn-save-equipment').text('Aktualisieren');
$('#btn-delete-equipment').removeClass('hidden');
$('#equipment-label').val(eq.label || '');
await loadTypeFields(eq.fk_equipment_type, eqId);
// Protection-Dropdown befüllen mit aktuellem Wert
populateProtectionDropdown(eq.fk_protection);
showEquipmentStep('fields');
openModal('add-equipment');
}
/**
* Bestätigungsdialog vor Equipment-Löschung
*/
function handleDeleteEquipmentConfirm() {
const eqId = App.editEquipmentId;
if (!eqId) return;
const eq = App.equipment.find(e => e.id == eqId);
const type = App.equipmentTypes.find(t => t.id == eq?.fk_equipment_type);
const typeName = type?.label_short || type?.ref || 'Automat';
const eqLabel = eq?.label ? ` "${eq.label}"` : '';
$('#confirm-title').text('Automat löschen?');
$('#confirm-message').text(`${typeName}${eqLabel} wirklich löschen?`);
// Callback für OK-Button
App.confirmCallback = () => deleteEquipment(eqId);
closeModal('add-equipment');
openModal('confirm');
}
/**
* Equipment löschen (nach Bestätigung)
*/
async function deleteEquipment(eqId) {
const data = {
action: 'delete_equipment',
equipment_id: eqId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.equipment = App.equipment.filter(e => e.id != eqId);
// Zugehörige Abgänge entfernen
App.outputs = App.outputs.filter(o => o.fk_source != eqId);
renderEditor();
showToast('Automat gelöscht', 'success');
} else {
showToast(response.error || 'Fehler beim Löschen', 'error');
}
} catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.id != eqId);
App.outputs = App.outputs.filter(o => o.fk_source != eqId);
renderEditor();
}
} else {
queueOfflineAction(data);
App.equipment = App.equipment.filter(e => e.id != eqId);
App.outputs = App.outputs.filter(o => o.fk_source != eqId);
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editEquipmentId = null;
}
/**
* Output-Terminal HTML erzeugen (Pfeil + Labels)
* @param {object} out - Connection-Objekt
* @param {string} phaseColor - Farbe der Phase
* @param {string} dir - 'up' oder 'down'
*/
function renderOutputLabel(out, phaseColor, dir) {
// Pfeil statt Punkt - Pfeil immer am Automaten (zwischen Block und Label)
const arrowClass = dir === 'up' ? 'terminal-arrow-up' : 'terminal-arrow-down';
const arrowHtml = ` `;
let labelHtml = '';
if (out.output_label) {
let cableInfo = '';
if (out.medium_type) cableInfo = out.medium_type;
if (out.medium_spec) cableInfo += ' ' + out.medium_spec;
if (out.medium_length) cableInfo += ' (' + out.medium_length + ')';
labelHtml = `${escapeHtml(out.output_label)}`;
if (cableInfo) labelHtml += `${escapeHtml(cableInfo.trim())} `;
labelHtml += ` `;
} else {
labelHtml = `${escapeHtml(out.connection_type || '')} `;
}
// Oben: Label zuerst, dann Pfeil (Pfeil zeigt zum Automaten darunter)
// Unten: Pfeil zuerst, dann Label (Pfeil zeigt zum Automaten darüber)
if (dir === 'up') {
return labelHtml + arrowHtml;
}
return arrowHtml + labelHtml;
}
// ============================================
// CONNECTION (TERMINAL) ACTIONS
// ============================================
/**
* Kabel-Info aus Connection zusammenbauen
*/
function buildCableInfo(conn) {
const parts = [];
if (conn.medium_type) parts.push(conn.medium_type);
if (conn.medium_spec) parts.push(conn.medium_spec);
if (conn.medium_length) parts.push('(' + conn.medium_length + ')');
return parts.join(' ');
}
/**
* Click-Handler für Terminal-Labels (zum Bearbeiten)
*/
function handleTerminalLabelClick(e) {
e.stopPropagation();
const $cell = $(this);
const connId = $cell.data('connection-id');
const eqId = $cell.data('equipment-id');
const direction = $cell.data('direction') || 'output';
if (!connId) return;
// Connection aus App-State finden
const conn = direction === 'input'
? App.inputs.find(i => i.id == connId)
: App.outputs.find(o => o.id == connId);
if (!conn) return;
// Terminal-Position ermitteln
const terminalPosition = conn.is_top ? 'top' : 'bottom';
// Connection-Bearbeitungsmodus mit vorhandenen Daten
openEditConnectionDialog(eqId, direction, terminalPosition, conn);
}
/**
* Connection-Dialog im Bearbeitungsmodus öffnen
*/
async function openEditConnectionDialog(eqId, direction, terminalPosition, conn) {
App.connectionEquipmentId = eqId;
App.connectionDirection = direction;
App.connectionTerminalPosition = terminalPosition;
App.editConnectionId = conn.id;
renderTypeSelect(direction, conn.connection_type || '');
$('#connection-modal-title').text(direction === 'input' ? 'Anschlusspunkt bearbeiten' : 'Abgang bearbeiten');
$('#btn-delete-connection').removeClass('hidden');
$('#conn-color').val(conn.color || '#3498db');
$('#conn-label').val(conn.output_label || '');
$('#conn-medium-length').val(conn.medium_length || '');
// Medium-Typen laden und Select befüllen
await loadMediumTypes();
renderMediumTypeSelect(conn.medium_type || '');
// Medium-Spec laden falls Typ gewählt
if (conn.medium_type) {
// Trigger change um Specs zu laden, dann Wert setzen
handleMediumTypeChange();
if (conn.medium_spec) {
$('#conn-medium-spec').val(conn.medium_spec);
}
} else {
$('#conn-medium-spec').html('-- Zuerst Kabeltyp wählen -- ');
}
// Side-Button auf aktuelle Terminal-Position setzen
setSideButton(terminalPosition);
// Side-Buttons immer zeigen
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
const eq = App.equipment ? App.equipment.find(e => e.id == eqId) : null;
const type = eq ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
// terminalPosition kommt bereits als Parameter
const terminalCount = getTerminalCount(type, terminalPosition, parseFloat(eq?.width_te) || 1);
if (direction === 'output' && terminalCount > 1) {
$('#conn-bundle-fields').removeClass('hidden');
$('#conn-bundle-all').prop('checked', conn.bundled_terminals === 'all');
} else {
$('#conn-bundle-fields').addClass('hidden');
}
openModal('connection');
}
// Phasen-Optionen wie auf der Website
const INPUT_PHASES = ['L1', 'L2', 'L3', '3P', '3P+N', 'PE'];
const OUTPUT_PHASES = ['LN', 'N', '3P+N', 'PE', 'DATA'];
/**
* Terminal-Anzahl aus Equipment-Typ ermitteln
* @param {object} type - Equipment-Typ mit terminals_config
* @param {string} position - 'top' oder 'bottom'
* @param {number} fallback - Fallback-Wert (normalerweise width_te)
* @returns {number} Anzahl der Terminals
*/
function getTerminalCount(type, position, fallback) {
if (!type || !type.terminals_config) return fallback;
try {
const config = typeof type.terminals_config === 'string'
? JSON.parse(type.terminals_config)
: type.terminals_config;
if (config.terminals && Array.isArray(config.terminals)) {
return config.terminals.filter(t => t.pos === position).length;
}
} catch (e) {
// JSON-Parse-Fehler ignorieren
}
return fallback;
}
/**
* Phasenfarbe ermitteln (DIN VDE Farben)
*/
function getPhaseColor(type) {
const colors = {
'L1': '#8B4513', 'L2': '#1a1a1a', 'L3': '#666',
'N': '#0066cc', 'PE': '#27ae60',
'LN': '#8B4513', // Phase+Neutral - braun wie L1
'L1N': '#8B4513', 'L2N': '#1a1a1a', 'L3N': '#666', // Legacy
'3P': '#e74c3c', '3P+N': '#e74c3c', 'DATA': '#9b59b6'
};
return colors[type] || '#888';
}
/**
* Schutzgruppen-Farbe ermitteln (eindeutig pro protection_id)
*/
const protectionColorCache = {};
function getProtectionColor(protectionId) {
if (!protectionId) return null;
if (protectionColorCache[protectionId]) return protectionColorCache[protectionId];
// Helle, gut sichtbare Farben für Schutzgruppen
const colors = [
'#e74c3c', // Rot
'#3498db', // Blau
'#f39c12', // Orange
'#9b59b6', // Lila
'#1abc9c', // Türkis
'#e91e63', // Pink
'#00bcd4', // Cyan
'#ff5722', // Deep Orange
];
const idx = Object.keys(protectionColorCache).length % colors.length;
protectionColorCache[protectionId] = colors[idx];
return colors[idx];
}
/**
* Abgangsseite-Button setzen
*/
function setSideButton(side) {
$('.side-btn').removeClass('selected');
$(`.side-btn[data-side="${side}"]`).addClass('selected');
}
/**
* Gewählte Abgangsseite auslesen
*/
function getSelectedSide() {
return $('.side-btn.selected').data('side') || 'bottom';
}
/**
* Typ-Select je nach Richtung befüllen (wie Website)
*/
function renderTypeSelect(direction, selectedType) {
const phases = direction === 'input' ? INPUT_PHASES : OUTPUT_PHASES;
let html = '-- Kein Typ -- ';
phases.forEach(p => {
const sel = (p === selectedType) ? ' selected' : '';
html += `${p} `;
});
$('#conn-type').html(html);
}
/**
* Klick auf Terminal-Zelle
* Bei vorhandener Verbindung: direkt bearbeiten
* Bei leerem Terminal: Kontextmenü mit Wahl Input/Output
*/
function handleTerminalClick(e) {
e.stopPropagation();
const $point = $(this);
const eqId = $point.data('equipment-id');
const terminalPosition = $point.data('terminal-position'); // 'top' oder 'bottom'
const connId = $point.data('connection-id');
// Bestehende Verbindung? -> Direkt bearbeiten
if (connId) {
const direction = $point.data('direction');
App.connectionEquipmentId = eqId;
App.connectionDirection = direction;
App.connectionTerminalPosition = terminalPosition;
App.editConnectionId = connId;
$('#connection-modal-title').text('Verbindung bearbeiten');
$('#btn-delete-connection').removeClass('hidden');
const conn = direction === 'input'
? App.inputs.find(i => i.id == connId)
: App.outputs.find(o => o.id == connId);
if (conn) {
renderTypeSelect(direction, conn.connection_type);
$('#conn-color').val(conn.color || '#3498db');
$('#conn-label').val(conn.output_label || '');
$('#conn-medium-length').val(conn.medium_length || '');
// Medium-Typen laden und Select befüllen
loadMediumTypes().then(() => {
renderMediumTypeSelect(conn.medium_type || '');
// Spezifikation Select befüllen basierend auf gewähltem Typ
handleMediumTypeChange();
// Gespeicherte Spezifikation setzen
if (conn.medium_spec) {
$('#conn-medium-spec').val(conn.medium_spec);
}
});
// Terminal-Position aus gespeicherter Verbindung
const connIsTop = conn.is_top || (conn.target_terminal_id === 't1');
setSideButton(connIsTop ? 'top' : 'bottom');
}
// Side-Buttons immer zeigen (Automaten haben keine feste Richtung)
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
const eq = App.equipment ? App.equipment.find(e => e.id == eqId) : null;
const type = eq ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
const connIsTop = conn && (conn.is_top || conn.target_terminal_id === 't1');
const termCount = getTerminalCount(type, connIsTop ? 'top' : 'bottom', parseFloat(eq?.width_te) || 1);
if (direction === 'output' && termCount > 1) {
$('#conn-bundle-fields').removeClass('hidden');
$('#conn-bundle-all').prop('checked', conn && conn.bundled_terminals === 'all');
} else {
$('#conn-bundle-fields').addClass('hidden');
}
openModal('connection');
} else {
// Leerer Terminal -> Kontextmenü anzeigen
showTerminalContextMenu(e, eqId, terminalPosition);
}
}
/**
* Kontextmenü für leere Terminals: Wahl zwischen Anschlusspunkt und Abgang
*/
function showTerminalContextMenu(e, eqId, terminalPosition) {
// Altes Menü entfernen
$('.terminal-context-menu').remove();
const x = e.touches ? e.touches[0].clientX : e.clientX;
const y = e.touches ? e.touches[0].clientY : e.clientY;
const html = `
`;
$('body').append(html);
// Click Handler
$('.tcm-item').on('click', function() {
const direction = $(this).data('type');
$('.terminal-context-menu').remove();
openConnectionDialog(eqId, direction, terminalPosition);
});
// Schließen bei Klick außerhalb
setTimeout(() => {
$(document).one('click', () => $('.terminal-context-menu').remove());
}, 10);
}
/**
* Medium-Typen (Kabeltypen) aus DB laden und cachen
*/
async function loadMediumTypes() {
if (App.mediumTypes) return App.mediumTypes;
try {
// Nutze pwa_api.php für Token-basierte Authentifizierung
const response = await apiCall('ajax/pwa_api.php', {
action: 'get_medium_types'
});
console.log('[PWA] loadMediumTypes response:', response);
if (response.success && response.groups) {
App.mediumTypes = response.groups;
// Cache für Offline
localStorage.setItem('kundenkarte_medium_types', JSON.stringify(response.groups));
return response.groups;
} else {
console.warn('[PWA] loadMediumTypes: no groups in response');
}
} catch (err) {
console.error('[PWA] loadMediumTypes error:', err);
// Fallback auf Cache
const cached = localStorage.getItem('kundenkarte_medium_types');
if (cached) {
App.mediumTypes = JSON.parse(cached);
return App.mediumTypes;
}
}
// Fallback auf statische Liste
return null;
}
/**
* Medium-Type Select befüllen
*/
function renderMediumTypeSelect(selectedValue) {
const groups = App.mediumTypes;
let html = '-- Auswählen -- ';
if (groups && groups.length > 0) {
groups.forEach(group => {
html += ``;
group.types.forEach(t => {
const selected = (selectedValue === t.ref) ? ' selected' : '';
const specs = t.available_specs ? ` data-specs='${JSON.stringify(t.available_specs)}'` : '';
const defSpec = t.default_spec ? ` data-default="${escapeHtml(t.default_spec)}"` : '';
html += `${escapeHtml(t.label)} `;
});
html += ' ';
});
} else {
// Fallback auf statische Liste
['NYM-J', 'NYY-J', 'H07V-K', 'CAT6', 'CAT7'].forEach(t => {
const selected = (selectedValue === t) ? ' selected' : '';
html += `${t} `;
});
}
$('#conn-medium-type').html(html);
}
/**
* Medium-Type Change Handler - Spezifikationen laden
*/
function handleMediumTypeChange() {
const $option = $('#conn-medium-type option:selected');
const specs = $option.data('specs');
const defaultSpec = $option.data('default');
let html = '-- Auswählen -- ';
if (specs && specs.length > 0) {
specs.forEach(spec => {
const selected = (spec === defaultSpec) ? ' selected' : '';
html += `${escapeHtml(spec)} `;
});
} else {
html = '-- Keine Auswahl -- ';
}
$('#conn-medium-spec').html(html);
}
/**
* Verbindungs-Dialog öffnen (nach Auswahl Input/Output)
*/
async function openConnectionDialog(eqId, direction, terminalPosition) {
App.connectionEquipmentId = eqId;
App.connectionDirection = direction;
App.connectionTerminalPosition = terminalPosition;
App.editConnectionId = null;
renderTypeSelect(direction, '');
$('#connection-modal-title').text(direction === 'input' ? 'Anschlusspunkt' : 'Abgang');
$('#btn-delete-connection').addClass('hidden');
$('#conn-color').val('#3498db');
$('#conn-label').val('');
$('#conn-medium-length').val('');
// Medium-Typen laden und Select befüllen
await loadMediumTypes();
renderMediumTypeSelect('');
$('#conn-medium-spec').html('-- Zuerst Kabeltyp wählen -- ');
// Side-Button auf aktuelle Terminal-Position setzen
setSideButton(terminalPosition || 'bottom');
// Side-Buttons immer zeigen (Automaten haben keine feste Richtung)
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
const eq = App.equipment ? App.equipment.find(e => e.id == eqId) : null;
const type = eq ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
const termCount = getTerminalCount(type, terminalPosition || 'bottom', parseFloat(eq?.width_te) || 1);
if (direction === 'output' && termCount > 1) {
$('#conn-bundle-fields').removeClass('hidden');
$('#conn-bundle-all').prop('checked', false);
} else {
$('#conn-bundle-fields').addClass('hidden');
}
openModal('connection');
}
/**
* Connection speichern (Neu oder Update)
*/
async function handleSaveConnection() {
const connectionType = $('#conn-type').val() || '';
const color = $('#conn-color').val() || '#3498db';
const outputLabel = $('#conn-label').val().trim();
const isOutput = App.connectionDirection === 'output';
const mediumType = isOutput ? ($('#conn-medium-type').val().trim() || '') : '';
const mediumSpec = isOutput ? ($('#conn-medium-spec').val().trim() || '') : '';
const mediumLength = isOutput ? ($('#conn-medium-length').val().trim() || '') : '';
const bundledTerminals = isOutput && $('#conn-bundle-all').is(':checked') ? 'all' : '';
// Terminal-Position: t1=oben, t2=unten (gilt für Input UND Output)
// Bei Bearbeitung: Side-Button-Auswahl verwenden, sonst die ursprüngliche Position
const terminalPosition = App.editConnectionId ? getSelectedSide() : (App.connectionTerminalPosition || 'bottom');
const isTop = terminalPosition === 'top';
const terminalId = isTop ? 't1' : 't2';
// Für Output: source_terminal
const sourceTerminalId = isOutput ? terminalId : '';
const sourceTerminal = isOutput ? (isTop ? 'top' : 'output') : '';
// Für Input: target_terminal
const targetTerminalId = !isOutput ? terminalId : '';
closeModal('connection');
if (App.editConnectionId) {
// Update
const data = {
action: 'update_connection',
connection_id: App.editConnectionId,
connection_type: connectionType,
color: color,
output_label: outputLabel,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
source_terminal: sourceTerminal,
source_terminal_id: sourceTerminalId,
bundled_terminals: bundledTerminals
};
const updateLocal = (conn) => {
if (!conn) return;
conn.connection_type = connectionType;
conn.color = color;
conn.output_label = outputLabel;
conn.medium_type = mediumType;
conn.medium_spec = mediumSpec;
conn.medium_length = mediumLength;
conn.bundled_terminals = bundledTerminals;
if (isOutput) {
conn.is_top = isTop;
conn.source_terminal_id = sourceTerminalId;
}
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const list = App.connectionDirection === 'input' ? App.inputs : App.outputs;
updateLocal(list.find(c => c.id == App.editConnectionId));
renderEditor();
showToast('Verbindung aktualisiert', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
queueOfflineAction(data);
showToast('Wird synchronisiert...', 'warning');
}
} else {
queueOfflineAction(data);
const list = App.connectionDirection === 'input' ? App.inputs : App.outputs;
updateLocal(list.find(c => c.id == App.editConnectionId));
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
} else {
// Neu anlegen
const data = {
action: 'create_connection',
equipment_id: App.connectionEquipmentId,
direction: App.connectionDirection,
connection_type: connectionType,
color: color,
output_label: outputLabel,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
source_terminal: sourceTerminal,
source_terminal_id: sourceTerminalId,
target_terminal_id: targetTerminalId,
bundled_terminals: bundledTerminals
};
const newConnBase = {
connection_type: connectionType,
color: color,
output_label: outputLabel,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
bundled_terminals: bundledTerminals,
is_top: isTop,
source_terminal_id: sourceTerminalId,
target_terminal_id: targetTerminalId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const newConn = Object.assign({ id: response.connection_id }, newConnBase);
if (App.connectionDirection === 'input') {
newConn.fk_target = App.connectionEquipmentId;
App.inputs.push(newConn);
} else {
newConn.fk_source = App.connectionEquipmentId;
App.outputs.push(newConn);
}
renderEditor();
showToast('Verbindung angelegt', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
queueOfflineAction(data);
showToast('Wird synchronisiert...', 'warning');
}
} else {
queueOfflineAction(data);
const newConn = Object.assign({ id: 'temp_' + Date.now() }, newConnBase);
if (App.connectionDirection === 'input') {
newConn.fk_target = App.connectionEquipmentId;
App.inputs.push(newConn);
} else {
newConn.fk_source = App.connectionEquipmentId;
App.outputs.push(newConn);
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
}
App.editConnectionId = null;
}
/**
* Connection löschen (mit Bestätigung)
*/
function handleDeleteConnectionConfirm() {
const connId = App.editConnectionId;
if (!connId) return;
$('#confirm-title').text('Verbindung löschen?');
$('#confirm-message').text('Diese Verbindung wirklich löschen?');
App.confirmCallback = () => deleteConnection(connId);
closeModal('connection');
openModal('confirm');
}
async function deleteConnection(connId) {
const data = {
action: 'delete_connection',
connection_id: connId
};
if (App.isOnline) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
App.outputs = App.outputs.filter(o => o.id != connId);
App.inputs = App.inputs.filter(i => i.id != connId);
renderEditor();
showToast('Verbindung gelöscht', 'success');
} else {
showToast(response.error || 'Fehler', 'error');
}
} catch (err) {
queueOfflineAction(data);
App.outputs = App.outputs.filter(o => o.id != connId);
App.inputs = App.inputs.filter(i => i.id != connId);
renderEditor();
}
} else {
queueOfflineAction(data);
App.outputs = App.outputs.filter(o => o.id != connId);
App.inputs = App.inputs.filter(i => i.id != connId);
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
App.editConnectionId = null;
}
// ============================================
// OFFLINE SYNC
// ============================================
function queueOfflineAction(data) {
data._timestamp = Date.now();
App.offlineQueue.push(data);
localStorage.setItem('kundenkarte_offline_queue', JSON.stringify(App.offlineQueue));
updateSyncBadge();
}
function updateSyncBadge() {
const count = App.offlineQueue.length;
const $badge = $('#sync-badge');
if (count > 0) {
$badge.text(count).removeClass('hidden');
} else {
$badge.addClass('hidden');
}
}
async function handleRefresh() {
// Zuerst Offline-Queue syncen falls vorhanden
if (App.offlineQueue.length && App.isOnline) {
await syncOfflineChanges();
}
// Dann Daten neu laden
showToast('Aktualisiere...');
await loadEditorData();
}
async function syncOfflineChanges() {
if (!App.offlineQueue.length) {
showToast('Alles synchronisiert');
return;
}
if (!App.isOnline) {
showToast('Offline - Sync nicht möglich', 'error');
return;
}
showToast('Synchronisiere...');
const queue = [...App.offlineQueue];
let successCount = 0;
for (const data of queue) {
try {
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
successCount++;
// Remove from queue
const idx = App.offlineQueue.findIndex(q => q._timestamp === data._timestamp);
if (idx > -1) App.offlineQueue.splice(idx, 1);
}
} catch (err) {
console.error('Sync failed for:', data, err);
}
}
localStorage.setItem('kundenkarte_offline_queue', JSON.stringify(App.offlineQueue));
updateSyncBadge();
if (successCount === queue.length) {
showToast('Alle Änderungen synchronisiert', 'success');
// Reload data
loadEditorData();
} else {
showToast(`${successCount}/${queue.length} synchronisiert`, 'warning');
}
}
// ============================================
// API HELPER
// ============================================
async function apiCall(endpoint, data = {}) {
const url = window.MODULE_URL + '/' + endpoint;
// Add token
if (App.token) {
data.token = App.token;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(data)
});
if (!response.ok) {
throw new Error('Network error');
}
return response.json();
}
// ============================================
// UI HELPERS
// ============================================
function openModal(name) {
$('#modal-' + name).addClass('active');
}
function closeModal(name) {
$('#modal-' + name).removeClass('active');
}
function showToast(message, type = '') {
const $toast = $('#toast');
$toast.text(message).removeClass('success error warning visible').addClass(type);
setTimeout(() => $toast.addClass('visible'), 10);
setTimeout(() => $toast.removeClass('visible'), 3000);
}
function showOfflineBar() {
// Status-Indikator entfernt - nur Toast-Nachricht
showToast('Offline', 'warning');
}
function hideOfflineBar() {
// Status-Indikator entfernt
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// jQuery wird als $ Parameter der IIFE übergeben
// ============================================
// START
// ============================================
document.addEventListener('DOMContentLoaded', init);
})(jQuery);