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>
This commit is contained in:
Eduard Wisch 2026-02-19 10:23:26 +01:00
parent 411a48d577
commit 07e0e2365b
23 changed files with 3288 additions and 135 deletions

126
CLAUDE.md
View file

@ -1 +1,125 @@
CLAUDE_CODE_DISABLE_AUTO_MEMORY=0
CLAUDE_CODE_DISABLE_AUTO_MEMORY=0
# KundenKarte Module - Entwicklungshinweise
## Dolibarr App Navigation (Vor/Zurück Pfeile)
### Problem
Die Dolibarr Mobile App hat eigene Navigations-Pfeile (vorheriger/nächster Kunde), die zwischen Datensätzen navigieren. Diese verwenden andere Parameter als unsere Module erwarten:
- **Kunden-Navigation**: Dolibarr verwendet `socid`, Module erwarten oft `id`
- **Kontakt-Navigation**: Dolibarr verwendet `contactid`, Module erwarten oft `id`
Wenn man diese Pfeile auf einem Modul-Tab verwendet, verliert das Modul die ID und zeigt einen Fehler oder leere Seite.
### Lösung: Beide Parameter akzeptieren
In **JEDEM Tab-PHP-File** muss am Anfang beide Parameter akzeptiert werden:
**Für Kunden-Tabs (thirdparty):**
```php
// Get parameters
// Support both 'id' and 'socid' for compatibility with Dolibarr's customer navigation arrows
$id = GETPOSTINT('id');
if ($id <= 0) {
$id = GETPOSTINT('socid');
}
```
**Für Kontakt-Tabs (contact):**
```php
// Get parameters
// Support both 'id' and 'contactid' for compatibility with Dolibarr's contact navigation arrows
$id = GETPOSTINT('id');
if ($id <= 0) {
$id = GETPOSTINT('contactid');
}
```
### Betroffene Dateien in diesem Modul
- `tabs/anlagen.php` - Kunden-Anlagen (socid)
- `tabs/favoriteproducts.php` - Kunden-Favoriten (socid)
- `tabs/contact_anlagen.php` - Kontakt-Anlagen (contactid)
- `tabs/contact_favoriteproducts.php` - Kontakt-Favoriten (contactid)
### Best Practices für zukünftige Module
1. **IMMER beide Parameter akzeptieren** - `id` UND `socid`/`contactid`
2. **Tab-Definition in modXxx.class.php** verwendet `?id=__ID__` - das ist korrekt
3. **Dolibarr's `dol_banner_tab()`** generiert die Navigationspfeile mit `socid`/`contactid`
4. **Fallback bei fehlender ID** - zur Liste weiterleiten, nicht Fehler zeigen:
```php
if ($id <= 0) {
header('Location: '.DOL_URL_ROOT.'/societe/list.php');
exit;
}
```
## Mobile Ansicht - Einheitliche Button-Größen
### Problem
Auf mobilen Geräten haben die Buttons (Kompakt, Aufklappen, Einklappen, PDF Export) unterschiedliche Größen.
### Lösung
CSS Media Queries für einheitliche Button-Größen:
```css
@media (max-width: 768px) {
.kundenkarte-tree-controls {
justify-content: center !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
.kundenkarte-tree-controls .button {
flex: 1 1 auto !important;
min-width: 80px !important;
max-width: 150px !important;
padding: 10px 8px !important;
font-size: 12px !important;
text-align: center !important;
}
}
@media (max-width: 480px) {
/* 2x2 Grid auf sehr kleinen Bildschirmen */
.kundenkarte-tree-controls {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
}
}
```
## Migrationen
Alle Datenbankänderungen werden als idempotente Migrationen in `modKundenKarte.class.php` implementiert:
- `runMigrations()` wird bei jeder Modulaktivierung aufgerufen
- Jede Migration prüft zuerst, ob die Änderung bereits existiert
- Später werden Migrationen entfernt und Tabellen direkt korrekt erstellt
## Dateistruktur
- `tabs/anlagen.php` - Hauptansicht für Anlagen auf Kundenebene
- `tabs/contact_anlagen.php` - Anlagen für Kontakte
- `tabs/favoriteproducts.php` - Lieblingsprodukte auf Kundenebene
- `tabs/contact_favoriteproducts.php` - Lieblingsprodukte für Kontakte
- `admin/anlage_types.php` - Verwaltung der Element-Typen
- `ajax/` - AJAX-Endpunkte für dynamische Funktionen
- `js/kundenkarte.js` - Alle JavaScript-Komponenten
- `css/kundenkarte.css` - Alle Styles (Dark Mode)
## Wichtige Hinweise
### FontAwesome Icons
- Dolibarr verwendet FontAwesome 4.x Format: `fa fa-icon-name`
- NICHT: `fas fa-icon-name` oder `far fa-icon-name`
### Badge-Farben
- Können pro Feld in Admin > Element-Typen konfiguriert werden
- Spalte `badge_color` in `llx_kundenkarte_anlage_type_field`
- Hex-Format: `#RRGGBB`
### Datei-Vorschau Tooltip
- AJAX-Endpoint: `ajax/file_preview.php`
- Zeigt Thumbnails für Bilder, Icons für Dokumente
- Hover über Datei-Badge im Baum

View file

