- Terminal→Leitung Verbindungen: Klick auf Leitung im Zeichenmodus erstellt Verbindung zum Ziel-Equipment der angeklickten Leitung - Dialog übernimmt Werte (Typ, Farbe, Kabel) von der Ziel-Leitung - Zeichenmodus bleibt aktiv nach Verbindungserstellung - Neue Phasen-Farben: LN (braun), DATA (lila) - Debug-Logging per Flag steuerbar (DEBUG: false) - Layout-Optimierungen: Routing-Zone, Output-Zone Höhen angepasst Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3081 lines
103 KiB
JavaScript
Executable file
3081 lines
103 KiB
JavaScript
Executable file
/**
|
||
* 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 += `
|
||
<div class="list-item" data-id="${c.id}">
|
||
<div class="list-item-icon">
|
||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||
</svg>
|
||
</div>
|
||
<div class="list-item-content">
|
||
<div class="list-item-title">${escapeHtml(c.name)}</div>
|
||
<div class="list-item-subtitle">${escapeHtml(c.address || '')}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
$('#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('<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);
|
||
|
||
// Zu "Zuletzt bearbeitet" hinzufügen
|
||
addToRecentCustomers(id, name, address);
|
||
|
||
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 || [];
|
||
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('<div class="list-empty">Fehler beim Laden</div>');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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(`<option value="${device.id}"${selected}>${escapeHtml(device.display_label)}</option>`);
|
||
});
|
||
}
|
||
|
||
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 + (parseFloat(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: 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 += `<span class="terminal-label-cell label-row-top bundled-label" style="${gridColStyle}" data-connection-id="${bundledTop.id}" data-equipment-id="${eq.id}" data-direction="output">`;
|
||
if (bundledTop.output_label) {
|
||
html += `<span class="terminal-label">${escapeHtml(bundledTop.output_label)}`;
|
||
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
|
||
html += `</span>`;
|
||
}
|
||
html += `</span>`;
|
||
} 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 += `<span class="terminal-label-cell label-row-top" style="${style}" data-connection-id="${topOut.id}" data-equipment-id="${eq.id}" data-direction="output">`;
|
||
html += `<span class="terminal-label">${escapeHtml(topOut.output_label)}`;
|
||
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
|
||
html += `</span>`;
|
||
html += `</span>`;
|
||
} else {
|
||
html += `<span class="terminal-label-cell empty label-row-top" style="${style}"></span>`;
|
||
}
|
||
}
|
||
// 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 += `<span class="terminal-label-cell empty label-row-top" style="${style}"></span>`;
|
||
}
|
||
}
|
||
});
|
||
|
||
// === 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 += `<span class="terminal-point terminal-output terminal-row-top bundled-output" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="top" data-connection-id="${bundledTop.id}" style="${bundledStyle}">`;
|
||
html += `<span class="terminal-arrow terminal-arrow-up" style="--arrow-color:${phaseColor}"></span>`;
|
||
html += `<span class="terminal-phase">${escapeHtml(bundledTop.connection_type || '')}</span>`;
|
||
html += `</span>`;
|
||
}
|
||
// 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 += `<span class="terminal-point terminal-output terminal-row-top" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="top" data-connection-id="${topOut.id}" style="${style}">`;
|
||
html += `<span class="terminal-arrow terminal-arrow-up" style="--arrow-color:${phaseColor}"></span>`;
|
||
html += `<span class="terminal-phase">${escapeHtml(topOut.connection_type || '')}</span>`;
|
||
html += `</span>`;
|
||
} else if (inp) {
|
||
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
|
||
html += `<span class="terminal-point terminal-input terminal-row-top" data-equipment-id="${eq.id}" data-direction="input" data-terminal-position="top" 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 {
|
||
// Leerer Terminal - neutral, Position "top"
|
||
html += `<span class="terminal-point terminal-empty terminal-row-top" data-equipment-id="${eq.id}" data-terminal-position="top" data-connection-id="" style="${style}">`;
|
||
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
|
||
html += `</span>`;
|
||
}
|
||
}
|
||
// 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 += `<span class="terminal-point terminal-empty no-terminal terminal-row-top" style="${style}"></span>`;
|
||
}
|
||
});
|
||
|
||
// === 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 += `
|
||
<div class="equipment-block${protectionClass}" data-equipment-id="${eq.id}" data-protection-id="${eq.fk_protection || ''}" style="background:${blockColor}; ${protectionStyle} ${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 3
|
||
html += `
|
||
<button class="btn-add-equipment${isFull ? ' disabled' : ''}" data-carrier-id="${carrier.id}"${isFull ? ' disabled' : ''} style="grid-row:3; grid-column:-1">
|
||
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||
</button>
|
||
`;
|
||
|
||
// === 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 += `<span class="terminal-point terminal-output terminal-row-bottom bundled-output" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="bottom" data-connection-id="${bundledBottom.id}" style="${bundledStyle}">`;
|
||
html += `<span class="terminal-arrow terminal-arrow-down" style="--arrow-color:${phaseColor}"></span>`;
|
||
html += `<span class="terminal-phase">${escapeHtml(bundledBottom.connection_type || '')}</span>`;
|
||
html += `</span>`;
|
||
}
|
||
// 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 += `<span class="terminal-point terminal-output terminal-row-bottom" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="bottom" data-connection-id="${out.id}" style="${style}">`;
|
||
html += `<span class="terminal-arrow terminal-arrow-down" style="--arrow-color:${phaseColor}"></span>`;
|
||
html += `<span class="terminal-phase">${escapeHtml(out.connection_type || '')}</span>`;
|
||
html += `</span>`;
|
||
} else if (inp) {
|
||
const phaseColor = inp.color || getPhaseColor(inp.connection_type);
|
||
html += `<span class="terminal-point terminal-input terminal-row-bottom" data-equipment-id="${eq.id}" data-direction="input" data-terminal-position="bottom" 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 {
|
||
// Leerer Terminal - neutral, Position "bottom"
|
||
html += `<span class="terminal-point terminal-empty terminal-row-bottom" data-equipment-id="${eq.id}" data-terminal-position="bottom" data-connection-id="" style="${style}">`;
|
||
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
|
||
html += `</span>`;
|
||
}
|
||
}
|
||
// 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 += `<span class="terminal-point terminal-empty no-terminal terminal-row-bottom" style="${style}"></span>`;
|
||
}
|
||
});
|
||
|
||
// === 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 += `<span class="terminal-label-cell label-row-bottom bundled-label" style="${gridColStyle}" data-connection-id="${bundledBottom.id}" data-equipment-id="${eq.id}" data-direction="output">`;
|
||
if (bundledBottom.output_label) {
|
||
html += `<span class="terminal-label">${escapeHtml(bundledBottom.output_label)}`;
|
||
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
|
||
html += `</span>`;
|
||
}
|
||
html += `</span>`;
|
||
} 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 += `<span class="terminal-label-cell label-row-bottom" style="${style}" data-connection-id="${out.id}" data-equipment-id="${eq.id}" data-direction="output">`;
|
||
html += `<span class="terminal-label">${escapeHtml(out.output_label)}`;
|
||
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
|
||
html += `</span>`;
|
||
html += `</span>`;
|
||
} else {
|
||
html += `<span class="terminal-label-cell empty label-row-bottom" style="${style}"></span>`;
|
||
}
|
||
}
|
||
// 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 += `<span class="terminal-label-cell empty label-row-bottom" style="${style}"></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);
|
||
|
||
// 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 = $('<svg class="connection-lines-svg"></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 += `<path class="connection-shadow" d="${scaledPath}" />`;
|
||
// Hauptpfad
|
||
svgContent += `<path class="connection-line" d="${scaledPath}" style="stroke:${color}" data-connection-id="${conn.id}" />`;
|
||
|
||
// 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 += `<rect x="${labelPos.x - labelWidth/2}" y="${labelPos.y - 8}" width="${labelWidth}" height="16" rx="3" fill="#1a1a1a" stroke="${color}" stroke-width="1"/>`;
|
||
svgContent += `<text x="${labelPos.x}" y="${labelPos.y + 4}" text-anchor="middle" fill="${color}" font-size="10" font-weight="bold">${escapeHtml(conn.output_label)}</text>`;
|
||
}
|
||
}
|
||
});
|
||
|
||
$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 += `<div class="type-grid-category">${escapeHtml(categoryLabels[cat] || cat)}</div>`;
|
||
groups[cat].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);
|
||
}
|
||
|
||
// ============================================
|
||
// 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('<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 => {
|
||
opt = opt.trim();
|
||
if (!opt) return;
|
||
const selected = (opt === val.trim()) ? ' 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 = 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 += '<div class="detail-section">';
|
||
html += '<div class="detail-section-title">Werte</div>';
|
||
html += '<div class="detail-field-list">';
|
||
|
||
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 += `<div class="detail-field-row">
|
||
<span class="detail-field-label">${escapeHtml(fm.label)}</span>
|
||
<span class="detail-field-value">${escapeHtml(String(val))}</span>
|
||
</div>`;
|
||
});
|
||
} else {
|
||
// Fallback: Code als Label
|
||
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 ? '▲' : '▼';
|
||
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">▼</span>
|
||
</div>`;
|
||
});
|
||
html += '</div></div>';
|
||
}
|
||
|
||
// 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 += '<div class="detail-section">';
|
||
html += '<div class="detail-section-title">Verbindungen</div>';
|
||
html += '<div class="detail-conn-list">';
|
||
|
||
// 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 += `<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(targetLabel)}</div>
|
||
<div class="detail-conn-meta">${escapeHtml(c.connection_type || '')}</div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
|
||
// 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 += `<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(sourceLabel)}</div>
|
||
<div class="detail-conn-meta">${escapeHtml(c.connection_type || '')}</div>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
// 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 += '<div class="detail-section">';
|
||
html += '<div class="detail-section-title">Schutzeinrichtung</div>';
|
||
html += '<div class="detail-conn-list">';
|
||
html += `<div class="detail-conn-item">
|
||
<span class="detail-conn-dot" style="background:${protectionColor}"></span>
|
||
<div class="detail-conn-info">
|
||
<div class="detail-conn-label">${escapeHtml(protLabel)}</div>
|
||
<div class="detail-conn-meta">${escapeHtml(protTypeLabel)} ${escapeHtml(protectionEq.block_label || '')}</div>
|
||
</div>
|
||
</div>`;
|
||
html += '</div></div>';
|
||
}
|
||
}
|
||
|
||
// 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 += '<div class="detail-section">';
|
||
html += '<div class="detail-section-title">Schützt</div>';
|
||
html += '<div class="detail-conn-list">';
|
||
protectedEquipment.forEach(pe => {
|
||
const peLabel = pe.label || pe.block_label || 'Equipment';
|
||
html += `<div class="detail-conn-item">
|
||
<span class="detail-conn-dot" style="background:${protectionColor}"></span>
|
||
<div class="detail-conn-info">
|
||
<div class="detail-conn-label">${escapeHtml(peLabel)}</div>
|
||
</div>
|
||
</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);
|
||
|
||
// 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 = `<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
|
||
// ============================================
|
||
|
||
/**
|
||
* 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('<option value="">-- Zuerst Kabeltyp wählen --</option>');
|
||
}
|
||
|
||
// 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 = '<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
|
||
* 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 = `
|
||
<div class="terminal-context-menu" style="position:fixed;left:${x}px;top:${y}px;z-index:10001;">
|
||
<div class="tcm-item tcm-input" data-type="input">
|
||
<span class="tcm-icon" style="color:#f39c12;">▼</span>
|
||
<span>Anschlusspunkt (L1/L2/L3)</span>
|
||
</div>
|
||
<div class="tcm-item tcm-output" data-type="output">
|
||
<span class="tcm-icon" style="color:#3498db;">▲</span>
|
||
<span>Abgang (Verbraucher)</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
$('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 = '<option value="">-- Auswählen --</option>';
|
||
|
||
if (groups && groups.length > 0) {
|
||
groups.forEach(group => {
|
||
html += `<optgroup label="${escapeHtml(group.category_label)}">`;
|
||
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 += `<option value="${escapeHtml(t.ref)}"${selected}${specs}${defSpec}>${escapeHtml(t.label)}</option>`;
|
||
});
|
||
html += '</optgroup>';
|
||
});
|
||
} else {
|
||
// Fallback auf statische Liste
|
||
['NYM-J', 'NYY-J', 'H07V-K', 'CAT6', 'CAT7'].forEach(t => {
|
||
const selected = (selectedValue === t) ? ' selected' : '';
|
||
html += `<option value="${t}"${selected}>${t}</option>`;
|
||
});
|
||
}
|
||
|
||
$('#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 = '<option value="">-- Auswählen --</option>';
|
||
|
||
if (specs && specs.length > 0) {
|
||
specs.forEach(spec => {
|
||
const selected = (spec === defaultSpec) ? ' selected' : '';
|
||
html += `<option value="${escapeHtml(spec)}"${selected}>${escapeHtml(spec)}</option>`;
|
||
});
|
||
} else {
|
||
html = '<option value="">-- Keine Auswahl --</option>';
|
||
}
|
||
|
||
$('#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('<option value="">-- Zuerst Kabeltyp wählen --</option>');
|
||
|
||
// 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);
|