kundenkarte/js/pwa.js
data dcd00fe844 feat(pwa): Equipment-Detail Bottom-Sheet
Tipp auf Equipment-Block zeigt jetzt Detail-Ansicht statt direkt
den Bearbeiten-Dialog zu öffnen. Bottom-Sheet mit:
- Typ-Badge + Bezeichnung
- Alle Feldwerte
- Abgänge mit Phasenfarben und Medium-Info
- Einspeisungen
- Position (Hutschiene + TE)
- "Bearbeiten"-Button öffnet Edit-Dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:28:30 +01:00

2110 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('<div class="loading-container"><div class="spinner"></div></div>');
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('<div class="list-empty">Keine Anlagen gefunden</div>');
}
} 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('<div class="list-empty">Fehler beim Laden</div>');
}
}
}
// ============================================
// CUSTOMER SEARCH
// ============================================
async function handleSearch() {
const query = $('#search-customer').val().trim();
if (query.length < 2) {
$('#customer-list').html('<div class="list-empty">Mindestens 2 Zeichen eingeben...</div>');
return;
}
$('#customer-list').html('<div class="loading-container"><div class="spinner"></div></div>');
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('<div class="list-empty">Keine Kunden gefunden</div>');
}
} catch (err) {
$('#customer-list').html('<div class="list-empty">Fehler bei der Suche</div>');
}
}
function renderCustomerList(customers) {
if (!customers.length) {
$('#customer-list').html('<div class="list-empty">Keine Kunden gefunden</div>');
return;
}
let html = '';
customers.forEach(c => {
html += `
<div class="list-item" data-id="${c.id}">
<div class="list-item-icon">
<svg viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/></svg>
</div>
<div class="list-item-content">
<div class="list-item-title">${escapeHtml(c.name)}</div>
<div class="list-item-subtitle">${escapeHtml(c.town || '')}</div>
</div>
<svg class="list-item-arrow" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div>
`;
});
$('#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('<div class="loading-container"><div class="spinner"></div></div>');
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('<div class="list-empty">Keine Anlagen gefunden</div>');
}
} 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('<div class="list-empty">Fehler beim Laden</div>');
}
}
}
function renderAnlagenList(anlagen, contacts) {
let html = '';
// Kontakt-Adressen (Gebäude/Standorte) als Liste
if (contacts && contacts.length) {
html += '<div class="contact-list">';
contacts.forEach(c => {
const subtitle = [c.address, c.town].filter(Boolean).join(', ');
html += `
<div class="contact-group" data-contact-id="${c.id}" data-customer-id="${App.customerId}">
<div class="contact-group-header">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
<div class="contact-group-info">
<div class="contact-group-name">${escapeHtml(c.name)}</div>
${subtitle ? '<div class="contact-group-address">' + escapeHtml(subtitle) + '</div>' : ''}
</div>
<span class="contact-group-count">${c.anlage_count}</span>
<svg class="contact-group-chevron" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</div>
<div class="contact-anlagen-list"></div>
</div>
`;
});
html += '</div>';
}
// Kunden-Anlagen (ohne Kontaktzuweisung) als Baum darunter
if (anlagen && anlagen.length) {
if (contacts && contacts.length && App.customerAddress) {
html += `<div class="anlagen-section-label">${escapeHtml(App.customerName)} ${escapeHtml(App.customerAddress)}</div>`;
}
html += renderTreeNodes(anlagen, 0);
}
if (!html) {
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
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 = '<div class="anlage-card-fields">';
a.fields.forEach(f => {
const style = f.color ? ` style="background:${f.color}"` : '';
fieldsHtml += `<span class="anlage-field-badge"${style}>${escapeHtml(f.value)}</span>`;
});
fieldsHtml += '</div>';
}
// Icons je nach Typ
let iconSvg;
if (isEquipment) {
// Schaltschrank/Verteiler
iconSvg = '<svg viewBox="0 0 24 24"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 7H7v2h2V7zm0 4H7v2h2v-2zm0 4H7v2h2v-2zm8-8h-6v2h6V7zm0 4h-6v2h6v-2zm0 4h-6v2h6v-2z"/></svg>';
} else if (isStructure) {
// Gebäude/Raum
iconSvg = '<svg viewBox="0 0 24 24"><path d="M12 3L2 12h3v8h6v-6h2v6h6v-8h3L12 3zm0 2.84L18 12v7h-2v-6H8v6H6v-7l6-6.16z"/></svg>';
} else {
// Endgerät
iconSvg = '<svg viewBox="0 0 24 24"><path d="M20 18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"/></svg>';
}
html += `<div class="pwa-tree-node ${typeClass}${hasChildren ? ' has-children' : ''}" data-id="${a.id}" data-level="${level}">`;
html += `<div class="pwa-tree-row" style="padding-left:${12 + level * 20}px">`;
// Toggle-Chevron (nur bei Kindern)
if (hasChildren) {
html += '<svg class="pwa-tree-toggle" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
} else {
html += '<span class="pwa-tree-toggle-spacer"></span>';
}
// Icon
html += `<div class="pwa-tree-icon ${typeClass}">${iconSvg}</div>`;
// Inhalt
html += '<div class="pwa-tree-content">';
html += `<div class="pwa-tree-label">${escapeHtml(a.label || 'Anlage ' + a.id)}</div>`;
if (a.type) html += `<div class="pwa-tree-type">${escapeHtml(a.type)}</div>`;
html += fieldsHtml;
html += '</div>';
// Editor-Pfeil nur bei Equipment-Containern
if (isEquipment) {
html += '<svg class="pwa-tree-open" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>';
}
html += '</div>'; // pwa-tree-row
// Kinder (eingeklappt)
if (hasChildren) {
html += '<div class="pwa-tree-children">';
html += renderTreeNodes(a.children, level + 1);
html += '</div>';
}
html += '</div>'; // 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('<div class="loading-container"><div class="spinner small"></div></div>');
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('<div class="list-empty small">Keine Anlagen</div>');
}
} catch (err) {
$list.html('<div class="list-empty small">Fehler beim Laden</div>');
}
}
// ============================================
// EDITOR
// ============================================
async function loadEditorData() {
$('#editor-content').html('<div class="loading-container"><div class="spinner"></div><div class="text-muted">Lade Daten...</div></div>');
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('<div class="list-empty">Fehler beim Laden</div>');
}
}
}
function renderEditor() {
if (!App.panels.length) {
$('#editor-content').html('<div class="list-empty">Noch keine Felder angelegt.<br>Tippe auf "+ Feld" um zu beginnen.</div>');
return;
}
let html = '';
App.panels.forEach(panel => {
const panelCarriers = App.carriers.filter(c => c.fk_panel == panel.id);
html += `
<div class="panel-card" data-panel-id="${panel.id}">
<div class="panel-header">
<div class="panel-title">${escapeHtml(panel.label || 'Feld ' + panel.id)}</div>
</div>
<div class="panel-body">
`;
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 += `
<div class="carrier-item" data-carrier-id="${carrier.id}">
<div class="carrier-header">
<span class="carrier-label">${escapeHtml(carrier.label || 'Hutschiene')}</span>
<span class="carrier-te">${usedTe}/${totalTe} TE</span>
</div>
<div class="carrier-content" style="grid-template-columns: repeat(${totalTe}, 1fr) auto">
`;
// === 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 += `<span class="terminal-point terminal-output" data-equipment-id="${eq.id}" data-direction="output" data-connection-id="${topOut.id}" style="${style}">`;
html += renderOutputLabel(topOut, phaseColor, 'up');
html += `</span>`;
} else if (inp) {
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
html += `<span class="terminal-point terminal-input" data-equipment-id="${eq.id}" data-direction="input" data-connection-id="${inp.id}" style="${style}">`;
html += `<span class="terminal-dot" style="background:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(inp.connection_type || '')}</span>`;
html += `</span>`;
} else {
html += `<span class="terminal-point terminal-input" data-equipment-id="${eq.id}" data-direction="input" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-empty"></span>`;
html += `</span>`;
}
}
});
// === 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 += `
<div class="equipment-block" data-equipment-id="${eq.id}" style="background:${blockColor}; ${gridCol}">
<span class="equipment-block-type">${escapeHtml(typeLabel)}</span>
${showBlockFields ? `<span class="equipment-block-value">${escapeHtml(blockFields)}</span>` : ''}
<span class="equipment-block-label">${escapeHtml(eqLabel)}</span>
</div>
`;
});
// +-Button in letzter Spalte (auto), Zeile 2
html += `
<button class="btn-add-equipment${isFull ? ' disabled' : ''}" data-carrier-id="${carrier.id}"${isFull ? ' disabled' : ''} style="grid-row:2; grid-column:-1">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button>
`;
// === 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 += `<span class="terminal-point terminal-output" data-equipment-id="${eq.id}" data-direction="output" data-connection-id="${out.id}" style="${style}">`;
html += renderOutputLabel(out, phaseColor, 'down');
html += `</span>`;
} else {
html += `<span class="terminal-point terminal-output" data-equipment-id="${eq.id}" data-direction="output" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-empty"></span>`;
html += `</span>`;
}
}
});
html += `</div></div>`;
});
html += `
<button class="btn-add-carrier" data-panel-id="${panel.id}">
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
Hutschiene hinzufügen
</button>
</div>
</div>
`;
});
$('#editor-content').html(html);
// Load type grid
renderTypeGrid();
}
function renderTypeGrid() {
let html = '';
App.equipmentTypes.forEach(type => {
html += `
<button class="type-btn" data-type-id="${type.id}" data-width="${type.width_te || 1}">
<div class="type-btn-icon" style="color:${type.color || '#3498db'}">⚡</div>
<div class="type-btn-label">${escapeHtml(type.label_short || type.ref || type.label)}</div>
</button>
`;
});
$('#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('<div class="loading-container"><div class="spinner small"></div></div>');
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('<div class="list-empty small">Offline - Felder nicht verfügbar</div>');
}
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 ? ' <span class="field-required">*</span>' : '';
const val = field.value || '';
html += `<div class="form-group">`;
html += `<label>${escapeHtml(field.label)}${req}</label>`;
switch (field.type) {
case 'select':
html += `<select name="eq_field_${field.code}" class="form-select">`;
html += `<option value="">--</option>`;
if (field.options) {
field.options.split('|').forEach(opt => {
const selected = (opt === val) ? ' selected' : '';
html += `<option value="${escapeHtml(opt)}"${selected}>${escapeHtml(opt)}</option>`;
});
}
html += `</select>`;
break;
case 'number':
html += `<input type="number" name="eq_field_${field.code}" class="form-input" value="${escapeHtml(val)}">`;
break;
case 'checkbox':
const checked = val === '1' || val === 'true' ? ' checked' : '';
html += `<label class="checkbox-label"><input type="checkbox" name="eq_field_${field.code}" value="1"${checked}> ${escapeHtml(field.label)}</label>`;
break;
case 'textarea':
html += `<textarea name="eq_field_${field.code}" class="form-input" rows="3">${escapeHtml(val)}</textarea>`;
break;
default: // text
html += `<input type="text" name="eq_field_${field.code}" class="form-input" value="${escapeHtml(val)}">`;
}
html += `</div>`;
});
$('#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 += '<div class="detail-section">';
html += '<div class="detail-section-title">Werte</div>';
html += '<div class="detail-field-list">';
for (const [key, val] of Object.entries(eq.field_values)) {
if (val === '' || val === null || val === undefined) continue;
html += `<div class="detail-field-row">
<span class="detail-field-label">${escapeHtml(key)}</span>
<span class="detail-field-value">${escapeHtml(String(val))}</span>
</div>`;
}
html += '</div></div>';
}
// Abgänge (Outputs)
const outputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id) : [];
if (outputs.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Abgänge</div>';
html += '<div class="detail-conn-list">';
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 ? '&#9650;' : '&#9660;';
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${color}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">${escapeHtml(label)}</div>
${meta ? '<div class="detail-conn-meta">' + escapeHtml(meta) + '</div>' : ''}
</div>
<span class="detail-conn-arrow">${arrow}</span>
</div>`;
});
html += '</div></div>';
}
// Einspeisungen (Inputs)
const inputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id) : [];
if (inputs.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Einspeisungen</div>';
html += '<div class="detail-conn-list">';
inputs.forEach(i => {
const color = i.color || getPhaseColor(i.connection_type);
const label = i.output_label || i.connection_type || 'Einspeisung';
html += `<div class="detail-conn-item">
<span class="detail-conn-dot" style="background:${color}"></span>
<div class="detail-conn-info">
<div class="detail-conn-label">${escapeHtml(label)}</div>
</div>
<span class="detail-conn-arrow">&#9660;</span>
</div>`;
});
html += '</div></div>';
}
// Position-Info
const carrier = App.carriers.find(c => c.id == eq.fk_carrier);
html += '<div class="detail-section">';
html += '<div class="detail-section-title">Position</div>';
html += '<div class="detail-field-list">';
if (carrier) {
html += `<div class="detail-field-row">
<span class="detail-field-label">Hutschiene</span>
<span class="detail-field-value">${escapeHtml(carrier.label || 'Hutschiene')}</span>
</div>`;
}
html += `<div class="detail-field-row">
<span class="detail-field-label">TE-Position</span>
<span class="detail-field-value">${eq.position_te || ''} (${eq.width_te || 1} TE breit)</span>
</div>`;
html += '</div></div>';
if (!html) {
html = '<p class="text-muted text-center">Keine Details vorhanden</p>';
}
$('#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 = `<span class="terminal-arrow ${arrowClass}" style="--arrow-color:${phaseColor}"></span>`;
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 = `<span class="terminal-label">${escapeHtml(out.output_label)}`;
if (cableInfo) labelHtml += `<br><small class="cable-info">${escapeHtml(cableInfo.trim())}</small>`;
labelHtml += `</span>`;
} else {
labelHtml = `<span class="terminal-phase">${escapeHtml(out.connection_type || '')}</span>`;
}
// 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 = '<option value="">-- Kein Typ --</option>';
phases.forEach(p => {
const sel = (p === selectedType) ? ' selected' : '';
html += `<option value="${p}"${sel}>${p}</option>`;
});
$('#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);