@ -1,5 +1,37 @@
# CHANGELOG MODULE KUNDENKARTE FOR [DOLIBARR ERP CRM](https://www.dolibarr.org)
## 4.0.1 (2026-02)
### Neue Features
- **Badge-Farben pro Feld**: Individuelle Farben fuer Badges im Baum konfigurierbar
- Neue Spalte in Admin > Element-Typen > Felder
- Color-Picker fuer einfache Farbauswahl
- Hex-Format (#RRGGBB)
- **Datei-Vorschau Tooltip**: Hover ueber Datei-Badge zeigt Vorschau
- Thumbnails fuer Bilder
- Icons fuer Dokumente (PDF, Word, Excel, etc.)
- Neuer AJAX-Endpoint `ajax/file_preview.php`
- **Mobile/Kompakte Ansicht**: Optimiert fuer mobile Geraete
- Kompakt-Modus Toggle-Button
- Einheitliche Button-Groessen auf mobilen Geraeten
- 2x2 Grid-Layout auf sehr kleinen Bildschirmen
- Touch-freundliche Bedienelemente
### Bugfixes
- **Dolibarr App Navigation**: Vor/Zurueck-Pfeile funktionieren jetzt korrekt
- Module akzeptieren nun `id` UND `socid`/`contactid` Parameter
- Kunden-Kontext bleibt beim Navigieren erhalten
- Betroffene Dateien: alle Tab-PHP-Files
- **Datei-Badge Icon**: Zeigt jetzt Bueroklammer-Icon statt nur Zahl
### Datenbank-Aenderungen
- Neue Spalte `badge_color` in `llx_kundenkarte_anlage_type_field`
---
## 3.5.0 (2026-02)
### Neue Features

View file

@ -203,7 +203,14 @@ if ($action == 'add_field') {
$fieldType = GETPOST('field_type', 'aZ09');
$fieldOptions = GETPOST('field_options', 'nohtml');
$showInTree = GETPOSTINT('show_in_tree');
$treeDisplayMode = GETPOST('tree_display_mode', 'aZ09');
if (empty($treeDisplayMode)) $treeDisplayMode = 'badge';
$badgeColor = GETPOST('badge_color', 'alphanohtml');
if ($badgeColor && !preg_match('/^#[0-9A-Fa-f]{6}$/', $badgeColor)) {
$badgeColor = '';
}
$showInHover = GETPOSTINT('show_in_hover');
$enableAutocomplete = GETPOSTINT('enable_autocomplete');
$isRequired = GETPOSTINT('is_required');
$fieldPosition = GETPOSTINT('field_position');
@ -211,10 +218,10 @@ if ($action == 'add_field') {
setEventMessages($langs->trans('ErrorFieldRequired'), null, 'errors');
} else {
$sql = "INSERT INTO ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field";
$sql .= " (fk_anlage_type, field_code, field_label, field_type, field_options, show_in_tree, show_in_hover, required, position, active)";
$sql .= " (fk_anlage_type, field_code, field_label, field_type, field_options, show_in_tree, tree_display_mode, badge_color, show_in_hover, enable_autocomplete, required, position, active)";
$sql .= " VALUES (".((int) $typeId).", '".$db->escape($fieldCode)."', '".$db->escape($fieldLabel)."',";
$sql .= " '".$db->escape($fieldType)."', '".$db->escape($fieldOptions)."',";
$sql .= " ".((int) $showInTree).", ".((int) $showInHover).", ".((int) $isRequired).", ".((int) $fieldPosition).", 1)";
$sql .= " ".((int) $showInTree).", '".$db->escape($treeDisplayMode)."', ".($badgeColor ? "'".$db->escape($badgeColor)."'" : "NULL").", ".((int) $showInHover).", ".((int) $enableAutocomplete).", ".((int) $isRequired).", ".((int) $fieldPosition).", 1)";
if ($db->query($sql)) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');
@ -231,7 +238,15 @@ if ($action == 'update_field') {
$fieldType = GETPOST('field_type', 'aZ09');
$fieldOptions = GETPOST('field_options', 'nohtml');
$showInTree = GETPOSTINT('show_in_tree');
$treeDisplayMode = GETPOST('tree_display_mode', 'aZ09');
if (empty($treeDisplayMode)) $treeDisplayMode = 'badge';
$badgeColor = GETPOST('badge_color', 'alphanohtml');
// Validate color format (#RRGGBB)
if ($badgeColor && !preg_match('/^#[0-9A-Fa-f]{6}$/', $badgeColor)) {
$badgeColor = '';
}
$showInHover = GETPOSTINT('show_in_hover');
$enableAutocomplete = GETPOSTINT('enable_autocomplete');
$isRequired = GETPOSTINT('is_required');
$fieldPosition = GETPOSTINT('field_position');
@ -241,7 +256,10 @@ if ($action == 'update_field') {
$sql .= " field_type = '".$db->escape($fieldType)."',";
$sql .= " field_options = '".$db->escape($fieldOptions)."',";
$sql .= " show_in_tree = ".((int) $showInTree).",";
$sql .= " tree_display_mode = '".$db->escape($treeDisplayMode)."',";
$sql .= " badge_color = ".($badgeColor ? "'".$db->escape($badgeColor)."'" : "NULL").",";
$sql .= " show_in_hover = ".((int) $showInHover).",";
$sql .= " enable_autocomplete = ".((int) $enableAutocomplete).",";
$sql .= " required = ".((int) $isRequired).",";
$sql .= " position = ".((int) $fieldPosition);
$sql .= " WHERE rowid = ".((int) $fieldId);
@ -513,7 +531,10 @@ if (in_array($action, array('create', 'edit'))) {
print '<th>'.$langs->trans('FieldType').'</th>';
print '<th>'.$langs->trans('FieldOptions').'</th>';
print '<th class="center">'.$langs->trans('ShowInTree').'</th>';
print '<th class="center" title="Badge (rechts) oder Klammer (nach Bezeichnung)">'.$langs->trans('TreeDisplayMode').'</th>';
print '<th class="center" title="Farbe des Badges im Baum"><i class="fa fa-paint-brush"></i></th>';
print '<th class="center">'.$langs->trans('ShowInHover').'</th>';
print '<th class="center" title="Autocomplete aus gespeicherten Werten"><i class="fa fa-magic"></i></th>';
print '<th class="center">'.$langs->trans('IsRequired').'</th>';
print '<th class="center">'.$langs->trans('Position').'</th>';
print '<th class="center">'.$langs->trans('Status').'</th>';
@ -537,7 +558,15 @@ if (in_array($action, array('create', 'edit'))) {
print '</select></td>';
print '<td><input type="text" name="field_options" form="'.$formId.'" class="flat minwidth100" value="'.dol_escape_htmltag($field->field_options).'" placeholder="opt1|opt2|opt3"></td>';
print '<td class="center"><input type="checkbox" name="show_in_tree" form="'.$formId.'" value="1"'.($field->show_in_tree ? ' checked' : '').'></td>';
$treeMode = $field->tree_display_mode ?? 'badge';
print '<td class="center"><select name="tree_display_mode" form="'.$formId.'" class="flat" style="width:90px;">';
print '<option value="badge"'.($treeMode == 'badge' ? ' selected' : '').'>'.$langs->trans('Badge').'</option>';
print '<option value="parentheses"'.($treeMode == 'parentheses' ? ' selected' : '').'>'.$langs->trans('Parentheses').'</option>';
print '</select></td>';
$badgeColor = $field->badge_color ?? '';
print '<td class="center"><input type="color" name="badge_color" form="'.$formId.'" value="'.($badgeColor ?: '#2a4a5e').'" style="width:30px;height:24px;padding:0;border:1px solid #ccc;cursor:pointer;" title="Badge-Farbe"></td>';
print '<td class="center"><input type="checkbox" name="show_in_hover" form="'.$formId.'" value="1"'.($field->show_in_hover ? ' checked' : '').'></td>';
print '<td class="center"><input type="checkbox" name="enable_autocomplete" form="'.$formId.'" value="1"'.($field->enable_autocomplete ? ' checked' : '').' title="Autocomplete aktivieren"></td>';
print '<td class="center"><input type="checkbox" name="is_required" form="'.$formId.'" value="1"'.($field->required ? ' checked' : '').'></td>';
print '<td class="center"><input type="number" name="field_position" form="'.$formId.'" class="flat" style="width:50px;" value="'.$field->position.'" min="0"></td>';
print '<td></td>';
@ -556,7 +585,17 @@ if (in_array($action, array('create', 'edit'))) {
print '<td>'.dol_escape_htmltag($fieldTypes[$field->field_type] ?? $field->field_type).'</td>';
print '<td class="small opacitymedium">'.dol_escape_htmltag(dol_trunc($field->field_options, 20)).'</td>';
print '<td class="center">'.($field->show_in_tree ? img_picto('', 'tick') : '').'</td>';
$treeMode = $field->tree_display_mode ?? 'badge';
$treeModeIcon = ($treeMode == 'badge') ? '<span class="badge badge-secondary" style="font-size:9px;">B</span>' : '<span style="color:#666;">(K)</span>';
print '<td class="center" title="'.($treeMode == 'badge' ? $langs->trans('Badge') : $langs->trans('Parentheses')).'">'.$treeModeIcon.'</td>';
$badgeColor = $field->badge_color ?? '';
if ($badgeColor) {
print '<td class="center"><span style="display:inline-block;width:20px;height:20px;background:'.$badgeColor.';border-radius:3px;border:1px solid #555;"></span></td>';
} else {
print '<td class="center"><span style="color:#888;">-</span></td>';
}
print '<td class="center">'.($field->show_in_hover ? img_picto('', 'tick') : '').'</td>';
print '<td class="center">'.($field->enable_autocomplete ? '<i class="fa fa-magic" style="color:#3498db;" title="Autocomplete aktiv"></i>' : '').'</td>';
print '<td class="center">'.($field->required ? img_picto('', 'tick') : '').'</td>';
print '<td class="center">'.$field->position.'</td>';
print '<td class="center">';
@ -577,7 +616,7 @@ if (in_array($action, array('create', 'edit'))) {
}
if (empty($fields)) {
print '<tr class="oddeven"><td colspan="10" class="opacitymedium">'.$langs->trans('NoFieldsDefined').'</td></tr>';
print '<tr class="oddeven"><td colspan="12" class="opacitymedium">'.$langs->trans('NoFieldsDefined').'</td></tr>';
}
print '</table>';
@ -591,7 +630,7 @@ if (in_array($action, array('create', 'edit'))) {
print '<input type="hidden" name="system" value="'.$systemFilter.'">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th colspan="10">'.$langs->trans('Add').' '.$langs->trans('Field').'</th>';
print '<th colspan="12">'.$langs->trans('Add').' '.$langs->trans('Field').'</th>';
print '</tr>';
print '<tr class="oddeven">';
print '<td><input type="text" name="field_code" class="flat minwidth100" placeholder="CODE" required></td>';
@ -604,7 +643,13 @@ if (in_array($action, array('create', 'edit'))) {
print '</select></td>';
print '<td><input type="text" name="field_options" class="flat minwidth100" placeholder="opt1|opt2"></td>';
print '<td class="center"><input type="checkbox" name="show_in_tree" value="1"></td>';
print '<td class="center"><select name="tree_display_mode" class="flat" style="width:90px;">';
print '<option value="badge">'.$langs->trans('Badge').'</option>';
print '<option value="parentheses">'.$langs->trans('Parentheses').'</option>';
print '</select></td>';
print '<td class="center"><input type="color" name="badge_color" value="#2a4a5e" style="width:30px;height:24px;padding:0;border:1px solid #ccc;cursor:pointer;" title="Badge-Farbe"></td>';
print '<td class="center"><input type="checkbox" name="show_in_hover" value="1" checked></td>';
print '<td class="center"><input type="checkbox" name="enable_autocomplete" value="1" title="Autocomplete aktivieren"></td>';
print '<td class="center"><input type="checkbox" name="is_required" value="1"></td>';
print '<td class="center"><input type="number" name="field_position" class="flat" style="width:50px;" value="0" min="0"></td>';
print '<td></td>';

364
admin/backup.php Normal file
View file

@ -0,0 +1,364 @@
<?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 kundenkarte/admin/backup.php
* \ingroup kundenkarte
* \brief Backup and restore page for KundenKarte data
*/
// 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) {
die("Include of main fails");
}
// Libraries
require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php";
require_once DOL_DOCUMENT_ROOT."/core/class/html.form.class.php";
require_once '../lib/kundenkarte.lib.php';
dol_include_once('/kundenkarte/class/anlagebackup.class.php');
// Translations
$langs->loadLangs(array("admin", "kundenkarte@kundenkarte"));
// Access control
if (!$user->admin && !$user->hasRight('kundenkarte', 'admin')) {
accessforbidden();
}
// Parameters
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');
$form = new Form($db);
$backup = new AnlageBackup($db);
/*
* Actions
*/
// Create backup
if ($action == 'create_backup') {
$includeFiles = GETPOSTINT('include_files');
$result = $backup->createBackup($includeFiles);
if ($result) {
setEventMessages($langs->trans('BackupCreatedSuccess', basename($result)), null, 'mesgs');
} else {
setEventMessages($langs->trans('BackupCreatedError').': '.$backup->error, null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
// Download backup
if ($action == 'download') {
$filename = GETPOST('file', 'alpha');
$filepath = $conf->kundenkarte->dir_output.'/backups/'.basename($filename);
if (file_exists($filepath) && strpos($filename, 'kundenkarte_backup_') === 0) {
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="'.basename($filepath).'"');
header('Content-Length: '.filesize($filepath));
readfile($filepath);
exit;
} else {
setEventMessages($langs->trans('FileNotFound'), null, 'errors');
}
}
// Delete backup
if ($action == 'confirm_delete' && $confirm == 'yes') {
$filename = GETPOST('file', 'alpha');
if ($backup->deleteBackup($filename)) {
setEventMessages($langs->trans('BackupDeleted'), null, 'mesgs');
} else {
setEventMessages($langs->trans('Error'), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
// Restore backup
if ($action == 'confirm_restore' && $confirm == 'yes') {
$filename = GETPOST('file', 'alpha');
$clearExisting = GETPOSTINT('clear_existing');
$filepath = $conf->kundenkarte->dir_output.'/backups/'.basename($filename);
if ($backup->restoreBackup($filepath, $clearExisting)) {
setEventMessages($langs->trans('RestoreSuccess'), null, 'mesgs');
} else {
setEventMessages($langs->trans('RestoreError').': '.$backup->error, null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
// Upload backup
if ($action == 'upload_backup' && !empty($_FILES['backupfile']['name'])) {
$backupDir = $conf->kundenkarte->dir_output.'/backups';
if (!is_dir($backupDir)) {
dol_mkdir($backupDir);
}
$filename = $_FILES['backupfile']['name'];
// Validate filename format
if (!preg_match('/^kundenkarte_backup_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip$/', $filename)) {
setEventMessages($langs->trans('InvalidBackupFile'), null, 'errors');
} elseif ($_FILES['backupfile']['error'] !== UPLOAD_ERR_OK) {
setEventMessages($langs->trans('ErrorUploadFailed'), null, 'errors');
} else {
$targetPath = $backupDir.'/'.$filename;
if (move_uploaded_file($_FILES['backupfile']['tmp_name'], $targetPath)) {
setEventMessages($langs->trans('BackupUploaded'), null, 'mesgs');
} else {
setEventMessages($langs->trans('ErrorUploadFailed'), null, 'errors');
}
}
header('Location: '.$_SERVER['PHP_SELF']);
exit;
}
/*
* View
*/
$title = $langs->trans("BackupRestore");
llxHeader('', $title);
// Subheader
$linkback = '<a href="'.DOL_URL_ROOT.'/admin/modules.php?restore_lastsearch_values=1">'.$langs->trans("BackToModuleList").'</a>';
print load_fiche_titre($title, $linkback, 'title_setup');
// Configuration header
$head = kundenkarteAdminPrepareHead();
print dol_get_fiche_head($head, 'backup', $langs->trans('ModuleKundenKarteName'), -1, "fa-address-card");
// Confirmation dialogs
if ($action == 'delete') {
$filename = GETPOST('file', 'alpha');
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?file='.urlencode($filename),
$langs->trans('DeleteBackup'),
$langs->trans('ConfirmDeleteBackup', $filename),
'confirm_delete',
'',
'yes',
1
);
}
if ($action == 'restore') {
$filename = GETPOST('file', 'alpha');
$formquestion = array(
array(
'type' => 'checkbox',
'name' => 'clear_existing',
'label' => $langs->trans('ClearExistingData'),
'value' => 0
)
);
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?file='.urlencode($filename),
$langs->trans('RestoreBackup'),
$langs->trans('ConfirmRestoreBackup', $filename),
'confirm_restore',
$formquestion,
'yes',
1
);
}
// Statistics
$stats = $backup->getStatistics();
print '<div class="fichecenter">';
// Stats cards
print '<div style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:30px;">';
print '<div class="info-box" style="min-width:200px;">';
print '<span class="info-box-icon bg-primary"><i class="fa fa-sitemap"></i></span>';
print '<div class="info-box-content">';
print '<span class="info-box-text">'.$langs->trans('TotalElements').'</span>';
print '<span class="info-box-number">'.$stats['total_anlagen'].'</span>';
print '</div>';
print '</div>';
print '<div class="info-box" style="min-width:200px;">';
print '<span class="info-box-icon bg-success"><i class="fa fa-file"></i></span>';
print '<div class="info-box-content">';
print '<span class="info-box-text">'.$langs->trans('TotalFiles').'</span>';
print '<span class="info-box-number">'.$stats['total_files'].'</span>';
print '</div>';
print '</div>';
print '<div class="info-box" style="min-width:200px;">';
print '<span class="info-box-icon bg-warning"><i class="fa fa-plug"></i></span>';
print '<div class="info-box-content">';
print '<span class="info-box-text">'.$langs->trans('TotalConnections').'</span>';
print '<span class="info-box-number">'.$stats['total_connections'].'</span>';
print '</div>';
print '</div>';
print '<div class="info-box" style="min-width:200px;">';
print '<span class="info-box-icon bg-info"><i class="fa fa-building"></i></span>';
print '<div class="info-box-content">';
print '<span class="info-box-text">'.$langs->trans('CustomersWithAnlagen').'</span>';
print '<span class="info-box-number">'.$stats['total_customers'].'</span>';
print '</div>';
print '</div>';
print '<div class="info-box" style="min-width:200px;">';
print '<span class="info-box-icon bg-secondary"><i class="fa fa-hdd-o"></i></span>';
print '<div class="info-box-content">';
print '<span class="info-box-text">'.$langs->trans('FilesStorageSize').'</span>';
print '<span class="info-box-number">'.dol_print_size($stats['files_size']).'</span>';
print '</div>';
print '</div>';
print '</div>';
// Create Backup Section
print '<div class="titre inline-block">'.$langs->trans("CreateBackup").'</div>';
print '<br><br>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="create_backup">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("BackupOptions").'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td style="width:300px;">'.$langs->trans("IncludeUploadedFiles").'</td>';
print '<td>';
print '<input type="checkbox" name="include_files" value="1" checked> ';
print '<span class="opacitymedium">'.$langs->trans("IncludeFilesHelp").'</span>';
print '</td>';
print '</tr>';
print '</table>';
print '<br>';
print '<div class="center">';
print '<button type="submit" class="button button-primary">';
print '<i class="fa fa-download"></i> '.$langs->trans("CreateBackupNow");
print '</button>';
print '</div>';
print '</form>';
// Upload Backup Section
print '<br><br>';
print '<div class="titre inline-block">'.$langs->trans("UploadBackup").'</div>';
print '<br><br>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'" enctype="multipart/form-data">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="upload_backup">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("UploadBackupFile").'</td>';
print '</tr>';
print '<tr class="oddeven">';
print '<td style="width:300px;">'.$langs->trans("SelectBackupFile").'</td>';
print '<td>';
print '<input type="file" name="backupfile" accept=".zip" class="flat">';
print ' <button type="submit" class="button buttongen smallpaddingimp">'.$langs->trans("Upload").'</button>';
print '</td>';
print '</tr>';
print '</table>';
print '</form>';
// Existing Backups Section
print '<br><br>';
print '<div class="titre inline-block">'.$langs->trans("ExistingBackups").'</div>';
print '<br><br>';
$backups = $backup->getBackupList();
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td>'.$langs->trans("Filename").'</td>';
print '<td>'.$langs->trans("Date").'</td>';
print '<td class="right">'.$langs->trans("Size").'</td>';
print '<td class="center">'.$langs->trans("Actions").'</td>';
print '</tr>';
if (empty($backups)) {
print '<tr class="oddeven">';
print '<td colspan="4" class="opacitymedium center">'.$langs->trans("NoBackupsFound").'</td>';
print '</tr>';
} else {
foreach ($backups as $bk) {
print '<tr class="oddeven">';
print '<td><i class="fa fa-file-archive-o"></i> '.dol_escape_htmltag($bk['filename']).'</td>';
print '<td>'.dol_print_date(strtotime($bk['date']), 'dayhour').'</td>';
print '<td class="right">'.dol_print_size($bk['size']).'</td>';
print '<td class="center nowraponall">';
// Download button
print '<a href="'.$_SERVER['PHP_SELF'].'?action=download&file='.urlencode($bk['filename']).'&token='.newToken().'" class="button buttongen smallpaddingimp" title="'.$langs->trans('Download').'">';
print '<i class="fa fa-download"></i>';
print '</a> ';
// Restore button
print '<a href="'.$_SERVER['PHP_SELF'].'?action=restore&file='.urlencode($bk['filename']).'&token='.newToken().'" class="button buttongen smallpaddingimp" title="'.$langs->trans('Restore').'">';
print '<i class="fa fa-undo"></i>';
print '</a> ';
// Delete button
print '<a href="'.$_SERVER['PHP_SELF'].'?action=delete&file='.urlencode($bk['filename']).'&token='.newToken().'" class="button buttongen smallpaddingimp" title="'.$langs->trans('Delete').'">';
print '<i class="fa fa-trash"></i>';
print '</a>';
print '</td>';
print '</tr>';
}
}
print '</table>';
// Info section
print '<br>';
print '<div class="info">';
print '<strong><i class="fa fa-info-circle"></i> '.$langs->trans("BackupInfo").':</strong><br>';
print '&bull; '.$langs->trans("BackupInfoContent").'<br>';
print '&bull; '.$langs->trans("BackupInfoFiles").'<br>';
print '&bull; '.$langs->trans("BackupInfoRestore").'<br>';
print '</div>';
print '</div>';
print dol_get_fiche_end();
llxFooter();
$db->close();

View file

@ -122,6 +122,10 @@ if ($action == 'update') {
dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_CONTENT', GETPOSTINT('KUNDENKARTE_PDF_FONT_CONTENT'), 'chaine', 0, '', $conf->entity);
dolibarr_set_const($db, 'KUNDENKARTE_PDF_FONT_FIELDS', GETPOSTINT('KUNDENKARTE_PDF_FONT_FIELDS'), 'chaine', 0, '', $conf->entity);
// Tree display settings
dolibarr_set_const($db, 'KUNDENKARTE_TREE_INFO_DISPLAY', GETPOST('KUNDENKARTE_TREE_INFO_DISPLAY', 'aZ09'), 'chaine', 0, '', $conf->entity);
dolibarr_set_const($db, 'KUNDENKARTE_TREE_BADGE_COLOR', GETPOST('KUNDENKARTE_TREE_BADGE_COLOR', 'alphanohtml'), 'chaine', 0, '', $conf->entity);
if (!$error) {
setEventMessages($langs->trans("SetupSaved"), null, 'mesgs');
} else {
@ -190,6 +194,86 @@ print '</tr>';
print '</table>';
// Tree Display Settings
print '<br><br>';
print '<div class="titre inline-block">'.$langs->trans("TreeDisplaySettings").'</div>';
print '<br><br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td>'.$langs->trans("Parameter").'</td>';
print '<td>'.$langs->trans("Value").'</td>';
print '<td>'.$langs->trans("Preview").'</td>';
print '</tr>';
// Tree info display mode
$currentDisplay = getDolGlobalString('KUNDENKARTE_TREE_INFO_DISPLAY', 'badge');
print '<tr class="oddeven">';
print '<td>'.$langs->trans("TreeInfoDisplayMode").'</td>';
print '<td>';
print '<select name="KUNDENKARTE_TREE_INFO_DISPLAY" class="flat" id="tree_info_display">';
print '<option value="badge"'.($currentDisplay == 'badge' ? ' selected' : '').'>'.$langs->trans("DisplayAsBadge").'</option>';
print '<option value="parentheses"'.($currentDisplay == 'parentheses' ? ' selected' : '').'>'.$langs->trans("DisplayInParentheses").'</option>';
print '<option value="none"'.($currentDisplay == 'none' ? ' selected' : '').'>'.$langs->trans("DisplayNone").'</option>';
print '</select>';
print '</td>';
print '<td>';
print '<span id="preview_badge" class="kundenkarte-tree-badge-preview" style="display:'.($currentDisplay == 'badge' ? 'inline-flex' : 'none').';align-items:center;gap:4px;padding:2px 8px;background:linear-gradient(135deg,#2a4a5e 0%,#1e3a4a 100%);border:1px solid #3a6a8e;border-radius:12px;font-size:11px;color:#8cc4e8;"><i class="fa fa-map-marker"></i> Serverraum</span>';
print '<span id="preview_parentheses" style="display:'.($currentDisplay == 'parentheses' ? 'inline' : 'none').';color:#999;font-size:0.9em;">(Standort: Serverraum)</span>';
print '<span id="preview_none" style="display:'.($currentDisplay == 'none' ? 'inline' : 'none').';color:#999;font-style:italic;">'.$langs->trans("Hidden").'</span>';
print '</td>';
print '</tr>';
// Badge color
$currentColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e');
print '<tr class="oddeven" id="row_badge_color"'.($currentDisplay != 'badge' ? ' style="display:none;"' : '').'>';
print '<td>'.$langs->trans("TreeBadgeColor").'</td>';
print '<td>';
print '<input type="color" name="KUNDENKARTE_TREE_BADGE_COLOR" id="badge_color" value="'.dol_escape_htmltag($currentColor).'" style="width:60px;height:30px;border:1px solid #ccc;border-radius:4px;cursor:pointer;">';
print ' <input type="text" id="badge_color_hex" value="'.dol_escape_htmltag($currentColor).'" style="width:80px;" class="flat" readonly>';
print '</td>';
print '<td class="opacitymedium small">'.$langs->trans("TreeBadgeColorHelp").'</td>';
print '</tr>';
print '</table>';
print '<script>
$(document).ready(function() {
// Update preview and show/hide color row on display mode change
$("#tree_info_display").on("change", function() {
var mode = $(this).val();
$("#preview_badge, #preview_parentheses, #preview_none").hide();
if (mode === "badge") {
$("#preview_badge").show();
$("#row_badge_color").show();
} else if (mode === "parentheses") {
$("#preview_parentheses").show();
$("#row_badge_color").hide();
} else {
$("#preview_none").show();
$("#row_badge_color").hide();
}
});
// Update color preview and hex input
$("#badge_color").on("input", function() {
var color = $(this).val();
$("#badge_color_hex").val(color);
// Update preview badge background
var lighterColor = color;
$("#preview_badge").css("background", "linear-gradient(135deg, " + color + " 0%, " + adjustColor(color, -20) + " 100%)");
});
function adjustColor(hex, percent) {
var num = parseInt(hex.slice(1), 16);
var r = Math.min(255, Math.max(0, (num >> 16) + percent));
var g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + percent));
var b = Math.min(255, Math.max(0, (num & 0x0000FF) + percent));
return "#" + (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
});
</script>';
// PDF Font Size Settings
print '<br><br>';
print '<div class="titre inline-block">'.$langs->trans("PDFFontSettings").'</div>';

106
ajax/field_autocomplete.php Normal file
View file

@ -0,0 +1,106 @@
<?php
/* Copyright (C) 2026 Alles Watt lauft
*
* AJAX endpoint for field autocomplete suggestions
* Returns unique values from saved anlage field values
*/
// 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");
}
header('Content-Type: application/json');
// Check permissions
if (!$user->hasRight('kundenkarte', 'read')) {
echo json_encode(array('error' => 'Access denied'));
exit;
}
$action = GETPOST('action', 'aZ09');
$fieldCode = GETPOST('field_code', 'aZ09');
$query = GETPOST('query', 'alphanohtml');
$typeId = GETPOSTINT('type_id');
if ($action == 'suggest') {
$suggestions = array();
if (empty($fieldCode)) {
echo json_encode(array('suggestions' => $suggestions));
exit;
}
// Get unique saved values for this field code
// Search in JSON field_values column
$sql = "SELECT DISTINCT ";
$sql .= "JSON_UNQUOTE(JSON_EXTRACT(field_values, '$.\"".($db->escape($fieldCode))."\"')) as field_value ";
$sql .= "FROM ".MAIN_DB_PREFIX."kundenkarte_anlage ";
$sql .= "WHERE field_values IS NOT NULL ";
$sql .= "AND JSON_EXTRACT(field_values, '$.\"".($db->escape($fieldCode))."\"') IS NOT NULL ";
// Filter by query if provided
if (!empty($query)) {
$sql .= "AND JSON_UNQUOTE(JSON_EXTRACT(field_values, '$.\"".($db->escape($fieldCode))."\"')) LIKE '%".$db->escape($query)."%' ";
}
// Optionally filter by type
if ($typeId > 0) {
$sql .= "AND fk_anlage_type = ".((int) $typeId)." ";
}
$sql .= "ORDER BY field_value ASC ";
$sql .= "LIMIT 20";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
if (!empty($obj->field_value) && $obj->field_value !== 'null') {
$suggestions[] = $obj->field_value;
}
}
$db->free($resql);
}
echo json_encode(array('suggestions' => $suggestions));
exit;
}
// Get all autocomplete-enabled fields for a type
if ($action == 'get_autocomplete_fields') {
$fields = array();
if ($typeId > 0) {
$sql = "SELECT field_code, field_label FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_type_field ";
$sql .= "WHERE fk_anlage_type = ".((int) $typeId)." ";
$sql .= "AND enable_autocomplete = 1 ";
$sql .= "AND active = 1 ";
$sql .= "AND field_type IN ('text', 'textarea')";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$fields[] = array(
'code' => $obj->field_code,
'label' => $obj->field_label
);
}
$db->free($resql);
}
}
echo json_encode(array('fields' => $fields));
exit;
}
echo json_encode(array('error' => 'Unknown action'));

101
ajax/file_preview.php Normal file
View file

@ -0,0 +1,101 @@
<?php
/* Copyright (C) 2026 Alles Watt lauft
*
* AJAX endpoint to get file preview data for an installation element
*/
if (!defined('NOTOKENRENEWAL')) define('NOTOKENRENEWAL', '1');
if (!defined('NOREQUIREHTML')) define('NOREQUIREHTML', '1');
if (!defined('NOREQUIREAJAX')) define('NOREQUIREAJAX', '1');
if (!defined('NOREQUIRESOC')) define('NOREQUIRESOC', '1');
$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) die("Include of main fails");
dol_include_once('/kundenkarte/class/anlagefile.class.php');
dol_include_once('/kundenkarte/class/anlage.class.php');
header('Content-Type: application/json');
// Permission check
if (!$user->hasRight('kundenkarte', 'read')) {
http_response_code(403);
echo json_encode(['error' => 'Permission denied']);
exit;
}
$anlageId = GETPOSTINT('anlage_id');
if ($anlageId <= 0) {
echo json_encode(array('error' => 'Invalid ID', 'files' => array()));
exit;
}
// Get the anlage
$anlage = new Anlage($db);
if ($anlage->fetch($anlageId) <= 0) {
echo json_encode(array('error' => 'Element not found', 'files' => array()));
exit;
}
// Get all files for this element
$anlagefile = new AnlageFile($db);
$files = $anlagefile->fetchAllByAnlage($anlageId);
$images = array();
$documents = array();
foreach ($files as $file) {
$fileData = array(
'id' => $file->id,
'name' => $file->filename,
'url' => $file->getUrl(),
'type' => $file->file_type,
'is_pinned' => (int)$file->is_pinned,
'is_cover' => (int)$file->is_cover,
);
if ($file->file_type == 'image') {
// Add thumbnail URL for images
$fileData['thumb_url'] = $file->getThumbnailUrl();
$images[] = $fileData;
} else {
// Determine icon based on file type
switch ($file->file_type) {
case 'pdf':
$fileData['icon'] = 'fa-file-pdf-o';
$fileData['color'] = '#e74c3c';
break;
case 'archive':
$fileData['icon'] = 'fa-file-archive-o';
$fileData['color'] = '#9b59b6';
break;
default:
// Check extension for more specific icons
$ext = strtolower(pathinfo($file->filename, PATHINFO_EXTENSION));
if (in_array($ext, array('doc', 'docx'))) {
$fileData['icon'] = 'fa-file-word-o';
$fileData['color'] = '#2980b9';
} elseif (in_array($ext, array('xls', 'xlsx'))) {
$fileData['icon'] = 'fa-file-excel-o';
$fileData['color'] = '#27ae60';
} elseif (in_array($ext, array('txt', 'rtf'))) {
$fileData['icon'] = 'fa-file-text-o';
$fileData['color'] = '#f39c12';
} else {
$fileData['icon'] = 'fa-file-o';
$fileData['color'] = '#7f8c8d';
}
}
$documents[] = $fileData;
}
}
echo json_encode(array(
'images' => $images,
'documents' => $documents,
'total_images' => count($images),
'total_documents' => count($documents),
));

View file

@ -49,9 +49,13 @@ foreach ($fields as $field) {
'type' => $field->field_type,
'options' => $field->field_options,
'required' => (int)$field->required === 1,
'autocomplete' => (int)$field->enable_autocomplete === 1,
'value' => isset($existingValues[$field->field_code]) ? $existingValues[$field->field_code] : ''
);
$result['fields'][] = $fieldData;
}
// Also include type_id for autocomplete
$result['type_id'] = $typeId;
echo json_encode($result);

View file

@ -0,0 +1,558 @@
<?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.
*/
/**
* Class AnlageBackup
* Handles backup and restore of all installation data
*/
class AnlageBackup
{
public $db;
public $error;
public $errors = array();
// Tables to backup (in order for foreign key constraints)
private $tables = array(
'kundenkarte_anlage_system',
'kundenkarte_anlage_type',
'kundenkarte_anlage_type_field',
'kundenkarte_customer_system',
'kundenkarte_anlage',
'kundenkarte_anlage_files',
'kundenkarte_anlage_connection',
'kundenkarte_equipment_panel',
'kundenkarte_equipment_carrier',
'kundenkarte_equipment',
'kundenkarte_medium_type',
'kundenkarte_busbar_type',
'kundenkarte_building_type',
);
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Create a full backup
*
* @param bool $includeFiles Include uploaded files in backup
* @return string|false Path to backup file or false on error
*/
public function createBackup($includeFiles = true)
{
global $conf;
$backupDir = $conf->kundenkarte->dir_output.'/backups';
if (!is_dir($backupDir)) {
dol_mkdir($backupDir);
}
$timestamp = date('Y-m-d_H-i-s');
$backupName = 'kundenkarte_backup_'.$timestamp;
$tempDir = $backupDir.'/'.$backupName;
if (!dol_mkdir($tempDir)) {
$this->error = 'Cannot create backup directory';
return false;
}
// Export database tables
$dbData = $this->exportDatabaseTables();
if ($dbData === false) {
return false;
}
// Save database export as JSON
$dbFile = $tempDir.'/database.json';
if (file_put_contents($dbFile, json_encode($dbData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) === false) {
$this->error = 'Cannot write database file';
return false;
}
// Create metadata file
$metadata = array(
'version' => '3.6.0',
'created' => date('Y-m-d H:i:s'),
'tables' => array_keys($dbData),
'record_counts' => array(),
'includes_files' => $includeFiles,
);
foreach ($dbData as $table => $records) {
$metadata['record_counts'][$table] = count($records);
}
file_put_contents($tempDir.'/metadata.json', json_encode($metadata, JSON_PRETTY_PRINT));
// Copy uploaded files if requested
if ($includeFiles) {
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
if (is_dir($filesDir)) {
$this->copyDirectory($filesDir, $tempDir.'/files');
}
}
// Create ZIP archive
$zipFile = $backupDir.'/'.$backupName.'.zip';
if (!$this->createZipArchive($tempDir, $zipFile)) {
$this->error = 'Cannot create ZIP archive';
return false;
}
// Clean up temp directory
$this->deleteDirectory($tempDir);
return $zipFile;
}
/**
* Export all database tables
*
* @return array|false Array of table data or false on error
*/
private function exportDatabaseTables()
{
global $conf;
$data = array();
foreach ($this->tables as $table) {
$fullTable = MAIN_DB_PREFIX.$table;
// Check if table exists
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
$resql = $this->db->query($sql);
if (!$resql || $this->db->num_rows($resql) == 0) {
continue; // Skip non-existent tables
}
$records = array();
$sql = "SELECT * FROM ".$fullTable;
$sql .= " WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_array($resql)) {
$records[] = $obj;
}
$this->db->free($resql);
}
$data[$table] = $records;
}
return $data;
}
/**
* Restore from a backup file
*
* @param string $backupFile Path to backup ZIP file
* @param bool $clearExisting Clear existing data before restore
* @return bool True on success, false on error
*/
public function restoreBackup($backupFile, $clearExisting = false)
{
global $conf, $user;
if (!file_exists($backupFile)) {
$this->error = 'Backup file not found';
return false;
}
// Create temp directory for extraction
$tempDir = $conf->kundenkarte->dir_output.'/backups/restore_'.uniqid();
if (!dol_mkdir($tempDir)) {
$this->error = 'Cannot create temp directory';
return false;
}
// Extract ZIP
$zip = new ZipArchive();
if ($zip->open($backupFile) !== true) {
$this->error = 'Cannot open backup file';
return false;
}
$zip->extractTo($tempDir);
$zip->close();
// Read metadata
$metadataFile = $tempDir.'/metadata.json';
if (!file_exists($metadataFile)) {
$this->error = 'Invalid backup: metadata.json not found';
$this->deleteDirectory($tempDir);
return false;
}
$metadata = json_decode(file_get_contents($metadataFile), true);
// Read database data
$dbFile = $tempDir.'/database.json';
if (!file_exists($dbFile)) {
$this->error = 'Invalid backup: database.json not found';
$this->deleteDirectory($tempDir);
return false;
}
$dbData = json_decode(file_get_contents($dbFile), true);
$this->db->begin();
try {
// Clear existing data if requested
if ($clearExisting) {
$this->clearExistingData();
}
// Import database tables (in correct order)
foreach ($this->tables as $table) {
if (isset($dbData[$table])) {
$this->importTable($table, $dbData[$table]);
}
}
// Restore files if included
if (!empty($metadata['includes_files']) && is_dir($tempDir.'/files')) {
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
if (!is_dir($filesDir)) {
dol_mkdir($filesDir);
}
$this->copyDirectory($tempDir.'/files', $filesDir);
}
$this->db->commit();
} catch (Exception $e) {
$this->db->rollback();
$this->error = $e->getMessage();
$this->deleteDirectory($tempDir);
return false;
}
// Clean up
$this->deleteDirectory($tempDir);
return true;
}
/**
* Clear existing data for this entity
*/
private function clearExistingData()
{
global $conf;
// Delete in reverse order to respect foreign keys
$reverseTables = array_reverse($this->tables);
foreach ($reverseTables as $table) {
$fullTable = MAIN_DB_PREFIX.$table;
// Check if table exists
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
$resql = $this->db->query($sql);
if (!$resql || $this->db->num_rows($resql) == 0) {
continue;
}
$sql = "DELETE FROM ".$fullTable." WHERE entity = ".((int) $conf->entity);
$this->db->query($sql);
}
}
/**
* Import data into a table
*
* @param string $table Table name (without prefix)
* @param array $records Array of records
*/
private function importTable($table, $records)
{
global $conf;
if (empty($records)) {
return;
}
$fullTable = MAIN_DB_PREFIX.$table;
// Check if table exists
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
$resql = $this->db->query($sql);
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Get column info
$columns = array();
$sql = "SHOW COLUMNS FROM ".$fullTable;
$resql = $this->db->query($sql);
while ($obj = $this->db->fetch_object($resql)) {
$columns[] = $obj->Field;
}
foreach ($records as $record) {
// Build insert statement
$fields = array();
$values = array();
foreach ($record as $field => $value) {
if (!in_array($field, $columns)) {
continue; // Skip unknown columns
}
$fields[] = $field;
if ($value === null) {
$values[] = 'NULL';
} elseif (is_numeric($value)) {
$values[] = $value;
} else {
$values[] = "'".$this->db->escape($value)."'";
}
}
if (empty($fields)) {
continue;
}
$sql = "INSERT INTO ".$fullTable." (".implode(', ', $fields).") VALUES (".implode(', ', $values).")";
$sql .= " ON DUPLICATE KEY UPDATE ";
$updates = array();
foreach ($fields as $i => $field) {
if ($field != 'rowid') {
$updates[] = $field." = ".$values[$i];
}
}
$sql .= implode(', ', $updates);
if (!$this->db->query($sql)) {
throw new Exception('Error importing '.$table.': '.$this->db->lasterror());
}
}
}
/**
* Get list of available backups
*
* @return array Array of backup info
*/
public function getBackupList()
{
global $conf;
$backups = array();
$backupDir = $conf->kundenkarte->dir_output.'/backups';
if (!is_dir($backupDir)) {
return $backups;
}
$files = glob($backupDir.'/kundenkarte_backup_*.zip');
if ($files) {
foreach ($files as $file) {
$filename = basename($file);
// Extract date from filename
if (preg_match('/kundenkarte_backup_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip/', $filename, $matches)) {
$date = str_replace('_', ' ', $matches[1]);
$date = str_replace('-', ':', substr($date, 11));
$date = substr($matches[1], 0, 10).' '.$date;
$backups[] = array(
'file' => $file,
'filename' => $filename,
'date' => $date,
'size' => filesize($file),
);
}
}
}
// Sort by date descending
usort($backups, function ($a, $b) {
return strcmp($b['date'], $a['date']);
});
return $backups;
}
/**
* Delete a backup file
*
* @param string $filename Backup filename
* @return bool
*/
public function deleteBackup($filename)
{
global $conf;
$file = $conf->kundenkarte->dir_output.'/backups/'.basename($filename);
if (file_exists($file) && strpos($filename, 'kundenkarte_backup_') === 0) {
return unlink($file);
}
return false;
}
/**
* Copy directory recursively
*
* @param string $src Source directory
* @param string $dst Destination directory
*/
private function copyDirectory($src, $dst)
{
if (!is_dir($dst)) {
dol_mkdir($dst);
}
$dir = opendir($src);
while (($file = readdir($dir)) !== false) {
if ($file == '.' || $file == '..') {
continue;
}
$srcFile = $src.'/'.$file;
$dstFile = $dst.'/'.$file;
if (is_dir($srcFile)) {
$this->copyDirectory($srcFile, $dstFile);
} else {
copy($srcFile, $dstFile);
}
}
closedir($dir);
}
/**
* Delete directory recursively
*
* @param string $dir Directory path
*/
private function deleteDirectory($dir)
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), array('.', '..'));
foreach ($files as $file) {
$path = $dir.'/'.$file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* Create ZIP archive from directory
*
* @param string $sourceDir Source directory
* @param string $zipFile Target ZIP file
* @return bool
*/
private function createZipArchive($sourceDir, $zipFile)
{
$zip = new ZipArchive();
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
return false;
}
$sourceDir = realpath($sourceDir);
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($files as $file) {
if (!$file->isDir()) {
$filePath = $file->getRealPath();
$relativePath = substr($filePath, strlen($sourceDir) + 1);
$zip->addFile($filePath, $relativePath);
}
}
return $zip->close();
}
/**
* Get backup statistics
*
* @return array Statistics array
*/
public function getStatistics()
{
global $conf;
$stats = array(
'total_anlagen' => 0,
'total_files' => 0,
'total_connections' => 0,
'total_customers' => 0,
'files_size' => 0,
);
// Count anlagen
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_anlagen'] = $obj->cnt;
}
// Count files
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_files'] = $obj->cnt;
}
// Count connections
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_connection WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_connections'] = $obj->cnt;
}
// Count customers with anlagen
$sql = "SELECT COUNT(DISTINCT fk_soc) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity);
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$stats['total_customers'] = $obj->cnt;
}
// Calculate files size
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
if (is_dir($filesDir)) {
$stats['files_size'] = $this->getDirectorySize($filesDir);
}
return $stats;
}
/**
* Get directory size recursively
*
* @param string $dir Directory path
* @return int Size in bytes
*/
private function getDirectorySize($dir)
{
$size = 0;
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file) {
if ($file->isFile()) {
$size += $file->getSize();
}
}
return $size;
}
}

View file

@ -25,6 +25,7 @@ class AnlageFile extends CommonObject
public $label;
public $description;
public $is_cover;
public $is_pinned;
public $position;
public $share;
@ -69,7 +70,7 @@ class AnlageFile extends CommonObject
$sql = "INSERT INTO ".MAIN_DB_PREFIX.$this->table_element." (";
$sql .= "entity, fk_anlage, filename, filepath, filesize, mimetype,";
$sql .= " file_type, label, description, is_cover, position, share,";
$sql .= " file_type, label, description, is_cover, is_pinned, position, share,";
$sql .= " date_creation, fk_user_creat";
$sql .= ") VALUES (";
$sql .= ((int) $conf->entity);
@ -82,6 +83,7 @@ class AnlageFile extends CommonObject
$sql .= ", ".($this->label ? "'".$this->db->escape($this->label)."'" : "NULL");
$sql .= ", ".($this->description ? "'".$this->db->escape($this->description)."'" : "NULL");
$sql .= ", ".((int) $this->is_cover);
$sql .= ", ".((int) $this->is_pinned);
$sql .= ", ".((int) $this->position);
$sql .= ", ".($this->share ? "'".$this->db->escape($this->share)."'" : "NULL");
$sql .= ", '".$this->db->idate($now)."'";
@ -137,6 +139,7 @@ class AnlageFile extends CommonObject
$this->label = $obj->label;
$this->description = $obj->description;
$this->is_cover = $obj->is_cover;
$this->is_pinned = $obj->is_pinned;
$this->position = $obj->position;
$this->share = $obj->share;
$this->date_creation = $this->db->jdate($obj->date_creation);
@ -216,7 +219,7 @@ class AnlageFile extends CommonObject
if ($fileType) {
$sql .= " AND file_type = '".$this->db->escape($fileType)."'";
}
$sql .= " ORDER BY is_cover DESC, position ASC, filename ASC";
$sql .= " ORDER BY is_pinned DESC, is_cover DESC, position ASC, filename ASC";
$resql = $this->db->query($sql);
if ($resql) {
@ -232,6 +235,7 @@ class AnlageFile extends CommonObject
$file->label = $obj->label;
$file->description = $obj->description;
$file->is_cover = $obj->is_cover;
$file->is_pinned = $obj->is_pinned;
$file->position = $obj->position;
$file->share = $obj->share;
$file->date_creation = $this->db->jdate($obj->date_creation);
@ -264,6 +268,9 @@ class AnlageFile extends CommonObject
if (in_array($ext, array('doc', 'docx', 'xls', 'xlsx', 'odt', 'ods', 'txt', 'rtf'))) {
return 'document';
}
if (in_array($ext, array('zip', 'rar', '7z', 'tar', 'gz', 'tgz'))) {
return 'archive';
}
return 'other';
}
@ -384,6 +391,29 @@ class AnlageFile extends CommonObject
}
}
/**
* Toggle pinned status of file
*
* @param User $user User making the change
* @return int <0 if KO, >0 if OK
*/
public function togglePin($user)
{
$newStatus = $this->is_pinned ? 0 : 1;
$sql = "UPDATE ".MAIN_DB_PREFIX.$this->table_element;
$sql .= " SET is_pinned = ".((int) $newStatus);
$sql .= ", fk_user_modif = ".((int) $user->id);
$sql .= " WHERE rowid = ".((int) $this->id);
$resql = $this->db->query($sql);
if ($resql) {
$this->is_pinned = $newStatus;
return 1;
}
return -1;
}
/**
* Generate thumbnail for image
*

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.5.0';
$this->version = '4.0.1';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
@ -515,6 +515,9 @@ class modKundenKarte extends DolibarrModules
// Migrationen: UNIQUE KEY uk_kundenkarte_societe_system um fk_contact erweitern
$this->_migrateSocieteSystemUniqueKey();
// Run all database migrations
$this->runMigrations();
// Permissions
$this->remove($options);
@ -589,6 +592,117 @@ class modKundenKarte extends DolibarrModules
$this->db->query("ALTER TABLE ".$table." ADD UNIQUE INDEX uk_kundenkarte_societe_system (fk_soc, fk_contact, fk_system)");
}
/**
* Run all database migrations.
* Each migration is idempotent - safe to run multiple times.
*/
private function runMigrations()
{
// v3.6.0: Add autocomplete field
$this->migrate_v360_autocomplete();
// v3.6.0: Add file pinning
$this->migrate_v360_file_pinning();
// v3.6.0: Add tree display mode for fields
$this->migrate_v360_tree_display_mode();
// v3.7.0: Add badge color for fields
$this->migrate_v370_badge_color();
}
/**
* Migration v3.6.0: Add enable_autocomplete column to type fields
*/
private function migrate_v360_autocomplete()
{
$table = MAIN_DB_PREFIX."kundenkarte_anlage_type_field";
// Check if table exists
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Check if column exists
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'enable_autocomplete'");
if ($resql && $this->db->num_rows($resql) > 0) {
return; // Already migrated
}
// Add column
$this->db->query("ALTER TABLE ".$table." ADD COLUMN enable_autocomplete tinyint DEFAULT 0 NOT NULL AFTER show_in_hover");
}
/**
* Migration v3.6.0: Add is_pinned column to files
*/
private function migrate_v360_file_pinning()
{
$table = MAIN_DB_PREFIX."kundenkarte_anlage_files";
// Check if table exists
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Check if column exists
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'is_pinned'");
if ($resql && $this->db->num_rows($resql) > 0) {
return; // Already migrated
}
// Add column
$this->db->query("ALTER TABLE ".$table." ADD COLUMN is_pinned tinyint DEFAULT 0 NOT NULL AFTER is_cover");
}
/**
* Migration v3.6.0: Add tree_display_mode column to type fields
*/
private function migrate_v360_tree_display_mode()
{
$table = MAIN_DB_PREFIX."kundenkarte_anlage_type_field";
// Check if table exists
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Check if column exists
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'tree_display_mode'");
if ($resql && $this->db->num_rows($resql) > 0) {
return; // Already migrated
}
// Add column
$this->db->query("ALTER TABLE ".$table." ADD COLUMN tree_display_mode varchar(20) DEFAULT 'badge' AFTER show_in_tree");
}
/**
* Migration v3.7.0: Add badge_color column to type fields
*/
private function migrate_v370_badge_color()
{
$table = MAIN_DB_PREFIX."kundenkarte_anlage_type_field";
// Check if table exists
$resql = $this->db->query("SHOW TABLES LIKE '".$this->db->escape($table)."'");
if (!$resql || $this->db->num_rows($resql) == 0) {
return;
}
// Check if column exists
$resql = $this->db->query("SHOW COLUMNS FROM ".$table." LIKE 'badge_color'");
if ($resql && $this->db->num_rows($resql) > 0) {
return; // Already migrated
}
// Add column
$this->db->query("ALTER TABLE ".$table." ADD COLUMN badge_color varchar(7) AFTER tree_display_mode");
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.

View file

@ -222,8 +222,49 @@
.kundenkarte-tree-label-info {
font-weight: normal !important;
color: #999 !important;
font-size: 0.9em !important;
color: #888 !important;
font-size: 0.85em !important;
margin-left: 4px !important;
}
/* Spacer to push badges to the right */
.kundenkarte-tree-spacer {
flex: 1 !important;
min-width: 10px !important;
}
/* Tree info badges */
.kundenkarte-tree-badges {
display: inline-flex !important;
gap: 6px !important;
margin-left: 0 !important;
margin-right: 8px !important;
flex-wrap: wrap !important;
}
.kundenkarte-tree-badge {
display: inline-flex !important;
align-items: center !important;
vertical-align: middle !important;
gap: 4px !important;
padding: 2px 8px !important;
background: linear-gradient(135deg, #2a4a5e 0%, #1e3a4a 100%) !important;
border: 1px solid #3a6a8e !important;
border-radius: 12px !important;
font-size: 11px !important;
color: #8cc4e8 !important;
font-weight: 500 !important;
white-space: nowrap !important;
}
.kundenkarte-tree-badge i {
font-size: 10px !important;
color: #5aa8d4 !important;
}
.kundenkarte-tree-badge:hover {
background: linear-gradient(135deg, #3a5a6e 0%, #2a4a5a 100%) !important;
border-color: #4a7a9e !important;
}
.kundenkarte-tree-actions {
@ -298,6 +339,7 @@ body.kundenkarte-drag-active * {
/* Tree - File Indicators */
.kundenkarte-tree-files {
display: inline-flex !important;
vertical-align: middle !important;
gap: 3px !important;
margin-left: 6px !important;
}
@ -306,8 +348,9 @@ body.kundenkarte-drag-active * {
font-size: 0.8em !important;
padding: 2px 6px !important;
border-radius: 4px !important;
display: flex !important;
display: inline-flex !important;
align-items: center !important;
vertical-align: middle !important;
gap: 3px !important;
text-decoration: none !important;
}
@ -322,6 +365,11 @@ body.kundenkarte-drag-active * {
color: #ddd !important;
}
.kundenkarte-tree-file-all {
background: #4a5a6a !important;
color: #ddd !important;
}
/* ========================================
TOOLTIP
======================================== */
@ -393,6 +441,144 @@ body.kundenkarte-drag-active * {
color: #aaa !important;
}
/* ========================================
FILE PREVIEW TOOLTIP
======================================== */
.kundenkarte-file-preview {
min-width: 280px !important;
max-width: 400px !important;
}
.kundenkarte-file-preview-section {
margin-bottom: 12px !important;
}
.kundenkarte-file-preview-section:last-child {
margin-bottom: 0 !important;
}
.kundenkarte-file-preview-title {
font-weight: bold !important;
color: #8cc4e8 !important;
margin-bottom: 8px !important;
font-size: 12px !important;
display: flex !important;
align-items: center !important;
gap: 6px !important;
}
.kundenkarte-file-preview-thumbs {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
}
.kundenkarte-file-preview-thumb {
position: relative !important;
width: 60px !important;
height: 60px !important;
border-radius: 4px !important;
overflow: hidden !important;
border: 2px solid #444 !important;
transition: border-color 0.2s !important;
}
.kundenkarte-file-preview-thumb:hover {
border-color: #8cc4e8 !important;
}
.kundenkarte-file-preview-thumb.is-cover {
border-color: #f39c12 !important;
}
.kundenkarte-file-preview-thumb.is-pinned {
border-color: #27ae60 !important;
}
.kundenkarte-file-preview-thumb img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
}
.kundenkarte-file-preview-pin {
position: absolute !important;
top: 2px !important;
right: 2px !important;
background: rgba(39, 174, 96, 0.9) !important;
color: #fff !important;
font-size: 9px !important;
padding: 2px 4px !important;
border-radius: 3px !important;
}
.kundenkarte-file-preview-more {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 60px !important;
height: 60px !important;
background: #333 !important;
border-radius: 4px !important;
color: #888 !important;
font-size: 14px !important;
font-weight: bold !important;
}
.kundenkarte-file-preview-docs {
display: flex !important;
flex-direction: column !important;
gap: 4px !important;
}
.kundenkarte-file-preview-doc {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 6px 8px !important;
background: #2a2a2a !important;
border-radius: 4px !important;
text-decoration: none !important;
color: #ddd !important;
transition: background-color 0.2s !important;
}
.kundenkarte-file-preview-doc:hover {
background: #3a3a3a !important;
color: #fff !important;
}
.kundenkarte-file-preview-doc.is-pinned {
background: #2a3a2a !important;
}
.kundenkarte-file-preview-doc i:first-child {
font-size: 16px !important;
width: 20px !important;
text-align: center !important;
}
.kundenkarte-file-preview-doc-name {
flex: 1 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
font-size: 12px !important;
}
.kundenkarte-file-preview-doc-pin {
color: #27ae60 !important;
font-size: 10px !important;
}
.kundenkarte-file-preview-more-docs {
padding: 4px 8px !important;
color: #888 !important;
font-size: 11px !important;
text-align: center !important;
}
/* ========================================
SYSTEM TABS
======================================== */
@ -569,38 +755,142 @@ body.kundenkarte-drag-active * {
FILE GALLERY
======================================== */
/* ========================================
FILE DROPZONE
======================================== */
.kundenkarte-dropzone {
border: 2px dashed #555 !important;
border-radius: 8px !important;
padding: 30px !important;
text-align: center !important;
background: #1e1e1e !important;
transition: all 0.3s ease !important;
cursor: pointer !important;
}
.kundenkarte-dropzone:hover,
.kundenkarte-dropzone.dragover {
border-color: #3498db !important;
background: #252530 !important;
}
.kundenkarte-dropzone.dragover {
transform: scale(1.02) !important;
}
.kundenkarte-dropzone-content {
pointer-events: none !important;
}
.kundenkarte-dropzone-files {
display: flex !important;
flex-wrap: wrap !important;
gap: 10px !important;
margin-top: 15px !important;
justify-content: center !important;
}
.kundenkarte-dropzone-file {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 8px 12px !important;
background: #333 !important;
border-radius: 4px !important;
font-size: 12px !important;
color: #e0e0e0 !important;
}
.kundenkarte-dropzone-file i {
color: #3498db !important;
}
.kundenkarte-dropzone-file .remove-file {
color: #e74c3c !important;
cursor: pointer !important;
margin-left: 5px !important;
}
.kundenkarte-dropzone-file .remove-file:hover {
color: #ff6b6b !important;
}
/* ========================================
FILE GRID - LARGER BOXES
======================================== */
.kundenkarte-files-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)) !important;
gap: 15px !important;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)) !important;
gap: 20px !important;
margin-top: 15px !important;
}
.kundenkarte-file-item {
border: 1px solid #444 !important;
border-radius: 6px !important;
border-radius: 8px !important;
overflow: hidden !important;
background: #2d2d2d !important;
transition: transform 0.2s, box-shadow 0.2s !important;
position: relative !important;
}
.kundenkarte-file-item:hover {
transform: translateY(-3px) !important;
box-shadow: 0 6px 20px rgba(0,0,0,0.3) !important;
}
/* Pinned files */
.kundenkarte-file-pinned {
border-color: #e6a500 !important;
box-shadow: 0 0 0 1px #e6a500 inset !important;
}
.kundenkarte-pin-indicator {
position: absolute !important;
top: 8px !important;
right: 8px !important;
background: #e6a500 !important;
color: #000 !important;
width: 24px !important;
height: 24px !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 12px !important;
z-index: 10 !important;
}
.kundenkarte-file-btn-pinned {
color: #e6a500 !important;
}
.kundenkarte-file-preview {
height: 140px !important;
height: 180px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: #1e1e1e !important;
overflow: hidden !important;
cursor: pointer !important;
}
.kundenkarte-file-preview img {
max-width: 100% !important;
max-height: 100% !important;
object-fit: contain !important;
transition: transform 0.3s !important;
}
.kundenkarte-file-item:hover .kundenkarte-file-preview img {
transform: scale(1.05) !important;
}
.kundenkarte-file-info {
padding: 10px !important;
font-size: 0.9em !important;
padding: 12px 15px !important;
font-size: 0.95em !important;
}
.kundenkarte-file-name {
@ -609,17 +899,19 @@ body.kundenkarte-drag-active * {
text-overflow: ellipsis !important;
white-space: nowrap !important;
color: #e0e0e0 !important;
font-size: 13px !important;
margin-bottom: 4px !important;
}
.kundenkarte-file-size {
color: #999 !important;
font-size: 0.85em !important;
color: #888 !important;
font-size: 11px !important;
}
.kundenkarte-file-actions {
display: flex !important;
gap: 5px !important;
margin-top: 8px !important;
gap: 8px !important;
margin-top: 10px !important;
justify-content: center !important;
}
@ -627,21 +919,23 @@ body.kundenkarte-drag-active * {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
border-radius: 4px !important;
width: 32px !important;
height: 32px !important;
border-radius: 6px !important;
background: #444 !important;
color: #e0e0e0 !important;
text-decoration: none !important;
transition: all 0.2s !important;
}
.kundenkarte-file-btn:hover {
background: #555 !important;
color: #fff !important;
transform: scale(1.1) !important;
}
.kundenkarte-file-btn-delete:hover {
background: #a33 !important;
background: #c0392b !important;
}
/* ========================================
@ -2165,3 +2459,261 @@ body.kundenkarte-drag-active * {
.kundenkarte-conn-tooltip-hint i {
margin-right: 5px !important;
}
/* ========================================
MOBILE / COMPACT VIEW
======================================== */
/* Mobile view toggle button */
.kundenkarte-view-toggle {
display: none !important;
}
@media (max-width: 768px) {
.kundenkarte-view-toggle {
display: inline-flex !important;
align-items: center !important;
gap: 5px !important;
padding: 6px 12px !important;
background: #333 !important;
border: 1px solid #555 !important;
border-radius: 4px !important;
color: #ddd !important;
cursor: pointer !important;
font-size: 12px !important;
}
.kundenkarte-view-toggle.active {
background: #2a4a5e !important;
border-color: #3a6a8e !important;
}
}
/* Compact mode styles */
.kundenkarte-tree.compact-mode .kundenkarte-tree-row {
min-height: 32px !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-item {
padding: 4px 8px !important;
font-size: 13px !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-badges,
.kundenkarte-tree.compact-mode .kundenkarte-tree-label-info,
.kundenkarte-tree.compact-mode .kundenkarte-tree-type {
display: none !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-actions {
opacity: 0 !important;
transition: opacity 0.2s !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-item:hover .kundenkarte-tree-actions,
.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-actions {
opacity: 1 !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-files {
margin-left: auto !important;
}
/* Mobile-specific overrides */
@media (max-width: 768px) {
/* Tree container */
.kundenkarte-tree {
padding: 5px 0 !important;
}
/* Tree row */
.kundenkarte-tree-row {
min-height: 40px !important;
}
/* Tree item - touch-friendly */
.kundenkarte-tree-item {
padding: 8px 10px !important;
font-size: 14px !important;
min-height: 40px !important;
}
/* Hide badges and extra info on mobile by default */
.kundenkarte-tree-badges,
.kundenkarte-tree-label-info {
display: none !important;
}
/* Smaller type badge */
.kundenkarte-tree-type {
font-size: 10px !important;
padding: 2px 4px !important;
}
/* Touch-friendly actions */
.kundenkarte-tree-actions {
gap: 12px !important;
}
.kundenkarte-tree-actions a {
padding: 8px !important;
}
/* Cable lines - thinner on mobile */
.cable-line {
width: 12px !important;
min-width: 12px !important;
}
/* File badge */
.kundenkarte-tree-file-badge {
padding: 4px 8px !important;
font-size: 12px !important;
}
/* System tabs - scrollable */
.kundenkarte-system-tabs-wrapper {
flex-direction: column !important;
align-items: stretch !important;
}
.kundenkarte-system-tabs {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
padding-bottom: 5px !important;
}
.kundenkarte-system-tabs::-webkit-scrollbar {
height: 4px !important;
}
.kundenkarte-tree-controls {
justify-content: center !important;
margin-top: 8px !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
/* Uniform button sizes on mobile */
.kundenkarte-tree-controls .button,
.kundenkarte-tree-controls .button.small,
.kundenkarte-tree-controls a.button,
.kundenkarte-tree-controls a.button.small,
.kundenkarte-tree-controls button.button,
.kundenkarte-view-toggle {
flex: 1 1 auto !important;
min-width: 80px !important;
max-width: 150px !important;
padding: 10px 8px !important;
font-size: 12px !important;
text-align: center !important;
justify-content: center !important;
white-space: nowrap !important;
}
/* Hide button text on very small screens, show only icons */
.kundenkarte-tree-controls .button span,
.kundenkarte-tree-controls a.button span {
display: inline !important;
}
.kundenkarte-tree-controls .button i,
.kundenkarte-tree-controls a.button i {
margin-right: 4px !important;
}
/* Compact toggle indicator */
.kundenkarte-tree-toggle {
padding: 10px !important;
}
/* Hide spacer on mobile */
.kundenkarte-tree-spacer {
display: none !important;
}
/* Tooltip adjustments for mobile */
.kundenkarte-tooltip {
max-width: 90vw !important;
min-width: 200px !important;
}
.kundenkarte-file-preview {
max-width: 85vw !important;
}
.kundenkarte-file-preview-thumbs {
justify-content: flex-start !important;
}
.kundenkarte-file-preview-thumb {
width: 50px !important;
height: 50px !important;
}
}
/* Very small screens */
@media (max-width: 480px) {
.kundenkarte-tree-item {
padding: 6px 8px !important;
}
.kundenkarte-tree-type {
display: none !important;
}
.kundenkarte-tree-actions a {
font-size: 14px !important;
}
/* Only show most important actions */
.kundenkarte-tree-actions a:not(:first-child):not(:nth-child(2)) {
display: none !important;
}
.cable-line {
width: 10px !important;
min-width: 10px !important;
}
/* Buttons on very small screens - 2x2 grid */
.kundenkarte-tree-controls {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 6px !important;
width: 100% !important;
}
.kundenkarte-tree-controls .button,
.kundenkarte-tree-controls .button.small,
.kundenkarte-tree-controls a.button,
.kundenkarte-tree-controls a.button.small,
.kundenkarte-tree-controls button.button,
.kundenkarte-view-toggle {
min-width: unset !important;
max-width: unset !important;
width: 100% !important;
padding: 10px 6px !important;
font-size: 11px !important;
}
}
/* Expanded item in compact mode - show details */
.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded {
background: #2a2a2a !important;
flex-wrap: wrap !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-badges,
.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-label-info {
display: flex !important;
width: 100% !important;
margin-top: 8px !important;
padding-top: 8px !important;
border-top: 1px solid #444 !important;
}
.kundenkarte-tree.compact-mode .kundenkarte-tree-item.expanded .kundenkarte-tree-badges {
flex-wrap: wrap !important;
gap: 6px !important;
}

View file

@ -178,6 +178,7 @@
init: function() {
this.bindEvents();
this.initDragDrop();
this.initCompactMode();
},
bindEvents: function() {
@ -209,6 +210,21 @@
self.collapseAll();
});
// Compact mode toggle
$(document).on('click', '#btn-compact-mode', function(e) {
e.preventDefault();
self.toggleCompactMode();
});
// In compact mode, click on item to expand/show details
$(document).on('click', '.kundenkarte-tree.compact-mode .kundenkarte-tree-item', function(e) {
// Don't trigger on action buttons or toggle
if ($(e.target).closest('.kundenkarte-tree-actions, .kundenkarte-tree-toggle, .kundenkarte-tree-files').length) {
return;
}
$(this).toggleClass('expanded');
});
// Hover tooltip on ICON only - show after delay
$(document).on('mouseenter', '.kundenkarte-tooltip-trigger', function(e) {
var $trigger = $(this);
@ -287,6 +303,30 @@
}, 100);
});
// File badge tooltip on hover (combined images + documents)
$(document).on('mouseenter', '.kundenkarte-tree-file-badge', function(e) {
var $trigger = $(this);
var anlageId = $trigger.data('anlage-id');
if (!anlageId) return;
clearTimeout(self.hideTimeout);
self.hideTimeout = null;
self.tooltipTimeout = setTimeout(function() {
self.showFilePreview($trigger, anlageId);
}, 300);
});
$(document).on('mouseleave', '.kundenkarte-tree-file-badge', function() {
clearTimeout(self.tooltipTimeout);
self.tooltipTimeout = null;
self.hideTimeout = setTimeout(function() {
self.hideTooltip();
}, 100);
});
// Keep tooltip visible when hovering over it
$(document).on('mouseenter', '#kundenkarte-tooltip', function() {
clearTimeout(self.hideTimeout);
@ -510,6 +550,105 @@
});
},
showFilePreview: function($trigger, anlageId) {
var self = this;
// Load all files via AJAX
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/file_preview.php',
data: { anlage_id: anlageId },
dataType: 'json',
success: function(response) {
if ((!response.images || response.images.length === 0) &&
(!response.documents || response.documents.length === 0)) {
return;
}
var html = '<div class="kundenkarte-file-preview">';
// Images section with thumbnails
if (response.images && response.images.length > 0) {
html += '<div class="kundenkarte-file-preview-section">';
html += '<div class="kundenkarte-file-preview-title"><i class="fa fa-image"></i> Bilder (' + response.images.length + ')</div>';
html += '<div class="kundenkarte-file-preview-thumbs">';
for (var i = 0; i < response.images.length && i < 6; i++) {
var img = response.images[i];
var pinnedClass = img.is_pinned ? ' is-pinned' : '';
var coverClass = img.is_cover ? ' is-cover' : '';
html += '<a href="' + img.url + '" target="_blank" class="kundenkarte-file-preview-thumb' + pinnedClass + coverClass + '">';
html += '<img src="' + img.thumb_url + '" alt="' + self.escapeHtml(img.name) + '">';
if (img.is_pinned) {
html += '<span class="kundenkarte-file-preview-pin"><i class="fa fa-thumb-tack"></i></span>';
}
html += '</a>';
}
if (response.images.length > 6) {
html += '<span class="kundenkarte-file-preview-more">+' + (response.images.length - 6) + '</span>';
}
html += '</div>';
html += '</div>';
}
// Documents section with icons
if (response.documents && response.documents.length > 0) {
html += '<div class="kundenkarte-file-preview-section">';
html += '<div class="kundenkarte-file-preview-title"><i class="fa fa-file-text-o"></i> Dokumente (' + response.documents.length + ')</div>';
html += '<div class="kundenkarte-file-preview-docs">';
for (var j = 0; j < response.documents.length && j < 5; j++) {
var doc = response.documents[j];
var docPinnedClass = doc.is_pinned ? ' is-pinned' : '';
html += '<a href="' + doc.url + '" target="_blank" class="kundenkarte-file-preview-doc' + docPinnedClass + '">';
html += '<i class="fa ' + doc.icon + '" style="color:' + doc.color + '"></i>';
html += '<span class="kundenkarte-file-preview-doc-name">' + self.escapeHtml(doc.name) + '</span>';
if (doc.is_pinned) {
html += '<i class="fa fa-thumb-tack kundenkarte-file-preview-doc-pin"></i>';
}
html += '</a>';
}
if (response.documents.length > 5) {
html += '<div class="kundenkarte-file-preview-more-docs">+' + (response.documents.length - 5) + ' weitere</div>';
}
html += '</div>';
html += '</div>';
}
html += '</div>';
var $tooltip = $('#kundenkarte-tooltip');
if (!$tooltip.length) {
$tooltip = $('<div id="kundenkarte-tooltip" class="kundenkarte-tooltip"></div>');
$('body').append($tooltip);
}
$tooltip.html(html);
// Position tooltip
var offset = $trigger.offset();
var windowWidth = $(window).width();
var scrollTop = $(window).scrollTop();
$tooltip.css({ visibility: 'hidden', display: 'block' });
var tooltipWidth = $tooltip.outerWidth();
var tooltipHeight = $tooltip.outerHeight();
$tooltip.css({ visibility: '', display: '' });
var left = offset.left + $trigger.outerWidth() + 10;
if (left + tooltipWidth > windowWidth - 20) {
left = offset.left - tooltipWidth - 10;
}
if (left < 10) left = 10;
var top = offset.top;
if (top + tooltipHeight > scrollTop + $(window).height() - 20) {
top = scrollTop + $(window).height() - tooltipHeight - 20;
}
$tooltip.css({ top: top, left: left }).addClass('visible').show();
self.currentTooltip = $tooltip;
}
});
},
buildTooltipHtml: function(data) {
var html = '<div class="kundenkarte-tooltip-header">';
html += '<span class="kundenkarte-tooltip-icon"><i class="fa ' + (data.picto || 'fa-cube') + '"></i></span>';
@ -704,6 +843,41 @@
collapseAll: function() {
$('.kundenkarte-tree-toggle').addClass('collapsed');
$('.kundenkarte-tree-children').addClass('collapsed');
},
toggleCompactMode: function() {
var $tree = $('.kundenkarte-tree');
var $btn = $('#btn-compact-mode');
$tree.toggleClass('compact-mode');
$btn.toggleClass('active');
if ($tree.hasClass('compact-mode')) {
$btn.find('span').text('Normal');
$btn.find('i').removeClass('fa-compress').addClass('fa-expand');
// Remove any expanded items
$('.kundenkarte-tree-item.expanded').removeClass('expanded');
// Store preference
localStorage.setItem('kundenkarte_compact_mode', '1');
} else {
$btn.find('span').text('Kompakt');
$btn.find('i').removeClass('fa-expand').addClass('fa-compress');
localStorage.removeItem('kundenkarte_compact_mode');
}
},
initCompactMode: function() {
// Check localStorage for saved preference
if (localStorage.getItem('kundenkarte_compact_mode') === '1') {
this.toggleCompactMode();
}
// Auto-enable on mobile
if (window.innerWidth <= 768 && !localStorage.getItem('kundenkarte_compact_mode_manual')) {
if (!$('.kundenkarte-tree').hasClass('compact-mode')) {
this.toggleCompactMode();
}
}
}
};
@ -1202,12 +1376,16 @@
},
loadFields: function(typeId) {
var self = this;
var $container = $('#dynamic_fields');
if (!typeId) {
$container.html('');
return;
}
// Store current type ID for autocomplete
this.currentTypeId = typeId;
// Get anlage_id if editing or copying
var anlageId = $('input[name="anlage_id"]').val() || $('input[name="copy_from"]').val() || 0;
@ -1240,17 +1418,21 @@
});
},
currentTypeId: 0,
renderField: function(field) {
var name = 'field_' + field.code;
var value = field.value || '';
var required = field.required ? ' required' : '';
var autocompleteClass = field.autocomplete ? ' kk-autocomplete' : '';
var autocompleteAttrs = field.autocomplete ? ' data-field-code="' + field.code + '" data-type-id="' + this.currentTypeId + '"' : '';
switch (field.type) {
case 'text':
return '<input type="text" name="' + name + '" class="flat minwidth300" value="' + this.escapeHtml(value) + '"' + required + '>';
return '<input type="text" name="' + name + '" class="flat minwidth300' + autocompleteClass + '" value="' + this.escapeHtml(value) + '"' + autocompleteAttrs + required + ' autocomplete="off">';
case 'textarea':
return '<textarea name="' + name + '" class="flat minwidth300" rows="3"' + required + '>' + this.escapeHtml(value) + '</textarea>';
return '<textarea name="' + name + '" class="flat minwidth300' + autocompleteClass + '" rows="3"' + autocompleteAttrs + required + ' autocomplete="off">' + this.escapeHtml(value) + '</textarea>';
case 'number':
var attrs = '';
@ -10502,4 +10684,353 @@
}
};
// ===========================================
// File Upload Dropzone
// ===========================================
KundenKarte.initFileDropzone = function() {
var dropzone = document.getElementById('fileDropzone');
var fileInput = document.getElementById('fileInput');
var selectedFilesDiv = document.getElementById('selectedFiles');
var uploadBtn = document.getElementById('uploadBtn');
var fileCountSpan = document.getElementById('fileCount');
var form = document.getElementById('fileUploadForm');
if (!dropzone || !fileInput) return;
var selectedFiles = [];
// Click to open file dialog
dropzone.addEventListener('click', function(e) {
if (e.target.tagName !== 'A') {
fileInput.click();
}
});
// Drag & Drop handlers
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(eventName) {
dropzone.addEventListener(eventName, function(e) {
e.preventDefault();
e.stopPropagation();
});
});
['dragenter', 'dragover'].forEach(function(eventName) {
dropzone.addEventListener(eventName, function() {
dropzone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function(eventName) {
dropzone.addEventListener(eventName, function() {
dropzone.classList.remove('dragover');
});
});
dropzone.addEventListener('drop', function(e) {
var files = e.dataTransfer.files;
handleFiles(files);
});
fileInput.addEventListener('change', function() {
handleFiles(this.files);
});
function handleFiles(files) {
for (var i = 0; i < files.length; i++) {
var file = files[i];
// Check for duplicates
var isDuplicate = selectedFiles.some(function(f) {
return f.name === file.name && f.size === file.size;
});
if (!isDuplicate) {
selectedFiles.push(file);
}
}
updateFileList();
}
function updateFileList() {
selectedFilesDiv.innerHTML = '';
if (selectedFiles.length === 0) {
selectedFilesDiv.style.display = 'none';
uploadBtn.style.display = 'none';
return;
}
selectedFilesDiv.style.display = 'flex';
uploadBtn.style.display = 'inline-block';
fileCountSpan.textContent = selectedFiles.length;
selectedFiles.forEach(function(file, index) {
var fileDiv = document.createElement('div');
fileDiv.className = 'kundenkarte-dropzone-file';
var icon = getFileIcon(file.name);
fileDiv.innerHTML = '<i class="fa ' + icon + '"></i> ' +
'<span>' + KundenKarte.AnlageConnection.escapeHtml(file.name) + '</span>' +
'<span class="remove-file" data-index="' + index + '"><i class="fa fa-times"></i></span>';
selectedFilesDiv.appendChild(fileDiv);
});
// Add remove handlers
selectedFilesDiv.querySelectorAll('.remove-file').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
var idx = parseInt(this.getAttribute('data-index'));
selectedFiles.splice(idx, 1);
updateFileList();
});
});
// Update file input with DataTransfer
updateFileInput();
}
function updateFileInput() {
var dt = new DataTransfer();
selectedFiles.forEach(function(file) {
dt.items.add(file);
});
fileInput.files = dt.files;
}
function getFileIcon(filename) {
var ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].indexOf(ext) !== -1) {
return 'fa-image';
} else if (ext === 'pdf') {
return 'fa-file-pdf-o';
} else if (['doc', 'docx'].indexOf(ext) !== -1) {
return 'fa-file-word-o';
} else if (['xls', 'xlsx'].indexOf(ext) !== -1) {
return 'fa-file-excel-o';
}
return 'fa-file-o';
}
};
// Auto-init on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', KundenKarte.initFileDropzone);
} else {
KundenKarte.initFileDropzone();
}
// ===========================================
// Field Autocomplete
// ===========================================
KundenKarte.FieldAutocomplete = {
baseUrl: '',
activeInput: null,
dropdown: null,
debounceTimer: null,
init: function(baseUrl) {
this.baseUrl = baseUrl || '';
this.createDropdown();
this.bindEvents();
},
createDropdown: function() {
// Create dropdown element if not exists
if (!document.getElementById('kk-autocomplete-dropdown')) {
var dropdown = document.createElement('div');
dropdown.id = 'kk-autocomplete-dropdown';
dropdown.className = 'kk-autocomplete-dropdown';
dropdown.style.cssText = 'display:none;position:absolute;z-index:10000;background:#2d2d2d;border:1px solid #555;border-radius:4px;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,0.3);';
document.body.appendChild(dropdown);
this.dropdown = dropdown;
} else {
this.dropdown = document.getElementById('kk-autocomplete-dropdown');
}
},
bindEvents: function() {
var self = this;
// Delegate events for autocomplete inputs
document.addEventListener('input', function(e) {
if (e.target.classList.contains('kk-autocomplete')) {
self.onInput(e.target);
}
});
document.addEventListener('focus', function(e) {
if (e.target.classList.contains('kk-autocomplete')) {
self.onFocus(e.target);
}
}, true);
document.addEventListener('blur', function(e) {
if (e.target.classList.contains('kk-autocomplete')) {
// Delay to allow click on dropdown item
setTimeout(function() {
self.hideDropdown();
}, 200);
}
}, true);
document.addEventListener('keydown', function(e) {
if (e.target.classList.contains('kk-autocomplete') && self.dropdown.style.display !== 'none') {
self.onKeydown(e);
}
});
// Click on dropdown item
this.dropdown.addEventListener('click', function(e) {
var item = e.target.closest('.kk-autocomplete-item');
if (item) {
self.selectItem(item);
}
});
},
onInput: function(input) {
var self = this;
this.activeInput = input;
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(function() {
self.fetchSuggestions(input);
}, 150);
},
onFocus: function(input) {
this.activeInput = input;
if (input.value.length >= 1) {
this.fetchSuggestions(input);
}
},
onKeydown: function(e) {
var items = this.dropdown.querySelectorAll('.kk-autocomplete-item');
var activeItem = this.dropdown.querySelector('.kk-autocomplete-item.active');
var activeIndex = -1;
items.forEach(function(item, idx) {
if (item.classList.contains('active')) activeIndex = idx;
});
if (e.key === 'ArrowDown') {
e.preventDefault();
if (activeIndex < items.length - 1) {
if (activeItem) activeItem.classList.remove('active');
items[activeIndex + 1].classList.add('active');
items[activeIndex + 1].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (activeIndex > 0) {
if (activeItem) activeItem.classList.remove('active');
items[activeIndex - 1].classList.add('active');
items[activeIndex - 1].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'Enter' && activeItem) {
e.preventDefault();
this.selectItem(activeItem);
} else if (e.key === 'Escape') {
this.hideDropdown();
}
},
fetchSuggestions: function(input) {
var self = this;
var fieldCode = input.getAttribute('data-field-code');
var typeId = input.getAttribute('data-type-id') || 0;
var query = input.value;
if (!fieldCode) return;
$.ajax({
url: this.baseUrl + '/custom/kundenkarte/ajax/field_autocomplete.php',
method: 'GET',
data: {
action: 'suggest',
field_code: fieldCode,
type_id: typeId,
query: query
},
dataType: 'json',
success: function(response) {
if (response.suggestions && response.suggestions.length > 0) {
self.showSuggestions(input, response.suggestions, query);
} else {
self.hideDropdown();
}
}
});
},
showSuggestions: function(input, suggestions, query) {
var self = this;
var rect = input.getBoundingClientRect();
this.dropdown.innerHTML = '';
this.dropdown.style.top = (rect.bottom + window.scrollY) + 'px';
this.dropdown.style.left = rect.left + 'px';
this.dropdown.style.width = rect.width + 'px';
this.dropdown.style.display = 'block';
suggestions.forEach(function(suggestion, idx) {
var item = document.createElement('div');
item.className = 'kk-autocomplete-item';
if (idx === 0) item.classList.add('active');
item.style.cssText = 'padding:8px 12px;cursor:pointer;color:#e0e0e0;border-bottom:1px solid #444;';
item.setAttribute('data-value', suggestion);
// Highlight matching text
var html = self.highlightMatch(suggestion, query);
item.innerHTML = html;
self.dropdown.appendChild(item);
});
// Hover effect
this.dropdown.querySelectorAll('.kk-autocomplete-item').forEach(function(item) {
item.addEventListener('mouseenter', function() {
self.dropdown.querySelectorAll('.kk-autocomplete-item').forEach(function(i) {
i.classList.remove('active');
});
item.classList.add('active');
});
});
},
highlightMatch: function(text, query) {
if (!query) return text;
var regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<strong style="color:#3498db;">$1</strong>');
},
selectItem: function(item) {
var value = item.getAttribute('data-value');
if (this.activeInput) {
this.activeInput.value = value;
// Trigger change event
var event = new Event('change', { bubbles: true });
this.activeInput.dispatchEvent(event);
}
this.hideDropdown();
},
hideDropdown: function() {
if (this.dropdown) {
this.dropdown.style.display = 'none';
}
}
};
// Auto-init autocomplete
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
KundenKarte.FieldAutocomplete.init('');
});
} else {
KundenKarte.FieldAutocomplete.init('');
}
})();

