feat(pwa): FI-Schutzgruppen, gebündelte Terminals, Terminal-Konfiguration

- Schutzgruppen-Zuordnung: Equipment kann FI/RCD zugeordnet werden
  - Farbliche Markierung der Schutzgruppen im Schaltplan
  - Dropdown zur Auswahl des Schutzgeräts im Equipment-Dialog
- Gebündelte Terminals: Multi-Phasen-Abgänge (E-Herd, Durchlauferhitzer)
  - "Alle bündeln" Option im Abgang-Dialog
  - Zentriertes Label über alle Terminals des Equipment
- Terminal-Anzahl aus terminals_config statt TE-Breite
  - Neozed 3F zeigt korrekt 3 statt 4 Terminals
  - Neue getTerminalCount() Hilfsfunktion
- Zuletzt bearbeitete Kunden (max. 5) auf Search-Screen
- Medium-Typen dynamisch aus DB mit Spezifikationen-Dropdown
- Terminal-Labels anklickbar zum direkten Bearbeiten
- Kontextmenü für leere Terminals (Input/Output Auswahl)
- Block-Label mit Einheiten (40A 30mA statt 40A30mA)
- Online-Status-Anzeige entfernt (funktionierte nicht zuverlässig)
- Service Worker v5.2: Versionierte Assets nicht cachen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-02 14:34:54 +01:00
parent 241229659b
commit 619d14e8d5
9 changed files with 1389 additions and 126 deletions

View file

@ -403,7 +403,8 @@ print '<br><br>';
print '<div class="titre inline-block">'.$langs->trans("PWAMobileApp").'</div>';
print '<br><br>';
$pwaUrl = dol_buildpath('/kundenkarte/pwa.php', 2);
// PWA URL mit vollständigem Pfad (ohne dol_buildpath wegen URL-Problemen)
$pwaUrl = DOL_URL_ROOT.'/custom/kundenkarte/pwa.php';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("PWAMobileApp").'</td>';

View file

