kundenkarte/tabs/anlagen.php
data 411a48d577 Exclude GLOBAL system from customer tab display
GLOBAL types (buildings) are now available in all system tabs via
fetchAllBySystem(), but the GLOBAL system itself should not appear
as a separate tab for customers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-19 08:02:34 +01:00

2340 lines
91 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Alles Watt lauft
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file tabs/anlagen.php
* \brief Tab for technical installations on thirdparty card
*/
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.form.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
dol_include_once('/kundenkarte/class/anlage.class.php');
dol_include_once('/kundenkarte/class/anlagetype.class.php');
dol_include_once('/kundenkarte/class/anlagefile.class.php');
dol_include_once('/kundenkarte/class/equipmentpanel.class.php');
dol_include_once('/kundenkarte/class/equipmentcarrier.class.php');
dol_include_once('/kundenkarte/class/equipment.class.php');
dol_include_once('/kundenkarte/lib/kundenkarte.lib.php');
// Load translation files
$langs->loadLangs(array('companies', 'kundenkarte@kundenkarte'));
// Get parameters
$id = GETPOSTINT('id');
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');
$systemId = GETPOSTINT('system');
$anlageId = GETPOSTINT('anlage_id');
$parentId = GETPOSTINT('parent_id');
// Security check
if (!$user->hasRight('kundenkarte', 'read')) {
accessforbidden();
}
// Initialize objects
$object = new Societe($db);
$form = new Form($db);
$anlage = new Anlage($db);
$anlageType = new AnlageType($db);
// Load thirdparty
if ($id > 0) {
$result = $object->fetch($id);
if ($result <= 0) {
dol_print_error($db, $object->error);
exit;
}
}
$permissiontoread = $user->hasRight('kundenkarte', 'read');
$permissiontoadd = $user->hasRight('kundenkarte', 'write');
$permissiontodelete = $user->hasRight('kundenkarte', 'delete');
// Load ALL available systems (from dictionary)
$allSystems = array();
$sql = "SELECT rowid, code, label, picto, color FROM ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system WHERE active = 1 ORDER BY position ASC";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$allSystems[$obj->rowid] = $obj;
}
}
// Load systems ENABLED for this customer (only thirdparty-level, not contact-specific)
$customerSystems = array();
$sql = "SELECT ss.rowid, ss.fk_system, s.code, s.label, s.picto, s.color
FROM ".MAIN_DB_PREFIX."kundenkarte_societe_system ss
JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system s ON s.rowid = ss.fk_system
WHERE ss.fk_soc = ".((int) $id)." AND (ss.fk_contact IS NULL OR ss.fk_contact = 0) AND ss.active = 1 AND s.active = 1
AND s.code != 'GLOBAL'
ORDER BY s.position ASC";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$customerSystems[$obj->fk_system] = $obj;
}
}
// Default to first enabled system if not specified
if (empty($systemId) && !empty($customerSystems)) {
$systemId = array_key_first($customerSystems);
}
/*
* Actions
*/
// Add system to customer
if ($action == 'add_system' && $permissiontoadd) {
$newSystemId = GETPOSTINT('new_system_id');
if ($newSystemId > 0 && !isset($customerSystems[$newSystemId])) {
$sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_societe_system";
$sql .= " (entity, fk_soc, fk_contact, fk_system, date_creation, fk_user_creat, active)";
$sql .= " VALUES (".$conf->entity.", ".((int) $id).", 0, ".((int) $newSystemId).", NOW(), ".((int) $user->id).", 1)";
$result = $db->query($sql);
if ($result) {
setEventMessages($langs->trans('SystemAdded'), null, 'mesgs');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$newSystemId);
exit;
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
}
$action = '';
}
// Remove system from customer
if ($action == 'confirm_remove_system' && $confirm == 'yes' && $permissiontodelete) {
$removeSystemId = GETPOSTINT('remove_system_id');
if ($removeSystemId > 0) {
// Check if system has any elements (only thirdparty-level, not contact-specific)
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE fk_soc = ".((int) $id)." AND (fk_contact IS NULL OR fk_contact = 0) AND fk_system = ".((int) $removeSystemId);
$resql = $db->query($sql);
$obj = $db->fetch_object($resql);
if ($obj->cnt > 0) {
setEventMessages($langs->trans('ErrorSystemHasElements'), null, 'errors');
} else {
$sql = "DELETE FROM ".MAIN_DB_PREFIX."kundenkarte_societe_system WHERE fk_soc = ".((int) $id)." AND (fk_contact IS NULL OR fk_contact = 0) AND fk_system = ".((int) $removeSystemId);
$db->query($sql);
setEventMessages($langs->trans('SystemRemoved'), null, 'mesgs');
// Switch to another system or none
unset($customerSystems[$removeSystemId]);
if (!empty($customerSystems)) {
$systemId = array_key_first($customerSystems);
} else {
$systemId = 0;
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.($systemId ? '&system='.$systemId : ''));
exit;
}
if ($action == 'add' && $permissiontoadd) {
$anlage->label = GETPOST('label', 'alphanohtml');
$anlage->fk_soc = $id;
$anlage->fk_anlage_type = GETPOSTINT('fk_anlage_type');
$anlage->fk_parent = GETPOSTINT('fk_parent');
$anlage->fk_system = $systemId;
$anlage->note_private = isset($_POST['note_private']) ? $_POST['note_private'] : '';
$anlage->status = 1;
// Get type - but keep current system for GLOBAL types (buildings)
$type = new AnlageType($db);
if ($type->fetch($anlage->fk_anlage_type) > 0) {
// Only change system if type is NOT a global type
// Global types (buildings) should stay in the current system
if ($type->system_code !== 'GLOBAL') {
$anlage->fk_system = $type->fk_system;
}
}
// All fields come from dynamic fields now
$fieldValues = array();
$fields = $type->fetchFields();
foreach ($fields as $field) {
if ($field->field_type === 'header') continue; // Skip headers
$value = GETPOST('field_'.$field->field_code, 'alphanohtml');
if ($value !== '') {
$fieldValues[$field->field_code] = $value;
}
}
$anlage->setFieldValues($fieldValues);
$result = $anlage->create($user);
if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId);
exit;
} else {
setEventMessages($anlage->error, $anlage->errors, 'errors');
$action = 'create';
}
}
if ($action == 'update' && $permissiontoadd) {
$anlage->fetch($anlageId);
$systemIdBefore = $anlage->fk_system; // Remember original system
$anlage->label = GETPOST('label', 'alphanohtml');
$anlage->fk_anlage_type = GETPOSTINT('fk_anlage_type');
$anlage->fk_parent = GETPOSTINT('fk_parent');
$anlage->note_private = isset($_POST['note_private']) ? $_POST['note_private'] : '';
// Get type - but keep current system for GLOBAL types (buildings)
$type = new AnlageType($db);
if ($type->fetch($anlage->fk_anlage_type) > 0) {
// Only change system if type is NOT a global type
// Global types (buildings) should stay in their current system
if ($type->system_code !== 'GLOBAL') {
$anlage->fk_system = $type->fk_system;
}
}
// All fields come from dynamic fields now
$fieldValues = array();
$fields = $type->fetchFields();
foreach ($fields as $field) {
if ($field->field_type === 'header') continue; // Skip headers
$value = GETPOST('field_'.$field->field_code, 'alphanohtml');
if ($value !== '') {
$fieldValues[$field->field_code] = $value;
}
}
$anlage->setFieldValues($fieldValues);
$result = $anlage->update($user);
if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId);
exit;
} else {
setEventMessages($anlage->error, $anlage->errors, 'errors');
$action = 'edit';
}
}
if ($action == 'confirm_delete' && $confirm == 'yes' && $permissiontodelete) {
$anlage->fetch($anlageId);
$systemIdBefore = $anlage->fk_system;
$result = $anlage->delete($user);
if ($result > 0) {
setEventMessages($langs->trans('RecordDeleted'), null, 'mesgs');
} else {
setEventMessages($anlage->error, $anlage->errors, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemIdBefore);
exit;
}
// File upload
if ($action == 'uploadfile' && $permissiontoadd) {
$anlage->fetch($anlageId);
$upload_dir = $anlage->getFileDirectory();
// Create directory if not exists
if (!is_dir($upload_dir)) {
dol_mkdir($upload_dir);
}
if (!empty($_FILES['userfile']['name'])) {
$result = dol_add_file_process($upload_dir, 0, 1, 'userfile', '', null, '', 1);
if ($result > 0) {
// Add to database
$anlagefile = new AnlageFile($db);
$anlagefile->fk_anlage = $anlageId;
$anlagefile->filename = dol_sanitizeFileName($_FILES['userfile']['name']);
// IMPORTANT: Store ONLY relative path (anlagen/socid/anlageid/filename) - never full path!
$anlagefile->filepath = 'anlagen/'.$anlage->fk_soc.'/'.$anlage->id.'/'.$anlagefile->filename;
$anlagefile->filesize = $_FILES['userfile']['size'];
$anlagefile->mimetype = dol_mimetype($anlagefile->filename);
$anlagefile->create($user);
// Generate thumbnail
$anlagefile->generateThumbnail();
setEventMessages($langs->trans('FileUploaded'), null, 'mesgs');
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=view&anlage_id='.$anlageId);
exit;
}
// File delete (after confirmation)
if ($action == 'confirm_deletefile' && $confirm == 'yes' && $permissiontodelete) {
$fileId = GETPOSTINT('fileid');
if ($fileId > 0) {
$anlagefile = new AnlageFile($db);
if ($anlagefile->fetch($fileId) > 0) {
// Delete method handles physical file and database
if ($anlagefile->delete($user) > 0) {
setEventMessages($langs->trans('FileDeleted'), null, 'mesgs');
} else {
setEventMessages($langs->trans('Error'), null, 'errors');
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=view&anlage_id='.$anlageId);
exit;
}
/*
* View
*/
// Use Dolibarr standard button classes
$title = $langs->trans('TechnicalInstallations').' - '.$object->name;
llxHeader('', $title, '', '', 0, 0, array('/kundenkarte/js/pathfinding.min.js', '/kundenkarte/js/kundenkarte.js?v='.time()), array('/kundenkarte/css/kundenkarte.css?v='.time()));
// Prepare tabs
$head = societe_prepare_head($object);
print dol_get_fiche_head($head, 'anlagen', $langs->trans("ThirdParty"), -1, 'company');
// Thirdparty card
$linkback = '<a href="'.DOL_URL_ROOT.'/societe/list.php?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($object, 'socid', $linkback, ($user->socid ? 0 : 1), 'rowid', 'nom');
print '<div class="fichecenter">';
// Confirmation dialogs
if ($action == 'delete') {
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$id.'&anlage_id='.$anlageId.'&system='.$systemId,
$langs->trans('DeleteElement'),
$langs->trans('ConfirmDeleteElement'),
'confirm_delete',
'',
'yes',
1
);
}
if ($action == 'remove_system') {
$removeSystemId = GETPOSTINT('remove_system_id');
$sysLabel = isset($customerSystems[$removeSystemId]) ? $customerSystems[$removeSystemId]->label : '';
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$id.'&remove_system_id='.$removeSystemId,
$langs->trans('RemoveSystem'),
$langs->trans('ConfirmRemoveSystem', $sysLabel),
'confirm_remove_system',
'',
'yes',
1
);
}
if ($action == 'askdeletefile') {
$fileId = GETPOSTINT('fileid');
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&anlage_id='.$anlageId.'&fileid='.$fileId,
$langs->trans('Delete'),
$langs->trans('ConfirmDeleteFile'),
'confirm_deletefile',
'',
'yes',
1
);
}
// System tabs (only show enabled systems for this customer)
print '<div class="kundenkarte-system-tabs-wrapper">';
print '<div class="kundenkarte-system-tabs">';
foreach ($customerSystems as $sysId => $sys) {
$activeClass = ($sysId == $systemId) ? ' active' : '';
print '<div class="kundenkarte-system-tab'.$activeClass.'" data-system="'.$sysId.'">';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$sysId.'" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:8px;">';
if ($sys->picto) {
print '<span class="kundenkarte-system-tab-icon" style="color:'.$sys->color.';">'.kundenkarte_render_icon($sys->picto).'</span>';
}
print '<span>'.dol_escape_htmltag($sys->label).'</span>';
print '</a>';
// Remove button (only if no elements)
if ($permissiontodelete && $sysId == $systemId) {
print ' <a href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&action=remove_system&remove_system_id='.$sysId.'" class="kundenkarte-system-remove" title="'.$langs->trans('RemoveSystem').'"><i class="fa fa-times"></i></a>';
}
print '</div>';
}
// Add system button (always on the right)
if ($permissiontoadd) {
// Get systems not yet enabled for this customer
$availableSystems = array_diff_key($allSystems, $customerSystems);
if (!empty($availableSystems)) {
print '<button type="button" class="button small" style="margin-left:auto;" onclick="document.getElementById(\'add-system-form\').style.display=\'block\';">';
print '<i class="fa fa-plus"></i> '.$langs->trans('AddSystem');
print '</button>';
}
}
print '</div>';
// Expand/Collapse buttons (only in tree view, not in create/edit/view/copy)
$isTreeView = !in_array($action, array('create', 'edit', 'view', 'copy'));
if ($isTreeView) {
print '<div class="kundenkarte-tree-controls">';
print '<button type="button" class="button small" id="btn-expand-all" title="'.$langs->trans('ExpandAll').'">';
print '<i class="fa fa-expand"></i> '.$langs->trans('ExpandAll');
print '</button>';
print '<button type="button" class="button small" id="btn-collapse-all" title="'.$langs->trans('CollapseAll').'">';
print '<i class="fa fa-compress"></i> '.$langs->trans('CollapseAll');
print '</button>';
if ($systemId > 0) {
$exportUrl = dol_buildpath('/kundenkarte/ajax/export_tree_pdf.php', 1).'?socid='.$id.'&system='.$systemId;
print '<a class="button small" href="'.$exportUrl.'" title="'.$langs->trans('ExportPDF').'" target="_blank">';
print '<i class="fa fa-file-pdf-o"></i> PDF Export';
print '</a>';
}
print '</div>';
}
print '</div>'; // End kundenkarte-system-tabs-wrapper
// Add system form (hidden by default)
if ($permissiontoadd && !empty($availableSystems)) {
print '<div id="add-system-form" class="kundenkarte-add-system-form" style="display:none;margin-bottom:15px;">';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.$id.'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="add_system">';
print '<strong>'.$langs->trans('SelectSystemToAdd').':</strong> ';
print '<select name="new_system_id" class="flat">';
print '<option value="">'.$langs->trans('Select').'</option>';
foreach ($availableSystems as $avSys) {
print '<option value="'.$avSys->rowid.'">'.dol_escape_htmltag($avSys->label).'</option>';
}
print '</select>';
print ' <button type="submit" class="button small">'.$langs->trans('Add').'</button>';
print ' <button type="button" class="button small" onclick="document.getElementById(\'add-system-form\').style.display=\'none\';">'.$langs->trans('Cancel').'</button>';
print '</form>';
print '</div>';
}
// Check if customer has any systems
if (empty($customerSystems)) {
print '<div class="opacitymedium" style="padding:20px;text-align:center;">';
print '<i class="fa fa-info-circle" style="font-size:24px;margin-bottom:10px;"></i><br>';
print $langs->trans('NoSystemsConfigured').'<br><br>';
if ($permissiontoadd && !empty($allSystems)) {
print $langs->trans('ClickAddSystemToStart');
} else {
print $langs->trans('ContactAdminToAddSystems');
}
print '</div>';
} elseif ($systemId > 0) {
// Show form or tree for selected system
if (in_array($action, array('create', 'edit', 'view', 'copy'))) {
// Load element for edit/view/copy
if ($action != 'create' && $anlageId > 0) {
$anlage->fetch($anlageId);
$type = new AnlageType($db);
$type->fetch($anlage->fk_anlage_type);
$type->fetchFields();
}
// Load types for select
$types = $anlageType->fetchAllBySystem($systemId);
print '<div class="kundenkarte-element-form">';
if ($action == 'view') {
// View mode
print '<h3>'.dol_escape_htmltag($anlage->label).'</h3>';
print '<table class="border centpercent">';
print '<tr><td class="titlefield">'.$langs->trans('Type').'</td>';
print '<td>'.dol_escape_htmltag($anlage->type_label).'</td></tr>';
// Dynamic fields - all fields come from type definition
$fieldValues = $anlage->getFieldValues();
$typeFieldsList = $type->fetchFields();
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>';
} else {
$value = isset($fieldValues[$field->field_code]) ? $fieldValues[$field->field_code] : '';
if ($value !== '') {
print '<tr><td>'.dol_escape_htmltag($field->field_label).'</td>';
// Format date fields
if ($field->field_type === 'date' && $value) {
print '<td>'.dol_print_date(strtotime($value), 'day').'</td></tr>';
} elseif ($field->field_type === 'checkbox') {
print '<td>'.($value ? $langs->trans('Yes') : $langs->trans('No')).'</td></tr>';
} else {
print '<td>'.dol_escape_htmltag($value).'</td></tr>';
}
}
}
}
if ($anlage->note_private) {
print '<tr><td>'.$langs->trans('FieldNotes').'</td>';
print '<td>'.dol_htmlentitiesbr($anlage->note_private).'</td></tr>';
}
// Creation date
if ($anlage->date_creation) {
print '<tr><td>'.$langs->trans('DateCreation').'</td>';
print '<td>'.dol_print_date($anlage->date_creation, 'dayhour').'</td></tr>';
}
// Last modification date
if ($anlage->tms && $anlage->tms != $anlage->date_creation) {
print '<tr><td>'.$langs->trans('DateLastModification').'</td>';
print '<td>'.dol_print_date($anlage->tms, 'dayhour').'</td></tr>';
}
print '</table>';
// Files section
$anlagefile = new AnlageFile($db);
$files = $anlagefile->fetchAllByAnlage($anlageId);
print '<br><h4>'.$langs->trans('AttachedFiles').'</h4>';
if ($permissiontoadd) {
print '<form method="POST" enctype="multipart/form-data" action="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=uploadfile&anlage_id='.$anlageId.'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="file" name="userfile" accept="image/*,.pdf,.doc,.docx">';
print ' <button type="submit" class="button">'.$langs->trans('Upload').'</button>';
print '</form><br>';
}
if (!empty($files)) {
print '<div class="kundenkarte-files-grid">';
foreach ($files as $file) {
print '<div class="kundenkarte-file-item">';
print '<div class="kundenkarte-file-preview">';
if ($file->file_type == 'image') {
$thumbUrl = $file->getThumbUrl();
if ($thumbUrl) {
print '<img src="'.$thumbUrl.'" alt="">';
} else {
print '<img src="'.$file->getUrl().'" alt="" style="max-width:100%;max-height:100%;">';
}
} elseif ($file->file_type == 'pdf') {
// PDF preview using iframe - 50% smaller, no toolbar
print '<div class="kundenkarte-pdf-preview-wrapper">';
print '<iframe src="'.$file->getUrl().'#page=1&toolbar=0&navpanes=0&statusbar=0&view=FitH" class="kundenkarte-pdf-preview-frame"></iframe>';
print '</div>';
} else {
print '<i class="fa fa-file-o" style="font-size:48px;color:#999;"></i>';
}
print '</div>';
print '<div class="kundenkarte-file-info">';
print '<div class="kundenkarte-file-name" title="'.dol_escape_htmltag($file->filename).'">'.dol_escape_htmltag(dol_trunc($file->filename, 20)).'</div>';
print '<div class="kundenkarte-file-size">'.dol_print_size($file->filesize).'</div>';
print '<div class="kundenkarte-file-actions">';
print '<a href="'.$file->getUrl().'" target="_blank" class="kundenkarte-file-btn" title="'.$langs->trans('View').'"><i class="fa fa-eye"></i></a>';
if ($permissiontodelete) {
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=askdeletefile&anlage_id='.$anlageId.'&fileid='.$file->id.'" class="kundenkarte-file-btn kundenkarte-file-btn-delete" title="'.$langs->trans('Delete').'"><i class="fa fa-trash"></i></a>';
}
print '</div>';
print '</div>';
print '</div>';
}
print '</div>';
} else {
print '<p class="opacitymedium">'.$langs->trans('NoFiles').'</p>';
}
// Equipment section (only if type can have equipment)
if ($type->can_have_equipment) {
print '<br><h4><i class="fa fa-microchip"></i> '.$langs->trans('Equipment').' - Schaltplan</h4>';
// Equipment container - nur noch der SchematicEditor
print '<div class="kundenkarte-equipment-container" data-anlage-id="'.$anlageId.'" data-system-id="'.$systemId.'">';
// Schematic Editor - Hauptansicht
print '<div class="schematic-editor-wrapper">';
print '<div class="schematic-editor-header" style="display:flex;justify-content:space-between;align-items:center;padding:10px 15px;background:#252525;border:1px solid #333;border-radius:4px 4px 0 0;">';
print '<div style="color:#3498db;">';
print '<strong>'.$langs->trans('SchematicEditor').'</strong> <span style="color:#888;font-size:0.85em;">(Klick auf Block = Bearbeiten | Drag = Verschieben | + = Hinzufügen)</span>';
print '</div>';
print '<div class="schematic-editor-actions" style="display:flex;gap:10px;align-items:center;">';
// Zoom controls
print '<div class="schematic-zoom-controls" style="display:flex;gap:2px;align-items:center;background:#222;border-radius:3px;padding:2px;">';
print '<button type="button" class="schematic-zoom-out" style="width:28px;height:28px;background:#333;border:1px solid #555;border-radius:3px;color:#fff;cursor:pointer;" title="Verkleinern (Ctrl+Scroll)"><i class="fa fa-minus"></i></button>';
print '<span class="schematic-zoom-level" style="min-width:45px;text-align:center;color:#888;font-size:12px;">100%</span>';
print '<button type="button" class="schematic-zoom-in" style="width:28px;height:28px;background:#333;border:1px solid #555;border-radius:3px;color:#fff;cursor:pointer;" title="Vergrößern (Ctrl+Scroll)"><i class="fa fa-plus"></i></button>';
print '<button type="button" class="schematic-zoom-fit" style="width:28px;height:28px;background:#333;border:1px solid #555;border-radius:3px;color:#3498db;cursor:pointer;margin-left:3px;" title="Einpassen"><i class="fa fa-compress"></i></button>';
print '<button type="button" class="schematic-zoom-reset" style="width:28px;height:28px;background:#333;border:1px solid #555;border-radius:3px;color:#888;cursor:pointer;" title="100%"><i class="fa fa-search"></i></button>';
print '</div>';
// Manual wire draw toggle
print '<button type="button" class="schematic-wire-draw-toggle" style="padding:5px 10px;background:#333;border:1px solid #555;border-radius:3px;color:#27ae60;cursor:pointer;" title="Manueller Zeichenmodus: Leitungen selbst zeichnen mit Raster-Snap">';
print '<i class="fa fa-pencil"></i> Manuell zeichnen';
print '</button>';
print '<button type="button" class="schematic-add-busbar" style="padding:5px 10px;background:#333;border:1px solid #555;border-radius:3px;color:#f39c12;cursor:pointer;" title="Phasenschiene hinzufügen">';
print '<i class="fa fa-arrows-h"></i> Phasenschiene';
print '</button>';
print '<button type="button" class="schematic-clear-connections" style="padding:5px 10px;background:#333;border:1px solid #555;border-radius:3px;color:#e74c3c;cursor:pointer;">';
print '<i class="fa fa-trash"></i> Alle Verbindungen löschen';
print '</button>';
// BOM (Stückliste) button
print '<button type="button" class="schematic-bom-generate" style="padding:5px 10px;background:#333;border:1px solid #555;border-radius:3px;color:#9b59b6;cursor:pointer;" title="Stückliste (Material) aus Schaltplan generieren">';
print '<i class="fa fa-list-alt"></i> Stückliste';
print '</button>';
// Audit Log button
print '<button type="button" class="schematic-audit-log" style="padding:5px 10px;background:#333;border:1px solid #555;border-radius:3px;color:#95a5a6;cursor:pointer;" title="Änderungsprotokoll anzeigen">';
print '<i class="fa fa-history"></i> Protokoll';
print '</button>';
// PDF Export button
$pdfExportUrl = dol_buildpath('/kundenkarte/ajax/export_schematic_pdf.php', 1).'?anlage_id='.$anlageId.'&format=A4&orientation=L';
print '<a href="'.$pdfExportUrl.'" target="_blank" class="schematic-export-pdf" style="padding:5px 10px;background:#333;border:1px solid #555;border-radius:3px;color:#3498db;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:5px;" title="PDF Export (Leitungslaufplan nach DIN EN 61082)">';
print '<i class="fa fa-file-pdf-o"></i> PDF Export';
print '</a>';
print '</div>';
print '</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>';
print '</div>'; // .kundenkarte-equipment-container
// Initialize SchematicEditor JavaScript
print '<script>
$(document).ready(function() {
if (typeof KundenKarte !== "undefined" && KundenKarte.SchematicEditor) {
KundenKarte.SchematicEditor.init('.$anlageId.');
}
});
</script>';
}
// Action buttons
print '<div class="tabsAction">';
if ($permissiontoadd) {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=edit&anlage_id='.$anlageId.'">'.$langs->trans('Modify').'</a>';
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=copy&anlage_id='.$anlageId.'">'.$langs->trans('Copy').'</a>';
}
if ($permissiontodelete) {
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=delete&anlage_id='.$anlageId.'">'.$langs->trans('Delete').'</a>';
}
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'">'.$langs->trans('Back').'</a>';
print '</div>';
} else {
// Create/Edit/Copy form
$isEdit = ($action == 'edit');
$isCopy = ($action == 'copy');
$formAction = $isEdit ? 'update' : 'add';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="'.$formAction.'">';
if ($isEdit) {
print '<input type="hidden" name="anlage_id" value="'.$anlageId.'">';
}
if ($isCopy) {
print '<input type="hidden" name="copy_from" value="'.$anlageId.'">';
}
print '<table class="border centpercent" id="element_form_table">';
// Label
$labelValue = '';
if ($isEdit) {
$labelValue = $anlage->label;
} elseif ($isCopy) {
$labelValue = $anlage->label.' (Kopie)';
} else {
$labelValue = GETPOST('label');
}
print '<tr><td class="titlefield fieldrequired">'.$langs->trans('Label').'</td>';
print '<td><input type="text" name="label" class="flat minwidth300" value="'.dol_escape_htmltag($labelValue).'" required></td></tr>';
// Kategorie (Gebäude/Standort vs Element/Gerät)
$currentCategory = '';
if (($isEdit || $isCopy) && !empty($anlage->fk_anlage_type)) {
// Kategorie des aktuellen Typs ermitteln
foreach ($types as $t) {
if ($t->id == $anlage->fk_anlage_type) {
$currentCategory = ($t->system_code === 'GLOBAL') ? 'building' : 'element';
break;
}
}
}
$postedCategory = GETPOST('element_category', 'alpha');
if ($postedCategory) $currentCategory = $postedCategory;
print '<tr><td class="fieldrequired">'.$langs->trans('Category').'</td>';
print '<td><select name="element_category" class="flat minwidth200" id="select_category">';
print '<option value="">'.$langs->trans('SelectCategory').'</option>';
print '<option value="building"'.($currentCategory === 'building' ? ' selected' : '').'>'.$langs->trans('BuildingStructure').'</option>';
print '<option value="element"'.($currentCategory === 'element' ? ' selected' : '').'>'.$langs->trans('TechnicalElement').'</option>';
print '</select></td></tr>';
// Type (gefiltert nach Kategorie)
print '<tr id="row_type"><td class="fieldrequired">'.$langs->trans('Type').'</td>';
print '<td><select name="fk_anlage_type" class="flat minwidth200" id="select_type" required>';
print '<option value="">'.$langs->trans('SelectType').'</option>';
// Typen nach Kategorie gruppieren
$buildingTypes = array();
$elementTypes = array();
foreach ($types as $t) {
if ($t->system_code === 'GLOBAL') {
$buildingTypes[] = $t;
} else {
$elementTypes[] = $t;
}
}
// Gebäude-Typen nach level_type gruppieren (position-basiert)
if (!empty($buildingTypes)) {
$lastGroup = '';
foreach ($buildingTypes as $t) {
// Gruppierung nach Position: 10-99=Gebäude, 100-199=Etage, 200-299=Flügel, 300-399=Flur, 400-599=Raum, 600+=Außen
if ($t->position < 100) $group = $langs->trans('BuildingLevelBuilding');
elseif ($t->position < 200) $group = $langs->trans('BuildingLevelFloor');
elseif ($t->position < 300) $group = $langs->trans('BuildingLevelWing');
elseif ($t->position < 400) $group = $langs->trans('BuildingLevelCorridor');
elseif ($t->position < 600) $group = $langs->trans('BuildingLevelRoom');
else $group = $langs->trans('BuildingLevelArea');
if ($group !== $lastGroup) {
if ($lastGroup !== '') print '</optgroup>';
print '<optgroup label="'.dol_escape_htmltag($group).'" class="type-category-building">';
$lastGroup = $group;
}
$selected = (($isEdit || $isCopy) && $anlage->fk_anlage_type == $t->id) ? ' selected' : '';
$picto = !empty($t->picto) ? dol_escape_htmltag($t->picto) : '';
$color = !empty($t->color) ? dol_escape_htmltag($t->color) : '';
print '<option value="'.$t->id.'" data-category="building" data-icon="'.$picto.'" data-color="'.$color.'"'.$selected.'>'.dol_escape_htmltag($t->label).'</option>';
}
if ($lastGroup !== '') print '</optgroup>';
}
// Element-Typen
if (!empty($elementTypes)) {
print '<optgroup label="'.$langs->trans('TechnicalElement').'" class="type-category-element">';
foreach ($elementTypes as $t) {
$selected = (($isEdit || $isCopy) && $anlage->fk_anlage_type == $t->id) ? ' selected' : '';
$picto = !empty($t->picto) ? dol_escape_htmltag($t->picto) : '';
$color = !empty($t->color) ? dol_escape_htmltag($t->color) : '';
print '<option value="'.$t->id.'" data-category="element" data-icon="'.$picto.'" data-color="'.$color.'"'.$selected.'>'.dol_escape_htmltag($t->label).'</option>';
}
print '</optgroup>';
}
print '</select>';
if (empty($types)) {
print '<br><span class="warning">'.$langs->trans('NoTypesDefinedForSystem').'</span>';
}
print '</td></tr>';
// Parent - for copy, use same parent as original
$tree = $anlage->fetchTree($id, $systemId);
$selectedParent = $isEdit || $isCopy ? $anlage->fk_parent : $parentId;
$excludeId = $isEdit ? $anlageId : 0;
print '<tr><td>'.$langs->trans('SelectParent').'</td>';
print '<td><select name="fk_parent" class="flat minwidth200">';
print '<option value="0">('.$langs->trans('Root').')</option>';
printTreeOptions($tree, $selectedParent, $excludeId);
print '</select></td></tr>';
// Dynamic fields will be inserted here via JavaScript
print '<tbody id="dynamic_fields"></tbody>';
// Notes (always at the end)
print '<tr class="notes-row"><td>'.$langs->trans('FieldNotes').'</td>';
$noteValue = ($isEdit || $isCopy) ? $anlage->note_private : (isset($_POST['note_private']) ? $_POST['note_private'] : '');
print '<td><textarea name="note_private" class="flat minwidth300" rows="3">'.htmlspecialchars($noteValue, ENT_QUOTES, 'UTF-8').'</textarea></td></tr>';
print '</table>';
print '<div class="center" style="margin-top:20px;">';
print '<button type="submit" class="button button-save">'.$langs->trans('Save').'</button>';
print ' <a class="button button-cancel" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'">'.$langs->trans('Cancel').'</a>';
print '</div>';
print '</form>';
// JavaScript: Kategorie-Filter + Select2 mit Icons
print '<script>
$(document).ready(function() {
var $catSelect = $("#select_category");
var $typeSelect = $("#select_type");
// Alle Options und Optgroups als HTML-String sichern
var allOptionsHtml = $typeSelect.html();
// Select2 Template-Funktion mit Icons
function formatTypeOption(option) {
if (!option.id) return option.text; // Placeholder
var $opt = $(option.element);
var icon = $opt.data("icon");
var color = $opt.data("color") || "#666";
if (icon) {
return $("<span><i class=\"fa " + icon + "\" style=\"color:" + color + ";width:20px;margin-right:8px;text-align:center;\"></i>" + option.text + "</span>");
}
return option.text;
}
// Select2 initialisieren
function initSelect2() {
// Falls bereits initialisiert, zerstören
if ($typeSelect.hasClass("select2-hidden-accessible")) {
$typeSelect.select2("destroy");
}
$typeSelect.select2({
templateResult: formatTypeOption,
templateSelection: formatTypeOption,
placeholder: "'.dol_escape_js($langs->trans('SelectType')).'",
allowClear: true,
width: "300px",
dropdownAutoWidth: true
});
}
function filterTypes() {
var category = $catSelect.val();
var currentVal = $typeSelect.val();
// Select2 zerstören vor DOM-Änderungen
if ($typeSelect.hasClass("select2-hidden-accessible")) {
$typeSelect.select2("destroy");
}
// Alle Options zurücksetzen
$typeSelect.html(allOptionsHtml);
if (!category) {
$typeSelect.prop("disabled", true);
$("#row_type").hide();
return;
}
// Nicht passende Options entfernen
$typeSelect.find("option[data-category]").each(function() {
if ($(this).data("category") !== category) {
$(this).remove();
}
});
// Leere Optgroups entfernen
$typeSelect.find("optgroup").each(function() {
if ($(this).find("option").length === 0) {
$(this).remove();
}
});
$typeSelect.prop("disabled", false);
$("#row_type").show();
// Wert wiederherstellen falls noch vorhanden
if (currentVal && $typeSelect.find("option[value=\"" + currentVal + "\"]").length) {
$typeSelect.val(currentVal);
} else {
$typeSelect.val("");
}
// Select2 neu initialisieren
initSelect2();
}
$catSelect.on("change", function() {
$typeSelect.val("");
filterTypes();
$typeSelect.trigger("change");
});
// Initial filtern
if ($catSelect.val()) {
filterTypes();
} else {
$typeSelect.prop("disabled", true);
$("#row_type").hide();
}
});
</script>';
}
print '</div>';
} else {
// Tree view
if ($permissiontoadd) {
print '<div style="margin-bottom:15px;">';
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=create">';
print '<i class="fa fa-plus"></i> '.$langs->trans('AddElement');
print '</a>';
print '</div>';
}
// Load tree
$tree = $anlage->fetchTree($id, $systemId);
// Pre-load all type fields for tooltip and tree display
$typeFieldsMap = array();
$sql = "SELECT f.*, f.fk_anlage_type FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field f WHERE f.active = 1 ORDER BY f.position ASC";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
if (!isset($typeFieldsMap[$obj->fk_anlage_type])) {
$typeFieldsMap[$obj->fk_anlage_type] = array();
}
$typeFieldsMap[$obj->fk_anlage_type][] = $obj;
}
$db->free($resql);
}
// Pre-load all connections for this customer/system
dol_include_once('/kundenkarte/class/anlageconnection.class.php');
$connObj = new AnlageConnection($db);
$allConnections = $connObj->fetchBySociete($id, $systemId);
// Index by target_id for quick lookup (connection shows ABOVE the target element)
$connectionsByTarget = array();
foreach ($allConnections as $conn) {
if (!isset($connectionsByTarget[$conn->fk_target])) {
$connectionsByTarget[$conn->fk_target] = array();
}
$connectionsByTarget[$conn->fk_target][] = $conn;
}
if (!empty($tree)) {
print '<div class="kundenkarte-tree" data-system="'.$systemId.'" data-socid="'.$id.'">';
printTree($tree, $id, $systemId, $permissiontoadd, $permissiontodelete, $langs, 0, $typeFieldsMap, $connectionsByTarget);
print '</div>';
} else {
print '<div class="opacitymedium">'.$langs->trans('NoInstallations').'</div>';
}
}
}
print '</div>';
print dol_get_fiche_end();
// Tooltip container
print '<div id="kundenkarte-tooltip" class="kundenkarte-tooltip"></div>';
llxFooter();
$db->close();
/**
* Print tree recursively
*/
function printTree($nodes, $socid, $systemId, $canEdit, $canDelete, $langs, $level = 0, $typeFieldsMap = array(), $connectionsByTarget = array())
{
foreach ($nodes as $node) {
$hasChildren = !empty($node->children);
$fieldValues = $node->getFieldValues();
// Build tooltip data - only label, type and dynamic fields
$tooltipData = array(
'label' => $node->label,
'type' => $node->type_label,
'note_html' => $node->note_private ? nl2br(htmlspecialchars($node->note_private, ENT_QUOTES, 'UTF-8')) : '',
'fields' => array()
);
// Collect fields for tooltip (show_in_hover) and tree label (show_in_tree)
$treeInfo = array();
if (!empty($typeFieldsMap[$node->fk_anlage_type])) {
foreach ($typeFieldsMap[$node->fk_anlage_type] as $fieldDef) {
// Handle header fields
if ($fieldDef->field_type === 'header') {
if ($fieldDef->show_in_hover) {
$tooltipData['fields'][$fieldDef->field_code] = array(
'label' => $fieldDef->field_label,
'value' => '',
'type' => 'header'
);
}
continue;
}
$value = isset($fieldValues[$fieldDef->field_code]) ? $fieldValues[$fieldDef->field_code] : '';
// Add to tooltip if show_in_hover
if ($fieldDef->show_in_hover && $value !== '') {
// Format date values
$displayValue = $value;
if ($fieldDef->field_type === 'date' && $value) {
$displayValue = dol_print_date(strtotime($value), 'day');
}
$tooltipData['fields'][$fieldDef->field_code] = array(
'label' => $fieldDef->field_label,
'value' => $displayValue,
'type' => $fieldDef->field_type
);
}
// Add to tree label info if show_in_tree
if ($fieldDef->show_in_tree && $value !== '') {
// Format date for tree info too
if ($fieldDef->field_type === 'date' && $value) {
$treeInfo[] = dol_print_date(strtotime($value), 'day');
} else {
$treeInfo[] = $value;
}
}
}
}
// Show cable connections TO this node (BEFORE the element - cable appears above verbraucher)
$hasConnection = !empty($connectionsByTarget[$node->id]);
if ($hasConnection) {
foreach ($connectionsByTarget[$node->id] as $conn) {
// Build cable info (type + spec + length)
$cableInfo = '';
if ($conn->medium_type_label || $conn->medium_type_text) {
$cableInfo = $conn->medium_type_label ?: $conn->medium_type_text;
}
if ($conn->medium_spec) {
$cableInfo .= ($cableInfo ? ' ' : '').$conn->medium_spec;
}
if ($conn->medium_length) {
$cableInfo .= ' ('.$conn->medium_length.')';
}
// If label exists, show cable info as badge. Otherwise show cable info as main text
$mainText = $conn->label ? $conn->label : $cableInfo;
$badgeText = $conn->label ? $cableInfo : '';
$connEditUrl = dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?id='.$conn->id.'&socid='.$socid.'&system_id='.$systemId;
print '<a href="'.$connEditUrl.'" class="kundenkarte-tree-conn">';
print '<span class="conn-icon"><i class="fa fa-plug"></i></span>';
if ($mainText) {
print '<span class="conn-main">'.dol_escape_htmltag($mainText).'</span>';
}
if ($badgeText) {
print ' <span class="conn-label">'.dol_escape_htmltag($badgeText).'</span>';
}
print '</a>';
}
}
// CSS class based on whether node has its own cable connection
$nodeClass = 'kundenkarte-tree-node';
if (!$hasConnection && $level > 0) {
$nodeClass .= ' no-cable'; // durchgeschleift - kein eigenes Kabel
}
print '<div class="'.$nodeClass.'">';
print '<div class="kundenkarte-tree-item" data-anlage-id="'.$node->id.'">';
// Toggle
if ($hasChildren) {
print '<span class="kundenkarte-tree-toggle"><i class="fa fa-chevron-down"></i></span>';
} else {
print '<span class="kundenkarte-tree-toggle" style="visibility:hidden;"><i class="fa fa-chevron-down"></i></span>';
}
// Icon with tooltip data
$picto = $node->type_picto ? $node->type_picto : 'fa-cube';
print '<span class="kundenkarte-tree-icon kundenkarte-tooltip-trigger" data-tooltip="'.htmlspecialchars(json_encode($tooltipData), ENT_QUOTES, 'UTF-8').'" data-anlage-id="'.$node->id.'">'.kundenkarte_render_icon($picto).'</span>';
// Label with tree info in parentheses + file indicators
$viewUrl = $_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=view&anlage_id='.$node->id;
print '<span class="kundenkarte-tree-label">'.dol_escape_htmltag($node->label);
if (!empty($treeInfo)) {
print ' <span class="kundenkarte-tree-label-info">('.dol_escape_htmltag(implode(', ', $treeInfo)).')</span>';
}
// File indicators - directly after parentheses
if ($node->image_count > 0 || $node->doc_count > 0) {
print ' <span class="kundenkarte-tree-files">';
if ($node->image_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-images kundenkarte-images-trigger" data-anlage-id="'.$node->id.'" title="'.$node->image_count.' '.($node->image_count == 1 ? 'Bild' : 'Bilder').'">';
print '<i class="fa fa-image"></i>';
if ($node->image_count > 1) {
print ' '.$node->image_count;
}
print '</a>';
}
if ($node->doc_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-docs kundenkarte-docs-trigger" data-anlage-id="'.$node->id.'" title="'.$node->doc_count.' '.($node->doc_count == 1 ? 'Dokument' : 'Dokumente').'">';
print '<i class="fa fa-file-pdf-o"></i>';
if ($node->doc_count > 1) {
print ' '.$node->doc_count;
}
print '</a>';
}
print '</span>';
}
print '</span>';
// Type badge
if ($node->type_short || $node->type_label) {
$typeDisplay = $node->type_short ? $node->type_short : $node->type_label;
print '<span class="kundenkarte-tree-type badge badge-secondary">'.dol_escape_htmltag($typeDisplay).'</span>';
}
// Actions
print '<span class="kundenkarte-tree-actions">';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=view&anlage_id='.$node->id.'" title="'.$langs->trans('View').'"><i class="fa fa-eye"></i></a>';
if ($canEdit) {
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=create&parent_id='.$node->id.'" title="'.$langs->trans('AddChild').'"><i class="fa fa-plus"></i></a>';
print '<a href="#" class="anlage-connection-add" data-anlage-id="'.$node->id.'" data-soc-id="'.$socid.'" data-system-id="'.$systemId.'" title="'.$langs->trans('AddCableConnection').'"><i class="fa fa-plug"></i></a>';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=edit&anlage_id='.$node->id.'" title="'.$langs->trans('Edit').'"><i class="fa fa-edit"></i></a>';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=copy&anlage_id='.$node->id.'" title="'.$langs->trans('Copy').'"><i class="fa fa-copy"></i></a>';
}
if ($canDelete) {
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=delete&anlage_id='.$node->id.'" title="'.$langs->trans('Delete').'" class="deletelink"><i class="fa fa-trash"></i></a>';
}
print '</span>';
print '</div>';
// Children - vertical tree layout with multiple parallel cable lines
if ($hasChildren) {
// First pass: assign cable index to each child with cable
$cableCount = 0;
$childCableIndex = array(); // child_id => cable_index
foreach ($node->children as $child) {
if (!empty($connectionsByTarget[$child->id])) {
$cableCount++;
$childCableIndex[$child->id] = $cableCount;
}
}
print '<div class="kundenkarte-tree-children" data-cable-count="'.$cableCount.'">';
// Render children - each row has cable line columns on the left
foreach ($node->children as $child) {
$myCableIdx = isset($childCableIndex[$child->id]) ? $childCableIndex[$child->id] : 0;
// Find which cable lines are still active (run past this row to children below)
// These are cables from children that appear AFTER this one in the list
$activeLinesAfter = array();
$foundCurrent = false;
foreach ($node->children as $c) {
if ($c->id == $child->id) {
$foundCurrent = true;
continue;
}
if ($foundCurrent && isset($childCableIndex[$c->id])) {
$activeLinesAfter[] = $childCableIndex[$c->id];
}
}
printTreeWithCableLines(array($child), $socid, $systemId, $canEdit, $canDelete, $langs, $level + 1, $typeFieldsMap, $connectionsByTarget, $myCableIdx, $cableCount, $activeLinesAfter);
}
print '</div>';
}
print '</div>';
}
}
/**
* Print tree with multiple parallel cable lines
* Each child with its own cable gets a vertical line that runs from top past all siblings
*/
function printTreeWithCableLines($nodes, $socid, $systemId, $canEdit, $canDelete, $langs, $level = 0, $typeFieldsMap = array(), $connectionsByTarget = array(), $myCableIndex = 0, $totalCables = 0, $activeLinesAfter = array())
{
foreach ($nodes as $node) {
$hasChildren = !empty($node->children);
$hasConnection = !empty($connectionsByTarget[$node->id]);
$fieldValues = $node->getFieldValues();
// Build tooltip data
$tooltipData = array(
'label' => $node->label,
'type' => $node->type_label,
'note_html' => $node->note_private ? nl2br(htmlspecialchars($node->note_private, ENT_QUOTES, 'UTF-8')) : '',
'fields' => array()
);
$treeInfo = array();
if (!empty($typeFieldsMap[$node->fk_anlage_type])) {
foreach ($typeFieldsMap[$node->fk_anlage_type] as $fieldDef) {
if ($fieldDef->field_type === 'header') continue;
$value = isset($fieldValues[$fieldDef->field_code]) ? $fieldValues[$fieldDef->field_code] : '';
if ($fieldDef->show_in_hover && $value !== '') {
$displayValue = $value;
if ($fieldDef->field_type === 'date' && $value) {
$displayValue = dol_print_date(strtotime($value), 'day');
}
$tooltipData['fields'][$fieldDef->field_code] = array(
'label' => $fieldDef->field_label,
'value' => $displayValue,
'type' => $fieldDef->field_type
);
}
if ($fieldDef->show_in_tree && $value !== '') {
$treeInfo[] = ($fieldDef->field_type === 'date' && $value) ? dol_print_date(strtotime($value), 'day') : $value;
}
}
}
// All lines that need to be drawn for this row (active lines passing through + my line if I have cable)
$allLines = $activeLinesAfter;
if ($hasConnection && $myCableIndex > 0) {
$allLines[] = $myCableIndex;
}
sort($allLines);
// Cable connection row (BEFORE the element)
if ($hasConnection) {
foreach ($connectionsByTarget[$node->id] as $conn) {
$cableInfo = '';
if ($conn->medium_type_label || $conn->medium_type_text) {
$cableInfo = $conn->medium_type_label ?: $conn->medium_type_text;
}
if ($conn->medium_spec) {
$cableInfo .= ($cableInfo ? ' ' : '').$conn->medium_spec;
}
if ($conn->medium_length) {
$cableInfo .= ' ('.$conn->medium_length.')';
}
$mainText = $conn->label ? $conn->label : $cableInfo;
$badgeText = $conn->label ? $cableInfo : '';
$connEditUrl = dol_buildpath('/kundenkarte/anlage_connection.php', 1).'?id='.$conn->id.'&socid='.$socid.'&system_id='.$systemId;
print '<div class="kundenkarte-tree-row">';
// Draw vertical line columns (for cables passing through)
// Reverse order: index 1 (first element) = rightmost, highest index = leftmost
for ($i = $totalCables; $i >= 1; $i--) {
$isActive = in_array($i, $activeLinesAfter);
$isMyLine = ($i == $myCableIndex);
$lineClass = 'cable-line';
$inlineStyle = '';
if ($isActive) {
$lineClass .= ' active'; // Line continues down
}
if ($isMyLine) {
$lineClass .= ' my-line conn-line'; // This is my cable line - ends here with horizontal
// Calculate width: columns to the right * 15px + 8px to reach element border
$columnsToRight = $i - 1;
$horizontalWidth = ($columnsToRight * 15) + 8;
$inlineStyle = ' style="--h-width: '.$horizontalWidth.'px;"';
}
print '<span class="'.$lineClass.'"'.$inlineStyle.' data-line="'.$i.'"></span>';
}
print '<a href="'.$connEditUrl.'" class="kundenkarte-tree-conn-content">';
print '<span class="conn-icon"><i class="fa fa-plug"></i></span>';
if ($mainText) {
print '<span class="conn-main">'.dol_escape_htmltag($mainText).'</span>';
}
if ($badgeText) {
print ' <span class="conn-label">'.dol_escape_htmltag($badgeText).'</span>';
}
print '</a>';
print '</div>';
}
}
// Node row
$nodeClass = 'kundenkarte-tree-row node-row';
if (!$hasConnection && $level > 0) {
$nodeClass .= ' no-cable';
}
print '<div class="'.$nodeClass.'">';
// Draw vertical line columns (for cables passing through)
// Reverse order: index 1 (first element) = rightmost, highest index = leftmost
for ($i = $totalCables; $i >= 1; $i--) {
$isActive = in_array($i, $activeLinesAfter);
$isMyLine = ($i == $myCableIndex && $hasConnection);
$lineClass = 'cable-line';
$inlineStyle = '';
if ($isActive) {
$lineClass .= ' active';
}
if ($isMyLine) {
$lineClass .= ' my-line node-line'; // Horizontal connector to this node
// Calculate width: columns to the right * 15px + 8px to reach element border
$columnsToRight = $i - 1;
$horizontalWidth = ($columnsToRight * 15) + 8;
$inlineStyle = ' style="--h-width: '.$horizontalWidth.'px;"';
}
print '<span class="'.$lineClass.'"'.$inlineStyle.' data-line="'.$i.'"></span>';
}
print '<div class="kundenkarte-tree-node-content">';
print '<div class="kundenkarte-tree-item" data-anlage-id="'.$node->id.'">';
if ($hasChildren) {
print '<span class="kundenkarte-tree-toggle"><i class="fa fa-chevron-down"></i></span>';
} else {
print '<span class="kundenkarte-tree-toggle" style="visibility:hidden;"><i class="fa fa-chevron-down"></i></span>';
}
$picto = $node->type_picto ? $node->type_picto : 'fa-cube';
print '<span class="kundenkarte-tree-icon kundenkarte-tooltip-trigger" data-tooltip="'.htmlspecialchars(json_encode($tooltipData), ENT_QUOTES, 'UTF-8').'" data-anlage-id="'.$node->id.'">'.kundenkarte_render_icon($picto).'</span>';
$viewUrl = $_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=view&anlage_id='.$node->id;
print '<span class="kundenkarte-tree-label">'.dol_escape_htmltag($node->label);
if (!empty($treeInfo)) {
print ' <span class="kundenkarte-tree-label-info">('.dol_escape_htmltag(implode(', ', $treeInfo)).')</span>';
}
if ($node->image_count > 0 || $node->doc_count > 0) {
print ' <span class="kundenkarte-tree-files">';
if ($node->image_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-images" data-anlage-id="'.$node->id.'">';
print '<i class="fa fa-image"></i>';
if ($node->image_count > 1) print ' '.$node->image_count;
print '</a>';
}
if ($node->doc_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-docs" data-anlage-id="'.$node->id.'">';
print '<i class="fa fa-file-pdf-o"></i>';
if ($node->doc_count > 1) print ' '.$node->doc_count;
print '</a>';
}
print '</span>';
}
print '</span>';
if ($node->type_short || $node->type_label) {
$typeDisplay = $node->type_short ? $node->type_short : $node->type_label;
print '<span class="kundenkarte-tree-type badge badge-secondary">'.dol_escape_htmltag($typeDisplay).'</span>';
}
print '<span class="kundenkarte-tree-actions">';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=view&anlage_id='.$node->id.'" title="'.$langs->trans('View').'"><i class="fa fa-eye"></i></a>';
if ($canEdit) {
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=create&parent_id='.$node->id.'" title="'.$langs->trans('AddChild').'"><i class="fa fa-plus"></i></a>';
print '<a href="#" class="anlage-connection-add" data-anlage-id="'.$node->id.'" data-soc-id="'.$socid.'" data-system-id="'.$systemId.'" title="'.$langs->trans('AddCableConnection').'"><i class="fa fa-plug"></i></a>';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=edit&anlage_id='.$node->id.'" title="'.$langs->trans('Edit').'"><i class="fa fa-edit"></i></a>';
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=copy&anlage_id='.$node->id.'" title="'.$langs->trans('Copy').'"><i class="fa fa-copy"></i></a>';
}
if ($canDelete) {
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=delete&anlage_id='.$node->id.'" title="'.$langs->trans('Delete').'" class="deletelink"><i class="fa fa-trash"></i></a>';
}
print '</span>';
print '</div>';
print '</div>'; // node-content
print '</div>'; // tree-row
// Children
if ($hasChildren) {
// First pass: assign cable index to each child with cable
$childCableCount = 0;
$childCableIndex = array(); // child_id => cable_index
foreach ($node->children as $child) {
if (!empty($connectionsByTarget[$child->id])) {
$childCableCount++;
$childCableIndex[$child->id] = $childCableCount;
}
}
print '<div class="kundenkarte-tree-children" data-cable-count="'.$childCableCount.'">';
$prevHadCable = false;
$isFirst = true;
foreach ($node->children as $child) {
$myCableIdx = isset($childCableIndex[$child->id]) ? $childCableIndex[$child->id] : 0;
$hasOwnCable = !empty($connectionsByTarget[$child->id]);
// Add spacer row before elements with their own cable (except first)
if ($hasOwnCable && !$isFirst) {
// Spacer row with active cable lines passing through
$spacerActiveLines = array();
$foundCurrent = false;
foreach ($node->children as $c) {
if ($c->id == $child->id) {
$foundCurrent = true;
}
if ($foundCurrent && isset($childCableIndex[$c->id])) {
$spacerActiveLines[] = $childCableIndex[$c->id];
}
}
print '<div class="kundenkarte-tree-row spacer-row">';
for ($i = $childCableCount; $i >= 1; $i--) {
$isActive = in_array($i, $spacerActiveLines);
$lineClass = 'cable-line';
if ($isActive) {
$lineClass .= ' active';
}
print '<span class="'.$lineClass.'" data-line="'.$i.'"></span>';
}
print '<div class="kundenkarte-tree-node-content"></div>';
print '</div>';
}
// Find which cable lines are still active (run past this row to children below)
$childActiveLinesAfter = array();
$foundCurrent = false;
foreach ($node->children as $c) {
if ($c->id == $child->id) {
$foundCurrent = true;
continue;
}
if ($foundCurrent && isset($childCableIndex[$c->id])) {
$childActiveLinesAfter[] = $childCableIndex[$c->id];
}
}
printTreeWithCableLines(array($child), $socid, $systemId, $canEdit, $canDelete, $langs, $level + 1, $typeFieldsMap, $connectionsByTarget, $myCableIdx, $childCableCount, $childActiveLinesAfter);
$prevHadCable = $hasOwnCable;
$isFirst = false;
}
print '</div>';
}
}
}
/**
* Print single tree node (helper for connection line rendering)
*/
function printTreeNode($node, $socid, $systemId, $canEdit, $canDelete, $langs, $level = 0, $typeFieldsMap = array(), $connectionsByTarget = array())
{
// Wrap single node in array and call printTree
printTree(array($node), $socid, $systemId, $canEdit, $canDelete, $langs, $level, $typeFieldsMap, $connectionsByTarget);
}
/**
* Print tree options for select
*/
function printTreeOptions($nodes, $selected = 0, $excludeId = 0, $prefix = '')
{
foreach ($nodes as $node) {
if ($node->id == $excludeId) continue;
$sel = ($node->id == $selected) ? ' selected' : '';
print '<option value="'.$node->id.'"'.$sel.'>'.$prefix.dol_escape_htmltag($node->label).'</option>';
if (!empty($node->children)) {
printTreeOptions($node->children, $selected, $excludeId, $prefix.'&nbsp;&nbsp;&nbsp;');
}
}
}
/**
* Render panel HTML with its carriers
*
* @param EquipmentPanel $panel Panel object
* @param Translate $langs Language object
* @param bool $canEdit Can edit permission
* @param bool $canDelete Can delete permission
* @param DoliDB $db Database handler
* @return string HTML output
*/
function renderPanelHTML($panel, $langs, $canEdit, $canDelete, $db)
{
$html = '<div class="kundenkarte-panel" data-panel-id="'.$panel->id.'">';
// Panel header
$html .= '<div class="kundenkarte-panel-header">';
$html .= '<span class="kundenkarte-panel-label">'.dol_escape_htmltag($panel->label ?: $langs->trans('PanelLabel')).'</span>';
$html .= '<span class="kundenkarte-panel-actions">';
if ($canEdit) {
$html .= '<a href="#" class="kundenkarte-add-carrier" data-anlage-id="'.$panel->fk_anlage.'" data-panel-id="'.$panel->id.'" title="'.$langs->trans('AddCarrier').'"><i class="fa fa-plus"></i></a> ';
$html .= '<a href="#" class="kundenkarte-panel-edit" title="'.$langs->trans('Edit').'"><i class="fa fa-edit"></i></a> ';
}
if ($canDelete) {
$html .= '<a href="#" class="kundenkarte-panel-delete" title="'.$langs->trans('Delete').'"><i class="fa fa-trash"></i></a> ';
}
$html .= '</span>';
$html .= '</div>';
// Load and render carriers for this panel
$panel->fetchCarriers();
$html .= '<div class="kundenkarte-carriers-list">';
if (empty($panel->carriers)) {
$html .= '<div class="opacitymedium" style="padding:10px;">'.$langs->trans('NoCarriers').'</div>';
} else {
foreach ($panel->carriers as $carrier) {
$carrier->fetchEquipment();
$html .= renderCarrierHTML($carrier, $langs, $canEdit, $canDelete);
}
}
// Quick-duplicate last carrier button
if ($canEdit && !empty($panel->carriers)) {
$lastCarrier = end($panel->carriers);
$html .= '<div class="kundenkarte-carrier-quickadd" data-carrier-id="'.$lastCarrier->id.'" title="'.$langs->trans('DuplicatePreviousCarrier').'">';
$html .= '<i class="fa fa-plus"></i>';
$html .= '</div>';
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
/**
* Render carrier HTML for equipment display
*
* @param EquipmentCarrier $carrier Carrier object with equipment loaded
* @param Translate $langs Language object
* @param bool $canEdit Can edit permission
* @param bool $canDelete Can delete permission
* @return string HTML output
*/
function renderCarrierHTML($carrier, $langs, $canEdit, $canDelete)
{
global $db;
$TE_WIDTH = 50; // Width of one TE in pixels (wider for more space)
$BLOCK_HEIGHT = 110; // Height of equipment block
$BRACKET_HEIGHT = 28; // Height reserved for protection brackets (top)
$RAIL_HEIGHT = 50; // Height for rail brackets
$OUTPUT_HEIGHT = 60; // Height for output labels (includes medium info)
$PADDING_LEFT = 10; // Left padding
$PADDING_RIGHT = 10; // Right padding
$totalWidth = $carrier->total_te * $TE_WIDTH + $PADDING_LEFT + $PADDING_RIGHT;
// Load connections (rails and outputs)
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentconnection.class.php';
$connectionObj = new EquipmentConnection($db);
$connections = $connectionObj->fetchByCarrier($carrier->id);
// Separate rails and outputs
$rails = array();
$outputs = array();
$equipmentById = array();
if (!empty($carrier->equipment)) {
foreach ($carrier->equipment as $eq) {
$equipmentById[$eq->id] = $eq;
}
}
$railsAbove = array();
$railsBelow = array();
foreach ($connections as $conn) {
if ($conn->is_rail) {
if ($conn->position_y < 0) {
$railsAbove[] = $conn;
} else {
$railsBelow[] = $conn;
}
}
}
$rails = array_merge($railsAbove, $railsBelow);
// Load outputs for equipment
if (!empty($carrier->equipment)) {
foreach ($carrier->equipment as $eq) {
$eqOutputs = $connectionObj->fetchOutputs($eq->id);
foreach ($eqOutputs as $out) {
$out->source_pos = $eq->position_te;
$out->source_width = $eq->width_te;
$outputs[] = $out;
}
}
}
// Group equipment by protection device to calculate brackets
$protectionGroups = array();
if (!empty($carrier->equipment)) {
foreach ($carrier->equipment as $eq) {
if ($eq->fk_protection > 0) {
if (!isset($protectionGroups[$eq->fk_protection])) {
$protectionGroups[$eq->fk_protection] = array(
'label' => $eq->protection_label ?: '',
'equipment' => array(),
'protection_device' => null
);
if (isset($equipmentById[$eq->fk_protection])) {
$protectionGroups[$eq->fk_protection]['protection_device'] = $equipmentById[$eq->fk_protection];
}
}
$protectionGroups[$eq->fk_protection]['equipment'][] = $eq;
}
}
}
$hasProtectionGroups = !empty($protectionGroups);
$hasRailsAbove = !empty($railsAbove);
$hasRailsBelow = !empty($railsBelow);
$hasOutputs = !empty($outputs);
// Calculate SVG height and offsets
// New layout: Outputs (top) -> Protection brackets -> Equipment -> Rails (bottom)
$svgHeight = $BLOCK_HEIGHT;
$blockYOffset = 0;
// Space for outputs ABOVE equipment (at the top)
if ($hasOutputs) {
$svgHeight += $OUTPUT_HEIGHT;
$blockYOffset += $OUTPUT_HEIGHT;
}
// Space for rails above (if any)
if ($hasRailsAbove) {
$svgHeight += $RAIL_HEIGHT;
$blockYOffset += $RAIL_HEIGHT;
}
// Space for FI protection brackets above equipment
if ($hasProtectionGroups) {
$svgHeight += $BRACKET_HEIGHT;
$blockYOffset += $BRACKET_HEIGHT;
}
// Space for rails below equipment
if ($hasRailsBelow) {
$svgHeight += $RAIL_HEIGHT;
}
// Add extra padding at bottom for rail labels
$svgHeight += 15;
$html = '<div class="kundenkarte-carrier" data-carrier-id="'.$carrier->id.'">';
// Header
$html .= '<div class="kundenkarte-carrier-header">';
$html .= '<span class="kundenkarte-carrier-label">'.dol_escape_htmltag($carrier->label ?: $langs->trans('CarrierLabel')).'</span>';
$html .= '<span class="kundenkarte-carrier-info">'.$carrier->getUsedTE().'/'.$carrier->total_te.' TE '.$langs->trans('UsedTE').'</span>';
$html .= '<span class="kundenkarte-carrier-actions">';
if ($canEdit) {
$html .= '<a href="#" class="kundenkarte-carrier-add-equipment" title="'.$langs->trans('AddEquipment').'"><i class="fa fa-plus"></i></a>';
$html .= '<a href="#" class="kundenkarte-carrier-edit" title="'.$langs->trans('Edit').'"><i class="fa fa-edit"></i></a>';
}
if ($canDelete) {
$html .= '<a href="#" class="kundenkarte-carrier-delete" title="'.$langs->trans('Delete').'"><i class="fa fa-trash"></i></a>';
}
$html .= '</span>';
$html .= '</div>';
// SVG Rail
$html .= '<div class="kundenkarte-carrier-svg-container">';
$html .= '<svg class="kundenkarte-carrier-svg" width="'.$totalWidth.'" height="'.$svgHeight.'" viewBox="0 0 '.$totalWidth.' '.$svgHeight.'">';
// Draw protection brackets (FI brackets)
// These are drawn just above the equipment blocks
$protBracketYStart = 0;
if ($hasOutputs) $protBracketYStart += $OUTPUT_HEIGHT;
if ($hasRailsAbove) $protBracketYStart += $RAIL_HEIGHT;
foreach ($protectionGroups as $protId => $group) {
if (count($group['equipment']) > 0) {
// Find leftmost and rightmost positions (including FI device)
$minPos = PHP_INT_MAX;
$maxPos = 0;
// Include the protection device (FI) in the bracket
if (!empty($group['protection_device'])) {
$fi = $group['protection_device'];
if ($fi->position_te < $minPos) $minPos = $fi->position_te;
$endPos = $fi->position_te + $fi->width_te - 1;
if ($endPos > $maxPos) $maxPos = $endPos;
}
// Include all protected equipment
foreach ($group['equipment'] as $eq) {
if ($eq->position_te < $minPos) $minPos = $eq->position_te;
$endPos = $eq->position_te + $eq->width_te - 1;
if ($endPos > $maxPos) $maxPos = $endPos;
}
$bracketX1 = $PADDING_LEFT + ($minPos - 1) * $TE_WIDTH + 2;
$bracketX2 = $PADDING_LEFT + $maxPos * $TE_WIDTH - 2;
$bracketWidth = $bracketX2 - $bracketX1;
$bracketTopY = $protBracketYStart + 3;
$bracketBottomY = $protBracketYStart + $BRACKET_HEIGHT - 2;
// Draw bracket line with corners (opens downward toward equipment)
$html .= '<path d="M'.$bracketX1.' '.$bracketBottomY.' L'.$bracketX1.' '.$bracketTopY.' L'.$bracketX2.' '.$bracketTopY.' L'.$bracketX2.' '.$bracketBottomY.'" ';
$html .= 'fill="none" stroke="#e74c3c" stroke-width="2" stroke-linecap="round"/>';
// Draw label if exists
if (!empty($group['label'])) {
$labelX = $bracketX1 + $bracketWidth / 2;
$html .= '<text x="'.$labelX.'" y="'.($bracketBottomY - 3).'" text-anchor="middle" ';
$html .= 'fill="#e74c3c" font-size="11px" font-weight="bold">';
$html .= dol_escape_htmltag($group['label']);
$html .= '</text>';
}
}
}
// Background grid (TE markers)
for ($i = 0; $i <= $carrier->total_te; $i++) {
$x = $PADDING_LEFT + $i * $TE_WIDTH;
$html .= '<line x1="'.$x.'" y1="'.$blockYOffset.'" x2="'.$x.'" y2="'.($blockYOffset + $BLOCK_HEIGHT).'" stroke="#555" stroke-width="0.5"/>';
}
// Track occupied slots
$occupiedSlots = array();
// Render equipment blocks
if (!empty($carrier->equipment)) {
foreach ($carrier->equipment as $eq) {
// Mark slots as occupied
for ($i = 0; $i < $eq->width_te; $i++) {
$occupiedSlots[$eq->position_te + $i] = true;
}
$x = $PADDING_LEFT + ($eq->position_te - 1) * $TE_WIDTH;
$width = $eq->width_te * $TE_WIDTH;
$color = $eq->getBlockColor() ?: '#3498db';
$label = $eq->getBlockLabel() ?: ($eq->type_label_short ?: '');
// Build tooltip data
$fieldValues = $eq->getFieldValues();
$tooltipData = array(
'label' => $eq->label,
'type' => $eq->type_label,
'fields' => $fieldValues,
'protection' => $eq->protection_device_label ?: ''
);
$html .= '<g class="kundenkarte-equipment-block" data-equipment-id="'.$eq->id.'" ';
$html .= 'data-tooltip=\''.htmlspecialchars(json_encode($tooltipData), ENT_QUOTES, 'UTF-8').'\' ';
$html .= 'draggable="true" style="cursor:pointer;">';
// Block rectangle with rounded corners - more padding between blocks
$html .= '<rect x="'.($x + 4).'" y="'.($blockYOffset + 5).'" width="'.($width - 8).'" height="'.($BLOCK_HEIGHT - 10).'" ';
$html .= 'rx="5" ry="5" fill="'.$color.'" stroke="#333" stroke-width="1"/>';
// Label text (centered)
$fontSize = $width < 50 ? 12 : 15;
$html .= '<text x="'.($x + $width/2).'" y="'.($blockYOffset + $BLOCK_HEIGHT/2).'" ';
$html .= 'text-anchor="middle" dominant-baseline="middle" ';
$html .= 'fill="#fff" font-size="'.$fontSize.'px" font-weight="bold">';
$html .= dol_escape_htmltag($label);
$html .= '</text>';
$html .= '</g>';
}
}
// Helper function to render a rail
$renderRail = function($rail, $yStart, $isAbove, $equipmentList, $teWidth, $railHeight, $paddingLeft) {
$html = '';
$x1 = $paddingLeft + ($rail->rail_start_te - 1) * $teWidth + 5;
$x2 = $paddingLeft + ($rail->rail_end_te) * $teWidth - 5;
$railWidth = $x2 - $x1;
$color = $rail->getColor();
// Parse excluded TE positions for gaps (e.g., for FI switches)
$excludedPositions = array();
if (!empty($rail->excluded_te)) {
$excludedPositions = array_map('intval', explode(',', $rail->excluded_te));
}
// Check for multi-phase rail
$railPhases = $rail->rail_phases ?: '';
$phaseColors = array();
if ($railPhases === '3P' || $railPhases === '3P+N' || $railPhases === '3P+N+PE') {
$phaseColors = array(
array('color' => '#8B4513', 'label' => 'L1'), // Brown
array('color' => '#1a1a1a', 'label' => 'L2'), // Black
array('color' => '#666666', 'label' => 'L3'), // Grey
);
if ($railPhases === '3P+N' || $railPhases === '3P+N+PE') {
$phaseColors[] = array('color' => '#0066CC', 'label' => 'N'); // Blue
}
} elseif ($railPhases === 'L1' || $railPhases === 'L1N') {
$phaseColors = array(
array('color' => '#8B4513', 'label' => 'L1'),
);
if ($railPhases === 'L1N') {
$phaseColors[] = array('color' => '#0066CC', 'label' => 'N');
}
}
$html .= '<g class="kundenkarte-rail" data-connection-id="'.$rail->id.'" style="cursor:pointer;">';
if ($isAbove) {
$bracketY1 = $yStart + $railHeight - 2;
$bracketY2 = $yStart + 8;
$labelY = $yStart + 18;
} else {
$bracketY1 = $yStart + 5;
$bracketY2 = $yStart + $railHeight - 8;
$labelY = $bracketY2 + 14;
}
// Multi-phase rendering: multiple parallel horizontal lines
if (!empty($phaseColors)) {
$lineSpacing = 4;
$totalHeight = (count($phaseColors) - 1) * $lineSpacing;
$baseY = $bracketY2 - $totalHeight / 2;
// Excluded positions: For FI gaps, only N is excluded, L1/L2/L3 pass through
// Format: "4" = N excluded at TE 4, "4,5" = N excluded at TE 4 and 5
$nExcludedAtTE = $excludedPositions; // These positions have N gaps
// Draw each phase line (L1, L2, L3 always continuous, N has gaps)
foreach ($phaseColors as $idx => $phase) {
$lineY = $baseY + ($idx * $lineSpacing);
$phaseLabel = $phase['label'];
if ($phaseLabel === 'N' && !empty($nExcludedAtTE)) {
// N line has gaps at excluded positions
$segments = array();
$segStart = $rail->rail_start_te;
for ($te = $rail->rail_start_te; $te <= $rail->rail_end_te; $te++) {
if (in_array($te, $nExcludedAtTE)) {
if ($segStart < $te) {
$segments[] = array('start' => $segStart, 'end' => $te - 1);
}
$segStart = $te + 1;
}
}
if ($segStart <= $rail->rail_end_te) {
$segments[] = array('start' => $segStart, 'end' => $rail->rail_end_te);
}
foreach ($segments as $seg) {
$segX1 = $paddingLeft + ($seg['start'] - 1) * $teWidth + 5;
$segX2 = $paddingLeft + ($seg['end']) * $teWidth - 5;
$html .= '<line x1="'.$segX1.'" y1="'.$lineY.'" x2="'.$segX2.'" y2="'.$lineY.'" ';
$html .= 'stroke="'.$phase['color'].'" stroke-width="2" stroke-linecap="round"/>';
}
} else {
// L1, L2, L3 are continuous (no gaps)
$html .= '<line x1="'.$x1.'" y1="'.$lineY.'" x2="'.$x2.'" y2="'.$lineY.'" ';
$html .= 'stroke="'.$phase['color'].'" stroke-width="2" stroke-linecap="round"/>';
}
// Vertical end caps
$html .= '<line x1="'.$x1.'" y1="'.$bracketY1.'" x2="'.$x1.'" y2="'.$lineY.'" ';
$html .= 'stroke="'.$phase['color'].'" stroke-width="2"/>';
$html .= '<line x1="'.$x2.'" y1="'.$bracketY1.'" x2="'.$x2.'" y2="'.$lineY.'" ';
$html .= 'stroke="'.$phase['color'].'" stroke-width="2"/>';
}
// Vertical taps to equipment - individual colored taps per pole
if (!empty($equipmentList)) {
foreach ($equipmentList as $eq) {
$eqEnd = $eq->position_te + $eq->width_te - 1;
if ($eq->position_te >= $rail->rail_start_te && $eqEnd <= $rail->rail_end_te) {
$eqCenter = $paddingLeft + ($eq->position_te - 1) * $teWidth + ($eq->width_te * $teWidth / 2);
// Determine pole count from equipment
$poleCount = 3; // Default: 3-pole (HS, LS etc.)
$typeLabel = strtolower($eq->type_label ?? '');
$typeRef = strtolower($eq->type_ref ?? '');
// FI/RCD = 4-pole (needs N)
if (strpos($typeLabel, 'fi') !== false || strpos($typeRef, 'fi') !== false ||
strpos($typeLabel, 'rcd') !== false) {
$poleCount = 4;
}
// Check field values for explicit pole setting
if (!empty($eq->field_values)) {
$fields = is_array($eq->field_values) ? $eq->field_values : @json_decode($eq->field_values, true);
if (isset($fields['pole']) && intval($fields['pole']) > 0) {
$poleCount = intval($fields['pole']);
}
}
// Is N excluded at this equipment's position?
$nExcludedHere = in_array($eq->position_te, $nExcludedAtTE);
// Draw individual colored taps, spread across equipment width
$numTaps = min($poleCount, count($phaseColors));
$tapSpacing = min(4, ($eq->width_te * $teWidth - 10) / max(1, $numTaps - 1));
$tapStartX = $eqCenter - (($numTaps - 1) * $tapSpacing / 2);
for ($p = 0; $p < $numTaps; $p++) {
$phase = $phaseColors[$p];
$lineY = $baseY + ($p * $lineSpacing);
$tapX = $tapStartX + ($p * $tapSpacing);
// Skip N tap if N is excluded here (FI gap position)
if ($phase['label'] === 'N' && $nExcludedHere) {
continue;
}
// Vertical tap line
$html .= '<line x1="'.$tapX.'" y1="'.$bracketY1.'" x2="'.$tapX.'" y2="'.$lineY.'" ';
$html .= 'stroke="'.$phase['color'].'" stroke-width="1.5"/>';
// Connection dot
$html .= '<circle cx="'.$tapX.'" cy="'.$lineY.'" r="2" fill="'.$phase['color'].'"/>';
}
}
}
}
} else {
// Single line rail (original U-bracket style) - with gap support
$strokeWidth = 3;
$connType = strtoupper($rail->connection_type ?: '');
if (strpos($connType, 'L1L2L3') !== false || strpos($connType, '3P') !== false) {
$strokeWidth = 5;
if (strpos($connType, 'N') !== false) $strokeWidth = 6;
if (strpos($connType, 'PE') !== false) $strokeWidth = 7;
}
if (empty($excludedPositions)) {
// No gaps - draw simple U-bracket
$html .= '<path d="M'.$x1.' '.$bracketY1.' L'.$x1.' '.$bracketY2.' L'.$x2.' '.$bracketY2.' L'.$x2.' '.$bracketY1.'" ';
$html .= 'fill="none" stroke="'.$color.'" stroke-width="'.$strokeWidth.'" stroke-linecap="round"/>';
} else {
// Draw with gaps
$segments = array();
$segStart = $rail->rail_start_te;
for ($te = $rail->rail_start_te; $te <= $rail->rail_end_te; $te++) {
if (in_array($te, $excludedPositions)) {
if ($segStart < $te) {
$segments[] = array('start' => $segStart, 'end' => $te - 1);
}
$segStart = $te + 1;
}
}
if ($segStart <= $rail->rail_end_te) {
$segments[] = array('start' => $segStart, 'end' => $rail->rail_end_te);
}
// Draw vertical lines at ends
$html .= '<line x1="'.$x1.'" y1="'.$bracketY1.'" x2="'.$x1.'" y2="'.$bracketY2.'" ';
$html .= 'stroke="'.$color.'" stroke-width="'.$strokeWidth.'" stroke-linecap="round"/>';
$html .= '<line x1="'.$x2.'" y1="'.$bracketY1.'" x2="'.$x2.'" y2="'.$bracketY2.'" ';
$html .= 'stroke="'.$color.'" stroke-width="'.$strokeWidth.'" stroke-linecap="round"/>';
// Draw horizontal segments
foreach ($segments as $seg) {
$segX1 = $paddingLeft + ($seg['start'] - 1) * $teWidth + 5;
$segX2 = $paddingLeft + ($seg['end']) * $teWidth - 5;
$html .= '<line x1="'.$segX1.'" y1="'.$bracketY2.'" x2="'.$segX2.'" y2="'.$bracketY2.'" ';
$html .= 'stroke="'.$color.'" stroke-width="'.$strokeWidth.'" stroke-linecap="round"/>';
}
}
// Vertical taps to equipment
if (!empty($equipmentList)) {
foreach ($equipmentList as $eq) {
$eqEnd = $eq->position_te + $eq->width_te - 1;
$eqExcluded = false;
for ($p = $eq->position_te; $p <= $eqEnd; $p++) {
if (in_array($p, $excludedPositions)) {
$eqExcluded = true;
break;
}
}
if (!$eqExcluded && $eq->position_te >= $rail->rail_start_te && $eqEnd <= $rail->rail_end_te) {
$eqCenter = $paddingLeft + ($eq->position_te - 1) * $teWidth + ($eq->width_te * $teWidth / 2);
$html .= '<line x1="'.$eqCenter.'" y1="'.$bracketY1.'" x2="'.$eqCenter.'" y2="'.$bracketY2.'" ';
$html .= 'stroke="'.$color.'" stroke-width="2"/>';
}
}
}
}
// Label - show rail_phases if set, otherwise connection_type
$labelText = $railPhases ?: $rail->connection_type;
if ($labelText) {
$labelX = $x1 + $railWidth / 2;
$labelColor = !empty($phaseColors) ? '#888' : $color;
$html .= '<text x="'.$labelX.'" y="'.$labelY.'" text-anchor="middle" font-size="12" fill="'.$labelColor.'" font-weight="bold">';
$html .= dol_escape_htmltag($labelText);
$html .= '</text>';
}
$html .= '</g>';
return $html;
};
// === RENDER ORDER: Outputs (top) -> Rails above -> Protection brackets -> Equipment -> Rails below ===
// 1. Render OUTPUTS at the TOP (above everything else)
if ($hasOutputs) {
$outputY = 0;
foreach ($outputs as $out) {
$eqCenter = $PADDING_LEFT + ($out->source_pos - 1) * $TE_WIDTH + ($out->source_width * $TE_WIDTH / 2);
$color = $out->getColor();
$html .= '<g class="kundenkarte-output" data-connection-id="'.$out->id.'" style="cursor:pointer;">';
// Label at top
$displayLabel = $out->output_label ?: '';
if ($displayLabel) {
// Medium info as smaller text
$mediumInfo = '';
if ($out->medium_type) {
$mediumInfo = $out->medium_type;
if ($out->medium_spec) $mediumInfo .= ' '.$out->medium_spec;
}
// Main label - larger font, more spacing
$html .= '<text x="'.$eqCenter.'" y="'.($outputY + 18).'" text-anchor="middle" font-size="13" fill="#ddd" font-weight="bold">';
$html .= dol_escape_htmltag($displayLabel);
$html .= '</text>';
// Medium info below label
if ($mediumInfo) {
$html .= '<text x="'.$eqCenter.'" y="'.($outputY + 36).'" text-anchor="middle" font-size="11" fill="#999">';
$html .= dol_escape_htmltag($mediumInfo);
$html .= '</text>';
}
}
// Vertical line from label DOWN to equipment block
$lineStartY = $outputY + $OUTPUT_HEIGHT - 8;
$lineEndY = $blockYOffset;
$html .= '<line x1="'.$eqCenter.'" y1="'.$lineStartY.'" x2="'.$eqCenter.'" y2="'.$lineEndY.'" ';
$html .= 'stroke="'.$color.'" stroke-width="2" stroke-dasharray="4,2"/>';
$html .= '</g>';
}
}
// 2. Render rails ABOVE equipment (if any)
if ($hasRailsAbove) {
$railAboveYStart = $hasOutputs ? $OUTPUT_HEIGHT : 0;
foreach ($railsAbove as $rail) {
$html .= $renderRail($rail, $railAboveYStart, true, $carrier->equipment, $TE_WIDTH, $RAIL_HEIGHT, $PADDING_LEFT);
}
}
// 3. Render rails BELOW equipment (at the bottom)
$currentY = $blockYOffset + $BLOCK_HEIGHT;
if ($hasRailsBelow) {
foreach ($railsBelow as $rail) {
$html .= $renderRail($rail, $currentY, false, $carrier->equipment, $TE_WIDTH, $RAIL_HEIGHT, $PADDING_LEFT);
}
}
$html .= '</svg>';
// Clickable slots overlay (for adding equipment)
if ($canEdit) {
$html .= '<div class="kundenkarte-carrier-slots" style="width:'.$totalWidth.'px;height:'.$svgHeight.'px;">';
// Find the last equipment and next free position for quick-duplicate button
$lastEquipment = null;
$nextFreePos = 1;
if (!empty($carrier->equipment)) {
$lastEquipment = end($carrier->equipment);
// Find the position right after the last equipment
$nextFreePos = $lastEquipment->position_te + $lastEquipment->width_te;
}
for ($pos = 1; $pos <= $carrier->total_te; $pos++) {
if (!isset($occupiedSlots[$pos])) {
$slotLeft = $PADDING_LEFT + ($pos - 1) * $TE_WIDTH;
// Check if this is the "quick duplicate" position (first free slot after last equipment)
if ($lastEquipment && $pos == $nextFreePos && ($pos + $lastEquipment->width_te - 1) <= $carrier->total_te) {
// Quick duplicate button - copies the last equipment
$html .= '<div class="kundenkarte-slot-quickadd" data-position="'.$pos.'" data-equipment-id="'.$lastEquipment->id.'" ';
$html .= 'style="left:'.$slotLeft.'px;width:'.($lastEquipment->width_te * $TE_WIDTH).'px;" ';
$html .= 'title="'.$langs->trans('DuplicatePrevious').'">';
$html .= '<i class="fa fa-plus"></i>';
$html .= '</div>';
// Skip the slots that would be covered by this button
$pos += $lastEquipment->width_te - 1;
} else {
// Regular empty slot
$html .= '<div class="kundenkarte-slot-empty" data-position="'.$pos.'" style="left:'.$slotLeft.'px;width:'.$TE_WIDTH.'px;"></div>';
}
}
}
$html .= '</div>';
}
$html .= '</div>'; // svg-container
// Connection Editor Wrapper
$html .= '<div class="kundenkarte-connection-editor-wrapper">';
// Toggle button and action buttons
$html .= '<div class="kundenkarte-connection-editor-header">';
$html .= '<a href="#" class="kundenkarte-connection-editor-toggle">';
$html .= '<i class="fa fa-chevron-down"></i> '.$langs->trans('ConnectionEditor');
$html .= '</a>';
if ($canEdit) {
$html .= '<div class="kundenkarte-connection-editor-actions">';
$html .= '<button type="button" class="kundenkarte-add-busbar-btn" data-carrier-id="'.$carrier->id.'" title="'.$langs->trans('AddBusbar').'">';
$html .= '<i class="fa fa-minus"></i> '.$langs->trans('Busbar');
$html .= '</button>';
$html .= '<button type="button" class="kundenkarte-add-conn-btn" data-carrier-id="'.$carrier->id.'" title="'.$langs->trans('AddConnection').'">';
$html .= '<i class="fa fa-link"></i> '.$langs->trans('Connection');
$html .= '</button>';
$html .= '</div>';
}
$html .= '</div>'; // header
// Editor canvas area (initially hidden)
$totalWidth = $carrier->total_te * $TE_WIDTH;
$html .= '<div class="kundenkarte-connection-editor" data-carrier-id="'.$carrier->id.'" data-total-te="'.$carrier->total_te.'">';
$html .= '<svg class="kundenkarte-connection-svg" width="'.$totalWidth.'" height="120">';
$html .= '</svg>';
$html .= '</div>'; // editor
$html .= '</div>'; // editor-wrapper
$html .= '</div>'; // carrier
return $html;
}
/**
* Render connections HTML for a carrier
*
* @param EquipmentCarrier $carrier Carrier object
* @param Translate $langs Language object
* @param bool $canEdit Can edit permission
* @param int $teWidth Width of one TE in pixels
* @return string HTML output
*/
function renderConnectionsHTML($carrier, $langs, $canEdit, $teWidth)
{
global $db;
require_once DOL_DOCUMENT_ROOT.'/custom/kundenkarte/class/equipmentconnection.class.php';
$connectionObj = new EquipmentConnection($db);
$connections = $connectionObj->fetchByCarrier($carrier->id);
// Also get outputs for equipment on this carrier
$outputs = array();
if (!empty($carrier->equipment)) {
foreach ($carrier->equipment as $eq) {
$eqOutputs = $connectionObj->fetchOutputs($eq->id);
foreach ($eqOutputs as $out) {
$out->source_pos = $eq->position_te;
$out->source_width = $eq->width_te;
$outputs[] = $out;
}
}
}
// If no connections and no outputs, don't render container
if (empty($connections) && empty($outputs) && !$canEdit) {
return '';
}
$totalWidth = $carrier->total_te * $teWidth;
$outputRowHeight = 25; // Height for output labels (25% larger)
$railRowHeight = 23; // Height for rail (25% larger)
// Count rails and determine layout
$rails = array();
$otherConnections = array();
foreach ($connections as $conn) {
if ($conn->is_rail) {
$rails[] = $conn;
} else {
$otherConnections[] = $conn;
}
}
// Layout: Outputs (row 0) -> Rails (row 1) -> Other connections (row 2+)
$hasOutputs = !empty($outputs);
$hasRails = !empty($rails);
// Check for multi-phase rails (need more height)
$hasMultiPhaseRail = false;
foreach ($rails as $r) {
if (!empty($r->rail_phases) && in_array($r->rail_phases, array('3P', '3P+N', '3P+N+PE'))) {
$hasMultiPhaseRail = true;
break;
}
}
$actualRailHeight = $hasMultiPhaseRail ? ($railRowHeight + 8) : $railRowHeight;
$svgHeight = 10;
if ($hasOutputs) $svgHeight += $outputRowHeight;
if ($hasRails) $svgHeight += $actualRailHeight;
if ($canEdit && !$hasOutputs && !$hasRails) $svgHeight = 30;
$html = '<div class="kundenkarte-connections-container" data-carrier-id="'.$carrier->id.'">';
$html .= '<svg class="kundenkarte-connections-svg" width="'.$totalWidth.'" height="'.$svgHeight.'" viewBox="0 0 '.$totalWidth.' '.$svgHeight.'">';
// Y positions
$outputY = 5; // Outputs at top
$railY = $hasOutputs ? ($outputRowHeight + 8) : 10;
// 1. Render outputs (Abgänge) - at top, closest to equipment
foreach ($outputs as $out) {
$eqCenter = ($out->source_pos - 1) * $teWidth + ($out->source_width * $teWidth / 2);
$color = $out->getColor();
$html .= '<g class="kundenkarte-output" data-connection-id="'.$out->id.'" style="cursor:pointer;">';
// Small vertical line down from equipment
$html .= '<line class="kundenkarte-output-line" x1="'.$eqCenter.'" y1="0" x2="'.$eqCenter.'" y2="'.($outputY + 8).'" stroke="'.$color.'" stroke-width="2" stroke-dasharray="3,2"/>';
// Label
$displayLabel = $out->getDisplayLabel();
if ($displayLabel) {
$html .= '<text class="kundenkarte-output-label" x="'.($eqCenter + 3).'" y="'.($outputY + 12).'" font-size="11" fill="#aaa">';
$html .= dol_escape_htmltag($displayLabel);
$html .= '</text>';
}
$html .= '</g>';
}
// 2. Render rails (Sammelschienen) - below outputs
foreach ($rails as $conn) {
$x1 = ($conn->rail_start_te - 1) * $teWidth + $teWidth/2;
$x2 = ($conn->rail_end_te - 1) * $teWidth + $teWidth/2;
$color = $conn->getColor();
// Parse excluded TE positions
$excludedPositions = array();
if (!empty($conn->excluded_te)) {
$excludedPositions = array_map('intval', explode(',', $conn->excluded_te));
}
$html .= '<g class="kundenkarte-rail" data-connection-id="'.$conn->id.'" style="cursor:pointer;">';
// Check for multi-phase rail
$railPhases = $conn->rail_phases ?: '';
$phaseLines = array();
if ($railPhases === '3P' || $railPhases === '3P+N' || $railPhases === '3P+N+PE') {
$phaseLines = array(
array('color' => '#8B4513', 'label' => 'L1'), // Brown
array('color' => '#1a1a1a', 'label' => 'L2'), // Black
array('color' => '#666666', 'label' => 'L3'), // Grey
);
if ($railPhases === '3P+N' || $railPhases === '3P+N+PE') {
$phaseLines[] = array('color' => '#0066CC', 'label' => 'N'); // Blue
}
} elseif ($railPhases === 'L1' || $railPhases === 'L1N') {
$phaseLines = array(
array('color' => '#8B4513', 'label' => 'L1'),
);
if ($railPhases === 'L1N') {
$phaseLines[] = array('color' => '#0066CC', 'label' => 'N');
}
}
// If multi-phase, draw parallel lines
if (!empty($phaseLines)) {
$lineSpacing = 3; // Space between lines
$totalPhaseHeight = (count($phaseLines) - 1) * $lineSpacing;
$startY = $railY - $totalPhaseHeight / 2;
foreach ($phaseLines as $idx => $phase) {
$lineY = $startY + ($idx * $lineSpacing);
// Draw line segments (with gaps for excluded positions)
$segments = array();
$segmentStart = $conn->rail_start_te;
for ($te = $conn->rail_start_te; $te <= $conn->rail_end_te; $te++) {
if (in_array($te, $excludedPositions)) {
// Close current segment before excluded position
if ($segmentStart < $te) {
$segments[] = array('start' => $segmentStart, 'end' => $te - 1);
}
$segmentStart = $te + 1;
}
}
// Add final segment
if ($segmentStart <= $conn->rail_end_te) {
$segments[] = array('start' => $segmentStart, 'end' => $conn->rail_end_te);
}
// Draw each segment
foreach ($segments as $seg) {
$segX1 = ($seg['start'] - 1) * $teWidth + $teWidth/2;
$segX2 = ($seg['end'] - 1) * $teWidth + $teWidth/2;
$html .= '<line x1="'.$segX1.'" y1="'.$lineY.'" x2="'.$segX2.'" y2="'.$lineY.'" ';
$html .= 'stroke="'.$phase['color'].'" stroke-width="2" stroke-linecap="round"/>';
}
}
} else {
// Single line rail (original behavior) - also with exclusions
$segments = array();
$segmentStart = $conn->rail_start_te;
for ($te = $conn->rail_start_te; $te <= $conn->rail_end_te; $te++) {
if (in_array($te, $excludedPositions)) {
if ($segmentStart < $te) {
$segments[] = array('start' => $segmentStart, 'end' => $te - 1);
}
$segmentStart = $te + 1;
}
}
if ($segmentStart <= $conn->rail_end_te) {
$segments[] = array('start' => $segmentStart, 'end' => $conn->rail_end_te);
}
foreach ($segments as $seg) {
$segX1 = ($seg['start'] - 1) * $teWidth + $teWidth/2;
$segX2 = ($seg['end'] - 1) * $teWidth + $teWidth/2;
$html .= '<line class="kundenkarte-rail-line" ';
$html .= 'x1="'.$segX1.'" y1="'.$railY.'" x2="'.$segX2.'" y2="'.$railY.'" stroke="'.$color.'" stroke-width="4" stroke-linecap="round"/>';
}
}
// Draw taps (small vertical lines connecting to equipment)
if (!empty($carrier->equipment)) {
foreach ($carrier->equipment as $eq) {
$eqCenter = ($eq->position_te - 1) * $teWidth + ($eq->width_te * $teWidth / 2);
// Check if equipment is in rail range and not excluded
$eqInRange = $eqCenter >= $x1 && $eqCenter <= $x2;
$eqNotExcluded = !in_array($eq->position_te, $excludedPositions);
if ($eqInRange && $eqNotExcluded) {
$tapColor = !empty($phaseLines) ? '#666' : $color;
$html .= '<line x1="'.$eqCenter.'" y1="'.($railY - 6).'" x2="'.$eqCenter.'" y2="'.($railY + 6).'" ';
$html .= 'stroke="'.$tapColor.'" stroke-width="1.5"/>';
}
}
}
// Label for rail type
$labelText = $conn->connection_type;
if ($railPhases) {
$labelText = $railPhases;
}
if ($labelText) {
$labelX = ($x1 + $x2) / 2;
$labelColor = !empty($phaseLines) ? '#555' : $color;
$html .= '<text x="'.$labelX.'" y="'.($railY - 10).'" text-anchor="middle" font-size="10" fill="'.$labelColor.'" font-weight="bold">';
$html .= dol_escape_htmltag($labelText);
$html .= '</text>';
}
$html .= '</g>';
}
// Render regular connections (equipment to equipment)
foreach ($connections as $conn) {
if (!$conn->is_rail && $conn->fk_source > 0 && $conn->fk_target > 0) {
// Find source and target positions
$sourcePos = $conn->source_pos;
$targetPos = $conn->target_pos;
if ($sourcePos && $targetPos) {
$x1 = ($sourcePos - 1) * $teWidth + $teWidth/2;
$x2 = ($targetPos - 1) * $teWidth + $teWidth/2;
$y = $conn->position_y * $rowHeight + 12;
$color = $conn->getColor();
$html .= '<g class="kundenkarte-connection" data-connection-id="'.$conn->id.'" style="cursor:pointer;">';
$html .= '<path class="kundenkarte-connection-line" ';
$html .= 'd="M'.$x1.' 0 L'.$x1.' '.$y.' L'.$x2.' '.$y.' L'.$x2.' 0" stroke="'.$color.'" fill="none" stroke-width="2"/>';
$html .= '</g>';
}
}
}
$html .= '</svg>';
// Add connection buttons
if ($canEdit) {
$html .= '<div style="display:flex;gap:10px;flex-wrap:wrap;">';
$html .= '<a href="#" class="kundenkarte-add-connection kundenkarte-add-output-btn" data-carrier-id="'.$carrier->id.'">';
$html .= '<i class="fa fa-arrow-down"></i> '.$langs->trans('AddOutput');
$html .= '</a>';
$html .= '<a href="#" class="kundenkarte-add-connection kundenkarte-add-rail-btn" data-carrier-id="'.$carrier->id.'">';
$html .= '<i class="fa fa-minus"></i> '.$langs->trans('AddRail');
$html .= '</a>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}