View file

@ -322,6 +322,60 @@ ErrorNoFileSelected = Keine Datei ausgewaehlt
PDFTemplateHelp = Laden Sie eine PDF-Datei als Hintergrund/Briefpapier fuer den Export hoch. Die erste Seite wird als Vorlage verwendet.
ExportTreeAsPDF = Als PDF exportieren
# Tree Display Settings
TreeDisplaySettings = Baum-Anzeige Einstellungen
TreeInfoDisplayMode = Zusatzinfos-Anzeige
DisplayAsBadge = Als Badge (mit Icon)
DisplayInParentheses = In Klammern
DisplayNone = Ausblenden
TreeBadgeColor = Badge-Farbe
TreeBadgeColorHelp = Waehlen Sie die Hintergrundfarbe fuer die Info-Badges
Hidden = Ausgeblendet
TreeDisplayMode = Anzeige-Modus
Badge = Badge
Parentheses = Klammer
# File Pinning
Pin = Anheften
Unpin = Loslassen
Pinned = Angeheftet
FilePinned = Datei wurde angeheftet
FileUnpinned = Datei wurde losgelassen
# Backup & Restore
BackupRestore = Backup & Wiederherstellung
CreateBackup = Backup erstellen
BackupOptions = Backup-Optionen
IncludeUploadedFiles = Hochgeladene Dateien einschliessen
IncludeFilesHelp = Alle Bilder und Dokumente der Anlagen werden im Backup gespeichert
CreateBackupNow = Backup jetzt erstellen
UploadBackup = Backup hochladen
UploadBackupFile = Backup-Datei hochladen
SelectBackupFile = Backup-Datei auswaehlen
ExistingBackups = Vorhandene Backups
NoBackupsFound = Keine Backups gefunden
BackupCreatedSuccess = Backup wurde erstellt: %s
BackupCreatedError = Backup konnte nicht erstellt werden
BackupDeleted = Backup wurde geloescht
BackupUploaded = Backup wurde hochgeladen
InvalidBackupFile = Ungueltige Backup-Datei
DeleteBackup = Backup loeschen
ConfirmDeleteBackup = Moechten Sie das Backup "%s" wirklich loeschen?
RestoreBackup = Backup wiederherstellen
ConfirmRestoreBackup = Moechten Sie das Backup "%s" wirklich wiederherstellen? Dies kann bestehende Daten ueberschreiben.
ClearExistingData = Bestehende Daten vorher loeschen
RestoreSuccess = Wiederherstellung erfolgreich
RestoreError = Wiederherstellung fehlgeschlagen
TotalElements = Gesamt Elemente
TotalFiles = Gesamt Dateien
TotalConnections = Gesamt Verbindungen
CustomersWithAnlagen = Kunden mit Anlagen
FilesStorageSize = Dateispeicher-Groesse
BackupInfo = Backup-Informationen
BackupInfoContent = Das Backup enthaelt alle Anlagen, Typen, Systeme, Felder und Verbindungen
BackupInfoFiles = Wenn aktiviert, werden auch alle hochgeladenen Dateien (Bilder, PDFs, Dokumente) gesichert
BackupInfoRestore = Bei der Wiederherstellung koennen bestehende Daten ueberschrieben oder ergaenzt werden
# Standard Dolibarr
Save = Speichern
Cancel = Abbrechen

