- Kontakt-Adressen als aufklappbare Gruppen in Anlagen-Übersicht - Equipment-Blöcke als CSS Grid (TE-basiert) statt Flex-Wrap - Abgang-Labels (Outputs) über/unter Automaten, Toggle-Button - jQuery statt eigener ElementCollection, aus Dolibarr geladen - Design-System auf Dolibarr Dark Theme Variablen umgestellt - Session-State-Wiederherstellung bei Refresh - Browser-History Support (Hardware-Zurück) - Quick-Select erweitert: AFDD, FI/LS-Kombi - Intelligente Positionsberechnung mit Lücken-Erkennung - Hutschiene zeigt belegt/gesamt TE - $user->getrights() nach Token-Validierung - Doku aktualisiert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1137 lines
33 KiB
JavaScript
1137 lines
33 KiB
JavaScript
/**
|
|
* KundenKarte PWA - Mobile Schaltschrank-Dokumentation
|
|
* Offline-First App für Elektriker
|
|
*/
|
|
|
|
(function($) {
|
|
'use strict';
|
|
|
|
// ============================================
|
|
// APP STATE
|
|
// ============================================
|
|
|
|
const App = {
|
|
// Auth
|
|
token: null,
|
|
user: null,
|
|
|
|
// Current selection
|
|
customerId: null,
|
|
customerName: '',
|
|
anlageId: null,
|
|
anlageName: '',
|
|
|
|
// Data
|
|
panels: [],
|
|
carriers: [],
|
|
equipment: [],
|
|
equipmentTypes: [],
|
|
outputs: [],
|
|
|
|
// Offline queue
|
|
offlineQueue: [],
|
|
isOnline: navigator.onLine,
|
|
|
|
// Current modal state
|
|
currentCarrierId: null,
|
|
selectedTypeId: null,
|
|
|
|
// Abgang-Labels: 'top' (Standard, wie echtes Panel) oder 'bottom'
|
|
labelsPosition: localStorage.getItem('kundenkarte_labels_pos') || 'top',
|
|
};
|
|
|
|
// ============================================
|
|
// INIT
|
|
// ============================================
|
|
|
|
function init() {
|
|
// Register Service Worker
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('sw.js')
|
|
.then(reg => console.log('[PWA] Service Worker registered'))
|
|
.catch(err => console.error('[PWA] SW registration failed:', err));
|
|
}
|
|
|
|
// Check online status
|
|
window.addEventListener('online', () => {
|
|
App.isOnline = true;
|
|
hideOfflineBar();
|
|
syncOfflineChanges();
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
App.isOnline = false;
|
|
showOfflineBar();
|
|
});
|
|
|
|
// Check stored auth
|
|
const storedToken = localStorage.getItem('kundenkarte_pwa_token');
|
|
const storedUser = localStorage.getItem('kundenkarte_pwa_user');
|
|
if (storedToken && storedUser) {
|
|
App.token = storedToken;
|
|
App.user = JSON.parse(storedUser);
|
|
|
|
// Letzten Zustand wiederherstellen
|
|
const lastState = JSON.parse(sessionStorage.getItem('kundenkarte_pwa_state') || 'null');
|
|
if (lastState && lastState.screen) {
|
|
if (lastState.customerId) {
|
|
App.customerId = lastState.customerId;
|
|
App.customerName = lastState.customerName || '';
|
|
$('#customer-name').text(App.customerName);
|
|
}
|
|
if (lastState.anlageId) {
|
|
App.anlageId = lastState.anlageId;
|
|
App.anlageName = lastState.anlageName || '';
|
|
$('#anlage-name').text(App.anlageName);
|
|
}
|
|
|
|
// Screen wiederherstellen
|
|
if (lastState.screen === 'editor' && App.anlageId) {
|
|
showScreen('editor');
|
|
loadEditorData();
|
|
} else if (lastState.screen === 'anlagen' && App.customerId) {
|
|
showScreen('anlagen');
|
|
reloadAnlagen();
|
|
} else {
|
|
showScreen('search');
|
|
}
|
|
} else {
|
|
showScreen('search');
|
|
}
|
|
}
|
|
|
|
// Initialen History-State setzen
|
|
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) {
|
|
if (e.state && e.state.screen) {
|
|
showScreen(e.state.screen, true);
|
|
} 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', '.anlage-card', handleAnlageSelect);
|
|
$('#anlagen-list').on('click', '.contact-group-header', handleContactGroupClick);
|
|
|
|
// Editor actions
|
|
$('#btn-add-panel').on('click', () => openModal('add-panel'));
|
|
$('#btn-save-panel').on('click', handleSavePanel);
|
|
|
|
$('#editor-content').on('click', '.btn-add-carrier', handleAddCarrier);
|
|
$('#btn-save-carrier').on('click', handleSaveCarrier);
|
|
|
|
$('#editor-content').on('click', '.btn-add-equipment', handleAddEquipment);
|
|
$('#editor-content').on('click', '.equipment-block', handleEquipmentClick);
|
|
|
|
// Equipment modal
|
|
$('#type-grid').on('click', '.type-btn', handleTypeSelect);
|
|
$('#btn-save-equipment').on('click', handleSaveEquipment);
|
|
$('#btn-cancel-equipment').on('click', () => closeModal('add-equipment'));
|
|
|
|
// TE buttons
|
|
$('.te-btn').on('click', function() {
|
|
$('.te-btn').removeClass('selected');
|
|
$(this).addClass('selected');
|
|
});
|
|
|
|
// Modal close
|
|
$('.modal-close').on('click', function() {
|
|
$(this).closest('.modal').removeClass('active');
|
|
});
|
|
|
|
// Sync button
|
|
$('#btn-sync').on('click', handleRefresh);
|
|
|
|
// Abgang-Labels Toggle (oben/unten)
|
|
$('#btn-toggle-labels').on('click', function() {
|
|
App.labelsPosition = App.labelsPosition === 'top' ? 'bottom' : 'top';
|
|
localStorage.setItem('kundenkarte_labels_pos', App.labelsPosition);
|
|
$(this).removeClass('labels-top labels-bottom').addClass('labels-' + App.labelsPosition);
|
|
renderEditor();
|
|
});
|
|
|
|
// Initialen Toggle-Zustand setzen
|
|
$('#btn-toggle-labels').removeClass('labels-top labels-bottom').addClass('labels-' + App.labelsPosition);
|
|
}
|
|
|
|
// ============================================
|
|
// AUTH
|
|
// ============================================
|
|
|
|
async function handleLogin(e) {
|
|
e.preventDefault();
|
|
const user = $('#login-user').val().trim();
|
|
const pass = $('#login-pass').val();
|
|
|
|
if (!user || !pass) {
|
|
$('#login-error').text('Bitte Benutzername und Passwort eingeben');
|
|
return;
|
|
}
|
|
|
|
$('#login-error').text('');
|
|
|
|
try {
|
|
const response = await apiCall('pwa_auth.php', {
|
|
action: 'login',
|
|
username: user,
|
|
password: pass
|
|
});
|
|
|
|
if (response.success) {
|
|
App.token = response.token;
|
|
App.user = response.user;
|
|
localStorage.setItem('kundenkarte_pwa_token', response.token);
|
|
localStorage.setItem('kundenkarte_pwa_user', JSON.stringify(response.user));
|
|
showScreen('search');
|
|
} else {
|
|
$('#login-error').text(response.error || 'Login fehlgeschlagen');
|
|
}
|
|
} catch (err) {
|
|
$('#login-error').text('Verbindungsfehler');
|
|
}
|
|
}
|
|
|
|
function handleLogout() {
|
|
App.token = null;
|
|
App.user = null;
|
|
App.customerId = null;
|
|
App.customerName = '';
|
|
App.anlageId = null;
|
|
App.anlageName = '';
|
|
localStorage.removeItem('kundenkarte_pwa_token');
|
|
localStorage.removeItem('kundenkarte_pwa_user');
|
|
sessionStorage.removeItem('kundenkarte_pwa_state');
|
|
showScreen('login');
|
|
}
|
|
|
|
// ============================================
|
|
// SCREENS
|
|
// ============================================
|
|
|
|
function showScreen(name, skipHistory) {
|
|
$('.screen').removeClass('active');
|
|
$('#screen-' + name).addClass('active');
|
|
|
|
// Browser-History für Zurück-Button
|
|
if (!skipHistory) {
|
|
history.pushState({ screen: name }, '', '#' + name);
|
|
}
|
|
|
|
// State speichern für Refresh-Wiederherstellung
|
|
saveState(name);
|
|
}
|
|
|
|
// Zustand in sessionStorage speichern
|
|
function saveState(screen) {
|
|
const state = {
|
|
screen: screen || 'search',
|
|
customerId: App.customerId,
|
|
customerName: App.customerName,
|
|
anlageId: App.anlageId,
|
|
anlageName: App.anlageName
|
|
};
|
|
sessionStorage.setItem('kundenkarte_pwa_state', JSON.stringify(state));
|
|
}
|
|
|
|
// Anlagen-Liste für aktuellen Kunden neu laden
|
|
async function reloadAnlagen() {
|
|
if (!App.customerId) return;
|
|
|
|
$('#anlagen-list').html('<div class="loading-container"><div class="spinner"></div></div>');
|
|
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', {
|
|
action: 'get_anlagen',
|
|
customer_id: App.customerId
|
|
});
|
|
|
|
if (response.success) {
|
|
renderAnlagenList(response.anlagen, response.contacts || []);
|
|
localStorage.setItem('kundenkarte_anlagen_' + App.customerId, JSON.stringify({
|
|
anlagen: response.anlagen,
|
|
contacts: response.contacts || []
|
|
}));
|
|
} else {
|
|
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
|
|
}
|
|
} catch (err) {
|
|
// Gecachte Daten verwenden
|
|
const cached = localStorage.getItem('kundenkarte_anlagen_' + App.customerId);
|
|
if (cached) {
|
|
const data = JSON.parse(cached);
|
|
renderAnlagenList(data.anlagen || data, data.contacts || []);
|
|
showToast('Offline - Zeige gecachte Daten', 'warning');
|
|
} else {
|
|
$('#anlagen-list').html('<div class="list-empty">Fehler beim Laden</div>');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// CUSTOMER SEARCH
|
|
// ============================================
|
|
|
|
async function handleSearch() {
|
|
const query = $('#search-customer').val().trim();
|
|
if (query.length < 2) {
|
|
$('#customer-list').html('<div class="list-empty">Mindestens 2 Zeichen eingeben...</div>');
|
|
return;
|
|
}
|
|
|
|
$('#customer-list').html('<div class="loading-container"><div class="spinner"></div></div>');
|
|
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', {
|
|
action: 'search_customers',
|
|
query: query
|
|
});
|
|
|
|
if (response.success && response.customers) {
|
|
renderCustomerList(response.customers);
|
|
} else {
|
|
$('#customer-list').html('<div class="list-empty">Keine Kunden gefunden</div>');
|
|
}
|
|
} catch (err) {
|
|
$('#customer-list').html('<div class="list-empty">Fehler bei der Suche</div>');
|
|
}
|
|
}
|
|
|
|
function renderCustomerList(customers) {
|
|
if (!customers.length) {
|
|
$('#customer-list').html('<div class="list-empty">Keine Kunden gefunden</div>');
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
customers.forEach(c => {
|
|
html += `
|
|
<div class="list-item" data-id="${c.id}">
|
|
<div class="list-item-icon">
|
|
<svg viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/></svg>
|
|
</div>
|
|
<div class="list-item-content">
|
|
<div class="list-item-title">${escapeHtml(c.name)}</div>
|
|
<div class="list-item-subtitle">${escapeHtml(c.town || '')}</div>
|
|
</div>
|
|
<svg class="list-item-arrow" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
$('#customer-list').html(html);
|
|
}
|
|
|
|
// ============================================
|
|
// CUSTOMER & ANLAGE SELECTION
|
|
// ============================================
|
|
|
|
async function handleCustomerSelect() {
|
|
const id = $(this).data('id');
|
|
const name = $(this).find('.list-item-title').text();
|
|
|
|
App.customerId = id;
|
|
App.customerName = name;
|
|
$('#customer-name').text(name);
|
|
|
|
showScreen('anlagen');
|
|
$('#anlagen-list').html('<div class="loading-container"><div class="spinner"></div></div>');
|
|
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', {
|
|
action: 'get_anlagen',
|
|
customer_id: id
|
|
});
|
|
|
|
if (response.success) {
|
|
renderAnlagenList(response.anlagen, response.contacts || []);
|
|
// Cache für Offline
|
|
localStorage.setItem('kundenkarte_anlagen_' + id, JSON.stringify({
|
|
anlagen: response.anlagen,
|
|
contacts: response.contacts || []
|
|
}));
|
|
} else {
|
|
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
|
|
}
|
|
} catch (err) {
|
|
// Try cached
|
|
const cached = localStorage.getItem('kundenkarte_anlagen_' + id);
|
|
if (cached) {
|
|
const data = JSON.parse(cached);
|
|
renderAnlagenList(data.anlagen || data, data.contacts || []);
|
|
showToast('Offline - Zeige gecachte Daten', 'warning');
|
|
} else {
|
|
$('#anlagen-list').html('<div class="list-empty">Fehler beim Laden</div>');
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderAnlagenList(anlagen, contacts) {
|
|
let html = '';
|
|
|
|
// Kunden-Anlagen (ohne Kontaktzuweisung)
|
|
if (anlagen && anlagen.length) {
|
|
anlagen.forEach(a => {
|
|
html += renderAnlageCard(a);
|
|
});
|
|
}
|
|
|
|
// Kontakt-Adressen als Gruppen
|
|
if (contacts && contacts.length) {
|
|
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>
|
|
<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>
|
|
</div>
|
|
<div class="contact-anlagen-list"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
if (!html) {
|
|
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
|
|
return;
|
|
}
|
|
|
|
$('#anlagen-list').html(html);
|
|
}
|
|
|
|
function renderAnlageCard(a) {
|
|
return `
|
|
<div class="anlage-card" data-id="${a.id}">
|
|
<div class="anlage-card-icon">
|
|
<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>
|
|
</div>
|
|
<div class="anlage-card-title">${escapeHtml(a.label || 'Anlage ' + a.id)}</div>
|
|
${a.type ? '<div class="anlage-card-type">' + escapeHtml(a.type) + '</div>' : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function handleAnlageSelect() {
|
|
const id = $(this).data('id');
|
|
const name = $(this).find('.anlage-card-title').text();
|
|
|
|
App.anlageId = id;
|
|
App.anlageName = name;
|
|
$('#anlage-name').text(name);
|
|
|
|
showScreen('editor');
|
|
await loadEditorData();
|
|
}
|
|
|
|
// ============================================
|
|
// 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) {
|
|
let html = '';
|
|
response.anlagen.forEach(a => {
|
|
html += renderAnlageCard(a);
|
|
});
|
|
$list.html(html);
|
|
} 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 || [];
|
|
|
|
// 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
|
|
}));
|
|
|
|
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 || [];
|
|
renderEditor();
|
|
showToast('Offline - Zeige gecachte Daten', 'warning');
|
|
} else {
|
|
$('#editor-content').html('<div class="list-empty">Fehler beim Laden</div>');
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderEditor() {
|
|
if (!App.panels.length) {
|
|
$('#editor-content').html('<div class="list-empty">Noch keine Felder angelegt.<br>Tippe auf "+ Feld" um zu beginnen.</div>');
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
|
|
App.panels.forEach(panel => {
|
|
const panelCarriers = App.carriers.filter(c => c.fk_panel == panel.id);
|
|
|
|
html += `
|
|
<div class="panel-card" data-panel-id="${panel.id}">
|
|
<div class="panel-header">
|
|
<div class="panel-title">${escapeHtml(panel.label || 'Feld ' + panel.id)}</div>
|
|
</div>
|
|
<div class="panel-body">
|
|
`;
|
|
|
|
panelCarriers.forEach(carrier => {
|
|
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrier.id);
|
|
carrierEquipment.sort((a, b) => (a.position_te || 0) - (b.position_te || 0));
|
|
|
|
const totalTe = parseInt(carrier.total_te) || 12;
|
|
const usedTe = carrierEquipment.reduce((sum, eq) => sum + (parseInt(eq.width_te) || 1), 0);
|
|
const isFull = usedTe >= totalTe;
|
|
const labelsTop = App.labelsPosition === 'top';
|
|
|
|
// Abgang-Labels aus Connections (output_label + Kabeltyp) generieren
|
|
let labelsHtml = `<div class="carrier-labels" style="grid-template-columns: repeat(${totalTe}, 1fr)">`;
|
|
carrierEquipment.forEach(eq => {
|
|
const widthTe = parseInt(eq.width_te) || 1;
|
|
const posTe = parseInt(eq.position_te) || 0;
|
|
const gridCol = posTe > 0
|
|
? `grid-column: ${posTe} / span ${widthTe}`
|
|
: `grid-column: span ${widthTe}`;
|
|
// Abgang aus equipment_connection (fk_target IS NULL)
|
|
const output = App.outputs ? App.outputs.find(o => o.fk_source == eq.id) : null;
|
|
labelsHtml += `<div class="carrier-label-cell" style="${gridCol}">`;
|
|
if (output && output.output_label) {
|
|
// Kabelinfo zusammenbauen (wie Website)
|
|
let cableInfo = '';
|
|
if (output.medium_type) cableInfo = output.medium_type;
|
|
if (output.medium_spec) cableInfo += ' ' + output.medium_spec;
|
|
labelsHtml += `<span class="carrier-label-text">`;
|
|
labelsHtml += escapeHtml(output.output_label);
|
|
if (cableInfo) labelsHtml += `<br><small class="cable-info">${escapeHtml(cableInfo.trim())}</small>`;
|
|
labelsHtml += `</span>`;
|
|
}
|
|
labelsHtml += `</div>`;
|
|
});
|
|
labelsHtml += `</div>`;
|
|
|
|
html += `
|
|
<div class="carrier-item ${labelsTop ? 'labels-top' : 'labels-bottom'}" 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>
|
|
`;
|
|
|
|
// Labels oben
|
|
if (labelsTop) html += labelsHtml;
|
|
|
|
html += `
|
|
<div class="carrier-body">
|
|
<div class="carrier-grid" style="grid-template-columns: repeat(${totalTe}, 1fr)">
|
|
`;
|
|
|
|
carrierEquipment.forEach(eq => {
|
|
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
|
|
const widthTe = parseInt(eq.width_te) || 1;
|
|
const posTe = parseInt(eq.position_te) || 0;
|
|
|
|
// Wie Website: Zeile 1 = Typ-Kurzname, Zeile 2 = Feldwerte, Zeile 3 = Bezeichnung
|
|
const typeLabel = type?.label_short || type?.ref || '';
|
|
const blockColor = eq.block_color || type?.color || '#3498db';
|
|
const eqLabel = eq.label || '';
|
|
|
|
// block_label kann = type_label_short sein wenn keine Feldwerte vorhanden
|
|
// Nur anzeigen wenn es echte Feldwerte sind (nicht gleich dem Typ-Kurznamen)
|
|
const blockFields = eq.block_label || '';
|
|
const showBlockFields = blockFields && blockFields !== typeLabel && blockFields !== (type?.ref || '');
|
|
|
|
const gridCol = posTe > 0
|
|
? `grid-column: ${posTe} / span ${widthTe}`
|
|
: `grid-column: span ${widthTe}`;
|
|
|
|
html += `
|
|
<div class="equipment-block" data-equipment-id="${eq.id}" style="background:${blockColor}; ${gridCol}">
|
|
<span class="equipment-block-type">${escapeHtml(typeLabel)}</span>
|
|
${showBlockFields ? `<span class="equipment-block-value">${escapeHtml(blockFields)}</span>` : ''}
|
|
<span class="equipment-block-label">${escapeHtml(eqLabel)}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
|
|
html += `
|
|
<button class="btn-add-equipment${isFull ? ' disabled' : ''}" data-carrier-id="${carrier.id}"${isFull ? ' disabled' : ''}>
|
|
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
</button>
|
|
`;
|
|
|
|
html += `</div>`;
|
|
|
|
// Labels unten
|
|
if (!labelsTop) html += labelsHtml;
|
|
|
|
html += `</div>`;
|
|
});
|
|
|
|
html += `
|
|
<button class="btn-add-carrier" data-panel-id="${panel.id}">
|
|
<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
|
Hutschiene hinzufügen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
$('#editor-content').html(html);
|
|
|
|
// Load type grid
|
|
renderTypeGrid();
|
|
}
|
|
|
|
function renderTypeGrid() {
|
|
let html = '';
|
|
App.equipmentTypes.forEach(type => {
|
|
html += `
|
|
<button class="type-btn" data-type-id="${type.id}" data-width="${type.width_te || 1}">
|
|
<div class="type-btn-icon" style="color:${type.color || '#3498db'}">⚡</div>
|
|
<div class="type-btn-label">${escapeHtml(type.label_short || type.ref || type.label)}</div>
|
|
</button>
|
|
`;
|
|
});
|
|
$('#type-grid').html(html);
|
|
}
|
|
|
|
// ============================================
|
|
// PANEL (FELD) ACTIONS
|
|
// ============================================
|
|
|
|
async function handleSavePanel() {
|
|
const label = $('#panel-label').val().trim() || 'Feld ' + (App.panels.length + 1);
|
|
|
|
const data = {
|
|
action: 'create_panel',
|
|
anlage_id: App.anlageId,
|
|
label: label
|
|
};
|
|
|
|
closeModal('add-panel');
|
|
$('#panel-label').val('');
|
|
|
|
if (App.isOnline) {
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', data);
|
|
if (response.success) {
|
|
App.panels.push({ id: response.panel_id, label: label });
|
|
renderEditor();
|
|
showToast('Feld angelegt');
|
|
}
|
|
} catch (err) {
|
|
queueOfflineAction(data);
|
|
}
|
|
} else {
|
|
queueOfflineAction(data);
|
|
// Optimistic UI
|
|
App.panels.push({ id: 'temp_' + Date.now(), label: label });
|
|
renderEditor();
|
|
showToast('Feld wird synchronisiert...', 'warning');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// CARRIER (HUTSCHIENE) ACTIONS
|
|
// ============================================
|
|
|
|
function handleAddCarrier() {
|
|
const panelId = $(this).data('panel-id');
|
|
App.currentPanelId = panelId;
|
|
$('.te-btn').removeClass('selected');
|
|
$('#carrier-label').val('');
|
|
openModal('add-carrier');
|
|
}
|
|
|
|
async function handleSaveCarrier() {
|
|
const teBtn = $('.te-btn.selected');
|
|
if (!teBtn.length) {
|
|
showToast('Bitte Größe wählen', 'error');
|
|
return;
|
|
}
|
|
|
|
const totalTe = parseInt(teBtn.data('te'));
|
|
const label = $('#carrier-label').val().trim() || 'Hutschiene';
|
|
|
|
const data = {
|
|
action: 'create_carrier',
|
|
panel_id: App.currentPanelId,
|
|
total_te: totalTe,
|
|
label: label
|
|
};
|
|
|
|
closeModal('add-carrier');
|
|
|
|
if (App.isOnline) {
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', data);
|
|
if (response.success) {
|
|
App.carriers.push({
|
|
id: response.carrier_id,
|
|
fk_panel: App.currentPanelId,
|
|
total_te: totalTe,
|
|
label: label
|
|
});
|
|
renderEditor();
|
|
showToast('Hutschiene angelegt');
|
|
}
|
|
} catch (err) {
|
|
queueOfflineAction(data);
|
|
}
|
|
} else {
|
|
queueOfflineAction(data);
|
|
App.carriers.push({
|
|
id: 'temp_' + Date.now(),
|
|
fk_panel: App.currentPanelId,
|
|
total_te: totalTe,
|
|
label: label
|
|
});
|
|
renderEditor();
|
|
showToast('Wird synchronisiert...', 'warning');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// EQUIPMENT (AUTOMAT) ACTIONS
|
|
// ============================================
|
|
|
|
function handleAddEquipment() {
|
|
const carrierId = $(this).data('carrier-id');
|
|
App.currentCarrierId = carrierId;
|
|
App.selectedTypeId = null;
|
|
|
|
// Reset modal
|
|
$('.type-btn').removeClass('selected');
|
|
$('#step-values').hide();
|
|
$('#equipment-label').val('');
|
|
$('#value-fields').html('');
|
|
|
|
openModal('add-equipment');
|
|
}
|
|
|
|
function handleTypeSelect() {
|
|
$('.type-btn').removeClass('selected');
|
|
$(this).addClass('selected');
|
|
|
|
App.selectedTypeId = $(this).data('type-id');
|
|
const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId);
|
|
|
|
// Werte-Bereich einblenden
|
|
$('#step-values').show();
|
|
|
|
// Felder basierend auf Typ aufbauen
|
|
let html = '';
|
|
|
|
// Quick-Select für LS-Schalter
|
|
if (type && (type.ref?.includes('LS') || type.label?.includes('Leitungsschutz'))) {
|
|
html += '<p class="step-label">Kennlinie + Ampere:</p>';
|
|
html += '<div class="value-quick">';
|
|
['B6', 'B10', 'B13', 'B16', 'B20', 'B25', 'B32', 'C6', 'C10', 'C13', 'C16', 'C20', 'C25', 'C32'].forEach(v => {
|
|
html += `<button type="button" class="value-chip" data-char="${v[0]}" data-amp="${v.slice(1)}">${v}</button>`;
|
|
});
|
|
html += '</div>';
|
|
// Quick-Select für FI-Schalter
|
|
} else if (type && (type.ref?.includes('FI') || type.label?.includes('RCD'))) {
|
|
html += '<p class="step-label">Ampere:</p>';
|
|
html += '<div class="value-quick">';
|
|
['25', '40', '63', '80'].forEach(v => {
|
|
html += `<button type="button" class="value-chip" data-amp="${v}">${v}A</button>`;
|
|
});
|
|
html += '</div>';
|
|
html += '<p class="step-label" style="margin-top:12px;">Empfindlichkeit:</p>';
|
|
html += '<div class="value-quick">';
|
|
['30', '100', '300'].forEach(v => {
|
|
html += `<button type="button" class="value-chip chip-sens" data-sens="${v}">${v}mA</button>`;
|
|
});
|
|
html += '</div>';
|
|
// Quick-Select für AFDD
|
|
} else if (type && type.ref?.includes('AFDD')) {
|
|
html += '<p class="step-label">Ampere:</p>';
|
|
html += '<div class="value-quick">';
|
|
['10', '13', '16', '20', '25', '32'].forEach(v => {
|
|
html += `<button type="button" class="value-chip" data-amp="${v}">${v}A</button>`;
|
|
});
|
|
html += '</div>';
|
|
// Quick-Select für FI/LS-Kombi
|
|
} else if (type && type.ref?.includes('FILS')) {
|
|
html += '<p class="step-label">Kennlinie + Ampere:</p>';
|
|
html += '<div class="value-quick">';
|
|
['B10', 'B13', 'B16', 'B20', 'B25', 'B32'].forEach(v => {
|
|
html += `<button type="button" class="value-chip" data-char="${v[0]}" data-amp="${v.slice(1)}">${v}</button>`;
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
$('#value-fields').html(html);
|
|
|
|
// Chip-Klick-Handler
|
|
$('#value-fields .value-chip').on('click', function() {
|
|
if ($(this).hasClass('chip-sens')) {
|
|
$('.chip-sens').removeClass('selected');
|
|
} else {
|
|
$('.value-chip:not(.chip-sens)').removeClass('selected');
|
|
}
|
|
$(this).addClass('selected');
|
|
});
|
|
|
|
// Focus auf Label-Feld wenn keine Chips vorhanden
|
|
if (!html) {
|
|
$('#equipment-label').focus();
|
|
}
|
|
}
|
|
|
|
async function handleSaveEquipment() {
|
|
if (!App.selectedTypeId) {
|
|
showToast('Bitte Typ wählen', 'error');
|
|
return;
|
|
}
|
|
|
|
const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId);
|
|
const label = $('#equipment-label').val().trim();
|
|
|
|
// Collect field values
|
|
const fieldValues = {};
|
|
const selectedChip = $('.value-chip.selected:not(.chip-sens)');
|
|
const selectedSens = $('.chip-sens.selected');
|
|
|
|
if (selectedChip.length) {
|
|
if (selectedChip.data('char')) fieldValues.characteristic = selectedChip.data('char');
|
|
if (selectedChip.data('amp')) fieldValues.ampere = selectedChip.data('amp');
|
|
}
|
|
if (selectedSens.length) {
|
|
fieldValues.sensitivity = selectedSens.data('sens');
|
|
}
|
|
|
|
// Nächste freie Position berechnen (Lücken berücksichtigen)
|
|
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId);
|
|
const carrier = App.carriers.find(c => c.id == App.currentCarrierId);
|
|
const totalTe = parseInt(carrier?.total_te) || 12;
|
|
const eqWidth = parseInt(type?.width_te) || 1;
|
|
|
|
// Belegungsarray erstellen
|
|
const occupied = new Array(totalTe + 1).fill(false);
|
|
carrierEquipment.forEach(e => {
|
|
const pos = parseInt(e.position_te) || 1;
|
|
const w = parseInt(e.width_te) || 1;
|
|
for (let i = pos; i < pos + w && i <= totalTe; i++) {
|
|
occupied[i] = true;
|
|
}
|
|
});
|
|
|
|
// Erste Lücke finden die breit genug ist
|
|
let nextPos = 0;
|
|
for (let i = 1; i <= totalTe - eqWidth + 1; i++) {
|
|
let fits = true;
|
|
for (let j = 0; j < eqWidth; j++) {
|
|
if (occupied[i + j]) { fits = false; break; }
|
|
}
|
|
if (fits) { nextPos = i; break; }
|
|
}
|
|
|
|
if (nextPos === 0) {
|
|
showToast('Kein Platz frei', 'error');
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
action: 'create_equipment',
|
|
carrier_id: App.currentCarrierId,
|
|
type_id: App.selectedTypeId,
|
|
label: label,
|
|
position_te: nextPos,
|
|
field_values: JSON.stringify(fieldValues)
|
|
};
|
|
|
|
closeModal('add-equipment');
|
|
|
|
if (App.isOnline) {
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', data);
|
|
if (response.success) {
|
|
App.equipment.push({
|
|
id: response.equipment_id,
|
|
fk_carrier: App.currentCarrierId,
|
|
fk_equipment_type: App.selectedTypeId,
|
|
label: label,
|
|
position_te: nextPos,
|
|
width_te: type?.width_te || 1,
|
|
field_values: fieldValues
|
|
});
|
|
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');
|
|
}
|
|
}
|
|
|
|
function handleEquipmentClick() {
|
|
const eqId = $(this).data('equipment-id');
|
|
// TODO: Edit/Delete popup
|
|
showToast('Bearbeiten kommt noch...');
|
|
}
|
|
|
|
// ============================================
|
|
// OFFLINE SYNC
|
|
// ============================================
|
|
|
|
function queueOfflineAction(data) {
|
|
data._timestamp = Date.now();
|
|
App.offlineQueue.push(data);
|
|
localStorage.setItem('kundenkarte_offline_queue', JSON.stringify(App.offlineQueue));
|
|
updateSyncBadge();
|
|
}
|
|
|
|
function updateSyncBadge() {
|
|
const count = App.offlineQueue.length;
|
|
const $badge = $('#sync-badge');
|
|
if (count > 0) {
|
|
$badge.text(count).removeClass('hidden');
|
|
} else {
|
|
$badge.addClass('hidden');
|
|
}
|
|
}
|
|
|
|
async function handleRefresh() {
|
|
// Zuerst Offline-Queue syncen falls vorhanden
|
|
if (App.offlineQueue.length && App.isOnline) {
|
|
await syncOfflineChanges();
|
|
}
|
|
// Dann Daten neu laden
|
|
showToast('Aktualisiere...');
|
|
await loadEditorData();
|
|
}
|
|
|
|
async function syncOfflineChanges() {
|
|
if (!App.offlineQueue.length) {
|
|
showToast('Alles synchronisiert');
|
|
return;
|
|
}
|
|
|
|
if (!App.isOnline) {
|
|
showToast('Offline - Sync nicht möglich', 'error');
|
|
return;
|
|
}
|
|
|
|
showToast('Synchronisiere...');
|
|
|
|
const queue = [...App.offlineQueue];
|
|
let successCount = 0;
|
|
|
|
for (const data of queue) {
|
|
try {
|
|
const response = await apiCall('ajax/pwa_api.php', data);
|
|
if (response.success) {
|
|
successCount++;
|
|
// Remove from queue
|
|
const idx = App.offlineQueue.findIndex(q => q._timestamp === data._timestamp);
|
|
if (idx > -1) App.offlineQueue.splice(idx, 1);
|
|
}
|
|
} catch (err) {
|
|
console.error('Sync failed for:', data, err);
|
|
}
|
|
}
|
|
|
|
localStorage.setItem('kundenkarte_offline_queue', JSON.stringify(App.offlineQueue));
|
|
updateSyncBadge();
|
|
|
|
if (successCount === queue.length) {
|
|
showToast('Alle Änderungen synchronisiert', 'success');
|
|
// Reload data
|
|
loadEditorData();
|
|
} else {
|
|
showToast(`${successCount}/${queue.length} synchronisiert`, 'warning');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// API HELPER
|
|
// ============================================
|
|
|
|
async function apiCall(endpoint, data = {}) {
|
|
const url = window.MODULE_URL + '/' + endpoint;
|
|
|
|
// Add token
|
|
if (App.token) {
|
|
data.token = App.token;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams(data)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Network error');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// ============================================
|
|
// UI HELPERS
|
|
// ============================================
|
|
|
|
function openModal(name) {
|
|
$('#modal-' + name).addClass('active');
|
|
}
|
|
|
|
function closeModal(name) {
|
|
$('#modal-' + name).removeClass('active');
|
|
}
|
|
|
|
function showToast(message, type = '') {
|
|
const $toast = $('#toast');
|
|
$toast.text(message).removeClass('success error warning visible').addClass(type);
|
|
setTimeout(() => $toast.addClass('visible'), 10);
|
|
setTimeout(() => $toast.removeClass('visible'), 3000);
|
|
}
|
|
|
|
function showOfflineBar() {
|
|
$('#offline-indicator').removeClass('hidden');
|
|
}
|
|
|
|
function hideOfflineBar() {
|
|
$('#offline-indicator').addClass('hidden');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function(...args) {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
|
};
|
|
}
|
|
|
|
// jQuery wird als $ Parameter der IIFE übergeben
|
|
|
|
// ============================================
|
|
// START
|
|
// ============================================
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
})(jQuery);
|