feat(v8.6): Räumlichkeit, Verteilungs-Tabellen, Bundled-Terminals, PWA-Updates

- output_location (Räumlichkeit): Neues Textfeld am Abgang für Raum/Ort des
  Verbrauchers. DB-Migration, Backend (AJAX), Frontend (Website + PWA),
  Anzeige im Schaltplan (kursiv) und in PDF-Tabellen.
- Verteilungs-Tabellen: Kundenansicht (A4, Nr/Verbraucher/Räumlichkeit) und
  Technikeransicht (A4, R.Klem/FI/Nr/Verbraucher/Räumlichkeit/Typ) im
  Leitungslaufplan-PDF. Gruppiert nach Feld/Reihe mit automatischem Seitenumbruch.
- Bundled-Terminals Checkbox: Im Website-Abgang-Dialog (war vorher nur PWA).
- PWA: Diverse Verbesserungen, Service Worker v12.4, Connection-Modal erweitert.
- Typ-Flags: has_product auch für Gebäudetypen, Equipment-Typ Erweiterungen.
- CLAUDE.md + Doku aktualisiert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-09 01:33:05 +01:00
parent 8826c286ef
commit 16e51a799a
30 changed files with 1875 additions and 435 deletions

View file

@ -132,7 +132,7 @@ Alle Datenbankänderungen werden als idempotente Migrationen in `modKundenKarte.
### Libraries (lib/)
- `kundenkarte.lib.php` - Allgemeine Hilfs-Funktionen
- `graph_view.lib.php` - Shared Graph-Funktionen (Toolbar, Container, Legende)
- `wiring_diagram.lib.php` - Leitungslaufplan (WiringDiagramAnalyzer + WiringDiagramRenderer)
- `wiring_diagram.lib.php` - Leitungslaufplan + Verteilungs-Tabellen (~2.130 Zeilen)
### AJAX-Endpunkte (ajax/) — 30+ Dateien
- `anlage.php` - Anlagen CRUD
@ -150,9 +150,9 @@ Alle Datenbankänderungen werden als idempotente Migrationen in `modKundenKarte.
- `pwa_api.php` - PWA-Endpoints
### Frontend
- `js/kundenkarte.js` - Haupt-JS (~11.000 Zeilen)
- `js/kundenkarte.js` - Haupt-JS (~15.600 Zeilen)
- `js/kundenkarte_cytoscape.js` - Graph-JS (~900 Zeilen)
- `js/pwa.js` - PWA-JS (~1.950 Zeilen)
- `js/pwa.js` - PWA-JS (~3.400 Zeilen)
- `css/kundenkarte.css` - Alle Styles (Dark Mode Theme)
- `css/pwa.css` - PWA-Styles
@ -183,7 +183,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 (v6.1)
- `sw.js` - Service Worker für Offline-Cache (v12.4)
- `manifest.json` - Web App Manifest für Installation
### Workflow
@ -260,7 +260,7 @@ Offline-fähige Progressive Web App für Elektriker zur Schaltschrank-Dokumentat
- `bundled_terminals = 'all'` in Connection bedeutet: Alle Terminals belegt
- Im Editor: Ein Pfeil spannt über alle Terminals des Equipment
- Label wird zentriert über alle Terminals angezeigt
- Checkbox "Alle bündeln" nur bei Equipment mit >1 Terminal sichtbar
- Checkbox "Alle Terminals bündeln" im Abgang-Dialog (Website + PWA), nur bei Equipment mit >1 Terminal
### Terminal-Konfiguration (v7.5)
- `terminals_config` JSON im Equipment-Typ definiert Terminal-Positionen
@ -348,16 +348,18 @@ Normgerechter Stromlaufplan in aufgelöster Darstellung (DIN EN 61082) als PDF-E
**Komplett separates Feature** — kann durch Löschen von 2 Dateien + 8 Zeilen rückstandsfrei entfernt werden.
### Dateien
- `lib/wiring_diagram.lib.php` — Kernlogik (~1240 Zeilen)
- `lib/wiring_diagram.lib.php` — Kernlogik (~2.130 Zeilen)
- `WiringDiagramAnalyzer` — Lädt Daten, baut Phase-Map (PHP-Port), tracet Strompfade
- `WiringDiagramRenderer` — Zeichnet PDF mit TCPDF
- `ajax/export_wiring_diagram_pdf.php` — Endpoint
- Buttons in `tabs/anlagen.php` + `tabs/contact_anlagen.php` (je 4 Zeilen)
### PDF-Inhalt (3 Teile)
1. **Leitungslaufplan** — L1/L2/L3 horizontal oben, vertikale Strompfade pro Abgang, FI/RCD + LS-Symbole, Abgang-Pfeile, N/PE unten
2. **Abgangsverzeichnis** — Tabelle pro Hutschiene mit: Abg.Nr, Bezeichnung, Phase, Absicherung, Kabel, Schutzgerät
3. **Legende** — Phasenfarben DIN VDE, VDE-Symbole, Norm-Referenzen
### PDF-Inhalt (5 Teile)
1. **Leitungslaufplan** (A3 quer) — L1/L2/L3 horizontal oben, vertikale Strompfade pro Abgang, FI/RCD + LS-Symbole, Abgang-Pfeile, N/PE unten
2. **Abgangsverzeichnis** (A3 quer) — Tabelle pro Hutschiene mit: Abg.Nr, Bezeichnung, Phase, Absicherung, Kabel, Schutzgerät
3. **Kundenansicht** (A4 hoch) — `renderKundenansicht()` — Einfache Tabelle: Nr | Verbraucher | Räumlichkeit, gruppiert nach Feld/Reihe
4. **Technikeransicht** (A4 hoch) — `renderTechnikeransicht()` — Erweiterte Tabelle: R.Klem | FI | Nr | Verbraucher | Räumlichkeit | Typ
5. **Mini-Legende** — Phasenfarben DIN VDE auf Seite 1 unten links
### Abgangsnummer-Format
`R{Reihe}.{Position}` z.B. `R1.3` = Carrier-Position 1, Equipment-TE-Position 3
@ -380,6 +382,28 @@ Pro Abgang (Connection mit `fk_target = NULL`):
- FI/RCD: Rechteck mit Kreis + Vertikallinie (Differenzstrom-Symbol)
- Gezeichnet mit TCPDF-Primitiven (Line, Rect, Circle, Polygon)
## Räumlichkeit / output_location (v8.6)
### Übersicht
Zusätzliches Textfeld am Abgang (Output-Connection) für den Raum/Ort des Verbrauchers (z.B. "Küche", "Bad OG").
### Datenbank
- Spalte `output_location` (varchar 255) in `llx_kundenkarte_equipment_connection`
- Migration: `migrate_v1110_output_location()` in `modKundenKarte.class.php`
### Backend
- `EquipmentConnection::$output_location` — Property, in create/update/fetch
- `ajax/equipment_connection.php` — create_output + update + list_all
- `ajax/pwa_api.php` — get_carrier_equipment + create_connection + update_connection
### Frontend
- **Website**: Eingabefeld im `renderAbgangDialog()` (kundenkarte.js)
- **PWA**: Eingabefeld `#conn-location` im Connection-Modal (pwa.php + pwa.js)
- **Anzeige im Schaltplan**:
- Website SVG: `<tspan>` kursiv nach Label mit ` · ` Trennzeichen
- PWA Grid: `<span class="output-location">` kursiv unter dem Label-Text
- **PDF**: In Kundenansicht + Technikeransicht als eigene Tabellenspalte
## Select2 mit Kategorie-Filter
### Problem & Lösung

0
ChangeLog.md Normal file → Executable file
View file

0
admin/anlage_types.php Normal file → Executable file
View file

0
admin/building_types.php Normal file → Executable file
View file

0
admin/equipment_types.php Normal file → Executable file
View file

0
admin/setup.php Normal file → Executable file
View file

0
ajax/anlage_accessory.php Normal file → Executable file
View file

View file

@ -267,6 +267,7 @@ switch ($action) {
'block_color' => $eq->getBlockColor(),
'field_values' => $eq->getFieldValues(),
'fk_product' => $eq->fk_product,
'fk_protection' => $eq->fk_protection,
'product_ref' => $productRef,
'product_label' => $productLabel
);

View file