View file

@ -187,6 +187,60 @@ ErrorNoFileSelected = No file selected
PDFTemplateHelp = Upload a PDF file as background/letterhead for export. The first page will be used as template.
ExportTreeAsPDF = Export as PDF
# Tree Display Settings
TreeDisplaySettings = Tree Display Settings
TreeInfoDisplayMode = Info Display Mode
DisplayAsBadge = As Badge (with icon)
DisplayInParentheses = In Parentheses
DisplayNone = Hidden
TreeBadgeColor = Badge Color
TreeBadgeColorHelp = Select the background color for the info badges
Hidden = Hidden
TreeDisplayMode = Display Mode
Badge = Badge
Parentheses = Parentheses
# File Pinning
Pin = Pin
Unpin = Unpin
Pinned = Pinned
FilePinned = File has been pinned
FileUnpinned = File has been unpinned
# Backup & Restore
BackupRestore = Backup & Restore
CreateBackup = Create Backup
BackupOptions = Backup Options
IncludeUploadedFiles = Include uploaded files
IncludeFilesHelp = All images and documents from installations will be saved in the backup
CreateBackupNow = Create Backup Now
UploadBackup = Upload Backup
UploadBackupFile = Upload Backup File
SelectBackupFile = Select backup file
ExistingBackups = Existing Backups
NoBackupsFound = No backups found
BackupCreatedSuccess = Backup created: %s
BackupCreatedError = Backup could not be created
BackupDeleted = Backup deleted
BackupUploaded = Backup uploaded
InvalidBackupFile = Invalid backup file
DeleteBackup = Delete Backup
ConfirmDeleteBackup = Do you really want to delete the backup "%s"?
RestoreBackup = Restore Backup
ConfirmRestoreBackup = Do you really want to restore the backup "%s"? This may overwrite existing data.
ClearExistingData = Clear existing data first
RestoreSuccess = Restore successful
RestoreError = Restore failed
TotalElements = Total Elements
TotalFiles = Total Files
TotalConnections = Total Connections
CustomersWithAnlagen = Customers with Installations
FilesStorageSize = Files Storage Size
BackupInfo = Backup Information
BackupInfoContent = The backup contains all installations, types, systems, fields and connections
BackupInfoFiles = If enabled, all uploaded files (images, PDFs, documents) will also be backed up
BackupInfoRestore = When restoring, existing data can be overwritten or supplemented
# Standard Dolibarr
Save = Save
Cancel = Cancel

