kundenkarte/tabs/anlagen.php
data 07e0e2365b Version 4.0.1 - Mobile Navigation, Badge-Farben, Datei-Vorschau
Neue Features:
- Badge-Farben pro Feld konfigurierbar (Admin > Element-Typen)
- Datei-Vorschau Tooltip beim Hover über Datei-Badge
- Mobile/Kompakte Ansicht mit einheitlichen Button-Größen
- Autocomplete für Textfelder
- Backup/Restore für Konfiguration

Bugfixes:
- Dolibarr App Navigation: Vor/Zurück-Pfeile funktionieren jetzt
  (Module akzeptieren id UND socid/contactid Parameter)
- Datei-Badge zeigt jetzt Büroklammer-Icon

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

2519 lines
98 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
// Support both 'id' and 'socid' for compatibility with Dolibarr's customer navigation arrows
$id = GETPOSTINT('id');
if ($id <= 0) {
$id = GETPOSTINT('socid');
}
$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 - id is required
if ($id <= 0) {
// If no id, try to get it from anlage_id
if ($anlageId > 0) {
$tmpAnlage = new Anlage($db);
if ($tmpAnlage->fetch($anlageId) > 0 && $tmpAnlage->fk_soc > 0) {
$id = $tmpAnlage->fk_soc;
// Redirect to include id in URL for proper navigation
$redirectUrl = $_SERVER['PHP_SELF'].'?id='.$id;
if ($systemId > 0) $redirectUrl .= '&system='.$systemId;
if ($action) $redirectUrl .= '&action='.urlencode($action);
if ($anlageId > 0) $redirectUrl .= '&anlage_id='.$anlageId;
header('Location: '.$redirectUrl);
exit;
}
}
// Still no id - redirect to thirdparty list
header('Location: '.DOL_URL_ROOT.'/societe/list.php');
exit;
}
$result = $object->fetch($id);
if ($result <= 0) {
// Thirdparty not found - redirect to list
header('Location: '.DOL_URL_ROOT.'/societe/list.php');
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 (multi-file support)
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);
}
$uploadCount = 0;
if (!empty($_FILES['userfiles']['name'][0])) {
// Multi-file upload
$fileCount = count($_FILES['userfiles']['name']);
for ($i = 0; $i < $fileCount; $i++) {
if ($_FILES['userfiles']['error'][$i] === UPLOAD_ERR_OK && !empty($_FILES['userfiles']['name'][$i])) {
$tmpName = $_FILES['userfiles']['tmp_name'][$i];
$fileName = dol_sanitizeFileName($_FILES['userfiles']['name'][$i]);
$destPath = $upload_dir.'/'.$fileName;
// Handle duplicate filenames
$counter = 1;
$baseName = pathinfo($fileName, PATHINFO_FILENAME);
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
while (file_exists($destPath)) {
$fileName = $baseName.'_'.$counter.'.'.$extension;
$destPath = $upload_dir.'/'.$fileName;
$counter++;
}
if (move_uploaded_file($tmpName, $destPath)) {
// Add to database
$anlagefile = new AnlageFile($db);
$anlagefile->fk_anlage = $anlageId;
$anlagefile->filename = $fileName;
// IMPORTANT: Store ONLY relative path (anlagen/socid/anlageid/filename) - never full path!
$anlagefile->filepath = 'anlagen/'.$anlage->fk_soc.'/'.$anlage->id.'/'.$fileName;
$anlagefile->filesize = $_FILES['userfiles']['size'][$i];
$anlagefile->mimetype = dol_mimetype($fileName);
$anlagefile->create($user);
// Generate thumbnail
$anlagefile->generateThumbnail();
$uploadCount++;
}
}
}
if ($uploadCount > 0) {
$msg = $uploadCount > 1 ? $uploadCount.' Dateien hochgeladen' : 'Datei hochgeladen';
setEventMessages($msg, 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;
}
// Toggle file pinned status
if ($action == 'togglepin' && $permissiontoadd) {
$fileId = GETPOSTINT('fileid');
if ($fileId > 0) {
$anlagefile = new AnlageFile($db);
if ($anlagefile->fetch($fileId) > 0) {
if ($anlagefile->togglePin($user) > 0) {
$msg = $anlagefile->is_pinned ? $langs->trans('FilePinned') : $langs->trans('FileUnpinned');
setEventMessages($msg, null, 'mesgs');
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=view&anlage_id='.$anlageId.'#files');
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">';
// Compact mode toggle (visible on mobile)
print '<button type="button" class="kundenkarte-view-toggle" id="btn-compact-mode" title="Kompakte Ansicht">';
print '<i class="fa fa-compress"></i> <span>Kompakt</span>';
print '</button>';
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.'" id="fileUploadForm">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<div class="kundenkarte-dropzone" id="fileDropzone">';
print '<input type="file" name="userfiles[]" id="fileInput" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.odt,.ods,.txt,.rtf,.zip,.rar,.7z,.tar,.gz" style="display:none;">';
print '<div class="kundenkarte-dropzone-content">';
print '<i class="fa fa-cloud-upload" style="font-size:32px;color:#3498db;margin-bottom:10px;"></i>';
print '<p style="margin:0;color:#888;">Dateien hierher ziehen oder <a href="#" onclick="document.getElementById(\'fileInput\').click();return false;" style="color:#3498db;">durchsuchen</a></p>';
print '<p style="margin:5px 0 0;font-size:11px;color:#666;">Bilder, PDF, Office-Dokumente, ZIP-Archive</p>';
print '</div>';
print '<div class="kundenkarte-dropzone-files" id="selectedFiles" style="display:none;"></div>';
print '</div>';
print '<button type="submit" class="button" id="uploadBtn" style="display:none;margin-top:10px;">'.$langs->trans('Upload').' (<span id="fileCount">0</span> Dateien)</button>';
print '</form><br>';
}
if (!empty($files)) {
print '<div class="kundenkarte-files-grid">';
foreach ($files as $file) {
$pinnedClass = $file->is_pinned ? ' kundenkarte-file-pinned' : '';
print '<div class="kundenkarte-file-item'.$pinnedClass.'">';
if ($file->is_pinned) {
print '<div class="kundenkarte-pin-indicator" title="'.$langs->trans('Pinned').'"><i class="fa fa-thumb-tack"></i></div>';
}
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 {
// Show icon based on file type
$fileIcon = 'fa-file-o';
$iconColor = '#999';
if ($file->file_type == 'archive') {
$fileIcon = 'fa-file-archive-o';
$iconColor = '#f39c12';
} elseif ($file->file_type == 'document') {
$ext = strtolower(pathinfo($file->filename, PATHINFO_EXTENSION));
if (in_array($ext, array('doc', 'docx'))) {
$fileIcon = 'fa-file-word-o';
$iconColor = '#2b579a';
} elseif (in_array($ext, array('xls', 'xlsx'))) {
$fileIcon = 'fa-file-excel-o';
$iconColor = '#217346';
} elseif ($ext == 'txt') {
$fileIcon = 'fa-file-text-o';
$iconColor = '#666';
} else {
$fileIcon = 'fa-file-text-o';
$iconColor = '#3498db';
}
}
print '<i class="fa '.$fileIcon.'" style="font-size:48px;color:'.$iconColor.';"></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, 35)).'</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 ($permissiontoadd) {
$pinClass = $file->is_pinned ? ' kundenkarte-file-btn-pinned' : '';
$pinTitle = $file->is_pinned ? $langs->trans('Unpin') : $langs->trans('Pin');
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=togglepin&anlage_id='.$anlageId.'&fileid='.$file->id.'" class="kundenkarte-file-btn'.$pinClass.'" title="'.$pinTitle.'"><i class="fa fa-thumb-tack"></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();
}
// Select2 für übergeordnetes Element mit Icons
var $parentSelect = $("select[name=\'fk_parent\']");
if ($parentSelect.length) {
$parentSelect.select2({
templateResult: formatTypeOption,
templateSelection: formatTypeOption,
placeholder: "'.dol_escape_js($langs->trans('SelectParent')).'",
allowClear: true,
width: "300px",
dropdownAutoWidth: true
});
}
});
</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)
$treeInfoBadges = array(); // Fields to display as badges (right side)
$treeInfoParentheses = array(); // Fields to display in parentheses (after label)
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
$displayVal = $value;
if ($fieldDef->field_type === 'date' && $value) {
$displayVal = dol_print_date(strtotime($value), 'day');
}
// Store as array with field info
$fieldInfo = array(
'label' => $fieldDef->field_label,
'value' => $displayVal,
'code' => $fieldDef->field_code,
'type' => $fieldDef->field_type,
'color' => $fieldDef->badge_color ?? ''
);
// Sort into badge or parentheses based on field setting
$displayMode = $fieldDef->tree_display_mode ?? 'badge';
if ($displayMode === 'parentheses') {
$treeInfoParentheses[] = $fieldInfo;
} else {
$treeInfoBadges[] = $fieldInfo;
}
}
}
}
// 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 parentheses info (directly after label, only values)
$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);
// Parentheses info directly after label (only values, no field names)
if (!empty($treeInfoParentheses)) {
$infoValues = array();
foreach ($treeInfoParentheses as $info) {
$infoValues[] = dol_escape_htmltag($info['value']);
}
print ' <span class="kundenkarte-tree-label-info">('.implode(', ', $infoValues).')</span>';
}
print '</span>';
// Spacer to push badges to the right
print '<span class="kundenkarte-tree-spacer"></span>';
// Badges (far right, before actions)
$defaultBadgeColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e');
if (!empty($treeInfoBadges)) {
print '<span class="kundenkarte-tree-badges">';
foreach ($treeInfoBadges as $info) {
$badgeIcon = kundenkarte_get_field_icon($info['code'], $info['type']);
// Use field-specific color if set, otherwise global default
$fieldBadgeColor = !empty($info['color']) ? $info['color'] : $defaultBadgeColor;
print '<span class="kundenkarte-tree-badge" title="'.dol_escape_htmltag($info['label']).'" style="background:linear-gradient(135deg, '.$fieldBadgeColor.' 0%, '.kundenkarte_adjust_color($fieldBadgeColor, -20).' 100%);">';
print '<i class="fa '.$badgeIcon.'"></i> '.dol_escape_htmltag($info['value']);
print '</span>';
}
print '</span>';
}
// File indicators
if ($node->image_count > 0 || $node->doc_count > 0) {
$totalFiles = $node->image_count + $node->doc_count;
print '<span class="kundenkarte-tree-files">';
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-all" data-anlage-id="'.$node->id.'" title="'.$totalFiles.' '.($totalFiles == 1 ? 'Datei' : 'Dateien').'">';
print '<i class="fa fa-paperclip"></i> '.$totalFiles;
print '</a>';
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()
);
$treeInfoBadges = array();
$treeInfoParentheses = 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 !== '') {
$displayVal = ($fieldDef->field_type === 'date' && $value) ? dol_print_date(strtotime($value), 'day') : $value;
$fieldInfo = array(
'label' => $fieldDef->field_label,
'value' => $displayVal,
'code' => $fieldDef->field_code,
'type' => $fieldDef->field_type,
'color' => $fieldDef->badge_color ?? ''
);
$displayMode = $fieldDef->tree_display_mode ?? 'badge';
if ($displayMode === 'parentheses') {
$treeInfoParentheses[] = $fieldInfo;
} else {
$treeInfoBadges[] = $fieldInfo;
}
}
}
}
// 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;
// Label with parentheses info (only values, no field names)
print '<span class="kundenkarte-tree-label">'.dol_escape_htmltag($node->label);
if (!empty($treeInfoParentheses)) {
$infoValues = array();
foreach ($treeInfoParentheses as $info) {
$infoValues[] = dol_escape_htmltag($info['value']);
}
print ' <span class="kundenkarte-tree-label-info">('.implode(', ', $infoValues).')</span>';
}
print '</span>';
// Spacer to push badges to the right
print '<span class="kundenkarte-tree-spacer"></span>';
// Badges (far right)
if (!empty($treeInfoBadges)) {
$defaultBadgeColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e');
print '<span class="kundenkarte-tree-badges">';
foreach ($treeInfoBadges as $info) {
$badgeIcon = kundenkarte_get_field_icon($info['code'], $info['type']);
$fieldBadgeColor = !empty($info['color']) ? $info['color'] : $defaultBadgeColor;
print '<span class="kundenkarte-tree-badge" title="'.dol_escape_htmltag($info['label']).'" style="background:linear-gradient(135deg, '.$fieldBadgeColor.' 0%, '.kundenkarte_adjust_color($fieldBadgeColor, -20).' 100%);">';
print '<i class="fa '.$badgeIcon.'"></i> '.dol_escape_htmltag($info['value']);
print '</span>';
}
print '</span>';
}
// File indicators
if ($node->image_count > 0 || $node->doc_count > 0) {
$totalFiles = $node->image_count + $node->doc_count;
print '<span class="kundenkarte-tree-files">';
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-all" data-anlage-id="'.$node->id.'" title="'.$totalFiles.' '.($totalFiles == 1 ? 'Datei' : 'Dateien').'">';
print '<i class="fa fa-paperclip"></i> '.$totalFiles;
print '</a>';
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 = '', $level = 0)
{
foreach ($nodes as $node) {
if ($node->id == $excludeId) continue;
$sel = ($node->id == $selected) ? ' selected' : '';
$icon = !empty($node->type_picto) ? $node->type_picto : 'fa-cube';
$color = !empty($node->type_color) ? $node->type_color : '#888';
print '<option value="'.$node->id.'"'.$sel.' data-icon="'.$icon.'" data-color="'.$color.'">'.$prefix.dol_escape_htmltag($node->label).'</option>';
if (!empty($node->children)) {
printTreeOptions($node->children, $selected, $excludeId, $prefix.'── ', $level + 1);
}
}
}
/**
* 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;
}