From a0d7670dd7f66250dc7b5fb24868b677a2ad0260 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 2 Apr 2026 17:48:27 +0200 Subject: [PATCH] feat(helpdesk): align module with core list/drawer standards - add helpdesk module pages, services, settings and tests - standardize debtor list on drawer/grid contracts and robust filter drawer behavior - add helpdesk aside panel navigation and settings visibility provider - switch primary list slug to helpdesk/debitor and remove helpdesk/search compatibility - include required core contract updates for list contracts and detail/drawer integration --- bin/helpdesk-diagnose.php | 82 +++ bin/helpdesk-explore-entities.php | 300 ++++++++ composer.json | 6 +- config/modules.php | 2 +- db/init/init.sql | 6 +- .../2026-04-02-helpdesk-permissions.sql | 20 + modules/helpdesk/i18n/default_de.json | 116 +++ modules/helpdesk/i18n/default_en.json | 116 +++ .../Helpdesk/HelpdeskAuthorizationPolicy.php | 50 ++ .../Helpdesk/HelpdeskContainerRegistrar.php | 53 ++ .../Providers/HelpdeskLayoutProvider.php | 29 + .../Helpdesk/Service/BcODataGateway.php | 670 ++++++++++++++++++ .../Helpdesk/Service/DebitorDetailService.php | 190 +++++ .../Helpdesk/Service/DebitorSearchService.php | 102 +++ .../Service/HelpdeskOAuthTokenService.php | 41 ++ .../Service/HelpdeskSettingsGateway.php | 310 ++++++++ .../Service/HelpdeskTokenRepository.php | 79 +++ .../001_create_oauth_token_cache.sql | 11 + modules/helpdesk/module.php | 85 +++ .../helpdesk/pages/helpdesk/debitor($id).php | 96 +++ .../pages/helpdesk/debitor(default).phtml | 284 ++++++++ .../helpdesk/debitor-contacts-data().php | 32 + .../debitor-ticket-categories-data().php | 80 +++ .../pages/helpdesk/debitor-tickets-data().php | 175 +++++ .../debitor-tickets-filter-schema.php | 51 ++ .../helpdesk/pages/helpdesk/filter-schema.php | 31 + modules/helpdesk/pages/helpdesk/search().php | 40 ++ .../pages/helpdesk/search(default).phtml | 57 ++ .../helpdesk/pages/helpdesk/search-data().php | 42 ++ .../helpdesk/settings/diagnose-data().php | 50 ++ .../pages/helpdesk/settings/index().php | 98 +++ .../helpdesk/settings/index(default).phtml | 198 ++++++ .../settings/test-connection-data().php | 59 ++ .../helpdesk/pages/helpdesk/ticket($id).php | 46 ++ .../pages/helpdesk/ticket(default).phtml | 161 +++++ .../pages/helpdesk/ticket-log-data().php | 31 + .../templates/aside-helpdesk-panel.phtml | 54 ++ .../Helpdesk/Service/BcODataGatewayTest.php | 188 +++++ .../Service/DebitorDetailServiceTest.php | 307 ++++++++ .../Service/DebitorSearchServiceTest.php | 161 +++++ .../Service/HelpdeskOAuthTokenServiceTest.php | 58 ++ .../Service/HelpdeskSettingsGatewayTest.php | 195 +++++ .../Service/HelpdeskTokenRepositoryTest.php | 63 ++ modules/helpdesk/web/css/helpdesk.css | 137 ++++ .../web/js/helpdesk-clickable-rows.js | 29 + modules/helpdesk/web/js/helpdesk-detail.js | 571 +++++++++++++++ modules/helpdesk/web/js/helpdesk-search.js | 6 + modules/helpdesk/web/js/helpdesk-settings.js | 77 ++ modules/helpdesk/web/js/helpdesk-ticket.js | 161 +++++ .../web/js/pages/helpdesk-search-index.js | 73 ++ tests/Architecture/ListContractFiles.php | 5 + .../ListDataEndpointContractTest.php | 2 +- web/css/components/app-details.css | 8 +- web/js/components/app-filter-drawer.js | 63 ++ web/js/core/app-grid-factory.js | 31 +- 55 files changed, 5977 insertions(+), 11 deletions(-) create mode 100644 bin/helpdesk-diagnose.php create mode 100644 bin/helpdesk-explore-entities.php create mode 100644 db/updates/2026-04-02-helpdesk-permissions.sql create mode 100644 modules/helpdesk/i18n/default_de.json create mode 100644 modules/helpdesk/i18n/default_en.json create mode 100644 modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php create mode 100644 modules/helpdesk/migrations/001_create_oauth_token_cache.sql create mode 100644 modules/helpdesk/module.php create mode 100644 modules/helpdesk/pages/helpdesk/debitor($id).php create mode 100644 modules/helpdesk/pages/helpdesk/debitor(default).phtml create mode 100644 modules/helpdesk/pages/helpdesk/debitor-contacts-data().php create mode 100644 modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php create mode 100644 modules/helpdesk/pages/helpdesk/debitor-tickets-data().php create mode 100644 modules/helpdesk/pages/helpdesk/debitor-tickets-filter-schema.php create mode 100644 modules/helpdesk/pages/helpdesk/filter-schema.php create mode 100644 modules/helpdesk/pages/helpdesk/search().php create mode 100644 modules/helpdesk/pages/helpdesk/search(default).phtml create mode 100644 modules/helpdesk/pages/helpdesk/search-data().php create mode 100644 modules/helpdesk/pages/helpdesk/settings/diagnose-data().php create mode 100644 modules/helpdesk/pages/helpdesk/settings/index().php create mode 100644 modules/helpdesk/pages/helpdesk/settings/index(default).phtml create mode 100644 modules/helpdesk/pages/helpdesk/settings/test-connection-data().php create mode 100644 modules/helpdesk/pages/helpdesk/ticket($id).php create mode 100644 modules/helpdesk/pages/helpdesk/ticket(default).phtml create mode 100644 modules/helpdesk/pages/helpdesk/ticket-log-data().php create mode 100644 modules/helpdesk/templates/aside-helpdesk-panel.phtml create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskTokenRepositoryTest.php create mode 100644 modules/helpdesk/web/css/helpdesk.css create mode 100644 modules/helpdesk/web/js/helpdesk-clickable-rows.js create mode 100644 modules/helpdesk/web/js/helpdesk-detail.js create mode 100644 modules/helpdesk/web/js/helpdesk-search.js create mode 100644 modules/helpdesk/web/js/helpdesk-settings.js create mode 100644 modules/helpdesk/web/js/helpdesk-ticket.js create mode 100644 modules/helpdesk/web/js/pages/helpdesk-search-index.js diff --git a/bin/helpdesk-diagnose.php b/bin/helpdesk-diagnose.php new file mode 100644 index 0000000..6dc0f0e --- /dev/null +++ b/bin/helpdesk-diagnose.php @@ -0,0 +1,82 @@ +#!/usr/bin/env php + + * Example: php bin/helpdesk-diagnose.php 10254 + */ + +declare(strict_types=1); + +chdir(__DIR__ . '/..'); +require 'vendor/autoload.php'; +require 'config/config.php'; +require 'lib/Support/helpers.php'; + +$container = require 'lib/App/registerContainer.php'; +setAppContainer($container); + +use MintyPHP\Module\Helpdesk\Service\BcODataGateway; +use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; + +$customerNo = trim($argv[1] ?? ''); +if ($customerNo === '') { + echo "Usage: php bin/helpdesk-diagnose.php \n"; + exit(1); +} + +$settings = app(HelpdeskSettingsGateway::class); + +echo "=== Helpdesk OData Diagnosis ===\n\n"; +echo "Base URL: " . $settings->getODataBaseUrl() . "\n"; +echo "Company: " . $settings->getCompanyName() . "\n"; +echo "Auth mode: " . $settings->getAuthMode() . "\n"; +echo "Configured: " . ($settings->isConfigured() ? 'YES' : 'NO') . "\n"; +echo "\n"; +echo "Entity URLs:\n"; +echo " Customer: " . $settings->buildEntityUrl(BcODataGateway::ENTITY_CUSTOMER) . "\n"; +echo " Contact: " . $settings->buildEntityUrl(BcODataGateway::ENTITY_CONTACT) . "\n"; +echo " Tickets: " . $settings->buildEntityUrl(BcODataGateway::ENTITY_TICKETS) . "\n"; +echo "\n"; + +if (!$settings->isConfigured()) { + echo "ERROR: Configuration incomplete. Cannot run diagnosis.\n"; + exit(1); +} + +$gateway = app(BcODataGateway::class); + +echo "Running diagnosis for customer: {$customerNo}\n"; +echo str_repeat('─', 60) . "\n\n"; + +$diagnosis = $gateway->diagnoseCustomer($customerNo); + +foreach ($diagnosis as $key => $result) { + echo "── {$key} ──\n"; + if (is_string($result)) { + echo " Error: {$result}\n\n"; + continue; + } + echo " URL: " . ($result['url'] ?? '?') . "\n"; + echo " HTTP: " . ($result['http_code'] ?? '?') . "\n"; + echo " Count: " . ($result['count'] ?? 0) . "\n"; + + if (!empty($result['error'])) { + echo " Error: " . $result['error'] . "\n"; + } + + if (!empty($result['fields'])) { + echo " Fields: " . implode(', ', $result['fields']) . "\n"; + } + + if (!empty($result['first_record'])) { + echo " 1st record:\n"; + foreach ($result['first_record'] as $field => $value) { + $display = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE); + echo " {$field}: {$display}\n"; + } + } + echo "\n"; +} diff --git a/bin/helpdesk-explore-entities.php b/bin/helpdesk-explore-entities.php new file mode 100644 index 0000000..1b74fb0 --- /dev/null +++ b/bin/helpdesk-explore-entities.php @@ -0,0 +1,300 @@ +#!/usr/bin/env php +isConfigured()) { + echo "ERROR: BC connection not configured.\n"; + exit(1); +} + +$baseUrl = $settings->getODataBaseUrl(); +$companyUrl = $baseUrl . "/Company('" . rawurlencode($settings->getCompanyName()) . "')"; + +echo "=== BC OData Entity Explorer ===\n\n"; +echo "Base URL: {$baseUrl}\n"; +echo "Company: {$settings->getCompanyName()}\n\n"; + +// ── Helper: raw cURL request ── +function bcRequest(string $url, HelpdeskSettingsGateway $settings): array +{ + $ch = curl_init(); + if ($ch === false) return ['error' => 'curl_init failed']; + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + // Apply auth + if ($settings->getAuthMode() === 'basic') { + curl_setopt($ch, CURLOPT_USERPWD, ($settings->getBasicUser() ?? '') . ':' . ($settings->getBasicPassword() ?? '')); + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + + $body = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + unset($ch); + + if (!is_string($body)) { + return ['http_code' => $httpCode, 'error' => $curlError ?: 'Empty response']; + } + + if ($httpCode < 200 || $httpCode >= 300) { + return ['http_code' => $httpCode, 'error' => mb_substr($body, 0, 300)]; + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + return ['http_code' => $httpCode, 'error' => 'Invalid JSON']; + } + + return ['http_code' => $httpCode, 'data' => $decoded]; +} + +function printResult(string $label, string $url, array $result): void +{ + echo "── {$label} ──\n"; + echo " URL: {$url}\n"; + echo " HTTP: " . ($result['http_code'] ?? '?') . "\n"; + + if (!empty($result['error'])) { + echo " Error: " . $result['error'] . "\n\n"; + return; + } + + $data = $result['data'] ?? []; + $values = $data['value'] ?? []; + + if (!is_array($values)) { + echo " Response has no 'value' array.\n\n"; + return; + } + + echo " Count: " . count($values) . "\n"; + + if (count($values) > 0) { + $first = $values[0]; + echo " Fields: " . implode(', ', array_keys($first)) . "\n"; + echo " 1st record:\n"; + foreach ($first as $field => $value) { + $display = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE); + if (strlen($display) > 120) $display = mb_substr($display, 0, 117) . '...'; + echo " {$field}: {$display}\n"; + } + } + echo "\n"; +} + +// ──────────────────────────────────────────── +// Step 1: Fetch $metadata to find all entities +// ──────────────────────────────────────────── + +echo str_repeat('═', 60) . "\n"; +echo "Step 1: Scanning all entity sets from \$metadata...\n"; +echo str_repeat('═', 60) . "\n\n"; + +$metadataUrl = $baseUrl . '/$metadata'; +$ch = curl_init(); +curl_setopt_array($ch, [ + CURLOPT_URL => $metadataUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => ['Accept: application/xml'], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, +]); +if ($settings->getAuthMode() === 'basic') { + curl_setopt($ch, CURLOPT_USERPWD, ($settings->getBasicUser() ?? '') . ':' . ($settings->getBasicPassword() ?? '')); + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); +} +$metadataBody = curl_exec($ch); +$metadataCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); +unset($ch); + +if (!is_string($metadataBody) || $metadataCode !== 200) { + echo "Could not fetch \$metadata (HTTP {$metadataCode})\n\n"; +} else { + // Parse XML for EntitySet names + preg_match_all('/EntitySet\s+Name="([^"]+)"/', $metadataBody, $matches); + $allEntities = $matches[1] ?? []; + + // Filter for interesting entities + $ticketLogEntities = array_filter($allEntities, fn($e) => + stripos($e, 'ticket') !== false || + stripos($e, 'log') !== false || + stripos($e, 'communication') !== false || + stripos($e, 'chat') !== false || + stripos($e, 'message') !== false || + stripos($e, 'comment') !== false || + stripos($e, 'note') !== false || + stripos($e, 'task') !== false || + stripos($e, 'time') !== false + ); + + $contractEntities = array_filter($allEntities, fn($e) => + stripos($e, 'contract') !== false || + stripos($e, 'vertrag') !== false || + stripos($e, 'agreement') !== false || + stripos($e, 'service') !== false || + stripos($e, 'subscription') !== false || + stripos($e, 'wartung') !== false || + stripos($e, 'support') !== false + ); + + echo "Total entities found: " . count($allEntities) . "\n\n"; + + echo "🎫 Ticket/Log/Communication related:\n"; + if (empty($ticketLogEntities)) { + echo " (none found)\n"; + } else { + foreach ($ticketLogEntities as $e) { + echo " - {$e}\n"; + } + } + + echo "\n📋 Contract/Service related:\n"; + if (empty($contractEntities)) { + echo " (none found)\n"; + } else { + foreach ($contractEntities as $e) { + echo " - {$e}\n"; + } + } + echo "\n"; +} + +// ──────────────────────────────────────────── +// Step 2: Probe ticket log entities +// ──────────────────────────────────────────── + +echo str_repeat('═', 60) . "\n"; +echo "Step 2: Probing ticket log/communication entities...\n"; +echo str_repeat('═', 60) . "\n\n"; + +$ticketLogCandidates = [ + 'PBI_FP_TicketLog', + 'PBI_LV_SupportTicketLog', + 'TicketsICI_Support_Time_Log_Subpage', + 'TicketsICI_Support_Task_Subpage', +]; + +// Also add any entities from metadata scan +if (!empty($ticketLogEntities)) { + foreach ($ticketLogEntities as $e) { + if (!in_array($e, $ticketLogCandidates, true)) { + $ticketLogCandidates[] = $e; + } + } +} + +foreach ($ticketLogCandidates as $entity) { + $url = $companyUrl . '/' . $entity . '?$top=3'; + $result = bcRequest($url, $settings); + printResult($entity . ' (top 3)', $url, $result); + + // If we got results and have a ticket number, try filtering + if ($ticketNo !== '' && !empty($result['data']['value'])) { + $fields = array_keys($result['data']['value'][0]); + // Try common filter fields + $filterFields = array_intersect($fields, [ + 'Ticket_No', 'No', 'Ticket_No_', 'TicketNo', 'Ticket_Number', + 'Support_Ticket_No', 'Support_Ticket_No_', + 'Document_No', 'Document_No_', + ]); + foreach ($filterFields as $filterField) { + $filterUrl = $companyUrl . '/' . $entity + . '?$filter=' . rawurlencode("{$filterField} eq '{$ticketNo}'") + . '&$top=10'; + $filterResult = bcRequest($filterUrl, $settings); + printResult("{$entity} filtered by {$filterField}='{$ticketNo}'", $filterUrl, $filterResult); + } + } +} + +// ──────────────────────────────────────────── +// Step 3: Probe contract entities +// ──────────────────────────────────────────── + +echo str_repeat('═', 60) . "\n"; +echo "Step 3: Probing contract/service entities...\n"; +echo str_repeat('═', 60) . "\n\n"; + +$contractCandidates = [ + 'Service_Contract', + 'Service_Contracts', + 'ServiceContract', + 'Service_Contract_List', + 'Service_Contract_Card', + 'Customer_Contract', + 'PBI_FP_ServiceContract', +]; + +// Also add any entities from metadata scan +if (!empty($contractEntities)) { + foreach ($contractEntities as $e) { + if (!in_array($e, $contractCandidates, true)) { + $contractCandidates[] = $e; + } + } +} + +foreach ($contractCandidates as $entity) { + $url = $companyUrl . '/' . $entity . '?$top=3'; + $result = bcRequest($url, $settings); + printResult($entity . ' (top 3)', $url, $result); + + // If we got results and have a customer name, try filtering + if ($customerName !== '' && !empty($result['data']['value'])) { + $fields = array_keys($result['data']['value'][0]); + $filterFields = array_intersect($fields, [ + 'Customer_No', 'Customer_No_', 'CustomerNo', + 'Customer_Name', 'Name', 'Bill_to_Customer_No_', + 'Company_Name', 'Company_Contact_Name', + ]); + $escapedName = str_replace("'", "''", $customerName); + foreach ($filterFields as $filterField) { + $filterUrl = $companyUrl . '/' . $entity + . '?$filter=' . rawurlencode("{$filterField} eq '{$escapedName}'") + . '&$top=10'; + $filterResult = bcRequest($filterUrl, $settings); + printResult("{$entity} filtered by {$filterField}='{$customerName}'", $filterUrl, $filterResult); + } + } +} + +echo str_repeat('═', 60) . "\n"; +echo "Done.\n"; diff --git a/composer.json b/composer.json index cf657d4..49b4089 100644 --- a/composer.json +++ b/composer.json @@ -29,13 +29,15 @@ "autoload-dev": { "psr-4": { "MintyPHP\\Tests\\": "tests/", - "MintyPHP\\Tests\\Module\\Audit\\": "modules/audit/tests/Module/Audit/" + "MintyPHP\\Tests\\Module\\Audit\\": "modules/audit/tests/Module/Audit/", + "MintyPHP\\Tests\\Module\\Helpdesk\\": "modules/helpdesk/tests/Module/Helpdesk/" } }, "autoload": { "psr-4": { "MintyPHP\\": "lib/", - "MintyPHP\\Module\\Audit\\": "modules/audit/lib/Module/Audit/" + "MintyPHP\\Module\\Audit\\": "modules/audit/lib/Module/Audit/", + "MintyPHP\\Module\\Helpdesk\\": "modules/helpdesk/lib/Module/Helpdesk/" } }, "scripts": { diff --git a/config/modules.php b/config/modules.php index 2e8a9b9..a81342a 100644 --- a/config/modules.php +++ b/config/modules.php @@ -12,5 +12,5 @@ * Each entry must match a directory name under modules/ containing a module.php manifest. */ return [ - 'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center'], + 'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center', 'helpdesk'], ]; diff --git a/db/init/init.sql b/db/init/init.sql index a09bd40..251d89f 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1550,7 +1550,8 @@ JOIN permissions p ON p.`key` IN ( 'api_tokens.manage', 'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge', 'stats.view', 'system_info.view', - 'roles.assign_all' + 'roles.assign_all', + 'helpdesk.access', 'helpdesk.settings.manage' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; @@ -1613,7 +1614,8 @@ JOIN permissions p ON p.`key` IN ( 'address_book.view', 'departments.view', 'roles.view', 'api_tokens.manage', - 'custom_fields.edit_values' + 'custom_fields.edit_values', + 'helpdesk.access' ) WHERE r.code = 'SUPPORT' ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/db/updates/2026-04-02-helpdesk-permissions.sql b/db/updates/2026-04-02-helpdesk-permissions.sql new file mode 100644 index 0000000..6aee4e7 --- /dev/null +++ b/db/updates/2026-04-02-helpdesk-permissions.sql @@ -0,0 +1,20 @@ +-- Helpdesk module: assign permissions to Admin and Support roles. +-- Admin gets full access (helpdesk.access + helpdesk.settings.manage). +-- Support gets helpdesk.access only. +-- Idempotent: safe to run multiple times. + +-- Admin: full helpdesk access +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` IN ('helpdesk.access', 'helpdesk.settings.manage') +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; + +-- Support: helpdesk read access +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'helpdesk.access' +WHERE r.code = 'SUPPORT' +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json new file mode 100644 index 0000000..098320f --- /dev/null +++ b/modules/helpdesk/i18n/default_de.json @@ -0,0 +1,116 @@ +{ + "Helpdesk": "Helpdesk", + "Helpdesk settings": "Helpdesk-Einstellungen", + "Customers": "Kunden", + "Debtor search": "Debitorensuche", + "Search debtors": "Debitoren suchen", + "Search by name or debtor number...": "Suche nach Name oder Debitorennummer...", + "Search": "Suchen", + "Please provide a customer number": "Bitte geben Sie eine Debitorennummer an", + "Minimum 2 characters": "Mindestens 2 Zeichen", + "Please enter at least 2 characters.": "Bitte mindestens 2 Zeichen eingeben.", + "No debtors found": "Keine Debitoren gefunden", + "Try a different search term.": "Versuchen Sie einen anderen Suchbegriff.", + "Could not connect to Business Central.": "Verbindung zu Business Central fehlgeschlagen.", + "BC connection is not configured.": "BC-Verbindung ist nicht konfiguriert.", + "BC connection is not configured. Please configure the connection in the settings.": "BC-Verbindung ist nicht konfiguriert. Bitte konfigurieren Sie die Verbindung in den Einstellungen.", + "BC connection is not configured": "BC-Verbindung ist nicht konfiguriert", + "Open settings": "Einstellungen öffnen", + "Debtor No.": "Debitoren-Nr.", + "Name": "Name", + "City": "Ort", + "Phone": "Telefon", + "E-Mail": "E-Mail", + "Master data": "Stammdaten", + "Contacts": "Ansprechpartner", + "Tickets": "Tickets", + "Debtor details": "Debitoren-Details", + "Search name": "Suchname", + "Address": "Adresse", + "Postal code": "PLZ", + "Salesperson": "Verkäufer", + "No contacts found": "Keine Ansprechpartner gefunden", + "No contacts are linked to this debtor in Business Central.": "Diesem Debitor sind in Business Central keine Ansprechpartner zugeordnet.", + "No.": "Nr.", + "Type": "Typ", + "Job title": "Position", + "No tickets found": "Keine Tickets gefunden", + "No tickets are linked to this debtor in Business Central.": "Diesem Debitor sind in Business Central keine Tickets zugeordnet.", + "Ticket No.": "Ticket-Nr.", + "Description": "Beschreibung", + "Status": "Status", + "Category": "Kategorie", + "Contact": "Kontakt", + "Support": "Bearbeiter", + "Ticket": "Ticket", + "Debtor": "Debitor", + "Debtor not found": "Debitor nicht gefunden", + "Ticket not found": "Ticket nicht gefunden", + "Back to search": "Zurück zur Suche", + "Back": "Zurück", + "Settings": "Einstellungen", + "Save": "Speichern", + "Settings saved": "Einstellungen gespeichert", + "Configuration incomplete": "Konfiguration unvollständig", + "OData connection": "OData-Verbindung", + "OData Base URL": "OData-Basis-URL", + "Base URL without Company path segment": "Basis-URL ohne Company-Pfadsegment", + "Company name": "Firmenname", + "BC company name (URL-encoded automatically)": "BC-Firmenname (wird automatisch URL-kodiert)", + "Authentication": "Authentifizierung", + "Auth mode": "Auth-Modus", + "Basic Auth": "Basic Auth", + "OAuth2 (client credentials)": "OAuth2 (Client Credentials)", + "Basic Auth credentials": "Basic-Auth-Zugangsdaten", + "Username": "Benutzername", + "Password": "Passwort", + "Leave unchanged to keep current password": "Leer lassen, um das aktuelle Passwort beizubehalten", + "OAuth2 credentials": "OAuth2-Zugangsdaten", + "Azure Tenant ID": "Azure-Mandanten-ID", + "Client ID": "Client-ID", + "Client Secret": "Client-Secret", + "Token endpoint URL": "Token-Endpoint-URL", + "Connection test": "Verbindungstest", + "Test connection": "Verbindung testen", + "Connection successful": "Verbindung erfolgreich", + "Connection failed": "Verbindung fehlgeschlagen", + "Testing...": "Teste...", + "Form expired, please try again": "Formular abgelaufen, bitte erneut versuchen", + "OData Base URL must use HTTPS": "OData-Basis-URL muss HTTPS verwenden", + "Address & contact": "Adresse & Kontakt", + "Company": "Unternehmen", + "Company details": "Firmendetails", + "Contact & dates": "Kontakt & Termine", + "Could not load contacts.": "Ansprechpartner konnten nicht geladen werden.", + "Could not load tickets.": "Tickets konnten nicht geladen werden.", + "Last activity": "Letzte Aktivität", + "Ticket details": "Ticket-Details", + "Overview": "Übersicht", + "Open tickets": "Offene Tickets", + "All tickets": "Alle Tickets", + "All contacts": "Alle Ansprechpartner", + "Recent activity": "Letzte Aktivität", + "Support info & master data": "Support-Info & Stammdaten", + "Support type": "Supportart", + "Location": "Standort", + "No open tickets": "Keine offenen Tickets", + "No recent activity found.": "Keine aktuellen Aktivitäten gefunden.", + "Created": "Erstellt", + "Loading...": "Laden...", + "Mobile": "Mobil", + "Activity": "Aktivität", + "Activity timeline": "Aktivitätsverlauf", + "Message": "Nachricht", + "Status change": "Statusänderung", + "No activity found.": "Keine Aktivitäten gefunden.", + "Could not load activity log.": "Aktivitätsverlauf konnte nicht geladen werden.", + "sent a message": "hat eine Nachricht gesendet", + "changed status": "hat den Status geändert", + "Forwarded": "hat das Ticket weitergeleitet", + "attached a file": "hat eine Datei angehängt", + "Process": "Prozess", + "All": "Alle", + "Open": "Offen", + "In Progress": "In Bearbeitung", + "Closed": "Geschlossen" +} diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json new file mode 100644 index 0000000..ca0cd9f --- /dev/null +++ b/modules/helpdesk/i18n/default_en.json @@ -0,0 +1,116 @@ +{ + "Helpdesk": "Helpdesk", + "Helpdesk settings": "Helpdesk settings", + "Customers": "Customers", + "Debtor search": "Debtor search", + "Search debtors": "Search debtors", + "Search by name or debtor number...": "Search by name or debtor number...", + "Search": "Search", + "Please provide a customer number": "Please provide a customer number", + "Minimum 2 characters": "Minimum 2 characters", + "Please enter at least 2 characters.": "Please enter at least 2 characters.", + "No debtors found": "No debtors found", + "Try a different search term.": "Try a different search term.", + "Could not connect to Business Central.": "Could not connect to Business Central.", + "BC connection is not configured.": "BC connection is not configured.", + "BC connection is not configured. Please configure the connection in the settings.": "BC connection is not configured. Please configure the connection in the settings.", + "BC connection is not configured": "BC connection is not configured", + "Open settings": "Open settings", + "Debtor No.": "Debtor No.", + "Name": "Name", + "City": "City", + "Phone": "Phone", + "E-Mail": "E-Mail", + "Master data": "Master data", + "Contacts": "Contacts", + "Tickets": "Tickets", + "Debtor details": "Debtor details", + "Search name": "Search name", + "Address": "Address", + "Postal code": "Postal code", + "Salesperson": "Salesperson", + "No contacts found": "No contacts found", + "No contacts are linked to this debtor in Business Central.": "No contacts are linked to this debtor in Business Central.", + "No.": "No.", + "Type": "Type", + "Job title": "Job title", + "No tickets found": "No tickets found", + "No tickets are linked to this debtor in Business Central.": "No tickets are linked to this debtor in Business Central.", + "Ticket No.": "Ticket No.", + "Description": "Description", + "Status": "Status", + "Category": "Category", + "Contact": "Contact", + "Support": "Support", + "Ticket": "Ticket", + "Debtor": "Debtor", + "Debtor not found": "Debtor not found", + "Ticket not found": "Ticket not found", + "Back to search": "Back to search", + "Back": "Back", + "Settings": "Settings", + "Save": "Save", + "Settings saved": "Settings saved", + "Configuration incomplete": "Configuration incomplete", + "OData connection": "OData connection", + "OData Base URL": "OData Base URL", + "Base URL without Company path segment": "Base URL without Company path segment", + "Company name": "Company name", + "BC company name (URL-encoded automatically)": "BC company name (URL-encoded automatically)", + "Authentication": "Authentication", + "Auth mode": "Auth mode", + "Basic Auth": "Basic Auth", + "OAuth2 (client credentials)": "OAuth2 (client credentials)", + "Basic Auth credentials": "Basic Auth credentials", + "Username": "Username", + "Password": "Password", + "Leave unchanged to keep current password": "Leave unchanged to keep current password", + "OAuth2 credentials": "OAuth2 credentials", + "Azure Tenant ID": "Azure Tenant ID", + "Client ID": "Client ID", + "Client Secret": "Client Secret", + "Token endpoint URL": "Token endpoint URL", + "Connection test": "Connection test", + "Test connection": "Test connection", + "Connection successful": "Connection successful", + "Connection failed": "Connection failed", + "Testing...": "Testing...", + "Form expired, please try again": "Form expired, please try again", + "OData Base URL must use HTTPS": "OData Base URL must use HTTPS", + "Address & contact": "Address & contact", + "Company": "Company", + "Company details": "Company details", + "Contact & dates": "Contact & dates", + "Could not load contacts.": "Could not load contacts.", + "Could not load tickets.": "Could not load tickets.", + "Last activity": "Last activity", + "Ticket details": "Ticket details", + "Overview": "Overview", + "Open tickets": "Open tickets", + "All tickets": "All tickets", + "All contacts": "All contacts", + "Recent activity": "Recent activity", + "Support info & master data": "Support info & master data", + "Support type": "Support type", + "Location": "Location", + "No open tickets": "No open tickets", + "No recent activity found.": "No recent activity found.", + "Created": "Created", + "Loading...": "Loading...", + "Mobile": "Mobile", + "Activity": "Activity", + "Activity timeline": "Activity timeline", + "Message": "Message", + "Status change": "Status change", + "No activity found.": "No activity found.", + "Could not load activity log.": "Could not load activity log.", + "sent a message": "sent a message", + "changed status": "changed status", + "Forwarded": "forwarded the ticket", + "attached a file": "attached a file", + "Process": "Process", + "All": "All", + "Open": "Open", + "In Progress": "In Progress", + "Closed": "Closed" +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php new file mode 100644 index 0000000..a5dc45b --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php @@ -0,0 +1,50 @@ + self::PERMISSION_ACCESS, + self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE, + default => null, + }; + + if ($permissionKey === null) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + if (!$this->permissionService->userHas($actorUserId, $permissionKey)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php new file mode 100644 index 0000000..d4db45c --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -0,0 +1,53 @@ +set(HelpdeskAuthorizationPolicy::class, static fn (AppContainer $c): HelpdeskAuthorizationPolicy => new HelpdeskAuthorizationPolicy( + $c->get(PermissionService::class) + )); + + $container->set(HelpdeskSettingsGateway::class, static fn (AppContainer $c): HelpdeskSettingsGateway => new HelpdeskSettingsGateway( + $c->get(SettingsMetadataGateway::class), + $c->get(SettingServicesFactory::class)->createSettingsCryptoGateway() + )); + + $container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway( + $c->get(HelpdeskSettingsGateway::class) + )); + + $container->set(HelpdeskTokenRepository::class, static fn (AppContainer $c): HelpdeskTokenRepository => new HelpdeskTokenRepository( + $c->get(SettingServicesFactory::class)->createSettingsCryptoGateway() + )); + + $container->set(HelpdeskOAuthTokenService::class, static fn (AppContainer $c): HelpdeskOAuthTokenService => new HelpdeskOAuthTokenService( + $c->get(HelpdeskSettingsGateway::class), + $c->get(HelpdeskTokenRepository::class) + )); + + $container->set(DebitorSearchService::class, static fn (AppContainer $c): DebitorSearchService => new DebitorSearchService( + $c->get(BcODataGateway::class), + $c->get(HelpdeskSettingsGateway::class) + )); + + $container->set(DebitorDetailService::class, static fn (AppContainer $c): DebitorDetailService => new DebitorDetailService( + $c->get(BcODataGateway::class), + $c->get(HelpdeskSettingsGateway::class) + )); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php new file mode 100644 index 0000000..e23c46b --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php @@ -0,0 +1,29 @@ + ['can_manage_settings' => false]]; + } + + try { + $authorizationService = $container->get(AuthorizationService::class); + $canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', [ + 'actor_user_id' => $userId, + ])->isAllowed(); + } catch (\Throwable) { + $canManageSettings = false; + } + + return ['helpdesk.nav' => ['can_manage_settings' => $canManageSettings]]; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php new file mode 100644 index 0000000..30c2de3 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -0,0 +1,670 @@ +> + */ + public function searchCustomers(string $query): array + { + $query = trim($query); + if ($query === '') { + return []; + } + + $escaped = $this->escapeODataString($query); + $upperQuery = mb_strtoupper($escaped); + $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code'; + $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER); + + $strategies = [ + // Strategy 1: contains() — most flexible but not supported by all BC versions + "contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')", + // Strategy 2: startswith() — universally supported fallback + "startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')", + // Strategy 3: range filter on Search_Name (last resort) + "Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'", + ]; + + $lastException = null; + foreach ($strategies as $filter) { + $url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select; + try { + $response = $this->request('GET', $url); + if ($response !== null) { + return $this->extractODataValues($response); + } + + // request() returned null → server/network error, propagate + throw new \RuntimeException('BC OData request failed for URL: ' . $url); + } catch (\RuntimeException $e) { + $lastException = $e; + // Filter not supported by this BC version (4xx) — try next strategy + continue; + } + } + + // All strategies failed — re-throw so DebitorSearchService shows error, not "no results" + throw $lastException ?? new \RuntimeException('All OData search strategies failed'); + } + + /** + * Read customer list for grid pagination with optional search/city filters. + * + * @return array{rows: array>, total: int} + */ + public function listCustomers( + string $search = '', + string $city = '', + int $limit = 10, + int $offset = 0, + string $order = 'Name', + string $dir = 'asc' + ): array { + $search = trim($search); + $city = trim($city); + $limit = max(1, min(100, $limit)); + $offset = max(0, $offset); + $sortField = $this->normalizeCustomerSortField($order); + $sortDir = strtolower(trim($dir)) === 'desc' ? 'desc' : 'asc'; + + $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER); + $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code'; + + $cityClause = ''; + if ($city !== '') { + $escapedCity = $this->escapeODataString($city); + $cityClause = "startswith(City,'" . $escapedCity . "')"; + } + + $searchStrategies = ['']; + if ($search !== '') { + $escapedSearch = $this->escapeODataString($search); + $upperSearch = mb_strtoupper($escapedSearch); + $searchStrategies = [ + "contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')", + "startswith(Search_Name,'" . $upperSearch . "') or startswith(Name,'" . $escapedSearch . "') or startswith(No,'" . $escapedSearch . "')", + "Search_Name ge '" . $upperSearch . "' and Search_Name lt '" . $upperSearch . "z'", + ]; + } + $baseNonEmptyNameClause = "Name ne ''"; + + $lastException = null; + foreach ($searchStrategies as $searchClause) { + $clauses = [$baseNonEmptyNameClause]; + if ($searchClause !== '') { + $clauses[] = '(' . $searchClause . ')'; + } + if ($cityClause !== '') { + $clauses[] = $cityClause; + } + + $url = $baseUrl + . '?$top=' . $limit + . '&$skip=' . $offset + . '&$count=true' + . '&$orderby=' . rawurlencode($sortField . ' ' . $sortDir) + . $select; + if ($clauses !== []) { + $url .= '&$filter=' . rawurlencode(implode(' and ', $clauses)); + } + + try { + $response = $this->request('GET', $url); + if ($response === null) { + throw new \RuntimeException('BC OData request failed for URL: ' . $url); + } + + $rows = $this->extractODataValues($response); + + return [ + 'rows' => $rows, + 'total' => $this->resolveCustomerListTotal($response, $offset, $limit, count($rows)), + ]; + } catch (\RuntimeException $e) { + $lastException = $e; + + if ($search === '') { + break; + } + } + } + + throw $lastException; + } + + /** + * Get a single customer by customer number. + * + * @return array|null + */ + public function getCustomer(string $customerNo): ?array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return null; + } + + $escaped = $this->escapeODataString($customerNo); + $filter = "No eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) + . '?$filter=' . rawurlencode($filter) + . '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code,Support_Type'; + + $response = $this->request('GET', $url); + if ($response === null) { + return null; + } + + $values = $this->extractODataValues($response); + + return $values[0] ?? null; + } + + /** + * Get contacts for a customer by customer name. + * + * BC OData does not support filtering by IntegrationCustomerNo, so we + * filter by Company_Name (the customer's name in the contact card). + * + * @return array> + */ + public function getContactsForCustomer(string $customerNo, string $customerName = ''): array + { + $customerName = trim($customerName); + if ($customerName === '') { + return []; + } + + $escaped = $this->escapeODataString($customerName); + $filter = "Company_Name eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT) + . '?$filter=' . rawurlencode($filter) + . '&$top=100' + . '&$select=No,Name,Type,Company_No,Company_Name,IntegrationCustomerNo,E_Mail,Phone_No,Mobile_Phone_No,Job_Title'; + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get tickets for a customer by customer name. + * + * Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports + * server-side $filter and $orderby — unlike the Card-based "Tickets" entity. + * + * @return array> + */ + public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array + { + $customerName = trim($customerName); + if ($customerName === '') { + return []; + } + + $escaped = $this->escapeODataString($customerName); + $filter = "Company_Contact_Name eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS) + . '?$filter=' . rawurlencode($filter) + . '&$top=200' + . '&$orderby=' . rawurlencode('Created_On desc') + . '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date'; + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get a single ticket by ticket number. + * + * @return array|null + */ + public function getTicket(string $ticketNo): ?array + { + $ticketNo = trim($ticketNo); + if ($ticketNo === '') { + return null; + } + + $escaped = $this->escapeODataString($ticketNo); + $filter = "No eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS) + . '?$filter=' . rawurlencode($filter) + . '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date'; + + $response = $this->request('GET', $url); + if ($response === null) { + return null; + } + + $values = $this->extractODataValues($response); + + return $values[0] ?? null; + } + + /** + * Get the activity log for a ticket. + * + * Merges data from two BC entities: + * - PBI_FP_TicketLog: detailed log (user, time, Record_ID) + * - PBI_LV_SupportTicketLog: has human-readable State_Subtype (e.g. "In Bearbeitung") + * + * Entries are joined by Entry_No and returned newest first. + * + * @return array> + */ + public function getTicketLog(string $ticketNo): array + { + $ticketNo = trim($ticketNo); + if ($ticketNo === '') { + return []; + } + + $escaped = $this->escapeODataString($ticketNo); + $filter = "Support_Ticket_No eq '" . $escaped . "'"; + + // Fetch detailed log (user, time, Record_ID) + $detailUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG) + . '?$filter=' . rawurlencode($filter) + . '&$orderby=' . rawurlencode('Entry_No desc') + . '&$top=100' + . '&$select=Entry_No,Created_By_Type,Created_By,Creation_Date,Creation_Time,Support_Ticket_No,Type,Record_ID'; + + $detailResponse = $this->request('GET', $detailUrl); + if ($detailResponse === null) { + return []; + } + + $entries = $this->extractODataValues($detailResponse); + if ($entries === []) { + return []; + } + + // Fetch status subtypes (human-readable state names) + $lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV) + . '?$filter=' . rawurlencode($filter) + . '&$orderby=' . rawurlencode('Entry_No desc') + . '&$top=100' + . '&$select=Entry_No,State_Subtype'; + + $lvResponse = $this->request('GET', $lvUrl); + $subtypeMap = []; + if ($lvResponse !== null) { + foreach ($this->extractODataValues($lvResponse) as $lv) { + $entryNo = $lv['Entry_No'] ?? null; + $subtype = (string) ($lv['State_Subtype'] ?? ''); + if ($entryNo !== null && $subtype !== '' && $subtype !== '0') { + $subtypeMap[$entryNo] = $subtype; + } + } + } + + // Merge State_Subtype into detail entries + foreach ($entries as &$entry) { + $entryNo = $entry['Entry_No'] ?? null; + $entry['State_Subtype'] = $subtypeMap[$entryNo] ?? ''; + } + unset($entry); + + return $entries; + } + + /** + * Test the connection by fetching one customer record. + * + * @return array{ok: bool, error?: string, url?: string, http_code?: int} + */ + public function testConnection(): array + { + if (!$this->settingsGateway->isConfigured()) { + return ['ok' => false, 'error' => 'Configuration incomplete']; + } + + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) + . '?$top=1&$select=No'; + + $ch = curl_init(); + if ($ch === false) { + return ['ok' => false, 'error' => 'curl_init failed']; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'OData-Version: 4.0', + ], + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + $this->applyAuth($ch); + + $responseBody = curl_exec($ch); + $curlError = curl_error($ch); + $curlErrno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($curlErrno !== 0) { + return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url]; + } + + if ($httpCode < 200 || $httpCode >= 300) { + $hint = match ($httpCode) { + 401 => 'Unauthorized — check username/password', + 403 => 'Forbidden — user has no access to this endpoint', + 404 => 'Not found — check OData URL and company name', + default => 'HTTP ' . $httpCode, + }; + + return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode]; + } + + if (!is_string($responseBody)) { + return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode]; + } + + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode]; + } + + return ['ok' => true, 'url' => $url, 'http_code' => $httpCode]; + } + + /** + * Diagnose OData calls for a specific customer number. + * + * Makes raw requests to all three entities and returns URLs, HTTP codes, + * result counts, field names, and first records — without any filtering fallback. + * + * @return array + */ + public function diagnoseCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '' || !$this->settingsGateway->isConfigured()) { + return ['error' => 'Not configured or empty customer number']; + } + + $results = []; + + // 1. Customer entity + $customerUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) + . '?$filter=' . rawurlencode("No eq '" . $customerNo . "'"); + $results['customer'] = $this->rawRequest($customerUrl); + + // Get customer name for contact/ticket filter + $customerName = ''; + if (($results['customer']['first_record']['Name'] ?? '') !== '') { + $customerName = $results['customer']['first_record']['Name']; + } + + // 2. Contact entity — filter by Company_Name + $contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT); + if ($customerName !== '') { + $contactFilteredUrl = $contactBaseUrl + . '?$filter=' . rawurlencode("Company_Name eq '" . $customerName . "'") + . '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title'); + $results['contact_filtered'] = $this->rawRequest($contactFilteredUrl); + } else { + $results['contact_filtered'] = ['error' => 'No customer name available for contact lookup']; + } + + // 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name + $ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS); + if ($customerName !== '') { + $ticketsFilteredUrl = $ticketsBaseUrl + . '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $customerName . "'") + . '&$top=10&$orderby=' . rawurlencode('Created_On desc') + . '&$select=' . rawurlencode('No,Description,Company_Contact_Name,Ticket_State,Created_On,Last_Activity_Date'); + $results['tickets_filtered'] = $this->rawRequest($ticketsFilteredUrl); + } else { + $results['tickets_filtered'] = ['error' => 'No customer name available for ticket lookup']; + } + + return $results; + } + + /** + * Raw HTTP request that returns diagnostic information (URL, status, body excerpt, field names). + * + * @return array{url: string, http_code: int, error?: string, count: int, fields: array, first_record?: array} + */ + private function rawRequest(string $url): array + { + $ch = curl_init(); + if ($ch === false) { + return ['url' => $url, 'http_code' => 0, 'error' => 'curl_init failed', 'count' => 0, 'fields' => []]; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'], + CURLOPT_CUSTOMREQUEST => 'GET', + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + $this->applyAuth($ch); + + $body = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + unset($ch); + + if (!is_string($body)) { + return ['url' => $url, 'http_code' => $httpCode, 'error' => $curlError ?: 'Empty response', 'count' => 0, 'fields' => []]; + } + + if ($httpCode < 200 || $httpCode >= 300) { + // Return a snippet of the error body for diagnosis + $snippet = mb_substr($body, 0, 500); + + return ['url' => $url, 'http_code' => $httpCode, 'error' => $snippet, 'count' => 0, 'fields' => []]; + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + return ['url' => $url, 'http_code' => $httpCode, 'error' => 'Invalid JSON', 'count' => 0, 'fields' => []]; + } + + $values = $decoded['value'] ?? []; + if (!is_array($values)) { + $values = []; + } + + $fields = []; + $firstRecord = null; + if ($values !== []) { + $firstRecord = $values[0]; + $fields = array_keys($values[0]); + } + + return [ + 'url' => $url, + 'http_code' => $httpCode, + 'count' => count($values), + 'fields' => $fields, + 'first_record' => $firstRecord, + ]; + } + + private function normalizeCustomerSortField(string $order): string + { + return match (trim($order)) { + 'No' => 'No', + 'City' => 'City', + default => 'Name', + }; + } + + /** + * @param array $response + */ + private function resolveCustomerListTotal(array $response, int $offset, int $limit, int $rowCount): int + { + $rawCount = $response['@odata.count'] ?? $response['odata.count'] ?? null; + if (is_numeric($rawCount)) { + return max(0, (int) $rawCount); + } + + $nextLink = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? '')); + if ($nextLink !== '') { + return $offset + $rowCount + 1; + } + + if ($rowCount === 0) { + return max(0, $offset); + } + + if ($rowCount < $limit) { + return $offset + $rowCount; + } + + return $offset + $rowCount + 1; + } + + /** + * @return array|null Decoded JSON body, or null on failure. + */ + private function request(string $method, string $url): ?array + { + if (!$this->settingsGateway->isConfigured()) { + return null; + } + + $ch = curl_init(); + if ($ch === false) { + return null; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT, + CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'OData-Version: 4.0', + ], + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + + $this->applyAuth($ch); + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + unset($ch); + + if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) { + if ($httpCode >= 400 && $httpCode < 500) { + throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode); + } + + return null; + } + + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + return null; + } + + return $decoded; + } + + /** + * Apply authentication to the cURL handle based on the configured auth mode. + * + * @param \CurlHandle $ch + */ + private function applyAuth($ch): void + { + $authMode = $this->settingsGateway->getAuthMode(); + + if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) { + $user = $this->settingsGateway->getBasicUser() ?? ''; + $password = $this->settingsGateway->getBasicPassword() ?? ''; + curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password); + curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + } + // OAuth2 bearer token mode is handled externally via HelpdeskOAuthTokenService. + // For V1, only Basic Auth is fully implemented. + } + + /** + * Extract the 'value' array from an OData JSON response. + * + * @param array $response + * @return array> + */ + private function extractODataValues(array $response): array + { + $values = $response['value'] ?? []; + if (!is_array($values)) { + return []; + } + + return array_values($values); + } + + /** + * Sanitize and escape a string for use inside an OData $filter string literal. + * + * Rejects characters that could manipulate OData filter logic (parentheses, + * boolean operators). Only allows letters, digits, spaces, dots, hyphens, + * underscores, umlauts and common punctuation. + * + * @throws \InvalidArgumentException If the value contains disallowed characters. + */ + private function escapeODataString(string $value): string + { + if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) { + throw new \InvalidArgumentException('Search query contains invalid characters.'); + } + + return str_replace("'", "''", $value); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php new file mode 100644 index 0000000..8650292 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -0,0 +1,190 @@ +, error?: string} + */ + public function loadCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return ['status' => 'not_found']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['status' => 'not_configured', 'error' => 'BC connection not configured']; + } + + try { + $customer = $this->bcODataGateway->getCustomer($customerNo); + } catch (\Throwable) { + return ['status' => 'error', 'error' => 'BC connection failed']; + } + + if ($customer === null) { + return ['status' => 'not_found']; + } + + return [ + 'status' => 'success', + 'customer' => $customer, + ]; + } + + /** + * Load contacts for a customer (for async data endpoint). + * + * @return array{ok: bool, contacts?: array>, error?: string} + */ + public function loadContacts(string $customerNo, string $customerName): array + { + $customerNo = trim($customerNo); + $customerName = trim($customerName); + + if ($customerNo === '' || $customerName === '') { + return ['ok' => false, 'error' => 'Missing customer number or name']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['ok' => false, 'error' => 'BC connection not configured']; + } + + try { + $contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName); + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + + return ['ok' => true, 'contacts' => $contacts]; + } + + /** + * Load tickets for a customer (for async data endpoint). + * + * @return array{ok: bool, tickets?: array>, error?: string} + */ + public function loadTickets(string $customerNo, string $customerName): array + { + $customerNo = trim($customerNo); + $customerName = trim($customerName); + + if ($customerNo === '' || $customerName === '') { + return ['ok' => false, 'error' => 'Missing customer number or name']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['ok' => false, 'error' => 'BC connection not configured']; + } + + try { + $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName); + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + + return ['ok' => true, 'tickets' => $tickets]; + } + + /** + * Load activity log for a ticket (for async data endpoint). + * + * @return array{ok: bool, log?: array>, error?: string} + */ + public function loadTicketLog(string $ticketNo): array + { + $ticketNo = trim($ticketNo); + + if ($ticketNo === '') { + return ['ok' => false, 'error' => 'Missing ticket number']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['ok' => false, 'error' => 'BC connection not configured']; + } + + try { + $log = $this->bcODataGateway->getTicketLog($ticketNo); + } catch (\Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + + return ['ok' => true, 'log' => $log]; + } + + /** + * Load full detail for a debtor: master data, contacts, and tickets. + * + * @deprecated Use loadCustomer() + async endpoints for contacts/tickets instead. + * + * @return array{status: string, customer?: array, contacts?: array>, tickets?: array>, error?: string, contactsError?: string, ticketsError?: string} + */ + public function loadDetail(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return ['status' => 'not_found']; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['status' => 'not_configured', 'error' => 'BC connection not configured']; + } + + try { + $customer = $this->bcODataGateway->getCustomer($customerNo); + } catch (\Throwable) { + return ['status' => 'error', 'error' => 'BC connection failed']; + } + + if ($customer === null) { + return ['status' => 'not_found']; + } + + // Load contacts — BC contacts are linked via Company_Name, not customer number. + // IntegrationCustomerNo is not filterable in BC OData, so we use the customer name. + $contacts = []; + $contactsError = ''; + $customerName = (string) ($customer['Name'] ?? ''); + try { + $contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName); + } catch (\Throwable $e) { + $contactsError = $e->getMessage(); + } + + // Load tickets via PBI_FP_Tickets entity which supports server-side filtering. + $tickets = []; + $ticketsError = ''; + try { + $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName); + } catch (\Throwable $e) { + $ticketsError = $e->getMessage(); + } + + return [ + 'status' => 'success', + 'customer' => $customer, + 'contacts' => $contacts, + 'tickets' => $tickets, + 'contactsError' => $contactsError, + 'ticketsError' => $ticketsError, + ]; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php new file mode 100644 index 0000000..00ce78a --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorSearchService.php @@ -0,0 +1,102 @@ +>, error?: string} + */ + public function search(string $query): array + { + $query = trim($query); + + if ($query === '') { + return ['status' => 'empty', 'results' => []]; + } + + if (mb_strlen($query) < self::MIN_QUERY_LENGTH) { + return ['status' => 'too_short', 'results' => []]; + } + + if (!$this->settingsGateway->isConfigured()) { + return ['status' => 'not_configured', 'results' => [], 'error' => 'BC connection not configured']; + } + + try { + $results = $this->bcODataGateway->searchCustomers($query); + } catch (\Throwable $e) { + return ['status' => 'error', 'results' => [], 'error' => 'BC connection failed']; + } + + if ($results === []) { + return ['status' => 'no_results', 'results' => []]; + } + + return ['status' => 'success', 'results' => $results]; + } + + /** + * Build grid data for the helpdesk debtor list. + * + * @param array $filters + * @return array{status: string, rows: array>, total: int, error?: string} + */ + public function listForGrid(array $filters): array + { + if (!$this->settingsGateway->isConfigured()) { + return [ + 'status' => 'not_configured', + 'rows' => [], + 'total' => 0, + 'error' => 'BC connection not configured', + ]; + } + + $search = trim((string) ($filters['search'] ?? '')); + $city = trim((string) ($filters['city'] ?? '')); + $limit = (int) ($filters['limit'] ?? 10); + $offset = (int) ($filters['offset'] ?? 0); + $order = (string) ($filters['order'] ?? 'Name'); + $dir = (string) ($filters['dir'] ?? 'asc'); + + if ($limit < 1) { + $limit = 1; + } elseif ($limit > 100) { + $limit = 100; + } + if ($offset < 0) { + $offset = 0; + } + + try { + $result = $this->bcODataGateway->listCustomers($search, $city, $limit, $offset, $order, $dir); + } catch (\Throwable) { + return [ + 'status' => 'error', + 'rows' => [], + 'total' => 0, + 'error' => 'BC connection failed', + ]; + } + + return [ + 'status' => 'success', + 'rows' => (array) $result['rows'], + 'total' => max(0, (int) $result['total']), + ]; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php new file mode 100644 index 0000000..60721f1 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskOAuthTokenService.php @@ -0,0 +1,41 @@ +settingsGateway->getAuthMode() !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) { + return null; + } + + // Check cache first + $cached = $this->tokenRepository->findValidToken($tenantId); + if ($cached !== null) { + return $cached; + } + + // Token request would go here in a future version. + // For V1, return null to indicate OAuth2 is not yet fully implemented. + return null; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php new file mode 100644 index 0000000..462a66a --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php @@ -0,0 +1,310 @@ +settingsMetadataGateway->getValue(self::KEY_AUTH_MODE); + $value = trim((string) ($value ?? '')); + + return $value === self::AUTH_MODE_OAUTH2 ? self::AUTH_MODE_OAUTH2 : self::AUTH_MODE_BASIC; + } + + public function setAuthMode(string $mode): bool + { + $mode = trim($mode); + if (!in_array($mode, [self::AUTH_MODE_BASIC, self::AUTH_MODE_OAUTH2], true)) { + return false; + } + + return $this->settingsMetadataGateway->set(self::KEY_AUTH_MODE, $mode, 'helpdesk.bc_auth_mode'); + } + + public function getODataBaseUrl(): string + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_ODATA_BASE_URL); + $value = trim((string) ($value ?? '')); + + return $value !== '' ? rtrim($value, '/') : self::DEFAULT_ODATA_BASE_URL; + } + + public function setODataBaseUrl(?string $url): bool + { + $url = trim((string) ($url ?? '')); + if ($url !== '' && !preg_match('#^https://#i', $url)) { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_ODATA_BASE_URL, + $url !== '' ? rtrim($url, '/') : null, + 'helpdesk.bc_odata_base_url' + ); + } + + public function getCompanyName(): string + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_COMPANY_NAME); + $value = trim((string) ($value ?? '')); + + return $value !== '' ? $value : self::DEFAULT_COMPANY_NAME; + } + + public function setCompanyName(?string $name): bool + { + $name = trim((string) ($name ?? '')); + + return $this->settingsMetadataGateway->set( + self::KEY_COMPANY_NAME, + $name !== '' ? $name : null, + 'helpdesk.bc_company_name' + ); + } + + public function getBasicUser(): ?string + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_USER); + $value = trim((string) ($value ?? '')); + + return $value !== '' ? $value : null; + } + + public function setBasicUser(?string $user): bool + { + $user = trim((string) ($user ?? '')); + + return $this->settingsMetadataGateway->set( + self::KEY_BASIC_USER, + $user !== '' ? $user : null, + 'helpdesk.bc_basic_user' + ); + } + + public function getBasicPassword(): ?string + { + $encrypted = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_PASSWORD_ENC); + $encrypted = trim((string) ($encrypted ?? '')); + if ($encrypted === '') { + return null; + } + + try { + $decrypted = $this->settingsCryptoGateway->decryptString($encrypted); + } catch (\Throwable) { + return null; + } + + $decrypted = trim($decrypted); + + return $decrypted !== '' ? $decrypted : null; + } + + public function setBasicPassword(?string $password): bool + { + $password = trim((string) ($password ?? '')); + if ($password === '') { + return $this->settingsMetadataGateway->set( + self::KEY_BASIC_PASSWORD_ENC, + null, + 'helpdesk.bc_basic_password_enc' + ); + } + + try { + $encrypted = $this->settingsCryptoGateway->encryptString($password); + } catch (\Throwable) { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_BASIC_PASSWORD_ENC, + $encrypted, + 'helpdesk.bc_basic_password_enc' + ); + } + + public function getOAuthTenantId(): ?string + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TENANT_ID); + $value = trim((string) ($value ?? '')); + + return $value !== '' ? $value : null; + } + + public function setOAuthTenantId(?string $tenantId): bool + { + $tenantId = trim((string) ($tenantId ?? '')); + + return $this->settingsMetadataGateway->set( + self::KEY_OAUTH_TENANT_ID, + $tenantId !== '' ? $tenantId : null, + 'helpdesk.bc_oauth_tenant_id' + ); + } + + public function getOAuthClientId(): ?string + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_ID); + $value = trim((string) ($value ?? '')); + + return $value !== '' ? $value : null; + } + + public function setOAuthClientId(?string $clientId): bool + { + $clientId = trim((string) ($clientId ?? '')); + + return $this->settingsMetadataGateway->set( + self::KEY_OAUTH_CLIENT_ID, + $clientId !== '' ? $clientId : null, + 'helpdesk.bc_oauth_client_id' + ); + } + + public function getOAuthClientSecret(): ?string + { + $encrypted = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_SECRET_ENC); + $encrypted = trim((string) ($encrypted ?? '')); + if ($encrypted === '') { + return null; + } + + try { + $decrypted = $this->settingsCryptoGateway->decryptString($encrypted); + } catch (\Throwable) { + return null; + } + + $decrypted = trim($decrypted); + + return $decrypted !== '' ? $decrypted : null; + } + + public function setOAuthClientSecret(?string $secret): bool + { + $secret = trim((string) ($secret ?? '')); + if ($secret === '') { + return $this->settingsMetadataGateway->set( + self::KEY_OAUTH_CLIENT_SECRET_ENC, + null, + 'helpdesk.bc_oauth_client_secret_enc' + ); + } + + try { + $encrypted = $this->settingsCryptoGateway->encryptString($secret); + } catch (\Throwable) { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_OAUTH_CLIENT_SECRET_ENC, + $encrypted, + 'helpdesk.bc_oauth_client_secret_enc' + ); + } + + public function getOAuthTokenEndpoint(): ?string + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TOKEN_ENDPOINT); + $value = trim((string) ($value ?? '')); + + return $value !== '' ? $value : null; + } + + public function setOAuthTokenEndpoint(?string $endpoint): bool + { + $endpoint = trim((string) ($endpoint ?? '')); + if ($endpoint !== '' && !preg_match('#^https://#i', $endpoint)) { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_OAUTH_TOKEN_ENDPOINT, + $endpoint !== '' ? $endpoint : null, + 'helpdesk.bc_oauth_token_endpoint' + ); + } + + /** + * Build the full OData entity URL for a given entity set. + */ + public function buildEntityUrl(string $entitySet): string + { + $baseUrl = $this->getODataBaseUrl(); + $company = rawurlencode($this->getCompanyName()); + + return $baseUrl . "/Company('" . $company . "')/" . $entitySet; + } + + /** + * Validate that required settings for the current auth mode are present. + * + * @return array List of missing setting descriptions (empty = valid) + */ + public function validateConfiguration(): array + { + $missing = []; + + if ($this->getBasicUser() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) { + $missing[] = 'BC Basic Auth User'; + } + if ($this->getBasicPassword() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) { + $missing[] = 'BC Basic Auth Password'; + } + if ($this->getAuthMode() === self::AUTH_MODE_OAUTH2) { + if ($this->getOAuthClientId() === null) { + $missing[] = 'BC OAuth2 Client ID'; + } + if ($this->getOAuthClientSecret() === null) { + $missing[] = 'BC OAuth2 Client Secret'; + } + if ($this->getOAuthTokenEndpoint() === null) { + $missing[] = 'BC OAuth2 Token Endpoint'; + } + } + + return $missing; + } + + /** + * Check whether the configuration is complete for the current auth mode. + */ + public function isConfigured(): bool + { + return $this->validateConfiguration() === []; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php new file mode 100644 index 0000000..7340456 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskTokenRepository.php @@ -0,0 +1,79 @@ + NOW() ORDER BY created_at DESC LIMIT 1', + (string) $tenantId + ); + + if (!is_array($result) || !isset($result['access_token_encrypted'])) { + return null; + } + + $encrypted = trim((string) $result['access_token_encrypted']); + if ($encrypted === '') { + return null; + } + + try { + return $this->settingsCryptoGateway->decryptString($encrypted); + } catch (\Throwable) { + return null; + } + } + + /** + * Store a new token in the cache (encrypted, tenant-scoped). + */ + public function storeToken(int $tenantId, string $accessToken, \DateTimeInterface $expiresAt): bool + { + if ($tenantId <= 0 || $accessToken === '') { + return false; + } + + try { + $encrypted = $this->settingsCryptoGateway->encryptString($accessToken); + } catch (\Throwable) { + return false; + } + + // Remove existing tokens for this tenant first + DB::delete( + 'DELETE FROM helpdesk_oauth_token_cache WHERE tenant_id = ?', + (string) $tenantId + ); + + return (bool) DB::insert( + 'INSERT INTO helpdesk_oauth_token_cache (tenant_id, access_token_encrypted, expires_at) VALUES (?, ?, ?)', + (string) $tenantId, + $encrypted, + $expiresAt->format('Y-m-d H:i:s') + ); + } +} diff --git a/modules/helpdesk/migrations/001_create_oauth_token_cache.sql b/modules/helpdesk/migrations/001_create_oauth_token_cache.sql new file mode 100644 index 0000000..16dfc98 --- /dev/null +++ b/modules/helpdesk/migrations/001_create_oauth_token_cache.sql @@ -0,0 +1,11 @@ +-- Helpdesk module: OAuth2 token cache (used only when OAuth2 auth mode is active) +CREATE TABLE IF NOT EXISTS `helpdesk_oauth_token_cache` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `access_token_encrypted` TEXT NOT NULL, + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_helpdesk_oauth_tenant` (`tenant_id`), + INDEX `idx_helpdesk_oauth_expires` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php new file mode 100644 index 0000000..c63a0ad --- /dev/null +++ b/modules/helpdesk/module.php @@ -0,0 +1,85 @@ + 'helpdesk', + 'version' => '1.0.0', + 'enabled_by_default' => false, + 'load_order' => 50, + 'requires' => [], + + 'routes' => [ + ['path' => 'helpdesk/debitor', 'target' => 'helpdesk/search'], + ['path' => 'helpdesk/search-data', 'target' => 'helpdesk/search-data'], + ['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'], + ['path' => 'helpdesk/ticket/{id}', 'target' => 'helpdesk/ticket'], + ['path' => 'helpdesk/settings', 'target' => 'helpdesk/settings'], + ['path' => 'helpdesk/settings/test-connection-data', 'target' => 'helpdesk/settings/test-connection-data'], + ['path' => 'helpdesk/settings/diagnose-data', 'target' => 'helpdesk/settings/diagnose-data'], + ['path' => 'helpdesk/debitor-contacts-data', 'target' => 'helpdesk/debitor-contacts-data'], + ['path' => 'helpdesk/debitor-tickets-data', 'target' => 'helpdesk/debitor-tickets-data'], + ['path' => 'helpdesk/debitor-ticket-categories-data', 'target' => 'helpdesk/debitor-ticket-categories-data'], + ['path' => 'helpdesk/ticket-log-data', 'target' => 'helpdesk/ticket-log-data'], + ], + 'public_paths' => [], + + 'container_registrars' => [ + \MintyPHP\Module\Helpdesk\HelpdeskContainerRegistrar::class, + ], + + 'ui_slots' => [ + 'aside.tab_panel' => [ + [ + 'key' => 'helpdesk', + 'label' => 'Helpdesk', + 'icon' => 'bi-headset', + 'href' => 'helpdesk/debitor', + 'permission' => 'helpdesk.access', + 'panel_template' => 'templates/aside-helpdesk-panel.phtml', + 'details_storage' => 'aside-helpdesk-sections-v1', + 'details_open_active' => true, + 'order' => 800, + ], + ], + ], + + 'authorization_policies' => [ + \MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy::class, + ], + + 'permissions' => [ + [ + 'key' => 'helpdesk.access', + 'description' => 'Access helpdesk debtor search and detail views', + 'active' => true, + 'is_system' => true, + ], + [ + 'key' => 'helpdesk.settings.manage', + 'description' => 'Manage helpdesk BC connection settings', + 'active' => true, + 'is_system' => true, + ], + ], + + 'search_resources' => [], + 'asset_groups' => [ + 'helpdesk' => [ + 'modules/helpdesk/css/helpdesk.css', + ], + ], + 'scheduler_jobs' => [], + 'layout_context_providers' => [ + \MintyPHP\Module\Helpdesk\Providers\HelpdeskLayoutProvider::class, + ], + 'session_providers' => [], + 'event_listeners' => [], + 'deactivation_handler' => null, + 'migrations_path' => 'migrations', + 'i18n_path' => 'i18n', +]; diff --git a/modules/helpdesk/pages/helpdesk/debitor($id).php b/modules/helpdesk/pages/helpdesk/debitor($id).php new file mode 100644 index 0000000..4ae6c3c --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor($id).php @@ -0,0 +1,96 @@ +loadCustomer($customerNo); + +$detailStatus = (string) ($detail['status'] ?? ''); + +if ($detailStatus === 'not_found') { + Flash::error(t('Debtor not found'), 'helpdesk/debitor', 'debtor_not_found'); + Router::redirect('helpdesk/debitor'); + + return; +} + +if ($detailStatus === 'not_configured') { + Flash::error(t('BC connection is not configured'), 'helpdesk/debitor', 'not_configured'); + Router::redirect('helpdesk/debitor'); + + return; +} + +$customer = $detail['customer'] ?? []; +$hasError = $detailStatus === 'error'; +$errorMessage = $detail['error'] ?? ''; + +$customerName = (string) ($customer['Name'] ?? $customerNo); + +$returnTarget = Request::safeReturnTarget((string) $request->query('return', '')); +if ($returnTarget !== '') { + $backUrl = lurl($returnTarget); +} else { + $searchQuery = trim((string) $request->query('search', '')); + + $backUrl = $searchQuery !== '' + ? lurl('helpdesk/debitor') . '?search=' . rawurlencode($searchQuery) + : lurl('helpdesk/debitor'); +} + +$ticketsFilterSchema = require __DIR__ . '/debitor-tickets-filter-schema.php'; +$ticketsFilterState = gridParseFilters($request->queryAll(), gridSchemaQuery($ticketsFilterSchema)); +$ticketsToolbarOptionSets = [ + 'category_items' => [], +]; +$ticketsListFilterContext = gridBuildListFilterContext($ticketsFilterSchema, [ + 'filter_state' => $ticketsFilterState, + 'search_keys' => ['search'], + 'toolbar_option_sets' => $ticketsToolbarOptionSets, +]); +$searchToolbarFilterSchema = $ticketsListFilterContext['searchToolbarFilterSchema']; +$drawerToolbarFilterSchema = $ticketsListFilterContext['drawerToolbarFilterSchema']; +$toolbarFilterState = $ticketsListFilterContext['toolbarFilterState']; +$toolbarOptionSets = $ticketsListFilterContext['toolbarOptionSets']; +$schemaByKey = $ticketsListFilterContext['schemaByKey']; +$clientFilterSchema = $ticketsListFilterContext['clientFilterSchema']; +$searchConfig = $ticketsListFilterContext['searchConfig']; +$filterChipMeta = [ + 'search' => [ + 'label' => t('Search'), + 'type' => 'text', + ], + 'status' => [ + 'label' => t('Status'), + 'type' => 'select', + 'default' => (string) (($schemaByKey['status']['default'] ?? '')), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])), + ], + 'category' => [ + 'label' => t('Category'), + 'type' => 'select', + 'default' => (string) (($schemaByKey['category']['default'] ?? '')), + 'options' => gridOptionMapFromItems((array) ($toolbarOptionSets['category_items'] ?? [])), + ], +]; + +Buffer::set('title', $customerName . ' — ' . t('Helpdesk')); +Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml new file mode 100644 index 0000000..dae4044 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -0,0 +1,284 @@ + +
+
+ t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'], + ['label' => $customerName], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + ?> + + $customerName, + 'backHref' => $backUrl, + 'backTitle' => t('Back'), + 'actions' => [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + + + + + + + +
+ + '#', + 'label' => t('Open tickets'), + 'count' => '—', + 'icon' => 'bi bi-ticket-detailed-fill', + 'iconBg' => '#f3e8ff', + 'iconColor' => '#7c3aed', + ]); + ?> + + + '#', + 'label' => t('Last activity'), + 'count' => '—', + 'icon' => 'bi bi-clock-history', + 'iconBg' => '#fde8d8', + 'iconColor' => '#b35c14', + ]); + ?> + + + '#', + 'label' => t('Contacts'), + 'count' => '—', + 'icon' => 'bi bi-people-fill', + 'iconBg' => '#d9f2e6', + 'iconColor' => '#1f6a3a', + ]); + ?> + + '#', + 'label' => t('Support type'), + 'count' => $supportType !== '' ? $supportType : '—', + 'icon' => 'bi bi-headset', + 'iconBg' => '#d9e8fb', + 'iconColor' => '#1a56db', + ]); + ?> +
+ + +
+
+ + + +
+ + +
+
+
+ +
+ +
+ + + + + + +
+ +
+
+ + + + + + + + + diff --git a/modules/helpdesk/pages/helpdesk/debitor-contacts-data().php b/modules/helpdesk/pages/helpdesk/debitor-contacts-data().php new file mode 100644 index 0000000..d83376e --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-contacts-data().php @@ -0,0 +1,32 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$customerNo = trim((string) $request->query('customerNo', '')); +$customerName = trim((string) $request->query('customerName', '')); + +if ($customerNo === '' || $customerName === '') { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'missing_parameters']); + + return; +} + +$service = app(DebitorDetailService::class); +$result = $service->loadContacts($customerNo, $customerName); + +Router::json($result); diff --git a/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php b/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php new file mode 100644 index 0000000..21f7292 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-ticket-categories-data().php @@ -0,0 +1,80 @@ +queryAll(), [ + 'customerNo' => ['type' => 'string'], + 'customerName' => ['type' => 'string'], +]); + +$customerNo = (string) ($filters['customerNo'] ?? ''); +$customerName = (string) ($filters['customerName'] ?? ''); + +if ($customerNo === '' || $customerName === '') { + Router::json([ + 'ok' => false, + 'categories' => [], + ]); + + return; +} + +$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class); +$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo; +$cacheTtl = 120; +$cached = $sessionStore->get($cacheKey); +$allTickets = null; + +if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + $allTickets = $cached['tickets'] ?? []; +} + +if ($allTickets === null) { + $service = app(DebitorDetailService::class); + $result = $service->loadTickets($customerNo, $customerName); + + if (!($result['ok'] ?? false)) { + Router::json([ + 'ok' => false, + 'categories' => [], + ]); + + return; + } + + $allTickets = $result['tickets'] ?? []; + + $sessionStore->set($cacheKey, [ + 'tickets' => $allTickets, + 'fetched_at' => time(), + ]); +} + +$categories = []; +foreach ((array) $allTickets as $ticket) { + if (!is_array($ticket)) { + continue; + } + $category = trim((string) ($ticket['Category_1_Description'] ?? '')); + if ($category === '') { + continue; + } + $categories[$category] = true; +} + +$categoryValues = array_keys($categories); +usort($categoryValues, 'strnatcasecmp'); + +Router::json([ + 'ok' => true, + 'categories' => array_values($categoryValues), +]); + diff --git a/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php b/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php new file mode 100644 index 0000000..14b1596 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-tickets-data().php @@ -0,0 +1,175 @@ +get($cacheKey); +$allTickets = null; + +if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + $allTickets = $cached['tickets'] ?? []; +} + +if ($allTickets === null) { + $service = app(DebitorDetailService::class); + $result = $service->loadTickets($customerNo, $customerName); + + if (!($result['ok'] ?? false)) { + gridJsonDataResult([], 0); + + return; + } + + $allTickets = $result['tickets'] ?? []; + + $sessionStore->set($cacheKey, [ + 'tickets' => $allTickets, + 'fetched_at' => time(), + ]); +} + +// --- PHP-side filtering --- + +$search = trim((string) ($filters['search'] ?? '')); +$status = trim((string) ($filters['status'] ?? '')); +$category = trim((string) ($filters['category'] ?? '')); +$order = (string) ($filters['order'] ?? 'Created_On'); +$dir = (string) ($filters['dir'] ?? 'desc'); +$limit = (int) ($filters['limit'] ?? 10); +$offset = (int) ($filters['offset'] ?? 0); + +$filtered = $allTickets; + +// Search: stripos on No + Description + Current_Contact_Name + Support_User_Name +if ($search !== '') { + $searchLower = mb_strtolower($search); + $filtered = array_values(array_filter($filtered, static function (array $ticket) use ($searchLower): bool { + $haystack = mb_strtolower( + ($ticket['No'] ?? '') . ' ' + . ($ticket['Description'] ?? '') . ' ' + . ($ticket['Current_Contact_Name'] ?? '') . ' ' + . ($ticket['Support_User_Name'] ?? '') + ); + + return str_contains($haystack, $searchLower); + })); +} + +// Status filter +if ($status !== '') { + $statusMap = [ + 'open' => ['Offen', 'Open'], + 'in_progress' => ['In Bearbeitung', 'In Progress'], + 'closed' => ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'], + ]; + $allowedStates = $statusMap[$status] ?? []; + if ($allowedStates !== []) { + $filtered = array_values(array_filter($filtered, static function (array $ticket) use ($allowedStates): bool { + return in_array((string) ($ticket['Ticket_State'] ?? ''), $allowedStates, true); + })); + } +} + +// Category filter +if ($category !== '') { + $filtered = array_values(array_filter($filtered, static function (array $ticket) use ($category): bool { + $ticketCategory = trim((string) ($ticket['Category_1_Description'] ?? '')); + return $ticketCategory === $category; + })); +} + +// --- Sorting --- + +$total = count($filtered); + +usort($filtered, static function (array $a, array $b) use ($order, $dir): int { + $va = (string) ($a[$order] ?? ''); + $vb = (string) ($b[$order] ?? ''); + $cmp = strnatcasecmp($va, $vb); + + return $dir === 'desc' ? -$cmp : $cmp; +}); + +// --- Pagination --- + +$rows = array_slice($filtered, $offset, $limit); + +// --- Row preparation --- + +$stateVariantMap = [ + 'Offen' => 'primary', + 'Open' => 'primary', + 'In Bearbeitung' => 'warning', + 'In Progress' => 'warning', + 'Erledigt' => 'success', + 'Resolved' => 'success', + 'Closed' => 'success', + 'Geschlossen' => 'success', +]; + +$ticketBaseUrl = lurl('helpdesk/ticket/'); + +$preparedRows = []; +foreach ($rows as $ticket) { + $ticketNo = (string) ($ticket['No'] ?? ''); + $state = (string) ($ticket['Ticket_State'] ?? ''); + $createdRaw = (string) ($ticket['Created_On'] ?? ''); + $lastActivityRaw = (string) ($ticket['Last_Activity_Date'] ?? ''); + + $createdFormatted = ''; + if ($createdRaw !== '' && $createdRaw !== '0001-01-01T00:00:00Z') { + try { + $createdFormatted = (new DateTimeImmutable($createdRaw))->format('d.m.Y H:i'); + } catch (\Throwable) { + $createdFormatted = $createdRaw; + } + } + + $lastActivityFormatted = ''; + if ($lastActivityRaw !== '' && $lastActivityRaw !== '0001-01-01T00:00:00Z') { + try { + $lastActivityFormatted = (new DateTimeImmutable($lastActivityRaw))->format('d.m.Y H:i'); + } catch (\Throwable) { + $lastActivityFormatted = $lastActivityRaw; + } + } + + $preparedRows[] = [ + 'No' => $ticketNo, + 'Description' => (string) ($ticket['Description'] ?? ''), + 'Ticket_State' => $state, + 'state_variant' => $stateVariantMap[$state] ?? 'neutral', + 'Category_1_Description' => (string) ($ticket['Category_1_Description'] ?? ''), + 'Support_User_Name' => (string) ($ticket['Support_User_Name'] ?? ''), + 'Current_Contact_Name' => (string) ($ticket['Current_Contact_Name'] ?? ''), + 'Created_On' => $createdRaw, + 'Last_Activity_Date' => $lastActivityRaw, + 'created_formatted' => $createdFormatted, + 'last_activity_formatted' => $lastActivityFormatted, + 'ticket_url' => $ticketBaseUrl . rawurlencode($ticketNo) . '?from=' . rawurlencode($customerNo), + ]; +} + +gridJsonDataResult($preparedRows, $total); diff --git a/modules/helpdesk/pages/helpdesk/debitor-tickets-filter-schema.php b/modules/helpdesk/pages/helpdesk/debitor-tickets-filter-schema.php new file mode 100644 index 0000000..1ae4208 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-tickets-filter-schema.php @@ -0,0 +1,51 @@ + [ + 'customerNo' => ['type' => 'string'], + 'customerName' => ['type' => 'string'], + 'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100], + 'offset' => ['type' => 'int', 'default' => 0, 'min' => 0], + 'search' => ['type' => 'string'], + 'category' => ['type' => 'string'], + 'order' => ['type' => 'order', 'allowed' => ['No', 'Created_On', 'Last_Activity_Date', 'Ticket_State'], 'default' => 'Created_On'], + 'dir' => ['type' => 'dir', 'default' => 'desc'], + 'status' => ['type' => 'string', 'default' => ''], + ], + 'toolbar' => [ + [ + 'key' => 'search', + 'type' => 'text', + 'label' => 'Search', + 'input_id' => 'helpdesk-ticket-search', + 'search' => true, + 'query' => ['type' => 'string'], + ], + [ + 'key' => 'status', + 'type' => 'select', + 'label' => 'Status', + 'input_id' => 'helpdesk-ticket-status-filter', + 'default' => '', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => '', 'description' => 'All'], + ['id' => 'open', 'description' => 'Open'], + ['id' => 'in_progress', 'description' => 'In Progress'], + ['id' => 'closed', 'description' => 'Closed'], + ], + ], + [ + 'key' => 'category', + 'type' => 'select', + 'label' => 'Category', + 'input_id' => 'helpdesk-ticket-category-filter', + 'default' => '', + 'options_key' => 'category_items', + 'allowed' => [ + ['id' => '', 'description' => 'All'], + ], + 'query' => ['type' => 'string'], + ], + ], +]); diff --git a/modules/helpdesk/pages/helpdesk/filter-schema.php b/modules/helpdesk/pages/helpdesk/filter-schema.php new file mode 100644 index 0000000..21abd61 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/filter-schema.php @@ -0,0 +1,31 @@ + [ + 'search' => ['type' => 'string'], + 'city' => ['type' => 'string'], + 'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100], + 'offset' => ['type' => 'int', 'default' => 0, 'min' => 0], + 'order' => ['type' => 'order', 'allowed' => ['No', 'Name', 'City'], 'default' => 'Name'], + 'dir' => ['type' => 'dir', 'default' => 'asc'], + ], + 'toolbar' => [ + [ + 'key' => 'search', + 'type' => 'text', + 'label' => 'Search', + 'placeholder' => 'Search...', + 'input_id' => 'helpdesk-search-input', + 'search' => true, + 'query' => ['type' => 'string'], + ], + [ + 'key' => 'city', + 'type' => 'text', + 'label' => 'City', + 'placeholder' => 'City', + 'input_id' => 'helpdesk-city-filter', + 'query' => ['type' => 'string'], + ], + ], +]); diff --git a/modules/helpdesk/pages/helpdesk/search().php b/modules/helpdesk/pages/helpdesk/search().php new file mode 100644 index 0000000..3e687fd --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/search().php @@ -0,0 +1,40 @@ +queryAll(); + +$filterSchema = require __DIR__ . '/filter-schema.php'; +$listFilterContext = gridBuildListFilterContext($filterSchema, [ + 'query' => $query, + 'search_keys' => ['search'], +]); +$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema']; +$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema']; +$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema']; +$toolbarFilterState = $listFilterContext['toolbarFilterState']; +$clientFilterSchema = $listFilterContext['clientFilterSchema']; +$searchConfig = $listFilterContext['searchConfig']; +$filterChipMeta = [ + 'search' => [ + 'label' => t('Search'), + 'type' => 'text', + ], + 'city' => [ + 'label' => t('City'), + 'type' => 'text', + ], +]; + +$settingsGateway = app(HelpdeskSettingsGateway::class); +$isConfigured = $settingsGateway->isConfigured(); + +Buffer::set('title', t('Customers')); +Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/search(default).phtml b/modules/helpdesk/pages/helpdesk/search(default).phtml new file mode 100644 index 0000000..23e14f9 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/search(default).phtml @@ -0,0 +1,57 @@ + + t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk')], +]; +require templatePath('partials/app-breadcrumb.phtml'); +?> + + + + + + + + +
+
+
+ + + + diff --git a/modules/helpdesk/pages/helpdesk/search-data().php b/modules/helpdesk/pages/helpdesk/search-data().php new file mode 100644 index 0000000..59783b2 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/search-data().php @@ -0,0 +1,42 @@ +queryAll(); +$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php', $query); +$result = app(DebitorSearchService::class)->listForGrid($filters); + +if (($result['status'] ?? '') !== 'success') { + gridJsonDataResult([], 0); + + return; +} + +$rows = []; +foreach ((array) ($result['rows'] ?? []) as $row) { + if (!is_array($row)) { + continue; + } + + $debtorNo = trim((string) ($row['No'] ?? '')); + if ($debtorNo === '') { + continue; + } + + $rows[] = [ + 'No' => $debtorNo, + 'Name' => (string) ($row['Name'] ?? ''), + 'City' => (string) ($row['City'] ?? ''), + 'Phone_No' => (string) ($row['Phone_No'] ?? ''), + 'E_Mail' => (string) ($row['E_Mail'] ?? ''), + 'detail_url' => lurl('helpdesk/debitor/' . rawurlencode($debtorNo)), + ]; +} + +gridJsonDataResult($rows, (int) ($result['total'] ?? 0)); diff --git a/modules/helpdesk/pages/helpdesk/settings/diagnose-data().php b/modules/helpdesk/pages/helpdesk/settings/diagnose-data().php new file mode 100644 index 0000000..d2a6aac --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/settings/diagnose-data().php @@ -0,0 +1,50 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf_expired']); + + return; +} + +$settingsGateway = app(HelpdeskSettingsGateway::class); +if (!$settingsGateway->isConfigured()) { + Router::json(['ok' => false, 'error' => t('Configuration incomplete')]); + + return; +} + +$body = $request->bodyAll(); +$customerNo = trim((string) ($body['customer_no'] ?? '')); +if ($customerNo === '') { + Router::json(['ok' => false, 'error' => t('Please provide a customer number')]); + + return; +} + +$gateway = app(BcODataGateway::class); +$diagnosis = $gateway->diagnoseCustomer($customerNo); + +Router::json([ + 'ok' => true, + 'customer_no' => $customerNo, + 'diagnosis' => $diagnosis, +]); diff --git a/modules/helpdesk/pages/helpdesk/settings/index().php b/modules/helpdesk/pages/helpdesk/settings/index().php new file mode 100644 index 0000000..eea9883 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/settings/index().php @@ -0,0 +1,98 @@ +isMethod('POST') && !Session::checkCsrfToken()) { + Flash::error(t('Form expired, please try again'), 'helpdesk/settings', 'csrf_expired'); + Router::redirect('helpdesk/settings'); + + return; +} + +if ($request->isMethod('POST')) { + $post = $request->bodyAll(); + + $authMode = trim((string) ($post['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC)); + $odataBaseUrl = trim((string) ($post['odata_base_url'] ?? '')); + $companyName = trim((string) ($post['company_name'] ?? '')); + $basicUser = trim((string) ($post['basic_user'] ?? '')); + $basicPassword = trim((string) ($post['basic_password'] ?? '')); + $oauthTenantId = trim((string) ($post['oauth_tenant_id'] ?? '')); + $oauthClientId = trim((string) ($post['oauth_client_id'] ?? '')); + $oauthClientSecret = trim((string) ($post['oauth_client_secret'] ?? '')); + $oauthTokenEndpoint = trim((string) ($post['oauth_token_endpoint'] ?? '')); + + $errorBag = formErrors(); + + if ($odataBaseUrl !== '' && !preg_match('#^https://#i', $odataBaseUrl)) { + $errorBag->add('odata_base_url', t('OData Base URL must use HTTPS')); + } + + if (!$errorBag->hasAny()) { + $settingsGateway->setAuthMode($authMode); + + if ($odataBaseUrl !== '') { + $settingsGateway->setODataBaseUrl($odataBaseUrl); + } + if ($companyName !== '') { + $settingsGateway->setCompanyName($companyName); + } + + $settingsGateway->setBasicUser($basicUser !== '' ? $basicUser : null); + // Only update password if a new value was provided (not the placeholder) + if ($basicPassword !== '' && $basicPassword !== '********') { + $settingsGateway->setBasicPassword($basicPassword); + } + + $settingsGateway->setOAuthTenantId($oauthTenantId !== '' ? $oauthTenantId : null); + $settingsGateway->setOAuthClientId($oauthClientId !== '' ? $oauthClientId : null); + if ($oauthClientSecret !== '' && $oauthClientSecret !== '********') { + $settingsGateway->setOAuthClientSecret($oauthClientSecret); + } + if ($oauthTokenEndpoint !== '') { + $settingsGateway->setOAuthTokenEndpoint($oauthTokenEndpoint); + } + + $action = trim((string) ($post['action'] ?? 'save')); + Flash::success(t('Settings saved'), 'helpdesk/settings', 'settings_saved'); + + if ($action === 'save_close') { + Router::redirect('helpdesk/debitor'); + + return; + } + } else { + flashFormErrors($errorBag, 'helpdesk/settings', 'settings_error'); + } + + Router::redirect('helpdesk/settings'); + + return; +} + +// Load current values for display +$authMode = $settingsGateway->getAuthMode(); +$odataBaseUrl = $settingsGateway->getODataBaseUrl(); +$companyName = $settingsGateway->getCompanyName(); +$basicUser = $settingsGateway->getBasicUser() ?? ''; +$hasBasicPassword = $settingsGateway->getBasicPassword() !== null; +$oauthTenantId = $settingsGateway->getOAuthTenantId() ?? ''; +$oauthClientId = $settingsGateway->getOAuthClientId() ?? ''; +$hasOAuthClientSecret = $settingsGateway->getOAuthClientSecret() !== null; +$oauthTokenEndpoint = $settingsGateway->getOAuthTokenEndpoint() ?? ''; +$configErrors = $settingsGateway->validateConfiguration(); + +Buffer::set('title', t('Helpdesk settings')); +Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml new file mode 100644 index 0000000..ba557a6 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml @@ -0,0 +1,198 @@ + +
+
+ t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'], + ['label' => t('Settings')], + ]; + require templatePath('partials/app-breadcrumb.phtml'); + ?> + + t('Helpdesk settings'), + 'backHref' => lurl('helpdesk/debitor'), + 'actions' => [ + [ + 'label' => t('Save'), + 'type' => 'submit', + 'form' => 'helpdesk-settings-form', + 'name' => 'action', + 'value' => 'save', + 'class' => 'secondary outline', + ], + [ + 'label' => t('Save & close'), + 'type' => 'submit', + 'form' => 'helpdesk-settings-form', + 'name' => 'action', + 'value' => 'save_close', + 'class' => 'primary', + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + + + + + + +
+ + +
+ + + + + + + + + +
+ +
+ + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ +
+ + + +
+
+
+
+ + diff --git a/modules/helpdesk/pages/helpdesk/settings/test-connection-data().php b/modules/helpdesk/pages/helpdesk/settings/test-connection-data().php new file mode 100644 index 0000000..e957791 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/settings/test-connection-data().php @@ -0,0 +1,59 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf_expired']); + + return; +} + +$settingsGateway = app(HelpdeskSettingsGateway::class); +if (!$settingsGateway->isConfigured()) { + Router::json(['ok' => false, 'error' => t('Configuration incomplete')]); + + return; +} + +$gateway = app(BcODataGateway::class); +$result = $gateway->testConnection(); + +// Optional: diagnose search if ?diag=search query param is set +$searchDiag = null; +$diagQuery = trim((string) $request->query('diag', '')); +if ($diagQuery === 'search') { + try { + $searchResults = $gateway->searchCustomers('10'); + $searchDiag = ['status' => 'ok', 'count' => count($searchResults), 'first' => $searchResults[0] ?? null]; + } catch (\Throwable $e) { + $searchDiag = ['status' => 'error', 'message' => $e->getMessage()]; + } +} + +Router::json([ + 'ok' => $result['ok'], + 'error' => $result['ok'] ? null : ($result['error'] ?? t('Connection failed')), + 'message' => $result['ok'] ? t('Connection successful') : null, + 'debug' => [ + 'url' => $result['url'] ?? null, + 'http_code' => $result['http_code'] ?? null, + 'search_diag' => $searchDiag, + ], +]); diff --git a/modules/helpdesk/pages/helpdesk/ticket($id).php b/modules/helpdesk/pages/helpdesk/ticket($id).php new file mode 100644 index 0000000..ce13245 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/ticket($id).php @@ -0,0 +1,46 @@ +query('from', '')); + +if ($ticketNo === '') { + Router::redirect('helpdesk/debitor'); + + return; +} + +$gateway = app(BcODataGateway::class); + +try { + $ticket = $gateway->getTicket($ticketNo); +} catch (\Throwable) { + $ticket = null; + $connectionError = true; +} + +$connectionError = $connectionError ?? false; + +if ($ticket === null && !$connectionError) { + Flash::error(t('Ticket not found'), 'helpdesk/debitor', 'ticket_not_found'); + Router::redirect('helpdesk/debitor'); + + return; +} + +$ticketCustomerNo = $fromCustomerNo; +$ticketDescription = (string) ($ticket['Description'] ?? $ticketNo); +$ticketLogUrl = lurl('helpdesk/ticket-log-data'); + +Buffer::set('title', $ticketDescription . ' — ' . t('Helpdesk')); +Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/ticket(default).phtml b/modules/helpdesk/pages/helpdesk/ticket(default).phtml new file mode 100644 index 0000000..c5d2911 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/ticket(default).phtml @@ -0,0 +1,161 @@ + +
+
+ t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'], + ]; + if ($ticketCustomerNo !== '') { + $breadcrumbs[] = ['label' => (string) ($ticket['Company_Contact_Name'] ?? $ticketCustomerNo), 'path' => 'helpdesk/debitor/' . rawurlencode($ticketCustomerNo)]; + } + $breadcrumbs[] = ['label' => t('Ticket') . ' ' . $ticketNo]; + require templatePath('partials/app-breadcrumb.phtml'); + ?> + + t('Ticket') . ' ' . $ticketNo, + 'backHref' => $backUrl, + 'backTitle' => t('Back'), + 'actions' => [], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + + + + + + + format('d.m.Y H:i') + : '—'; + $lastActivityRaw = (string) ($ticket['Last_Activity_Date'] ?? ''); + $lastActivityFormatted = $lastActivityRaw !== '' && $lastActivityRaw !== '0001-01-01T00:00:00Z' + ? (new DateTimeImmutable($lastActivityRaw))->format('d.m.Y H:i') + : '—'; + $ticketState = (string) ($ticket['Ticket_State'] ?? ''); + $stateVariant = match ($ticketState) { + 'Offen', 'Open' => 'primary', + 'In Bearbeitung', 'In Progress' => 'warning', + 'Erledigt', 'Resolved', 'Closed', 'Geschlossen' => 'success', + default => 'neutral', + }; + ?> + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + + + + diff --git a/modules/helpdesk/pages/helpdesk/ticket-log-data().php b/modules/helpdesk/pages/helpdesk/ticket-log-data().php new file mode 100644 index 0000000..6ed909d --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/ticket-log-data().php @@ -0,0 +1,31 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$ticketNo = trim((string) $request->query('ticketNo', '')); + +if ($ticketNo === '') { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'missing_parameters']); + + return; +} + +$service = app(DebitorDetailService::class); +$result = $service->loadTicketLog($ticketNo); + +Router::json($result); diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml new file mode 100644 index 0000000..a7a7e79 --- /dev/null +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -0,0 +1,54 @@ + t('Customers'), + 'path' => 'helpdesk/debitor', + 'active' => navActive('helpdesk/debitor', true), + 'visible' => true, + ], + [ + 'label' => t('Settings'), + 'path' => 'helpdesk/settings', + 'active' => navActive('helpdesk/settings', true), + 'visible' => $canManageSettings, + ], +]; + +$visibleItems = array_values(array_filter($helpdeskNavItems, static fn (array $item): bool => !empty($item['visible']))); +$groupIsActive = false; +foreach ($visibleItems as $item) { + $activeState = $item['active'] ?? []; + if (!empty($activeState['isActive'])) { + $groupIsActive = true; + break; + } +} +?> +
    + +
  • +
    open> + + + + +
      + '', 'aria' => '']; + ?> +
    • + > + + +
    • + +
    +
    +
  • + +
diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php new file mode 100644 index 0000000..968d839 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/BcODataGatewayTest.php @@ -0,0 +1,188 @@ +createMock(HelpdeskSettingsGateway::class); + $settings->method('isConfigured')->willReturn($isConfigured); + $settings->method('getODataBaseUrl')->willReturn('https://bc.example.com/ODataV4'); + $settings->method('getCompanyName')->willReturn('Test Company'); + $settings->method('buildEntityUrl')->willReturnCallback( + static fn (string $entity): string => "https://bc.example.com/ODataV4/Company('Test%20Company')/" . $entity + ); + $settings->method('getAuthMode')->willReturn('basic'); + $settings->method('getBasicUser')->willReturn('testuser'); + $settings->method('getBasicPassword')->willReturn('testpass'); + + return new BcODataGateway($settings); + } + + public function testSearchCustomersReturnsEmptyForEmptyQuery(): void + { + $gateway = $this->createGatewayWithSettings(); + $results = $gateway->searchCustomers(''); + $this->assertSame([], $results); + } + + public function testSearchCustomersReturnsEmptyForWhitespaceQuery(): void + { + $gateway = $this->createGatewayWithSettings(); + $results = $gateway->searchCustomers(' '); + $this->assertSame([], $results); + } + + public function testGetCustomerReturnsNullForEmptyNo(): void + { + $gateway = $this->createGatewayWithSettings(); + $result = $gateway->getCustomer(''); + $this->assertNull($result); + } + + public function testGetContactsForCustomerReturnsEmptyForEmptyName(): void + { + $gateway = $this->createGatewayWithSettings(); + $results = $gateway->getContactsForCustomer('10254', ''); + $this->assertSame([], $results); + } + + public function testGetTicketsForCustomerReturnsEmptyForEmptyName(): void + { + $gateway = $this->createGatewayWithSettings(); + $results = $gateway->getTicketsForCustomer('10254', ''); + $this->assertSame([], $results); + } + + public function testGetTicketReturnsNullForEmptyNo(): void + { + $gateway = $this->createGatewayWithSettings(); + $result = $gateway->getTicket(''); + $this->assertNull($result); + } + + public function testSearchCustomersThrowsWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $this->expectException(\RuntimeException::class); + $gateway->searchCustomers('test'); + } + + public function testGetCustomerReturnsNullWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $result = $gateway->getCustomer('10001'); + $this->assertNull($result); + } + + public function testGetTicketReturnsNullWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $result = $gateway->getTicket('T0001'); + $this->assertNull($result); + } + + public function testTestConnectionReturnsNotOkWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $result = $gateway->testConnection(); + $this->assertFalse($result['ok']); + $this->assertSame('Configuration incomplete', $result['error']); + } + + public function testSearchCustomersRejectsODataInjectionWithParentheses(): void + { + $gateway = $this->createGatewayWithSettings(); + $this->expectException(\InvalidArgumentException::class); + $gateway->searchCustomers("') or true or contains(Name,'"); + } + + public function testSearchCustomersRejectsODataInjectionWithSingleQuotes(): void + { + $gateway = $this->createGatewayWithSettings(); + $this->expectException(\InvalidArgumentException::class); + $gateway->searchCustomers("test' eq 'hack"); + } + + public function testGetCustomerRejectsODataInjectionCharacters(): void + { + $gateway = $this->createGatewayWithSettings(); + $this->expectException(\InvalidArgumentException::class); + $gateway->getCustomer("10254' or '1'='1"); + } + + public function testSearchCustomersAllowsGermanUmlauts(): void + { + // Should not throw InvalidArgumentException — umlauts are valid search characters + // Will throw RuntimeException because cURL can't reach fake URL, which is expected + $gateway = $this->createGatewayWithSettings(); + try { + $gateway->searchCustomers('Müller Straße'); + } catch (\InvalidArgumentException) { + $this->fail('Umlauts should be allowed in search queries'); + } catch (\RuntimeException) { + // Expected: cURL fails against fake URL + $this->assertTrue(true); + } + } + + public function testSearchCustomersAllowsNumbersAndDashes(): void + { + $gateway = $this->createGatewayWithSettings(); + try { + $gateway->searchCustomers('10254-A'); + } catch (\InvalidArgumentException) { + $this->fail('Numbers and dashes should be allowed in search queries'); + } catch (\RuntimeException) { + // Expected: cURL fails against fake URL + $this->assertTrue(true); + } + } + + public function testGetTicketLogReturnsEmptyForEmptyNo(): void + { + $gateway = $this->createGatewayWithSettings(); + $results = $gateway->getTicketLog(''); + $this->assertSame([], $results); + } + + public function testGetTicketLogReturnsEmptyWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $results = $gateway->getTicketLog('T26-1214'); + $this->assertSame([], $results); + } + + public function testGetContactsForCustomerReturnsEmptyWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $results = $gateway->getContactsForCustomer('10254', 'Test GmbH'); + $this->assertSame([], $results); + } + + public function testGetTicketsForCustomerReturnsEmptyWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $results = $gateway->getTicketsForCustomer('10254', 'Test GmbH'); + $this->assertSame([], $results); + } + + public function testListCustomersThrowsWhenNotConfigured(): void + { + $gateway = $this->createGatewayWithSettings(false); + $this->expectException(\RuntimeException::class); + $gateway->listCustomers('', '', 10, 0, 'Name', 'asc'); + } + + public function testListCustomersRejectsInvalidCityFilterCharacters(): void + { + $gateway = $this->createGatewayWithSettings(); + $this->expectException(\InvalidArgumentException::class); + $gateway->listCustomers('', 'Berlin)', 10, 0, 'Name', 'asc'); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php new file mode 100644 index 0000000..700355a --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php @@ -0,0 +1,307 @@ +createMock(BcODataGateway::class); + $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings->method('isConfigured')->willReturn($isConfigured); + + return new DebitorDetailService($gateway, $settings); + } + + // --- loadDetail() (legacy) --- + + public function testLoadDetailReturnsNotFoundForEmptyCustomerNo(): void + { + $service = $this->createService(); + $result = $service->loadDetail(''); + $this->assertSame('not_found', $result['status']); + } + + public function testLoadDetailReturnsNotConfiguredWhenBcNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadDetail('10254'); + $this->assertSame('not_configured', $result['status']); + } + + public function testLoadDetailReturnsNotFoundWhenCustomerMissing(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once())->method('getCustomer')->with('99999')->willReturn(null); + + $service = $this->createService($gateway); + $result = $service->loadDetail('99999'); + $this->assertSame('not_found', $result['status']); + } + + public function testLoadDetailReturnsSuccessWithAllData(): void + { + $customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH']; + $contacts = [['No' => 'KT0001', 'Name' => 'Max Mustermann']]; + $tickets = [['No' => 'T0001', 'Description' => 'Problem X']]; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer); + $gateway->expects($this->once())->method('getContactsForCustomer')->with('10254', 'Musterfirma GmbH')->willReturn($contacts); + $gateway->expects($this->once())->method('getTicketsForCustomer')->with('10254', 'Musterfirma GmbH')->willReturn($tickets); + + $service = $this->createService($gateway); + $result = $service->loadDetail('10254'); + + $this->assertSame('success', $result['status']); + $this->assertSame($customer, $result['customer']); + $this->assertSame($contacts, $result['contacts']); + $this->assertSame($tickets, $result['tickets']); + } + + public function testLoadDetailReturnsErrorOnBcConnectionFailure(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willThrowException(new \RuntimeException('timeout')); + + $service = $this->createService($gateway); + $result = $service->loadDetail('10254'); + $this->assertSame('error', $result['status']); + } + + public function testLoadDetailStillSucceedsWhenContactsFail(): void + { + $customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH']; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer); + $gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('contacts fail')); + $gateway->method('getTicketsForCustomer')->willReturn([]); + + $service = $this->createService($gateway); + $result = $service->loadDetail('10254'); + + $this->assertSame('success', $result['status']); + $this->assertSame([], $result['contacts']); + $this->assertSame('contacts fail', $result['contactsError']); + } + + public function testLoadDetailStillSucceedsWhenTicketsFail(): void + { + $customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH']; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer); + $gateway->method('getContactsForCustomer')->willReturn([]); + $gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('tickets fail')); + + $service = $this->createService($gateway); + $result = $service->loadDetail('10254'); + + $this->assertSame('success', $result['status']); + $this->assertSame([], $result['tickets']); + $this->assertSame('tickets fail', $result['ticketsError']); + } + + // --- loadCustomer() --- + + public function testLoadCustomerReturnsNotFoundForEmptyNo(): void + { + $service = $this->createService(); + $result = $service->loadCustomer(''); + $this->assertSame('not_found', $result['status']); + } + + public function testLoadCustomerReturnsNotConfiguredWhenNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadCustomer('10001'); + $this->assertSame('not_configured', $result['status']); + } + + public function testLoadCustomerReturnsNotFoundWhenCustomerNull(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willReturn(null); + + $service = $this->createService($gateway); + $result = $service->loadCustomer('99999'); + $this->assertSame('not_found', $result['status']); + } + + public function testLoadCustomerReturnsSuccessWithCustomerData(): void + { + $customer = ['No' => '10001', 'Name' => 'Test GmbH', 'Support_Type' => 'Premium']; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willReturn($customer); + + $service = $this->createService($gateway); + $result = $service->loadCustomer('10001'); + + $this->assertSame('success', $result['status']); + $this->assertSame($customer, $result['customer']); + $this->assertArrayNotHasKey('contacts', $result); + $this->assertArrayNotHasKey('tickets', $result); + } + + public function testLoadCustomerReturnsErrorOnException(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getCustomer')->willThrowException(new \RuntimeException('Connection failed')); + + $service = $this->createService($gateway); + $result = $service->loadCustomer('10001'); + + $this->assertSame('error', $result['status']); + $this->assertSame('BC connection failed', $result['error']); + } + + // --- loadContacts() --- + + public function testLoadContactsReturnsFalseForEmptyCustomerNo(): void + { + $service = $this->createService(); + $result = $service->loadContacts('', 'Test GmbH'); + $this->assertFalse($result['ok']); + } + + public function testLoadContactsReturnsFalseForEmptyCustomerName(): void + { + $service = $this->createService(); + $result = $service->loadContacts('10001', ''); + $this->assertFalse($result['ok']); + } + + public function testLoadContactsReturnsFalseWhenNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadContacts('10001', 'Test GmbH'); + $this->assertFalse($result['ok']); + } + + public function testLoadContactsReturnsOkWithContacts(): void + { + $contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']]; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getContactsForCustomer')->willReturn($contacts); + + $service = $this->createService($gateway); + $result = $service->loadContacts('10001', 'Test GmbH'); + + $this->assertTrue($result['ok']); + $this->assertSame($contacts, $result['contacts']); + } + + public function testLoadContactsReturnsErrorOnException(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('API error')); + + $service = $this->createService($gateway); + $result = $service->loadContacts('10001', 'Test GmbH'); + + $this->assertFalse($result['ok']); + $this->assertSame('API error', $result['error']); + } + + // --- loadTickets() --- + + public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void + { + $service = $this->createService(); + $result = $service->loadTickets('', 'Test GmbH'); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketsReturnsFalseForEmptyCustomerName(): void + { + $service = $this->createService(); + $result = $service->loadTickets('10001', ''); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketsReturnsFalseWhenNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadTickets('10001', 'Test GmbH'); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketsReturnsOkWithTickets(): void + { + $tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']]; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketsForCustomer')->willReturn($tickets); + + $service = $this->createService($gateway); + $result = $service->loadTickets('10001', 'Test GmbH'); + + $this->assertTrue($result['ok']); + $this->assertSame($tickets, $result['tickets']); + } + + public function testLoadTicketsReturnsErrorOnException(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout')); + + $service = $this->createService($gateway); + $result = $service->loadTickets('10001', 'Test GmbH'); + + $this->assertFalse($result['ok']); + $this->assertSame('Timeout', $result['error']); + } + + // --- loadTicketLog() --- + + public function testLoadTicketLogReturnsFalseForEmptyTicketNo(): void + { + $service = $this->createService(); + $result = $service->loadTicketLog(''); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketLogReturnsFalseWhenNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadTicketLog('T001'); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketLogReturnsOkWithLog(): void + { + $log = [ + ['Entry_No' => 1, 'Type' => 'Nachricht', 'Created_By' => 'NKS', 'Support_Ticket_No' => 'T001'], + ['Entry_No' => 2, 'Type' => 'Status', 'Created_By' => 'NKS', 'Support_Ticket_No' => 'T001'], + ]; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketLog')->willReturn($log); + + $service = $this->createService($gateway); + $result = $service->loadTicketLog('T001'); + + $this->assertTrue($result['ok']); + $this->assertSame($log, $result['log']); + } + + public function testLoadTicketLogReturnsErrorOnException(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketLog')->willThrowException(new \RuntimeException('API error')); + + $service = $this->createService($gateway); + $result = $service->loadTicketLog('T001'); + + $this->assertFalse($result['ok']); + $this->assertSame('API error', $result['error']); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php new file mode 100644 index 0000000..6630a10 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorSearchServiceTest.php @@ -0,0 +1,161 @@ +createMock(BcODataGateway::class); + $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings->method('isConfigured')->willReturn($isConfigured); + + return new DebitorSearchService($gateway, $settings); + } + + public function testSearchReturnsEmptyStatusForEmptyQuery(): void + { + $service = $this->createService(); + $result = $service->search(''); + $this->assertSame('empty', $result['status']); + $this->assertSame([], $result['results']); + } + + public function testSearchReturnsTooShortForSingleCharacter(): void + { + $service = $this->createService(); + $result = $service->search('A'); + $this->assertSame('too_short', $result['status']); + } + + public function testSearchReturnsNotConfiguredWhenBcNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->search('Test Company'); + $this->assertSame('not_configured', $result['status']); + $this->assertNotEmpty($result['error']); + } + + public function testSearchReturnsSuccessWithResults(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once()) + ->method('searchCustomers') + ->with('Musterfirma') + ->willReturn([ + ['No' => '10254', 'Name' => 'Musterfirma GmbH', 'City' => 'Berlin'], + ]); + + $service = $this->createService($gateway); + $result = $service->search('Musterfirma'); + $this->assertSame('success', $result['status']); + $this->assertCount(1, $result['results']); + $this->assertSame('10254', $result['results'][0]['No']); + } + + public function testSearchReturnsNoResultsWhenEmpty(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once()) + ->method('searchCustomers') + ->willReturn([]); + + $service = $this->createService($gateway); + $result = $service->search('xyz-nonexistent'); + $this->assertSame('no_results', $result['status']); + $this->assertSame([], $result['results']); + } + + public function testSearchReturnsErrorOnBcConnectionFailure(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once()) + ->method('searchCustomers') + ->willThrowException(new \RuntimeException('Connection refused')); + + $service = $this->createService($gateway); + $result = $service->search('Musterfirma'); + $this->assertSame('error', $result['status']); + $this->assertNotEmpty($result['error']); + } + + public function testListForGridReturnsNotConfiguredWhenBcNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->listForGrid([]); + + $this->assertSame('not_configured', $result['status']); + $this->assertSame([], $result['rows']); + $this->assertSame(0, $result['total']); + } + + public function testListForGridReturnsSuccessWithRowsAndTotal(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once()) + ->method('listCustomers') + ->with('Muster', 'Berlin', 10, 0, 'Name', 'asc') + ->willReturn([ + 'rows' => [ + ['No' => '10000', 'Name' => 'Muster GmbH', 'City' => 'Berlin'], + ], + 'total' => 42, + ]); + + $service = $this->createService($gateway); + $result = $service->listForGrid([ + 'search' => 'Muster', + 'city' => 'Berlin', + 'limit' => 10, + 'offset' => 0, + 'order' => 'Name', + 'dir' => 'asc', + ]); + + $this->assertSame('success', $result['status']); + $this->assertCount(1, $result['rows']); + $this->assertSame(42, $result['total']); + } + + public function testListForGridReturnsErrorWhenGatewayFails(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once()) + ->method('listCustomers') + ->willThrowException(new \RuntimeException('Connection refused')); + + $service = $this->createService($gateway); + $result = $service->listForGrid(['search' => 'Muster']); + + $this->assertSame('error', $result['status']); + $this->assertSame([], $result['rows']); + $this->assertSame(0, $result['total']); + } + + public function testListForGridNormalizesLimitAndOffsetBounds(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->expects($this->once()) + ->method('listCustomers') + ->with('', '', 100, 0, 'Name', 'asc') + ->willReturn([ + 'rows' => [], + 'total' => 0, + ]); + + $service = $this->createService($gateway); + $result = $service->listForGrid([ + 'limit' => 999, + 'offset' => -5, + 'order' => 'Name', + 'dir' => 'asc', + ]); + + $this->assertSame('success', $result['status']); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php new file mode 100644 index 0000000..a53052a --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskOAuthTokenServiceTest.php @@ -0,0 +1,58 @@ +createMock(HelpdeskSettingsGateway::class); + $settings->method('getAuthMode')->willReturn($authMode ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC); + + $tokenRepo = $this->createMock(HelpdeskTokenRepository::class); + $tokenRepo->method('findValidToken')->willReturn($cachedToken); + + return new HelpdeskOAuthTokenService($settings, $tokenRepo); + } + + public function testReturnsNullWhenAuthModeIsBasic(): void + { + $service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_BASIC); + $this->assertNull($service->getAccessToken(1)); + } + + public function testReturnsCachedTokenWhenAvailable(): void + { + $service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, 'cached-bearer-token'); + $this->assertSame('cached-bearer-token', $service->getAccessToken(1)); + } + + public function testReturnsNullWhenNoCachedTokenExists(): void + { + $service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, null); + $this->assertNull($service->getAccessToken(1)); + } + + public function testReturnsNullForBasicAuthRegardlessOfTenantId(): void + { + $service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_BASIC); + $this->assertNull($service->getAccessToken(99)); + } + + public function testDoesNotQueryCacheWhenAuthModeIsBasic(): void + { + $settings = $this->createMock(HelpdeskSettingsGateway::class); + $settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC); + + $tokenRepo = $this->createMock(HelpdeskTokenRepository::class); + $tokenRepo->expects($this->never())->method('findValidToken'); + + $service = new HelpdeskOAuthTokenService($settings, $tokenRepo); + $service->getAccessToken(1); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php new file mode 100644 index 0000000..8075a1e --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php @@ -0,0 +1,195 @@ +createMock(SettingRepositoryInterface::class); + $crypto = $crypto ?? $this->createMock(SettingsCryptoGatewayInterface::class); + + return new HelpdeskSettingsGateway(new SettingsMetadataGateway($settings), $crypto); + } + + public function testGetAuthModeDefaultsToBasic(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn(null); + + $gateway = $this->createGateway($settings); + $this->assertSame('basic', $gateway->getAuthMode()); + } + + public function testGetAuthModeReturnsOAuth2WhenSet(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn('oauth2'); + + $gateway = $this->createGateway($settings); + $this->assertSame('oauth2', $gateway->getAuthMode()); + } + + public function testSetAuthModeRejectsInvalidMode(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $gateway = $this->createGateway($settings); + $this->assertFalse($gateway->setAuthMode('invalid')); + } + + public function testSetODataBaseUrlRejectsHttp(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $gateway = $this->createGateway($settings); + $this->assertFalse($gateway->setODataBaseUrl('http://insecure.example.com')); + } + + public function testSetODataBaseUrlAcceptsHttps(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with(HelpdeskSettingsGateway::KEY_ODATA_BASE_URL, 'https://bc.example.com/OData', 'helpdesk.bc_odata_base_url') + ->willReturn(true); + + $gateway = $this->createGateway($settings); + $this->assertTrue($gateway->setODataBaseUrl('https://bc.example.com/OData')); + } + + public function testGetBasicPasswordReturnsNullWhenDecryptFails(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn('encrypted-value'); + + $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); + $crypto->method('decryptString')->willThrowException(new \RuntimeException('bad key')); + + $gateway = $this->createGateway($settings, $crypto); + $this->assertNull($gateway->getBasicPassword()); + } + + public function testSetBasicPasswordEncryptsAndPersists(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with(HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, 'enc-pw', 'helpdesk.bc_basic_password_enc') + ->willReturn(true); + + $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); + $crypto->expects($this->once()) + ->method('encryptString') + ->with('my-password') + ->willReturn('enc-pw'); + + $gateway = $this->createGateway($settings, $crypto); + $this->assertTrue($gateway->setBasicPassword('my-password')); + } + + public function testSetBasicPasswordClearsWhenEmpty(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with(HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, null, 'helpdesk.bc_basic_password_enc') + ->willReturn(true); + + $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); + $crypto->expects($this->never())->method('encryptString'); + + $gateway = $this->createGateway($settings, $crypto); + $this->assertTrue($gateway->setBasicPassword('')); + } + + public function testSetBasicPasswordReturnsFalseOnEncryptionFailure(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); + $crypto->method('encryptString')->willThrowException(new \RuntimeException('encryption failed')); + + $gateway = $this->createGateway($settings, $crypto); + $this->assertFalse($gateway->setBasicPassword('my-password')); + } + + public function testBuildEntityUrlConstructsCorrectUrl(): void + { + $callMap = [ + [HelpdeskSettingsGateway::KEY_ODATA_BASE_URL, 'https://bc.example.com/ODataV4'], + [HelpdeskSettingsGateway::KEY_COMPANY_NAME, 'Test Company'], + ]; + + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturnCallback(static function (string $key) use ($callMap): ?string { + foreach ($callMap as [$k, $v]) { + if ($k === $key) { + return $v; + } + } + + return null; + }); + + $gateway = $this->createGateway($settings); + $url = $gateway->buildEntityUrl('Integration_Customer_Card'); + $this->assertSame("https://bc.example.com/ODataV4/Company('Test%20Company')/Integration_Customer_Card", $url); + } + + public function testValidateConfigurationReportsMissingBasicCredentials(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn(null); + + $gateway = $this->createGateway($settings); + $missing = $gateway->validateConfiguration(); + $this->assertNotEmpty($missing); + $this->assertContains('BC Basic Auth User', $missing); + $this->assertContains('BC Basic Auth Password', $missing); + } + + public function testIsConfiguredReturnsTrueWhenBasicCredentialsPresent(): void + { + $callMap = [ + [HelpdeskSettingsGateway::KEY_AUTH_MODE, 'basic'], + [HelpdeskSettingsGateway::KEY_BASIC_USER, 'testuser'], + [HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, 'encrypted'], + ]; + + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturnCallback(static function (string $key) use ($callMap): ?string { + foreach ($callMap as [$k, $v]) { + if ($k === $key) { + return $v; + } + } + + return null; + }); + + $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); + $crypto->method('decryptString')->willReturn('decrypted-password'); + + $gateway = $this->createGateway($settings, $crypto); + $this->assertTrue($gateway->isConfigured()); + } + + public function testSetOAuthTokenEndpointRejectsHttp(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->never())->method('set'); + + $gateway = $this->createGateway($settings); + $this->assertFalse($gateway->setOAuthTokenEndpoint('http://login.microsoftonline.com/token')); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskTokenRepositoryTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskTokenRepositoryTest.php new file mode 100644 index 0000000..d02d692 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskTokenRepositoryTest.php @@ -0,0 +1,63 @@ +createMock(SettingsCryptoGatewayInterface::class); + + return new HelpdeskTokenRepository($crypto); + } + + public function testFindValidTokenReturnsNullForZeroTenantId(): void + { + $repo = $this->createRepository(); + $this->assertNull($repo->findValidToken(0)); + } + + public function testFindValidTokenReturnsNullForNegativeTenantId(): void + { + $repo = $this->createRepository(); + $this->assertNull($repo->findValidToken(-1)); + } + + public function testStoreTokenReturnsFalseForZeroTenantId(): void + { + $repo = $this->createRepository(); + $this->assertFalse($repo->storeToken(0, 'token', new \DateTimeImmutable('+1 hour'))); + } + + public function testStoreTokenReturnsFalseForNegativeTenantId(): void + { + $repo = $this->createRepository(); + $this->assertFalse($repo->storeToken(-5, 'token', new \DateTimeImmutable('+1 hour'))); + } + + public function testStoreTokenReturnsFalseForEmptyAccessToken(): void + { + $repo = $this->createRepository(); + $this->assertFalse($repo->storeToken(1, '', new \DateTimeImmutable('+1 hour'))); + } + + public function testStoreTokenReturnsFalseWhenEncryptionFails(): void + { + $crypto = $this->createMock(SettingsCryptoGatewayInterface::class); + $crypto->method('encryptString')->willThrowException(new \RuntimeException('encryption failed')); + + $repo = $this->createRepository($crypto); + $this->assertFalse($repo->storeToken(1, 'valid-token', new \DateTimeImmutable('+1 hour'))); + } +} diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css new file mode 100644 index 0000000..2036660 --- /dev/null +++ b/modules/helpdesk/web/css/helpdesk.css @@ -0,0 +1,137 @@ +@layer pages { + /* Clickable rows — shared between search results and ticket list */ + .app-clickable-row { + cursor: pointer; + transition: background-color 0.15s ease; + } + + .app-clickable-row:hover, + .app-clickable-row:focus-visible { + background: color-mix(in srgb, var(--app-contrast, #000) 4%, transparent); + } + + .app-clickable-row:focus-visible { + outline: 2px solid var(--app-primary, #0d6efd); + outline-offset: -2px; + } + + /* Async loading spinner */ + .helpdesk-tab-loading { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 2rem; + color: var(--app-muted, #6c757d); + font-size: 0.875rem; + } + + .helpdesk-spinner { + width: 1.25rem; + height: 1.25rem; + border: 2px solid var(--app-border, #dee2e6); + border-top-color: var(--app-primary, #0d6efd); + border-radius: 50%; + animation: helpdesk-spin 0.6s linear infinite; + } + + @keyframes helpdesk-spin { + to { transform: rotate(360deg); } + } + + /* Overview tab — header links */ + .app-stats-table-header .helpdesk-tab-link { + float: right; + font-size: 0.8125rem; + font-weight: 400; + text-decoration: none; + } + + .app-stats-table-header .helpdesk-tab-link:hover { + text-decoration: underline; + } + + /* KPI tile wrappers — ensure data-kpi spans don't break flex layout */ + .app-tiles > [data-kpi] { + display: contents; + } + + /* Activity timeline */ + .helpdesk-timeline { + padding: 0.75rem 1rem; + } + + .helpdesk-timeline-date { + font-size: 0.75rem; + font-weight: 600; + color: var(--app-muted, #6c757d); + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.75rem 0 0.375rem; + border-bottom: 1px solid var(--app-border, #dee2e6); + margin-bottom: 0.5rem; + } + + .helpdesk-timeline-date:first-child { + padding-top: 0; + } + + .helpdesk-timeline-entry { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.375rem 0; + font-size: 0.875rem; + line-height: 1.4; + } + + .helpdesk-timeline-icon { + flex: 0 0 1.5rem; + width: 1.5rem; + height: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.75rem; + } + + .helpdesk-timeline-entry[data-variant="message"] .helpdesk-timeline-icon { + background: #d9e8fb; + color: #1a56db; + } + + .helpdesk-timeline-entry[data-variant="status"] .helpdesk-timeline-icon { + background: #fde8d8; + color: #b35c14; + } + + .helpdesk-timeline-entry[data-variant="forward"] .helpdesk-timeline-icon { + background: #f3e8ff; + color: #7c3aed; + } + + .helpdesk-timeline-entry[data-variant="file"] .helpdesk-timeline-icon { + background: #d9f2e6; + color: #1f6a3a; + } + + .helpdesk-timeline-entry[data-variant="other"] .helpdesk-timeline-icon { + background: var(--app-border, #dee2e6); + color: var(--app-muted, #6c757d); + } + + .helpdesk-timeline-body { + flex: 1; + min-width: 0; + } + + .helpdesk-timeline-body strong { + font-weight: 600; + } + + .helpdesk-timeline-time { + margin-left: 0.5rem; + color: var(--app-muted, #6c757d); + font-size: 0.8125rem; + } +} diff --git a/modules/helpdesk/web/js/helpdesk-clickable-rows.js b/modules/helpdesk/web/js/helpdesk-clickable-rows.js new file mode 100644 index 0000000..f802dfd --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-clickable-rows.js @@ -0,0 +1,29 @@ +/** + * Shared clickable-row behaviour for helpdesk tables. + * Navigates to `data-href` on click or Enter/Space key. + */ +export function initClickableRows() { + const rows = document.querySelectorAll('.app-clickable-row[data-href]'); + + rows.forEach((row) => { + row.addEventListener('click', (event) => { + if (event.target.closest('a')) { + return; + } + const href = row.dataset.href; + if (href) { + window.location.href = href; + } + }); + + row.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + const href = row.dataset.href; + if (href) { + window.location.href = href; + } + } + }); + }); +} diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js new file mode 100644 index 0000000..cd6dc75 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -0,0 +1,571 @@ +/** + * Helpdesk debtor detail page — async data loading + dashboard rendering. + * + * - Overview tab: open tickets, recent activity, top contacts (manual HTML) + * - Tickets tab: Grid.js server-side grid with search, status filter, pagination + * - Contacts tab: full contact table (manual HTML) + * - KPI tile updates from overview data + */ +import { initClickableRows } from './helpdesk-clickable-rows.js'; +import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { readPageConfig } from '/js/core/app-page-config.js'; + +const container = document.querySelector('.app-details-container[data-customer-no]'); +if (container) { + init(container); +} + +function init(container) { + const customerNo = container.dataset.customerNo || ''; + const customerName = container.dataset.customerName || ''; + const contactsUrl = container.dataset.contactsUrl || ''; + const ticketsUrl = container.dataset.ticketsUrl || ''; + const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; + + if (!customerNo || !customerName) return; + + // i18n + const i18nEl = document.getElementById('helpdesk-i18n'); + const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {}; + const t = (key) => i18n[key] || key; + + // State — promise caching ensures only 1 request per resource + let contactsPromise = null; + let overviewTicketsPromise = null; + let ticketCategoriesPromise = null; + let contactsRendered = { overview: false, full: false }; + let ticketsGridInitialized = false; + let ticketsGridInitializing = false; + + // Date formatter + const dateFmt = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + + // --- Fetch helpers --- + + function fetchContacts() { + if (!contactsPromise) { + const params = new URLSearchParams({ customerNo, customerName }); + contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' }) + .then(r => r.json()) + .catch(() => ({ ok: false, error: 'Network error' })); + } + return contactsPromise; + } + + /** + * Fetch all tickets for overview tab (uses the grid data endpoint with high limit). + * Returns { data: [...], total: N } format from gridJsonDataResult. + */ + function fetchOverviewTickets() { + if (!overviewTicketsPromise) { + const params = new URLSearchParams({ + customerNo, + customerName, + limit: '200', + offset: '0', + order: 'Created_On', + dir: 'desc', + }); + overviewTicketsPromise = fetch(ticketsUrl + '?' + params.toString(), { credentials: 'same-origin' }) + .then(r => r.json()) + .catch(() => ({ data: [], total: 0 })); + } + return overviewTicketsPromise; + } + + function fetchTicketCategories(config, appBase) { + if (!ticketCategoriesPromise) { + const categoryOptionsUrl = config.categoryOptionsUrl || ''; + if (!categoryOptionsUrl) { + ticketCategoriesPromise = Promise.resolve({ ok: false, categories: [] }); + return ticketCategoriesPromise; + } + + const url = new URL(categoryOptionsUrl, appBase); + url.searchParams.set('customerNo', config.customerNo || customerNo); + url.searchParams.set('customerName', config.customerName || customerName); + + ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' }) + .then(r => r.json()) + .catch(() => ({ ok: false, categories: [] })); + } + + return ticketCategoriesPromise; + } + + function hydrateTicketCategoryFilter(config, categories) { + const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter'); + if (!(categoryFilter instanceof HTMLSelectElement)) return; + + const uniqueCategories = Array.from(new Set( + (Array.isArray(categories) ? categories : []) + .map(item => String(item || '').trim()) + .filter(Boolean), + )).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + + const currentValue = String(categoryFilter.value || ''); + categoryFilter.innerHTML = ''; + + const allOption = document.createElement('option'); + allOption.value = ''; + allOption.textContent = t('All'); + categoryFilter.appendChild(allOption); + + const categoryOptionMap = {}; + for (const category of uniqueCategories) { + const option = document.createElement('option'); + option.value = category; + option.textContent = category; + categoryFilter.appendChild(option); + categoryOptionMap[category] = category; + } + + if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) { + categoryFilter.value = currentValue; + } else { + categoryFilter.value = ''; + } + + if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') { + config.filterChipMeta = {}; + } + + const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object') + ? config.filterChipMeta.category + : {}; + + config.filterChipMeta.category = { + ...currentCategoryMeta, + type: 'select', + default: '', + options: categoryOptionMap, + }; + } + + // --- Rendering helpers --- + + function esc(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } + + function formatDate(raw) { + if (!raw || raw === '0001-01-01T00:00:00Z') return ''; + try { + return dateFmt.format(new Date(raw)); + } catch { + return ''; + } + } + + function stateVariant(state) { + switch (state) { + case 'Offen': case 'Open': return 'primary'; + case 'In Bearbeitung': case 'In Progress': return 'warning'; + case 'Erledigt': case 'Resolved': case 'Closed': case 'Geschlossen': return 'success'; + default: return 'neutral'; + } + } + + function isOpen(state) { + return !['Erledigt', 'Resolved', 'Closed', 'Geschlossen'].includes(state); + } + + function ticketUrl(ticketNo) { + return ticketBaseUrl + encodeURIComponent(ticketNo) + '?from=' + encodeURIComponent(customerNo); + } + + function emptyState(message, hint) { + return `
+

${esc(message)}

+ ${hint ? `

${esc(hint)}

` : ''} +
`; + } + + function errorNotice(message) { + return ``; + } + + // --- Ticket table renderer (compact, for overview) --- + + function renderTicketTableCompact(tickets) { + if (tickets.length === 0) return emptyState(t('No open tickets'), ''); + let html = ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + for (const tk of tickets) { + const no = tk.No || ''; + const url = ticketUrl(no); + const state = tk.Ticket_State || ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + html += '
${esc(t('Ticket No.'))}${esc(t('Description'))}${esc(t('Status'))}${esc(t('Created'))}
${esc(no)}${esc(tk.Description || '')}${esc(state)}${esc(formatDate(tk.Created_On))}
'; + return html; + } + + // --- Recent activity renderer --- + + function renderRecentActivity(tickets) { + const sorted = [...tickets].sort((a, b) => { + const da = a.Last_Activity_Date || ''; + const db = b.Last_Activity_Date || ''; + return db.localeCompare(da); + }).slice(0, 5); + + if (sorted.length === 0) return emptyState(t('No recent activity found.'), ''); + + let html = ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + for (const tk of sorted) { + const no = tk.No || ''; + const url = ticketUrl(no); + const state = tk.Ticket_State || ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + html += '
${esc(t('Ticket No.'))}${esc(t('Description'))}${esc(t('Status'))}${esc(t('Last activity'))}
${esc(no)}${esc(tk.Description || '')}${esc(state)}${esc(formatDate(tk.Last_Activity_Date))}
'; + return html; + } + + // --- Contact table renderers --- + + function renderContactTableCompact(contacts) { + if (contacts.length === 0) { + return emptyState(t('No contacts found'), ''); + } + const top5 = contacts.slice(0, 5); + let html = ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + for (const c of top5) { + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + html += '
${esc(t('Name'))}${esc(t('Job title'))}${esc(t('E-Mail'))}${esc(t('Phone'))}
${esc(c.Name || '')}${esc(c.Job_Title || '')}${esc(c.E_Mail || '')}${esc(c.Phone_No || '')}
'; + return html; + } + + function renderContactTableFull(contacts) { + if (contacts.length === 0) { + return emptyState(t('No contacts found'), t('No contacts are linked to this debtor in Business Central.')); + } + let html = '
'; + html += `${esc(t('Contacts'))} (${contacts.length})
`; + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + for (const c of contacts) { + html += ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + } + html += '
${esc(t('No.'))}${esc(t('Name'))}${esc(t('Type'))}${esc(t('Job title'))}${esc(t('E-Mail'))}${esc(t('Phone'))}${esc(t('Mobile'))}
${esc(c.No || '')}${esc(c.Name || '')}${esc(c.Type || '')}${esc(c.Job_Title || '')}${esc(c.E_Mail || '')}${esc(c.Phone_No || '')}${esc(c.Mobile_Phone_No || '')}
'; + return html; + } + + // --- KPI tile updates --- + + function updateKpi(kpiKey, value) { + const wrapper = container.querySelector(`[data-kpi="${kpiKey}"]`); + if (!wrapper) return; + const countEl = wrapper.querySelector('.app-tile-count'); + if (countEl) { + countEl.textContent = value; + } + } + + // --- Tab badge updates --- + + function updateBadge(id, count) { + const badge = document.getElementById(id); + if (badge) badge.textContent = String(count); + } + + // --- Show/hide loading states --- + + function showLoading(id) { + const el = document.getElementById(id); + if (el) el.hidden = false; + } + + function hideLoading(id) { + const el = document.getElementById(id); + if (el) el.hidden = true; + } + + function showContent(id) { + const el = document.getElementById(id); + if (el) el.hidden = false; + } + + // --- Tab switch links --- + + container.addEventListener('click', (e) => { + const link = e.target.closest('[data-switch-tab]'); + if (!link) return; + e.preventDefault(); + const tabName = link.dataset.switchTab; + const tabButton = container.querySelector(`[data-tab="${tabName}"]`); + if (tabButton) tabButton.click(); + }); + + // --- Grid.js tickets tab --- + + async function initTicketsGrid() { + if (ticketsGridInitialized || ticketsGridInitializing) return; + ticketsGridInitializing = true; + + const config = readPageConfig('helpdesk-tickets'); + if (!config) { + ticketsGridInitializing = false; + return; + } + + const labels = config.labels || {}; + const gridjs = window.gridjs; + if (!gridjs) { + ticketsGridInitializing = false; + return; + } + + const baseEl = document.querySelector('base'); + const appBase = (baseEl && baseEl.href) ? baseEl.href : '/'; + + const gridDataUrl = new URL(config.dataUrl || '', appBase); + gridDataUrl.searchParams.set('customerNo', config.customerNo || customerNo); + gridDataUrl.searchParams.set('customerName', config.customerName || customerName); + + try { + const categoryResponse = await fetchTicketCategories(config, appBase); + hydrateTicketCategoryFilter(config, categoryResponse?.categories || []); + + const gridOptions = { + gridjs, + container: '#helpdesk-tickets-grid', + dataUrl: gridDataUrl.toString(), + appBase, + columns: [ + { + name: labels.ticketNo || 'Ticket No.', + sort: true, + formatter: (cell) => gridjs.html(`${cell.no}`), + }, + { name: labels.description || 'Description', sort: false }, + { + name: labels.status || 'Status', + sort: true, + formatter: (cell) => { + if (!cell || typeof cell !== 'object') return gridjs.html('-'); + return gridjs.html(`${cell.label || '-'}`); + }, + }, + { name: labels.category || 'Category', sort: false }, + { name: labels.support || 'Support', sort: false }, + { name: labels.created || 'Created', sort: true }, + { name: labels.lastActivity || 'Last activity', sort: true }, + { name: 'ticket_url', hidden: true }, + ], + sortColumns: ['No', null, 'Ticket_State', null, null, 'Created_On', 'Last_Activity_Date', null], + paginationLimit: 10, + language: config.gridLang || {}, + mapData: (data) => (data.data || []).map((row) => [ + { no: row.No || '', url: row.ticket_url || '#' }, + row.Description || '', + { variant: row.state_variant || 'neutral', label: row.Ticket_State || '' }, + row.Category_1_Description || '', + row.Support_User_Name || '', + row.created_formatted || '', + row.last_activity_formatted || '', + row.ticket_url || '', + ]), + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + actions: { enabled: false }, + rowDblClick: { + getUrl: (rowData) => { + const url = rowData?.cells?.[7]?.data; + return url || ''; + }, + }, + rowInteraction: { linkColumn: 0 }, + }; + + initStandardListPage({ + grid: gridOptions, + filters: { + mode: 'drawer', + chipMeta: config.filterChipMeta || {}, + watchInputs: ['#helpdesk-ticket-search'], + }, + }); + ticketsGridInitialized = true; + } finally { + ticketsGridInitializing = false; + } + } + + // --- Render overview tab --- + + async function renderOverview() { + showLoading('overview-loading'); + + const [ticketsResult, contactsResult] = await Promise.all([ + fetchOverviewTickets(), + fetchContacts(), + ]); + + hideLoading('overview-loading'); + showContent('overview-content'); + + // Extract tickets from grid data format { data: [...], total: N } + const allTickets = ticketsResult.data || []; + + // Open tickets section + const openTicketsEl = document.getElementById('overview-open-tickets'); + if (openTicketsEl) { + if (!Array.isArray(allTickets) || allTickets.length === 0) { + openTicketsEl.innerHTML = emptyState(t('No open tickets'), ''); + } else { + const openTickets = allTickets.filter(tk => isOpen(tk.Ticket_State || '')); + openTicketsEl.innerHTML = renderTicketTableCompact(openTickets.slice(0, 10)); + } + } + + // Recent activity section + const recentEl = document.getElementById('overview-recent-activity'); + if (recentEl) { + if (!Array.isArray(allTickets) || allTickets.length === 0) { + recentEl.innerHTML = emptyState(t('No recent activity found.'), ''); + } else { + recentEl.innerHTML = renderRecentActivity(allTickets); + } + } + + // Top contacts section + const contactsEl = document.getElementById('overview-contacts'); + if (contactsEl) { + if (!contactsResult.ok) { + contactsEl.innerHTML = errorNotice(t('Could not load contacts.')); + } else { + contactsEl.innerHTML = renderContactTableCompact(contactsResult.contacts || []); + } + } + + // Update KPI tiles + if (Array.isArray(allTickets)) { + const openCount = allTickets.filter(tk => isOpen(tk.Ticket_State || '')).length; + updateKpi('open-tickets', String(openCount)); + + // Last activity date — find max Last_Activity_Date + let maxDate = ''; + for (const tk of allTickets) { + const d = tk.Last_Activity_Date || ''; + if (d > maxDate && d !== '0001-01-01T00:00:00Z') maxDate = d; + } + updateKpi('last-activity', maxDate ? formatDate(maxDate) : '—'); + + // Update tickets tab badge + const totalTickets = ticketsResult.total ?? allTickets.length; + updateBadge('tab-badge-tickets', totalTickets); + } + + if (contactsResult.ok) { + const contacts = contactsResult.contacts || []; + updateKpi('contacts', String(contacts.length)); + updateBadge('tab-badge-contacts', contacts.length); + contactsRendered.overview = true; + } + + // Init clickable rows for newly rendered content + initClickableRows(); + } + + // --- Render full contacts tab --- + + async function renderContactsTab() { + if (contactsRendered.full) return; + + showLoading('contacts-loading'); + const result = await fetchContacts(); + hideLoading('contacts-loading'); + + const contentEl = document.getElementById('contacts-content'); + if (!contentEl) return; + + if (!result.ok) { + contentEl.innerHTML = errorNotice(t('Could not load contacts.')); + } else { + contentEl.innerHTML = renderContactTableFull(result.contacts || []); + updateBadge('tab-badge-contacts', (result.contacts || []).length); + } + + showContent('contacts-content'); + contactsRendered.full = true; + initClickableRows(); + } + + // --- Tab change observer --- + + // Watch for tab panel visibility changes to trigger lazy rendering + const tabPanels = container.querySelectorAll('[data-tab-panel]'); + const observer = new MutationObserver(() => { + for (const panel of tabPanels) { + if (panel.hidden) continue; + const tabName = panel.dataset.tabPanel; + if (tabName === 'tickets' && !ticketsGridInitialized) { + initTicketsGrid(); + } else if (tabName === 'contacts' && !contactsRendered.full) { + renderContactsTab(); + } + } + }); + + for (const panel of tabPanels) { + observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] }); + } + + // --- Initial load --- + + // Start loading immediately — overview is the default tab + renderOverview(); +} diff --git a/modules/helpdesk/web/js/helpdesk-search.js b/modules/helpdesk/web/js/helpdesk-search.js new file mode 100644 index 0000000..7830388 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-search.js @@ -0,0 +1,6 @@ +/** + * Helpdesk search page — clickable row navigation. + */ +import { initClickableRows } from './helpdesk-clickable-rows.js'; + +initClickableRows(); diff --git a/modules/helpdesk/web/js/helpdesk-settings.js b/modules/helpdesk/web/js/helpdesk-settings.js new file mode 100644 index 0000000..63b8251 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-settings.js @@ -0,0 +1,77 @@ +/** + * Helpdesk settings page — auth mode toggle and connection test. + */ + +// Toggle OAuth2 vs Basic Auth field visibility +const authModeSelect = document.getElementById('auth_mode'); +const basicFields = document.getElementById('helpdesk-basic-auth-fields'); +const oauth2Fields = document.getElementById('helpdesk-oauth2-fields'); + +const toggleAuthFields = () => { + if (!authModeSelect || !basicFields || !oauth2Fields) { + return; + } + const isOAuth2 = authModeSelect.value === 'oauth2'; + basicFields.style.display = isOAuth2 ? 'none' : ''; + oauth2Fields.style.display = isOAuth2 ? '' : 'none'; +}; + +if (authModeSelect) { + authModeSelect.addEventListener('change', toggleAuthFields); + toggleAuthFields(); +} + +// Connection test button +const testButton = document.getElementById('helpdesk-test-connection-button'); +const testResult = document.getElementById('helpdesk-test-connection-result'); + +if (testButton && testResult) { + testButton.addEventListener('click', async () => { + testButton.disabled = true; + testResult.textContent = testButton.dataset.testingLabel || 'Testing...'; + testResult.className = ''; + + try { + const csrfMeta = document.querySelector('meta[name="csrf-token"]'); + const csrfInput = document.querySelector('input[name="csrf_token"]'); + const csrfToken = csrfInput?.value || csrfMeta?.content || ''; + + const formData = new FormData(); + if (csrfToken) { + formData.append('csrf_token', csrfToken); + } + + const response = await fetch( + new URL('helpdesk/settings/test-connection-data', document.baseURI).href, + { + method: 'POST', + body: formData, + credentials: 'same-origin', + }, + ); + + const data = await response.json(); + + if (data.ok) { + testResult.textContent = data.message || 'Connection successful'; + testResult.style.color = 'var(--color-success, green)'; + } else { + let msg = data.error || 'Connection failed'; + if (data.debug?.url) { + msg += '\nURL: ' + data.debug.url; + } + if (data.debug?.http_code) { + msg += ' (HTTP ' + data.debug.http_code + ')'; + } + testResult.textContent = msg; + testResult.style.color = 'var(--color-error, red)'; + testResult.style.whiteSpace = 'pre-wrap'; + } + } catch { + testResult.textContent = 'Connection failed'; + testResult.style.color = 'var(--color-error, red)'; + } finally { + testButton.disabled = false; + } + }); +} diff --git a/modules/helpdesk/web/js/helpdesk-ticket.js b/modules/helpdesk/web/js/helpdesk-ticket.js new file mode 100644 index 0000000..fb58fe7 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-ticket.js @@ -0,0 +1,161 @@ +/** + * Helpdesk ticket detail page — async activity timeline. + * + * Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders + * a chronological timeline of status changes and messages. + */ + +const container = document.querySelector('.app-details-container[data-ticket-no]'); +if (container) { + init(container); +} + +function init(container) { + const ticketNo = container.dataset.ticketNo || ''; + const logUrl = container.dataset.ticketLogUrl || ''; + + if (!ticketNo || !logUrl) return; + + // i18n + const i18nEl = document.getElementById('helpdesk-ticket-i18n'); + const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {}; + const t = (key) => i18n[key] || key; + + // Date/time formatter + const dateFmt = new Intl.DateTimeFormat('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }); + const timeFmt = new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', minute: '2-digit', + }); + + function esc(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; + } + + function formatDate(raw) { + if (!raw || raw === '0001-01-01' || raw === '0001-01-01T00:00:00Z') return ''; + try { + return dateFmt.format(new Date(raw)); + } catch { + return raw; + } + } + + function formatTime(raw) { + if (!raw) return ''; + // BC time format: "15:21:09.76" — parse as today's time + const parts = raw.split(':'); + if (parts.length < 2) return raw; + return parts[0] + ':' + parts[1]; + } + + function typeIcon(type) { + switch (type) { + case 'Nachricht': return 'bi-chat-dots-fill'; + case 'Status': return 'bi-arrow-repeat'; + case 'Weiterleitung': return 'bi-forward-fill'; + case 'Datei': return 'bi-paperclip'; + default: return 'bi-circle-fill'; + } + } + + function typeVariant(type) { + switch (type) { + case 'Nachricht': return 'message'; + case 'Status': return 'status'; + case 'Weiterleitung': return 'forward'; + case 'Datei': return 'file'; + default: return 'other'; + } + } + + function typeLabel(type, stateSubtype) { + switch (type) { + case 'Nachricht': return t('sent a message'); + case 'Status': { + if (stateSubtype) { + return t('changed status') + ' → ' + stateSubtype; + } + return t('changed status'); + } + case 'Weiterleitung': return t('Forwarded'); + case 'Datei': return t('attached a file'); + default: return type || t('Activity'); + } + } + + function renderTimeline(entries) { + if (entries.length === 0) { + return `
+

${esc(t('No activity found.'))}

+
`; + } + + let html = '
'; + let lastDate = ''; + + for (const entry of entries) { + const date = formatDate(entry.Creation_Date); + const time = formatTime(entry.Creation_Time); + const type = entry.Type || ''; + const user = entry.Created_By || ''; + const variant = typeVariant(type); + const icon = typeIcon(type); + const label = typeLabel(type, entry.State_Subtype || ''); + + // Date separator + if (date !== lastDate) { + html += `
${esc(date)}
`; + lastDate = date; + } + + html += `
`; + html += `
`; + html += '
'; + html += `${esc(user)} ${esc(label)}`; + if (time) { + html += `${esc(time)}`; + } + html += '
'; + html += '
'; + } + + html += '
'; + return html; + } + + function renderError(message) { + return ``; + } + + // --- Load and render --- + + async function loadTimeline() { + const timelineEl = document.getElementById('ticket-timeline'); + const loadingEl = document.getElementById('timeline-loading'); + if (!timelineEl) return; + + try { + const params = new URLSearchParams({ ticketNo }); + const response = await fetch(logUrl + '?' + params.toString(), { credentials: 'same-origin' }); + const data = await response.json(); + + if (loadingEl) loadingEl.hidden = true; + + if (!data.ok) { + timelineEl.innerHTML = renderError(t('Could not load activity log.')); + return; + } + + timelineEl.innerHTML = renderTimeline(data.log || []); + } catch { + if (loadingEl) loadingEl.hidden = true; + timelineEl.innerHTML = renderError(t('Could not load activity log.')); + } + } + + loadTimeline(); +} diff --git a/modules/helpdesk/web/js/pages/helpdesk-search-index.js b/modules/helpdesk/web/js/pages/helpdesk-search-index.js new file mode 100644 index 0000000..cb05479 --- /dev/null +++ b/modules/helpdesk/web/js/pages/helpdesk-search-index.js @@ -0,0 +1,73 @@ +import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { readPageConfig } from '/js/core/app-page-config.js'; +import { warnOnce } from '/js/core/app-dom.js'; +import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js'; + +const config = readPageConfig('helpdesk-search'); +if (config) { + const gridjs = window.gridjs; + if (!gridjs) { + warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed: Grid.js missing', { module: 'helpdesk-search', component: 'grid' }); + } else { + const appBase = getAppBase(); + const labels = config.labels || {}; + + const gridOptions = { + gridjs, + container: '#helpdesk-search-grid', + dataUrl: config.dataUrl || 'helpdesk/search-data', + appBase, + columns: [ + { + name: labels.debtorNo || 'Debtor No.', + sort: true, + formatter: (cell) => { + const debtorNo = escapeHtml(cell?.no || ''); + const detailUrl = String(cell?.url || '').trim(); + if (detailUrl === '') { + return gridjs.html(debtorNo); + } + const href = escapeHtml(withCurrentListReturn(detailUrl)); + return gridjs.html(`${debtorNo}`); + }, + }, + { name: labels.name || 'Name', sort: true }, + { name: labels.city || 'City', sort: true }, + { name: labels.phone || 'Phone', sort: false }, + { name: labels.email || 'E-Mail', sort: false }, + { name: 'detail_url', hidden: true }, + ], + sortColumns: ['No', 'Name', 'City', null, null, null], + paginationLimit: 10, + language: config.gridLang || {}, + mapData: (data) => (data.data || []).map((row) => [ + { no: row.No || '', url: row.detail_url || '' }, + row.Name || '', + row.City || '', + row.Phone_No || '', + row.E_Mail || '', + row.detail_url || '', + ]), + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + rowInteraction: { linkColumn: 0 }, + rowDblClick: { + getUrl: (rowData) => rowData?.cells?.[5]?.data || '', + }, + }; + + const { gridConfig } = initStandardListPage({ + grid: gridOptions, + filters: { + mode: 'drawer', + chipMeta: config.filterChipMeta || {}, + watchInputs: ['#helpdesk-search-input'], + }, + }); + + if (!gridConfig || !gridConfig.grid) { + warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed', { module: 'helpdesk-search', component: 'grid' }); + } + } +} diff --git a/tests/Architecture/ListContractFiles.php b/tests/Architecture/ListContractFiles.php index 16294e5..8a40b2a 100644 --- a/tests/Architecture/ListContractFiles.php +++ b/tests/Architecture/ListContractFiles.php @@ -44,6 +44,7 @@ trait ListContractFiles { return [ 'modules/addressbook/pages/address-book/data().php', + 'modules/helpdesk/pages/helpdesk/search-data().php', 'pages/search/data().php', 'pages/admin/users/data().php', 'pages/admin/tenants/data().php', @@ -67,6 +68,7 @@ trait ListContractFiles { return [ 'modules/addressbook/pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", + 'modules/helpdesk/pages/helpdesk/search-data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", 'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", 'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", 'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'", @@ -90,6 +92,7 @@ trait ListContractFiles { return [ 'modules/addressbook/pages/address-book/index(default).phtml', + 'modules/helpdesk/pages/helpdesk/search(default).phtml', 'pages/search/index(default).phtml', 'pages/admin/users/index(default).phtml', 'pages/admin/tenants/index(default).phtml', @@ -112,6 +115,7 @@ trait ListContractFiles { return [ 'modules/addressbook/pages/address-book/index().php', + 'modules/helpdesk/pages/helpdesk/search().php', 'pages/search/index().php', 'pages/admin/users/index().php', 'pages/admin/tenants/index().php', @@ -134,6 +138,7 @@ trait ListContractFiles { return [ 'modules/addressbook/pages/address-book/index(default).phtml', + 'modules/helpdesk/pages/helpdesk/search(default).phtml', 'pages/admin/users/index(default).phtml', 'pages/admin/tenants/index(default).phtml', 'pages/admin/departments/index(default).phtml', diff --git a/tests/Architecture/ListDataEndpointContractTest.php b/tests/Architecture/ListDataEndpointContractTest.php index 1140f76..2a31376 100644 --- a/tests/Architecture/ListDataEndpointContractTest.php +++ b/tests/Architecture/ListDataEndpointContractTest.php @@ -12,7 +12,7 @@ class ListDataEndpointContractTest extends TestCase public function testDataEndpointsRequireGetGuard(): void { $files = $this->hardCutGridEndpointFiles(); - $this->assertCount(14, $files, 'Unexpected number of hard-cut grid data endpoints.'); + $this->assertCount(15, $files, 'Unexpected number of hard-cut grid data endpoints.'); $this->assertNotContains('pages/admin/search/data().php', $files); foreach ($files as $file) { diff --git a/web/css/components/app-details.css b/web/css/components/app-details.css index 135283d..9d26758 100644 --- a/web/css/components/app-details.css +++ b/web/css/components/app-details.css @@ -1,6 +1,6 @@ @layer components { /* Detail page layout — aside panel, card sections, sticky titlebar integration. */ - .app-details-container aside { + .app-details-container > aside { padding-top: var(--app-spacing); } @@ -150,7 +150,7 @@ padding: calc(var(--app-spacing) * 2) calc(var(--app-spacing) * 2) calc(var(--app-spacing) * 2) calc(var(--app-spacing) * 0); } */ - .app-details-container:has(aside) { + .app-details-container:has(> aside) { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 300px); min-height: 82vh; @@ -158,10 +158,10 @@ .app-details-container.is-aside-collapsed { grid-template-columns: minmax(0, 1fr); } - .app-details-container.is-aside-collapsed aside { + .app-details-container.is-aside-collapsed > aside { display: none; } - .app-details-container aside { + .app-details-container > aside { border-left: 1px solid var(--app-border); padding: calc(var(--app-spacing) * 2); border-top: none; diff --git a/web/js/components/app-filter-drawer.js b/web/js/components/app-filter-drawer.js index 89917d7..36fa045 100644 --- a/web/js/components/app-filter-drawer.js +++ b/web/js/components/app-filter-drawer.js @@ -36,6 +36,8 @@ export function initFilterDrawer(options = {}) { const applyBaseLabel = applyButton ? String(applyButton.textContent || '').trim() : ''; const openButtonBaseLabels = new WeakMap(); const panel = drawer.querySelector('[data-filter-drawer-panel]') || drawer; + const originalParent = drawer.parentNode; + const originalNextSibling = drawer.nextSibling; const cleanupFns = []; const bind = (target, eventName, handler, bindOptions = undefined) => { @@ -47,6 +49,29 @@ export function initFilterDrawer(options = {}) { let lockedScrollY = 0; let lastTrigger = null; + const ensureDrawerInBody = () => { + if (!document.body || !drawer.isConnected || drawer.parentNode === document.body) { + return; + } + document.body.appendChild(drawer); + }; + + const restoreDrawerPosition = () => { + if (!(originalParent instanceof Node) || !drawer.isConnected) { + return; + } + if (!originalParent.isConnected || drawer.parentNode === originalParent) { + return; + } + if (originalNextSibling && originalNextSibling.parentNode === originalParent) { + originalParent.insertBefore(drawer, originalNextSibling); + return; + } + originalParent.appendChild(drawer); + }; + + ensureDrawerInBody(); + if (!drawer.hasAttribute('role')) { drawer.setAttribute('role', 'dialog'); } @@ -115,6 +140,9 @@ export function initFilterDrawer(options = {}) { }; const setOpen = (nextOpen) => { + if (nextOpen) { + ensureDrawerInBody(); + } isOpen = nextOpen; drawer.hidden = !nextOpen; drawer.setAttribute('aria-hidden', nextOpen ? 'false' : 'true'); @@ -148,6 +176,21 @@ export function initFilterDrawer(options = {}) { focusFirstField(); }; + const normalizeForcedClose = () => { + if (!isOpen) { + return; + } + isOpen = false; + unlockPageScroll(); + document.body.classList.remove('filter-drawer-open'); + openButtons.forEach((button) => { + button.setAttribute('aria-expanded', 'false'); + }); + if (typeof onClose === 'function') { + onClose(); + } + }; + const open = (trigger = null) => { if (isOpen) { return; @@ -254,6 +297,23 @@ export function initFilterDrawer(options = {}) { }; bind(document, 'keydown', onKeyDown); + const documentObserver = new MutationObserver(() => { + if (!drawer.isConnected || drawer.hidden) { + normalizeForcedClose(); + } + }); + if (document.body) { + documentObserver.observe(document.body, { childList: true, subtree: true }); + cleanupFns.push(() => documentObserver.disconnect()); + } + const drawerAttributeObserver = new MutationObserver(() => { + if (drawer.hidden) { + normalizeForcedClose(); + } + }); + drawerAttributeObserver.observe(drawer, { attributes: true, attributeFilter: ['hidden'] }); + cleanupFns.push(() => drawerAttributeObserver.disconnect()); + const setApplyEnabled = (enabled) => { if (!applyButton) { return; @@ -314,6 +374,9 @@ export function initFilterDrawer(options = {}) { if (isOpen) { close('discard'); } + unlockPageScroll(); + document.body.classList.remove('filter-drawer-open'); + restoreDrawerPosition(); delete drawer.dataset.filterDrawerBound; delete drawer._filterDrawerApi; }; diff --git a/web/js/core/app-grid-factory.js b/web/js/core/app-grid-factory.js index 09e87d6..9cf69d7 100644 --- a/web/js/core/app-grid-factory.js +++ b/web/js/core/app-grid-factory.js @@ -1370,6 +1370,35 @@ const normalizeListMode = (value) => { return 'drawer'; }; +const resolveDrawerRoot = (gridConfig, filterOptions = {}) => { + const explicitRoot = filterOptions?.drawerRoot; + if (explicitRoot) { + return resolveDomQueryElement(explicitRoot) || document; + } + + const containerEl = gridConfig?.container instanceof Element + ? gridConfig.container + : null; + if (!containerEl) { + return document; + } + + const drawerSelector = String(filterOptions?.drawer?.drawerSelector || '[data-filter-drawer]').trim() || '[data-filter-drawer]'; + const openSelector = String(filterOptions?.drawer?.openSelector || '[data-filter-drawer-open]').trim() || '[data-filter-drawer-open]'; + + let current = containerEl; + while (current && current !== document.documentElement) { + const hasDrawer = Boolean(current.querySelector(drawerSelector)); + const hasOpenButton = Boolean(current.querySelector(openSelector)); + if (hasDrawer && hasOpenButton) { + return current; + } + current = current.parentElement; + } + + return containerEl.parentElement || document; +}; + /** * Standardized list-page bootstrap: * - builds grid filters from schema (or uses explicit filters) @@ -1425,7 +1454,7 @@ export function initStandardListPage(options = {}) { const chipUiConfig = filterOptions.chips && typeof filterOptions.chips === 'object' ? filterOptions.chips : {}; - const drawerRoot = filterOptions.drawerRoot || document; + const drawerRoot = resolveDrawerRoot(gridConfig, filterOptions); const domLabels = readDomLabelDefaults({ drawerRoot, chipsSelector: chipUiConfig.container || '[data-active-filter-chips]',