View file

@ -74,6 +74,11 @@ function kundenkarteAdminPrepareHead()
$head[$h][2] = 'building_types';
$h++;
$head[$h][0] = dol_buildpath("/kundenkarte/admin/backup.php", 1);
$head[$h][1] = $langs->trans("BackupRestore");
$head[$h][2] = 'backup';
$h++;
/*
$head[$h][0] = dol_buildpath("/kundenkarte/admin/myobject_extrafields.php", 1);
$head[$h][1] = $langs->trans("ExtraFields");
@ -138,3 +143,106 @@ function kundenkarte_render_icon($picto, $alt = '', $style = '')
// Font Awesome icon
return '<i class="fa '.dol_escape_htmltag($picto).'"'.($style ? ' style="'.$style.'"' : '').'></i>';
}
/**
* Get icon for a field based on field code or type
*
* @param string $fieldCode Field code
* @param string $fieldType Field type
* @return string Font Awesome icon class
*/
function kundenkarte_get_field_icon($fieldCode, $fieldType = '')
{
// Map common field codes to icons
$codeIcons = array(
'standort' => 'fa-map-marker',
'location' => 'fa-map-marker',
'ort' => 'fa-map-marker',
'raum' => 'fa-door-open',
'room' => 'fa-door-open',
'zimmer' => 'fa-door-open',
'etage' => 'fa-layer-group',
'floor' => 'fa-layer-group',
'stockwerk' => 'fa-layer-group',
'gebaeude' => 'fa-building',
'building' => 'fa-building',
'adresse' => 'fa-home',
'address' => 'fa-home',
'hersteller' => 'fa-industry',
'manufacturer' => 'fa-industry',
'modell' => 'fa-tag',
'model' => 'fa-tag',
'seriennummer' => 'fa-barcode',
'serial' => 'fa-barcode',
'leistung' => 'fa-bolt',
'power' => 'fa-bolt',
'spannung' => 'fa-plug',
'voltage' => 'fa-plug',
'strom' => 'fa-bolt',
'current' => 'fa-bolt',
'datum' => 'fa-calendar',
'date' => 'fa-calendar',
'installation' => 'fa-wrench',
'wartung' => 'fa-tools',
'maintenance' => 'fa-tools',
'ip' => 'fa-network-wired',
'ip_adresse' => 'fa-network-wired',
'mac' => 'fa-ethernet',
'telefon' => 'fa-phone',
'phone' => 'fa-phone',
'notiz' => 'fa-sticky-note',
'note' => 'fa-sticky-note',
'bemerkung' => 'fa-comment',
'comment' => 'fa-comment',
);
// Check field code (lowercase)
$codeLower = strtolower($fieldCode);
foreach ($codeIcons as $key => $icon) {
if (strpos($codeLower, $key) !== false) {
return $icon;
}
}
// Fallback based on field type
$typeIcons = array(
'date' => 'fa-calendar',
'number' => 'fa-hashtag',
'textarea' => 'fa-align-left',
'select' => 'fa-list',
'checkbox' => 'fa-check-square',
);
if (isset($typeIcons[$fieldType])) {
return $typeIcons[$fieldType];
}
// Default icon
return 'fa-info-circle';
}
/**
* Adjust a hex color by a percentage
*
* @param string $hex Hex color (e.g., #2a4a5e)
* @param int $percent Percentage to adjust (-100 to 100, negative = darker)
* @return string Adjusted hex color
*/
function kundenkarte_adjust_color($hex, $percent)
{
// Remove # if present
$hex = ltrim($hex, '#');
// Parse RGB values
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
// Adjust each component
$r = max(0, min(255, $r + $percent));
$g = max(0, min(255, $g + $percent));
$b = max(0, min(255, $b + $percent));
// Return as hex
return sprintf('#%02x%02x%02x', $r, $g, $b);
}

