diff --git a/CLAUDE.md b/CLAUDE.md index c6c0113..60f6263 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,125 @@ -CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 \ No newline at end of file +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 diff --git a/ChangeLog.md b/ChangeLog.md index c6c4016..34c8e40 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/admin/anlage_types.php b/admin/anlage_types.php index 7849fb0..95bcdb9 100755 --- a/admin/anlage_types.php +++ b/admin/anlage_types.php @@ -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 ''.$langs->trans('FieldType').''; print ''.$langs->trans('FieldOptions').''; print ''.$langs->trans('ShowInTree').''; + print ''.$langs->trans('TreeDisplayMode').''; + print ''; print ''.$langs->trans('ShowInHover').''; + print ''; print ''.$langs->trans('IsRequired').''; print ''.$langs->trans('Position').''; print ''.$langs->trans('Status').''; @@ -537,7 +558,15 @@ if (in_array($action, array('create', 'edit'))) { print ''; print ''; print 'show_in_tree ? ' checked' : '').'>'; + $treeMode = $field->tree_display_mode ?? 'badge'; + print ''; + $badgeColor = $field->badge_color ?? ''; + print ''; print 'show_in_hover ? ' checked' : '').'>'; + print 'enable_autocomplete ? ' checked' : '').' title="Autocomplete aktivieren">'; print 'required ? ' checked' : '').'>'; print ''; print ''; @@ -556,7 +585,17 @@ if (in_array($action, array('create', 'edit'))) { print ''.dol_escape_htmltag($fieldTypes[$field->field_type] ?? $field->field_type).''; print ''.dol_escape_htmltag(dol_trunc($field->field_options, 20)).''; print ''.($field->show_in_tree ? img_picto('', 'tick') : '').''; + $treeMode = $field->tree_display_mode ?? 'badge'; + $treeModeIcon = ($treeMode == 'badge') ? 'B' : '(K)'; + print ''.$treeModeIcon.''; + $badgeColor = $field->badge_color ?? ''; + if ($badgeColor) { + print ''; + } else { + print '-'; + } print ''.($field->show_in_hover ? img_picto('', 'tick') : '').''; + print ''.($field->enable_autocomplete ? '' : '').''; print ''.($field->required ? img_picto('', 'tick') : '').''; print ''.$field->position.''; print ''; @@ -577,7 +616,7 @@ if (in_array($action, array('create', 'edit'))) { } if (empty($fields)) { - print ''.$langs->trans('NoFieldsDefined').''; + print ''.$langs->trans('NoFieldsDefined').''; } print ''; @@ -591,7 +630,7 @@ if (in_array($action, array('create', 'edit'))) { print ''; print ''; print ''; - print ''; + print ''; print ''; print ''; print ''; @@ -604,7 +643,13 @@ if (in_array($action, array('create', 'edit'))) { print ''; print ''; print ''; + print ''; + print ''; print ''; + print ''; print ''; print ''; print ''; diff --git a/admin/backup.php b/admin/backup.php new file mode 100644 index 0000000..952d17f --- /dev/null +++ b/admin/backup.php @@ -0,0 +1,364 @@ +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 = ''.$langs->trans("BackToModuleList").''; +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 '
'; + +// Stats cards +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('TotalElements').''; +print ''.$stats['total_anlagen'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('TotalFiles').''; +print ''.$stats['total_files'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('TotalConnections').''; +print ''.$stats['total_connections'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('CustomersWithAnlagen').''; +print ''.$stats['total_customers'].''; +print '
'; +print '
'; + +print '
'; +print ''; +print '
'; +print ''.$langs->trans('FilesStorageSize').''; +print ''.dol_print_size($stats['files_size']).''; +print '
'; +print '
'; + +print '
'; + +// Create Backup Section +print '
'.$langs->trans("CreateBackup").'
'; +print '

'; + +print '
'; +print ''; +print ''; + +print '
'.$langs->trans('Add').' '.$langs->trans('Field').''.$langs->trans('Add').' '.$langs->trans('Field').'
'; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("BackupOptions").'
'.$langs->trans("IncludeUploadedFiles").''; +print ' '; +print ''.$langs->trans("IncludeFilesHelp").''; +print '
'; + +print '
'; +print '
'; +print ''; +print '
'; + +print ''; + +// Upload Backup Section +print '

'; +print '
'.$langs->trans("UploadBackup").'
'; +print '

'; + +print '
'; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("UploadBackupFile").'
'.$langs->trans("SelectBackupFile").''; +print ''; +print ' '; +print '
'; + +print '
'; + +// Existing Backups Section +print '

'; +print '
'.$langs->trans("ExistingBackups").'
'; +print '

'; + +$backups = $backup->getBackupList(); + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +if (empty($backups)) { + print ''; + print ''; + print ''; +} else { + foreach ($backups as $bk) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } +} + +print '
'.$langs->trans("Filename").''.$langs->trans("Date").''.$langs->trans("Size").''.$langs->trans("Actions").'
'.$langs->trans("NoBackupsFound").'
'.dol_escape_htmltag($bk['filename']).''.dol_print_date(strtotime($bk['date']), 'dayhour').''.dol_print_size($bk['size']).''; + + // Download button + print ''; + print ''; + print ' '; + + // Restore button + print ''; + print ''; + print ' '; + + // Delete button + print ''; + print ''; + print ''; + + print '
'; + +// Info section +print '
'; +print '
'; +print ' '.$langs->trans("BackupInfo").':
'; +print '• '.$langs->trans("BackupInfoContent").'
'; +print '• '.$langs->trans("BackupInfoFiles").'
'; +print '• '.$langs->trans("BackupInfoRestore").'
'; +print '
'; + +print ''; + +print dol_get_fiche_end(); + +llxFooter(); +$db->close(); diff --git a/admin/setup.php b/admin/setup.php index c68ffc1..b15a9d4 100755 --- a/admin/setup.php +++ b/admin/setup.php @@ -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 ''; print ''; +// Tree Display Settings +print '

'; +print '
'.$langs->trans("TreeDisplaySettings").'
'; +print '

'; + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +// Tree info display mode +$currentDisplay = getDolGlobalString('KUNDENKARTE_TREE_INFO_DISPLAY', 'badge'); +print ''; +print ''; +print ''; +print ''; +print ''; + +// Badge color +$currentColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e'); +print ''; +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("Parameter").''.$langs->trans("Value").''.$langs->trans("Preview").'
'.$langs->trans("TreeInfoDisplayMode").''; +print ''; +print ''; +print ' Serverraum'; +print '(Standort: Serverraum)'; +print ''.$langs->trans("Hidden").''; +print '
'; + +print ''; + // PDF Font Size Settings print '

'; print '
'.$langs->trans("PDFFontSettings").'
'; diff --git a/ajax/field_autocomplete.php b/ajax/field_autocomplete.php new file mode 100644 index 0000000..67d6350 --- /dev/null +++ b/ajax/field_autocomplete.php @@ -0,0 +1,106 @@ +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')); diff --git a/ajax/file_preview.php b/ajax/file_preview.php new file mode 100644 index 0000000..f338859 --- /dev/null +++ b/ajax/file_preview.php @@ -0,0 +1,101 @@ +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), +)); diff --git a/ajax/type_fields.php b/ajax/type_fields.php index 9c2a6be..40230bf 100755 --- a/ajax/type_fields.php +++ b/ajax/type_fields.php @@ -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); diff --git a/class/anlagebackup.class.php b/class/anlagebackup.class.php new file mode 100644 index 0000000..50d52fc --- /dev/null +++ b/class/anlagebackup.class.php @@ -0,0 +1,558 @@ +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; + } +} diff --git a/class/anlagefile.class.php b/class/anlagefile.class.php index 213fe2c..0e9b956 100755 --- a/class/anlagefile.class.php +++ b/class/anlagefile.class.php @@ -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 * diff --git a/core/modules/modKundenKarte.class.php b/core/modules/modKundenKarte.class.php index 871f896..b283733 100755 --- a/core/modules/modKundenKarte.class.php +++ b/core/modules/modKundenKarte.class.php @@ -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. diff --git a/css/kundenkarte.css b/css/kundenkarte.css index 2205f0b..9481ebb 100755 --- a/css/kundenkarte.css +++ b/css/kundenkarte.css @@ -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; +} diff --git a/js/kundenkarte.js b/js/kundenkarte.js index 7261f93..0a46b64 100755 --- a/js/kundenkarte.js +++ b/js/kundenkarte.js @@ -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 = '
'; + + // Images section with thumbnails + if (response.images && response.images.length > 0) { + html += '
'; + html += '
Bilder (' + response.images.length + ')
'; + html += '
'; + 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 += ''; + html += '' + self.escapeHtml(img.name) + ''; + if (img.is_pinned) { + html += ''; + } + html += ''; + } + if (response.images.length > 6) { + html += '+' + (response.images.length - 6) + ''; + } + html += '
'; + html += '
'; + } + + // Documents section with icons + if (response.documents && response.documents.length > 0) { + html += '
'; + html += '
Dokumente (' + response.documents.length + ')
'; + html += '
'; + for (var j = 0; j < response.documents.length && j < 5; j++) { + var doc = response.documents[j]; + var docPinnedClass = doc.is_pinned ? ' is-pinned' : ''; + html += ''; + html += ''; + html += '' + self.escapeHtml(doc.name) + ''; + if (doc.is_pinned) { + html += ''; + } + html += ''; + } + if (response.documents.length > 5) { + html += '
+' + (response.documents.length - 5) + ' weitere
'; + } + html += '
'; + html += '
'; + } + + html += '
'; + + var $tooltip = $('#kundenkarte-tooltip'); + if (!$tooltip.length) { + $tooltip = $('
'); + $('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 = '
'; html += ''; @@ -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 ''; + return ''; case 'textarea': - return ''; + return ''; 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 = ' ' + + '' + KundenKarte.AnlageConnection.escapeHtml(file.name) + '' + + ''; + + 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, '$1'); + }, + + 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(''); + } + })(); diff --git a/langs/de_DE/kundenkarte.lang b/langs/de_DE/kundenkarte.lang index 57ae6cb..d4c6200 100755 --- a/langs/de_DE/kundenkarte.lang +++ b/langs/de_DE/kundenkarte.lang @@ -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 diff --git a/langs/en_US/kundenkarte.lang b/langs/en_US/kundenkarte.lang index b31f064..99441a1 100755 --- a/langs/en_US/kundenkarte.lang +++ b/langs/en_US/kundenkarte.lang @@ -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 diff --git a/lib/kundenkarte.lib.php b/lib/kundenkarte.lib.php index a79dc05..83f786b 100755 --- a/lib/kundenkarte.lib.php +++ b/lib/kundenkarte.lib.php @@ -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 ''; } + +/** + * 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); +} diff --git a/sql/llx_kundenkarte_anlage_files.sql b/sql/llx_kundenkarte_anlage_files.sql index bcc208a..82d3fb1 100755 --- a/sql/llx_kundenkarte_anlage_files.sql +++ b/sql/llx_kundenkarte_anlage_files.sql @@ -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), diff --git a/sql/llx_kundenkarte_anlage_type_field.sql b/sql/llx_kundenkarte_anlage_type_field.sql index 506194c..4148cee 100755 --- a/sql/llx_kundenkarte_anlage_type_field.sql +++ b/sql/llx_kundenkarte_anlage_type_field.sql @@ -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, diff --git a/sql/update_3.6.0.sql b/sql/update_3.6.0.sql new file mode 100644 index 0000000..c0483a1 --- /dev/null +++ b/sql/update_3.6.0.sql @@ -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; diff --git a/tabs/anlagen.php b/tabs/anlagen.php index ec73b5d..3e3241c 100755 --- a/tabs/anlagen.php +++ b/tabs/anlagen.php @@ -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 '
'; $isTreeView = !in_array($action, array('create', 'edit', 'view', 'copy')); if ($isTreeView) { print '
'; + // Compact mode toggle (visible on mobile) + print ''; print ''; @@ -511,17 +578,29 @@ if (empty($customerSystems)) { print '

