Version 3.4.0 - Kategorie-Auswahl, Icons, Sicherheitsfixes

- Kategorie-Select (Gebäude/Standort vs Element/Gerät) beim Erstellen
- Select2 mit FontAwesome-Icons und Farbkodierung für Typ-Auswahl
- GLOBAL-Gebäudetypen aus Admin Element-Typen ausgeblendet (eigener Tab)
- Aktions-Buttons rechtsbündig in der Typ-Verwaltung
- Sicherheits-Fixes: Berechtigungsprüfungen, Path-Traversal, Transaktionen
- Version auf 3.4.0 aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-16 19:57:37 +01:00
parent 37ae45e8f2
commit f2f3393b12
13 changed files with 285 additions and 24 deletions

View file

@ -13,6 +13,9 @@ Das KundenKarte-Modul erweitert Dolibarr um zwei wichtige Funktionen fuer Kunden
### Technische Anlagen (Anlagen)
- Hierarchische Baumstruktur fuer technische Installationen
- Flexible Systemkategorien (z.B. Strom, Internet, Kabel, Sat)
- Kategorie-Auswahl beim Erstellen: Gebaeude/Standort oder Element/Geraet
- Typ-Select mit FontAwesome-Icons und Farbkodierung (Select2)
- Gebaeude-Typen gruppiert nach Ebene (Gebaeude, Etage, Fluegel, Raum, Bereich)
- Konfigurierbare Element-Typen mit individuellen Feldern
- Datei-Upload mit Bild-Vorschau und PDF-Anzeige
- Separate Verwaltung pro Kunde oder pro Kontakt/Adresse (z.B. verschiedene Gebaeude)
@ -63,6 +66,8 @@ Im Admin-Bereich (Home > Setup > Module > KundenKarte) koennen Sie:
- **Anlagen-Systeme**: System-Kategorien anlegen (z.B. Strom, Internet)
- **Element-Typen**: Geraetetypen definieren (z.B. Zaehler, Router, Wallbox)
- **Typ-Felder**: Individuelle Felder pro Geraetetyp konfigurieren
- **Gebaeudetypen**: Strukturtypen (Haus, Etage, Raum etc.) fuer die Gebaeude-Hierarchie
- **Kabeltypen**: Verbindungsmedien (NYM, NYY, CAT etc.) mit Spezifikationen
- **Equipment-Typen**: Schaltplan-Komponenten (z.B. Sicherungsautomaten, FI-Schalter) mit Breite (TE), Farbe und Terminal-Konfiguration
- **Phasenschienen-Typen**: Sammelschienen/Phasenschienen-Vorlagen (L1, L2, L3, N, PE, 3P+N etc.) mit Farben und Linien-Konfiguration

View file