View file

@ -24,6 +24,7 @@ CREATE TABLE llx_kundenkarte_anlage_files
description text,
is_cover tinyint DEFAULT 0 NOT NULL,
is_pinned tinyint DEFAULT 0 NOT NULL,
position integer DEFAULT 0,
share varchar(128),

View file

@ -21,7 +21,10 @@ CREATE TABLE llx_kundenkarte_anlage_type_field
required tinyint DEFAULT 0 NOT NULL,
show_in_tree tinyint DEFAULT 0 NOT NULL,
tree_display_mode varchar(20) DEFAULT 'badge',
badge_color varchar(7),
show_in_hover tinyint DEFAULT 1 NOT NULL,
enable_autocomplete tinyint DEFAULT 0 NOT NULL,
position integer DEFAULT 0,
active tinyint DEFAULT 1 NOT NULL,

16
sql/update_3.6.0.sql Normal file
View file

@ -0,0 +1,16 @@
-- ============================================================================
-- KundenKarte Module - Update 3.6.0
-- Autocomplete for text fields + File pinning
-- ============================================================================
-- Add autocomplete option to field definitions
ALTER TABLE llx_kundenkarte_anlage_type_field
ADD COLUMN enable_autocomplete tinyint DEFAULT 0 NOT NULL AFTER show_in_hover;
-- Add pinned flag for files (pinned files appear first)
ALTER TABLE llx_kundenkarte_anlage_files
ADD COLUMN is_pinned tinyint DEFAULT 0 NOT NULL AFTER is_cover;
-- Add tree display mode for fields (badge = right side badge, parentheses = after label in parentheses)
ALTER TABLE llx_kundenkarte_anlage_type_field
ADD COLUMN tree_display_mode varchar(20) DEFAULT 'badge' AFTER show_in_tree;