'.$langs->trans('AttachedFiles').'

'; if ($permissiontoadd) { - print '
'; + print ''; print ''; - print ''; - print ' '; + print '
'; + print ''; + print '
'; + print ''; + print '

Dateien hierher ziehen oder durchsuchen

'; + print '

Bilder, PDF, Office-Dokumente, ZIP-Archive

'; + print '
'; + print ''; + print '
'; + print ''; print '

'; } if (!empty($files)) { print '
'; foreach ($files as $file) { - print '
'; + $pinnedClass = $file->is_pinned ? ' kundenkarte-file-pinned' : ''; + print '
'; + if ($file->is_pinned) { + print '
'; + } print '
'; if ($file->file_type == 'image') { $thumbUrl = $file->getThumbUrl(); @@ -536,14 +615,41 @@ if (empty($customerSystems)) { print ''; print '
'; } else { - print ''; + // 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 ''; } print '
'; print '
'; - print '
'.dol_escape_htmltag(dol_trunc($file->filename, 20)).'
'; + print '
'.dol_escape_htmltag(dol_trunc($file->filename, 35)).'
'; print '
'.dol_print_size($file->filesize).'
'; print '
'; print ''; + if ($permissiontoadd) { + $pinClass = $file->is_pinned ? ' kundenkarte-file-btn-pinned' : ''; + $pinTitle = $file->is_pinned ? $langs->trans('Unpin') : $langs->trans('Pin'); + print ''; + } if ($permissiontodelete) { print ''; } @@ -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 + }); + } }); '; } @@ -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 ''.kundenkarte_render_icon($picto).''; - // 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 ''.dol_escape_htmltag($node->label); - if (!empty($treeInfo)) { - print ' ('.dol_escape_htmltag(implode(', ', $treeInfo)).')'; - } - // File indicators - directly after parentheses - if ($node->image_count > 0 || $node->doc_count > 0) { - print ' '; - if ($node->image_count > 0) { - print ''; - print ''; - if ($node->image_count > 1) { - print ' '.$node->image_count; - } - print ''; + + // 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 ''; - print ''; - if ($node->doc_count > 1) { - print ' '.$node->doc_count; - } - print ''; + print ' ('.implode(', ', $infoValues).')'; + } + print ''; + + // Spacer to push badges to the right + print ''; + + // Badges (far right, before actions) + $defaultBadgeColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e'); + if (!empty($treeInfoBadges)) { + print ''; + 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 ''; + print ' '.dol_escape_htmltag($info['value']); + print ''; } print ''; } - print ''; + + // File indicators + if ($node->image_count > 0 || $node->doc_count > 0) { + $totalFiles = $node->image_count + $node->doc_count; + print ''; + print ''; + print ' '.$totalFiles; + print ''; + print ''; + } // 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 ''.kundenkarte_render_icon($picto).''; $viewUrl = $_SERVER['PHP_SELF'].'?id='.$socid.'&system='.$systemId.'&action=view&anlage_id='.$node->id; + // Label with parentheses info (only values, no field names) print ''.dol_escape_htmltag($node->label); - if (!empty($treeInfo)) { - print ' ('.dol_escape_htmltag(implode(', ', $treeInfo)).')'; - } - if ($node->image_count > 0 || $node->doc_count > 0) { - print ' '; - if ($node->image_count > 0) { - print ''; - print ''; - if ($node->image_count > 1) print ' '.$node->image_count; - print ''; + if (!empty($treeInfoParentheses)) { + $infoValues = array(); + foreach ($treeInfoParentheses as $info) { + $infoValues[] = dol_escape_htmltag($info['value']); } - if ($node->doc_count > 0) { - print ''; - print ''; - if ($node->doc_count > 1) print ' '.$node->doc_count; - print ''; + print ' ('.implode(', ', $infoValues).')'; + } + print ''; + + // Spacer to push badges to the right + print ''; + + // Badges (far right) + if (!empty($treeInfoBadges)) { + $defaultBadgeColor = getDolGlobalString('KUNDENKARTE_TREE_BADGE_COLOR', '#2a4a5e'); + print ''; + foreach ($treeInfoBadges as $info) { + $badgeIcon = kundenkarte_get_field_icon($info['code'], $info['type']); + $fieldBadgeColor = !empty($info['color']) ? $info['color'] : $defaultBadgeColor; + print ''; + print ' '.dol_escape_htmltag($info['value']); + print ''; } print ''; } - print ''; + + // File indicators + if ($node->image_count > 0 || $node->doc_count > 0) { + $totalFiles = $node->image_count + $node->doc_count; + print ''; + print ''; + print ' '.$totalFiles; + print ''; + print ''; + } 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 ''; + $icon = !empty($node->type_picto) ? $node->type_picto : 'fa-cube'; + $color = !empty($node->type_color) ? $node->type_color : '#888'; + print ''; if (!empty($node->children)) { - printTreeOptions($node->children, $selected, $excludeId, $prefix.'   '); + printTreeOptions($node->children, $selected, $excludeId, $prefix.'── ', $level + 1); } } } diff --git a/tabs/contact_anlagen.php b/tabs/contact_anlagen.php index 8f825f1..64b93d7 100755 --- a/tabs/contact_anlagen.php +++ b/tabs/contact_anlagen.php @@ -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 ''; foreach ($types as $t) { $selected = (($isEdit || $isCopy) && $anlage->fk_anlage_type == $t->id) ? ' selected' : ''; - print ''; + $icon = !empty($t->picto) ? $t->picto : 'fa-cube'; + $color = !empty($t->color) ? $t->color : '#888'; + print ''; } print ''; if (empty($types)) { @@ -657,6 +678,49 @@ if (empty($customerSystems)) { print '
'; print ''; + + // JavaScript: Select2 mit Icons für Type und Parent + print ''; } print '
'; @@ -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 ''.kundenkarte_render_icon($picto).''; - // 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 ''.dol_escape_htmltag($node->label); - if (!empty($treeInfoParts)) { - print ' ('.dol_escape_htmltag(implode(', ', $treeInfoParts)).')'; + print ''.dol_escape_htmltag($node->label).''; + + // 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 ''; + foreach ($treeInfoParts as $info) { + $badgeIcon = kundenkarte_get_field_icon($info['code'], $info['type']); + print ''; + print ' '.dol_escape_htmltag($info['value']); + print ''; + } + print ''; + } else { + // Display in parentheses (old style) + print ' ('; + $infoTexts = array(); + foreach ($treeInfoParts as $info) { + $infoTexts[] = dol_escape_htmltag($info['label']).': '.dol_escape_htmltag($info['value']); + } + print implode(', ', $infoTexts); + print ')'; + } } - // File indicators - directly after parentheses + + // File indicators if ($node->image_count > 0 || $node->doc_count > 0) { + $totalFiles = $node->image_count + $node->doc_count; print ' '; - if ($node->image_count > 0) { - print ''; - print ''; - if ($node->image_count > 1) { - print ' '.$node->image_count; - } - print ''; - } - if ($node->doc_count > 0) { - print ''; - print ''; - if ($node->doc_count > 1) { - print ' '.$node->doc_count; - } - print ''; - } + // Combined file badge with file icon + print ''; + print ' '.$totalFiles; + print ''; print ''; } print ''; @@ -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 ''; + $icon = !empty($node->type_picto) ? $node->type_picto : 'fa-cube'; + $color = !empty($node->type_color) ? $node->type_color : '#888'; + print ''; if (!empty($node->children)) { - printTreeOptions($node->children, $selected, $excludeId, $prefix.'   '); + printTreeOptions($node->children, $selected, $excludeId, $prefix.'── ', $level + 1); } } } diff --git a/tabs/contact_favoriteproducts.php b/tabs/contact_favoriteproducts.php index 93ad451..6226dfe 100755 --- a/tabs/contact_favoriteproducts.php +++ b/tabs/contact_favoriteproducts.php @@ -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'); diff --git a/tabs/favoriteproducts.php b/tabs/favoriteproducts.php index f807ee9..214caf0 100755 --- a/tabs/favoriteproducts.php +++ b/tabs/favoriteproducts.php @@ -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');