feat: Dezimal-TE, Equipment-Kategorien, Schaltplan-Fixes

- Dezimal-TE (0.1 Schritte): DB DECIMAL(4,1), JS parseFloat statt parseInt,
  Drag&Drop mit 0.1-Snap, SVG-Markierungen (ganzzahlig deutlich, 0.5er subtil)
- Equipment-Kategorien: automat/schutz/steuerung/klemme im Typ-Editor und Dialog
- Hutschiene löschen Fix: showConfirmDialog → KundenKarte.showConfirm() (3 Stellen)
- terminals_config JSON-Sanitizer: PHP beim Speichern + JS-Fallback
  (Dolibarr GETPOST konvertiert Newlines zu literal \r\n → ungültiges JSON)
- Equipment duplizieren: Label-Inkrement, Feldwerte werden mitkopiert
- Statusleiste Größen-Sprung behoben (min-height statt dynamisch)
- Duplikat-Docblock in equipmentcarrier.class.php bereinigt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-26 14:04:17 +01:00
parent dcd00fe844
commit 20fb9d3b05
12 changed files with 414 additions and 187 deletions

View file

@ -69,10 +69,19 @@ if ($action == 'add') {
$equipmentType->label_short = GETPOST('label_short', 'alphanohtml');
$equipmentType->description = GETPOST('description', 'restricthtml');
$equipmentType->fk_system = GETPOSTINT('fk_system');
$equipmentType->width_te = GETPOSTINT('width_te');
$equipmentType->category = GETPOST('category', 'aZ09') ?: 'steuerung';
$equipmentType->width_te = floatval(GETPOST('width_te', 'alpha'));
$equipmentType->color = GETPOST('color', 'alphanohtml');
// fk_product removed - products are selected per equipment in editor
$equipmentType->terminals_config = GETPOST('terminals_config', 'nohtml');
// JSON bereinigen: literale \r\n entfernen, dann parsen+re-encodieren für sauberes JSON
$rawConfig = GETPOST('terminals_config', 'nohtml');
if (!empty($rawConfig)) {
$rawConfig = str_replace(array("\\r\\n", "\\r", "\\n", "\\t"), array("\n", "", "\n", "\t"), $rawConfig);
$decoded = json_decode($rawConfig);
$equipmentType->terminals_config = ($decoded !== null) ? json_encode($decoded) : $rawConfig;
} else {
$equipmentType->terminals_config = '';
}
$equipmentType->flow_direction = GETPOST('flow_direction', 'alphanohtml');
$equipmentType->terminal_position = GETPOST('terminal_position', 'alphanohtml') ?: 'both';
$equipmentType->picto = GETPOST('picto', 'alphanohtml');
@ -118,10 +127,19 @@ if ($action == 'update') {
$equipmentType->label_short = GETPOST('label_short', 'alphanohtml');
$equipmentType->description = GETPOST('description', 'restricthtml');
$equipmentType->fk_system = GETPOSTINT('fk_system');
$equipmentType->width_te = GETPOSTINT('width_te');
$equipmentType->category = GETPOST('category', 'aZ09') ?: 'steuerung';
$equipmentType->width_te = floatval(GETPOST('width_te', 'alpha'));
$equipmentType->color = GETPOST('color', 'alphanohtml');
// fk_product removed - products are selected per equipment in editor
$equipmentType->terminals_config = GETPOST('terminals_config', 'nohtml');
// JSON bereinigen: literale \r\n entfernen, dann parsen+re-encodieren für sauberes JSON
$rawConfig = GETPOST('terminals_config', 'nohtml');
if (!empty($rawConfig)) {
$rawConfig = str_replace(array("\\r\\n", "\\r", "\\n", "\\t"), array("\n", "", "\n", "\t"), $rawConfig);
$decoded = json_decode($rawConfig);
$equipmentType->terminals_config = ($decoded !== null) ? json_encode($decoded) : $rawConfig;
} else {
$equipmentType->terminals_config = '';
}
$equipmentType->flow_direction = GETPOST('flow_direction', 'alphanohtml');
$equipmentType->terminal_position = GETPOST('terminal_position', 'alphanohtml') ?: 'both';
$equipmentType->picto = GETPOST('picto', 'alphanohtml');
@ -171,6 +189,7 @@ if ($action == 'copy' && $typeId > 0) {
$newType->label_short = $sourceType->label_short;
$newType->description = $sourceType->description;
$newType->fk_system = $sourceType->fk_system;
$newType->category = $sourceType->category;
$newType->width_te = $sourceType->width_te;
$newType->color = $sourceType->color;
// fk_product not copied - products are selected per equipment in editor
@ -350,6 +369,23 @@ if (in_array($action, array('create', 'edit'))) {
}
print '</select></td></tr>';
// Category
$categories = array(
'automat' => 'Leitungsschutz',
'schutz' => 'Schutzgeräte',
'steuerung' => 'Steuerung & Sonstiges',
'klemme' => 'Klemmen',
);
print '<tr><td>'.$langs->trans('Category').'</td>';
print '<td><select name="category" class="flat minwidth200">';
foreach ($categories as $catKey => $catLabel) {
$sel = ($equipmentType->category == $catKey) ? ' selected' : '';
print '<option value="'.$catKey.'"'.$sel.'>'.dol_escape_htmltag($catLabel).'</option>';
}
print '</select>';
print ' <span class="opacitymedium">(Gruppierung im Typ-Auswahl-Dialog)</span>';
print '</td></tr>';
// Reference
print '<tr><td class="fieldrequired">'.$langs->trans('TypeRef').'</td>';
print '<td><input type="text" name="ref" class="flat minwidth200" value="'.dol_escape_htmltag($equipmentType->ref).'" maxlength="64" required>';
@ -370,7 +406,7 @@ if (in_array($action, array('create', 'edit'))) {
// Width in TE
print '<tr><td class="fieldrequired">'.$langs->trans('WidthTE').'</td>';
print '<td><input type="number" name="width_te" class="flat" value="'.($equipmentType->width_te ?: 1).'" min="1" max="12" required>';
print '<td><input type="number" name="width_te" class="flat" value="'.($equipmentType->width_te ?: 1).'" min="0.1" max="24" step="0.1" required>';
print ' <span class="opacitymedium">'.$langs->trans('WidthTEHelp').'</span></td></tr>';
// Color
@ -922,6 +958,7 @@ if (in_array($action, array('create', 'edit'))) {
print '<tr class="liste_titre">';
print '<th>'.$langs->trans('TypeRef').'</th>';
print '<th>'.$langs->trans('TypeLabel').'</th>';
print '<th>'.$langs->trans('Category').'</th>';
print '<th>'.$langs->trans('System').'</th>';
print '<th class="center">'.$langs->trans('WidthTE').'</th>';
print '<th class="center">'.$langs->trans('Color').'</th>';
@ -930,6 +967,13 @@ if (in_array($action, array('create', 'edit'))) {
print '<th class="center">'.$langs->trans('Actions').'</th>';
print '</tr>';
$categoryLabels = array(
'automat' => 'Leitungsschutz',
'schutz' => 'Schutzgeräte',
'steuerung' => 'Steuerung & Sonstiges',
'klemme' => 'Klemmen',
);
foreach ($types as $type) {
print '<tr class="oddeven">';
@ -945,6 +989,7 @@ if (in_array($action, array('create', 'edit'))) {
}
print '</td>';
print '<td>'.dol_escape_htmltag($categoryLabels[$type->category] ?? $type->category).'</td>';
print '<td>'.dol_escape_htmltag($type->system_label).'</td>';
print '<td class="center">'.$type->width_te.' TE</td>';
print '<td class="center"><span style="display:inline-block;width:24px;height:24px;background:'.($type->color ?: '#3498db').';border-radius:3px;"></span></td>';
@ -972,7 +1017,7 @@ if (in_array($action, array('create', 'edit'))) {
}
if (empty($types)) {
print '<tr class="oddeven"><td colspan="8" class="opacitymedium">'.$langs->trans('NoRecords').'</td></tr>';
print '<tr class="oddeven"><td colspan="9" class="opacitymedium">'.$langs->trans('NoRecords').'</td></tr>';
}
print '</table>';

View file

@ -107,6 +107,7 @@ switch ($action) {
'ref' => $t->ref,
'label' => $t->label,
'label_short' => $t->label_short,
'category' => $t->category ?: 'steuerung',
'width_te' => $t->width_te,
'color' => $t->color,
'picto' => $t->picto
@ -286,8 +287,8 @@ switch ($action) {
$equipment->fk_carrier = $carrierId;
$equipment->fk_equipment_type = GETPOSTINT('type_id');
$equipment->label = GETPOST('label', 'alphanohtml');
$equipment->position_te = GETPOSTINT('position_te');
$equipment->width_te = GETPOSTINT('width_te');
$equipment->position_te = floatval(GETPOST('position_te', 'alpha'));
$equipment->width_te = floatval(GETPOST('width_te', 'alpha'));
$equipment->fk_product = GETPOSTINT('fk_product');
$equipment->fk_protection = GETPOSTINT('fk_protection');
$equipment->protection_label = GETPOST('protection_label', 'alphanohtml');
@ -372,8 +373,8 @@ switch ($action) {
break;
}
if ($equipment->fetch($equipmentId) > 0) {
$newPosition = GETPOSTINT('position_te');
$newWidth = GETPOSTINT('width_te') ?: $equipment->width_te;
$newPosition = floatval(GETPOST('position_te', 'alpha'));
$newWidth = floatval(GETPOST('width_te', 'alpha')) ?: $equipment->width_te;
// Check if new position is available (excluding current equipment)
if ($newPosition != $equipment->position_te || $newWidth != $equipment->width_te) {
@ -451,7 +452,7 @@ switch ($action) {
break;
}
if ($equipment->fetch($equipmentId) > 0) {
$newPosition = GETPOSTINT('position_te');
$newPosition = floatval(GETPOST('position_te', 'alpha'));
// Check if new position is available
$carrier = new EquipmentCarrier($db);
@ -500,7 +501,7 @@ switch ($action) {
}
if ($equipment->fetch($equipmentId) > 0) {
$newCarrierId = GETPOSTINT('carrier_id');
$newPosition = GETPOSTINT('position_te') ?: 1;
$newPosition = floatval(GETPOST('position_te', 'alpha')) ?: 1;
// Get old carrier for label pattern check
$oldCarrier = new EquipmentCarrier($db);
@ -608,6 +609,30 @@ switch ($action) {
// Fetch the new equipment to return its data
$newEquipment = new Equipment($db);
if ($newEquipment->fetch($newId) > 0) {
// Icon-URL berechnen
$iconUrl = '';
if (!empty($newEquipment->type_icon_file)) {
$iconUrl = DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=equipment_icons/'.urlencode($newEquipment->type_icon_file);
}
// Typ-Felder laden
$typeFields = array();
$sqlTf = "SELECT field_code, field_label, show_on_block, show_in_hover";
$sqlTf .= " FROM ".MAIN_DB_PREFIX."kundenkarte_equipment_type_field";
$sqlTf .= " WHERE fk_equipment_type = ".((int) $newEquipment->fk_equipment_type);
$sqlTf .= " AND active = 1 ORDER BY position ASC";
$resTf = $db->query($sqlTf);
if ($resTf) {
while ($objTf = $db->fetch_object($resTf)) {
$typeFields[] = array(
'field_code' => $objTf->field_code,
'field_label' => $objTf->field_label,
'show_on_block' => (int) $objTf->show_on_block,
'show_in_hover' => (int) $objTf->show_in_hover
);
}
}
$response['equipment'] = array(
'id' => $newEquipment->id,
'fk_carrier' => $newEquipment->fk_carrier,
@ -617,14 +642,22 @@ switch ($action) {
'type_color' => $newEquipment->type_color,
'type_ref' => $newEquipment->type_ref,
'type_icon_file' => $newEquipment->type_icon_file,
'type_icon_url' => $iconUrl,
'type_block_image' => $newEquipment->type_block_image,
'type_block_image_url' => !empty($newEquipment->type_block_image) ? DOL_URL_ROOT.'/document.php?modulepart=kundenkarte&file=block_images/'.urlencode($newEquipment->type_block_image) : '',
'type_flow_direction' => $newEquipment->type_flow_direction,
'type_terminal_position' => $newEquipment->type_terminal_position ?: 'both',
'terminals_config' => $newEquipment->terminals_config,
'type_fields' => $typeFields,
'label' => $newEquipment->label,
'position_te' => $newEquipment->position_te,
'width_te' => $newEquipment->width_te,
'block_label' => $newEquipment->getBlockLabel(),
'block_color' => $newEquipment->getBlockColor(),
'field_values' => $newEquipment->getFieldValues(),
'fk_product' => $newEquipment->fk_product
'fk_product' => $newEquipment->fk_product,
'fk_protection' => $newEquipment->fk_protection,
'protection_label' => $newEquipment->protection_label
);
// Audit log
@ -658,7 +691,7 @@ switch ($action) {
break;
}
if ($equipment->fetch($equipmentId) > 0) {
$newPosition = GETPOSTINT('position_te');
$newPosition = floatval(GETPOST('position_te', 'alpha'));
$carrier = new EquipmentCarrier($db);
if ($carrier->fetch($equipment->fk_carrier) > 0) {

View file

@ -68,6 +68,12 @@ switch ($action) {
'type_id' => $eq->fk_equipment_type,
'type_label' => $eq->type_label,
'type_label_short' => $eq->type_label_short,
'type_ref' => $eq->type_ref,
'type_icon_file' => $eq->type_icon_file,
'type_block_image' => $eq->type_block_image,
'type_flow_direction' => $eq->type_flow_direction,
'type_terminal_position' => $eq->type_terminal_position ?: 'both',
'terminals_config' => $eq->terminals_config,
'type_color' => $eq->type_color,
'label' => $eq->label,
'position_te' => $eq->position_te,

View file

@ -99,8 +99,8 @@ class Equipment extends CommonObject
$sql .= ", ".((int) $this->fk_carrier);
$sql .= ", ".((int) $this->fk_equipment_type);
$sql .= ", ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL");
$sql .= ", ".((int) $this->position_te);
$sql .= ", ".((int) $this->width_te);
$sql .= ", ".floatval($this->position_te);
$sql .= ", ".floatval($this->width_te);
$sql .= ", ".($this->field_values ? "'".$this->db->escape($this->field_values)."'" : "NULL");
$sql .= ", ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL");
$sql .= ", ".($this->fk_protection > 0 ? ((int) $this->fk_protection) : "NULL");
@ -215,8 +215,8 @@ class Equipment extends CommonObject
$sql .= " fk_carrier = ".((int) $this->fk_carrier);
$sql .= ", fk_equipment_type = ".((int) $this->fk_equipment_type);
$sql .= ", label = ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL");
$sql .= ", position_te = ".((int) $this->position_te);
$sql .= ", width_te = ".((int) $this->width_te);
$sql .= ", position_te = ".floatval($this->position_te);
$sql .= ", width_te = ".floatval($this->width_te);
$sql .= ", field_values = ".($this->field_values ? "'".$this->db->escape($this->field_values)."'" : "NULL");
$sql .= ", fk_product = ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL");
$sql .= ", fk_protection = ".($this->fk_protection > 0 ? ((int) $this->fk_protection) : "NULL");
@ -393,10 +393,32 @@ class Equipment extends CommonObject
$newEquipment = new Equipment($this->db);
$newEquipment->fk_carrier = $carrierId > 0 ? $carrierId : $this->fk_carrier;
$newEquipment->fk_equipment_type = $this->fk_equipment_type;
$newEquipment->label = $this->label;
$newEquipment->width_te = $this->width_te;
// Label: Nummer weiterzählen wenn vorhanden, sonst beibehalten
if (!empty($this->label) && preg_match('/^(.*?)(\d+)$/', $this->label, $matches)) {
$prefix = $matches[1];
// Höchste Nummer mit gleichem Präfix auf dem Carrier finden
$sql = "SELECT label FROM ".$this->db->prefix()."kundenkarte_equipment";
$sql .= " WHERE fk_carrier = ".(int)$newEquipment->fk_carrier;
$sql .= " AND status = 1 AND label IS NOT NULL AND label != ''";
$resql = $this->db->query($sql);
$maxNum = 0;
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
if (preg_match('/^'.preg_quote($prefix, '/').'(\d+)$/', $obj->label, $m)) {
$maxNum = max($maxNum, (int)$m[1]);
}
}
}
$newEquipment->label = $prefix . ($maxNum + 1);
} else {
$newEquipment->label = $this->label;
}
$newEquipment->field_values = $this->field_values;
$newEquipment->fk_product = $this->fk_product;
$newEquipment->fk_protection = $this->fk_protection;
$newEquipment->protection_label = $this->protection_label;
$newEquipment->note_private = $this->note_private;
$newEquipment->status = 1;

View file

@ -318,41 +318,54 @@ class EquipmentCarrier extends CommonObject
}
/**
* Get array of occupied TE slots
* Belegte TE-Ranges zurückgeben (für Dezimal-Breiten)
*
* @return array Array of occupied slot numbers (0-based)
* @return array Array von [start, end] Ranges
*/
public function getOccupiedSlots()
public function getOccupiedRanges()
{
$occupied = array();
$ranges = array();
if (empty($this->equipment)) {
$this->fetchEquipment();
}
foreach ($this->equipment as $eq) {
for ($i = $eq->position_te; $i < $eq->position_te + $eq->width_te; $i++) {
$occupied[] = $i;
}
$ranges[] = array(
'start' => floatval($eq->position_te),
'end' => floatval($eq->position_te) + floatval($eq->width_te)
);
}
return $occupied;
usort($ranges, function($a, $b) {
return $a['start'] <=> $b['start'];
});
return $ranges;
}
/**
* Get used TE count
* Belegte TE-Summe
*
* @return int Number of used TE
* @return float Belegte TE (kann Dezimal sein, z.B. 10.5)
*/
public function getUsedTE()
{
return count($this->getOccupiedSlots());
if (empty($this->equipment)) {
$this->fetchEquipment();
}
$used = 0;
foreach ($this->equipment as $eq) {
$used += floatval($eq->width_te);
}
return $used;
}
/**
* Get free TE count
* Freie TE
*
* @return int Number of free TE
* @return float Freie TE
*/
public function getFreeTE()
{
@ -360,44 +373,52 @@ class EquipmentCarrier extends CommonObject
}
/**
* Find next free position for given width
* Nächste freie Position finden (unterstützt Dezimal-Breiten)
*
* @param int $width Width in TE needed
* @return int Position (1-based) or -1 if no space
* @param float $width Benötigte Breite in TE
* @return float Position (1-basiert) oder -1 wenn kein Platz
*/
public function getNextFreePosition($width = 1)
{
$occupied = $this->getOccupiedSlots();
$width = floatval($width);
$ranges = $this->getOccupiedRanges();
$maxEnd = floatval($this->total_te) + 1; // Position 1 + total_te = Ende der Schiene
// Positions are 1-based (1 to total_te)
for ($pos = 1; $pos <= $this->total_te - $width + 1; $pos++) {
$fits = true;
for ($i = $pos; $i < $pos + $width; $i++) {
if (in_array($i, $occupied)) {
$fits = false;
break;
}
}
if ($fits) {
$pos = 1.0;
foreach ($ranges as $range) {
// Passt in die Lücke vor diesem Element?
if ($pos + $width <= $range['start'] + 0.001) {
return $pos;
}
// Hinter dieses Element springen
if ($range['end'] > $pos) {
$pos = $range['end'];
}
}
return -1; // No space available
// Platz nach dem letzten Element?
if ($pos + $width <= $maxEnd + 0.001) {
return $pos;
}
return -1;
}
/**
* Check if position is available for given width
* Prüfen ob Position verfügbar ist (unterstützt Dezimal-Breiten)
*
* @param int $position Start position (1-based)
* @param int $width Width in TE
* @param int $excludeEquipmentId Equipment ID to exclude (for updates)
* @return bool True if position is available
* @param float $position Startposition (1-basiert)
* @param float $width Breite in TE
* @param int $excludeEquipmentId Equipment-ID zum Ausschließen (für Updates)
* @return bool True wenn Position frei
*/
public function isPositionAvailable($position, $width, $excludeEquipmentId = 0)
{
// Check bounds (positions are 1-based)
if ($position < 1 || $position + $width - 1 > $this->total_te) {
$position = floatval($position);
$width = floatval($width);
// Grenzen prüfen (Position 1 = erstes TE)
if ($position < 1 || $position + $width > floatval($this->total_te) + 1 + 0.001) {
return false;
}
@ -405,19 +426,19 @@ class EquipmentCarrier extends CommonObject
$this->fetchEquipment();
}
$newEnd = $position + $width;
foreach ($this->equipment as $eq) {
if ($excludeEquipmentId > 0 && $eq->id == $excludeEquipmentId) {
continue;
}
// Check for overlap
$eqStart = $eq->position_te;
$eqEnd = $eq->position_te + $eq->width_te - 1;
$newStart = $position;
$newEnd = $position + $width - 1;
// Overlap-Prüfung mit Half-Open-Ranges: [start, end)
$eqStart = floatval($eq->position_te);
$eqEnd = $eqStart + floatval($eq->width_te);
if ($newStart <= $eqEnd && $newEnd >= $eqStart) {
return false; // Overlap
if ($position < $eqEnd - 0.001 && $eqStart < $newEnd - 0.001) {
return false;
}
}

View file

@ -21,6 +21,7 @@ class EquipmentType extends CommonObject
public $label_short;
public $description;
public $fk_system;
public $category = 'steuerung'; // automat, schutz, steuerung, klemme
// Equipment-spezifische Felder
public $width_te = 1;
@ -78,7 +79,7 @@ class EquipmentType extends CommonObject
$this->db->begin();
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "entity, ref, label, label_short, description, fk_system,";
$sql .= "entity, ref, label, label_short, description, fk_system, category,";
$sql .= " width_te, color, fk_product, terminals_config, flow_direction, terminal_position,";
$sql .= " picto, icon_file, block_image, is_system, position, active,";
$sql .= " date_creation, fk_user_creat";
@ -89,7 +90,8 @@ class EquipmentType extends CommonObject
$sql .= ", ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL");
$sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL");
$sql .= ", ".((int) $this->fk_system);
$sql .= ", ".((int) ($this->width_te > 0 ? $this->width_te : 1));
$sql .= ", '".$this->db->escape($this->category ?: 'steuerung')."'";
$sql .= ", ".floatval($this->width_te > 0 ? $this->width_te : 1);
$sql .= ", ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL");
$sql .= ", ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL");
$sql .= ", ".($this->terminals_config ? "'".$this->db->escape($this->terminals_config)."'" : "NULL");
@ -151,6 +153,7 @@ class EquipmentType extends CommonObject
$this->label_short = $obj->label_short;
$this->description = $obj->description;
$this->fk_system = $obj->fk_system;
$this->category = $obj->category ?: 'steuerung';
$this->width_te = $obj->width_te;
$this->color = $obj->color;
$this->fk_product = $obj->fk_product;
@ -202,7 +205,8 @@ class EquipmentType extends CommonObject
$sql .= ", label_short = ".($this->label_short ? "'".$this->db->escape($this->label_short)."'" : "NULL");
$sql .= ", description = ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL");
$sql .= ", fk_system = ".((int) $this->fk_system);
$sql .= ", width_te = ".((int) ($this->width_te > 0 ? $this->width_te : 1));
$sql .= ", category = '".$this->db->escape($this->category ?: 'steuerung')."'";
$sql .= ", width_te = ".floatval($this->width_te > 0 ? $this->width_te : 1);
$sql .= ", color = ".($this->color ? "'".$this->db->escape($this->color)."'" : "NULL");
$sql .= ", fk_product = ".($this->fk_product > 0 ? ((int) $this->fk_product) : "NULL");
$sql .= ", terminals_config = ".($this->terminals_config ? "'".$this->db->escape($this->terminals_config)."'" : "NULL");
@ -315,6 +319,7 @@ class EquipmentType extends CommonObject
$type->label = $obj->label;
$type->label_short = $obj->label_short;
$type->fk_system = $obj->fk_system;
$type->category = $obj->category ?: 'steuerung';
$type->width_te = $obj->width_te;
$type->color = $obj->color;
$type->fk_product = $obj->fk_product;

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 = '5.2.0';
$this->version = '6.1';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -615,6 +615,12 @@ class modKundenKarte extends DolibarrModules
// v4.1.0: Graph-Positionen speichern
$this->migrate_v410_graph_positions();
// v5.2.0: Equipment-Typ Kategorie
$this->migrate_v520_equipment_type_category();
// v5.2.0: Halbe TE-Breiten (4.5 TE für Neozed etc.)
$this->migrate_v520_decimal_te();
}
/**
@ -760,6 +766,69 @@ class modKundenKarte extends DolibarrModules
$this->db->query("ALTER TABLE ".$table." ADD COLUMN graph_y double DEFAULT NULL AFTER graph_x");
}
/**
* Migration v5.2.0: Equipment-Typ Kategorie-Spalte
* Ermöglicht konfigurierbare Kategorien im Typ-Auswahl-Dialog
*/
private function migrate_v520_equipment_type_category()
{
$table = MAIN_DB_PREFIX."kundenkarte_equipment_type";
$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 'category'");
if ($resql && $this->db->num_rows($resql) > 0) {
return;
}
// Spalte hinzufügen
$this->db->query("ALTER TABLE ".$table." ADD COLUMN category varchar(32) DEFAULT 'steuerung' AFTER fk_system");
// Bestehende Typen kategorisieren (basierend auf bisheriger Logik)
// Leitungsschutz: LS*, HS*, NH*, MSS
$this->db->query("UPDATE ".$table." SET category = 'automat' WHERE ref LIKE 'LS%' OR ref LIKE 'HS%' OR ref LIKE 'NH%' OR ref = 'MSS'");
// Schutzgeräte: FI*, AFDD, SPD*
$this->db->query("UPDATE ".$table." SET category = 'schutz' WHERE ref LIKE 'FI%' OR ref = 'AFDD' OR ref LIKE 'SPD%'");
// Klemmen: RK_*
$this->db->query("UPDATE ".$table." SET category = 'klemme' WHERE ref LIKE 'RK\\_%'");
// Neozed als Leitungsschutz
$this->db->query("UPDATE ".$table." SET category = 'automat' WHERE ref LIKE 'NEO%'");
}
/**
* Migration v5.2.0: width_te und position_te auf DECIMAL(4,1)
* Ermöglicht halbe TE-Breiten (z.B. 4.5 TE für Neozed-Elemente)
*/
private function migrate_v520_decimal_te()
{
// Equipment-Type: width_te INT → DECIMAL(4,1)
$typeTable = MAIN_DB_PREFIX."kundenkarte_equipment_type";
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($typeTable)."'");
if ($resql && $this->db->num_rows($resql) > 0) {
$resql = $this->db->query("SHOW COLUMNS FROM ".$typeTable." WHERE Field = 'width_te' AND Type LIKE 'int%'");
if ($resql && $this->db->num_rows($resql) > 0) {
$this->db->query("ALTER TABLE ".$typeTable." MODIFY COLUMN width_te DECIMAL(4,1) NOT NULL DEFAULT 1");
}
}
// Equipment: width_te und position_te INT → DECIMAL(4,1)
$eqTable = MAIN_DB_PREFIX."kundenkarte_equipment";
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($eqTable)."'");
if ($resql && $this->db->num_rows($resql) > 0) {
$resql = $this->db->query("SHOW COLUMNS FROM ".$eqTable." WHERE Field = 'width_te' AND Type LIKE 'int%'");
if ($resql && $this->db->num_rows($resql) > 0) {
$this->db->query("ALTER TABLE ".$eqTable." MODIFY COLUMN width_te DECIMAL(4,1) NOT NULL DEFAULT 1");
}
$resql = $this->db->query("SHOW COLUMNS FROM ".$eqTable." WHERE Field = 'position_te' AND Type LIKE 'int%'");
if ($resql && $this->db->num_rows($resql) > 0) {
$this->db->query("ALTER TABLE ".$eqTable." MODIFY COLUMN position_te DECIMAL(4,1) NOT NULL DEFAULT 1");
}
}
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.

View file

@ -2260,8 +2260,8 @@ body.kundenkarte-drag-active * {
/* Messages - Fixed height status bar */
.schematic-message {
padding: 6px 15px !important;
margin-bottom: 10px !important;
border-radius: 4px !important;
margin-bottom: 0 !important;
border-radius: 0 !important;
font-size: 12px !important;
height: 28px !important;
min-height: 28px !important;
@ -2269,30 +2269,40 @@ body.kundenkarte-drag-active * {
line-height: 16px !important;
overflow: hidden !important;
box-sizing: border-box !important;
transition: none !important;
background: #2d4a5e !important;
color: #7ec8e3 !important;
border: 1px solid #3498db !important;
border-top: none !important;
border-bottom: none !important;
}
.schematic-message.info {
background: #2d4a5e !important;
color: #7ec8e3 !important;
border: 1px solid #3498db !important;
border-left: 1px solid #3498db !important;
border-right: 1px solid #3498db !important;
}
.schematic-message.success {
background: #2d5a3d !important;
color: #7ee8a0 !important;
border: 1px solid #27ae60 !important;
border-left: 1px solid #27ae60 !important;
border-right: 1px solid #27ae60 !important;
}
.schematic-message.warning {
background: #5a5a2d !important;
color: #e8e87e !important;
border: 1px solid #f1c40f !important;
border-left: 1px solid #f1c40f !important;
border-right: 1px solid #f1c40f !important;
}
.schematic-message.error {
background: #5a2d2d !important;
color: #e87e7e !important;
border: 1px solid #e74c3c !important;
border-left: 1px solid #e74c3c !important;
border-right: 1px solid #e74c3c !important;
}
/* Terminal color by phase */

View file

@ -1756,10 +1756,14 @@
html += '<div class="kundenkarte-carrier-svg-container" style="width:' + totalWidth + 'px;">';
html += '<svg class="kundenkarte-carrier-svg" width="' + totalWidth + '" height="' + this.BLOCK_HEIGHT + '" viewBox="0 0 ' + totalWidth + ' ' + this.BLOCK_HEIGHT + '">';
// Background grid (TE markers)
// TE-Raster (ganzzahlige TEs deutlich, 0.5er subtil)
for (var i = 0; i <= carrier.total_te; i++) {
var x = i * this.TE_WIDTH;
html += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="' + this.BLOCK_HEIGHT + '" stroke="#ddd" stroke-width="0.5"/>';
html += '<line x1="' + x + '" y1="0" x2="' + x + '" y2="' + this.BLOCK_HEIGHT + '" stroke="#ddd" stroke-width="1.5"/>';
if (i < carrier.total_te) {
var halfX = x + this.TE_WIDTH / 2;
html += '<line x1="' + halfX + '" y1="5" x2="' + halfX + '" y2="' + (this.BLOCK_HEIGHT - 5) + '" stroke="#ddd" stroke-width="0.3" opacity="0.4"/>';
}
}
// Render equipment blocks
@ -1824,11 +1828,16 @@
},
getOccupiedSlots: function(equipment) {
// Range-basierte Belegung (unterstützt Dezimal-TE wie 4.5)
var slots = {};
if (equipment) {
equipment.forEach(function(eq) {
for (var i = 0; i < eq.width_te; i++) {
slots[eq.position_te + i] = true;
var start = parseFloat(eq.position_te) || 1;
var width = parseFloat(eq.width_te) || 1;
var end = start + width;
// Ganzzahl-Slots markieren die von diesem Equipment überlappt werden
for (var i = Math.floor(start); i < Math.ceil(end); i++) {
slots[i] = true;
}
});
}
@ -5188,7 +5197,7 @@
// Clear all connections
$(document).off('click.clearConns').on('click.clearConns', '.schematic-clear-connections', function(e) {
e.preventDefault();
self.showConfirmDialog('Alle löschen', 'Alle Verbindungen wirklich löschen?', function() {
KundenKarte.showConfirm('Alle löschen', 'Alle Verbindungen wirklich löschen?', function() {
self.clearAllConnections();
});
});
@ -5408,7 +5417,7 @@
var newEndTE = newStartTE + data.busbarWidthTE - 1;
// Clamp to carrier bounds (shift if needed to fit)
var totalTE = parseInt(targetCarrier.total_te) || 12;
var totalTE = parseFloat(targetCarrier.total_te) || 12;
if (newStartTE < 1) {
newStartTE = 1;
newEndTE = newStartTE + data.busbarWidthTE - 1;
@ -5931,7 +5940,7 @@
var self = this;
var baseUrl = $('body').data('base-url') || '';
this.showConfirmDialog('Hutschiene löschen', 'Diese Hutschiene und alle Equipments darauf wirklich löschen?', function() {
KundenKarte.showConfirm('Hutschiene löschen', 'Diese Hutschiene und alle Equipments darauf wirklich löschen?', function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
@ -5962,7 +5971,7 @@
if (!conn) return;
var carrier = this.carriers.find(function(c) { return String(c.id) === String(conn.fk_carrier); });
var totalTE = carrier ? (parseInt(carrier.total_te) || 12) : 12;
var totalTE = carrier ? (parseFloat(carrier.total_te) || 12) : 12;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:350px;">';
@ -6474,10 +6483,16 @@
html += self.escapeHtml(carrier.label || 'H' + (carrierIdx + 1));
html += '</text>';
// TE markers on the rail
for (var te = 0; te <= (carrier.total_te || 12); te++) {
// TE-Markierungen auf der Schiene (ganzzahlige TEs deutlich, 0.5er-Zwischenmarken subtil)
var totalTECount = carrier.total_te || 12;
for (var te = 0; te <= totalTECount; te++) {
var teX = x + te * self.TE_WIDTH;
html += '<line x1="' + teX + '" y1="' + railY + '" x2="' + teX + '" y2="' + (railY + self.RAIL_HEIGHT) + '" stroke="#555" stroke-width="0.5"/>';
html += '<line x1="' + teX + '" y1="' + railY + '" x2="' + teX + '" y2="' + (railY + self.RAIL_HEIGHT) + '" stroke="#999" stroke-width="1.5"/>';
// 0.5-TE Zwischenmarkierung
if (te < totalTECount) {
var halfX = teX + self.TE_WIDTH / 2;
html += '<line x1="' + halfX + '" y1="' + (railY + 3) + '" x2="' + halfX + '" y2="' + (railY + self.RAIL_HEIGHT - 3) + '" stroke="#555" stroke-width="0.5"/>';
}
}
});
});
@ -6506,9 +6521,15 @@
html += self.escapeHtml(carrier.label || 'Hutschiene ' + (idx + 1));
html += '</text>';
for (var te = 0; te <= (carrier.total_te || 12); te++) {
// TE-Markierungen (ganzzahlige TEs deutlich, 0.5er subtil)
var totalTECount = carrier.total_te || 12;
for (var te = 0; te <= totalTECount; te++) {
var teX = x + te * self.TE_WIDTH;
html += '<line x1="' + teX + '" y1="' + railY + '" x2="' + teX + '" y2="' + (railY + self.RAIL_HEIGHT) + '" stroke="#555" stroke-width="0.5"/>';
html += '<line x1="' + teX + '" y1="' + railY + '" x2="' + teX + '" y2="' + (railY + self.RAIL_HEIGHT) + '" stroke="#999" stroke-width="1.5"/>';
if (te < totalTECount) {
var halfX = teX + self.TE_WIDTH / 2;
html += '<line x1="' + halfX + '" y1="' + (railY + 3) + '" x2="' + halfX + '" y2="' + (railY + self.RAIL_HEIGHT - 3) + '" stroke="#555" stroke-width="0.5"/>';
}
}
});
}
@ -6538,7 +6559,7 @@
var blockWidth = (eq.width_te || 1) * self.TE_WIDTH - 4;
var blockHeight = self.BLOCK_HEIGHT;
var x = parseFloat(carrier._x) + ((parseInt(eq.position_te) || 1) - 1) * self.TE_WIDTH + 2;
var x = parseFloat(carrier._x) + ((parseFloat(eq.position_te) || 1) - 1) * self.TE_WIDTH + 2;
// Position blocks centered on the rail (Hutschiene)
// Rail is at carrier._y, block center should align with rail center
var railCenterY = carrier._y + self.RAIL_HEIGHT / 2;
@ -6666,7 +6687,7 @@
var topTerminals = terminals.filter(function(t) { return t.pos === 'top'; });
var bottomTerminals = terminals.filter(function(t) { return t.pos === 'bottom'; });
var widthTE = parseInt(eq.width_te) || 1;
var widthTE = parseFloat(eq.width_te) || 1;
// Check if this equipment is covered by a busbar (returns { top: bool, bottom: bool })
var busbarCoverage = self.isEquipmentCoveredByBusbar(eq);
@ -7406,35 +7427,34 @@
// Check if carrier has position data (use typeof to allow 0 values)
if (typeof carrier._x !== 'undefined' && typeof carrier._y !== 'undefined') {
// Belegte Slots ermitteln (1-basiert)
var totalTE = parseInt(carrier.total_te) || 12;
var occupied = {};
// Belegte Ranges ermitteln (Dezimal-TE-Unterstützung)
var totalTE = parseFloat(carrier.total_te) || 12;
var lastEquipment = null;
var lastEndPos = 0;
var ranges = [];
carrierEquipment.forEach(function(eq) {
var pos = parseInt(eq.position_te) || 1;
var w = parseInt(eq.width_te) || 1;
var endPos = pos + w - 1;
for (var s = pos; s <= endPos; s++) {
occupied[s] = true;
}
var pos = parseFloat(eq.position_te) || 1;
var w = parseFloat(eq.width_te) || 1;
var endPos = pos + w;
ranges.push({ start: pos, end: endPos });
if (endPos > lastEndPos) {
lastEndPos = endPos;
lastEquipment = eq;
}
});
ranges.sort(function(a, b) { return a.start - b.start; });
// Maximale zusammenhängende Lücke berechnen
var maxGap = 0;
var currentGap = 0;
for (var s = 1; s <= totalTE; s++) {
if (!occupied[s]) {
currentGap++;
if (currentGap > maxGap) maxGap = currentGap;
} else {
currentGap = 0;
}
}
var gapPos = 1;
var railEnd = totalTE + 1;
ranges.forEach(function(r) {
var gap = r.start - gapPos;
if (gap > maxGap) maxGap = gap;
if (r.end > gapPos) gapPos = r.end;
});
var endGap = railEnd - gapPos;
if (endGap > maxGap) maxGap = endGap;
// Carrier-Objekt merkt sich maximale Lücke für Typ-Filter
carrier._maxGap = maxGap;
@ -7868,7 +7888,7 @@
showAddBusbarDialog: function(carrierId) {
var self = this;
var carrier = this.carriers.find(function(c) { return String(c.id) === String(carrierId); });
var totalTE = carrier ? (parseInt(carrier.total_te) || 12) : 12;
var totalTE = carrier ? (parseFloat(carrier.total_te) || 12) : 12;
var html = '<div class="schematic-dialog-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:100000;"></div>';
html += '<div class="schematic-dialog" style="position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d44;border:1px solid #555;border-radius:8px;padding:20px;z-index:100001;min-width:350px;">';
@ -8051,7 +8071,7 @@
showEquipmentTypeSelector: function(carrierId, types, maxGap) {
var self = this;
// Kategorisiere Equipment-Typen
// Kategorisiere Equipment-Typen (category kommt aus DB)
var categories = {
'schutz': { label: 'Schutzgeräte', icon: 'fa-shield', items: [] },
'automat': { label: 'Leitungsschutz', icon: 'fa-bolt', items: [] },
@ -8062,18 +8082,12 @@
// Sortiere Typen in Kategorien (nur wenn Breite in verfügbare Lücke passt)
maxGap = maxGap || 99;
types.forEach(function(t) {
var typeWidth = parseInt(t.width_te) || 1;
var typeWidth = parseFloat(t.width_te) || 1;
if (typeWidth > maxGap) return; // Passt nicht in verfügbare Lücke
var ref = (t.ref || '').toUpperCase();
var label = (t.label || '').toLowerCase();
if (ref.indexOf('FI') === 0 || ref === 'AFDD' || ref.indexOf('SPD') === 0) {
categories.schutz.items.push(t);
} else if (ref.indexOf('LS') === 0 || ref.indexOf('HS') === 0 || ref.indexOf('NH') === 0 || ref === 'MSS') {
categories.automat.items.push(t);
} else if (ref.indexOf('RK_') === 0 || label.indexOf('klemme') !== -1) {
categories.klemme.items.push(t);
var cat = t.category || 'steuerung';
if (categories[cat]) {
categories[cat].items.push(t);
} else {
categories.steuerung.items.push(t);
}
@ -8365,9 +8379,9 @@
// Returns: { top: boolean, bottom: boolean } indicating which terminals are covered
isEquipmentCoveredByBusbar: function(eq) {
var eqCarrierId = eq.carrier_id || eq.fk_carrier;
var eqPosTE = parseInt(eq.position_te) || 1;
var eqWidthTE = parseInt(eq.width_te) || 1;
var eqEndTE = eqPosTE + eqWidthTE - 1;
var eqPosTE = parseFloat(eq.position_te) || 1;
var eqWidthTE = parseFloat(eq.width_te) || 1;
var eqEndTE = eqPosTE + eqWidthTE; // Half-open range: [eqPosTE, eqEndTE)
var result = { top: false, bottom: false };
@ -8383,8 +8397,8 @@
var railEnd = parseInt(conn.rail_end_te) || railStart;
var positionY = parseInt(conn.position_y) || 0;
// Check if equipment overlaps with busbar range
var overlaps = !(eqEndTE < railStart || eqPosTE > railEnd);
// Overlap: [eqPosTE, eqEndTE) vs [railStart, railEnd+1)
var overlaps = eqPosTE < railEnd + 1 && railStart < eqEndTE;
if (!overlaps) return;
// Determine if busbar is above (position_y = 0) or below (position_y > 0)
@ -8408,7 +8422,9 @@
// Try to parse terminals_config from equipment type
if (eq.terminals_config) {
try {
var config = JSON.parse(eq.terminals_config);
// Literale \r\n bereinigen (falls DB-Daten kaputt)
var configStr = eq.terminals_config.replace(/\\r\\n|\\r|\\n/g, ' ').replace(/\\t/g, '');
var config = JSON.parse(configStr);
// Handle both old format (inputs/outputs) and new format (terminals)
if (config.terminals) {
return config.terminals;
@ -8501,7 +8517,7 @@
}
var isTop = terminal.pos === 'top';
var widthTE = parseInt(eq.width_te) || 1;
var widthTE = parseFloat(eq.width_te) || 1;
// Terminal im festen TE-Raster platzieren
// Jeder Terminal belegt 1 TE - Index bestimmt welches TE
@ -8577,7 +8593,7 @@
this.equipment.forEach(function(eq) {
if (eq._x === undefined || eq._y === undefined) return;
var eqWidth = eq._width || (self.TE_WIDTH * (parseInt(eq.width_te) || 1));
var eqWidth = eq._width || (self.TE_WIDTH * (parseFloat(eq.width_te) || 1));
var eqHeight = eq._height || self.BLOCK_HEIGHT;
var eqX1 = Math.floor((eq._x - minX) / GRID_SIZE);
@ -10456,21 +10472,26 @@
var carrier = this.carriers.find(function(c) { return parseInt(c.id) === parseInt(carrierId); });
var carrierLabel = carrier ? (carrier.label || 'Hutschiene') : 'Hutschiene';
this.showConfirmDialog('Hutschiene löschen', 'Hutschiene "' + carrierLabel + '" wirklich löschen? Alle darauf platzierten Geräte werden ebenfalls gelöscht!', function() {
KundenKarte.showConfirm('Hutschiene löschen', 'Hutschiene "' + carrierLabel + '" wirklich löschen? Alle darauf platzierten Geräte werden ebenfalls gelöscht!', function() {
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_carrier.php',
method: 'POST',
data: {
action: 'delete',
carrier_id: carrierId
carrier_id: carrierId,
token: KundenKarte.token
},
dataType: 'json',
success: function(response) {
if (response.success) {
self.loadData(); // Reload all data
self.loadData();
self.showMessage('Hutschiene gelöscht', 'success');
} else {
alert('Fehler: ' + (response.error || 'Unbekannter Fehler'));
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
}
},
error: function() {
self.showMessage('Fehler beim Löschen', 'error');
}
});
});
@ -10535,7 +10556,7 @@
// Position
dialogHtml += '<div style="margin-bottom:12px;">';
dialogHtml += '<label style="display:block;color:#aaa;font-size:12px;margin-bottom:4px;">Position (TE):</label>';
dialogHtml += '<input type="number" class="edit-equipment-position" value="' + (eq.position_te || 1) + '" min="1" ' +
dialogHtml += '<input type="number" class="edit-equipment-position" value="' + (eq.position_te || 1) + '" min="0.1" step="0.1" ' +
'style="width:100%;padding:8px;border:1px solid #555;border-radius:4px;background:#1e1e1e;color:#fff;box-sizing:border-box;"/></div>';
// Produktauswahl mit Autocomplete
@ -10700,8 +10721,8 @@
element: $block,
startX: e.pageX,
startY: e.pageY,
originalTE: parseInt(eq.position_te) || 1,
originalX: parseFloat(carrier._x) + ((parseInt(eq.position_te) || 1) - 1) * this.TE_WIDTH + 2,
originalTE: parseFloat(eq.position_te) || 1,
originalX: parseFloat(carrier._x) + ((parseFloat(eq.position_te) || 1) - 1) * this.TE_WIDTH + 2,
originalY: eq._y
};
@ -10724,13 +10745,14 @@
var targetCarrier = this.findClosestCarrier(mouseY, mouseX);
if (!targetCarrier) targetCarrier = this.dragState.carrier;
// Calculate TE position on target carrier based on absolute X position
// TE-Position auf Ziel-Carrier berechnen (0.1er-Snap für Dezimal-Breiten)
var relativeX = mouseX - targetCarrier._x;
var newTE = Math.round(relativeX / this.TE_WIDTH) + 1;
var rawTE = relativeX / this.TE_WIDTH + 1;
var newTE = Math.round(rawTE * 10) / 10;
// Clamp to valid range on TARGET carrier
var maxTE = (parseInt(targetCarrier.total_te) || 12) - (parseInt(this.dragState.equipment.width_te) || 1) + 1;
newTE = Math.max(1, Math.min(newTE, maxTE));
// Auf gültigen Bereich auf ZIEL-Carrier begrenzen
var maxTE = (parseFloat(targetCarrier.total_te) || 12) - (parseFloat(this.dragState.equipment.width_te) || 1) + 1;
newTE = Math.max(1, Math.min(newTE, Math.round(maxTE * 10) / 10));
// Calculate visual position
var newX, newY;
@ -10962,11 +10984,7 @@
showMessage: function(text, type) {
var $msg = $('.schematic-message');
if (!$msg.length) {
// Create permanent status bar with fixed height
$msg = $('<div class="schematic-message" style="min-height:28px;display:block !important;visibility:visible;"></div>');
$('.schematic-editor-canvas').prepend($msg);
}
if (!$msg.length) return; // Statuszeile wird in PHP erstellt
// Clear any pending hide timeout
if (this.messageTimeout) {
@ -10974,20 +10992,19 @@
this.messageTimeout = null;
}
$msg.removeClass('success error warning info').addClass(type).text(text).css('opacity', 1);
// Atomarer Klassen-Wechsel - kein Flackern durch removeClass/addClass
$msg.attr('class', 'schematic-message ' + type).text(text);
if (type === 'success' || type === 'error') {
this.messageTimeout = setTimeout(function() {
// Don't hide, just clear the message text but keep the bar
$msg.removeClass('success error warning info').addClass('info').text('Bereit');
$msg.attr('class', 'schematic-message info').text('Bereit');
}, 2500);
}
},
hideMessage: function() {
var $msg = $('.schematic-message');
// Don't hide, just reset to default state
$msg.removeClass('success error warning info').addClass('info').text('Bereit');
$msg.attr('class', 'schematic-message info').text('Bereit');
},
escapeHtml: function(text) {

View file

@ -709,7 +709,7 @@
carrierEquipment.sort((a, b) => (a.position_te || 0) - (b.position_te || 0));
const totalTe = parseInt(carrier.total_te) || 12;
const usedTe = carrierEquipment.reduce((sum, eq) => sum + (parseInt(eq.width_te) || 1), 0);
const usedTe = carrierEquipment.reduce((sum, eq) => sum + (parseFloat(eq.width_te) || 1), 0);
const isFull = usedTe >= totalTe;
html += `
@ -723,8 +723,8 @@
// === Zeile 1: Terminals oben (Inputs + Top-Outputs) ===
carrierEquipment.forEach(eq => {
const widthTe = parseInt(eq.width_te) || 1;
const posTe = parseInt(eq.position_te) || 0;
const 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) : [];
const eqTopOutputs = App.outputs ? App.outputs.filter(o => o.fk_source == eq.id && o.is_top) : [];
@ -757,8 +757,8 @@
// === Zeile 2: Equipment-Blöcke ===
carrierEquipment.forEach(eq => {
const type = App.equipmentTypes.find(t => t.id == eq.fk_equipment_type);
const widthTe = parseInt(eq.width_te) || 1;
const posTe = parseInt(eq.position_te) || 0;
const widthTe = parseFloat(eq.width_te) || 1;
const posTe = parseFloat(eq.position_te) || 0;
const typeLabel = type?.label_short || type?.ref || '';
const blockColor = eq.block_color || type?.color || '#3498db';
@ -788,8 +788,8 @@
// === Zeile 3: Output-Terminals unten (Standard-Abgänge) ===
carrierEquipment.forEach(eq => {
const widthTe = parseInt(eq.width_te) || 1;
const posTe = parseInt(eq.position_te) || 0;
const 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) : [];
for (let t = 0; t < widthTe; t++) {
@ -1074,26 +1074,24 @@
const totalTe = parseInt(carrier.total_te) || 12;
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == carrierId);
// Belegte Slots ermitteln (1-basiert)
const occupied = {};
carrierEquipment.forEach(eq => {
const pos = parseInt(eq.position_te) || 1;
const w = parseInt(eq.width_te) || 1;
for (let s = pos; s < pos + w; s++) {
occupied[s] = true;
}
});
// Belegte Ranges ermitteln (1-basiert, Dezimal-Breiten)
const ranges = carrierEquipment.map(eq => ({
start: parseFloat(eq.position_te) || 1,
end: (parseFloat(eq.position_te) || 1) + (parseFloat(eq.width_te) || 1)
})).sort((a, b) => a.start - b.start);
// Maximale zusammenhängende Lücke
let maxGap = 0, currentGap = 0;
for (let s = 1; s <= totalTe; s++) {
if (!occupied[s]) {
currentGap++;
if (currentGap > maxGap) maxGap = currentGap;
} else {
currentGap = 0;
}
let maxGap = 0;
let pos = 1;
const railEnd = totalTe + 1;
for (const range of ranges) {
const gap = range.start - pos;
if (gap > maxGap) maxGap = gap;
if (range.end > pos) pos = range.end;
}
// Lücke nach letztem Element
const endGap = railEnd - pos;
if (endGap > maxGap) maxGap = endGap;
return maxGap;
}
@ -1304,26 +1302,27 @@
const carrierEquipment = App.equipment.filter(e => e.fk_carrier == App.currentCarrierId);
const carrier = App.carriers.find(c => c.id == App.currentCarrierId);
const totalTe = parseInt(carrier?.total_te) || 12;
const eqWidth = parseInt(type?.width_te) || 1;
const eqWidth = parseFloat(type?.width_te) || 1;
// Belegungsarray erstellen
const occupied = new Array(totalTe + 1).fill(false);
carrierEquipment.forEach(e => {
const pos = parseInt(e.position_te) || 1;
const w = parseInt(e.width_te) || 1;
for (let i = pos; i < pos + w && i <= totalTe; i++) {
occupied[i] = true;
}
});
// Belegte Ranges ermitteln (Dezimal-TE-Unterstützung)
const ranges = carrierEquipment.map(e => ({
start: parseFloat(e.position_te) || 1,
end: (parseFloat(e.position_te) || 1) + (parseFloat(e.width_te) || 1)
})).sort((a, b) => a.start - b.start);
// Erste Lücke finden die breit genug ist
let nextPos = 0;
for (let i = 1; i <= totalTe - eqWidth + 1; i++) {
let fits = true;
for (let j = 0; j < eqWidth; j++) {
if (occupied[i + j]) { fits = false; break; }
let pos = 1;
const railEnd = totalTe + 1;
for (const range of ranges) {
if (pos + eqWidth <= range.start + 0.001) {
nextPos = pos;
break;
}
if (fits) { nextPos = i; break; }
if (range.end > pos) pos = range.end;
}
if (nextPos === 0 && pos + eqWidth <= railEnd + 0.001) {
nextPos = pos;
}
if (nextPos === 0) {

View file

@ -745,8 +745,8 @@ if (empty($customerSystems)) {
print '</a>';
print '</div>';
print '</div>';
print '<div class="schematic-message info">Bereit</div>';
print '<div class="schematic-editor-canvas expanded" style="display:block;background:#1a1a1a;border:1px solid #333;border-top:none;border-radius:0 0 4px 4px;padding:15px;overflow:auto;">';
print '<div class="schematic-message" style="display:none;padding:8px 15px;margin-bottom:10px;border-radius:4px;font-size:12px;"></div>';
print '</div>';
print '</div>';

View file

@ -743,8 +743,8 @@ if (empty($customerSystems)) {
print '</a>';
print '</div>';
print '</div>';
print '<div class="schematic-message info">Bereit</div>';
print '<div class="schematic-editor-canvas expanded" style="display:block;background:#1a1a1a;border:1px solid #333;border-top:none;border-radius:0 0 4px 4px;padding:15px;overflow:auto;">';
print '<div class="schematic-message" style="display:none;padding:8px 15px;margin-bottom:10px;border-radius:4px;font-size:12px;"></div>';
print '</div>';
print '</div>';