@ -345,6 +345,7 @@ if (in_array($action, array('create', 'edit'))) {
$selAll = (empty($anlageType->fk_system)) ? ' selected' : '';
print '<option value="0"'.$selAll.'>'.$langs->trans('AllSystems').'</option>';
foreach ($systems as $sys) {
if ($sys->code === 'GLOBAL') continue; // Gebaeude-Typen haben eigenen Tab
$sel = ($anlageType->fk_system == $sys->rowid) ? ' selected' : '';
print '<option value="'.$sys->rowid.'"'.$sel.'>'.dol_escape_htmltag($sys->label).'</option>';
}
@ -634,6 +635,7 @@ if (in_array($action, array('create', 'edit'))) {
print '<select name="system" class="flat" onchange="this.form.submit();">';
print '<option value="0">'.$langs->trans('All').'</option>';
foreach ($systems as $sys) {
if ($sys->code === 'GLOBAL') continue; // Gebaeude-Typen haben eigenen Tab
$sel = ($systemFilter == $sys->rowid) ? ' selected' : '';
print '<option value="'.$sys->rowid.'"'.$sel.'>'.dol_escape_htmltag($sys->label).'</option>';
}
@ -648,7 +650,7 @@ if (in_array($action, array('create', 'edit'))) {
print '</div>';
// List
$types = $anlageType->fetchAllBySystem($systemFilter, 0);
$types = $anlageType->fetchAllBySystem($systemFilter, 0, 1);
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
@ -658,7 +660,7 @@ if (in_array($action, array('create', 'edit'))) {
print '<th class="center">'.$langs->trans('CanHaveChildren').'</th>';
print '<th class="center">'.$langs->trans('Position').'</th>';
print '<th class="center">'.$langs->trans('Status').'</th>';
print '<th class="center">'.$langs->trans('Actions').'</th>';
print '<th class="right">'.$langs->trans('Actions').'</th>';
print '</tr>';
foreach ($types as $type) {
@ -692,8 +694,8 @@ if (in_array($action, array('create', 'edit'))) {
}
print '</td>';
print '<td class="center nowraponall">';
print '<div style="display:inline-flex;gap:8px;">';
print '<td class="right nowraponall">';
print '<div style="display:inline-flex;gap:8px;justify-content:flex-end;">';
print '<a class="button buttongen" style="width:32px;height:32px;padding:0;display:inline-flex;align-items:center;justify-content:center;" href="'.$_SERVER['PHP_SELF'].'?action=edit&typeid='.$type->id.'&system='.$systemFilter.'" title="'.$langs->trans('Edit').'"><i class="fas fa-pen"></i></a>';
print '<a class="button buttongen" style="width:32px;height:32px;padding:0;display:inline-flex;align-items:center;justify-content:center;" href="'.$_SERVER['PHP_SELF'].'?action=copy&typeid='.$type->id.'&system='.$systemFilter.'&token='.newToken().'" title="'.$langs->trans('Copy').'"><i class="fas fa-copy"></i></a>';
if (!$type->is_system) {

View file

@ -19,6 +19,13 @@ dol_include_once('/kundenkarte/class/anlage.class.php');
header('Content-Type: application/json');
// Berechtigungsprüfung
if (!$user->hasRight('kundenkarte', 'read')) {
http_response_code(403);
echo json_encode(['error' => 'Permission denied']);
exit;
}
$anlageId = GETPOSTINT('anlage_id');
if ($anlageId <= 0) {

View file

@ -19,6 +19,13 @@ dol_include_once('/kundenkarte/class/anlage.class.php');
header('Content-Type: application/json');
// Berechtigungsprüfung
if (!$user->hasRight('kundenkarte', 'read')) {
http_response_code(403);
echo json_encode(['error' => 'Permission denied']);
exit;
}
$anlageId = GETPOSTINT('anlage_id');
if ($anlageId <= 0) {

3
ajax/equipment_type_block_image.php Normal file → Executable file
View file

@ -148,6 +148,9 @@ switch ($action) {
break;
}
// Path-Traversal-Schutz: nur Dateiname ohne Verzeichnisanteile
$selectedImage = basename($selectedImage);
// Validate that the image exists
$imagePath = $uploadDir . $selectedImage;
if (!file_exists($imagePath)) {

64
class/anlageconnection.class.php Normal file → Executable file
View file

@ -58,6 +58,17 @@ class AnlageConnection extends CommonObject
{
global $conf;
$error = 0;
$now = dol_now();
$this->db->begin();
// installation_date als DATE-Feld (YYYY-MM-DD String) sicher escapen
$installDateSQL = "NULL";
if ($this->installation_date) {
$installDateSQL = "'".$this->db->escape($this->installation_date)."'";
}
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "entity, fk_source, fk_target, label,";
$sql .= "fk_medium_type, medium_type_text, medium_spec, medium_length, medium_color,";
@ -74,21 +85,28 @@ class AnlageConnection extends CommonObject
$sql .= ", ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL");
$sql .= ", ".($this->medium_color ? "'".$this->db->escape($this->medium_color)."'" : "NULL");
$sql .= ", ".($this->route_description ? "'".$this->db->escape($this->route_description)."'" : "NULL");
$sql .= ", ".($this->installation_date ? "'".$this->db->escape($this->installation_date)."'" : "NULL");
$sql .= ", ".$installDateSQL;
$sql .= ", ".(int)($this->status ?: 1);
$sql .= ", ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL");
$sql .= ", ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL");
$sql .= ", NOW()";
$sql .= ", '".$this->db->idate($now)."'";
$sql .= ", ".(int)$user->id;
$sql .= ")";
$resql = $this->db->query($sql);
if ($resql) {
$this->id = $this->db->last_insert_id(MAIN_DB_PREFIX.$this->table_element);
return $this->id;
} else {
$error++;
$this->error = $this->db->lasterror();
}
if ($error) {
$this->db->rollback();
return -1;
} else {
$this->db->commit();
return $this->id;
}
}
@ -157,6 +175,16 @@ class AnlageConnection extends CommonObject
*/
public function update($user)
{
$error = 0;
$this->db->begin();
// installation_date als DATE-Feld (YYYY-MM-DD String) sicher escapen
$installDateSQL = "NULL";
if ($this->installation_date) {
$installDateSQL = "'".$this->db->escape($this->installation_date)."'";
}
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element." SET";
$sql .= " fk_source = ".(int)$this->fk_source;
$sql .= ", fk_target = ".(int)$this->fk_target;
@ -167,7 +195,7 @@ class AnlageConnection extends CommonObject
$sql .= ", medium_length = ".($this->medium_length ? "'".$this->db->escape($this->medium_length)."'" : "NULL");
$sql .= ", medium_color = ".($this->medium_color ? "'".$this->db->escape($this->medium_color)."'" : "NULL");
$sql .= ", route_description = ".($this->route_description ? "'".$this->db->escape($this->route_description)."'" : "NULL");
$sql .= ", installation_date = ".($this->installation_date ? "'".$this->db->escape($this->installation_date)."'" : "NULL");
$sql .= ", installation_date = ".$installDateSQL;
$sql .= ", status = ".(int)$this->status;
$sql .= ", note_private = ".($this->note_private ? "'".$this->db->escape($this->note_private)."'" : "NULL");
$sql .= ", note_public = ".($this->note_public ? "'".$this->db->escape($this->note_public)."'" : "NULL");
@ -175,11 +203,17 @@ class AnlageConnection extends CommonObject
$sql .= " WHERE rowid = ".(int)$this->id;
$resql = $this->db->query($sql);
if ($resql) {
return 1;
} else {
if (!$resql) {
$error++;
$this->error = $this->db->lasterror();
}
if ($error) {
$this->db->rollback();
return -1;
} else {
$this->db->commit();
return 1;
}
}
@ -191,15 +225,25 @@ class AnlageConnection extends CommonObject
*/
public function delete($user)
{
$error = 0;
$this->db->begin();
$sql = "DELETE FROM ".MAIN_DB_PREFIX.$this->table_element;
$sql .= " WHERE rowid = ".(int)$this->id;
$resql = $this->db->query($sql);
if ($resql) {
return 1;
} else {
if (!$resql) {
$error++;
$this->error = $this->db->lasterror();
}
if ($error) {
$this->db->rollback();
return -1;
} else {
$this->db->commit();
return 1;
}
}

View file

@ -269,9 +269,10 @@ class AnlageType extends CommonObject
*
* @param int $systemId System ID (0 = all)
* @param int $activeOnly Only active types
* @param int $excludeGlobal 1 = GLOBAL-Typen ausschliessen (fuer Admin-Ansicht)
* @return array Array of AnlageType objects
*/
public function fetchAllBySystem($systemId = 0, $activeOnly = 1)
public function fetchAllBySystem($systemId = 0, $activeOnly = 1, $excludeGlobal = 0)
{
$results = array();
@ -279,9 +280,18 @@ class AnlageType extends CommonObject
$sql .= " FROM ".MAIN_DB_PREFIX.$this->table_element." as t";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."c_kundenkarte_anlage_system as s ON t.fk_system = s.rowid";
$sql .= " WHERE 1 = 1";
if ($excludeGlobal) {
// GLOBAL-Typen (Gebaeude) ausschliessen (Admin-Ansicht)
$sql .= " AND (s.code IS NULL OR s.code != 'GLOBAL')";
}
if ($systemId > 0) {
// Show types for this system AND GLOBAL types (types from GLOBAL system are available everywhere)
$sql .= " AND (t.fk_system = ".((int) $systemId)." OR s.code = 'GLOBAL')";
if (!$excludeGlobal) {
// Typen dieses Systems UND GLOBAL-Typen (fuer Tabs-Ansicht)
$sql .= " AND (t.fk_system = ".((int) $systemId)." OR s.code = 'GLOBAL')";
} else {
// Nur Typen dieses Systems (fuer Admin-Ansicht)
$sql .= " AND t.fk_system = ".((int) $systemId);
}
}
if ($activeOnly) {
$sql .= " AND t.active = 1";

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

@ -63,6 +63,7 @@ class BuildingType extends CommonObject
{
global $conf;
$now = dol_now();
$this->ref = trim($this->ref);
$this->label = trim($this->label);
@ -85,7 +86,7 @@ class BuildingType extends CommonObject
$sql .= ", ".(int)($this->can_have_children !== null ? $this->can_have_children : 1);
$sql .= ", ".(int)($this->position ?: 0);
$sql .= ", ".(int)($this->active !== null ? $this->active : 1);
$sql .= ", NOW()";
$sql .= ", '".$this->db->idate($now)."'";
$sql .= ", ".(int)$user->id;
$sql .= ")";

4
class/terminalbridge.class.php Normal file → Executable file
View file

@ -48,6 +48,8 @@ class TerminalBridge extends CommonObject
*/
public function create($user)
{
global $conf;
$error = 0;
$now = dol_now();
@ -63,7 +65,7 @@ class TerminalBridge extends CommonObject
$sql .= " terminal_side, terminal_row, color, bridge_type, label,";
$sql .= " status, date_creation, fk_user_creat";
$sql .= ") VALUES (";
$sql .= ((int) $this->entity ?: 1);
$sql .= ((int) ($conf->entity));
$sql .= ", ".((int) $this->fk_anlage);
$sql .= ", ".((int) $this->fk_carrier);
$sql .= ", ".((int) $this->start_te);

View file

@ -76,7 +76,7 @@ class modKundenKarte extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@kundenkarte'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '3.3.2';
$this->version = '3.4.0';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';

View file

@ -46,8 +46,11 @@ NoInstallations = Keine Installationen vorhanden
SelectSystem = System auswaehlen
AllSystems = Alle Systeme
AllSystemsHint = Leer lassen fuer alle Systeme
Category = Kategorie
SelectCategory = Kategorie auswaehlen
SelectType = Typ auswaehlen
SelectParent = Uebergeordnetes Element
TechnicalElement = Element / Geraet
Root = Stamm
# Customer System Management

View file

@ -44,8 +44,11 @@ EditElement = Edit element
DeleteElement = Delete element
NoInstallations = No installations
SelectSystem = Select system
Category = Category
SelectCategory = Select category
SelectType = Select type
SelectParent = Parent element
TechnicalElement = Element / Device
Root = Root
# Customer System Management
@ -208,3 +211,12 @@ Close = Close
Confirm = Confirm
Yes = Yes
No = No
# Building Types
BuildingStructure = Building / Location
BuildingLevelBuilding = Building
BuildingLevelFloor = Floor / Level
BuildingLevelWing = Wing
BuildingLevelCorridor = Corridor / Hallway
BuildingLevelRoom = Room
BuildingLevelArea = Area / Zone

View file

@ -652,14 +652,80 @@ if (empty($customerSystems)) {
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>';
// Type
print '<tr><td class="fieldrequired">'.$langs->trans('Type').'</td>';
// 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) {
$selected = (($isEdit || $isCopy) && $anlage->fk_anlage_type == $t->id) ? ' selected' : '';
print '<option value="'.$t->id.'"'.$selected.'>'.dol_escape_htmltag($t->label).'</option>';
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>';
@ -692,6 +758,105 @@ if (empty($customerSystems)) {
print '</div>';
print '</form>';
// JavaScript: Kategorie-Filter + Select2 mit Icons
print '<script>
$(document).ready(function() {
var $catSelect = $("#select_category");
var $typeSelect = $("#select_type");
// Alle Options und Optgroups als HTML-String sichern
var allOptionsHtml = $typeSelect.html();
// Select2 Template-Funktion mit Icons
function formatTypeOption(option) {
if (!option.id) return option.text; // Placeholder
var $opt = $(option.element);
var icon = $opt.data("icon");
var color = $opt.data("color") || "#666";
if (icon) {
return $("<span><i class=\"fa " + icon + "\" style=\"color:" + color + ";width:20px;margin-right:8px;text-align:center;\"></i>" + option.text + "</span>");
}
return option.text;
}
// Select2 initialisieren
function initSelect2() {
// Falls bereits initialisiert, zerstören
if ($typeSelect.hasClass("select2-hidden-accessible")) {
$typeSelect.select2("destroy");
}
$typeSelect.select2({
templateResult: formatTypeOption,
templateSelection: formatTypeOption,
placeholder: "'.dol_escape_js($langs->trans('SelectType')).'",
allowClear: true,
width: "300px",
dropdownAutoWidth: true
});
}
function filterTypes() {
var category = $catSelect.val();
var currentVal = $typeSelect.val();
// Select2 zerstören vor DOM-Änderungen
if ($typeSelect.hasClass("select2-hidden-accessible")) {
$typeSelect.select2("destroy");
}
// Alle Options zurücksetzen
$typeSelect.html(allOptionsHtml);
if (!category) {
$typeSelect.prop("disabled", true);
$("#row_type").hide();
return;
}
// Nicht passende Options entfernen
$typeSelect.find("option[data-category]").each(function() {
if ($(this).data("category") !== category) {
$(this).remove();
}
});
// Leere Optgroups entfernen
$typeSelect.find("optgroup").each(function() {
if ($(this).find("option").length === 0) {
$(this).remove();
}
});
$typeSelect.prop("disabled", false);
$("#row_type").show();
// Wert wiederherstellen falls noch vorhanden
if (currentVal && $typeSelect.find("option[value=\"" + currentVal + "\"]").length) {
$typeSelect.val(currentVal);
} else {
$typeSelect.val("");
}
// Select2 neu initialisieren
initSelect2();
}
$catSelect.on("change", function() {
$typeSelect.val("");
filterTypes();
$typeSelect.trigger("change");
});
// Initial filtern
if ($catSelect.val()) {
filterTypes();
} else {
$typeSelect.prop("disabled", true);
$("#row_type").hide();
}
});
</script>';
}
print '</div>';