From da4ed40ad2802a7020160b72b63c15b4088342a6 Mon Sep 17 00:00:00 2001 From: data Date: Thu, 26 Feb 2026 11:46:09 +0100 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20Anlagen-=C3=9Cbersicht=20Redesign,?= =?UTF-8?q?=20TE-L=C3=BCcken,=20Feld-Badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 23 +- ajax/pwa_api.php | 533 ++++++++++++- class/equipmentconnection.class.php | 2 + css/pwa.css | 570 +++++++++----- js/kundenkarte.js | 56 +- js/pwa.js | 1131 +++++++++++++++++++++++---- pwa.php | 149 +++- sw.js | 29 +- 8 files changed, 2069 insertions(+), 424 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31d6e58..5484a65 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/ajax/pwa_api.php b/ajax/pwa_api.php index ee91201..678403a 100644 --- a/ajax/pwa_api.php +++ b/ajax/pwa_api.php @@ -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; +} diff --git a/class/equipmentconnection.class.php b/class/equipmentconnection.class.php index d1e7365..b174cf3 100755 --- a/class/equipmentconnection.class.php +++ b/class/equipmentconnection.class.php @@ -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"); diff --git a/css/pwa.css b/css/pwa.css index d809db9..459a105 100644 --- a/css/pwa.css +++ b/css/pwa.css @@ -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; } /* ============================================ diff --git a/js/kundenkarte.js b/js/kundenkarte.js index 6c795cb..4f3e605 100755 --- a/js/kundenkarte.js +++ b/js/kundenkarte.js @@ -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 = $(''); $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(); diff --git a/js/pwa.js b/js/pwa.js index 3709260..8f5f8df 100644 --- a/js/pwa.js +++ b/js/pwa.js @@ -18,6 +18,7 @@ // Current selection customerId: null, customerName: '', + customerAddress: '', anlageId: null, anlageName: '', @@ -27,6 +28,7 @@ equipment: [], equipmentTypes: [], outputs: [], + inputs: [], // Offline queue offlineQueue: [], @@ -34,10 +36,16 @@ // Current modal state currentCarrierId: null, + editCarrierId: null, // null = Add-Modus, ID = Edit-Modus (Hutschiene) selectedTypeId: null, + editEquipmentId: null, // null = Add-Modus, ID = Edit-Modus + confirmCallback: null, // Callback für Bestätigungsdialog + editConnectionId: null, // null = Neu, ID = Edit + connectionEquipmentId: null, // Equipment für aktuelle Connection + connectionDirection: 'output', // 'output' oder 'input' + mediumTypes: null, // Kabeltypen aus DB (gecacht) + cachedTypeFields: null, // Equipment-Felder Cache - // Abgang-Labels: 'top' (Standard, wie echtes Panel) oder 'bottom' - labelsPosition: localStorage.getItem('kundenkarte_labels_pos') || 'top', }; // ============================================ @@ -77,6 +85,7 @@ if (lastState.customerId) { App.customerId = lastState.customerId; App.customerName = lastState.customerName || ''; + App.customerAddress = lastState.customerAddress || ''; $('#customer-name').text(App.customerName); } if (lastState.anlageId) { @@ -85,11 +94,15 @@ $('#anlage-name').text(App.anlageName); } - // Screen wiederherstellen + // Screen wiederherstellen inkl. vollständiger History-Stack + // Damit Zurück-Button auch nach App-Suspend korrekt funktioniert if (lastState.screen === 'editor' && App.anlageId) { + history.replaceState({ screen: 'search' }, '', '#search'); + history.pushState({ screen: 'anlagen' }, '', '#anlagen'); showScreen('editor'); loadEditorData(); } else if (lastState.screen === 'anlagen' && App.customerId) { + history.replaceState({ screen: 'search' }, '', '#search'); showScreen('anlagen'); reloadAnlagen(); } else { @@ -100,8 +113,10 @@ } } - // Initialen History-State setzen - history.replaceState({ screen: $('.screen.active').attr('id')?.replace('screen-', '') || 'login' }, ''); + // Initialen History-State setzen (nur wenn kein Session-Restore) + if (!sessionStorage.getItem('kundenkarte_pwa_state')) { + history.replaceState({ screen: $('.screen.active').attr('id')?.replace('screen-', '') || 'login' }, ''); + } // Load offline queue const storedQueue = localStorage.getItem('kundenkarte_offline_queue'); @@ -129,8 +144,22 @@ // Browser/Hardware Zurück-Button window.addEventListener('popstate', function(e) { + // Wenn ein Modal offen ist: Modal schließen statt navigieren + const $activeModal = $('.modal.active'); + if ($activeModal.length) { + $activeModal.removeClass('active'); + // Aktuellen State wieder pushen (Navigation verhindern) + const currentScreen = $('.screen.active').attr('id')?.replace('screen-', '') || 'search'; + history.pushState({ screen: currentScreen }, '', '#' + currentScreen); + return; + } + if (e.state && e.state.screen) { showScreen(e.state.screen, true); + // Anlagen-Liste nachladen falls leer (z.B. nach Seiten-Refresh) + if (e.state.screen === 'anlagen' && App.customerId && !$('#anlagen-list').children('.anlage-card, .contact-group').length) { + reloadAnlagen(); + } } else { // Kein State = zurück zum Anfang const activeScreen = App.token ? 'search' : 'login'; @@ -151,15 +180,38 @@ $('#btn-save-panel').on('click', handleSavePanel); $('#editor-content').on('click', '.btn-add-carrier', handleAddCarrier); + $('#editor-content').on('click', '.carrier-header', handleCarrierClick); $('#btn-save-carrier').on('click', handleSaveCarrier); + $('#btn-delete-carrier').on('click', handleDeleteCarrierConfirm); $('#editor-content').on('click', '.btn-add-equipment', handleAddEquipment); $('#editor-content').on('click', '.equipment-block', handleEquipmentClick); // Equipment modal $('#type-grid').on('click', '.type-btn', handleTypeSelect); + $('#btn-eq-back').on('click', () => showEquipmentStep('type')); $('#btn-save-equipment').on('click', handleSaveEquipment); $('#btn-cancel-equipment').on('click', () => closeModal('add-equipment')); + $('#btn-delete-equipment').on('click', handleDeleteEquipmentConfirm); + + // Terminal/Connection - Klick auf einzelne Klemme + $('#editor-content').on('click', '.terminal-point', handleTerminalClick); + $('#btn-save-connection').on('click', handleSaveConnection); + $('#btn-delete-connection').on('click', handleDeleteConnectionConfirm); + // Abgangsseite-Buttons + $('#conn-side-grid').on('click', '.side-btn', function() { + $('.side-btn').removeClass('selected'); + $(this).addClass('selected'); + }); + + // Bestätigungsdialog + $('#btn-confirm-ok').on('click', function() { + closeModal('confirm'); + if (App.confirmCallback) { + App.confirmCallback(); + App.confirmCallback = null; + } + }); // TE buttons $('.te-btn').on('click', function() { @@ -175,16 +227,6 @@ // Sync button $('#btn-sync').on('click', handleRefresh); - // Abgang-Labels Toggle (oben/unten) - $('#btn-toggle-labels').on('click', function() { - App.labelsPosition = App.labelsPosition === 'top' ? 'bottom' : 'top'; - localStorage.setItem('kundenkarte_labels_pos', App.labelsPosition); - $(this).removeClass('labels-top labels-bottom').addClass('labels-' + App.labelsPosition); - renderEditor(); - }); - - // Initialen Toggle-Zustand setzen - $('#btn-toggle-labels').removeClass('labels-top labels-bottom').addClass('labels-' + App.labelsPosition); } // ============================================ @@ -260,6 +302,7 @@ screen: screen || 'search', customerId: App.customerId, customerName: App.customerName, + customerAddress: App.customerAddress, anlageId: App.anlageId, anlageName: App.anlageName }; @@ -361,9 +404,11 @@ async function handleCustomerSelect() { const id = $(this).data('id'); const name = $(this).find('.list-item-title').text(); + const address = $(this).find('.list-item-subtitle').text(); App.customerId = id; App.customerName = name; + App.customerAddress = address; $('#customer-name').text(name); showScreen('anlagen'); @@ -401,31 +446,37 @@ function renderAnlagenList(anlagen, contacts) { let html = ''; - // Kunden-Anlagen (ohne Kontaktzuweisung) - if (anlagen && anlagen.length) { - anlagen.forEach(a => { - html += renderAnlageCard(a); - }); - } - - // Kontakt-Adressen als Gruppen + // Kontakt-Adressen (Gebäude/Standorte) als Liste if (contacts && contacts.length) { + html += '
'; contacts.forEach(c => { const subtitle = [c.address, c.town].filter(Boolean).join(', '); html += `
-
+
${escapeHtml(c.name)}
${subtitle ? '
' + escapeHtml(subtitle) + '
' : ''}
${c.anlage_count} +
`; }); + html += '
'; + } + + // Kunden-Anlagen (ohne Kontaktzuweisung) darunter + if (anlagen && anlagen.length) { + if (contacts && contacts.length && App.customerAddress) { + html += ``; + } + anlagen.forEach(a => { + html += renderAnlageCard(a); + }); } if (!html) { @@ -437,13 +488,26 @@ } function renderAnlageCard(a) { + let fieldsHtml = ''; + if (a.fields && a.fields.length) { + fieldsHtml = '
'; + a.fields.forEach(f => { + const style = f.color ? ` style="background:${f.color}"` : ''; + fieldsHtml += `${escapeHtml(f.value)}`; + }); + fieldsHtml += '
'; + } return `
-
${escapeHtml(a.label || 'Anlage ' + a.id)}
- ${a.type ? '
' + escapeHtml(a.type) + '
' : ''} +
+
${escapeHtml(a.label || 'Anlage ' + a.id)}
+ ${a.type ? '
' + escapeHtml(a.type) + '
' : ''} + ${fieldsHtml} +
+
`; } @@ -519,6 +583,7 @@ App.equipment = response.equipment || []; App.equipmentTypes = response.types || []; App.outputs = response.outputs || []; + App.inputs = response.inputs || []; // Cache for offline localStorage.setItem('kundenkarte_data_' + App.anlageId, JSON.stringify({ @@ -526,7 +591,8 @@ carriers: App.carriers, equipment: App.equipment, types: App.equipmentTypes, - outputs: App.outputs + outputs: App.outputs, + inputs: App.inputs })); renderEditor(); @@ -541,6 +607,7 @@ App.equipment = data.equipment || []; App.equipmentTypes = data.types || []; App.outputs = data.outputs || []; + App.inputs = data.inputs || []; renderEditor(); showToast('Offline - Zeige gecachte Daten', 'warning'); } else { @@ -575,67 +642,64 @@ 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 = `
`; - 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 += `
`; - 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 += ``; - labelsHtml += escapeHtml(output.output_label); - if (cableInfo) labelsHtml += `
${escapeHtml(cableInfo.trim())}`; - labelsHtml += `
`; - } - labelsHtml += `
`; - }); - labelsHtml += `
`; html += ` -
+
${escapeHtml(carrier.label || 'Hutschiene')} ${usedTe}/${totalTe} TE
+
`; - // Labels oben - if (labelsTop) html += labelsHtml; + // === Zeile 1: Terminals oben (Inputs + Top-Outputs) === + carrierEquipment.forEach(eq => { + const widthTe = parseInt(eq.width_te) || 1; + const posTe = parseInt(eq.position_te) || 0; + const eqInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id) : []; + const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : []; - html += ` -
-
- `; + for (let t = 0; t < widthTe; t++) { + const colPos = posTe > 0 ? posTe + t : 0; + const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`; + const inp = eqInputs[t] || null; + const topOut = eqTopOutputs[t] || null; + if (topOut) { + // Top-Output: Pfeil nach oben ▲ + const phaseColor = topOut.color || getPhaseColor(topOut.connection_type); + html += ``; + html += renderOutputLabel(topOut, phaseColor, 'up'); + html += ``; + } else if (inp) { + const phaseColor = inp.color || getPhaseColor(inp.connection_type); + html += ``; + html += ``; + html += `${escapeHtml(inp.connection_type || '')}`; + html += ``; + } else { + html += ``; + html += ``; + html += ``; + } + } + }); + + // === Zeile 2: Equipment-Blöcke === carrierEquipment.forEach(eq => { const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type); const widthTe = parseInt(eq.width_te) || 1; const posTe = parseInt(eq.position_te) || 0; - // 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}`; + ? `grid-row:2; grid-column: ${posTe} / span ${widthTe}` + : `grid-row:2; grid-column: span ${widthTe}`; html += `
@@ -646,20 +710,38 @@ `; }); - html += `
`; - + // +-Button in letzter Spalte (auto), Zeile 2 html += ` - + `; - html += `
`; + // === Zeile 3: Output-Terminals unten (Standard-Abgänge) === + carrierEquipment.forEach(eq => { + const widthTe = parseInt(eq.width_te) || 1; + const posTe = parseInt(eq.position_te) || 0; + const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : []; - // Labels unten - if (!labelsTop) html += labelsHtml; + for (let t = 0; t < widthTe; t++) { + const colPos = posTe > 0 ? posTe + t : 0; + const style = `grid-row:3;${colPos > 0 ? ' grid-column:' + colPos : ''}`; + const out = eqBottomOutputs[t] || null; - html += `
`; + if (out) { + const phaseColor = out.color || getPhaseColor(out.connection_type); + html += ``; + html += renderOutputLabel(out, phaseColor, 'down'); + html += ``; + } else { + html += ``; + html += ``; + html += ``; + } + } + }); + + html += `
`; }); html += ` @@ -714,8 +796,11 @@ App.panels.push({ id: response.panel_id, label: label }); renderEditor(); showToast('Feld angelegt'); + } else { + showToast(response.error || 'Fehler beim Anlegen', 'error'); } } catch (err) { + showToast('Netzwerkfehler - wird offline gespeichert', 'warning'); queueOfflineAction(data); } } else { @@ -734,8 +819,30 @@ function handleAddCarrier() { const panelId = $(this).data('panel-id'); App.currentPanelId = panelId; + App.editCarrierId = null; $('.te-btn').removeClass('selected'); $('#carrier-label').val(''); + $('#carrier-modal-title').text('Hutschiene hinzufügen'); + $('#btn-save-carrier').text('Hinzufügen'); + $('#btn-delete-carrier').addClass('hidden'); + openModal('add-carrier'); + } + + function handleCarrierClick() { + const carrierId = $(this).closest('.carrier-item').data('carrier-id'); + const carrier = App.carriers.find(c => c.id == carrierId); + if (!carrier) return; + + App.editCarrierId = carrierId; + App.currentPanelId = carrier.fk_panel; + + // TE-Button vorselektieren + $('.te-btn').removeClass('selected'); + $(`.te-btn[data-te="${carrier.total_te}"]`).addClass('selected'); + $('#carrier-label').val(carrier.label || ''); + $('#carrier-modal-title').text('Hutschiene bearbeiten'); + $('#btn-save-carrier').text('Speichern'); + $('#btn-delete-carrier').removeClass('hidden'); openModal('add-carrier'); } @@ -749,131 +856,341 @@ const totalTe = parseInt(teBtn.data('te')); const label = $('#carrier-label').val().trim() || 'Hutschiene'; - const data = { - action: 'create_carrier', - panel_id: App.currentPanelId, - total_te: totalTe, - label: label - }; + closeModal('add-carrier'); + + if (App.editCarrierId) { + // Update + const data = { + action: 'update_carrier', + carrier_id: App.editCarrierId, + total_te: totalTe, + label: label + }; + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + const carrier = App.carriers.find(c => c.id == App.editCarrierId); + if (carrier) { + carrier.total_te = totalTe; + carrier.label = label; + } + renderEditor(); + showToast('Hutschiene aktualisiert', 'success'); + } else { + showToast(response.error || 'Fehler', 'error'); + } + } catch (err) { + showToast('Netzwerkfehler - wird offline gespeichert', 'warning'); + queueOfflineAction(data); + } + } else { + queueOfflineAction(data); + const carrier = App.carriers.find(c => c.id == App.editCarrierId); + if (carrier) { + carrier.total_te = totalTe; + carrier.label = label; + } + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + } else { + // Neu anlegen + const data = { + action: 'create_carrier', + panel_id: App.currentPanelId, + total_te: totalTe, + label: label + }; + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + App.carriers.push({ + id: response.carrier_id, + fk_panel: App.currentPanelId, + total_te: totalTe, + label: label + }); + renderEditor(); + showToast('Hutschiene angelegt'); + } else { + showToast(response.error || 'Fehler beim Anlegen', 'error'); + } + } catch (err) { + showToast('Netzwerkfehler - wird offline gespeichert', 'warning'); + queueOfflineAction(data); + } + } else { + queueOfflineAction(data); + App.carriers.push({ + id: 'temp_' + Date.now(), + fk_panel: App.currentPanelId, + total_te: totalTe, + label: label + }); + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + } + + App.editCarrierId = null; + } + + function handleDeleteCarrierConfirm() { + const carrierId = App.editCarrierId; + if (!carrierId) return; + + const carrier = App.carriers.find(c => c.id == carrierId); + const eqCount = App.equipment.filter(e => e.fk_carrier == carrierId).length; + const msg = eqCount > 0 + ? `"${carrier?.label || 'Hutschiene'}" mit ${eqCount} Automat${eqCount > 1 ? 'en' : ''} wirklich löschen?` + : `"${carrier?.label || 'Hutschiene'}" wirklich löschen?`; + + $('#confirm-title').text('Hutschiene löschen?'); + $('#confirm-message').text(msg); + App.confirmCallback = () => deleteCarrier(carrierId); closeModal('add-carrier'); + openModal('confirm'); + } + + async function deleteCarrier(carrierId) { + const data = { + action: 'delete_carrier', + carrier_id: carrierId + }; if (App.isOnline) { try { const response = await apiCall('ajax/pwa_api.php', data); if (response.success) { - App.carriers.push({ - id: response.carrier_id, - fk_panel: App.currentPanelId, - total_te: totalTe, - label: label - }); + App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId); + App.carriers = App.carriers.filter(c => c.id != carrierId); renderEditor(); - showToast('Hutschiene angelegt'); + showToast('Hutschiene gelöscht', 'success'); + } else { + showToast(response.error || 'Fehler', 'error'); } } catch (err) { + showToast('Netzwerkfehler - wird offline gespeichert', 'warning'); queueOfflineAction(data); + App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId); + App.carriers = App.carriers.filter(c => c.id != carrierId); + renderEditor(); } } else { queueOfflineAction(data); - App.carriers.push({ - id: 'temp_' + Date.now(), - fk_panel: App.currentPanelId, - total_te: totalTe, - label: label - }); + App.equipment = App.equipment.filter(e => e.fk_carrier != carrierId); + App.carriers = App.carriers.filter(c => c.id != carrierId); renderEditor(); showToast('Wird synchronisiert...', 'warning'); } + + App.editCarrierId = null; } // ============================================ // EQUIPMENT (AUTOMAT) ACTIONS // ============================================ + /** + * Maximale zusammenhängende Lücke auf einem Carrier berechnen + */ + function getMaxGap(carrierId) { + const carrier = App.carriers.find(c => c.id == carrierId); + if (!carrier) return 0; + const totalTe = parseInt(carrier.total_te) || 12; + const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrierId); + + // Belegte Slots ermitteln (1-basiert) + const occupied = {}; + carrierEquipment.forEach(eq => { + const pos = parseInt(eq.position_te) || 1; + const w = parseInt(eq.width_te) || 1; + for (let s = pos; s < pos + w; s++) { + occupied[s] = true; + } + }); + + // Maximale zusammenhängende Lücke + let maxGap = 0, currentGap = 0; + for (let s = 1; s <= totalTe; s++) { + if (!occupied[s]) { + currentGap++; + if (currentGap > maxGap) maxGap = currentGap; + } else { + currentGap = 0; + } + } + return maxGap; + } + function handleAddEquipment() { const carrierId = $(this).data('carrier-id'); App.currentCarrierId = carrierId; App.selectedTypeId = null; + App.editEquipmentId = null; - // Reset modal - $('.type-btn').removeClass('selected'); - $('#step-values').hide(); - $('#equipment-label').val(''); - $('#value-fields').html(''); + // Add-Modus: Titel, Typ-Grid freigeben + $('#equipment-modal-title').text('Automat hinzufügen'); + $('#btn-save-equipment').text('Speichern'); + $('#btn-delete-equipment').addClass('hidden'); + $('#type-grid .type-btn').removeClass('selected'); + // Typ-Buttons nach verfügbarem Platz filtern + const maxGap = getMaxGap(carrierId); + $('#type-grid .type-btn').each(function() { + const w = parseInt($(this).data('width')) || 1; + if (w > maxGap) { + $(this).addClass('disabled').prop('disabled', true); + } else { + $(this).removeClass('disabled').prop('disabled', false); + } + }); + + // Schritt 1 zeigen + showEquipmentStep('type'); openModal('add-equipment'); } - function handleTypeSelect() { + async function handleTypeSelect() { $('.type-btn').removeClass('selected'); $(this).addClass('selected'); App.selectedTypeId = $(this).data('type-id'); const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId); - // Werte-Bereich einblenden - $('#step-values').show(); + // Titel für Schritt 2 + $('#eq-fields-title').text(type?.label_short || type?.label || 'Werte'); - // Felder basierend auf Typ aufbauen - let html = ''; + // Felder vom Server laden + await loadTypeFields(App.selectedTypeId, App.editEquipmentId); - // Quick-Select für LS-Schalter - if (type && (type.ref?.includes('LS') || type.label?.includes('Leitungsschutz'))) { - html += '

Kennlinie + Ampere:

'; - html += '
'; - ['B6', 'B10', 'B13', 'B16', 'B20', 'B25', 'B32', 'C6', 'C10', 'C13', 'C16', 'C20', 'C25', 'C32'].forEach(v => { - html += ``; - }); - html += '
'; - // Quick-Select für FI-Schalter - } else if (type && (type.ref?.includes('FI') || type.label?.includes('RCD'))) { - html += '

Ampere:

'; - html += '
'; - ['25', '40', '63', '80'].forEach(v => { - html += ``; - }); - html += '
'; - html += '

Empfindlichkeit:

'; - html += '
'; - ['30', '100', '300'].forEach(v => { - html += ``; - }); - html += '
'; - // Quick-Select für AFDD - } else if (type && type.ref?.includes('AFDD')) { - html += '

Ampere:

'; - html += '
'; - ['10', '13', '16', '20', '25', '32'].forEach(v => { - html += ``; - }); - html += '
'; - // Quick-Select für FI/LS-Kombi - } else if (type && type.ref?.includes('FILS')) { - html += '

Kennlinie + Ampere:

'; - html += '
'; - ['B10', 'B13', 'B16', 'B20', 'B25', 'B32'].forEach(v => { - html += ``; - }); - html += '
'; + // Zu Schritt 2 wechseln + showEquipmentStep('fields'); + } + + /** + * Wechselt zwischen Schritt 1 (Typ) und Schritt 2 (Felder) + */ + function showEquipmentStep(step) { + if (step === 'type') { + $('#eq-step-type').removeClass('hidden'); + $('#eq-step-fields').addClass('hidden'); + } else { + $('#eq-step-type').addClass('hidden'); + $('#eq-step-fields').removeClass('hidden'); + } + } + + /** + * Lädt Felder für einen Equipment-Typ vom Server + */ + async function loadTypeFields(typeId, equipmentId) { + $('#eq-dynamic-fields').html('
'); + + if (!App.isOnline) { + // Offline: Gecachte Felder verwenden falls vorhanden + const cached = App.cachedTypeFields && App.cachedTypeFields[typeId]; + if (cached) { + renderDynamicFields(cached); + } else { + $('#eq-dynamic-fields').html('
Offline - Felder nicht verfügbar
'); + } + return; } - $('#value-fields').html(html); + try { + const data = { action: 'get_type_fields', type_id: typeId }; + if (equipmentId) data.equipment_id = equipmentId; - // Chip-Klick-Handler - $('#value-fields .value-chip').on('click', function() { - if ($(this).hasClass('chip-sens')) { - $('.chip-sens').removeClass('selected'); + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + // Felder cachen für Offline + if (!App.cachedTypeFields) App.cachedTypeFields = {}; + App.cachedTypeFields[typeId] = response.fields; + + renderDynamicFields(response.fields); } else { - $('.value-chip:not(.chip-sens)').removeClass('selected'); + $('#eq-dynamic-fields').html(''); } - $(this).addClass('selected'); + } catch (err) { + $('#eq-dynamic-fields').html(''); + } + } + + /** + * Rendert dynamische Felder basierend auf field_type aus der DB + */ + function renderDynamicFields(fields) { + if (!fields || !fields.length) { + $('#eq-dynamic-fields').html(''); + $('#equipment-label').focus(); + return; + } + + let html = ''; + fields.forEach(field => { + const req = field.required ? ' *' : ''; + const val = field.value || ''; + + html += `
`; + html += ``; + + switch (field.type) { + case 'select': + html += ``; + break; + + case 'number': + html += ``; + break; + + case 'checkbox': + const checked = val === '1' || val === 'true' ? ' checked' : ''; + html += ``; + break; + + case 'textarea': + html += ``; + break; + + default: // text + html += ``; + } + + html += `
`; }); - // Focus auf Label-Feld wenn keine Chips vorhanden - if (!html) { - $('#equipment-label').focus(); - } + $('#eq-dynamic-fields').html(html); + } + + /** + * Sammelt Feldwerte aus den dynamischen Formularfeldern + */ + function collectFieldValues() { + const fieldValues = {}; + $('#eq-dynamic-fields [name^="eq_field_"]').each(function() { + const code = $(this).attr('name').replace('eq_field_', ''); + if ($(this).is(':checkbox')) { + fieldValues[code] = $(this).is(':checked') ? '1' : '0'; + } else { + const val = $(this).val(); + if (val) fieldValues[code] = val; + } + }); + return fieldValues; } async function handleSaveEquipment() { @@ -882,22 +1199,38 @@ return; } + // Pflichtfelder prüfen + let valid = true; + $('#eq-dynamic-fields select[name], #eq-dynamic-fields input[name]').each(function() { + const $field = $(this); + const $group = $field.closest('.form-group'); + if ($group.find('.field-required').length && !$field.val()) { + $field.addClass('field-error'); + valid = false; + } else { + $field.removeClass('field-error'); + } + }); + if (!valid) { + showToast('Pflichtfelder ausfüllen', 'error'); + return; + } + const type = App.equipmentTypes.find(t => t.id == App.selectedTypeId); const label = $('#equipment-label').val().trim(); + const fieldValues = collectFieldValues(); - // Collect field values - const fieldValues = {}; - const selectedChip = $('.value-chip.selected:not(.chip-sens)'); - const selectedSens = $('.chip-sens.selected'); - - if (selectedChip.length) { - if (selectedChip.data('char')) fieldValues.characteristic = selectedChip.data('char'); - if (selectedChip.data('amp')) fieldValues.ampere = selectedChip.data('amp'); - } - if (selectedSens.length) { - fieldValues.sensitivity = selectedSens.data('sens'); + if (App.editEquipmentId) { + await saveEquipmentUpdate(label, fieldValues); + } else { + await saveEquipmentCreate(type, label, fieldValues); } + } + /** + * Neuen Automaten anlegen + */ + async function saveEquipmentCreate(type, label, fieldValues) { // Nächste freie Position berechnen (Lücken berücksichtigen) const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId); const carrier = App.carriers.find(c => c.id == App.currentCarrierId); @@ -948,10 +1281,12 @@ id: response.equipment_id, fk_carrier: App.currentCarrierId, fk_equipment_type: App.selectedTypeId, - label: label, + label: response.label || label, position_te: nextPos, width_te: type?.width_te || 1, - field_values: fieldValues + field_values: fieldValues, + block_label: response.block_label || '', + block_color: response.block_color || type?.color || '' }); renderEditor(); showToast('Automat angelegt', 'success'); @@ -978,10 +1313,468 @@ } } - function handleEquipmentClick() { + /** + * Bestehenden Automaten aktualisieren + */ + async function saveEquipmentUpdate(label, fieldValues) { + const data = { + action: 'update_equipment', + equipment_id: App.editEquipmentId, + label: label, + field_values: JSON.stringify(fieldValues) + }; + + closeModal('add-equipment'); + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + // Lokale Daten aktualisieren + const eq = App.equipment.find(e => e.id == App.editEquipmentId); + if (eq) { + eq.label = label; + eq.field_values = fieldValues; + eq.block_label = response.block_label || ''; + eq.block_color = response.block_color || eq.block_color; + } + renderEditor(); + showToast('Automat aktualisiert', 'success'); + } else { + showToast(response.error || 'Fehler beim Aktualisieren', 'error'); + } + } catch (err) { + showToast('Netzwerkfehler - wird offline gespeichert', 'warning'); + queueOfflineAction(data); + // Optimistic UI + const eq = App.equipment.find(e => e.id == App.editEquipmentId); + if (eq) { + eq.label = label; + eq.field_values = fieldValues; + } + renderEditor(); + } + } else { + queueOfflineAction(data); + const eq = App.equipment.find(e => e.id == App.editEquipmentId); + if (eq) { + eq.label = label; + eq.field_values = fieldValues; + } + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + + App.editEquipmentId = null; + } + + async function handleEquipmentClick() { const eqId = $(this).data('equipment-id'); - // TODO: Edit/Delete popup - showToast('Bearbeiten kommt noch...'); + const eq = App.equipment.find(e => e.id == eqId); + if (!eq) return; + + const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type); + + // Edit-Modus setzen + App.editEquipmentId = eqId; + App.currentCarrierId = eq.fk_carrier; + App.selectedTypeId = eq.fk_equipment_type; + + // Titel + Buttons anpassen + $('#eq-fields-title').text(type?.label_short || type?.label || 'Bearbeiten'); + $('#btn-save-equipment').text('Aktualisieren'); + $('#btn-delete-equipment').removeClass('hidden'); + + // Label befüllen + $('#equipment-label').val(eq.label || ''); + + // Felder vom Server laden (mit bestehenden Werten) + await loadTypeFields(eq.fk_equipment_type, eqId); + + // Direkt Schritt 2 zeigen (kein Typ-Wechsel beim Edit) + showEquipmentStep('fields'); + openModal('add-equipment'); + } + + /** + * Bestätigungsdialog vor Equipment-Löschung + */ + function handleDeleteEquipmentConfirm() { + const eqId = App.editEquipmentId; + if (!eqId) return; + + const eq = App.equipment.find(e => e.id == eqId); + const type = App.equipmentTypes.find(t => t.id == eq?.fk_equipment_type); + const typeName = type?.label_short || type?.ref || 'Automat'; + const eqLabel = eq?.label ? ` "${eq.label}"` : ''; + + $('#confirm-title').text('Automat löschen?'); + $('#confirm-message').text(`${typeName}${eqLabel} wirklich löschen?`); + + // Callback für OK-Button + App.confirmCallback = () => deleteEquipment(eqId); + + closeModal('add-equipment'); + openModal('confirm'); + } + + /** + * Equipment löschen (nach Bestätigung) + */ + async function deleteEquipment(eqId) { + const data = { + action: 'delete_equipment', + equipment_id: eqId + }; + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + App.equipment = App.equipment.filter(e => e.id != eqId); + // Zugehörige Abgänge entfernen + App.outputs = App.outputs.filter(o => o.fk_source != eqId); + renderEditor(); + showToast('Automat gelöscht', 'success'); + } else { + showToast(response.error || 'Fehler beim Löschen', 'error'); + } + } catch (err) { + showToast('Netzwerkfehler - wird offline gespeichert', 'warning'); + queueOfflineAction(data); + App.equipment = App.equipment.filter(e => e.id != eqId); + App.outputs = App.outputs.filter(o => o.fk_source != eqId); + renderEditor(); + } + } else { + queueOfflineAction(data); + App.equipment = App.equipment.filter(e => e.id != eqId); + App.outputs = App.outputs.filter(o => o.fk_source != eqId); + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + + App.editEquipmentId = null; + } + + /** + * Output-Terminal HTML erzeugen (Pfeil + Labels) + * @param {object} out - Connection-Objekt + * @param {string} phaseColor - Farbe der Phase + * @param {string} dir - 'up' oder 'down' + */ + function renderOutputLabel(out, phaseColor, dir) { + // Pfeil statt Punkt - Pfeil immer am Automaten (zwischen Block und Label) + const arrowClass = dir === 'up' ? 'terminal-arrow-up' : 'terminal-arrow-down'; + const arrowHtml = ``; + + let labelHtml = ''; + if (out.output_label) { + let cableInfo = ''; + if (out.medium_type) cableInfo = out.medium_type; + if (out.medium_spec) cableInfo += ' ' + out.medium_spec; + if (out.medium_length) cableInfo += ' (' + out.medium_length + ')'; + labelHtml = `${escapeHtml(out.output_label)}`; + if (cableInfo) labelHtml += `
${escapeHtml(cableInfo.trim())}`; + labelHtml += `
`; + } else { + labelHtml = `${escapeHtml(out.connection_type || '')}`; + } + + // Oben: Label zuerst, dann Pfeil (Pfeil zeigt zum Automaten darunter) + // Unten: Pfeil zuerst, dann Label (Pfeil zeigt zum Automaten darüber) + if (dir === 'up') { + return labelHtml + arrowHtml; + } + return arrowHtml + labelHtml; + } + + // ============================================ + // CONNECTION (TERMINAL) ACTIONS + // ============================================ + + // Phasen-Optionen wie auf der Website + const INPUT_PHASES = ['L1', 'L2', 'L3', '3P', '3P+N', 'PE']; + const OUTPUT_PHASES = ['L1N', 'L2N', 'L3N', 'N', '3P+N', 'PE', 'DATA']; + + /** + * Phasenfarbe ermitteln (DIN VDE Farben) + */ + function getPhaseColor(type) { + const colors = { + 'L1': '#8B4513', 'L2': '#1a1a1a', 'L3': '#666', + 'N': '#0066cc', 'PE': '#27ae60', + 'L1N': '#8B4513', 'L2N': '#1a1a1a', 'L3N': '#666', + '3P': '#e74c3c', '3P+N': '#e74c3c', 'DATA': '#9b59b6' + }; + return colors[type] || '#888'; + } + + /** + * Abgangsseite-Button setzen + */ + function setSideButton(side) { + $('.side-btn').removeClass('selected'); + $(`.side-btn[data-side="${side}"]`).addClass('selected'); + } + + /** + * Gewählte Abgangsseite auslesen + */ + function getSelectedSide() { + return $('.side-btn.selected').data('side') || 'bottom'; + } + + /** + * Typ-Select je nach Richtung befüllen (wie Website) + */ + function renderTypeSelect(direction, selectedType) { + const phases = direction === 'input' ? INPUT_PHASES : OUTPUT_PHASES; + let html = ''; + phases.forEach(p => { + const sel = (p === selectedType) ? ' selected' : ''; + html += ``; + }); + $('#conn-type').html(html); + } + + /** + * Klick auf Terminal-Zelle (Input oder Output) + */ + function handleTerminalClick(e) { + e.stopPropagation(); + const $point = $(this); + const eqId = $point.data('equipment-id'); + const direction = $point.data('direction'); + const connId = $point.data('connection-id'); + + App.connectionEquipmentId = eqId; + App.connectionDirection = direction; + + // Typ-Select befüllen + renderTypeSelect(direction, ''); + + if (connId) { + // Bearbeiten + App.editConnectionId = connId; + $('#connection-modal-title').text('Verbindung bearbeiten'); + $('#btn-delete-connection').removeClass('hidden'); + + const conn = direction === 'input' + ? App.inputs.find(i => i.id == connId) + : App.outputs.find(o => o.id == connId); + + if (conn) { + renderTypeSelect(direction, conn.connection_type); + $('#conn-color').val(conn.color || '#3498db'); + $('#conn-label').val(conn.output_label || ''); + $('#conn-medium-type').val(conn.medium_type || ''); + $('#conn-medium-spec').val(conn.medium_spec || ''); + $('#conn-medium-length').val(conn.medium_length || ''); + setSideButton(conn.is_top ? 'top' : 'bottom'); + } + } else { + // Neu anlegen + App.editConnectionId = null; + $('#connection-modal-title').text(direction === 'input' ? 'Einspeisung' : 'Abgang'); + $('#btn-delete-connection').addClass('hidden'); + $('#conn-color').val('#3498db'); + $('#conn-label').val(''); + $('#conn-medium-type').val(''); + $('#conn-medium-spec').val(''); + $('#conn-medium-length').val(''); + setSideButton('bottom'); + } + + // Medium-Felder nur bei Abgang zeigen + $('#conn-output-fields').toggle(direction === 'output'); + + openModal('connection'); + } + + /** + * Connection speichern (Neu oder Update) + */ + async function handleSaveConnection() { + const connectionType = $('#conn-type').val() || ''; + const color = $('#conn-color').val() || '#3498db'; + const outputLabel = $('#conn-label').val().trim(); + const isOutput = App.connectionDirection === 'output'; + const mediumType = isOutput ? ($('#conn-medium-type').val().trim() || '') : ''; + const mediumSpec = isOutput ? ($('#conn-medium-spec').val().trim() || '') : ''; + const mediumLength = isOutput ? ($('#conn-medium-length').val().trim() || '') : ''; + const isTop = isOutput && getSelectedSide() === 'top'; + // source_terminal_id wie Website: t1=oben, t2=unten + const sourceTerminalId = isOutput ? (isTop ? 't1' : 't2') : ''; + const sourceTerminal = isOutput ? (isTop ? 'top' : 'output') : ''; + + closeModal('connection'); + + if (App.editConnectionId) { + // Update + const data = { + action: 'update_connection', + connection_id: App.editConnectionId, + connection_type: connectionType, + color: color, + output_label: outputLabel, + medium_type: mediumType, + medium_spec: mediumSpec, + medium_length: mediumLength, + source_terminal: sourceTerminal, + source_terminal_id: sourceTerminalId + }; + + const updateLocal = (conn) => { + if (!conn) return; + conn.connection_type = connectionType; + conn.color = color; + conn.output_label = outputLabel; + conn.medium_type = mediumType; + conn.medium_spec = mediumSpec; + conn.medium_length = mediumLength; + if (isOutput) { + conn.is_top = isTop; + conn.source_terminal_id = sourceTerminalId; + } + }; + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + const list = App.connectionDirection === 'input' ? App.inputs : App.outputs; + updateLocal(list.find(c => c.id == App.editConnectionId)); + renderEditor(); + showToast('Verbindung aktualisiert', 'success'); + } else { + showToast(response.error || 'Fehler', 'error'); + } + } catch (err) { + queueOfflineAction(data); + showToast('Wird synchronisiert...', 'warning'); + } + } else { + queueOfflineAction(data); + const list = App.connectionDirection === 'input' ? App.inputs : App.outputs; + updateLocal(list.find(c => c.id == App.editConnectionId)); + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + } else { + // Neu anlegen + const data = { + action: 'create_connection', + equipment_id: App.connectionEquipmentId, + direction: App.connectionDirection, + connection_type: connectionType, + color: color, + output_label: outputLabel, + medium_type: mediumType, + medium_spec: mediumSpec, + medium_length: mediumLength, + source_terminal: sourceTerminal, + source_terminal_id: sourceTerminalId + }; + + const newConnBase = { + connection_type: connectionType, + color: color, + output_label: outputLabel, + medium_type: mediumType, + medium_spec: mediumSpec, + medium_length: mediumLength, + is_top: isTop, + source_terminal_id: sourceTerminalId + }; + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + const newConn = Object.assign({ id: response.connection_id }, newConnBase); + if (App.connectionDirection === 'input') { + newConn.fk_target = App.connectionEquipmentId; + App.inputs.push(newConn); + } else { + newConn.fk_source = App.connectionEquipmentId; + App.outputs.push(newConn); + } + renderEditor(); + showToast('Verbindung angelegt', 'success'); + } else { + showToast(response.error || 'Fehler', 'error'); + } + } catch (err) { + queueOfflineAction(data); + showToast('Wird synchronisiert...', 'warning'); + } + } else { + queueOfflineAction(data); + const newConn = Object.assign({ id: 'temp_' + Date.now() }, newConnBase); + if (App.connectionDirection === 'input') { + newConn.fk_target = App.connectionEquipmentId; + App.inputs.push(newConn); + } else { + newConn.fk_source = App.connectionEquipmentId; + App.outputs.push(newConn); + } + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + } + + App.editConnectionId = null; + } + + /** + * Connection löschen (mit Bestätigung) + */ + function handleDeleteConnectionConfirm() { + const connId = App.editConnectionId; + if (!connId) return; + + $('#confirm-title').text('Verbindung löschen?'); + $('#confirm-message').text('Diese Verbindung wirklich löschen?'); + + App.confirmCallback = () => deleteConnection(connId); + + closeModal('connection'); + openModal('confirm'); + } + + async function deleteConnection(connId) { + const data = { + action: 'delete_connection', + connection_id: connId + }; + + if (App.isOnline) { + try { + const response = await apiCall('ajax/pwa_api.php', data); + if (response.success) { + App.outputs = App.outputs.filter(o => o.id != connId); + App.inputs = App.inputs.filter(i => i.id != connId); + renderEditor(); + showToast('Verbindung gelöscht', 'success'); + } else { + showToast(response.error || 'Fehler', 'error'); + } + } catch (err) { + queueOfflineAction(data); + App.outputs = App.outputs.filter(o => o.id != connId); + App.inputs = App.inputs.filter(i => i.id != connId); + renderEditor(); + } + } else { + queueOfflineAction(data); + App.outputs = App.outputs.filter(o => o.id != connId); + App.inputs = App.inputs.filter(i => i.id != connId); + renderEditor(); + showToast('Wird synchronisiert...', 'warning'); + } + + App.editConnectionId = null; } // ============================================ diff --git a/pwa.php b/pwa.php index ea67405..d5aa843 100644 --- a/pwa.php +++ b/pwa.php @@ -44,7 +44,7 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db'); - + @@ -125,9 +125,6 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');

Anlage

-
- + @@ -235,6 +324,6 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db'); window.DOLIBARR_URL = ''; window.MODULE_URL = '/custom/kundenkarte'; - + diff --git a/sw.js b/sw.js index 4a6ea99..33ff85d 100644 --- a/sw.js +++ b/sw.js @@ -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; }); }) );