@ -187,6 +187,7 @@ switch ($action) {
if (GETPOSTISSET('connection_type')) $connection->connection_type = GETPOST('connection_type', 'alphanohtml');
if (GETPOSTISSET('color')) $connection->color = GETPOST('color', 'alphanohtml');
if (GETPOSTISSET('output_label')) $connection->output_label = GETPOST('output_label', 'alphanohtml');
if (GETPOSTISSET('output_location')) $connection->output_location = GETPOST('output_location', 'alphanohtml');
if (GETPOSTISSET('medium_type')) $connection->medium_type = GETPOST('medium_type', 'alphanohtml');
if (GETPOSTISSET('medium_spec')) $connection->medium_spec = GETPOST('medium_spec', 'alphanohtml');
if (GETPOSTISSET('medium_length')) $connection->medium_length = GETPOST('medium_length', 'alphanohtml');
@ -303,6 +304,7 @@ switch ($action) {
$connection->connection_type = GETPOST('connection_type', 'alphanohtml');
$connection->color = GETPOST('color', 'alphanohtml');
$connection->output_label = GETPOST('output_label', 'alphanohtml');
$connection->output_location = GETPOST('output_location', 'alphanohtml');
$connection->medium_type = GETPOST('medium_type', 'alphanohtml');
$connection->medium_spec = GETPOST('medium_spec', 'alphanohtml');
$connection->medium_length = GETPOST('medium_length', 'alphanohtml');
@ -367,6 +369,7 @@ switch ($action) {
'connection_type' => $obj->connection_type,
'color' => $obj->color ?: '#3498db',
'output_label' => $obj->output_label,
'output_location' => isset($obj->output_location) ? $obj->output_location : null,
'medium_type' => $obj->medium_type,
'medium_spec' => $obj->medium_spec,
'medium_length' => $obj->medium_length,

70
ajax/pwa_api.php Normal file → Executable file
View file

@ -291,7 +291,7 @@ switch ($action) {
$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, medium_length, connection_type, color, source_terminal, source_terminal_id, bundled_terminals";
$sql = "SELECT rowid, fk_source, output_label, output_location, medium_type, medium_spec, medium_length, connection_type, color, source_terminal, source_terminal_id, bundled_terminals";
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection";
$sql .= " WHERE fk_source IN (".implode(',', $equipmentIds).")";
$sql .= " AND fk_target IS NULL";
@ -344,6 +344,7 @@ switch ($action) {
'id' => $obj->rowid,
'fk_source' => $obj->fk_source,
'output_label' => $obj->output_label,
'output_location' => isset($obj->output_location) ? $obj->output_location : '',
'medium_type' => $obj->medium_type,
'medium_spec' => $obj->medium_spec,
'medium_length' => $obj->medium_length,
@ -360,7 +361,7 @@ switch ($action) {
// 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 = "SELECT rowid, fk_target, target_terminal_id, 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";
@ -371,6 +372,7 @@ switch ($action) {
$inputsData[] = array(
'id' => $obj->rowid,
'fk_target' => $obj->fk_target,
'target_terminal_id' => $obj->target_terminal_id ?: '',
'output_label' => $obj->output_label,
'connection_type' => $obj->connection_type,
'color' => $obj->color
@ -379,31 +381,64 @@ switch ($action) {
}
}
// Verbindungen zwischen Equipment laden (mit path_data für Linien-Anzeige)
// Verbindungen zwischen Equipment laden (alle, für Phasen-Propagierung + Linien-Anzeige)
$connectionsData = array();
if (!empty($equipmentData)) {
$sql = "SELECT rowid, fk_source, fk_target, source_terminal_id, target_terminal_id, connection_type, color, path_data";
$sql = "SELECT rowid, fk_source, fk_target, source_terminal_id, target_terminal_id, connection_type, color, path_data, is_rail";
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection";
$sql .= " WHERE fk_source IN (".implode(',', $equipmentIds).")";
$sql .= " AND fk_target IS NOT NULL";
$sql .= " AND fk_target IN (".implode(',', $equipmentIds).")";
$sql .= " AND is_rail = 0";
$sql .= " AND status = 1";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
// Nur Verbindungen mit gezeichnetem Pfad laden
if (!empty($obj->path_data)) {
$connectionsData[] = array(
'id' => $obj->rowid,
'fk_source' => $obj->fk_source,
'fk_target' => $obj->fk_target,
'source_terminal_id' => $obj->source_terminal_id,
'target_terminal_id' => $obj->target_terminal_id,
'connection_type' => $obj->connection_type,
'color' => $obj->color,
'path_data' => $obj->path_data
);
$connectionsData[] = array(
'id' => $obj->rowid,
'fk_source' => $obj->fk_source,
'fk_target' => $obj->fk_target,
'source_terminal_id' => $obj->source_terminal_id,
'target_terminal_id' => $obj->target_terminal_id,
'connection_type' => $obj->connection_type,
'color' => $obj->color,
'path_data' => $obj->path_data ?: null
);
}
}
}
// Busbars (Phasenschienen) laden für Farbpropagierung
$busbarsData = array();
if (!empty($carriersData)) {
$carrierIds = array_map(function($c) { return (int) $c['id']; }, $carriersData);
$sql = "SELECT c.rowid, c.fk_carrier, c.rail_start_te, c.rail_end_te, c.rail_phases,";
$sql .= " c.excluded_te, c.position_y, c.connection_type,";
$sql .= " bt.phases_config as busbar_phases_config";
$sql .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_connection as c";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."kundenkarte_busbar_type as bt ON c.fk_busbar_type = bt.rowid";
$sql .= " WHERE c.fk_carrier IN (".implode(',', $carrierIds).")";
$sql .= " AND c.is_rail = 1";
$sql .= " AND c.status = 1";
$sql .= " ORDER BY c.position_y ASC";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$phasesConfig = null;
if (!empty($obj->busbar_phases_config)) {
$phasesConfig = json_decode($obj->busbar_phases_config, true);
}
$busbarsData[] = array(
'id' => $obj->rowid,
'fk_carrier' => $obj->fk_carrier,
'rail_start_te' => (int) $obj->rail_start_te,
'rail_end_te' => (int) $obj->rail_end_te,
'rail_phases' => $obj->rail_phases,
'excluded_te' => $obj->excluded_te ?: '',
'position_y' => (int) $obj->position_y,
'connection_type' => $obj->connection_type,
'phases_config' => $phasesConfig
);
}
}
}
@ -458,6 +493,7 @@ switch ($action) {
$response['outputs'] = $outputsData;
$response['inputs'] = $inputsData;
$response['connections'] = $connectionsData;
$response['busbars'] = $busbarsData;
$response['types'] = $typesData;
$response['field_meta'] = $fieldMetaData;
break;
@ -837,6 +873,7 @@ switch ($action) {
$conn->connection_type = $connectionType;
$conn->color = GETPOST('color', 'alphanohtml');
$conn->output_label = $outputLabel;
$conn->output_location = GETPOST('output_location', 'alphanohtml');
$conn->fk_carrier = $eq->fk_carrier;
if ($direction === 'input') {
@ -892,6 +929,7 @@ switch ($action) {
$conn->connection_type = GETPOST('connection_type', 'alphanohtml');
$conn->color = GETPOST('color', 'alphanohtml');
$conn->output_label = GETPOST('output_label', 'alphanohtml');
if (GETPOSTISSET('output_location')) $conn->output_location = GETPOST('output_location', 'alphanohtml');
$conn->medium_type = GETPOST('medium_type', 'alphanohtml');
$conn->medium_spec = GETPOST('medium_spec', 'alphanohtml');
$conn->medium_length = GETPOST('medium_length', 'alphanohtml');

0
class/anlageaccessory.class.php Normal file → Executable file
View file

0
class/anlagetype.class.php Normal file → Executable file
View file

0
class/buildingtype.class.php Normal file → Executable file
View file

View file

@ -30,6 +30,7 @@ class EquipmentConnection extends CommonObject
// Output/endpoint info
public $output_label;
public $output_location; // Räumlichkeit/Örtlichkeit des Verbrauchers
// Medium info (cable, wire, etc.)
public $medium_type;
@ -91,7 +92,7 @@ class EquipmentConnection extends CommonObject
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "entity, fk_source, source_terminal, source_terminal_id, bundled_terminals, fk_target, target_terminal, target_terminal_id,";
$sql .= " connection_type, color, output_label,";
$sql .= " connection_type, color, output_label, output_location,";
$sql .= " medium_type, medium_spec, medium_length,";
$sql .= " is_rail, rail_start_te, rail_end_te, rail_phases, excluded_te, num_lines, fk_busbar_type, fk_carrier, position_y, path_data,";
$sql .= " note_private, status, date_creation, fk_user_creat";
@ -107,6 +108,7 @@ class EquipmentConnection extends CommonObject
$sql .= ", ".($this->connection_type ? "'".$this->db->escape($this->connection_type)."'" : "NULL");
$sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL");
$sql .= ", ".($this->output_label ? "'".$this->db->escape($this->output_label)."'" : "NULL");
$sql .= ", ".($this->output_location ? "'".$this->db->escape($this->output_location)."'" : "NULL");
$sql .= ", ".($this->medium_type ? "'".$this->db->escape($this->medium_type)."'" : "NULL");
$sql .= ", ".($this->medium_spec ? "'".$this->db->escape($this->medium_spec)."'" : "NULL");
$sql .= ", ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL");
@ -178,6 +180,7 @@ class EquipmentConnection extends CommonObject
$this->connection_type = $obj->connection_type;
$this->color = $obj->color;
$this->output_label = $obj->output_label;
$this->output_location = isset($obj->output_location) ? $obj->output_location : null;
$this->medium_type = $obj->medium_type;
$this->medium_spec = $obj->medium_spec;
$this->medium_length = $obj->medium_length;
@ -234,6 +237,7 @@ class EquipmentConnection extends CommonObject
$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");
$sql .= ", output_location = ".($this->output_location ? "'".$this->db->escape($this->output_location)."'" : "NULL");
$sql .= ", medium_type = ".($this->medium_type ? "'".$this->db->escape($this->medium_type)."'" : "NULL");
$sql .= ", medium_spec = ".($this->medium_spec ? "'".$this->db->escape($this->medium_spec)."'" : "NULL");
$sql .= ", medium_length = ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL");

View file

@ -328,6 +328,7 @@ class EquipmentType extends CommonObject
$type->picto = $obj->picto;
$type->icon_file = $obj->icon_file;
$type->block_image = $obj->block_image;
$type->terminals_config = $obj->terminals_config;
$type->is_system = $obj->is_system;
$type->position = $obj->position;
$type->active = $obj->active;

View file

@ -76,7 +76,7 @@ class modKundenKarte extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@kundenkarte'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '9.7';
$this->version = '11.0.8';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -662,6 +662,9 @@ class modKundenKarte extends DolibarrModules
// v11.0.0: Busbar type reference and num_lines for connections
$this->migrate_v1100_busbar_fields();
// v11.1.0: Räumlichkeit (output_location) für Abgänge
$this->migrate_v1110_output_location();
}
/**
@ -1093,6 +1096,24 @@ class modKundenKarte extends DolibarrModules
}
}
/**
* Migration v11.1.0: Räumlichkeit (output_location) für Abgänge
*/
private function migrate_v1110_output_location()
{
$table = MAIN_DB_PREFIX."kundenkarte_equipment_connection";
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'output_location'");
if (!$resql || $this->db->num_rows($resql) == 0) {
$this->db->query("ALTER TABLE ".$table." ADD COLUMN output_location varchar(255) DEFAULT NULL AFTER output_label");
}
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.

View file

@ -2151,10 +2151,30 @@ body.kundenkarte-drag-active * {
.schematic-editor-actions {
display: flex !important;
flex-wrap: wrap !important;
gap: 10px !important;
gap: 5px !important;
align-items: center !important;
}
/* Einheitliche Größe für alle Toolbar-Buttons (button + a) */
.schematic-editor-actions > button,
.schematic-editor-actions > a {
padding: 5px 8px !important;
background: #333 !important;
border: 1px solid #555 !important;
border-radius: 3px !important;
cursor: pointer !important;
font-size: 12px !important;
line-height: 1.4 !important;
height: 30px !important;
box-sizing: border-box !important;
display: inline-flex !important;
align-items: center !important;
gap: 4px !important;
text-decoration: none !important;
white-space: nowrap !important;
font-family: inherit !important;
}
.schematic-editor-toggle {
color: #3498db !important;
text-decoration: none !important;

21
css/pwa.css Normal file → Executable file
View file

@ -905,12 +905,14 @@ body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
padding: 3px 2px;
padding: 3px 0;
border-radius: 4px;
transition: background 0.15s;
min-width: 0;
text-align: center;
}
/* Zeile 2 (obere Terminals): direkt am Equipment (margin-bottom negativ) */
@ -950,6 +952,8 @@ body {
width: 0;
height: 0;
flex-shrink: 0;
align-self: center;
margin: 0 auto;
}
.terminal-arrow-down {
@ -1029,6 +1033,15 @@ body {
background: rgba(173, 140, 79, 0.4);
}
.terminal-label .output-location {
display: block;
font-style: italic;
font-weight: normal;
font-size: 8px;
color: rgba(255,255,255,0.5);
margin-top: 1px;
}
.terminal-label .cable-info {
font-weight: normal;
font-size: 7px;
@ -2082,3 +2095,9 @@ body {
border: 2px dashed rgba(255, 255, 255, 0.25);
}
/* Leere TE-Positionen ohne Terminal (z.B. NEO 4TE aber nur 3 Terminals) */
.terminal-point.no-terminal {
visibility: hidden;
pointer-events: none;
}

0
img/pwa-icon-192.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

0
img/pwa-icon-512.png Normal file → Executable file
View file

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View file

@ -1504,7 +1504,7 @@
data.fields.forEach(function(field) {
if (field.type === 'header') {
// Header row spans both columns with styling
html += '<tr class="liste_titre dynamic-field-row"><th colspan="2" style="background:#f0f0f0;padding:8px;">' + KundenKarte.DynamicFields.escapeHtml(field.label) + '</th></tr>';
html += '<tr class="liste_titre dynamic-field-row"><th colspan="2" style="padding:8px;">' + KundenKarte.DynamicFields.escapeHtml(field.label) + '</th></tr>';
} else {
html += '<tr class="dynamic-field-row"><td class="titlefield">' + KundenKarte.DynamicFields.escapeHtml(field.label);
if (field.required) html += ' <span class="fieldrequired">*</span>';
@ -4284,11 +4284,17 @@
// Label if exists - positioned along the routing path
if (conn.output_label) {
var fullLabel = conn.output_label;
if (conn.output_location) fullLabel += ' · ' + conn.output_location;
var labelY = equipmentY + 35 + (connectionIndex * 12);
var labelX = (sourceX + targetX) / 2;
html += '<rect x="' + (labelX - 25) + '" y="' + (labelY - 8) + '" width="50" height="12" rx="2" fill="#1e1e1e" opacity="0.9"/>';
var rectWidth = Math.max(50, fullLabel.length * 5 + 10);
html += '<rect x="' + (labelX - rectWidth/2) + '" y="' + (labelY - 8) + '" width="' + rectWidth + '" height="12" rx="2" fill="#1e1e1e" opacity="0.9"/>';
html += '<text x="' + labelX + '" y="' + (labelY + 2) + '" text-anchor="middle" fill="#ccc" font-size="9">';
html += this.escapeHtml(conn.output_label);
if (conn.output_location) {
html += '<tspan font-style="italic" fill="#888"> · ' + this.escapeHtml(conn.output_location) + '</tspan>';
}
html += '</text>';
}
@ -7416,6 +7422,21 @@
}
// Schutzgruppen-Markierung (FI/RCD)
if (eq.fk_protection) {
// Geschütztes Equipment: farbiger Balken unten (wie PWA)
var protColor = self.getProtectionColor(eq.fk_protection);
blockHtml += '<rect x="0" y="' + (blockHeight - 4) + '" width="' + blockWidth + '" height="4" ';
blockHtml += 'fill="' + protColor + '" opacity="0.9" pointer-events="none"/>';
}
// Schutzgerät selbst: linker Balken
var isProtectionDevice = self.equipment.some(function(e) { return e.fk_protection == eq.id; });
if (isProtectionDevice) {
var deviceColor = self.getProtectionColor(eq.id);
blockHtml += '<rect x="-4" y="0" width="4" height="' + blockHeight + '" ';
blockHtml += 'fill="' + deviceColor + '" rx="2" opacity="0.9"/>';
}
blockHtml += '</g>';
// Terminals (bidirectional)
@ -7790,7 +7811,8 @@
var $layer = $(this.svgElement).find('.schematic-connections-layer');
$layer.empty();
var html = '';
var htmlBack = ''; // Leitungen (unten)
var htmlFront = ''; // Abgänge + Eingänge (oben)
var renderedCount = 0;
// Get display settings
@ -7798,6 +7820,31 @@
var shadowWidth = wireWidth + 4;
var hoverWidth = wireWidth + 1.5;
// Abgang-Bereiche vorab berechnen (für Badge-Kollisionserkennung)
this._outputAreas = [];
this.connections.forEach(function(conn) {
if (!conn.fk_target && !conn.path_data && conn.fk_source) {
var eq = self.getEquipmentById(conn.fk_source);
if (!eq) return;
var terms = self.getTerminals(eq);
var termId = conn.source_terminal_id || 't2';
var pos = self.getTerminalPosition(eq, termId, terms);
if (!pos) return;
var labelText = conn.output_label || '';
if (conn.output_location) labelText += ' · ' + conn.output_location;
var cableText = ((conn.medium_type || '') + ' ' + (conn.medium_spec || '')).trim();
var maxLen = Math.max(labelText.length, cableText.length);
var lineLen = Math.min(120, Math.max(50, maxLen * 6 + 20));
var goUp = pos.isTop;
self._outputAreas.push({
x: pos.x - 25,
y: goUp ? (pos.y - lineLen - 5) : pos.y,
w: 50,
h: lineLen + 10
});
}
});
this.connections.forEach(function(conn, connIndex) {
// Check is_rail as integer (PHP may return string "1" or "0")
if (parseInt(conn.is_rail) === 1) {
@ -7854,6 +7901,7 @@
// Calculate line length based on label text
var labelText = conn.output_label || '';
if (conn.output_location) labelText += ' · ' + conn.output_location;
var cableText = (conn.medium_type || '') + ' ' + (conn.medium_spec || '');
var maxTextLen = Math.max(labelText.length, cableText.trim().length);
var lineLength = Math.min(120, Math.max(50, maxTextLen * 6 + 20));
@ -7869,45 +7917,49 @@
// Draw vertical line
var path = 'M ' + lineX + ' ' + startY + ' L ' + lineX + ' ' + endY;
html += '<g class="schematic-output-group' + (isBundled ? ' bundled' : '') + '" data-connection-id="' + conn.id + '" style="cursor:pointer;">';
// Abgänge in htmlFront (über Leitungen)
htmlFront += '<g class="schematic-output-group' + (isBundled ? ' bundled' : '') + '" data-connection-id="' + conn.id + '" style="cursor:pointer;">';
// For bundled: draw horizontal bar connecting all terminals
if (isBundled && bundleWidth > 0) {
var barY = startY + (goingUp ? -5 : 5);
html += '<line x1="' + (bundleCenterX - bundleWidth/2) + '" y1="' + barY + '" ';
html += 'x2="' + (bundleCenterX + bundleWidth/2) + '" y2="' + barY + '" ';
html += 'stroke="' + color + '" stroke-width="' + (wireWidth + 1) + '" stroke-linecap="round"/>';
htmlFront += '<line x1="' + (bundleCenterX - bundleWidth/2) + '" y1="' + barY + '" ';
htmlFront += 'x2="' + (bundleCenterX + bundleWidth/2) + '" y2="' + barY + '" ';
htmlFront += 'stroke="' + color + '" stroke-width="' + (wireWidth + 1) + '" stroke-linecap="round"/>';
}
// Invisible hit area for clicking
var hitY = goingUp ? endY : startY;
var hitWidth = isBundled && bundleWidth > 0 ? Math.max(40, bundleWidth + 20) : 40;
html += '<rect x="' + (lineX - hitWidth/2) + '" y="' + hitY + '" width="' + hitWidth + '" height="' + lineLength + '" fill="transparent"/>';
htmlFront += '<rect x="' + (lineX - hitWidth/2) + '" y="' + hitY + '" width="' + hitWidth + '" height="' + lineLength + '" fill="transparent"/>';
// Connection line
html += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
html += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round"/>';
htmlFront += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlFront += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round"/>';
// Arrow at end (pointing away from equipment)
var arrowSize = isBundled && bundleWidth > 0 ? 8 : 5;
if (goingUp) {
// Arrow pointing UP
html += '<polygon points="' + (lineX - arrowSize) + ',' + (endY + 6) + ' ' + lineX + ',' + endY + ' ' + (lineX + arrowSize) + ',' + (endY + 6) + '" fill="' + color + '"/>';
htmlFront += '<polygon points="' + (lineX - arrowSize) + ',' + (endY + 6) + ' ' + lineX + ',' + endY + ' ' + (lineX + arrowSize) + ',' + (endY + 6) + '" fill="' + color + '"/>';
} else {
// Arrow pointing DOWN
html += '<polygon points="' + (lineX - arrowSize) + ',' + (endY - 6) + ' ' + lineX + ',' + endY + ' ' + (lineX + arrowSize) + ',' + (endY - 6) + '" fill="' + color + '"/>';
htmlFront += '<polygon points="' + (lineX - arrowSize) + ',' + (endY - 6) + ' ' + lineX + ',' + endY + ' ' + (lineX + arrowSize) + ',' + (endY - 6) + '" fill="' + color + '"/>';
}
// Labels - vertical text on both sides
var labelY = (startY + endY) / 2;
// Left side: Bezeichnung (output_label)
// Left side: Bezeichnung (output_label) + Räumlichkeit
if (conn.output_label) {
html += '<text x="' + (lineX - 10) + '" y="' + labelY + '" ';
html += 'text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" ';
html += 'transform="rotate(-90 ' + (lineX - 10) + ' ' + labelY + ')">';
html += self.escapeHtml(conn.output_label);
html += '</text>';
htmlFront += '<text x="' + (lineX - 10) + '" y="' + labelY + '" ';
htmlFront += 'text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" ';
htmlFront += 'transform="rotate(-90 ' + (lineX - 10) + ' ' + labelY + ')">';
htmlFront += self.escapeHtml(conn.output_label);
if (conn.output_location) {
htmlFront += '<tspan font-size="9" font-weight="normal" font-style="italic" fill="#999"> · ' + self.escapeHtml(conn.output_location) + '</tspan>';
}
htmlFront += '</text>';
}
// Right side: Kabeltyp + Größe
@ -7915,23 +7967,23 @@
if (conn.medium_type) cableInfo = conn.medium_type;
if (conn.medium_spec) cableInfo += ' ' + conn.medium_spec;
if (cableInfo) {
html += '<text x="' + (lineX + 10) + '" y="' + labelY + '" ';
html += 'text-anchor="middle" fill="#888" font-size="10" ';
html += 'transform="rotate(-90 ' + (lineX + 10) + ' ' + labelY + ')">';
html += self.escapeHtml(cableInfo.trim());
html += '</text>';
htmlFront += '<text x="' + (lineX + 10) + '" y="' + labelY + '" ';
htmlFront += 'text-anchor="middle" fill="#888" font-size="10" ';
htmlFront += 'transform="rotate(-90 ' + (lineX + 10) + ' ' + labelY + ')">';
htmlFront += self.escapeHtml(cableInfo.trim());
htmlFront += '</text>';
}
// Phase type at end of line
if (conn.connection_type) {
var phaseY = goingUp ? (endY - 10) : (endY + 14);
html += '<text x="' + lineX + '" y="' + phaseY + '" ';
html += 'text-anchor="middle" fill="' + color + '" font-size="11" font-weight="bold">';
html += conn.connection_type;
html += '</text>';
htmlFront += '<text x="' + lineX + '" y="' + phaseY + '" ';
htmlFront += 'text-anchor="middle" fill="' + color + '" font-size="11" font-weight="bold">';
htmlFront += conn.connection_type;
htmlFront += '</text>';
}
html += '</g>';
htmlFront += '</g>';
renderedCount++;
return;
}
@ -7957,18 +8009,18 @@
var junctionX = pathMatch ? parseFloat(pathMatch[1]) : 0;
var junctionY = pathMatch ? parseFloat(pathMatch[2]) : 0;
html += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
htmlBack += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
html += '<g class="schematic-junction-group" data-connection-id="' + conn.id + '">';
html += '<path class="schematic-connection-hitarea" d="' + path + '" ';
html += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
htmlBack += '<g class="schematic-junction-group" data-connection-id="' + conn.id + '">';
htmlBack += '<path class="schematic-connection-hitarea" d="' + path + '" ';
htmlBack += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
html += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
html += 'fill="none" stroke="' + junctionColor + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
htmlBack += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlBack += 'fill="none" stroke="' + junctionColor + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
// Junction marker (dot at start point)
if (self.displaySettings.showJunctions) {
html += '<circle cx="' + junctionX + '" cy="' + junctionY + '" r="5" fill="' + junctionColor + '" stroke="#fff" stroke-width="1.5"/>';
htmlBack += '<circle cx="' + junctionX + '" cy="' + junctionY + '" r="5" fill="' + junctionColor + '" stroke="#fff" stroke-width="1.5"/>';
}
// Label if present
@ -7983,15 +8035,15 @@
// Badge with solid background and border for visibility
// Text always white for readability (e.g. L2 black wire)
html += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
html += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
html += 'fill="#1a1a1a" stroke="' + junctionColor + '" stroke-width="1.5"/>';
html += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
html += self.escapeHtml(conn.output_label);
html += '</text>';
htmlBack += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
htmlBack += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
htmlBack += 'fill="#1a1a1a" stroke="' + junctionColor + '" stroke-width="1.5"/>';
htmlBack += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlBack += self.escapeHtml(conn.output_label);
htmlBack += '</text>';
}
html += '</g>';
htmlBack += '</g>';
renderedCount++;
return;
}
@ -8009,63 +8061,57 @@
// Farbe: gespeichert > Phase-Farbe > Fallback hellblau
var inputColor = conn.color || self.PHASE_COLORS[conn.connection_type] || '#4fc3f7';
var isTop = targetPos.isTop;
// Calculate line length based on label
// Linienlänge basierend auf Label
var inputLabel = conn.output_label || '';
var inputLineLength = Math.min(80, Math.max(45, inputLabel.length * 5 + 30));
var startY = targetPos.y - inputLineLength;
// Draw vertical line coming down into terminal
// Richtung: Top-Terminal = Linie von oben, Bottom-Terminal = Linie von unten
var startY = isTop ? (targetPos.y - inputLineLength) : (targetPos.y + inputLineLength);
var path = 'M ' + targetPos.x + ' ' + startY + ' L ' + targetPos.x + ' ' + targetPos.y;
html += '<g class="schematic-input-group" data-connection-id="' + conn.id + '" style="cursor:pointer;">';
// Eingänge in htmlFront (über Leitungen)
htmlFront += '<g class="schematic-input-group" data-connection-id="' + conn.id + '" style="cursor:pointer;">';
// Invisible hit area for clicking
html += '<rect x="' + (targetPos.x - 20) + '" y="' + startY + '" width="40" height="' + inputLineLength + '" fill="transparent"/>';
// Invisible hit area
var hitY = isTop ? startY : targetPos.y;
htmlFront += '<rect x="' + (targetPos.x - 20) + '" y="' + hitY + '" width="40" height="' + inputLineLength + '" fill="transparent"/>';
// Connection line
html += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
html += 'fill="none" stroke="' + inputColor + '" stroke-width="' + wireWidth + '" stroke-linecap="round"/>';
// Verbindungslinie
htmlFront += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlFront += 'fill="none" stroke="' + inputColor + '" stroke-width="' + wireWidth + '" stroke-linecap="round"/>';
// Circle at top (external source indicator)
html += '<circle cx="' + targetPos.x + '" cy="' + startY + '" r="6" fill="' + inputColor + '" stroke="#fff" stroke-width="2"/>';
// Kreis am externen Ende (Quell-Indikator)
htmlFront += '<circle cx="' + targetPos.x + '" cy="' + startY + '" r="6" fill="' + inputColor + '" stroke="#fff" stroke-width="2"/>';
// Arrow pointing down into terminal
html += '<polygon points="' + (targetPos.x - 5) + ',' + (targetPos.y - 8) + ' ' + targetPos.x + ',' + (targetPos.y - 2) + ' ' + (targetPos.x + 5) + ',' + (targetPos.y - 8) + '" fill="' + inputColor + '"/>';
// Phase-Label als Badge über dem Eingang
var phaseLabel = conn.connection_type || 'L1';
var phaseBadgeWidth = Math.max(phaseLabel.length * 9 + 12, 30);
var phaseBadgeHeight = 22;
var phaseBadgeX = targetPos.x - phaseBadgeWidth / 2;
var phaseBadgeY = startY - phaseBadgeHeight - 8;
html += '<rect x="' + phaseBadgeX + '" y="' + phaseBadgeY + '" ';
html += 'width="' + phaseBadgeWidth + '" height="' + phaseBadgeHeight + '" rx="4" ';
html += 'fill="' + inputColor + '" stroke="#fff" stroke-width="1"/>';
html += '<text x="' + targetPos.x + '" y="' + (phaseBadgeY + 16) + '" ';
html += 'text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
html += phaseLabel;
html += '</text>';
// Bezeichnung als Badge neben der Eingangsleitung
if (conn.output_label) {
var badgeText = self.escapeHtml(conn.output_label);
var badgeWidth = Math.min(badgeText.length * 7 + 16, 140);
var badgeHeight = 20;
var badgeX = targetPos.x + 14;
var badgeY = startY + (inputLineLength / 2) - badgeHeight / 2;
html += '<rect x="' + badgeX + '" y="' + badgeY + '" ';
html += 'width="' + badgeWidth + '" height="' + badgeHeight + '" rx="4" ';
html += 'fill="#1a1a1a" stroke="' + inputColor + '" stroke-width="1.5"/>';
html += '<text x="' + (badgeX + badgeWidth / 2) + '" y="' + (badgeY + 14) + '" ';
html += 'text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">';
html += badgeText;
html += '</text>';
// Pfeil ins Terminal (Richtung abhängig von Position)
if (isTop) {
// Pfeil nach unten ins Top-Terminal
htmlFront += '<polygon points="' + (targetPos.x - 5) + ',' + (targetPos.y - 8) + ' ' + targetPos.x + ',' + (targetPos.y - 2) + ' ' + (targetPos.x + 5) + ',' + (targetPos.y - 8) + '" fill="' + inputColor + '"/>';
} else {
// Pfeil nach oben ins Bottom-Terminal
htmlFront += '<polygon points="' + (targetPos.x - 5) + ',' + (targetPos.y + 8) + ' ' + targetPos.x + ',' + (targetPos.y + 2) + ' ' + (targetPos.x + 5) + ',' + (targetPos.y + 8) + '" fill="' + inputColor + '"/>';
}
html += '</g>';
// Badge am Ende der Linie: Bezeichnung wenn vorhanden, sonst Phase
var badgeLabel = conn.output_label ? self.escapeHtml(conn.output_label) : (conn.connection_type || 'L1');
var phaseBadgeWidth = Math.max(badgeLabel.length * 9 + 12, 30);
var phaseBadgeHeight = 22;
var phaseBadgeX = targetPos.x - phaseBadgeWidth / 2;
var phaseBadgeY = isTop ? (startY - phaseBadgeHeight - 8) : (startY + 8);
htmlFront += '<rect x="' + phaseBadgeX + '" y="' + phaseBadgeY + '" ';
htmlFront += 'width="' + phaseBadgeWidth + '" height="' + phaseBadgeHeight + '" rx="4" ';
htmlFront += 'fill="' + inputColor + '" stroke="#fff" stroke-width="1"/>';
htmlFront += '<text x="' + targetPos.x + '" y="' + (phaseBadgeY + 16) + '" ';
htmlFront += 'text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlFront += badgeLabel;
htmlFront += '</text>';
htmlFront += '</g>';
renderedCount++;
return;
}
@ -8084,17 +8130,17 @@
var junctionX = lastMatch ? parseFloat(lastMatch[1]) : 0;
var junctionY = lastMatch ? parseFloat(lastMatch[2]) : 0;
html += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
htmlBack += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
html += '<g class="schematic-connection-group" data-connection-id="' + conn.id + '">';
html += '<path class="schematic-connection-hitarea" d="' + path + '" ';
html += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
htmlBack += '<g class="schematic-connection-group" data-connection-id="' + conn.id + '">';
htmlBack += '<path class="schematic-connection-hitarea" d="' + path + '" ';
htmlBack += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
html += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
html += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
htmlBack += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlBack += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
// Junction marker (dot at end point where it connects to other wire)
html += '<circle cx="' + junctionX + '" cy="' + junctionY + '" r="4" fill="' + color + '" stroke="#fff" stroke-width="1.5"/>';
htmlBack += '<circle cx="' + junctionX + '" cy="' + junctionY + '" r="4" fill="' + color + '" stroke="#fff" stroke-width="1.5"/>';
// Label if present
if (conn.output_label) {
@ -8104,15 +8150,15 @@
var labelX = labelPos ? labelPos.x : junctionX;
var labelY = labelPos ? labelPos.y : junctionY - 20;
html += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
html += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
html += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1.5"/>';
html += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
html += self.escapeHtml(conn.output_label);
html += '</text>';
htmlBack += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
htmlBack += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
htmlBack += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1.5"/>';
htmlBack += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlBack += self.escapeHtml(conn.output_label);
htmlBack += '</text>';
}
html += '</g>';
htmlBack += '</g>';
renderedCount++;
return;
}
@ -8144,21 +8190,18 @@
path = self.createOrthogonalPath(sourcePos, targetPos, routeOffset, sourceEq, targetEq);
}
html += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
htmlBack += '<path class="schematic-connection-shadow" d="' + path + '" fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="' + shadowWidth + '" stroke-linecap="round" stroke-linejoin="round"/>';
html += '<g class="schematic-connection-group" data-connection-id="' + conn.id + '">';
html += '<path class="schematic-connection-hitarea" d="' + path + '" ';
html += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
htmlBack += '<g class="schematic-connection-group" data-connection-id="' + conn.id + '">';
htmlBack += '<path class="schematic-connection-hitarea" d="' + path + '" ';
htmlBack += 'fill="none" stroke="transparent" stroke-width="15" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;"/>';
html += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
html += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
htmlBack += '<path class="schematic-connection" data-connection-id="' + conn.id + '" d="' + path + '" ';
htmlBack += 'fill="none" stroke="' + color + '" stroke-width="' + wireWidth + '" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none;"/>';
if (conn.output_label) {
// Calculate label dimensions
var labelWidth = Math.min(conn.output_label.length * 8 + 16, 120);
var labelHeight = 22;
// Find safe label position that doesn't overlap equipment
var labelPos = self.findSafeLabelPosition(path, labelWidth, labelHeight);
if (!labelPos) {
labelPos = self.getPathMidpoint(path);
@ -8166,39 +8209,35 @@
var labelX = labelPos ? labelPos.x : (sourcePos.x + targetPos.x) / 2;
var labelY = labelPos ? labelPos.y : (sourcePos.y + targetPos.y) / 2;
// Badge with solid background and border for visibility
// Text always white for readability (e.g. L2 black wire)
html += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
html += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
html += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1.5"/>';
html += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
html += self.escapeHtml(conn.output_label);
html += '</text>';
htmlBack += '<rect class="connection-label-bg" x="' + (labelX - labelWidth/2) + '" y="' + (labelY - 12) + '" ';
htmlBack += 'width="' + labelWidth + '" height="' + labelHeight + '" rx="4" ';
htmlBack += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1.5"/>';
htmlBack += '<text x="' + labelX + '" y="' + (labelY + 4) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="bold">';
htmlBack += self.escapeHtml(conn.output_label);
htmlBack += '</text>';
}
if (conn.connection_type && !conn.output_label) {
// Also check for safe position for type labels
var typeWidth = conn.connection_type.length * 9 + 14;
var typeHeight = 18;
var typePos = self.findSafeLabelPosition(path, typeWidth, typeHeight);
var typeX = typePos ? typePos.x : (sourcePos.x + targetPos.x) / 2;
var typeY = typePos ? typePos.y : (sourcePos.y + targetPos.y) / 2;
// Badge background for type label too
// Text always white for readability
html += '<rect class="connection-type-bg" x="' + (typeX - typeWidth/2) + '" y="' + (typeY - 10) + '" ';
html += 'width="' + typeWidth + '" height="' + typeHeight + '" rx="3" ';
html += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1"/>';
html += '<text x="' + typeX + '" y="' + (typeY + 4) + '" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">';
html += conn.connection_type;
html += '</text>';
htmlBack += '<rect class="connection-type-bg" x="' + (typeX - typeWidth/2) + '" y="' + (typeY - 10) + '" ';
htmlBack += 'width="' + typeWidth + '" height="' + typeHeight + '" rx="3" ';
htmlBack += 'fill="#1a1a1a" stroke="' + color + '" stroke-width="1"/>';
htmlBack += '<text x="' + typeX + '" y="' + (typeY + 4) + '" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold">';
htmlBack += conn.connection_type;
htmlBack += '</text>';
}
html += '</g>';
htmlBack += '</g>';
renderedCount++;
});
$layer.html(html);
// Leitungen zuerst (hinten), dann Abgänge/Eingänge (vorne)
$layer.html(htmlBack + htmlFront);
// Bind click events to SVG connection elements (must be done after rendering)
var self = this;
@ -9686,6 +9725,17 @@
return result.top;
},
// Schutzgruppen-Farbe basierend auf Protection-Device-ID
_protectionColorCache: {},
getProtectionColor: function(protectionId) {
if (!protectionId) return null;
if (this._protectionColorCache[protectionId]) return this._protectionColorCache[protectionId];
var colors = ['#e74c3c', '#3498db', '#f39c12', '#9b59b6', '#1abc9c', '#e91e63', '#00bcd4', '#ff5722'];
var idx = Object.keys(this._protectionColorCache).length % colors.length;
this._protectionColorCache[protectionId] = colors[idx];
return colors[idx];
},
getTerminals: function(eq) {
// Try to parse terminals_config from equipment type
if (eq.terminals_config) {
@ -10009,6 +10059,17 @@
return eq; // Return the overlapping equipment
}
}
// Auch gegen Abgang-Bereiche prüfen
if (this._outputAreas) {
for (var j = 0; j < this._outputAreas.length; j++) {
var oa = this._outputAreas[j];
if (!(lx2 < oa.x || lx1 > oa.x + oa.w || ly2 < oa.y || ly1 > oa.y + oa.h)) {
return { _isOutput: true }; // Abgang-Bereich überlappt
}
}
}
return null; // No overlap
},
@ -10031,7 +10092,7 @@
if (points.length < 2) return null;
// Try positions along the path (at 10%, 30%, 50%, 70%, 90%)
// Positionen entlang des Pfades testen (Mitte bevorzugt, dann alternierend)
var percentages = [0.5, 0.3, 0.7, 0.2, 0.8, 0.1, 0.9];
// Calculate total path length and segment lengths
@ -11307,6 +11368,7 @@
}
var labelText = conn.output_label || '';
if (conn.output_location) labelText += ' · ' + conn.output_location;
var cableText = (conn.medium_type || '') + ' ' + (conn.medium_spec || '');
var maxTextLen = Math.max(labelText.length, cableText.trim().length);
var lineLength = Math.min(120, Math.max(50, maxTextLen * 6 + 20));
@ -11314,7 +11376,7 @@
var endY = goingUp ? (sourcePos.y - lineLength) : (sourcePos.y + lineLength);
pathData = 'M ' + lineX + ' ' + sourcePos.y + ' L ' + lineX + ' ' + endY;
} else if (!conn.fk_source && targetEq) {
// Anschlusspunkt (Input) - Linie von oben ins Terminal
// Anschlusspunkt (Input) - Linie von außen ins Terminal
var targetTerminals = this.getTerminals(targetEq);
var targetTermId = conn.target_terminal_id || 't1';
var targetPos = this.getTerminalPosition(targetEq, targetTermId, targetTerminals);
@ -11322,7 +11384,7 @@
var inputLabel = conn.output_label || '';
var inputLineLength = Math.min(80, Math.max(45, inputLabel.length * 5 + 30));
var startY = targetPos.y - inputLineLength;
var startY = targetPos.isTop ? (targetPos.y - inputLineLength) : (targetPos.y + inputLineLength);
pathData = 'M ' + targetPos.x + ' ' + startY + ' L ' + targetPos.x + ' ' + targetPos.y;
}
@ -12525,6 +12587,14 @@
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '</div>';
// Räumlichkeit
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Räumlichkeit:</label>';
html += '<input type="text" class="output-location" placeholder="z.B. Küche, Bad OG, Keller" value="' +
self.escapeHtml(existingOutput ? existingOutput.output_location || '' : '') + '" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;">';
html += '</div>';
// Kabeltyp (from database)
html += '<div style="margin-bottom:10px;">';
html += '<label style="display:block;color:#aaa;font-size:11px;margin-bottom:3px;">Kabeltyp:</label>';
@ -12577,6 +12647,23 @@
});
html += '</select></div>';
// Alle Terminals auf dieser Seite bündeln (nur bei >1 Terminal auf gleicher Seite)
var eq = self.equipment.find(function(e) { return e.id == eqId; });
var terminals = eq ? self.getTerminals(eq) : [];
// Terminal-Seite ermitteln (top oder bottom)
var clickedTerm = terminals.find(function(t) { return t.id === termId; });
var termSide = clickedTerm ? clickedTerm.pos : 'bottom';
var sideTerminals = terminals.filter(function(t) { return t.pos === termSide; });
var sideCount = sideTerminals.length;
if (sideCount > 1) {
html += '<div style="margin-bottom:12px;">';
html += '<label style="display:flex;align-items:center;gap:8px;color:#ccc;cursor:pointer;">';
html += '<input type="checkbox" class="output-bundle-all"' +
(existingOutput && existingOutput.bundled_terminals === 'all' ? ' checked' : '') + '>';
html += '<span>Alle ' + sideCount + ' Klemmen bündeln (Drehstrom-Verbraucher)</span>';
html += '</label></div>';
}
// Buttons
html += '<div style="display:flex;gap:8px;justify-content:flex-end;">';
if (existingOutput) {
@ -12628,15 +12715,17 @@
});
$('.output-save-btn').on('click', function() {
var label = $('.output-label').val();
var location = $('.output-location').val();
var cableType = $('.output-cable-type').val();
var cableSpec = $('.output-cable-spec').val();
var cableLength = $('.output-length').val();
var phaseType = $('.output-phase-type').val();
var bundled = $('.output-bundle-all').is(':checked') ? 'all' : '';
if (existingOutput) {
self.updateOutput(existingOutput.id, label, cableType, cableSpec, phaseType, cableLength);
self.updateOutput(existingOutput.id, label, location, cableType, cableSpec, phaseType, cableLength, bundled);
} else {
self.createOutput(eqId, termId, label, cableType, cableSpec, phaseType, cableLength);
self.createOutput(eqId, termId, label, location, cableType, cableSpec, phaseType, cableLength, bundled);
}
$('.schematic-output-dialog').remove();
});
@ -12711,7 +12800,7 @@
},
// Create a new cable output (no target, fk_target = NULL)
createOutput: function(eqId, termId, label, cableType, cableSpec, phaseType, cableLength) {
createOutput: function(eqId, termId, label, location, cableType, cableSpec, phaseType, cableLength, bundledTerminals) {
var self = this;
$.ajax({
@ -12725,9 +12814,11 @@
target_terminal_id: '',
connection_type: phaseType || 'L1N',
output_label: label,
output_location: location || '',
medium_type: cableType,
medium_spec: cableSpec,
medium_length: cableLength || '',
bundled_terminals: bundledTerminals || '',
token: $('input[name="token"]').val()
},
dataType: 'json',
@ -12746,7 +12837,7 @@
},
// Update existing output
updateOutput: function(connId, label, cableType, cableSpec, phaseType, cableLength) {
updateOutput: function(connId, label, location, cableType, cableSpec, phaseType, cableLength, bundledTerminals) {
var self = this;
$.ajax({
@ -12757,9 +12848,11 @@
connection_id: connId,
connection_type: phaseType || 'L1N',
output_label: label,
output_location: location || '',
medium_type: cableType,
medium_spec: cableSpec,
medium_length: cableLength || '',
bundled_terminals: bundledTerminals || '',
token: $('input[name="token"]').val()
},
dataType: 'json',
@ -12992,7 +13085,23 @@
var conn = this.connections.find(function(c) { return c.id == connId; });
if (!conn) return;
// Build edit dialog
// Abgang → showAbgangDialog mit existingOutput
if (conn.fk_source && !conn.fk_target && !conn.path_data) {
var eqId = conn.fk_source;
var termId = conn.source_terminal_id || 't2';
this.showAbgangDialog(eqId, termId, window.innerWidth / 2 - 160, window.innerHeight / 2 - 150, conn);
return;
}
// Eingang → showInputDialog mit existingInput
if (!conn.fk_source && conn.fk_target && !conn.path_data) {
var eqId = conn.fk_target;
var termId = conn.target_terminal_id || 't1';
this.showInputDialog(eqId, termId, window.innerWidth / 2 - 140, window.innerHeight / 2 - 100, conn);
return;
}
// Normale Verbindung → Standard-Edit-Dialog
var dialogHtml = '<div class="schematic-edit-dialog" style="' +
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
'background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;' +
@ -14242,6 +14351,13 @@
dialogHtml += '</div>';
dialogHtml += '</div>';
// FI/RCD-Zuordnung
dialogHtml += '<div style="margin-bottom:12px;padding-top:12px;border-top:1px solid #444;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;"><i class="fa fa-shield" style="color:#e67e22;"></i> Schutzgerät (FI/RCD):</label>';
dialogHtml += '<select class="edit-equipment-protection" style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;">';
dialogHtml += '<option value="">-- Keins --</option>';
dialogHtml += '</select></div>';
// Buttons
dialogHtml += '<div style="display:flex;gap:10px;justify-content:flex-end;">';
dialogHtml += '<button type="button" class="edit-dialog-cancel" style="' +
@ -14287,6 +14403,22 @@
// Produktsuche mit Autocomplete initialisieren
self.initProductAutocomplete('.edit-product-search', '.edit-product-id', '.edit-product-clear', eq.fk_product);
// Protection Devices (FI/RCD) laden
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment.php',
data: { action: 'get_protection_devices', anlage_id: self.anlageId },
dataType: 'json',
success: function(response) {
if (response.success && response.devices) {
var $select = $('.edit-equipment-protection');
response.devices.forEach(function(device) {
var selected = (eq.fk_protection && parseInt(device.id) === parseInt(eq.fk_protection)) ? ' selected' : '';
$select.append('<option value="' + device.id + '"' + selected + '>' + self.escapeHtml(device.display_label) + '</option>');
});
}
}
});
},
saveEquipmentEdit: function(equipmentId) {
@ -14309,6 +14441,7 @@
position_te: $('.edit-equipment-position').val(),
field_values: JSON.stringify(fieldValues),
fk_product: $('.edit-product-id').val() || 0,
fk_protection: $('.edit-equipment-protection').val() || 0,
token: $('input[name="token"]').val()
};

341
js/pwa.js
View file

@ -59,10 +59,14 @@
// ============================================
function init() {
// Register Service Worker
// Register Service Worker + Update erzwingen
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('[PWA] Service Worker registered'))
navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' })
.then(reg => {
console.log('[PWA] Service Worker registered');
// Sofort nach Updates suchen
reg.update();
})
.catch(err => console.error('[PWA] SW registration failed:', err));
}
@ -749,6 +753,7 @@
App.outputs = response.outputs || [];
App.inputs = response.inputs || [];
App.connections = response.connections || [];
App.busbars = response.busbars || [];
App.fieldMeta = response.field_meta || {};
// Cache for offline
@ -760,6 +765,7 @@
outputs: App.outputs,
inputs: App.inputs,
connections: App.connections,
busbars: App.busbars,
fieldMeta: App.fieldMeta
}));
@ -780,6 +786,7 @@
App.outputs = data.outputs || [];
App.inputs = data.inputs || [];
App.connections = data.connections || [];
App.busbars = data.busbars || [];
App.fieldMeta = data.fieldMeta || {};
renderEditor();
showToast('Offline - Zeige gecachte Daten', 'warning');
@ -827,6 +834,9 @@
return;
}
// Terminal-Farbpropagierung aufbauen (Phasenfarben an allen Terminals)
buildTerminalPhaseMap();
let html = '';
App.panels.forEach(panel => {
@ -879,21 +889,26 @@
html += `<span class="terminal-label-cell label-row-top bundled-label" style="${gridColStyle}" data-connection-id="${bundledTop.id}" data-equipment-id="${eq.id}" data-direction="output">`;
if (bundledTop.output_label) {
html += `<span class="terminal-label">${escapeHtml(bundledTop.output_label)}`;
if (bundledTop.output_location) html += `<span class="output-location">${escapeHtml(bundledTop.output_location)}</span>`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
}
html += `</span>`;
} else {
// Normale einzelne Labels pro Terminal - nur für tatsächliche Terminals
// Normale einzelne Labels pro Terminal - per Terminal-ID matchen
const eqTerminals = getTerminals(eq);
const topTerms = eqTerminals.filter(tm => tm.pos === 'top');
for (let t = 0; t < topTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:1;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const topOut = eqTopOutputs[t] || null;
const termId = topTerms[t] ? topTerms[t].id : ('t' + (t + 1));
const topOut = eqTopOutputs.find(o => o.source_terminal_id === termId) || null;
if (topOut && topOut.output_label && (!topOut.bundled_terminals || widthTe <= 1)) {
const cableInfo = buildCableInfo(topOut);
html += `<span class="terminal-label-cell label-row-top" style="${style}" data-connection-id="${topOut.id}" data-equipment-id="${eq.id}" data-direction="output">`;
html += `<span class="terminal-label">${escapeHtml(topOut.output_label)}`;
if (topOut.output_location) html += `<span class="output-location">${escapeHtml(topOut.output_location)}</span>`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
html += `</span>`;
@ -914,38 +929,43 @@
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && i.target_terminal_id === 't1') : [];
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const topTerminalCount = getTerminalCount(type, 'top', widthTe);
// Terminal-IDs für diese Position ermitteln
const eqTerminals = getTerminals(eq);
const topTerms = eqTerminals.filter(tm => tm.pos === 'top');
const topTermIds = topTerms.map(tm => tm.id);
// Inputs und Outputs per Terminal-ID matchen (nicht per Index!)
const eqTopInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && topTermIds.indexOf(i.target_terminal_id) !== -1) : [];
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
// Gebündelter Abgang?
const bundledTop = eqTopOutputs.find(o => o.bundled_terminals === 'all');
// Nur so viele Terminal-Punkte wie tatsächlich konfiguriert
for (let t = 0; t < topTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:2;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const inp = eqInputs[t] || null;
const topOut = bundledTop || eqTopOutputs[t] || null;
const termId = topTerms[t] ? topTerms[t].id : ('t' + (t + 1));
// Input/Output per Terminal-ID finden
const inp = eqTopInputs.find(i => i.target_terminal_id === termId) || null;
const topOut = bundledTop || eqTopOutputs.find(o => o.source_terminal_id === termId) || null;
if (bundledTop && widthTe > 1) {
// Gebündelter Abgang: Pfeil nur beim ersten Terminal, Rest leer
if (t === 0) {
const phaseColor = bundledTop.color || getPhaseColor(bundledTop.connection_type);
const bundledStyle = posTe > 0
? `grid-row:2; grid-column: ${posTe} / span ${topTerminalCount}`
: `grid-row:2; grid-column: span ${topTerminalCount}`;
? `grid-row:2; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:2; grid-column: span ${widthTe}`;
html += `<span class="terminal-point terminal-output terminal-row-top bundled-output" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="top" data-connection-id="${bundledTop.id}" style="${bundledStyle}">`;
html += `<span class="terminal-arrow terminal-arrow-up" style="--arrow-color:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(bundledTop.connection_type || '')}</span>`;
html += `</span>`;
}
// Restliche Terminals überspringen (grid-column: span hat sie schon)
} else if (topOut && (!topOut.bundled_terminals || widthTe <= 1)) {
// Normaler Top-Output ODER bundled bei 1 TE (Bundle macht bei 1 TE keinen Unterschied)
} else if (topOut && topOut.output_label && (!topOut.bundled_terminals || widthTe <= 1)) {
// Output MIT Label → Pfeil (echter Abgang)
const phaseColor = topOut.color || getPhaseColor(topOut.connection_type);
html += `<span class="terminal-point terminal-output terminal-row-top" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="top" data-connection-id="${topOut.id}" style="${style}">`;
html += `<span class="terminal-arrow terminal-arrow-up" style="--arrow-color:${phaseColor}"></span>`;
@ -958,13 +978,22 @@
html += `<span class="terminal-phase">${escapeHtml(inp.connection_type || '')}</span>`;
html += `</span>`;
} else {
// Leerer Terminal - neutral, Position "top"
html += `<span class="terminal-point terminal-empty terminal-row-top" data-equipment-id="${eq.id}" data-terminal-position="top" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
html += `</span>`;
// Phasenfarbe aus Propagierung
const propColor = (App.terminalColorMap[eq.id] || {})[termId];
const propPhase = (App.terminalPhaseMap[eq.id] || {})[termId];
if (propColor) {
html += `<span class="terminal-point terminal-propagated terminal-row-top" data-equipment-id="${eq.id}" data-terminal-position="top" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot" style="background:${propColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(propPhase || '')}</span>`;
html += `</span>`;
} else {
html += `<span class="terminal-point terminal-empty terminal-row-top" data-equipment-id="${eq.id}" data-terminal-position="top" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
html += `</span>`;
}
}
}
// Leere Zellen für restliche TE-Breite (ohne Terminal-Punkte)
// Leere Zellen für restliche TE-Breite
for (let t = topTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:2;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
@ -1033,38 +1062,43 @@
carrierEquipment.forEach(eq => {
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : [];
const eqBottomInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && i.target_terminal_id === 't2') : [];
// Terminal-Anzahl aus terminals_config ermitteln
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const bottomTerminalCount = getTerminalCount(type, 'bottom', widthTe);
// Terminal-IDs für Bottom ermitteln
const eqTerminals = getTerminals(eq);
const botTerms = eqTerminals.filter(tm => tm.pos === 'bottom');
const botTermIds = botTerms.map(tm => tm.id);
// Inputs und Outputs per Terminal-ID matchen
const eqBottomInputs = App.inputs ? App.inputs.filter(i => i.fk_target == eq.id && botTermIds.indexOf(i.target_terminal_id) !== -1) : [];
const eqBottomOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && !o.is_top) : [];
// Gebündelter Abgang?
const bundledBottom = eqBottomOutputs.find(o => o.bundled_terminals === 'all');
// Nur so viele Terminal-Punkte wie tatsächlich konfiguriert
for (let t = 0; t < bottomTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:4;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const out = bundledBottom || eqBottomOutputs[t] || null;
const inp = eqBottomInputs[t] || null;
const termId = botTerms[t] ? botTerms[t].id : ('t' + (widthTe + t + 1));
// Input/Output per Terminal-ID finden
const out = bundledBottom || eqBottomOutputs.find(o => o.source_terminal_id === termId) || null;
const inp = eqBottomInputs.find(i => i.target_terminal_id === termId) || null;
if (bundledBottom && widthTe > 1) {
// Gebündelter Abgang: Pfeil nur beim ersten Terminal, Rest leer
if (t === 0) {
const phaseColor = bundledBottom.color || getPhaseColor(bundledBottom.connection_type);
const bundledStyle = posTe > 0
? `grid-row:4; grid-column: ${posTe} / span ${bottomTerminalCount}`
: `grid-row:4; grid-column: span ${bottomTerminalCount}`;
? `grid-row:4; grid-column: ${posTe} / span ${widthTe}`
: `grid-row:4; grid-column: span ${widthTe}`;
html += `<span class="terminal-point terminal-output terminal-row-bottom bundled-output" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="bottom" data-connection-id="${bundledBottom.id}" style="${bundledStyle}">`;
html += `<span class="terminal-arrow terminal-arrow-down" style="--arrow-color:${phaseColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(bundledBottom.connection_type || '')}</span>`;
html += `</span>`;
}
// Restliche Terminals überspringen (grid-column: span hat sie schon)
} else if (out && (!out.bundled_terminals || widthTe <= 1)) {
// Normaler Abgang ODER bundled bei 1 TE (Bundle macht bei 1 TE keinen Unterschied)
} else if (out && out.output_label && (!out.bundled_terminals || widthTe <= 1)) {
// Output MIT Label → Pfeil (echter Abgang)
const phaseColor = out.color || getPhaseColor(out.connection_type);
html += `<span class="terminal-point terminal-output terminal-row-bottom" data-equipment-id="${eq.id}" data-direction="output" data-terminal-position="bottom" data-connection-id="${out.id}" style="${style}">`;
html += `<span class="terminal-arrow terminal-arrow-down" style="--arrow-color:${phaseColor}"></span>`;
@ -1077,13 +1111,22 @@
html += `<span class="terminal-phase">${escapeHtml(inp.connection_type || '')}</span>`;
html += `</span>`;
} else {
// Leerer Terminal - neutral, Position "bottom"
html += `<span class="terminal-point terminal-empty terminal-row-bottom" data-equipment-id="${eq.id}" data-terminal-position="bottom" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
html += `</span>`;
// Phasenfarbe aus Propagierung
const propColor = (App.terminalColorMap[eq.id] || {})[termId];
const propPhase = (App.terminalPhaseMap[eq.id] || {})[termId];
if (propColor) {
html += `<span class="terminal-point terminal-propagated terminal-row-bottom" data-equipment-id="${eq.id}" data-terminal-position="bottom" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot" style="background:${propColor}"></span>`;
html += `<span class="terminal-phase">${escapeHtml(propPhase || '')}</span>`;
html += `</span>`;
} else {
html += `<span class="terminal-point terminal-empty terminal-row-bottom" data-equipment-id="${eq.id}" data-terminal-position="bottom" data-connection-id="" style="${style}">`;
html += `<span class="terminal-dot terminal-dot-empty"></span>`;
html += `</span>`;
}
}
}
// Leere Zellen für restliche TE-Breite (ohne Terminal-Punkte)
// Leere Zellen für restliche TE-Breite
for (let t = bottomTerminalCount; t < widthTe; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:4;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
@ -1113,21 +1156,26 @@
html += `<span class="terminal-label-cell label-row-bottom bundled-label" style="${gridColStyle}" data-connection-id="${bundledBottom.id}" data-equipment-id="${eq.id}" data-direction="output">`;
if (bundledBottom.output_label) {
html += `<span class="terminal-label">${escapeHtml(bundledBottom.output_label)}`;
if (bundledBottom.output_location) html += `<span class="output-location">${escapeHtml(bundledBottom.output_location)}</span>`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
}
html += `</span>`;
} else {
// Normale einzelne Labels pro Terminal - nur für tatsächliche Terminals
// Normale einzelne Labels pro Terminal - per Terminal-ID matchen
const eqTerminals = getTerminals(eq);
const botTerms = eqTerminals.filter(tm => tm.pos === 'bottom');
for (let t = 0; t < bottomTerminalCount; t++) {
const colPos = posTe > 0 ? posTe + t : 0;
const style = `grid-row:5;${colPos > 0 ? ' grid-column:' + colPos : ''}`;
const out = eqBottomOutputs[t] || null;
const termId = botTerms[t] ? botTerms[t].id : ('t' + (widthTe + t + 1));
const out = eqBottomOutputs.find(o => o.source_terminal_id === termId) || null;
if (out && out.output_label && (!out.bundled_terminals || widthTe <= 1)) {
const cableInfo = buildCableInfo(out);
html += `<span class="terminal-label-cell label-row-bottom" style="${style}" data-connection-id="${out.id}" data-equipment-id="${eq.id}" data-direction="output">`;
html += `<span class="terminal-label">${escapeHtml(out.output_label)}`;
if (out.output_location) html += `<span class="output-location">${escapeHtml(out.output_location)}</span>`;
if (cableInfo) html += `<span class="cable-info">${escapeHtml(cableInfo)}</span>`;
html += `</span>`;
html += `</span>`;
@ -2366,6 +2414,7 @@
$('#btn-delete-connection').removeClass('hidden');
$('#conn-color').val(conn.color || '#3498db');
$('#conn-label').val(conn.output_label || '');
$('#conn-location').val(conn.output_location || '');
$('#conn-medium-length').val(conn.medium_length || '');
// Medium-Typen laden und Select befüllen
@ -2388,7 +2437,8 @@
// Side-Buttons immer zeigen
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
// Räumlichkeit und Medium-Felder nur bei Abgang zeigen
$('#conn-location-fields').toggle(direction === 'output');
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
@ -2470,6 +2520,200 @@
return colors[idx];
}
/**
* Terminals eines Equipment ermitteln (aus terminals_config oder Fallback)
* @param {object} eq - Equipment-Objekt
* @returns {Array} [{id: 't1', pos: 'top'}, ...]
*/
function getTerminals(eq) {
const type = App.equipmentTypes ? App.equipmentTypes.find(t => t.id == eq.fk_equipment_type) : null;
if (type && type.terminals_config) {
try {
const configStr = typeof type.terminals_config === 'string'
? type.terminals_config.replace(/\\r\\n|\\r|\\n/g, ' ')
: '';
const config = typeof type.terminals_config === 'string'
? JSON.parse(configStr)
: type.terminals_config;
if (config.terminals && Array.isArray(config.terminals)) {
return config.terminals;
}
} catch (e) { /* Parse-Fehler ignorieren */ }
}
// Fallback: Fortlaufende t1, t2, t3... IDs (gleiche Konvention wie Website)
// Standard-LS: t1 (top), t2 (bottom)
// Breiteres Equipment: t1..tN (top), t(N+1)..t(2N) (bottom)
const widthTe = parseFloat(eq.width_te) || 1;
const terminals = [];
for (let i = 0; i < widthTe; i++) {
terminals.push({id: 't' + (i + 1), pos: 'top', col: i});
}
for (let i = 0; i < widthTe; i++) {
terminals.push({id: 't' + (widthTe + i + 1), pos: 'bottom', col: i});
}
return terminals;
}
/**
* Phasen-Labels aus Kürzel parsen (z.B. "3P" ["L1","L2","L3"])
*/
function parsePhaseLabels(phases) {
if (!phases) return [];
const p = phases.toUpperCase();
if (p === '3P' || p === 'L1L2L3') return ['L1', 'L2', 'L3'];
if (p === '3P+N' || p === '3PN') return ['L1', 'L2', 'L3', 'N'];
if (p === '3P+N+PE' || p === '3PNPE') return ['L1', 'L2', 'L3', 'N', 'PE'];
if (p === 'L1N' || p === 'L1+N') return ['L1', 'N'];
if (p === 'L1') return ['L1'];
if (p === 'L2') return ['L2'];
if (p === 'L3') return ['L3'];
if (p === 'N') return ['N'];
if (p === 'PE') return ['PE'];
if (p.indexOf('+') !== -1) return p.split('+');
if (p.indexOf(',') !== -1) return p.split(',');
return [phases];
}
/**
* Terminal-Farbpropagierung aufbauen
* Setzt Phasen direkt anhand der Verbindungsdaten KEINE Block-Durchreichung.
*
* Ablauf:
* 1. Inputs setzen Phase auf ihr Ziel-Terminal
* 2. Outputs setzen Phase auf ihr Quell-Terminal
* 3. Wires setzen Phase auf BEIDE Enden (connection_type der Leitung)
* 4. Busbars verteilen Phasen an überlappende Equipment-Terminals
*/
function buildTerminalPhaseMap() {
const phaseMap = {}; // {eqId: {termId: "L1"}}
const colorMap = {}; // {eqId: {termId: "#hex"}}
function setPhase(eqId, termId, phase, color) {
if (!phaseMap[eqId]) phaseMap[eqId] = {};
if (!colorMap[eqId]) colorMap[eqId] = {};
if (phaseMap[eqId][termId]) return false; // Bereits gesetzt
phaseMap[eqId][termId] = phase;
colorMap[eqId][termId] = color || getPhaseColor(phase);
return true;
}
function forcePhase(eqId, termId, phase, color) {
if (!phaseMap[eqId]) phaseMap[eqId] = {};
if (!colorMap[eqId]) colorMap[eqId] = {};
if (phaseMap[eqId][termId] === phase) return false;
phaseMap[eqId][termId] = phase;
colorMap[eqId][termId] = color || getPhaseColor(phase);
return true;
}
// Schritt 1: Inputs setzen Phase auf Ziel-Terminal
if (App.inputs) {
App.inputs.forEach(function(inp) {
if (!inp.fk_target || !inp.target_terminal_id) return;
var phase = (inp.connection_type || '').toUpperCase();
if (!phase) return;
setPhase(inp.fk_target, inp.target_terminal_id, phase, inp.color || getPhaseColor(phase));
});
}
// Schritt 2: Outputs setzen Phase auf Quell-Terminal
if (App.outputs) {
App.outputs.forEach(function(out) {
if (!out.fk_source || !out.source_terminal_id) return;
var phase = (out.connection_type || '').toUpperCase();
if (!phase) return;
setPhase(out.fk_source, out.source_terminal_id, phase, out.color || getPhaseColor(phase));
});
}
// Schritt 3: Wires setzen Phase auf BEIDE Enden
// connection_type der Leitung bestimmt die Phase direkt
if (App.connections) {
App.connections.forEach(function(conn) {
if (!conn.fk_source || !conn.fk_target) return;
var phase = (conn.connection_type || '').toUpperCase();
if (!phase) return;
var wireColor = conn.color || getPhaseColor(phase);
if (conn.source_terminal_id) {
setPhase(conn.fk_source, conn.source_terminal_id, phase, wireColor);
}
if (conn.target_terminal_id) {
setPhase(conn.fk_target, conn.target_terminal_id, phase, wireColor);
}
});
}
// Schritt 4: Busbars verteilen Phasen an überlappende Equipment-Terminals
if (App.busbars && App.equipment) {
App.busbars.forEach(function(busbar) {
var railStart = busbar.rail_start_te || 1;
var railEnd = busbar.rail_end_te || railStart;
var targetPos = (busbar.position_y === 0) ? 'top' : 'bottom';
// Phase-Labels ermitteln
var phaseLabels;
if (busbar.phases_config && Array.isArray(busbar.phases_config) && busbar.phases_config.length > 0) {
phaseLabels = busbar.phases_config;
} else {
phaseLabels = parsePhaseLabels(busbar.rail_phases || busbar.connection_type || '');
}
if (phaseLabels.length === 0) return;
// Prüfen ob mindestens eine Phase eingespeist wird
var anyPhaseFed = false;
App.equipment.forEach(function(eq) {
if (String(eq.fk_carrier) !== String(busbar.fk_carrier)) return;
var eqPos = parseFloat(eq.position_te) || 1;
var eqWidth = parseFloat(eq.width_te) || 1;
if (!(eqPos < railEnd + 1 && railStart < eqPos + eqWidth)) return;
var terms = getTerminals(eq);
terms.filter(function(t) { return t.pos === targetPos; }).forEach(function(term) {
if ((phaseMap[eq.id] || {})[term.id]) {
anyPhaseFed = true;
}
});
});
// Nur verteilen wenn mindestens eine Phase anliegt
if (!anyPhaseFed) return;
// Excluded TEs
var excludedTEs = busbar.excluded_te
? busbar.excluded_te.split(',').map(function(t) { return parseInt(t.trim()); }).filter(function(t) { return !isNaN(t); })
: [];
// Phasen auf Equipment verteilen
App.equipment.forEach(function(eq) {
if (String(eq.fk_carrier) !== String(busbar.fk_carrier)) return;
var eqPos = parseFloat(eq.position_te) || 1;
var eqWidth = parseFloat(eq.width_te) || 1;
if (!(eqPos < railEnd + 1 && railStart < eqPos + eqWidth)) return;
var terms = getTerminals(eq);
var posTerminals = terms.filter(function(t) { return t.pos === targetPos; });
posTerminals.forEach(function(term, idx) {
var teIndex = term.col !== undefined ? term.col : (idx % eqWidth);
var absoluteTE = Math.round(eqPos + teIndex);
if (excludedTEs.indexOf(absoluteTE) !== -1) return;
if (absoluteTE < railStart || absoluteTE > railEnd) return;
var teOffset = absoluteTE - railStart;
var phase = phaseLabels[teOffset % phaseLabels.length];
var phaseColor = getPhaseColor(phase);
forcePhase(eq.id, term.id, phase, phaseColor);
});
});
});
}
// Als App-State speichern
App.terminalPhaseMap = phaseMap;
App.terminalColorMap = colorMap;
}
/**
* Abgangsseite-Button setzen
*/
@ -2709,6 +2953,7 @@
$('#btn-delete-connection').addClass('hidden');
$('#conn-color').val('#3498db');
$('#conn-label').val('');
$('#conn-location').val('');
$('#conn-medium-length').val('');
// Medium-Typen laden und Select befüllen
@ -2721,7 +2966,8 @@
// Side-Buttons immer zeigen (Automaten haben keine feste Richtung)
$('#conn-side-fields').show();
// Medium-Felder nur bei Abgang zeigen
// Räumlichkeit und Medium-Felder nur bei Abgang zeigen
$('#conn-location-fields').toggle(direction === 'output');
$('#conn-output-fields').toggle(direction === 'output');
// Bundle-Option: Nur bei Abgang + Equipment mit mehr als 1 Terminal
@ -2745,6 +2991,7 @@
const connectionType = $('#conn-type').val() || '';
const color = $('#conn-color').val() || '#3498db';
const outputLabel = $('#conn-label').val().trim();
const outputLocation = $('#conn-location').val().trim();
const isOutput = App.connectionDirection === 'output';
const mediumType = isOutput ? ($('#conn-medium-type').val().trim() || '') : '';
const mediumSpec = isOutput ? ($('#conn-medium-spec').val().trim() || '') : '';
@ -2773,6 +3020,7 @@
connection_type: connectionType,
color: color,
output_label: outputLabel,
output_location: outputLocation,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
@ -2786,6 +3034,7 @@
conn.connection_type = connectionType;
conn.color = color;
conn.output_label = outputLabel;
conn.output_location = outputLocation;
conn.medium_type = mediumType;
conn.medium_spec = mediumSpec;
conn.medium_length = mediumLength;
@ -2839,6 +3088,7 @@
connection_type: connectionType,
color: color,
output_label: outputLabel,
output_location: outputLocation,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,
@ -2852,6 +3102,7 @@
connection_type: connectionType,
color: color,
output_label: outputLabel,
output_location: outputLocation,
medium_type: mediumType,
medium_spec: mediumSpec,
medium_length: mediumLength,

File diff suppressed because it is too large Load diff

0
manifest.json Normal file → Executable file
View file

37
pwa.php Normal file → Executable file
View file

@ -44,8 +44,37 @@ $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?v=5.3">
<link rel="stylesheet" href="css/pwa.css?v=5.9">
<style>:root { --primary: <?php echo $themeColor; ?>; }</style>
<script>
// Einmaliger Cache-Reset (v12.4) — löscht alte Service Worker + Caches + Editor-Daten
(function() {
var REQUIRED_VERSION = 'v12.4';
if (localStorage.getItem('sw_version') !== REQUIRED_VERSION) {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(function(regs) {
regs.forEach(function(r) { r.unregister(); });
});
}
if ('caches' in window) {
caches.keys().then(function(names) {
names.forEach(function(n) { caches.delete(n); });
});
}
// Gecachte Editor-Daten löschen (kundenkarte_data_*)
var keysToRemove = [];
for (var i = 0; i < localStorage.length; i++) {
var key = localStorage.key(i);
if (key && key.indexOf('kundenkarte_data_') === 0) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(function(k) { localStorage.removeItem(k); });
localStorage.setItem('sw_version', REQUIRED_VERSION);
setTimeout(function() { location.reload(true); }, 500);
}
})();
</script>
</head>
<body>
<div id="app" class="app">
@ -246,6 +275,10 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<label>Bezeichnung</label>
<input type="text" id="conn-label" class="form-input" placeholder="z.B. Küche Steckdosen">
</div>
<div id="conn-location-fields" class="form-group">
<label>Räumlichkeit</label>
<input type="text" id="conn-location" class="form-input" placeholder="z.B. Küche, Bad OG, Keller">
</div>
<!-- Anschlussseite: Immer sichtbar (Automaten haben keine feste Richtung) -->
<div id="conn-side-fields" class="form-group">
<label>Anschlussseite</label>
@ -377,6 +410,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?v=5.1"></script>
<script src="js/pwa.js?v=5.8"></script>
</body>
</html>

0
pwa_auth.php Normal file → Executable file
View file

4
sw.js Normal file → Executable file
View file

@ -3,8 +3,8 @@
* Offline-First für Schaltschrank-Dokumentation
*/
const CACHE_NAME = 'kundenkarte-pwa-v11.7';
const OFFLINE_CACHE = 'kundenkarte-offline-v11.7';
const CACHE_NAME = 'kundenkarte-pwa-v12.4';
const OFFLINE_CACHE = 'kundenkarte-offline-v12.4';
// Statische Assets die immer gecached werden (ohne Query-String)
const STATIC_ASSETS = [

View file

@ -615,7 +615,7 @@ if (empty($customerSystems)) {
foreach ($typeFieldsList as $field) {
if ($field->field_type === 'header') {
// Section header
print '<tr class="liste_titre"><th colspan="2" style="background:#f0f0f0;padding:8px;">'.dol_escape_htmltag($field->field_label).'</th></tr>';
print '<tr class="liste_titre"><th colspan="2" style="padding:8px;">'.dol_escape_htmltag($field->field_label).'</th></tr>';
} else {
$value = isset($fieldValues[$field->field_code]) ? $fieldValues[$field->field_code] : '';
if ($value !== '') {

View file

@ -613,7 +613,7 @@ if (empty($customerSystems)) {
foreach ($typeFieldsList as $field) {
if ($field->field_type === 'header') {
// Section header
print '<tr class="liste_titre"><th colspan="2" style="background:#f0f0f0;padding:8px;">'.dol_escape_htmltag($field->field_label).'</th></tr>';
print '<tr class="liste_titre"><th colspan="2" style="padding:8px;">'.dol_escape_htmltag($field->field_label).'</th></tr>';
} else {
$value = isset($fieldValues[$field->field_code]) ? $fieldValues[$field->field_code] : '';
if ($value !== '') {

2
werkzeuge.php Normal file → Executable file
View file

@ -380,7 +380,7 @@ if (in_array($action, array('create', 'edit', 'view'))) {
$typeFieldsList = $type->fetchFields();
foreach ($typeFieldsList as $field) {
if ($field->field_type === 'header') {
print '<tr class="liste_titre"><th colspan="2" style="background:#f0f0f0;padding:8px;">'.dol_escape_htmltag($field->field_label).'</th></tr>';
print '<tr class="liste_titre"><th colspan="2" style="padding:8px;">'.dol_escape_htmltag($field->field_label).'</th></tr>';
} else {
$value = isset($fieldValues[$field->field_code]) ? $fieldValues[$field->field_code] : '';
if ($value !== '') {