feat(pwa): Anlagen-Übersicht Redesign, TE-Lücken, Feld-Badges

- Anlagen-Screen: Kontakte/Adressen oben als vertikale Liste mit Chevron
- Anlagen-Cards: Horizontales Layout (Icon + Titel + Pfeil), volle Breite
- Feld-Badges aus Admin-Einstellungen (show_in_tree) auf Anlagen-Cards
- Kunden-Adresse als Trennlabel bei Anlagen ohne Kontaktzuweisung
- Back-Navigation Fix: Anlagen werden nachgeladen falls leer (Refresh→Back)
- TE-Lücken-Berechnung: getMaxGap() für zusammenhängende freie Slots
- Typ-Buttons gefiltert: Nur Typen die in verfügbare Lücke passen
- Equipment-Blöcke: 80px Höhe, Sicherungsautomat-Optik
- PHP 8.1: trim() null-safe mit ?? ''
- Cache-Versionen synchronisiert auf v2.7
- equipmentconnection: source/target_terminal_id im UPDATE SQL ergänzt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-26 11:46:09 +01:00
parent 6e88f0eb87
commit da4ed40ad2
8 changed files with 2069 additions and 424 deletions

View file

@ -135,7 +135,7 @@ Offline-fähige Progressive Web App für Elektriker zur Schaltschrank-Dokumentat
- `ajax/pwa_api.php` - Alle AJAX-Endpoints für die PWA
- `js/pwa.js` - Komplette App-Logik (jQuery, als IIFE mit jQuery-Parameter)
- `css/pwa.css` - Mobile-First Design, Dolibarr Dark Theme Variablen
- `sw.js` - Service Worker für Offline-Cache (v1.8)
- `sw.js` - Service Worker für Offline-Cache (v2.7)
- `manifest.json` - Web App Manifest für Installation
### Workflow
@ -154,28 +154,35 @@ Offline-fähige Progressive Web App für Elektriker zur Schaltschrank-Dokumentat
- Header: sticky, primary-Farbe
### Kontakt-Adressen
- `get_anlagen` API liefert jetzt `anlagen` (Kunden-Ebene) + `contacts` (Adressen)
- Kontakt-Gruppen werden als aufklappbare Akkordeons dargestellt
- `get_anlagen` API liefert `anlagen` (Kunden-Ebene) + `contacts` (Adressen)
- Kontakt-Gruppen (Gebäude/Standorte) werden OBEN als vertikale Liste dargestellt
- Chevron-Pfeil zeigt Aufklapp-Status, Anlagen werden bei Klick geladen
- Kunden-Anlagen darunter mit Kunden-Adresse als Trennlabel
- `get_contact_anlagen` API lädt Anlagen pro Kontakt-Adresse bei Bedarf
- Kontakt-Adressen zeigen Name, Adresse, Ort und Anzahl Anlagen
- Layout: Alles untereinander (Handy-optimiert), keine Grid-Spalten
### 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`
- Equipment-Blöcke: 80px Höhe, Sicherungsautomat-Optik mit Border und Schatten
- `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
- Intelligente Positionsberechnung mit Lücken-Erkennung (getMaxGap)
- +-Button disabled wenn Hutschiene voll, Typ-Buttons gefiltert nach verfügbarer Breite
- Hutschiene zeigt belegt/gesamt TE
### 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
- Popstate-Handler: Lädt Anlagen-Daten nach falls Liste leer (z.B. nach Refresh→Back)
- Sync-Button lädt jetzt erst Offline-Queue, dann Daten neu
### Anlagen-Übersicht
- Anlagen-Cards: Horizontales Layout (Icon links, Titel rechts), volle Breite
- Einspaltiges vertikales Layout - optimiert fürs Handy
### Token-Authentifizierung
- Tokens enthalten: user_id, login, created, expires, hash
- Hash = MD5(user_id + login + MAIN_SECURITY_SALT)

View file

@ -78,6 +78,7 @@ require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentcarrier.class
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/equipmentconnection.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/mediumtype.class.php';
$action = GETPOST('action', 'aZ09');
@ -128,18 +129,43 @@ switch ($action) {
break;
}
// Feld-Metadaten laden (show_in_tree Felder für Anzeige)
$fieldMeta = array();
$sqlFields = "SELECT fk_anlage_type, field_code, field_label, tree_display_mode, badge_color, show_in_tree, field_type";
$sqlFields .= " FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field";
$sqlFields .= " WHERE active = 1 AND show_in_tree = 1";
$sqlFields .= " ORDER BY position";
$resFields = $db->query($sqlFields);
if ($resFields) {
while ($fObj = $db->fetch_object($resFields)) {
if ($fObj->field_type === 'header') continue;
$fieldMeta[(int)$fObj->fk_anlage_type][$fObj->field_code] = array(
'label' => $fObj->field_label,
'display' => $fObj->tree_display_mode ?: 'badge',
'color' => $fObj->badge_color ?: '',
);
}
$db->free($resFields);
}
// Root-Anlagen ohne Kontaktzuweisung (Kunden-Ebene)
$anlage = new Anlage($db);
$anlagen = $anlage->fetchChildren(0, $customerId);
$result = array();
foreach ($anlagen as $a) {
$result[] = array(
$item = array(
'id' => $a->id,
'label' => $a->label,
'type' => $a->type_label,
'has_editor' => !empty($a->schematic_editor_enabled)
);
// Feld-Badges hinzufügen
$fields = pwaGetAnlageFields($a, $fieldMeta);
if (!empty($fields)) {
$item['fields'] = $fields;
}
$result[] = $item;
}
// Kontakt-Adressen mit Anlagen laden
@ -181,17 +207,43 @@ switch ($action) {
break;
}
// Feld-Metadaten laden falls nicht schon vorhanden
if (!isset($fieldMeta)) {
$fieldMeta = array();
$sqlFields = "SELECT fk_anlage_type, field_code, field_label, tree_display_mode, badge_color, show_in_tree, field_type";
$sqlFields .= " FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field";
$sqlFields .= " WHERE active = 1 AND show_in_tree = 1";
$sqlFields .= " ORDER BY position";
$resFields = $db->query($sqlFields);
if ($resFields) {
while ($fObj = $db->fetch_object($resFields)) {
if ($fObj->field_type === 'header') continue;
$fieldMeta[(int)$fObj->fk_anlage_type][$fObj->field_code] = array(
'label' => $fObj->field_label,
'display' => $fObj->tree_display_mode ?: 'badge',
'color' => $fObj->badge_color ?: '',
);
}
$db->free($resFields);
}
}
$anlage = new Anlage($db);
$anlagen = $anlage->fetchChildrenByContact(0, $customerId, $contactId);
$result = array();
foreach ($anlagen as $a) {
$result[] = array(
$item = array(
'id' => $a->id,
'label' => $a->label,
'type' => $a->type_label,
'has_editor' => !empty($a->schematic_editor_enabled)
);
$fields = pwaGetAnlageFields($a, $fieldMeta);
if (!empty($fields)) {
$item['fields'] = $fields;
}
$result[] = $item;
}
$response['success'] = true;
@ -256,11 +308,15 @@ switch ($action) {
}
}
// Equipment-Typen laden (benötigt für Terminal-Position-Auflösung)
$eqType = new EquipmentType($db);
$types = $eqType->fetchAllBySystem(1, 1); // System 1 = Elektro, nur aktive
// 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 = "SELECT rowid, fk_source, output_label, medium_type, medium_spec, medium_length, connection_type, color, source_terminal, source_terminal_id";
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection";
$sql .= " WHERE fk_source IN (".implode(',', $equipmentIds).")";
$sql .= " AND fk_target IS NULL";
@ -268,21 +324,86 @@ switch ($action) {
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
// Position bestimmen wie Website: source_terminal_id → Terminal-Config
$isTop = false;
if ($obj->source_terminal === 'top') {
// PWA-erstellte Verbindung mit expliziter Top-Angabe
$isTop = true;
} elseif (!empty($obj->source_terminal_id)) {
// Website-erstellte Verbindung: Terminal-Position aus Equipment-Typ ermitteln
$termId = $obj->source_terminal_id;
$eqTypeId = null;
foreach ($equipmentData as $e) {
if ($e['id'] == $obj->fk_source) {
$eqTypeId = $e['fk_equipment_type'];
break;
}
}
if ($eqTypeId) {
// Terminal-Config des Typs prüfen
$termResolved = false;
foreach ($types as $t) {
if ($t->id == $eqTypeId && !empty($t->terminals_config)) {
$config = json_decode($t->terminals_config, true);
$terms = $config['terminals'] ?? ($config['inputs'] ?? []);
if (!empty($config['terminals'])) {
foreach ($config['terminals'] as $term) {
if ($term['id'] === $termId) {
$isTop = ($term['pos'] === 'top');
$termResolved = true;
break;
}
}
}
break;
}
}
// Fallback: t1=oben, t2=unten (Standard LS)
if (!$termResolved) {
$isTop = ($termId === 't1');
}
}
}
$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
'medium_length' => $obj->medium_length,
'connection_type' => $obj->connection_type,
'color' => $obj->color,
'source_terminal_id' => $obj->source_terminal_id ?: '',
'is_top' => $isTop
);
}
}
}
// Load equipment types
$eqType = new EquipmentType($db);
$types = $eqType->fetchAllBySystem(1, 1); // System 1 = Elektro, nur aktive
// Einspeisungen laden (Connections mit fk_source IS NULL = Inputs)
$inputsData = array();
if (!empty($equipmentData)) {
$sql = "SELECT rowid, fk_target, output_label, connection_type, color";
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection";
$sql .= " WHERE fk_target IN (".implode(',', $equipmentIds).")";
$sql .= " AND fk_source IS NULL";
$sql .= " AND status = 1";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$inputsData[] = array(
'id' => $obj->rowid,
'fk_target' => $obj->fk_target,
'output_label' => $obj->output_label,
'connection_type' => $obj->connection_type,
'color' => $obj->color
);
}
}
}
// Equipment-Typen für Response aufbereiten (bereits oben geladen)
$typesData = array();
foreach ($types as $t) {
$typesData[] = array(
@ -300,6 +421,7 @@ switch ($action) {
$response['carriers'] = $carriersData;
$response['equipment'] = $equipmentData;
$response['outputs'] = $outputsData;
$response['inputs'] = $inputsData;
$response['types'] = $typesData;
break;
@ -351,7 +473,15 @@ switch ($action) {
break;
}
// Anlage-ID vom Panel holen (benötigt für Carrier-Erstellung)
$panelObj = new EquipmentPanel($db);
if ($panelObj->fetch($panelId) <= 0) {
$response['error'] = 'Panel nicht gefunden';
break;
}
$carrier = new EquipmentCarrier($db);
$carrier->fk_anlage = $panelObj->fk_anlage;
$carrier->fk_panel = $panelId;
$carrier->label = $label ?: 'Hutschiene';
$carrier->total_te = $totalTe;
@ -365,6 +495,70 @@ switch ($action) {
}
break;
// ============================================
// UPDATE CARRIER
// ============================================
case 'update_carrier':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$carrierId = GETPOSTINT('carrier_id');
if ($carrierId <= 0) {
$response['error'] = 'Keine Carrier-ID';
break;
}
$carrier = new EquipmentCarrier($db);
if ($carrier->fetch($carrierId) <= 0) {
$response['error'] = 'Hutschiene nicht gefunden';
break;
}
$carrier->label = GETPOST('label', 'alphanohtml') ?: $carrier->label;
$newTe = GETPOSTINT('total_te');
if ($newTe > 0) {
$carrier->total_te = $newTe;
}
$result = $carrier->update($user);
if ($result > 0) {
$response['success'] = true;
} else {
$response['error'] = $carrier->error ?: 'Fehler beim Aktualisieren';
}
break;
// ============================================
// DELETE CARRIER
// ============================================
case 'delete_carrier':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$carrierId = GETPOSTINT('carrier_id');
if ($carrierId <= 0) {
$response['error'] = 'Keine Carrier-ID';
break;
}
$carrier = new EquipmentCarrier($db);
if ($carrier->fetch($carrierId) <= 0) {
$response['error'] = 'Hutschiene nicht gefunden';
break;
}
$result = $carrier->delete($user);
if ($result > 0) {
$response['success'] = true;
} else {
$response['error'] = $carrier->error ?: 'Fehler beim Löschen';
}
break;
// ============================================
// CREATE EQUIPMENT
// ============================================
@ -397,18 +591,343 @@ switch ($action) {
$equipment->width_te = $eqType->width_te ?: 1;
$equipment->field_values = $fieldValues;
// Bezeichnung automatisch generieren wenn leer (wie Website)
if (empty(trim($equipment->label ?? ''))) {
$carrier = new EquipmentCarrier($db);
if ($carrier->fetch($carrierId) > 0) {
$carrierLabel = $carrier->label ?: ('R'.$carrier->id);
$posStart = $equipment->position_te;
$posEnd = $posStart + $equipment->width_te - 1;
if ($equipment->width_te > 1) {
$equipment->label = $carrierLabel.'.'.$posStart.'-'.$posEnd;
} else {
$equipment->label = $carrierLabel.'.'.$posStart;
}
}
}
$result = $equipment->create($user);
if ($result > 0) {
$response['success'] = true;
$response['equipment_id'] = $result;
$response['label'] = $equipment->label;
$response['block_label'] = $equipment->getBlockLabel();
$response['block_color'] = $equipment->getBlockColor();
} else {
$response['error'] = $equipment->error ?: 'Fehler beim Anlegen';
}
break;
// ============================================
// GET TYPE FIELDS (Felder pro Equipment-Typ)
// ============================================
case 'get_type_fields':
$typeId = GETPOSTINT('type_id');
$equipmentId = GETPOSTINT('equipment_id');
if ($typeId <= 0) {
$response['error'] = 'Keine Typ-ID';
break;
}
$type = new EquipmentType($db);
if ($type->fetch($typeId) <= 0) {
$response['error'] = 'Typ nicht gefunden';
break;
}
$fields = $type->fetchFields(1);
// Bestehende Werte laden (bei Bearbeitung)
$existingValues = array();
if ($equipmentId > 0) {
$eq = new Equipment($db);
if ($eq->fetch($equipmentId) > 0) {
$existingValues = $eq->getFieldValues();
}
}
$result = array();
foreach ($fields as $field) {
$value = isset($existingValues[$field->field_code]) ? $existingValues[$field->field_code] : $field->field_default;
$result[] = array(
'code' => $field->field_code,
'label' => $field->field_label,
'type' => $field->field_type,
'options' => $field->field_options,
'required' => (int) $field->required,
'show_on_block' => (int) $field->show_on_block,
'value' => $value
);
}
$response['success'] = true;
$response['fields'] = $result;
break;
// ============================================
// UPDATE EQUIPMENT
// ============================================
case 'update_equipment':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$equipmentId = GETPOSTINT('equipment_id');
$label = GETPOST('label', 'alphanohtml');
$fieldValues = GETPOST('field_values', 'nohtml');
if ($equipmentId <= 0) {
$response['error'] = 'Keine Equipment-ID';
break;
}
$equipment = new Equipment($db);
if ($equipment->fetch($equipmentId) <= 0) {
$response['error'] = 'Automat nicht gefunden';
break;
}
$equipment->label = $label;
$equipment->field_values = $fieldValues;
$result = $equipment->update($user);
if ($result > 0) {
$response['success'] = true;
// Aktualisierte Daten zurückgeben
$response['block_label'] = $equipment->getBlockLabel();
$response['block_color'] = $equipment->getBlockColor();
} else {
$response['error'] = $equipment->error ?: 'Fehler beim Aktualisieren';
}
break;
// ============================================
// DELETE EQUIPMENT
// ============================================
case 'delete_equipment':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$equipmentId = GETPOSTINT('equipment_id');
if ($equipmentId <= 0) {
$response['error'] = 'Keine Equipment-ID';
break;
}
$equipment = new Equipment($db);
if ($equipment->fetch($equipmentId) <= 0) {
$response['error'] = 'Automat nicht gefunden';
break;
}
$result = $equipment->delete($user);
if ($result > 0) {
$response['success'] = true;
} else {
$response['error'] = $equipment->error ?: 'Fehler beim Löschen';
}
break;
// ============================================
// CREATE CONNECTION (Abgang oder Einspeisung)
// ============================================
case 'create_connection':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$equipmentId = GETPOSTINT('equipment_id');
$direction = GETPOST('direction', 'alpha'); // 'output' oder 'input'
$connectionType = GETPOST('connection_type', 'alphanohtml');
$outputLabel = GETPOST('output_label', 'alphanohtml');
$mediumType = GETPOST('medium_type', 'alphanohtml');
$mediumSpec = GETPOST('medium_spec', 'alphanohtml');
$mediumLength = GETPOST('medium_length', 'alphanohtml');
$sourceTerminal = GETPOST('source_terminal', 'alphanohtml') ?: 'output';
$sourceTerminalId = GETPOST('source_terminal_id', 'alphanohtml');
if ($equipmentId <= 0) {
$response['error'] = 'Keine Equipment-ID';
break;
}
// Equipment prüfen und Carrier-ID ermitteln
$eq = new Equipment($db);
if ($eq->fetch($equipmentId) <= 0) {
$response['error'] = 'Automat nicht gefunden';
break;
}
$conn = new EquipmentConnection($db);
$conn->connection_type = $connectionType;
$conn->color = GETPOST('color', 'alphanohtml');
$conn->output_label = $outputLabel;
$conn->fk_carrier = $eq->fk_carrier;
if ($direction === 'input') {
// Einspeisung: fk_source = NULL, fk_target = Equipment
$conn->fk_target = $equipmentId;
$conn->fk_source = null;
$conn->target_terminal = 'input';
} else {
// Abgang: fk_source = Equipment, fk_target = NULL
$conn->fk_source = $equipmentId;
$conn->fk_target = null;
$conn->source_terminal = $sourceTerminal;
$conn->source_terminal_id = $sourceTerminalId ?: ($sourceTerminal === 'top' ? 't1' : 't2');
$conn->medium_type = $mediumType;
$conn->medium_spec = $mediumSpec;
$conn->medium_length = $mediumLength;
}
$result = $conn->create($user);
if ($result > 0) {
$response['success'] = true;
$response['connection_id'] = $result;
} else {
$response['error'] = $conn->error ?: 'Fehler beim Anlegen';
}
break;
// ============================================
// UPDATE CONNECTION
// ============================================
case 'update_connection':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$connectionId = GETPOSTINT('connection_id');
if ($connectionId <= 0) {
$response['error'] = 'Keine Verbindungs-ID';
break;
}
$conn = new EquipmentConnection($db);
if ($conn->fetch($connectionId) <= 0) {
$response['error'] = 'Verbindung nicht gefunden';
break;
}
$conn->connection_type = GETPOST('connection_type', 'alphanohtml');
$conn->color = GETPOST('color', 'alphanohtml');
$conn->output_label = GETPOST('output_label', 'alphanohtml');
$conn->medium_type = GETPOST('medium_type', 'alphanohtml');
$conn->medium_spec = GETPOST('medium_spec', 'alphanohtml');
$conn->medium_length = GETPOST('medium_length', 'alphanohtml');
if (GETPOSTISSET('source_terminal')) {
$conn->source_terminal = GETPOST('source_terminal', 'alphanohtml') ?: $conn->source_terminal;
}
if (GETPOSTISSET('source_terminal_id')) {
$conn->source_terminal_id = GETPOST('source_terminal_id', 'alphanohtml') ?: $conn->source_terminal_id;
}
$result = $conn->update($user);
if ($result > 0) {
$response['success'] = true;
} else {
$response['error'] = $conn->error ?: 'Fehler beim Aktualisieren';
}
break;
// ============================================
// DELETE CONNECTION
// ============================================
case 'delete_connection':
if (!$user->hasRight('kundenkarte', 'write')) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$connectionId = GETPOSTINT('connection_id');
if ($connectionId <= 0) {
$response['error'] = 'Keine Verbindungs-ID';
break;
}
$conn = new EquipmentConnection($db);
if ($conn->fetch($connectionId) <= 0) {
$response['error'] = 'Verbindung nicht gefunden';
break;
}
$result = $conn->delete($user);
if ($result > 0) {
$response['success'] = true;
} else {
$response['error'] = $conn->error ?: 'Fehler beim Löschen';
}
break;
// ============================================
// GET MEDIUM TYPES (Kabeltypen aus DB)
// ============================================
case 'get_medium_types':
$mediumType = new MediumType($db);
$grouped = $mediumType->fetchGroupedByCategory(1); // System 1 = Elektro
$result = array();
foreach ($grouped as $category => $types) {
$catTypes = array();
foreach ($types as $t) {
$catTypes[] = array(
'ref' => $t->ref,
'label' => $t->label,
'default_spec' => $t->default_spec,
'available_specs' => $t->getAvailableSpecsArray(),
);
}
$result[] = array(
'category' => $category,
'category_label' => $types[0]->getCategoryLabel(),
'types' => $catTypes
);
}
$response['success'] = true;
$response['groups'] = $result;
break;
default:
$response['error'] = 'Unbekannte Aktion: ' . $action;
}
echo json_encode($response);
$db->close();
/**
* Feld-Badges für eine Anlage aufbereiten (show_in_tree Felder)
*
* @param Anlage $anlage Anlage-Objekt
* @param array $fieldMeta Feld-Metadaten [typeId][code] = {label, display, color}
* @return array Array von Feld-Objekten mit label, value, color
*/
function pwaGetAnlageFields($anlage, $fieldMeta) {
$result = array();
$values = $anlage->getFieldValues();
if (empty($values)) return $result;
$typeId = (int) $anlage->fk_anlage_type;
$meta = isset($fieldMeta[$typeId]) ? $fieldMeta[$typeId] : array();
if (empty($meta)) return $result;
foreach ($meta as $code => $fm) {
$val = isset($values[$code]) ? $values[$code] : null;
if ($val === '' || $val === null) continue;
$result[] = array(
'label' => $fm['label'],
'value' => $val,
'color' => $fm['color'],
'display' => $fm['display'],
);
}
return $result;
}

View file

@ -218,8 +218,10 @@ class EquipmentConnection extends CommonObject
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " fk_source = ".($this->fk_source > 0 ? ((int) $this->fk_source) : "NULL");
$sql .= ", source_terminal = '".$this->db->escape($this->source_terminal ?: 'output')."'";
$sql .= ", source_terminal_id = ".($this->source_terminal_id ? "'".$this->db->escape($this->source_terminal_id)."'" : "NULL");
$sql .= ", fk_target = ".($this->fk_target > 0 ? ((int) $this->fk_target) : "NULL");
$sql .= ", target_terminal = '".$this->db->escape($this->target_terminal ?: 'input')."'";
$sql .= ", target_terminal_id = ".($this->target_terminal_id ? "'".$this->db->escape($this->target_terminal_id)."'" : "NULL");
$sql .= ", connection_type = ".($this->connection_type ? "'".$this->db->escape($this->connection_type)."'" : "NULL");
$sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL");
$sql .= ", output_label = ".($this->output_label ? "'".$this->db->escape($this->output_label)."'" : "NULL");

View file

@ -443,25 +443,27 @@ body {
.anlagen-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 16px;
flex-direction: column;
gap: 6px;
padding: 12px;
}
.anlagen-grid > .anlage-card {
width: calc(50% - 5px);
box-sizing: border-box;
}
.anlagen-grid > .contact-group {
width: 100%;
/* Trennlabel Kunden-Adresse */
.anlagen-section-label {
font-size: 12px;
font-weight: 600;
color: var(--colortextmuted);
padding: 8px 4px 2px;
border-top: 1px solid var(--colorborder);
margin-top: 4px;
}
.anlage-card {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
padding: 18px 14px;
gap: 12px;
padding: 14px;
background: var(--colorbackline);
border: 1px solid var(--colorborder);
border-radius: 8px;
@ -475,48 +477,84 @@ body {
}
.anlage-card-icon {
width: 52px;
height: 52px;
width: 44px;
height: 44px;
background: var(--success);
border-radius: 12px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
flex-shrink: 0;
}
.anlage-card-icon svg {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
fill: #fff;
}
.anlage-card-title {
font-size: 14px;
font-size: 15px;
font-weight: 600;
text-align: center;
word-break: break-word;
line-height: 1.3;
}
.anlage-card-content {
flex: 1;
min-width: 0;
}
.anlage-card-type {
font-size: 11px;
font-size: 12px;
color: var(--colortextmuted);
margin-top: 4px;
margin-top: 2px;
}
.anlage-card-arrow {
width: 20px;
height: 20px;
fill: var(--colortextmuted);
flex-shrink: 0;
}
.anlage-card-fields {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.anlage-field-badge {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: var(--colorbackinput);
color: #fff;
white-space: nowrap;
}
/* ============================================
CONTACT GROUPS
============================================ */
.contact-list {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.contact-group {
margin-bottom: 8px;
margin-bottom: 0;
}
.contact-group-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
gap: 10px;
padding: 12px 14px;
background: var(--colorbackline);
border: 1px solid var(--colorborder);
border-radius: 8px;
@ -552,8 +590,12 @@ body {
margin-top: 2px;
}
.contact-group-info {
flex: 1;
min-width: 0;
}
.contact-group-count {
margin-left: auto;
background: var(--colorbackinput);
color: var(--colortextmuted);
font-size: 12px;
@ -562,35 +604,35 @@ body {
border-radius: 10px;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
.contact-group-chevron {
width: 20px;
height: 20px;
fill: var(--colortextmuted);
flex-shrink: 0;
transition: transform 0.2s;
}
.contact-group.expanded .contact-group-chevron {
transform: rotate(90deg);
}
.contact-anlagen-list {
padding: 10px;
padding: 8px;
background: rgba(59, 60, 62, 0.4);
border: 1px solid var(--colorborder);
border-top: none;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
flex-wrap: wrap;
gap: 8px;
flex-direction: column;
gap: 6px;
display: none;
}
.contact-group.expanded .contact-anlagen-list {
display: flex;
gap: 8px;
}
.contact-anlagen-list .anlage-card {
width: calc(50% - 4px);
padding: 14px 10px;
box-sizing: border-box;
}
.contact-anlagen-list .anlage-card-icon {
width: 44px;
height: 44px;
margin-bottom: 8px;
}
.contact-anlagen-list .loading-container,
@ -625,8 +667,9 @@ body {
background: var(--colorbackline);
border: 1px solid var(--colorborder);
border-radius: 8px;
margin-bottom: 12px;
margin: 0 auto 12px auto;
overflow: hidden;
max-width: 900px;
}
.panel-header {
@ -667,6 +710,7 @@ body {
justify-content: space-between;
padding: 8px 10px;
background: rgba(255,255,255,0.03);
cursor: pointer;
}
.carrier-label {
@ -681,35 +725,35 @@ body {
opacity: 0.7;
}
.carrier-body {
padding: 5px;
display: flex;
align-items: stretch;
gap: 3px;
min-height: 50px;
}
.carrier-grid {
flex: 1;
min-width: 0;
/* Carrier-Content: EIN Grid für Terminals + Equipment + +-Button
Spalten: repeat(totalTE, 1fr) auto
Zeile 1: Input-Terminals (Einspeisungen)
Zeile 2: Equipment-Blöcke + +-Button
Zeile 3: Output-Terminals (Abgänge) */
.carrier-content {
display: grid;
gap: 2px;
padding: 5px;
grid-template-rows: auto auto auto;
align-items: stretch;
}
/* Equipment Block */
/* Equipment Block (Zeile 2) - Sicherungsautomat-Optik */
.equipment-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: space-between;
min-width: 0;
height: 52px;
padding: 2px 1px;
height: 80px;
padding: 4px 2px;
background: var(--primary);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.15);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.equipment-block:active {
@ -724,10 +768,10 @@ body {
}
.equipment-block-value {
font-size: 11px;
font-size: 13px;
font-weight: bold;
color: #fff;
line-height: 1.1;
line-height: 1.2;
}
.equipment-block-label {
@ -754,119 +798,103 @@ body {
padding: 0 1px;
}
/* Abgang-Labels (Zeile über/unter den Blöcken) */
.carrier-labels {
display: grid;
gap: 2px;
padding: 0 5px;
min-height: 120px;
}
.carrier-label-cell {
display: flex;
align-items: flex-end;
justify-content: center;
min-width: 0;
overflow: visible;
}
/* Labels oben: Text vertikal von unten nach oben */
.labels-top .carrier-labels {
align-items: end;
}
.labels-top .carrier-label-text {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 11px;
font-weight: bold;
color: #fff;
line-height: 1.3;
max-height: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 0;
}
.labels-top .carrier-label-text .cable-info {
font-weight: normal;
font-size: 10px;
color: #888;
}
/* Labels unten: Text vertikal von oben nach unten */
.labels-bottom .carrier-labels {
align-items: start;
}
.labels-bottom .carrier-label-cell {
align-items: flex-start;
}
.labels-bottom .carrier-label-text {
writing-mode: vertical-rl;
font-size: 11px;
font-weight: bold;
color: #fff;
line-height: 1.3;
max-height: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 4px 0;
}
.labels-bottom .carrier-label-text .cable-info {
font-weight: normal;
font-size: 10px;
color: #888;
}
/* Toggle-Button für Label-Position */
.btn-toggle-labels {
width: 44px;
height: 44px;
/* Terminal-Point = Einzelne klickbare Klemme
Sitzt in derselben Grid-Spalte wie der zugehörige Equipment-Block */
.terminal-point {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.15);
border: none;
border-radius: 8px;
color: #fff;
gap: 2px;
cursor: pointer;
transition: background 0.2s, transform 0.3s;
padding: 3px 2px;
border-radius: 4px;
transition: background 0.15s;
min-width: 0;
}
.btn-toggle-labels:active {
background: rgba(255,255,255,0.3);
.terminal-point.terminal-input {
align-self: end;
}
.btn-toggle-labels svg {
width: 22px;
height: 22px;
fill: currentColor;
.terminal-point.terminal-output {
align-self: start;
}
/* Pfeil nach oben = Labels oben */
.btn-toggle-labels.labels-top svg {
transform: rotate(0deg);
.terminal-point:active {
background: rgba(255,255,255,0.15);
}
/* Pfeil nach unten = Labels unten */
.btn-toggle-labels.labels-bottom svg {
/* Terminal-Punkt (Kreis für Inputs) */
.terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.5);
}
.terminal-dot.terminal-empty {
background: transparent;
border: 2px dashed rgba(255,255,255,0.25);
}
/* Terminal-Pfeil (für Outputs) */
.terminal-arrow {
width: 0;
height: 0;
flex-shrink: 0;
}
.terminal-arrow-down {
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid var(--arrow-color, #888);
}
.terminal-arrow-up {
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 10px solid var(--arrow-color, #888);
}
/* Phase-Anzeige am Terminal */
.terminal-phase {
font-size: 8px;
font-weight: bold;
color: rgba(255,255,255,0.7);
text-align: center;
line-height: 1;
}
/* Abgang-Label (vertikal) */
.terminal-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 9px;
font-weight: bold;
color: #fff;
line-height: 1.1;
max-height: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 0;
}
/* Add Button in Carrier */
.terminal-label .cable-info {
font-weight: normal;
font-size: 8px;
color: #888;
}
/* Output-Zeile braucht mehr Platz wenn Labels vorhanden */
/* Add Button in Carrier (letzte Spalte, Zeile 2) */
.btn-add-equipment {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
min-width: 36px;
flex-shrink: 0;
align-self: stretch;
padding: 4px;
background: transparent;
border: 2px dashed var(--colorborder);
@ -972,10 +1000,11 @@ body {
bottom: 0;
background: rgba(0,0,0,0.7);
display: none;
align-items: flex-end;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--safe-bottom);
padding: 16px;
padding-bottom: calc(16px + var(--safe-bottom));
}
.modal.active {
@ -988,23 +1017,25 @@ body {
max-height: 85vh;
background: var(--colorbackline);
border: 1px solid var(--colorborder);
border-radius: 12px 12px 0 0;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 0.3s ease;
animation: modalFadeIn 0.25s ease;
}
.modal-small {
max-height: auto;
}
@keyframes slideUp {
@keyframes modalFadeIn {
from {
transform: translateY(100%);
opacity: 0;
transform: scale(0.95);
}
to {
transform: translateY(0);
opacity: 1;
transform: scale(1);
}
}
@ -1051,21 +1082,101 @@ body {
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--colorborder);
justify-content: space-between;
align-items: center;
}
.modal-footer .btn {
flex: 0 1 auto;
}
.modal-footer-right {
display: flex;
gap: 12px;
flex: 1;
}
.modal-footer-right .btn {
flex: 1;
}
/* Wenn kein Delete-Button sichtbar: Footer-Right nimmt volle Breite */
.modal-footer .btn-danger.hidden + .modal-footer-right {
width: 100%;
}
/* ============================================
TYPE GRID
============================================ */
.step-values-area {
display: none;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--colorborder);
/* Zurück-Button im Modal-Header */
.btn-back-modal {
background: none;
border: none;
color: var(--colortext);
padding: 4px;
cursor: pointer;
flex-shrink: 0;
}
.btn-back-modal svg {
width: 24px;
height: 24px;
fill: currentColor;
}
/* Dynamische Felder */
#eq-dynamic-fields .form-group {
margin-bottom: 14px;
}
#eq-dynamic-fields .form-select {
width: 100%;
padding: 12px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 8px;
color: var(--colortext);
font-size: 16px;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23888'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 24px;
}
#eq-dynamic-fields .form-input {
width: 100%;
padding: 12px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 8px;
color: var(--colortext);
font-size: 16px;
box-sizing: border-box;
}
.field-required {
color: var(--danger);
font-weight: bold;
}
.field-error {
border-color: var(--danger) !important;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 20px;
height: 20px;
}
.step-label {
@ -1100,6 +1211,12 @@ body {
background: rgba(173, 140, 79, 0.15);
}
.type-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
pointer-events: none;
}
.type-btn-icon {
font-size: 24px;
margin-bottom: 6px;
@ -1135,31 +1252,124 @@ body {
background: rgba(173, 140, 79, 0.15);
}
/* Value Quick Select */
.value-quick {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
/* Connection Modal - Phase Grid */
.phase-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.value-chip {
padding: 10px 16px;
.phase-btn {
padding: 10px 4px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 20px;
border: 2px solid var(--colorborder);
border-radius: 8px;
color: var(--colortext);
font-size: 14px;
font-weight: 500;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.value-chip:active,
.value-chip.selected {
background: var(--butactionbg);
.phase-btn:active,
.phase-btn.selected {
border-color: var(--butactionbg);
color: var(--textbutaction);
background: rgba(173, 140, 79, 0.15);
}
/* Phasen-Farben für Buttons */
.phase-btn[data-type="L1"].selected { border-color: #8B4513; background: rgba(139,69,19,0.2); }
.phase-btn[data-type="L2"].selected { border-color: #555; background: rgba(50,50,50,0.3); }
.phase-btn[data-type="L3"].selected { border-color: #888; background: rgba(100,100,100,0.2); }
.phase-btn[data-type="N"].selected { border-color: #0066cc; background: rgba(0,102,204,0.2); }
.phase-btn[data-type="PE"].selected { border-color: #27ae60; background: rgba(39,174,96,0.2); }
.phase-btn[data-type="L1N"].selected { border-color: #8B4513; background: rgba(139,69,19,0.2); }
.phase-btn[data-type="3P"].selected { border-color: #e74c3c; background: rgba(231,76,60,0.2); }
.phase-btn[data-type="3P+N"].selected { border-color: #e74c3c; background: rgba(231,76,60,0.2); }
.phase-btn[data-type="L2N"].selected { border-color: #555; background: rgba(50,50,50,0.3); }
.phase-btn[data-type="L3N"].selected { border-color: #888; background: rgba(100,100,100,0.2); }
.phase-btn[data-type="DATA"].selected { border-color: #9b59b6; background: rgba(155,89,182,0.2); }
/* Abgangsseite-Buttons */
.side-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.side-btn {
padding: 10px 4px;
background: var(--colorbackinput);
border: 2px solid var(--colorborder);
border-radius: 8px;
color: var(--colortext);
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.side-btn:active,
.side-btn.selected {
border-color: var(--butactionbg);
background: rgba(173, 140, 79, 0.15);
}
/* Connection Modal - Typ + Farbe nebeneinander */
.form-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.form-group-grow {
flex: 1;
min-width: 0;
}
.form-group-color {
flex: 0 0 auto;
width: 60px;
}
.form-color {
width: 100%;
height: 44px;
padding: 2px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 8px;
cursor: pointer;
}
/* Connection Modal - optgroup Styles */
#modal-connection .form-select optgroup {
font-weight: bold;
color: var(--colortext);
}
/* Connection Modal - form-select/form-input global */
#modal-connection .form-select,
#modal-connection .form-input {
width: 100%;
padding: 12px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 8px;
color: var(--colortext);
font-size: 16px;
box-sizing: border-box;
}
#modal-connection .form-select {
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23888'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 24px;
}
/* ============================================

View file

@ -7406,27 +7406,45 @@
// Check if carrier has position data (use typeof to allow 0 values)
if (typeof carrier._x !== 'undefined' && typeof carrier._y !== 'undefined') {
// Find last equipment position
var lastPos = 0;
// Belegte Slots ermitteln (1-basiert)
var totalTE = parseInt(carrier.total_te) || 12;
var occupied = {};
var lastEquipment = null;
var lastEndPos = 0;
carrierEquipment.forEach(function(eq) {
var endPos = (parseInt(eq.position_te) || 1) + (parseInt(eq.width_te) || 1) - 1;
if (endPos > lastPos) {
lastPos = endPos;
var pos = parseInt(eq.position_te) || 1;
var w = parseInt(eq.width_te) || 1;
var endPos = pos + w - 1;
for (var s = pos; s <= endPos; s++) {
occupied[s] = true;
}
if (endPos > lastEndPos) {
lastEndPos = endPos;
lastEquipment = eq;
}
});
// Maximale zusammenhängende Lücke berechnen
var maxGap = 0;
var currentGap = 0;
for (var s = 1; s <= totalTE; s++) {
if (!occupied[s]) {
currentGap++;
if (currentGap > maxGap) maxGap = currentGap;
} else {
currentGap = 0;
}
}
// Carrier-Objekt merkt sich maximale Lücke für Typ-Filter
carrier._maxGap = maxGap;
// Calculate button positions - place buttons on the LEFT side of the carrier
// Rail label is at about carrier._x - 44, so buttons need to be further left
var btnX = carrier._x - 100; // Left of the carrier and rail label
var btnY = carrier._y - self.BLOCK_HEIGHT / 2 + 10; // Aligned with blocks
// Check if there's space left
var totalTE = parseInt(carrier.total_te) || 12;
console.log('Carrier ' + carrier.id + ' (' + carrier.label + '): lastPos=' + lastPos + ', totalTE=' + totalTE + ', hasSpace=' + (lastPos < totalTE) + ', lastEquipment=' + (lastEquipment ? lastEquipment.id : 'none'));
if (lastPos < totalTE) {
if (maxGap > 0) {
// Add Equipment button - positioned left of carrier
var $addEquipment = $('<button class="schematic-add-equipment" data-carrier-id="' + carrier.id + '" title="Equipment hinzufügen" style="position:absolute;left:' + btnX + 'px;top:' + btnY + 'px;' + btnStyle + '">+</button>');
$controls.append($addEquipment);
@ -7498,7 +7516,10 @@
$(document).off('click.addEquipment').on('click.addEquipment', '.schematic-add-equipment', function(e) {
e.preventDefault();
var carrierId = $(this).data('carrier-id');
self.showAddEquipmentDialog(carrierId);
// Maximale Lücke vom Carrier holen für Typ-Filter
var carrier = self.carriers.find(function(c) { return String(c.id) === String(carrierId); });
var maxGap = carrier ? (carrier._maxGap || 99) : 99;
self.showAddEquipmentDialog(carrierId, maxGap);
});
// Copy Equipment (single copy)
@ -8010,8 +8031,9 @@
});
},
showAddEquipmentDialog: function(carrierId) {
showAddEquipmentDialog: function(carrierId, maxGap) {
var self = this;
maxGap = maxGap || 99;
// Load equipment types first
$.ajax({
@ -8020,13 +8042,13 @@
dataType: 'json',
success: function(response) {
if (response.success) {
self.showEquipmentTypeSelector(carrierId, response.types);
self.showEquipmentTypeSelector(carrierId, response.types, maxGap);
}
}
});
},
showEquipmentTypeSelector: function(carrierId, types) {
showEquipmentTypeSelector: function(carrierId, types, maxGap) {
var self = this;
// Kategorisiere Equipment-Typen
@ -8037,8 +8059,12 @@
'klemme': { label: 'Klemmen', icon: 'fa-th-list', items: [] }
};
// Sortiere Typen in Kategorien
// Sortiere Typen in Kategorien (nur wenn Breite in verfügbare Lücke passt)
maxGap = maxGap || 99;
types.forEach(function(t) {
var typeWidth = parseInt(t.width_te) || 1;
if (typeWidth > maxGap) return; // Passt nicht in verfügbare Lücke
var ref = (t.ref || '').toUpperCase();
var label = (t.label || '').toLowerCase();

1131
js/pwa.js

File diff suppressed because it is too large Load diff

149
pwa.php
View file

@ -44,7 +44,7 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<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="stylesheet" href="css/pwa.css">
<link rel="stylesheet" href="css/pwa.css?v=2.7">
<style>:root { --primary: <?php echo $themeColor; ?>; }</style>
</head>
<body>
@ -125,9 +125,6 @@ $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>
</button>
<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">
<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>
@ -146,42 +143,130 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
</div>
</div>
<!-- Automat hinzufügen Modal -->
<!-- Automat hinzufügen/bearbeiten Modal -->
<div id="modal-add-equipment" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Automat hinzufügen</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p class="step-label">Typ wählen:</p>
<div id="type-grid" class="type-grid">
<!-- Typen werden geladen -->
<!-- Schritt 1: Typ wählen -->
<div id="eq-step-type">
<div class="modal-header">
<h2 id="equipment-modal-title">Automat hinzufügen</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p class="step-label">Typ wählen:</p>
<div id="type-grid" class="type-grid">
<!-- Typen werden geladen -->
</div>
</div>
</div>
<!-- Werte + Abgang (erscheint nach Typ-Auswahl) -->
<div id="step-values" class="step-values-area">
<div id="value-fields" class="value-fields">
<!-- Dynamische Quick-Select Chips -->
<!-- Schritt 2: Felder ausfüllen -->
<div id="eq-step-fields" class="hidden">
<div class="modal-header">
<button id="btn-eq-back" class="btn-icon btn-back-modal">
<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>
<h2 id="eq-fields-title">Werte</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div id="eq-dynamic-fields">
<!-- Dynamische Felder vom Server -->
</div>
<div class="form-group">
<label>Abgang / Beschriftung</label>
<input type="text" id="equipment-label" placeholder="z.B. Küche Licht, Steckdose Bad">
<label>Bezeichnung</label>
<input type="text" id="equipment-label" placeholder="Leer = automatisch (z.B. R1.3)">
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-equipment" class="btn btn-danger hidden">Löschen</button>
<div class="modal-footer-right">
<button id="btn-cancel-equipment" class="btn btn-secondary">Abbrechen</button>
<button id="btn-save-equipment" class="btn btn-primary">Speichern</button>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel-equipment" class="btn btn-secondary">Abbrechen</button>
<button id="btn-save-equipment" class="btn btn-primary">Speichern</button>
</div>
</div>
</div>
<!-- Hutschiene hinzufügen Modal -->
<!-- Bestätigungsdialog -->
<div id="modal-confirm" class="modal">
<div class="modal-content modal-small">
<div class="modal-header">
<h2 id="confirm-title">Löschen?</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<p id="confirm-message">Diesen Automaten wirklich löschen?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-close">Abbrechen</button>
<button id="btn-confirm-ok" class="btn btn-danger">Löschen</button>
</div>
</div>
</div>
<!-- Verbindung (Abgang/Anschlusspunkt) Modal -->
<div id="modal-connection" class="modal">
<div class="modal-content modal-small">
<div class="modal-header">
<h2 id="connection-modal-title">Verbindung bearbeiten</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group form-group-grow">
<label>Typ</label>
<select id="conn-type" class="form-select">
<!-- Wird dynamisch befüllt -->
</select>
</div>
<div class="form-group form-group-color">
<label>Farbe</label>
<input type="color" id="conn-color" class="form-color" value="#3498db">
</div>
</div>
<div class="form-group">
<label>Bezeichnung</label>
<input type="text" id="conn-label" class="form-input" placeholder="z.B. Küche Steckdosen">
</div>
<div id="conn-output-fields">
<div class="form-group">
<label>Abgangsseite</label>
<div id="conn-side-grid" class="side-grid">
<button type="button" class="side-btn selected" data-side="bottom">&#9660; Unten</button>
<button type="button" class="side-btn" data-side="top">&#9650; Oben</button>
</div>
</div>
<div class="form-group">
<label>Medium</label>
<input type="text" id="conn-medium-type" class="form-input" placeholder="z.B. NYM-J, CAT6">
</div>
<div class="form-group">
<label>Spezifikation</label>
<input type="text" id="conn-medium-spec" class="form-input" placeholder="z.B. 3x1,5mm²">
</div>
<div class="form-group">
<label>Länge</label>
<input type="text" id="conn-medium-length" class="form-input" placeholder="z.B. 5m">
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-connection" class="btn btn-danger hidden">Löschen</button>
<div class="modal-footer-right">
<button class="btn btn-secondary modal-close">Abbrechen</button>
<button id="btn-save-connection" class="btn btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Hutschiene hinzufügen/bearbeiten Modal -->
<div id="modal-add-carrier" class="modal">
<div class="modal-content modal-small">
<div class="modal-header">
<h2>Hutschiene</h2>
<h2 id="carrier-modal-title">Hutschiene</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
@ -198,8 +283,10 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-close">Abbrechen</button>
<button id="btn-save-carrier" class="btn btn-primary">Hinzufügen</button>
<button id="btn-delete-carrier" class="btn btn-danger hidden">Löschen</button>
<div class="modal-footer-right">
<button id="btn-save-carrier" class="btn btn-primary">Hinzufügen</button>
</div>
</div>
</div>
</div>
@ -218,8 +305,10 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-close">Abbrechen</button>
<button id="btn-save-panel" class="btn btn-primary">Hinzufügen</button>
<div class="modal-footer-right">
<button class="btn btn-secondary modal-close">Abbrechen</button>
<button id="btn-save-panel" class="btn btn-primary">Hinzufügen</button>
</div>
</div>
</div>
</div>
@ -235,6 +324,6 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
window.DOLIBARR_URL = '<?php echo DOL_URL_ROOT; ?>';
window.MODULE_URL = '<?php echo DOL_URL_ROOT; ?>/custom/kundenkarte';
</script>
<script src="js/pwa.js"></script>
<script src="js/pwa.js?v=2.7"></script>
</body>
</html>

29
sw.js
View file

@ -3,8 +3,8 @@
* Offline-First für Schaltschrank-Dokumentation
*/
const CACHE_NAME = 'kundenkarte-pwa-v1.8';
const OFFLINE_CACHE = 'kundenkarte-offline-v1.8';
const CACHE_NAME = 'kundenkarte-pwa-v2.7';
const OFFLINE_CACHE = 'kundenkarte-offline-v2.7';
// Statische Assets die immer gecached werden
const STATIC_ASSETS = [
@ -106,20 +106,19 @@ self.addEventListener('fetch', event => {
return;
}
// Statische Assets - Cache First
// Statische Assets (JS, CSS, Bilder) - Stale-While-Revalidate
// Liefert sofort aus Cache, holt parallel frische Version für nächsten Aufruf
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) {
return cached;
}
return fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, clone);
});
}
return response;
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
if (response.ok) {
cache.put(event.request, response.clone());
}
return response;
}).catch(() => cached);
return cached || fetchPromise;
});
})
);