View file

@ -35,7 +35,11 @@ dol_include_once('/kundenkarte/lib/kundenkarte.lib.php');
$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');
@ -53,13 +57,32 @@ $form = new Form($db);
$anlage = new Anlage($db);
$anlageType = new AnlageType($db);
// Load thirdparty
if ($id > 0) {
$result = $object->fetch($id);
if ($result <= 0) {
dol_print_error($db, $object->error);
exit;
// 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');
@ -244,7 +267,7 @@ if ($action == 'confirm_delete' && $confirm == 'yes' && $permissiontodelete) {
exit;
}
// File upload
// File upload (multi-file support)
if ($action == 'uploadfile' && $permissiontoadd) {
$anlage->fetch($anlageId);
$upload_dir = $anlage->getFileDirectory();
@ -254,23 +277,47 @@ if ($action == 'uploadfile' && $permissiontoadd) {
dol_mkdir($upload_dir);
}
if (!empty($_FILES['userfile']['name'])) {
$result = dol_add_file_process($upload_dir, 0, 1, 'userfile', '', null, '', 1);
if ($result > 0) {
// Add to database
$anlagefile = new AnlageFile($db);
$anlagefile->fk_anlage = $anlageId;
$anlagefile->filename = dol_sanitizeFileName($_FILES['userfile']['name']);
// IMPORTANT: Store ONLY relative path (anlagen/socid/anlageid/filename) - never full path!
$anlagefile->filepath = 'anlagen/'.$anlage->fk_soc.'/'.$anlage->id.'/'.$anlagefile->filename;
$anlagefile->filesize = $_FILES['userfile']['size'];
$anlagefile->mimetype = dol_mimetype($anlagefile->filename);
$anlagefile->create($user);
$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;
// Generate thumbnail
$anlagefile->generateThumbnail();
// 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++;
}
setEventMessages($langs->trans('FileUploaded'), null, 'mesgs');
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);
@ -295,6 +342,22 @@ if ($action == 'confirm_deletefile' && $confirm == 'yes' && $permissiontodelete)
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
*/
@ -390,6 +453,10 @@ print '</div>';
$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>';
@ -511,17 +578,29 @@ if (empty($customerSystems)) {
print '<br><h4>'.$langs->trans('AttachedFiles').'</h4>';
if ($permissiontoadd) {
print '<form method="POST" enctype="multipart/form-data" action="'.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action=uploadfile&anlage_id='.$anlageId.'">';
print '<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 '<input type="file" name="userfile" accept="image/*,.pdf,.doc,.docx">';
print ' <button type="submit" class="button">'.$langs->trans('Upload').'</button>';
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) {
print '<div class="kundenkarte-file-item">';
$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();
@ -536,14 +615,41 @@ if (empty($customerSystems)) {
print '<iframe src="'.$file->getUrl().'#page=1&toolbar=0&navpanes=0&statusbar=0&view=FitH" class="kundenkarte-pdf-preview-frame"></iframe>';
print '</div>';
} else {
print '<i class="fa fa-file-o" style="font-size:48px;color:#999;"></i>';
// 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, 20)).'</div>';
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>';
}
@ -865,6 +971,19 @@ if (empty($customerSystems)) {
$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>';
}
@ -949,7 +1068,8 @@ function printTree($nodes, $socid, $systemId, $canEdit, $canDelete, $langs, $lev
);
// Collect fields for tooltip (show_in_hover) and tree label (show_in_tree)
$treeInfo = array();
$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) {
@ -984,10 +1104,24 @@ function printTree($nodes, $socid, $systemId, $canEdit, $canDelete, $langs, $lev
// 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) {
$treeInfo[] = dol_print_date(strtotime($value), 'day');
$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 {
$treeInfo[] = $value;
$treeInfoBadges[] = $fieldInfo;
}
}
}
@ -1045,34 +1179,47 @@ function printTree($nodes, $socid, $systemId, $canEdit, $canDelete, $langs, $lev
$picto = $node->type_picto ? $node->type_picto : 'fa-cube';
print '<span class="kundenkarte-tree-icon kundenkarte-tooltip-trigger" data-tooltip="'.htmlspecialchars(json_encode($tooltipData), ENT_QUOTES, 'UTF-8').'" data-anlage-id="'.$node->id.'">'.kundenkarte_render_icon($picto).'</span>';
// Label with tree info in parentheses + file indicators
// 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);
if (!empty($treeInfo)) {
print ' <span class="kundenkarte-tree-label-info">('.dol_escape_htmltag(implode(', ', $treeInfo)).')</span>';
}
// File indicators - directly after parentheses
if ($node->image_count > 0 || $node->doc_count > 0) {
print ' <span class="kundenkarte-tree-files">';
if ($node->image_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-images kundenkarte-images-trigger" data-anlage-id="'.$node->id.'" title="'.$node->image_count.' '.($node->image_count == 1 ? 'Bild' : 'Bilder').'">';
print '<i class="fa fa-image"></i>';
if ($node->image_count > 1) {
print ' '.$node->image_count;
}
print '</a>';
// 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']);
}
if ($node->doc_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-docs kundenkarte-docs-trigger" data-anlage-id="'.$node->id.'" title="'.$node->doc_count.' '.($node->doc_count == 1 ? 'Dokument' : 'Dokumente').'">';
print '<i class="fa fa-file-pdf-o"></i>';
if ($node->doc_count > 1) {
print ' '.$node->doc_count;
}
print '</a>';
print ' <span 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>';
}
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) {
@ -1156,7 +1303,8 @@ function printTreeWithCableLines($nodes, $socid, $systemId, $canEdit, $canDelete
'fields' => array()
);
$treeInfo = 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;
@ -1173,7 +1321,20 @@ function printTreeWithCableLines($nodes, $socid, $systemId, $canEdit, $canDelete
);
}
if ($fieldDef->show_in_tree && $value !== '') {
$treeInfo[] = ($fieldDef->field_type === 'date' && $value) ? dol_print_date(strtotime($value), 'day') : $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;
}
}
}
}
@ -1279,27 +1440,43 @@ function printTreeWithCableLines($nodes, $socid, $systemId, $canEdit, $canDelete
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($treeInfo)) {
print ' <span class="kundenkarte-tree-label-info">('.dol_escape_htmltag(implode(', ', $treeInfo)).')</span>';
}
if ($node->image_count > 0 || $node->doc_count > 0) {
print ' <span class="kundenkarte-tree-files">';
if ($node->image_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-images" data-anlage-id="'.$node->id.'">';
print '<i class="fa fa-image"></i>';
if ($node->image_count > 1) print ' '.$node->image_count;
print '</a>';
if (!empty($treeInfoParentheses)) {
$infoValues = array();
foreach ($treeInfoParentheses as $info) {
$infoValues[] = dol_escape_htmltag($info['value']);
}
if ($node->doc_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-docs" data-anlage-id="'.$node->id.'">';
print '<i class="fa fa-file-pdf-o"></i>';
if ($node->doc_count > 1) print ' '.$node->doc_count;
print '</a>';
print ' <span 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>';
}
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;
@ -1405,16 +1582,18 @@ function printTreeNode($node, $socid, $systemId, $canEdit, $canDelete, $langs, $
/**
* Print tree options for select
*/
function printTreeOptions($nodes, $selected = 0, $excludeId = 0, $prefix = '')
function printTreeOptions($nodes, $selected = 0, $excludeId = 0, $prefix = '', $level = 0)
{
foreach ($nodes as $node) {
if ($node->id == $excludeId) continue;
$sel = ($node->id == $selected) ? ' selected' : '';
print '<option value="'.$node->id.'"'.$sel.'>'.$prefix.dol_escape_htmltag($node->label).'</option>';
$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.'&nbsp;&nbsp;&nbsp;');
printTreeOptions($node->children, $selected, $excludeId, $prefix.'── ', $level + 1);
}
}
}

View file

@ -32,7 +32,11 @@ dol_include_once('/kundenkarte/lib/kundenkarte.lib.php');
$langs->loadLangs(array('companies', 'kundenkarte@kundenkarte'));
// Get parameters
// Support both 'id' and 'contactid' for compatibility with Dolibarr's contact navigation arrows
$id = GETPOSTINT('id');
if ($id <= 0) {
$id = GETPOSTINT('contactid');
}
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');
$systemId = GETPOSTINT('system');
@ -50,13 +54,28 @@ $form = new Form($db);
$anlage = new Anlage($db);
$anlageType = new AnlageType($db);
// Load contact
if ($id > 0) {
$result = $object->fetch($id);
if ($result <= 0) {
dol_print_error($db, $object->error);
exit;
// Load contact - 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) {
$id = $tmpAnlage->fk_contact;
if ($id > 0) {
// Redirect to include id in URL for proper navigation
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$id.'&system='.$systemId.'&action='.$action.'&anlage_id='.$anlageId);
exit;
}
}
}
// Still no id - show error
accessforbidden($langs->trans('ErrorRecordNotFound'));
}
$result = $object->fetch($id);
if ($result <= 0) {
dol_print_error($db, $object->error);
exit;
}
$permissiontoread = $user->hasRight('kundenkarte', 'read');
@ -623,7 +642,9 @@ if (empty($customerSystems)) {
print '<option value="">'.$langs->trans('SelectType').'</option>';
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>';
$icon = !empty($t->picto) ? $t->picto : 'fa-cube';
$color = !empty($t->color) ? $t->color : '#888';
print '<option value="'.$t->id.'"'.$selected.' data-icon="'.$icon.'" data-color="'.$color.'">'.dol_escape_htmltag($t->label).'</option>';
}
print '</select>';
if (empty($types)) {
@ -657,6 +678,49 @@ if (empty($customerSystems)) {
print '</div>';
print '</form>';
// JavaScript: Select2 mit Icons für Type und Parent
print '<script>
$(document).ready(function() {
// Select2 Template-Funktion mit Icons
function formatTypeOption(option) {
if (!option.id) return option.text;
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 für Type
var $typeSelect = $("#select_type");
if ($typeSelect.length) {
$typeSelect.select2({
templateResult: formatTypeOption,
templateSelection: formatTypeOption,
placeholder: "'.dol_escape_js($langs->trans('SelectType')).'",
allowClear: true,
width: "300px",
dropdownAutoWidth: true
});
}
// Select2 für übergeordnetes Element
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>';
@ -760,11 +824,16 @@ function printTree($nodes, $contactid, $systemId, $canEdit, $canDelete, $langs,
// 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) {
$treeInfoParts[] = dol_print_date(strtotime($value), 'day');
} else {
$treeInfoParts[] = $value;
$displayVal = dol_print_date(strtotime($value), 'day');
}
$treeInfoParts[] = array(
'label' => $fieldDef->field_label,
'value' => $displayVal,
'code' => $fieldDef->field_code,
'type' => $fieldDef->field_type
);
}
}
}
@ -783,31 +852,45 @@ function printTree($nodes, $contactid, $systemId, $canEdit, $canDelete, $langs,
$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 manufacturer/power in parentheses + file indicators
// Label with tree info badges + file indicators
$viewUrl = $_SERVER['PHP_SELF'].'?id='.$contactid.'&system='.$systemId.'&action=view&anlage_id='.$node->id;
print '<span class="kundenkarte-tree-label">'.dol_escape_htmltag($node->label);
if (!empty($treeInfoParts)) {
print ' <span class="kundenkarte-tree-label-info">('.dol_escape_htmltag(implode(', ', $treeInfoParts)).')</span>';
print '<span class="kundenkarte-tree-label">'.dol_escape_htmltag($node->label).'</span>';
// Tree info display based on settings
$treeInfoDisplay = getDolGlobalString('KUNDENKARTE_TREE_INFO_DISPLAY', 'badge');
$badgeColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e');
if (!empty($treeInfoParts) && $treeInfoDisplay !== 'none') {
if ($treeInfoDisplay === 'badge') {
// Display as badges with icons
print '<span class="kundenkarte-tree-badges">';
foreach ($treeInfoParts as $info) {
$badgeIcon = kundenkarte_get_field_icon($info['code'], $info['type']);
print '<span class="kundenkarte-tree-badge" title="'.dol_escape_htmltag($info['label']).'" style="background:linear-gradient(135deg, '.$badgeColor.' 0%, '.kundenkarte_adjust_color($badgeColor, -20).' 100%);">';
print '<i class="fa '.$badgeIcon.'"></i> '.dol_escape_htmltag($info['value']);
print '</span>';
}
print '</span>';
} else {
// Display in parentheses (old style)
print '<span class="kundenkarte-tree-label-info"> (';
$infoTexts = array();
foreach ($treeInfoParts as $info) {
$infoTexts[] = dol_escape_htmltag($info['label']).': '.dol_escape_htmltag($info['value']);
}
print implode(', ', $infoTexts);
print ')</span>';
}
}
// File indicators - directly after parentheses
// File indicators
if ($node->image_count > 0 || $node->doc_count > 0) {
$totalFiles = $node->image_count + $node->doc_count;
print ' <span class="kundenkarte-tree-files">';
if ($node->image_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-images kundenkarte-images-trigger" data-anlage-id="'.$node->id.'" title="'.$node->image_count.' '.($node->image_count == 1 ? 'Bild' : 'Bilder').'">';
print '<i class="fa fa-image"></i>';
if ($node->image_count > 1) {
print ' '.$node->image_count;
}
print '</a>';
}
if ($node->doc_count > 0) {
print '<a href="'.$viewUrl.'#files" class="kundenkarte-tree-file-badge kundenkarte-tree-file-docs kundenkarte-docs-trigger" data-anlage-id="'.$node->id.'" title="'.$node->doc_count.' '.($node->doc_count == 1 ? 'Dokument' : 'Dokumente').'">';
print '<i class="fa fa-file-pdf-o"></i>';
if ($node->doc_count > 1) {
print ' '.$node->doc_count;
}
print '</a>';
}
// Combined file badge with file icon
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>';
}
print '</span>';
@ -847,16 +930,18 @@ function printTree($nodes, $contactid, $systemId, $canEdit, $canDelete, $langs,
/**
* Print tree options for select
*/
function printTreeOptions($nodes, $selected = 0, $excludeId = 0, $prefix = '')
function printTreeOptions($nodes, $selected = 0, $excludeId = 0, $prefix = '', $level = 0)
{
foreach ($nodes as $node) {
if ($node->id == $excludeId) continue;
$sel = ($node->id == $selected) ? ' selected' : '';
print '<option value="'.$node->id.'"'.$sel.'>'.$prefix.dol_escape_htmltag($node->label).'</option>';
$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.'&nbsp;&nbsp;&nbsp;');
printTreeOptions($node->children, $selected, $excludeId, $prefix.'── ', $level + 1);
}
}
}

View file

@ -30,7 +30,11 @@ dol_include_once('/kundenkarte/class/favoriteproduct.class.php');
$langs->loadLangs(array('companies', 'products', 'orders', 'kundenkarte@kundenkarte'));
// Get parameters
// Support both 'id' and 'contactid' for compatibility with Dolibarr's contact navigation arrows
$id = GETPOSTINT('id');
if ($id <= 0) {
$id = GETPOSTINT('contactid');
}
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');

View file

@ -30,7 +30,11 @@ dol_include_once('/kundenkarte/class/favoriteproduct.class.php');
$langs->loadLangs(array('companies', 'products', 'orders', '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');