feat(pwa): Kontakt-Adressen, Grid-Layout, Abgang-Labels, jQuery

- 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>
This commit is contained in:
Eduard Wisch 2026-02-25 21:37:17 +01:00
parent 844e6060c6
commit 6e88f0eb87
7 changed files with 1009 additions and 362 deletions

View file

@ -130,27 +130,58 @@ Alle Datenbankänderungen werden als idempotente Migrationen in `modKundenKarte.
Offline-fähige Progressive Web App für Elektriker zur Schaltschrank-Dokumentation vor Ort. Offline-fähige Progressive Web App für Elektriker zur Schaltschrank-Dokumentation vor Ort.
### Dateien ### Dateien
- `pwa.php` - Haupteinstieg (HTML/CSS/JS Container) - `pwa.php` - Haupteinstieg (HTML/CSS/JS Container, lädt jQuery aus Dolibarr)
- `pwa_auth.php` - Token-basierte Authentifizierung (15 Tage gültig) - `pwa_auth.php` - Token-basierte Authentifizierung (15 Tage gültig)
- `ajax/pwa_api.php` - Alle AJAX-Endpoints für die PWA - `ajax/pwa_api.php` - Alle AJAX-Endpoints für die PWA
- `js/pwa.js` - Komplette App-Logik (vanilla JS, kein jQuery) - `js/pwa.js` - Komplette App-Logik (jQuery, als IIFE mit jQuery-Parameter)
- `css/pwa.css` - Mobile-First Dark Mode Design - `css/pwa.css` - Mobile-First Design, Dolibarr Dark Theme Variablen
- `sw.js` - Service Worker für Offline-Cache - `sw.js` - Service Worker für Offline-Cache (v1.8)
- `manifest.json` - Web App Manifest für Installation - `manifest.json` - Web App Manifest für Installation
### Workflow ### Workflow
1. Login mit Dolibarr-Credentials → Token wird lokal gespeichert 1. Login mit Dolibarr-Credentials → Token wird lokal gespeichert
2. Kunde suchen → Anlagen werden gecached 2. Kunde suchen → Anlagen werden gecached
3. Anlage mit Schaltplan-Editor auswählen → Daten werden gecached 3. Kontakt-Adressen (Gebäude/Standorte) aufklappen → Anlagen pro Adresse
4. Offline arbeiten: Felder, Hutschienen, Automaten hinzufügen 4. Anlage mit Schaltplan-Editor auswählen → Daten werden gecached
5. Änderungen werden in lokaler Queue gespeichert 5. Offline arbeiten: Hutschienen, Automaten hinzufügen
6. Bei Internetverbindung: Automatische Synchronisierung 6. Änderungen werden in lokaler Queue gespeichert
7. Bei Internetverbindung: Automatische Synchronisierung
### Design-System
- CSS-Variablen basierend auf Dolibarr Dark Theme (`--colorbackbody`, `--colortext`, etc.)
- Theme-Farbe `--primary` wird dynamisch aus Dolibarr-Config geladen (`THEME_ELDY_TOPMENU_BACK1`)
- Buttons: `--butactionbg` (goldbraun) statt blau
- Header: sticky, primary-Farbe
### Kontakt-Adressen
- `get_anlagen` API liefert jetzt `anlagen` (Kunden-Ebene) + `contacts` (Adressen)
- Kontakt-Gruppen werden als aufklappbare Akkordeons dargestellt
- `get_contact_anlagen` API lädt Anlagen pro Kontakt-Adresse bei Bedarf
- Kontakt-Adressen zeigen Name, Adresse, Ort und Anzahl Anlagen
### Schaltplan-Editor
- Equipment-Blöcke als CSS Grid (`grid-template-columns: repeat(totalTE, 1fr)`)
- Blöcke positioniert per `grid-column` basierend auf `position_te` und `width_te`
- `block_label` und `block_color` aus Backend (wie Website)
- Abgang-Labels (Outputs) werden über/unter den Automaten angezeigt
- Toggle-Button zum Wechseln der Label-Position (oben/unten), in localStorage gespeichert
- Connections mit `fk_target IS NULL` = Ausgänge/Abgänge
- Quick-Select erweitert: LS, FI/RCD, AFDD, FI/LS-Kombi
- Intelligente Positionsberechnung mit Lücken-Erkennung
- Hutschiene zeigt belegt/gesamt TE, +-Button wird disabled wenn voll
### Navigation & State
- Browser-History Support (hardware Zurück-Button funktioniert)
- Session-State wird in `sessionStorage` gespeichert (Screen, Kunde, Anlage)
- Bei Seiten-Refresh wird letzter Zustand wiederhergestellt
- Sync-Button lädt jetzt erst Offline-Queue, dann Daten neu
### Token-Authentifizierung ### Token-Authentifizierung
- Tokens enthalten: user_id, login, created, expires, hash - Tokens enthalten: user_id, login, created, expires, hash
- Hash = MD5(user_id + login + MAIN_SECURITY_SALT) - Hash = MD5(user_id + login + MAIN_SECURITY_SALT)
- Gültigkeit: 15 Tage - Gültigkeit: 15 Tage
- Gespeichert in localStorage - Gespeichert in localStorage
- `$user->getrights()` wird nach Token-Validierung aufgerufen
### Offline-Sync ### Offline-Sync
- Alle Änderungen werden in `offlineQueue` (localStorage) gespeichert - Alle Änderungen werden in `offlineQueue` (localStorage) gespeichert

View file

@ -58,6 +58,7 @@ if ($tokenData['hash'] !== $expectedHash) {
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
$user = new User($db); $user = new User($db);
$user->fetch($tokenData['user_id']); $user->fetch($tokenData['user_id']);
$user->getrights();
if ($user->id <= 0 || $user->statut != 1) { if ($user->id <= 0 || $user->statut != 1) {
echo json_encode(array('success' => false, 'error' => 'Benutzer nicht mehr aktiv')); echo json_encode(array('success' => false, 'error' => 'Benutzer nicht mehr aktiv'));
@ -76,6 +77,7 @@ require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.p
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipment.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipment.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmenttype.class.php'; require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmenttype.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentconnection.class.php';
$action = GETPOST('action', 'aZ09'); $action = GETPOST('action', 'aZ09');
@ -117,7 +119,7 @@ switch ($action) {
break; break;
// ============================================ // ============================================
// GET ANLAGEN FOR CUSTOMER // GET ANLAGEN FOR CUSTOMER (inkl. Kontakt-Adressen)
// ============================================ // ============================================
case 'get_anlagen': case 'get_anlagen':
$customerId = GETPOSTINT('customer_id'); $customerId = GETPOSTINT('customer_id');
@ -126,20 +128,72 @@ switch ($action) {
break; break;
} }
// Root-Anlagen ohne Kontaktzuweisung (Kunden-Ebene)
$anlage = new Anlage($db); $anlage = new Anlage($db);
$anlagen = $anlage->fetchAll('ASC', 'label', 0, 0, array('fk_soc' => $customerId)); $anlagen = $anlage->fetchChildren(0, $customerId);
$result = array(); $result = array();
if (is_array($anlagen)) { foreach ($anlagen as $a) {
foreach ($anlagen as $a) { $result[] = array(
$result[] = array( 'id' => $a->id,
'id' => $a->id, 'label' => $a->label,
'label' => $a->label, 'type' => $a->type_label,
'has_editor' => !empty($a->schematic_editor_enabled) 'has_editor' => !empty($a->schematic_editor_enabled)
);
}
// Kontakt-Adressen mit Anlagen laden
$contacts = array();
$sql = "SELECT c.rowid, c.lastname, c.firstname, c.address, c.town,";
$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."kundenkarte_anlage a WHERE a.fk_contact = c.rowid AND a.status = 1) as anlage_count";
$sql .= " FROM ".MAIN_DB_PREFIX."socpeople as c";
$sql .= " WHERE c.fk_soc = ".((int) $customerId);
$sql .= " AND c.statut = 1";
$sql .= " ORDER BY c.lastname ASC";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$contactName = trim($obj->lastname.' '.$obj->firstname);
$contacts[] = array(
'id' => $obj->rowid,
'name' => $contactName,
'address' => $obj->address,
'town' => $obj->town,
'anlage_count' => (int) $obj->anlage_count
); );
} }
} }
$response['success'] = true;
$response['anlagen'] = $result;
$response['contacts'] = $contacts;
break;
// ============================================
// GET ANLAGEN FOR CONTACT ADDRESS
// ============================================
case 'get_contact_anlagen':
$customerId = GETPOSTINT('customer_id');
$contactId = GETPOSTINT('contact_id');
if ($customerId <= 0 || $contactId <= 0) {
$response['error'] = 'Kunden-ID und Kontakt-ID erforderlich';
break;
}
$anlage = new Anlage($db);
$anlagen = $anlage->fetchChildrenByContact(0, $customerId, $contactId);
$result = array();
foreach ($anlagen as $a) {
$result[] = array(
'id' => $a->id,
'label' => $a->label,
'type' => $a->type_label,
'has_editor' => !empty($a->schematic_editor_enabled)
);
}
$response['success'] = true; $response['success'] = true;
$response['anlagen'] = $result; $response['anlagen'] = $result;
break; break;
@ -195,11 +249,37 @@ switch ($action) {
'label' => $eq->label, 'label' => $eq->label,
'position_te' => $eq->position_te, 'position_te' => $eq->position_te,
'width_te' => $eq->width_te, 'width_te' => $eq->width_te,
'block_label' => $eq->getBlockLabel(),
'block_color' => $eq->getBlockColor(),
'field_values' => $eq->getFieldValues() 'field_values' => $eq->getFieldValues()
); );
} }
} }
// Abgänge laden (Connections mit fk_target IS NULL = Ausgänge)
$outputsData = array();
if (!empty($equipmentData)) {
$equipmentIds = array_map(function($e) { return (int) $e['id']; }, $equipmentData);
$sql = "SELECT rowid, fk_source, output_label, medium_type, medium_spec, connection_type";
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection";
$sql .= " WHERE fk_source IN (".implode(',', $equipmentIds).")";
$sql .= " AND fk_target IS NULL";
$sql .= " AND status = 1";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$outputsData[] = array(
'id' => $obj->rowid,
'fk_source' => $obj->fk_source,
'output_label' => $obj->output_label,
'medium_type' => $obj->medium_type,
'medium_spec' => $obj->medium_spec,
'connection_type' => $obj->connection_type
);
}
}
}
// Load equipment types // Load equipment types
$eqType = new EquipmentType($db); $eqType = new EquipmentType($db);
$types = $eqType->fetchAllBySystem(1, 1); // System 1 = Elektro, nur aktive $types = $eqType->fetchAllBySystem(1, 1); // System 1 = Elektro, nur aktive
@ -219,6 +299,7 @@ switch ($action) {
$response['panels'] = $panelsData; $response['panels'] = $panelsData;
$response['carriers'] = $carriersData; $response['carriers'] = $carriersData;
$response['equipment'] = $equipmentData; $response['equipment'] = $equipmentData;
$response['outputs'] = $outputsData;
$response['types'] = $typesData; $response['types'] = $typesData;
break; break;

File diff suppressed because it is too large Load diff

511
js/pwa.js
View file

@ -3,7 +3,7 @@
* Offline-First App für Elektriker * Offline-First App für Elektriker
*/ */
(function() { (function($) {
'use strict'; 'use strict';
// ============================================ // ============================================
@ -26,6 +26,7 @@
carriers: [], carriers: [],
equipment: [], equipment: [],
equipmentTypes: [], equipmentTypes: [],
outputs: [],
// Offline queue // Offline queue
offlineQueue: [], offlineQueue: [],
@ -34,6 +35,9 @@
// Current modal state // Current modal state
currentCarrierId: null, currentCarrierId: null,
selectedTypeId: null, selectedTypeId: null,
// Abgang-Labels: 'top' (Standard, wie echtes Panel) oder 'bottom'
labelsPosition: localStorage.getItem('kundenkarte_labels_pos') || 'top',
}; };
// ============================================ // ============================================
@ -66,9 +70,39 @@
if (storedToken && storedUser) { if (storedToken && storedUser) {
App.token = storedToken; App.token = storedToken;
App.user = JSON.parse(storedUser); App.user = JSON.parse(storedUser);
showScreen('search');
// 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 // Load offline queue
const storedQueue = localStorage.getItem('kundenkarte_offline_queue'); const storedQueue = localStorage.getItem('kundenkarte_offline_queue');
if (storedQueue) { if (storedQueue) {
@ -90,8 +124,19 @@
$('#btn-logout').on('click', handleLogout); $('#btn-logout').on('click', handleLogout);
// Navigation // Navigation
$('#btn-back-search').on('click', () => showScreen('search')); $('#btn-back-search').on('click', () => history.back());
$('#btn-back-anlagen').on('click', () => showScreen('anlagen')); $('#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
$('#search-customer').on('input', debounce(handleSearch, 300)); $('#search-customer').on('input', debounce(handleSearch, 300));
@ -99,6 +144,7 @@
// Customer/Anlage selection // Customer/Anlage selection
$('#customer-list').on('click', '.list-item', handleCustomerSelect); $('#customer-list').on('click', '.list-item', handleCustomerSelect);
$('#anlagen-list').on('click', '.anlage-card', handleAnlageSelect); $('#anlagen-list').on('click', '.anlage-card', handleAnlageSelect);
$('#anlagen-list').on('click', '.contact-group-header', handleContactGroupClick);
// Editor actions // Editor actions
$('#btn-add-panel').on('click', () => openModal('add-panel')); $('#btn-add-panel').on('click', () => openModal('add-panel'));
@ -127,7 +173,18 @@
}); });
// Sync button // Sync button
$('#btn-sync').on('click', syncOfflineChanges); $('#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);
} }
// ============================================ // ============================================
@ -170,8 +227,13 @@
function handleLogout() { function handleLogout() {
App.token = null; App.token = null;
App.user = null; App.user = null;
App.customerId = null;
App.customerName = '';
App.anlageId = null;
App.anlageName = '';
localStorage.removeItem('kundenkarte_pwa_token'); localStorage.removeItem('kundenkarte_pwa_token');
localStorage.removeItem('kundenkarte_pwa_user'); localStorage.removeItem('kundenkarte_pwa_user');
sessionStorage.removeItem('kundenkarte_pwa_state');
showScreen('login'); showScreen('login');
} }
@ -179,14 +241,62 @@
// SCREENS // SCREENS
// ============================================ // ============================================
function showScreen(name) { function showScreen(name, skipHistory) {
$('.screen').removeClass('active'); $('.screen').removeClass('active');
$('#screen-' + name).addClass('active'); $('#screen-' + name).addClass('active');
// Load data if needed // Browser-History für Zurück-Button
if (name === 'search') { if (!skipHistory) {
$('#search-customer').val('').focus(); history.pushState({ screen: name }, '', '#' + name);
$('#customer-list').html('<div class="list-empty">Suchbegriff eingeben...</div>'); }
// 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>');
}
} }
} }
@ -265,10 +375,13 @@
customer_id: id customer_id: id
}); });
if (response.success && response.anlagen) { if (response.success) {
renderAnlagenList(response.anlagen); renderAnlagenList(response.anlagen, response.contacts || []);
// Cache for offline // Cache für Offline
localStorage.setItem('kundenkarte_anlagen_' + id, JSON.stringify(response.anlagen)); localStorage.setItem('kundenkarte_anlagen_' + id, JSON.stringify({
anlagen: response.anlagen,
contacts: response.contacts || []
}));
} else { } else {
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>'); $('#anlagen-list').html('<div class="list-empty">Keine Anlagen gefunden</div>');
} }
@ -276,7 +389,8 @@
// Try cached // Try cached
const cached = localStorage.getItem('kundenkarte_anlagen_' + id); const cached = localStorage.getItem('kundenkarte_anlagen_' + id);
if (cached) { if (cached) {
renderAnlagenList(JSON.parse(cached)); const data = JSON.parse(cached);
renderAnlagenList(data.anlagen || data, data.contacts || []);
showToast('Offline - Zeige gecachte Daten', 'warning'); showToast('Offline - Zeige gecachte Daten', 'warning');
} else { } else {
$('#anlagen-list').html('<div class="list-empty">Fehler beim Laden</div>'); $('#anlagen-list').html('<div class="list-empty">Fehler beim Laden</div>');
@ -284,30 +398,56 @@
} }
} }
function renderAnlagenList(anlagen) { function renderAnlagenList(anlagen, contacts) {
// Filter nur Anlagen mit Editor let html = '';
const withEditor = anlagen.filter(a => a.has_editor);
if (!withEditor.length) { // Kunden-Anlagen (ohne Kontaktzuweisung)
$('#anlagen-list').html('<div class="list-empty">Keine Anlagen mit Schaltplan-Editor</div>'); 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; return;
} }
let html = '';
withEditor.forEach(a => {
html += `
<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>
</div>
`;
});
$('#anlagen-list').html(html); $('#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() { async function handleAnlageSelect() {
const id = $(this).data('id'); const id = $(this).data('id');
const name = $(this).find('.anlage-card-title').text(); const name = $(this).find('.anlage-card-title').text();
@ -320,6 +460,46 @@
await loadEditorData(); 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 // EDITOR
// ============================================ // ============================================
@ -338,13 +518,15 @@
App.carriers = response.carriers || []; App.carriers = response.carriers || [];
App.equipment = response.equipment || []; App.equipment = response.equipment || [];
App.equipmentTypes = response.types || []; App.equipmentTypes = response.types || [];
App.outputs = response.outputs || [];
// Cache for offline // Cache for offline
localStorage.setItem('kundenkarte_data_' + App.anlageId, JSON.stringify({ localStorage.setItem('kundenkarte_data_' + App.anlageId, JSON.stringify({
panels: App.panels, panels: App.panels,
carriers: App.carriers, carriers: App.carriers,
equipment: App.equipment, equipment: App.equipment,
types: App.equipmentTypes types: App.equipmentTypes,
outputs: App.outputs
})); }));
renderEditor(); renderEditor();
@ -358,6 +540,7 @@
App.carriers = data.carriers || []; App.carriers = data.carriers || [];
App.equipment = data.equipment || []; App.equipment = data.equipment || [];
App.equipmentTypes = data.types || []; App.equipmentTypes = data.types || [];
App.outputs = data.outputs || [];
renderEditor(); renderEditor();
showToast('Offline - Zeige gecachte Daten', 'warning'); showToast('Offline - Zeige gecachte Daten', 'warning');
} else { } else {
@ -389,37 +572,94 @@
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrier.id); const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrier.id);
carrierEquipment.sort((a, b) => (a.position_te || 0) - (b.position_te || 0)); 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 += ` html += `
<div class="carrier-item" data-carrier-id="${carrier.id}"> <div class="carrier-item ${labelsTop ? 'labels-top' : 'labels-bottom'}" data-carrier-id="${carrier.id}">
<div class="carrier-header"> <div class="carrier-header">
<span class="carrier-label">${escapeHtml(carrier.label || 'Hutschiene')}</span> <span class="carrier-label">${escapeHtml(carrier.label || 'Hutschiene')}</span>
<span class="carrier-te">${carrier.total_te || 12} TE</span> <span class="carrier-te">${usedTe}/${totalTe} TE</span>
</div> </div>
`;
// Labels oben
if (labelsTop) html += labelsHtml;
html += `
<div class="carrier-body"> <div class="carrier-body">
<div class="carrier-grid" style="grid-template-columns: repeat(${totalTe}, 1fr)">
`; `;
carrierEquipment.forEach(eq => { carrierEquipment.forEach(eq => {
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type); const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const typeLabel = type ? (type.label_short || type.ref) : '?'; const widthTe = parseInt(eq.width_te) || 1;
const fieldVals = eq.field_values ? (typeof eq.field_values === 'string' ? JSON.parse(eq.field_values) : eq.field_values) : {}; const posTe = parseInt(eq.position_te) || 0;
const value = fieldVals.ampere ? fieldVals.ampere + 'A' : (fieldVals.characteristic || '');
// 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 += ` html += `
<div class="equipment-block" data-equipment-id="${eq.id}" style="background:${type?.color || '#3498db'}"> <div class="equipment-block" data-equipment-id="${eq.id}" style="background:${blockColor}; ${gridCol}">
<div class="equipment-block-type">${escapeHtml(typeLabel)}</div> <span class="equipment-block-type">${escapeHtml(typeLabel)}</span>
<div class="equipment-block-value">${escapeHtml(value)}</div> ${showBlockFields ? `<span class="equipment-block-value">${escapeHtml(blockFields)}</span>` : ''}
${eq.label ? '<div class="equipment-block-label">' + escapeHtml(eq.label) + '</div>' : ''} <span class="equipment-block-label">${escapeHtml(eqLabel)}</span>
</div> </div>
`; `;
}); });
html += `</div>`;
html += ` html += `
<button class="btn-add-equipment" data-carrier-id="${carrier.id}"> <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> <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
</button> </button>
</div>
</div>
`; `;
html += `</div>`;
// Labels unten
if (!labelsTop) html += labelsHtml;
html += `</div>`;
}); });
html += ` html += `
@ -558,8 +798,7 @@
// Reset modal // Reset modal
$('.type-btn').removeClass('selected'); $('.type-btn').removeClass('selected');
$('#step-type').addClass('active'); $('#step-values').hide();
$('#step-values').removeClass('active');
$('#equipment-label').val(''); $('#equipment-label').val('');
$('#value-fields').html(''); $('#value-fields').html('');
@ -573,14 +812,13 @@
App.selectedTypeId = $(this).data('type-id'); App.selectedTypeId = $(this).data('type-id');
const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId); const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId);
// Show value step // Werte-Bereich einblenden
$('#step-type').removeClass('active'); $('#step-values').show();
$('#step-values').addClass('active');
// Build value fields based on type // Felder basierend auf Typ aufbauen
let html = ''; let html = '';
// Quick select for common values // Quick-Select für LS-Schalter
if (type && (type.ref?.includes('LS') || type.label?.includes('Leitungsschutz'))) { if (type && (type.ref?.includes('LS') || type.label?.includes('Leitungsschutz'))) {
html += '<p class="step-label">Kennlinie + Ampere:</p>'; html += '<p class="step-label">Kennlinie + Ampere:</p>';
html += '<div class="value-quick">'; html += '<div class="value-quick">';
@ -588,6 +826,7 @@
html += `<button type="button" class="value-chip" data-char="${v[0]}" data-amp="${v.slice(1)}">${v}</button>`; html += `<button type="button" class="value-chip" data-char="${v[0]}" data-amp="${v.slice(1)}">${v}</button>`;
}); });
html += '</div>'; html += '</div>';
// Quick-Select für FI-Schalter
} else if (type && (type.ref?.includes('FI') || type.label?.includes('RCD'))) { } else if (type && (type.ref?.includes('FI') || type.label?.includes('RCD'))) {
html += '<p class="step-label">Ampere:</p>'; html += '<p class="step-label">Ampere:</p>';
html += '<div class="value-quick">'; html += '<div class="value-quick">';
@ -601,11 +840,27 @@
html += `<button type="button" class="value-chip chip-sens" data-sens="${v}">${v}mA</button>`; html += `<button type="button" class="value-chip chip-sens" data-sens="${v}">${v}mA</button>`;
}); });
html += '</div>'; 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); $('#value-fields').html(html);
// Bind chip clicks // Chip-Klick-Handler
$('#value-fields .value-chip').on('click', function() { $('#value-fields .value-chip').on('click', function() {
if ($(this).hasClass('chip-sens')) { if ($(this).hasClass('chip-sens')) {
$('.chip-sens').removeClass('selected'); $('.chip-sens').removeClass('selected');
@ -614,6 +869,11 @@
} }
$(this).addClass('selected'); $(this).addClass('selected');
}); });
// Focus auf Label-Feld wenn keine Chips vorhanden
if (!html) {
$('#equipment-label').focus();
}
} }
async function handleSaveEquipment() { async function handleSaveEquipment() {
@ -638,14 +898,37 @@
fieldValues.sensitivity = selectedSens.data('sens'); fieldValues.sensitivity = selectedSens.data('sens');
} }
// Calculate position // Nächste freie Position berechnen (Lücken berücksichtigen)
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId); const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId);
let nextPos = 1; 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 => { carrierEquipment.forEach(e => {
const endPos = (parseInt(e.position_te) || 1) + (parseInt(e.width_te) || 1); const pos = parseInt(e.position_te) || 1;
if (endPos > nextPos) nextPos = endPos; 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 = { const data = {
action: 'create_equipment', action: 'create_equipment',
carrier_id: App.currentCarrierId, carrier_id: App.currentCarrierId,
@ -671,9 +954,12 @@
field_values: fieldValues field_values: fieldValues
}); });
renderEditor(); renderEditor();
showToast('Automat angelegt'); showToast('Automat angelegt', 'success');
} else {
showToast(response.error || 'Fehler beim Speichern', 'error');
} }
} catch (err) { } catch (err) {
showToast('Netzwerkfehler - wird offline gespeichert', 'warning');
queueOfflineAction(data); queueOfflineAction(data);
} }
} else { } else {
@ -719,6 +1005,16 @@
} }
} }
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() { async function syncOfflineChanges() {
if (!App.offlineQueue.length) { if (!App.offlineQueue.length) {
showToast('Alles synchronisiert'); showToast('Alles synchronisiert');
@ -830,98 +1126,7 @@
}; };
} }
// jQuery shorthand // jQuery wird als $ Parameter der IIFE übergeben
function $(selector) {
if (typeof selector === 'string') {
const elements = document.querySelectorAll(selector);
return new ElementCollection(elements);
}
return new ElementCollection([selector]);
}
class ElementCollection {
constructor(elements) {
this.elements = Array.from(elements);
this.length = this.elements.length;
}
on(event, selectorOrHandler, handler) {
if (typeof selectorOrHandler === 'function') {
// Direct event
this.elements.forEach(el => el.addEventListener(event, selectorOrHandler));
} else {
// Delegated event
this.elements.forEach(el => {
el.addEventListener(event, function(e) {
const target = e.target.closest(selectorOrHandler);
if (target && el.contains(target)) {
handler.call(target, e);
}
});
});
}
return this;
}
addClass(className) {
this.elements.forEach(el => el.classList.add(className));
return this;
}
removeClass(className) {
this.elements.forEach(el => el.classList.remove(className));
return this;
}
hasClass(className) {
return this.elements[0]?.classList.contains(className);
}
html(content) {
if (content === undefined) {
return this.elements[0]?.innerHTML;
}
this.elements.forEach(el => el.innerHTML = content);
return this;
}
text(content) {
if (content === undefined) {
return this.elements[0]?.textContent;
}
this.elements.forEach(el => el.textContent = content);
return this;
}
val(value) {
if (value === undefined) {
return this.elements[0]?.value;
}
this.elements.forEach(el => el.value = value);
return this;
}
data(key) {
return this.elements[0]?.dataset[key];
}
find(selector) {
const found = [];
this.elements.forEach(el => {
found.push(...el.querySelectorAll(selector));
});
return new ElementCollection(found);
}
closest(selector) {
return new ElementCollection([this.elements[0]?.closest(selector)].filter(Boolean));
}
focus() {
this.elements[0]?.focus();
return this;
}
}
// ============================================ // ============================================
// START // START
@ -929,4 +1134,4 @@
document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', init);
})(); })(jQuery);

24
pwa.php
View file

@ -42,8 +42,10 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<meta name="apple-mobile-web-app-title" content="KundenKarte"> <meta name="apple-mobile-web-app-title" content="KundenKarte">
<title>KundenKarte</title> <title>KundenKarte</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="img/pwa-icon-192.png">
<link rel="apple-touch-icon" href="img/pwa-icon-192.png"> <link rel="apple-touch-icon" href="img/pwa-icon-192.png">
<link rel="stylesheet" href="css/pwa.css"> <link rel="stylesheet" href="css/pwa.css">
<style>:root { --primary: <?php echo $themeColor; ?>; }</style>
</head> </head>
<body> <body>
<div id="app" class="app"> <div id="app" class="app">
@ -123,6 +125,9 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg> <svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button> </button>
<h1 id="anlage-name">Anlage</h1> <h1 id="anlage-name">Anlage</h1>
<button id="btn-toggle-labels" class="btn-toggle-labels labels-top" title="Abgänge oben/unten">
<svg viewBox="0 0 24 24"><path d="M7 14l5-5 5 5z"/></svg>
</button>
<button id="btn-sync" class="btn-icon sync-btn"> <button id="btn-sync" class="btn-icon sync-btn">
<svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg> <svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
<span id="sync-badge" class="sync-badge hidden">0</span> <span id="sync-badge" class="sync-badge hidden">0</span>
@ -149,23 +154,19 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<button class="modal-close">&times;</button> <button class="modal-close">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Step 1: Typ wählen --> <p class="step-label">Typ wählen:</p>
<div id="step-type" class="step active"> <div id="type-grid" class="type-grid">
<p class="step-label">Typ wählen:</p> <!-- Typen werden geladen -->
<div id="type-grid" class="type-grid">
<!-- Typen werden geladen -->
</div>
</div> </div>
<!-- Step 2: Werte eingeben --> <!-- Werte + Abgang (erscheint nach Typ-Auswahl) -->
<div id="step-values" class="step"> <div id="step-values" class="step-values-area">
<p class="step-label">Werte:</p>
<div id="value-fields" class="value-fields"> <div id="value-fields" class="value-fields">
<!-- Dynamische Felder --> <!-- Dynamische Quick-Select Chips -->
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Abgang / Beschriftung</label> <label>Abgang / Beschriftung</label>
<input type="text" id="equipment-label" placeholder="z.B. Küche Licht"> <input type="text" id="equipment-label" placeholder="z.B. Küche Licht, Steckdose Bad">
</div> </div>
</div> </div>
</div> </div>
@ -228,6 +229,7 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
</div> </div>
<script src="<?php echo DOL_URL_ROOT; ?>/includes/jquery/js/jquery.min.js"></script>
<script> <script>
// Dolibarr URL für AJAX // Dolibarr URL für AJAX
window.DOLIBARR_URL = '<?php echo DOL_URL_ROOT; ?>'; window.DOLIBARR_URL = '<?php echo DOL_URL_ROOT; ?>';

View file

@ -59,6 +59,7 @@ switch ($action) {
if ($result && $db->num_rows($result) > 0) { if ($result && $db->num_rows($result) > 0) {
$obj = $db->fetch_object($result); $obj = $db->fetch_object($result);
$userLogin->fetch($obj->rowid); $userLogin->fetch($obj->rowid);
$userLogin->getrights();
// Check password // Check password
require_once DOL_DOCUMENT_ROOT.'/core/lib/security2.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/security2.lib.php';

7
sw.js
View file

@ -3,8 +3,8 @@
* Offline-First für Schaltschrank-Dokumentation * Offline-First für Schaltschrank-Dokumentation
*/ */
const CACHE_NAME = 'kundenkarte-pwa-v1.0'; const CACHE_NAME = 'kundenkarte-pwa-v1.8';
const OFFLINE_CACHE = 'kundenkarte-offline-v1.0'; const OFFLINE_CACHE = 'kundenkarte-offline-v1.8';
// Statische Assets die immer gecached werden // Statische Assets die immer gecached werden
const STATIC_ASSETS = [ const STATIC_ASSETS = [
@ -12,7 +12,8 @@ const STATIC_ASSETS = [
'css/pwa.css', 'css/pwa.css',
'js/pwa.js', 'js/pwa.js',
'img/pwa-icon-192.png', 'img/pwa-icon-192.png',
'img/pwa-icon-512.png' 'img/pwa-icon-512.png',
'../../../includes/jquery/js/jquery.min.js'
]; ];
// Install - Cache statische Assets // Install - Cache statische Assets