@ -71,6 +71,9 @@ if (!$user->hasRight('kundenkarte', 'read')) {
exit;
}
// Load language file for labels
$langs->loadLangs(array('kundenkarte@kundenkarte'));
// Load required classes
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/anlage.class.php';
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentpanel.class.php';
@ -274,7 +277,8 @@ switch ($action) {
'width_te' => $eq->width_te,
'block_label' => $eq->getBlockLabel(),
'block_color' => $eq->getBlockColor(),
'field_values' => $eq->getFieldValues()
'field_values' => $eq->getFieldValues(),
'fk_protection' => $eq->fk_protection > 0 ? (int) $eq->fk_protection : null
);
}
}
@ -287,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";
$sql = "SELECT rowid, fk_source, output_label, 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";
@ -346,6 +350,7 @@ switch ($action) {
'connection_type' => $obj->connection_type,
'color' => $obj->color,
'source_terminal_id' => $obj->source_terminal_id ?: '',
'bundled_terminals' => $obj->bundled_terminals ?: '',
'is_top' => $isTop
);
}
@ -374,6 +379,35 @@ switch ($action) {
}
}
// Verbindungen zwischen Equipment laden (mit path_data für 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 .= " 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 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
);
}
}
}
}
// Equipment-Typen für Response aufbereiten (bereits oben geladen)
$typesData = array();
foreach ($types as $t) {
@ -384,7 +418,8 @@ switch ($action) {
'label_short' => $t->label_short,
'width_te' => $t->width_te,
'color' => $t->color,
'category' => $t->category
'category' => $t->category,
'terminals_config' => $t->terminals_config ?: null
);
}
@ -422,6 +457,7 @@ switch ($action) {
$response['equipment'] = $equipmentData;
$response['outputs'] = $outputsData;
$response['inputs'] = $inputsData;
$response['connections'] = $connectionsData;
$response['types'] = $typesData;
$response['field_meta'] = $fieldMetaData;
break;
@ -560,6 +596,32 @@ switch ($action) {
}
break;
// ============================================
// GET PROTECTION DEVICES (FI/RCD für Anlage)
// ============================================
case 'get_protection_devices':
$anlageId = GETPOSTINT('anlage_id');
if ($anlageId <= 0) {
$response['error'] = 'Keine Anlage-ID';
break;
}
$equipment = new Equipment($db);
$devices = $equipment->fetchProtectionDevices($anlageId);
$result = array();
foreach ($devices as $d) {
$result[] = array(
'id' => $d->id,
'label' => $d->label ?: $d->type_label,
'type_label' => $d->type_label,
'type_label_short' => $d->type_label_short,
'display_label' => ($d->label ?: $d->type_label_short ?: $d->type_label).' (Pos. '.$d->position_te.')'
);
}
$response['success'] = true;
$response['devices'] = $result;
break;
// ============================================
// CREATE EQUIPMENT
// ============================================
@ -574,6 +636,7 @@ switch ($action) {
$label = GETPOST('label', 'alphanohtml');
$positionTe = GETPOSTINT('position_te') ?: 1;
$fieldValues = GETPOST('field_values', 'nohtml');
$fkProtection = GETPOSTINT('fk_protection');
if ($carrierId <= 0 || $typeId <= 0) {
$response['error'] = 'Carrier-ID und Typ-ID erforderlich';
@ -591,6 +654,7 @@ switch ($action) {
$equipment->position_te = $positionTe;
$equipment->width_te = $eqType->width_te ?: 1;
$equipment->field_values = $fieldValues;
$equipment->fk_protection = $fkProtection > 0 ? $fkProtection : null;
// Bezeichnung automatisch generieren wenn leer (wie Website)
if (empty(trim($equipment->label ?? ''))) {
@ -678,6 +742,7 @@ switch ($action) {
$equipmentId = GETPOSTINT('equipment_id');
$label = GETPOST('label', 'alphanohtml');
$fieldValues = GETPOST('field_values', 'nohtml');
$fkProtection = GETPOSTINT('fk_protection');
if ($equipmentId <= 0) {
$response['error'] = 'Keine Equipment-ID';
@ -692,6 +757,7 @@ switch ($action) {
$equipment->label = $label;
$equipment->field_values = $fieldValues;
$equipment->fk_protection = $fkProtection > 0 ? $fkProtection : null;
$result = $equipment->update($user);
if ($result > 0) {
@ -752,6 +818,8 @@ switch ($action) {
$mediumLength = GETPOST('medium_length', 'alphanohtml');
$sourceTerminal = GETPOST('source_terminal', 'alphanohtml') ?: 'output';
$sourceTerminalId = GETPOST('source_terminal_id', 'alphanohtml');
$targetTerminalId = GETPOST('target_terminal_id', 'alphanohtml');
$bundledTerminals = GETPOST('bundled_terminals', 'alphanohtml'); // 'all' oder '0,1,2'
if ($equipmentId <= 0) {
$response['error'] = 'Keine Equipment-ID';
@ -773,15 +841,19 @@ switch ($action) {
if ($direction === 'input') {
// Einspeisung: fk_source = NULL, fk_target = Equipment
// Terminal-Position: t1=oben, t2=unten (Sicherungsautomaten haben keine feste Richtung!)
$conn->fk_target = $equipmentId;
$conn->fk_source = null;
$conn->target_terminal = 'input';
$conn->target_terminal_id = $targetTerminalId ?: 't2'; // Default: unten
} else {
// Abgang: fk_source = Equipment, fk_target = NULL
// Terminal-Position: t1=oben, t2=unten (Sicherungsautomaten haben keine feste Richtung!)
$conn->fk_source = $equipmentId;
$conn->fk_target = null;
$conn->source_terminal = $sourceTerminal;
$conn->source_terminal_id = $sourceTerminalId ?: ($sourceTerminal === 'top' ? 't1' : 't2');
$conn->bundled_terminals = $bundledTerminals ?: null;
$conn->medium_type = $mediumType;
$conn->medium_spec = $mediumSpec;
$conn->medium_length = $mediumLength;
@ -829,6 +901,9 @@ switch ($action) {
if (GETPOSTISSET('source_terminal_id')) {
$conn->source_terminal_id = GETPOST('source_terminal_id', 'alphanohtml') ?: $conn->source_terminal_id;
}
if (GETPOSTISSET('bundled_terminals')) {
$conn->bundled_terminals = GETPOST('bundled_terminals', 'alphanohtml') ?: null;
}
$result = $conn->update($user);
if ($result > 0) {

View file

@ -508,7 +508,14 @@ class Equipment extends CommonObject
foreach ($blockFields as $field) {
if (isset($values[$field->field_code]) && $values[$field->field_code] !== '') {
$parts[] = $values[$field->field_code];
$val = $values[$field->field_code];
// Einheit hinzufügen für bekannte Felder (wie in Website JS kundenkarte.js:6613-6617)
if ($field->field_code === 'ampere') {
$val = $val . 'A';
} elseif ($field->field_code === 'sensitivity') {
$val = $val . 'mA';
}
$parts[] = $val;
}
}
@ -516,7 +523,8 @@ class Equipment extends CommonObject
return $this->type_label_short ?: '';
}
return implode('', $parts);
// Mit Leerzeichen verbinden für bessere Lesbarkeit (z.B. "40A 30mA" statt "40A30mA")
return implode(' ', $parts);
}
/**

View file

@ -19,6 +19,7 @@ class EquipmentConnection extends CommonObject
public $fk_source;
public $source_terminal = 'output';
public $source_terminal_id;
public $bundled_terminals; // 'all' = alle Terminals belegt, '0,1,2' = spezifische Indizes, NULL = einzeln
public $fk_target;
public $target_terminal = 'input';
public $target_terminal_id;
@ -86,7 +87,7 @@ class EquipmentConnection extends CommonObject
$this->db->begin();
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "entity, fk_source, source_terminal, source_terminal_id, fk_target, target_terminal, target_terminal_id,";
$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 .= " medium_type, medium_spec, medium_length,";
$sql .= " is_rail, rail_start_te, rail_end_te, rail_phases, excluded_te, fk_carrier, position_y, path_data,";
@ -96,6 +97,7 @@ class EquipmentConnection extends CommonObject
$sql .= ", ".($this->fk_source > 0 ? ((int) $this->fk_source) : "NULL");
$sql .= ", '".$this->db->escape($this->source_terminal ?: 'output')."'";
$sql .= ", ".($this->source_terminal_id ? "'".$this->db->escape($this->source_terminal_id)."'" : "NULL");
$sql .= ", ".($this->bundled_terminals ? "'".$this->db->escape($this->bundled_terminals)."'" : "NULL");
$sql .= ", ".($this->fk_target > 0 ? ((int) $this->fk_target) : "NULL");
$sql .= ", '".$this->db->escape($this->target_terminal ?: 'input')."'";
$sql .= ", ".($this->target_terminal_id ? "'".$this->db->escape($this->target_terminal_id)."'" : "NULL");
@ -164,6 +166,7 @@ class EquipmentConnection extends CommonObject
$this->fk_source = $obj->fk_source;
$this->source_terminal = $obj->source_terminal;
$this->source_terminal_id = $obj->source_terminal_id;
$this->bundled_terminals = isset($obj->bundled_terminals) ? $obj->bundled_terminals : null;
$this->fk_target = $obj->fk_target;
$this->target_terminal = $obj->target_terminal;
$this->target_terminal_id = $obj->target_terminal_id;
@ -219,6 +222,7 @@ class EquipmentConnection extends CommonObject
$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 .= ", bundled_terminals = ".($this->bundled_terminals ? "'".$this->db->escape($this->bundled_terminals)."'" : "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");
@ -315,6 +319,7 @@ class EquipmentConnection extends CommonObject
$conn->fk_source = $obj->fk_source;
$conn->source_terminal = $obj->source_terminal;
$conn->source_terminal_id = $obj->source_terminal_id;
$conn->bundled_terminals = isset($obj->bundled_terminals) ? $obj->bundled_terminals : null;
$conn->fk_target = $obj->fk_target;
$conn->target_terminal = $obj->target_terminal;
$conn->target_terminal_id = $obj->target_terminal_id;

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 = '6.1';
$this->version = '7.5';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -621,6 +621,12 @@ class modKundenKarte extends DolibarrModules
// v5.2.0: Halbe TE-Breiten (4.5 TE für Neozed etc.)
$this->migrate_v520_decimal_te();
// v6.8.0: Gebündelte Terminals für Multi-Phasen-Abgänge
$this->migrate_v680_bundled_terminals();
// v6.8.0: Schutzgruppen-Zuordnung (fk_protection)
$this->migrate_v680_protection_groups();
}
/**
@ -829,6 +835,61 @@ class modKundenKarte extends DolibarrModules
}
}
/**
* Migration v6.8.0: Gebündelte Terminals für Multi-Phasen-Abgänge
* Ermöglicht einen Abgang der alle Terminals eines breiten Equipment belegt
* z.B. 3-Phasen B16 Automat mit einem E-Herd-Abgang
*/
private function migrate_v680_bundled_terminals()
{
$table = MAIN_DB_PREFIX."kundenkarte_equipment_connection";
// Prüfen ob Tabelle existiert
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Prüfen ob Spalte bereits existiert
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'bundled_terminals'");
if ($resql && $this->db->num_rows($resql) > 0) {
return;
}
// Spalte hinzufügen: 'all' = alle Terminals, '0,1,2' = spezifische Indizes, NULL = einzeln
$this->db->query("ALTER TABLE ".$table." ADD COLUMN bundled_terminals varchar(50) DEFAULT NULL AFTER source_terminal_id");
}
/**
* Migration v6.8.0: Schutzgruppen-Zuordnung für Equipment
* Ermöglicht Zuordnung von Equipment zu einem Schutzgerät (FI/RCD)
* fk_protection = ID des schützenden Equipment
* protection_label = Optionales Label für die Gruppe
*/
private function migrate_v680_protection_groups()
{
$table = MAIN_DB_PREFIX."kundenkarte_equipment";
// Prüfen ob Tabelle existiert
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// fk_protection Spalte
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'fk_protection'");
if (!$resql || $this->db->num_rows($resql) == 0) {
$this->db->query("ALTER TABLE ".$table." ADD COLUMN fk_protection integer DEFAULT NULL AFTER fk_product");
$this->db->query("ALTER TABLE ".$table." ADD INDEX idx_equipment_protection (fk_protection)");
}
// protection_label Spalte
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'protection_label'");
if (!$resql || $this->db->num_rows($resql) == 0) {
$this->db->query("ALTER TABLE ".$table." ADD COLUMN protection_label varchar(64) DEFAULT NULL AFTER fk_protection");
}
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.

View file

@ -360,6 +360,56 @@ body {
opacity: 0.6;
}
/* ============================================
ZULETZT BEARBEITET
============================================ */
.recent-section {
padding: 0 16px 16px;
}
.recent-title {
font-size: 13px;
font-weight: 600;
color: var(--colortextmuted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 16px 0 10px;
padding-left: 4px;
}
.recent-section .list {
padding: 0;
flex: none;
overflow: visible;
}
.recent-section .list-item {
padding: 12px 14px;
}
.recent-section .list-item-icon {
width: 38px;
height: 38px;
}
.recent-section .list-item-icon svg {
width: 20px;
height: 20px;
}
.recent-section .list-item-title {
font-size: 14px;
}
.recent-section .list-item-subtitle {
font-size: 11px;
}
#recent-customers.hidden {
display: none;
}
/* ============================================
LISTS
============================================ */
@ -810,27 +860,29 @@ body {
}
.equipment-block-type {
font-size: 9px;
font-size: 7px;
font-weight: bold;
color: rgba(255,255,255,0.8);
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.equipment-block-value {
font-size: 11px;
font-weight: bold;
color: #fff;
line-height: 1.1;
}
.equipment-block-value {
font-size: 13px;
font-weight: bold;
color: #fff;
line-height: 1.2;
}
.equipment-block-label {
font-size: 8px;
color: rgba(255,255,255,0.7);
font-size: 7px;
color: rgba(255,255,255,0.6);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
line-height: 1;
}
/* Equipment Block Text (einzelner Block-Label wie "B16") */
@ -915,28 +967,169 @@ body {
line-height: 1;
}
/* Abgang-Label Zelle (Zeile 1 und 5) */
.terminal-label-cell {
display: flex;
justify-content: center;
min-height: 20px;
}
/* Obere Labels (Zeile 1): am unteren Rand ausrichten (zum Terminal hin) */
.terminal-label-cell.label-row-top {
align-items: flex-end;
}
/* Untere Labels (Zeile 5): am oberen Rand ausrichten (zum Terminal hin) */
.terminal-label-cell.label-row-bottom {
align-items: flex-start;
}
.terminal-label-cell.empty {
min-height: 4px;
}
/* Abgang-Label (vertikal) */
.terminal-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 9px;
font-weight: bold;
font-weight: 600;
color: #fff;
line-height: 1.1;
max-height: 90px;
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 0;
padding: 2px 4px;
background: rgba(0,0,0,0.3);
border-radius: 3px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
/* Anklickbare Labels */
.terminal-label-cell:not(.empty) {
cursor: pointer;
transition: transform 0.15s;
}
.terminal-label-cell:not(.empty):active {
transform: scale(0.95);
}
.terminal-label-cell:not(.empty):active .terminal-label {
background: rgba(173, 140, 79, 0.4);
}
.terminal-label .cable-info {
font-weight: normal;
font-size: 8px;
color: #888;
font-size: 7px;
color: rgba(255,255,255,0.6);
display: block;
}
/* Output-Zeile braucht mehr Platz wenn Labels vorhanden */
/* ============================================
GEBÜNDELTE TERMINALS (Multi-Phasen-Abgänge)
============================================ */
/* Gebündeltes Label: Zentriert über alle Spalten */
.terminal-label-cell.bundled-label {
display: flex;
justify-content: center;
align-items: center;
}
/* Gebündeltes Label mit Pfeil */
.terminal-label-cell.bundled-with-arrow {
cursor: pointer;
}
.bundled-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
/* Oben: Label zuerst, Pfeil unten (zeigt zum Automaten) */
.label-row-top .bundled-content {
flex-direction: column;
}
/* Unten: Pfeil oben (zeigt zum Automaten), Label unten */
.bundled-content-bottom {
flex-direction: column;
}
.bundled-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
}
.bundled-arrow .terminal-phase {
font-size: 8px;
font-weight: bold;
color: rgba(255,255,255,0.7);
}
/* Platzhalter für gebündelte Terminals (keine Pfeile mehr in Zeile 2/4) */
.terminal-point.bundled-placeholder {
min-height: 8px;
}
/* Hauptterminal bei Bündelung */
.terminal-point.bundled-main {
position: relative;
z-index: 2;
}
/* Rand-Terminals bei Bündelung: Gedimmt */
.terminal-point.bundled-edge {
opacity: 0.6;
pointer-events: none;
}
/* Terminal-Connector wird nicht mehr angezeigt */
.terminal-connector {
display: none;
}
/* ============================================
VERBINDUNGSLINIEN (SVG Overlay)
============================================ */
.connection-lines-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
overflow: visible;
}
.connection-shadow {
fill: none;
stroke: rgba(0, 0, 0, 0.3);
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
}
.connection-line {
fill: none;
stroke: var(--colortextmuted);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Grid-Rows: 1=Labels oben, 2=Terminals oben, 3=Equipment, 4=Terminals unten, 5=Labels unten */
/* Add Button in Carrier (letzte Spalte, Zeile 2) */
.btn-add-equipment {
display: flex;
@ -1179,6 +1372,25 @@ body {
margin-bottom: 14px;
}
/* Protection Section (FI/RCD-Zuordnung) */
.protection-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--colorborder);
}
.protection-section label {
display: flex;
align-items: center;
gap: 8px;
}
.protection-section .icon-small {
width: 16px;
height: 16px;
fill: var(--butactionbg);
}
#eq-dynamic-fields .form-select {
width: 100%;
padding: 12px;
@ -1228,6 +1440,12 @@ body {
height: 20px;
}
.hint-text {
font-size: 12px;
color: var(--colortextmuted);
margin-top: 4px;
}
.step-label {
font-size: 14px;
color: var(--colortextmuted);
@ -1349,11 +1567,9 @@ body {
.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="LN"].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 */
@ -1784,3 +2000,55 @@ body {
min-height: 44px;
}
}
/* ============================================
TERMINAL KONTEXTMENÜ
============================================ */
.terminal-context-menu {
background: var(--colorbacktitle);
border: 1px solid var(--colorborder);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
overflow: hidden;
min-width: 200px;
}
.tcm-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
color: var(--colortext);
transition: background 0.15s;
}
.tcm-item:not(:last-child) {
border-bottom: 1px solid var(--colorborder);
}
.tcm-item:hover,
.tcm-item:active {
background: rgba(255, 255, 255, 0.1);
}
.tcm-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
/* Leere Terminals - neutral */
.terminal-empty {
cursor: pointer;
}
.terminal-dot-empty {
width: 10px;
height: 10px;
border-radius: 50%;
background: transparent;
border: 2px dashed rgba(255, 255, 255, 0.25);
}

943
js/pwa.js

File diff suppressed because it is too large Load diff

50
pwa.php
View file

@ -44,7 +44,7 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="img/pwa-icon-192.png">
<link rel="apple-touch-icon" href="img/pwa-icon-192.png">
<link rel="stylesheet" href="css/pwa.css?v=2.9">
<link rel="stylesheet" href="css/pwa.css?v=4.4">
<style>:root { --primary: <?php echo $themeColor; ?>; }</style>
</head>
<body>
@ -98,8 +98,12 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<!-- Kunden werden hier geladen -->
</div>
<div id="offline-indicator" class="offline-bar hidden">
Offline-Modus
<!-- Zuletzt bearbeitete Kunden -->
<div id="recent-customers" class="recent-section">
<h3 class="recent-title">Zuletzt bearbeitet</h3>
<div id="recent-list" class="list">
<!-- Wird per JS gefüllt -->
</div>
</div>
</div>
@ -177,6 +181,15 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#3498db');
<label>Bezeichnung</label>
<input type="text" id="equipment-label" placeholder="Leer = automatisch (z.B. R1.3)">
</div>
<!-- FI/RCD-Schutz Zuordnung -->
<div id="eq-protection-fields" class="protection-section">
<div class="form-group">
<label><svg viewBox="0 0 24 24" class="icon-small"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg> Schutzgerät (FI/RCD)</label>
<select id="equipment-protection" class="form-select">
<option value="">-- Keins --</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-equipment" class="btn btn-danger hidden">Löschen</button>
@ -230,21 +243,36 @@ $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-output-fields">
<div class="form-group">
<label>Abgangsseite</label>
<!-- Anschlussseite: Immer sichtbar (Automaten haben keine feste Richtung) -->
<div id="conn-side-fields" class="form-group">
<label>Anschlussseite</label>
<div id="conn-side-grid" class="side-grid">
<button type="button" class="side-btn selected" data-side="bottom">&#9660; Unten</button>
<button type="button" class="side-btn" data-side="top">&#9650; Oben</button>
</div>
</div>
<!-- Bundle-Option: Nur bei Abgängen + breitem Equipment sichtbar -->
<div id="conn-bundle-fields" class="form-group hidden">
<label class="checkbox-label">
<input type="checkbox" id="conn-bundle-all">
<span>Alle Terminals belegen (Drehstrom-Verbraucher)</span>
</label>
<p class="hint-text">Für E-Herd, Durchlauferhitzer u.ä. ein Abgang belegt alle Klemmen</p>
</div>
<!-- Medium-Felder: Nur bei Abgängen sichtbar -->
<div id="conn-output-fields">
<div class="form-group">
<label>Medium</label>
<input type="text" id="conn-medium-type" class="form-input" placeholder="z.B. NYM-J, CAT6">
<label>Kabeltyp</label>
<select id="conn-medium-type" class="form-select">
<option value="">-- Auswählen --</option>
<!-- Wird per JS gefüllt -->
</select>
</div>
<div class="form-group">
<label>Spezifikation</label>
<input type="text" id="conn-medium-spec" class="form-input" placeholder="z.B. 3x1,5mm²">
<label>Querschnitt</label>
<select id="conn-medium-spec" class="form-select">
<option value="">-- Zuerst Kabeltyp wählen --</option>
</select>
</div>
<div class="form-group">
<label>Länge</label>
@ -346,6 +374,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=2.9"></script>
<script src="js/pwa.js?v=4.4"></script>
</body>
</html>

20
sw.js
View file

@ -3,19 +3,20 @@
* Offline-First für Schaltschrank-Dokumentation
*/
const CACHE_NAME = 'kundenkarte-pwa-v2.9';
const OFFLINE_CACHE = 'kundenkarte-offline-v2.9';
const CACHE_NAME = 'kundenkarte-pwa-v5.2';
const OFFLINE_CACHE = 'kundenkarte-offline-v5.2';
// Statische Assets die immer gecached werden
// Statische Assets die immer gecached werden (ohne Query-String)
const STATIC_ASSETS = [
'pwa.php',
'css/pwa.css',
'js/pwa.js',
'img/pwa-icon-192.png',
'img/pwa-icon-512.png',
'../../../includes/jquery/js/jquery.min.js'
];
// Assets mit Versions-Query-String - NICHT cachen, immer vom Netzwerk laden
const VERSIONED_ASSETS = ['pwa.css', 'pwa.js'];
// Install - Cache statische Assets
self.addEventListener('install', event => {
console.log('[SW] Installing...');
@ -55,6 +56,15 @@ self.addEventListener('fetch', event => {
return;
}
// Versionierte Assets (CSS/JS mit ?v=X) - IMMER Netzwerk, kein Cache
// Damit neue Versionen sofort geladen werden
if (url.search && VERSIONED_ASSETS.some(a => url.pathname.includes(a))) {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
// AJAX Requests - Netzwerk mit Offline-Fallback
if (url.pathname.includes('/ajax/')) {
event.respondWith(