From da0abc824ac745d75ed9a81a6468f30c1b2dfe98 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 16 Apr 2026 13:21:40 +0200 Subject: [PATCH] feat(helpdesk): add software updates tracking with BC ticket integration New Updates page in the Helpdesk module that fetches UPDATE/UPDATE-HF tickets from Business Central (PBI_LV_Tickets) and allows assigning a domain and Gitea link via a dialog. Ticket status (from BC) and assignment status (local) are shown as separate columns with filters for both plus type and free-text search. Assigned updates also appear on the domain detail page. Includes session-cached BC fetch with refresh button, admin permissions, migration, and 16 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- db/init/init.sql | 3 +- ...026-04-16-helpdesk-updates-permissions.sql | 16 + modules/helpdesk/i18n/default_de.json | 62 +++- modules/helpdesk/i18n/default_en.json | 62 +++- .../Helpdesk/HelpdeskAuthorizationPolicy.php | 12 +- .../Helpdesk/HelpdeskContainerRegistrar.php | 17 + .../Providers/HelpdeskLayoutProvider.php | 3 + .../Helpdesk/Repository/UpdateRepository.php | 179 +++++++++ .../Helpdesk/Service/BcODataGateway.php | 168 +++++++++ .../Helpdesk/Service/DebitorCacheControl.php | 6 + .../Module/Helpdesk/Service/UpdateService.php | 270 ++++++++++++++ .../migrations/008_create_updates.sql | 23 ++ modules/helpdesk/module.php | 20 + .../pages/helpdesk/update-domains-data().php | 44 +++ .../pages/helpdesk/updates-data().php | 98 +++++ .../pages/helpdesk/updates/assign().php | 44 +++ .../pages/helpdesk/updates/filter-schema.php | 65 ++++ .../pages/helpdesk/updates/index().php | 72 ++++ .../helpdesk/updates/index(default).phtml | 114 ++++++ .../templates/aside-helpdesk-panel.phtml | 12 +- .../Helpdesk/Service/UpdateServiceTest.php | 349 ++++++++++++++++++ .../web/js/pages/helpdesk-updates-index.js | 325 ++++++++++++++++ web/css/vendor-overrides/gridjs.css | 13 + 23 files changed, 1971 insertions(+), 6 deletions(-) create mode 100644 db/updates/2026-04-16-helpdesk-updates-permissions.sql create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Repository/UpdateRepository.php create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/UpdateService.php create mode 100644 modules/helpdesk/migrations/008_create_updates.sql create mode 100644 modules/helpdesk/pages/helpdesk/update-domains-data().php create mode 100644 modules/helpdesk/pages/helpdesk/updates-data().php create mode 100644 modules/helpdesk/pages/helpdesk/updates/assign().php create mode 100644 modules/helpdesk/pages/helpdesk/updates/filter-schema.php create mode 100644 modules/helpdesk/pages/helpdesk/updates/index().php create mode 100644 modules/helpdesk/pages/helpdesk/updates/index(default).phtml create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/UpdateServiceTest.php create mode 100644 modules/helpdesk/web/js/pages/helpdesk-updates-index.js diff --git a/db/init/init.sql b/db/init/init.sql index 01d36d1..074385e 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1552,7 +1552,8 @@ JOIN permissions p ON p.`key` IN ( 'stats.view', 'system_info.view', 'roles.assign_all', 'helpdesk.access', 'helpdesk.settings.manage', 'helpdesk.team-workload.view', 'helpdesk.risk-radar.view', 'helpdesk.software-products.manage', - 'helpdesk.handovers.view', 'helpdesk.handovers.create', 'helpdesk.handovers.manage' + 'helpdesk.handovers.view', 'helpdesk.handovers.create', 'helpdesk.handovers.manage', + 'helpdesk.updates.view', 'helpdesk.updates.manage' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/db/updates/2026-04-16-helpdesk-updates-permissions.sql b/db/updates/2026-04-16-helpdesk-updates-permissions.sql new file mode 100644 index 0000000..2861196 --- /dev/null +++ b/db/updates/2026-04-16-helpdesk-updates-permissions.sql @@ -0,0 +1,16 @@ +-- Helpdesk module: assign updates permissions to Admin role. +-- Idempotent: safe to run multiple times. + +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.updates.view' +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; + +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.updates.manage' +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index fed4cd3..74a6df1 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -381,6 +381,7 @@ "Define the fields that make up the handover protocol for this product.": "Definieren Sie die Felder, die das Übergabeprotokoll für dieses Produkt bilden.", "Add field": "Feld hinzufügen", "Remove field": "Feld entfernen", + "Duplicate field": "Feld duplizieren", "Move up": "Nach oben", "Move down": "Nach unten", "Field key": "Feld-Schlüssel", @@ -469,6 +470,7 @@ "Restore this version": "Diese Version wiederherstellen", "Restore this version?": "Diese Version wiederherstellen?", "Version restored": "Version wiederhergestellt", + "Failed to restore": "Wiederherstellen fehlgeschlagen", "Only managers can restore revisions": "Nur Manager können Versionen wiederherstellen", "Revision not found": "Version nicht gefunden", "Created": "Erstellt", @@ -480,5 +482,63 @@ "Yes": "Ja", "No": "Nein", "Changed": "Geändert", - "Compare with current": "Mit aktueller Version vergleichen" + "Compare with current": "Mit aktueller Version vergleichen", + "Domain": "Domain", + "Select a customer first...": "Bitte zuerst einen Kunden auswählen...", + "Loading domains...": "Domains werden geladen...", + "No domains found for this customer": "Keine Domains für diesen Kunden gefunden", + "Failed to load domains": "Domains konnten nicht geladen werden", + "Please select a domain": "Bitte wählen Sie eine Domain", + "Delete": "Löschen", + "Select all": "Alle auswählen", + "Delete selected handovers?": "Ausgewählte Übergaben löschen?", + "%d handover(s) deleted": "%d Übergabe(n) gelöscht", + "Failed to delete handovers": "Übergaben konnten nicht gelöscht werden", + "Only managers can delete handovers": "Nur Manager können Übergaben löschen", + "No handovers selected": "Keine Übergaben ausgewählt", + "Domain not found": "Domain nicht gefunden", + "Domain detail": "Domain-Details", + "Back to domains": "Zurück zu Domains", + "Open domain": "Domain öffnen", + "Open debitor": "Debitor öffnen", + "Customer information": "Kundeninformation", + "Contract information": "Vertragsinformation", + "Related domains": "Verwandte Domains", + "No handovers for this domain yet": "Noch keine Übergaben für diese Domain", + "No contract information available": "Keine Vertragsinformation verfügbar", + "Network error — please try again": "Netzwerkfehler — bitte erneut versuchen", + "Contract state": "Vertragsstatus", + "Total": "Gesamt", + "URL": "URL", + "State": "Status", + "Product": "Produkt", + "Updates": "Updates", + "Ticket status": "Ticketstatus", + "Assignment": "Zuweisung", + "Resolved": "Erledigt", + "Closed": "Geschlossen", + "Open": "Offen", + "Assign update": "Update zuweisen", + "Assign": "Zuweisen", + "Unassigned": "Nicht zugewiesen", + "Assigned": "Zugewiesen", + "Update": "Update", + "Hotfix": "Hotfix", + "Gitea link": "Gitea-Link", + "e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02": "z.B. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02", + "Notes": "Notizen", + "No update tickets found": "Keine Update-Tickets gefunden", + "Failed to load update tickets": "Update-Tickets konnten nicht geladen werden", + "Update assigned successfully": "Update erfolgreich zugewiesen", + "Failed to assign update": "Update konnte nicht zugewiesen werden", + "Select domain...": "Domain auswählen...", + "Loading domains...": "Domains werden geladen...", + "No domains found for this customer": "Keine Domains für diesen Kunden gefunden", + "No updates for this domain yet": "Noch keine Updates für diese Domain", + "Search updates...": "Updates suchen...", + "Open in Gitea": "In Gitea öffnen", + "Invalid category": "Ungültige Kategorie", + "Ticket number is required": "Ticket-Nummer ist erforderlich", + "View software updates list": "Software-Updates-Liste anzeigen", + "Assign and manage software updates": "Software-Updates zuweisen und verwalten" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 869c3ea..bcb88c0 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -381,6 +381,7 @@ "Define the fields that make up the handover protocol for this product.": "Define the fields that make up the handover protocol for this product.", "Add field": "Add field", "Remove field": "Remove field", + "Duplicate field": "Duplicate field", "Move up": "Move up", "Move down": "Move down", "Field key": "Field key", @@ -469,6 +470,7 @@ "Restore this version": "Restore this version", "Restore this version?": "Restore this version?", "Version restored": "Version restored", + "Failed to restore": "Failed to restore", "Only managers can restore revisions": "Only managers can restore revisions", "Revision not found": "Revision not found", "Created": "Created", @@ -480,5 +482,63 @@ "Yes": "Yes", "No": "No", "Changed": "Changed", - "Compare with current": "Compare with current" + "Compare with current": "Compare with current", + "Domain": "Domain", + "Select a customer first...": "Select a customer first...", + "Loading domains...": "Loading domains...", + "No domains found for this customer": "No domains found for this customer", + "Failed to load domains": "Failed to load domains", + "Please select a domain": "Please select a domain", + "Delete": "Delete", + "Select all": "Select all", + "Delete selected handovers?": "Delete selected handovers?", + "%d handover(s) deleted": "%d handover(s) deleted", + "Failed to delete handovers": "Failed to delete handovers", + "Only managers can delete handovers": "Only managers can delete handovers", + "No handovers selected": "No handovers selected", + "Domain not found": "Domain not found", + "Domain detail": "Domain detail", + "Back to domains": "Back to domains", + "Open domain": "Open domain", + "Open debitor": "Open debitor", + "Customer information": "Customer information", + "Contract information": "Contract information", + "Related domains": "Related domains", + "No handovers for this domain yet": "No handovers for this domain yet", + "No contract information available": "No contract information available", + "Network error — please try again": "Network error — please try again", + "Contract state": "Contract state", + "Total": "Total", + "URL": "URL", + "State": "State", + "Product": "Product", + "Updates": "Updates", + "Ticket status": "Ticket status", + "Assignment": "Assignment", + "Resolved": "Resolved", + "Closed": "Closed", + "Open": "Open", + "Assign update": "Assign update", + "Assign": "Assign", + "Unassigned": "Unassigned", + "Assigned": "Assigned", + "Update": "Update", + "Hotfix": "Hotfix", + "Gitea link": "Gitea link", + "e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02": "e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02", + "Notes": "Notes", + "No update tickets found": "No update tickets found", + "Failed to load update tickets": "Failed to load update tickets", + "Update assigned successfully": "Update assigned successfully", + "Failed to assign update": "Failed to assign update", + "Select domain...": "Select domain...", + "Loading domains...": "Loading domains...", + "No domains found for this customer": "No domains found for this customer", + "No updates for this domain yet": "No updates for this domain yet", + "Search updates...": "Search updates...", + "Open in Gitea": "Open in Gitea", + "Invalid category": "Invalid category", + "Ticket number is required": "Ticket number is required", + "View software updates list": "View software updates list", + "Assign and manage software updates": "Assign and manage software updates" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php index 1110652..d451f8a 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php @@ -16,6 +16,10 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public const ABILITY_HANDOVERS_VIEW = 'helpdesk.handovers.view'; public const ABILITY_HANDOVERS_CREATE = 'helpdesk.handovers.create'; public const ABILITY_HANDOVERS_MANAGE = 'helpdesk.handovers.manage'; + /** @api Used in pages/helpdesk/updates action files */ + public const ABILITY_UPDATES_VIEW = 'helpdesk.updates.view'; + /** @api Used in pages/helpdesk/updates action files */ + public const ABILITY_UPDATES_MANAGE = 'helpdesk.updates.manage'; public const PERMISSION_ACCESS = 'helpdesk.access'; public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage'; @@ -29,6 +33,10 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public const PERMISSION_HANDOVERS_CREATE = 'helpdesk.handovers.create'; /** @api Used in authorize() match for ability resolution */ public const PERMISSION_HANDOVERS_MANAGE = 'helpdesk.handovers.manage'; + /** @api Used in authorize() match */ + public const PERMISSION_UPDATES_VIEW = 'helpdesk.updates.view'; + /** @api Used in authorize() match */ + public const PERMISSION_UPDATES_MANAGE = 'helpdesk.updates.manage'; public function __construct( private readonly PermissionService $permissionService @@ -37,7 +45,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public function supports(string $ability): bool { - return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE, self::ABILITY_HANDOVERS_VIEW, self::ABILITY_HANDOVERS_CREATE, self::ABILITY_HANDOVERS_MANAGE], true); + return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE, self::ABILITY_HANDOVERS_VIEW, self::ABILITY_HANDOVERS_CREATE, self::ABILITY_HANDOVERS_MANAGE, self::ABILITY_UPDATES_VIEW, self::ABILITY_UPDATES_MANAGE], true); } public function authorize(string $ability, array $context = []): AuthorizationDecision @@ -56,6 +64,8 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface self::ABILITY_HANDOVERS_VIEW => self::PERMISSION_HANDOVERS_VIEW, self::ABILITY_HANDOVERS_CREATE => self::PERMISSION_HANDOVERS_CREATE, self::ABILITY_HANDOVERS_MANAGE => self::PERMISSION_HANDOVERS_MANAGE, + self::ABILITY_UPDATES_VIEW => self::PERMISSION_UPDATES_VIEW, + self::ABILITY_UPDATES_MANAGE => self::PERMISSION_UPDATES_MANAGE, default => null, }; diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index 403633a..d0ef304 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -9,6 +9,7 @@ use MintyPHP\Module\Helpdesk\Service\BcODataGateway; use MintyPHP\Module\Helpdesk\Service\BcSoapGateway; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; +use MintyPHP\Module\Helpdesk\Service\DomainDetailService; use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; @@ -23,8 +24,10 @@ use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler; use MintyPHP\Module\Helpdesk\Repository\HandoverRepository; use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository; use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository; +use MintyPHP\Module\Helpdesk\Repository\UpdateRepository; use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService; use MintyPHP\Module\Helpdesk\Service\HandoverService; +use MintyPHP\Module\Helpdesk\Service\UpdateService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Settings\SettingsMetadataGateway; @@ -86,6 +89,13 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(EffectiveHelpdeskSettingsService::class) )); + $container->set(DomainDetailService::class, static fn (AppContainer $c): DomainDetailService => new DomainDetailService( + $c->get(BcODataGateway::class), + $c->get(EffectiveHelpdeskSettingsService::class), + $c->get(HandoverRepository::class), + $c->get(UpdateRepository::class) + )); + $container->set(TicketCommunicationService::class, static fn (AppContainer $c): TicketCommunicationService => new TicketCommunicationService( $c->get(BcSoapGateway::class), $c->get(BcODataGateway::class) @@ -123,5 +133,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(SoftwareProductService::class), $c->get(HandoverRevisionService::class) )); + + $container->set(UpdateRepository::class, static fn (): UpdateRepository => new UpdateRepository()); + + $container->set(UpdateService::class, static fn (AppContainer $c): UpdateService => new UpdateService( + $c->get(UpdateRepository::class), + $c->get(BcODataGateway::class) + )); } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php index caa5e99..f976306 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php @@ -25,6 +25,7 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider $canViewHandovers = $authorizationService->authorize('helpdesk.handovers.view', $actorContext)->isAllowed(); $canCreateHandovers = $authorizationService->authorize('helpdesk.handovers.create', $actorContext)->isAllowed(); $canManageHandovers = $authorizationService->authorize('helpdesk.handovers.manage', $actorContext)->isAllowed(); + $canViewUpdates = $authorizationService->authorize('helpdesk.updates.view', $actorContext)->isAllowed(); } catch (\Throwable) { $canManageSettings = false; $canViewTeam = false; @@ -33,6 +34,7 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider $canViewHandovers = false; $canCreateHandovers = false; $canManageHandovers = false; + $canViewUpdates = false; } return ['helpdesk.nav' => [ @@ -43,6 +45,7 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider 'can_view_handovers' => $canViewHandovers, 'can_create_handovers' => $canCreateHandovers, 'can_manage_handovers' => $canManageHandovers, + 'can_view_updates' => $canViewUpdates, ]]; } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/UpdateRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/UpdateRepository.php new file mode 100644 index 0000000..02c36d8 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/UpdateRepository.php @@ -0,0 +1,179 @@ + $data + * @return int|null Inserted ID or null on failure + */ + public function insert(array $data): ?int + { + $result = DB::insert( + 'INSERT INTO helpdesk_updates ' + . '(tenant_id, ticket_no, category_code, debitor_no, debitor_name, domain_no, domain_url, gitea_path, notes, status, created_by) ' + . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + (string) ($data['tenant_id'] ?? 0), + (string) ($data['ticket_no'] ?? ''), + (string) ($data['category_code'] ?? ''), + (string) ($data['debitor_no'] ?? ''), + (string) ($data['debitor_name'] ?? ''), + (string) ($data['domain_no'] ?? ''), + (string) ($data['domain_url'] ?? ''), + (string) ($data['gitea_path'] ?? ''), + $data['notes'] ?? null, + (string) ($data['status'] ?? 'open'), + (string) ($data['created_by'] ?? 0) + ); + + if ($result === false) { + return null; + } + + return (int) $result; + } + + public function findById(int $tenantId, int $id): ?array + { + if ($id <= 0) { + return null; + } + + $row = DB::selectOne( + 'SELECT u.*,' + . ' uc.display_name AS created_by_label, uc.uuid AS created_by_uuid,' + . ' uu.display_name AS updated_by_label, uu.uuid AS updated_by_uuid' + . ' FROM helpdesk_updates u' + . ' LEFT JOIN users uc ON uc.id = u.created_by' + . ' LEFT JOIN users uu ON uu.id = u.updated_by' + . ' WHERE u.id = ? AND u.tenant_id = ? LIMIT 1', + (string) $id, + (string) $tenantId + ); + + return $this->normalizeRow($row); + } + + public function findByTicketNo(int $tenantId, string $ticketNo): ?array + { + if ($ticketNo === '') { + return null; + } + + $row = DB::selectOne( + 'SELECT u.*,' + . ' uc.display_name AS created_by_label' + . ' FROM helpdesk_updates u' + . ' LEFT JOIN users uc ON uc.id = u.created_by' + . ' WHERE u.tenant_id = ? AND u.ticket_no = ? LIMIT 1', + (string) $tenantId, + $ticketNo + ); + + return $this->normalizeRow($row); + } + + /** + * @return list> + */ + public function findByDomainNo(int $tenantId, string $domainNo): array + { + if ($domainNo === '') { + return []; + } + + $rows = DB::select( + 'SELECT u.*,' + . ' uc.display_name AS created_by_label' + . ' FROM helpdesk_updates u' + . ' LEFT JOIN users uc ON uc.id = u.created_by' + . ' WHERE u.tenant_id = ? AND u.domain_no = ?' + . ' ORDER BY u.created_at DESC', + (string) $tenantId, + $domainNo + ); + + return $this->normalizeRows($rows); + } + + /** + * @return list> + */ + public function findAllByTenant(int $tenantId): array + { + $rows = DB::select( + 'SELECT u.ticket_no, u.domain_no, u.domain_url, u.gitea_path, u.notes, u.status,' + . ' u.category_code, u.debitor_no, u.id' + . ' FROM helpdesk_updates u' + . ' WHERE u.tenant_id = ?', + (string) $tenantId + ); + + return $this->normalizeRows($rows); + } + + /** + * @param array $data + */ + public function updateAssignment(int $tenantId, int $id, array $data): bool + { + $result = DB::update( + 'UPDATE helpdesk_updates SET domain_no = ?, domain_url = ?, gitea_path = ?, notes = ?, status = ?, updated_by = ? WHERE id = ? AND tenant_id = ?', + (string) ($data['domain_no'] ?? ''), + (string) ($data['domain_url'] ?? ''), + (string) ($data['gitea_path'] ?? ''), + $data['notes'] ?? null, + (string) ($data['status'] ?? 'assigned'), + (string) ($data['updated_by'] ?? 0), + (string) $id, + (string) $tenantId + ); + + return $result !== false; + } + + /** + * @param mixed $rows + * @return list> + */ + private function normalizeRows(mixed $rows): array + { + $normalized = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $item = $this->normalizeRow($row); + if ($item !== null) { + $normalized[] = $item; + } + } + } + + return $normalized; + } + + private function normalizeRow(mixed $row): ?array + { + if (!is_array($row)) { + return null; + } + + $item = []; + foreach ($row as $key => $value) { + if (is_array($value)) { + $item = array_merge($item, $value); + } else { + $item[$key] = $value; + } + } + + if (!isset($item['id']) && !isset($item['ticket_no'])) { + return null; + } + + return $item; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index e43234e..1ba48b8 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -24,6 +24,8 @@ class BcODataGateway public const ENTITY_MEETINGS = 'FS_Debitor_Meetings'; /** @api Used by listContractTypes() */ public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types'; + /** @api Used by handover-domains-data endpoint and domains-data endpoint */ + public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains'; private const CONNECT_TIMEOUT = 10; private const REQUEST_TIMEOUT = 30; @@ -687,6 +689,62 @@ class BcODataGateway return ['tickets' => $allTickets, 'truncated' => $truncated]; } + /** + * Fetch all update/hotfix tickets across all customers. + * + * Queries PBI_LV_Tickets with Category_1_Code filter for UPDATE and UPDATE-HF. + * Results are cached in session with standard TTL. + * Uses pagination via @odata.nextLink (same as risk radar). + * + * @return array{tickets: array>, truncated: bool} + */ + public function fetchUpdateTickets(): array + { + $tenantScope = DebitorCacheControl::resolveTenantScope($this->sessionStore->all()); + $cacheKey = DebitorCacheControl::updateTicketsKey($tenantScope); + $cached = $this->sessionStore->get($cacheKey); + if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < DebitorCacheControl::TTL_STANDARD_SECONDS) { + $tickets = $cached['tickets'] ?? []; + $truncated = (bool) ($cached['truncated'] ?? false); + if (is_array($tickets)) { + return ['tickets' => $tickets, 'truncated' => $truncated]; + } + } + + $select = 'No,Description,Customer_No,Cust_Name,Category_1_Code,Ticket_State,Created_On,Last_Activity_Date,Support_User_Name'; + $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV); + + // BC OData often rejects `or` in $filter — fetch each category separately and merge. + $allTickets = []; + $truncated = false; + foreach (['UPDATE', 'UPDATE-HF'] as $category) { + $filter = "Category_1_Code eq '" . $category . "'"; + $url = $baseUrl + . '?$top=500' + . '&$select=' . rawurlencode($select) + . '&$filter=' . rawurlencode($filter) + . '&$orderby=' . rawurlencode('Created_On desc'); + + $partial = $this->paginateOData($url, 10); + foreach ($partial['tickets'] as $ticket) { + $allTickets[] = $ticket; + } + if ($partial['truncated']) { + $truncated = true; + } + } + + $result = ['tickets' => $this->dedupeRowsByNo($allTickets), 'truncated' => $truncated]; + + $this->sessionStore->set($cacheKey, [ + 'tickets' => $result['tickets'], + 'truncated' => $result['truncated'], + 'fetched_at' => time(), + ]); + + return $result; + } + /** * Get contracts for a customer by customer number. * @@ -798,6 +856,116 @@ class BcODataGateway return $this->extractODataValues($response); } + /** + * Get all domains (unfiltered, for the domains list page). + * + * @api Called from domains-data endpoint + * @return array> + */ + public function listDomains(): array + { + $select = 'No,Customer_No,Customer_Name,URL,State,Administration'; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS) + . '?$top=5000' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('Customer_Name asc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get all contract lines of type "Domain". + * + * Returns contract line metadata for domain-type lines, + * keyed by DNS number (No field). Used to enrich the domain + * list with contract type information (PI_Header_Type). + * + * @return array> + */ + public function listDomainContractLines(): array + { + $filter = "Type eq 'Domain'"; + $select = 'No,Header_No,PI_Header_Type,PI_Header_Description,PI_Header_State,Line_State'; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES) + . '?$filter=' . rawurlencode($filter) + . '&$top=5000' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('No asc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get a single domain by its domain number. + * + * @api Called from domain($id) action + * @return array|null + */ + public function getDomain(string $domainNo): ?array + { + $domainNo = trim($domainNo); + if ($domainNo === '') { + return null; + } + + $escaped = $this->escapeODataStrictUserInput($domainNo); + $filter = "No eq '" . $escaped . "'"; + $select = 'No,Customer_No,Customer_Name,URL,State,Administration'; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS) + . '?$filter=' . rawurlencode($filter) + . '&$top=1' + . '&$select=' . rawurlencode($select); + + $response = $this->request('GET', $url); + if ($response === null) { + return null; + } + + $values = $this->extractODataValues($response); + + return $values[0] ?? null; + } + + /** + * Get active domains for a customer. + * + * @api Called from handover-domains-data endpoint + * @return array> + */ + public function getDomainsForCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataStrictUserInput($customerNo); + $filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'"; + $select = 'No,Customer_No,URL,State'; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS) + . '?$filter=' . rawurlencode($filter) + . '&$top=200' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('URL asc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + /** * Get meetings/appointments for a customer. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php index 07e6998..61f2eb0 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php @@ -97,6 +97,12 @@ final class DebitorCacheControl return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays; } + /** @api Used by BcODataGateway::fetchUpdateTickets() */ + public static function updateTicketsKey(string $tenantScope): string + { + return 'module.helpdesk.update-tickets.v1.' . $tenantScope; + } + /** * @return array */ diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/UpdateService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/UpdateService.php new file mode 100644 index 0000000..3408b03 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/UpdateService.php @@ -0,0 +1,270 @@ + $filters Optional filters: status, category + * @return array{rows: list>, truncated: bool} + */ + public function getUpdatesList(int $tenantId, array $filters = []): array + { + $bcResult = $this->bcGateway->fetchUpdateTickets(); + $bcTickets = $bcResult['tickets']; + $truncated = $bcResult['truncated']; + + $localRows = $this->repository->findAllByTenant($tenantId); + $localByTicketNo = []; + foreach ($localRows as $local) { + $ticketNo = trim((string) ($local['ticket_no'] ?? '')); + if ($ticketNo !== '') { + $localByTicketNo[$ticketNo] = $local; + } + } + + $filterStatus = trim((string) ($filters['status'] ?? '')); + $filterCategory = trim((string) ($filters['category'] ?? '')); + $filterTicketState = trim((string) ($filters['ticket_state'] ?? '')); + $filterSearch = trim((string) ($filters['search'] ?? '')); + + $merged = []; + foreach ($bcTickets as $ticket) { + $ticketNo = trim((string) ($ticket['No'] ?? '')); + if ($ticketNo === '') { + continue; + } + + $categoryCode = trim((string) ($ticket['Category_1_Code'] ?? '')); + if (!in_array($categoryCode, self::UPDATE_CATEGORIES, true)) { + continue; + } + + $local = $localByTicketNo[$ticketNo] ?? null; + $status = $local !== null ? (string) ($local['status'] ?? self::STATUS_OPEN) : self::STATUS_OPEN; + $ticketState = trim((string) ($ticket['Ticket_State'] ?? '')); + + // Apply filters + if ($filterStatus !== '' && $filterStatus !== 'all' && $status !== $filterStatus) { + continue; + } + if ($filterCategory !== '' && $filterCategory !== 'all' && $categoryCode !== $filterCategory) { + continue; + } + if ($filterTicketState !== '' && $filterTicketState !== 'all' && $ticketState !== $filterTicketState) { + continue; + } + + $debitorNo = trim((string) ($ticket['Customer_No'] ?? '')); + $debitorName = trim((string) ($ticket['Cust_Name'] ?? '')); + + // Search filter: match against ticket no, debitor name/no, domain url, gitea path + if ($filterSearch !== '') { + $searchHaystack = strtolower($ticketNo . ' ' . $debitorName . ' ' . $debitorNo . ' ' . ($local['domain_url'] ?? '') . ' ' . ($local['gitea_path'] ?? '')); + if (strpos($searchHaystack, strtolower($filterSearch)) === false) { + continue; + } + } + + $merged[] = [ + 'ticket_no' => $ticketNo, + 'ticket_description' => trim((string) ($ticket['Description'] ?? '')), + 'category_code' => $categoryCode, + 'debitor_no' => $debitorNo, + 'debitor_name' => $debitorName, + 'ticket_state' => trim((string) ($ticket['Ticket_State'] ?? '')), + 'support_user' => trim((string) ($ticket['Support_User_Name'] ?? '')), + 'created_on' => trim((string) ($ticket['Created_On'] ?? '')), + 'last_activity' => trim((string) ($ticket['Last_Activity_Date'] ?? '')), + 'domain_no' => (string) ($local['domain_no'] ?? ''), + 'domain_url' => (string) ($local['domain_url'] ?? ''), + 'gitea_path' => (string) ($local['gitea_path'] ?? ''), + 'notes' => (string) ($local['notes'] ?? ''), + 'status' => $status, + 'local_id' => $local !== null ? (int) ($local['id'] ?? 0) : null, + ]; + } + + return ['rows' => $merged, 'truncated' => $truncated]; + } + + /** + * Assign a domain and gitea path to an update ticket. + * + * Creates a new helpdesk_updates record or updates an existing one. + * + * @return array{ok: bool, id: int|null, errors: array} + */ + public function assignUpdate( + int $tenantId, + string $ticketNo, + string $debitorNo, + string $debitorName, + string $categoryCode, + string $domainNo, + string $domainUrl, + string $giteaPath, + string $notes, + int $userId, + ): array { + $errors = []; + + $ticketNo = trim($ticketNo); + if ($ticketNo === '') { + $errors['ticket_no'] = t('Ticket number is required'); + } + + $debitorNo = trim($debitorNo); + if ($debitorNo === '') { + $errors['debitor_no'] = t('Debitor number is required'); + } + + $categoryCode = trim($categoryCode); + if (!in_array($categoryCode, self::UPDATE_CATEGORIES, true)) { + $errors['category_code'] = t('Invalid category'); + } + + if ($errors !== []) { + return ['ok' => false, 'id' => null, 'errors' => $errors]; + } + + $domainNo = trim($domainNo); + $domainUrl = trim($domainUrl); + $giteaPath = trim($giteaPath); + $notes = trim($notes); + + $status = ($domainNo !== '' || $giteaPath !== '') ? self::STATUS_ASSIGNED : self::STATUS_OPEN; + + $existing = $this->repository->findByTicketNo($tenantId, $ticketNo); + + if ($existing !== null) { + $existingId = (int) ($existing['id'] ?? 0); + if ($existingId <= 0) { + return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to save')]]; + } + + $updated = $this->repository->updateAssignment($tenantId, $existingId, [ + 'domain_no' => $domainNo, + 'domain_url' => $domainUrl, + 'gitea_path' => $giteaPath, + 'notes' => $notes, + 'status' => $status, + 'updated_by' => $userId, + ]); + + if (!$updated) { + return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to save')]]; + } + + return ['ok' => true, 'id' => $existingId, 'errors' => []]; + } + + $id = $this->repository->insert([ + 'tenant_id' => $tenantId, + 'ticket_no' => $ticketNo, + 'category_code' => $categoryCode, + 'debitor_no' => $debitorNo, + 'debitor_name' => trim($debitorName), + 'domain_no' => $domainNo, + 'domain_url' => $domainUrl, + 'gitea_path' => $giteaPath, + 'notes' => $notes, + 'status' => $status, + 'created_by' => $userId, + ]); + + if ($id === null) { + return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to save')]]; + } + + return ['ok' => true, 'id' => $id, 'errors' => []]; + } + + public static function statusLabel(string $status): string + { + return match ($status) { + self::STATUS_OPEN => t('Unassigned'), + self::STATUS_ASSIGNED => t('Assigned'), + default => $status, + }; + } + + public static function statusVariant(string $status): string + { + return match ($status) { + self::STATUS_OPEN => 'warning', + self::STATUS_ASSIGNED => 'success', + default => 'neutral', + }; + } + + public static function ticketStateLabel(string $state): string + { + return match ($state) { + 'Offen' => t('Open'), + 'In Bearbeitung' => t('In progress'), + 'Erledigt' => t('Resolved'), + 'Geschlossen' => t('Closed'), + default => $state !== '' ? $state : '—', + }; + } + + public static function ticketStateVariant(string $state): string + { + return match ($state) { + 'Offen' => 'warning', + 'In Bearbeitung' => 'info', + 'Erledigt' => 'success', + 'Geschlossen' => 'neutral', + default => 'neutral', + }; + } + + public static function categoryLabel(string $code): string + { + return match ($code) { + self::CATEGORY_UPDATE => t('Update'), + self::CATEGORY_HOTFIX => t('Hotfix'), + default => $code, + }; + } + + public static function categoryVariant(string $code): string + { + return match ($code) { + self::CATEGORY_UPDATE => 'neutral', + self::CATEGORY_HOTFIX => 'warning', + default => 'neutral', + }; + } +} diff --git a/modules/helpdesk/migrations/008_create_updates.sql b/modules/helpdesk/migrations/008_create_updates.sql new file mode 100644 index 0000000..1bc00e3 --- /dev/null +++ b/modules/helpdesk/migrations/008_create_updates.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS `helpdesk_updates` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tenant_id` INT UNSIGNED NOT NULL, + `ticket_no` VARCHAR(30) NOT NULL, + `category_code` VARCHAR(30) NOT NULL, + `debitor_no` VARCHAR(30) NOT NULL, + `debitor_name` VARCHAR(255) NOT NULL DEFAULT '', + `domain_no` VARCHAR(30) NOT NULL DEFAULT '', + `domain_url` VARCHAR(255) NOT NULL DEFAULT '', + `gitea_path` VARCHAR(255) NOT NULL DEFAULT '', + `notes` TEXT NULL DEFAULT NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'open', + `created_by` INT UNSIGNED NOT NULL, + `updated_by` INT UNSIGNED NULL DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_hu_ticket` (`tenant_id`, `ticket_no`), + KEY `idx_hu_tenant` (`tenant_id`), + KEY `idx_hu_domain` (`tenant_id`, `domain_no`), + KEY `idx_hu_status` (`tenant_id`, `status`), + KEY `idx_hu_debitor` (`tenant_id`, `debitor_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index c799365..a216947 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -17,6 +17,8 @@ return [ ['path' => 'helpdesk/search-data', 'target' => 'helpdesk/search-data'], ['path' => 'helpdesk/domains', 'target' => 'helpdesk/domains'], ['path' => 'helpdesk/domains-data', 'target' => 'helpdesk/domains-data'], + ['path' => 'helpdesk/domain/{id}', 'target' => 'helpdesk/domain'], + ['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'], ['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'], ['path' => 'helpdesk/ticket/{id}', 'target' => 'helpdesk/ticket'], ['path' => 'helpdesk/settings', 'target' => 'helpdesk/settings'], @@ -46,7 +48,13 @@ return [ ['path' => 'helpdesk/handovers-data', 'target' => 'helpdesk/handovers-data'], ['path' => 'helpdesk/handovers/create', 'target' => 'helpdesk/handovers/create'], ['path' => 'helpdesk/handovers/edit/{id}', 'target' => 'helpdesk/handovers/edit'], + ['path' => 'helpdesk/handovers/bulk/{action}', 'target' => 'helpdesk/handovers/bulk'], ['path' => 'helpdesk/debitor-lookup-data', 'target' => 'helpdesk/debitor-lookup-data'], + ['path' => 'helpdesk/handover-domains-data', 'target' => 'helpdesk/handover-domains-data'], + ['path' => 'helpdesk/updates', 'target' => 'helpdesk/updates'], + ['path' => 'helpdesk/updates-data', 'target' => 'helpdesk/updates-data'], + ['path' => 'helpdesk/updates/assign', 'target' => 'helpdesk/updates/assign'], + ['path' => 'helpdesk/update-domains-data', 'target' => 'helpdesk/update-domains-data'], ], 'public_paths' => [], @@ -124,6 +132,18 @@ return [ 'active' => true, 'is_system' => true, ], + [ + 'key' => 'helpdesk.updates.view', + 'description' => 'View software updates list', + 'active' => true, + 'is_system' => true, + ], + [ + 'key' => 'helpdesk.updates.manage', + 'description' => 'Assign and manage software updates', + 'active' => true, + 'is_system' => true, + ], ], 'search_resources' => [], diff --git a/modules/helpdesk/pages/helpdesk/update-domains-data().php b/modules/helpdesk/pages/helpdesk/update-domains-data().php new file mode 100644 index 0000000..ea36483 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/update-domains-data().php @@ -0,0 +1,44 @@ +query('debitor_no', '')); + +if ($debitorNo === '') { + Router::json([]); + + return; +} + +try { + $domains = app(BcODataGateway::class)->getDomainsForCustomer($debitorNo); +} catch (\Throwable) { + Router::json([]); + + return; +} + +$items = []; +foreach ($domains as $domain) { + $no = trim((string) ($domain['No'] ?? '')); + $url = trim((string) ($domain['URL'] ?? '')); + if ($no === '') { + continue; + } + + $items[] = [ + 'value' => $no, + 'label' => $url !== '' ? $url : $no, + 'url' => $url, + ]; +} + +Router::json($items); diff --git a/modules/helpdesk/pages/helpdesk/updates-data().php b/modules/helpdesk/pages/helpdesk/updates-data().php new file mode 100644 index 0000000..1a9961b --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/updates-data().php @@ -0,0 +1,98 @@ + trim((string) $request->query('status', '')), + 'category' => trim((string) $request->query('category', '')), + 'ticket_state' => trim((string) $request->query('ticket_state', '')), + 'search' => trim((string) $request->query('search', '')), +]; + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); + +$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', '')); +if ($refreshRequested) { + $tenantScope = DebitorCacheControl::resolveTenantScope($session); + $sessionStore->remove(DebitorCacheControl::updateTicketsKey($tenantScope)); +} + +$service = app(UpdateService::class); + +try { + $result = $service->getUpdatesList($tenantId, $filters); +} catch (\Throwable) { + Router::json(['rows' => [], 'total' => 0, 'error' => true]); + + return; +} + +$rows = $result['rows'] ?? []; + +$debitorBaseUrl = lurl('helpdesk/debitor/'); +$ticketBaseUrl = lurl('helpdesk/ticket/'); + +$formatBcDate = static function (string $raw): string { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + try { + return (new \DateTimeImmutable($raw))->format('d.m.Y H:i'); + } catch (\Throwable) { + return $raw; + } +}; + +$preparedRows = []; +foreach ($rows as $row) { + $ticketNo = (string) ($row['ticket_no'] ?? ''); + $status = (string) ($row['status'] ?? 'open'); + $categoryCode = (string) ($row['category_code'] ?? ''); + $debitorNo = (string) ($row['debitor_no'] ?? ''); + + $ticketState = (string) ($row['ticket_state'] ?? ''); + + $preparedRows[] = [ + 'ticket_no' => $ticketNo, + 'ticket_url' => $ticketNo !== '' ? $ticketBaseUrl . rawurlencode($ticketNo) : '', + 'ticket_description' => (string) ($row['ticket_description'] ?? ''), + 'category_code' => $categoryCode, + 'category_label' => UpdateService::categoryLabel($categoryCode), + 'category_variant' => UpdateService::categoryVariant($categoryCode), + 'debitor_no' => $debitorNo, + 'debitor_name' => (string) ($row['debitor_name'] ?? ''), + 'debitor_url' => $debitorNo !== '' ? $debitorBaseUrl . rawurlencode($debitorNo) : '', + 'domain_no' => (string) ($row['domain_no'] ?? ''), + 'domain_url' => (string) ($row['domain_url'] ?? ''), + 'gitea_path' => (string) ($row['gitea_path'] ?? ''), + 'notes' => (string) ($row['notes'] ?? ''), + 'created_on' => $formatBcDate((string) ($row['created_on'] ?? '')), + 'ticket_state' => $ticketState, + 'ticket_state_label' => UpdateService::ticketStateLabel($ticketState), + 'ticket_state_variant' => UpdateService::ticketStateVariant($ticketState), + 'status' => $status, + 'status_label' => UpdateService::statusLabel($status), + 'status_variant' => UpdateService::statusVariant($status), + 'local_id' => $row['local_id'] ?? null, + ]; +} + +Router::json([ + 'rows' => $preparedRows, + 'total' => count($preparedRows), + 'truncated' => $result['truncated'] ?? false, +]); diff --git a/modules/helpdesk/pages/helpdesk/updates/assign().php b/modules/helpdesk/pages/helpdesk/updates/assign().php new file mode 100644 index 0000000..5aceb44 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/updates/assign().php @@ -0,0 +1,44 @@ +bodyAll(); + +// CSRF: Router skips automatic check for AJAX (X-Requested-With header). +// For non-AJAX POST, verify manually. +if ($request->isMethod('POST') && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) { + if (!Session::checkCsrfToken()) { + Router::json(['ok' => false, 'errors' => ['general' => 'Invalid CSRF token']]); + + return; + } +} + +$session = app(SessionStoreInterface::class)->all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); +$userId = (int) ($session['user']['id'] ?? 0); + +$service = app(UpdateService::class); +$result = $service->assignUpdate( + $tenantId, + (string) ($body['ticket_no'] ?? ''), + (string) ($body['debitor_no'] ?? ''), + (string) ($body['debitor_name'] ?? ''), + (string) ($body['category_code'] ?? ''), + (string) ($body['domain_no'] ?? ''), + (string) ($body['domain_url'] ?? ''), + (string) ($body['gitea_path'] ?? ''), + (string) ($body['notes'] ?? ''), + $userId +); + +Router::json($result); diff --git a/modules/helpdesk/pages/helpdesk/updates/filter-schema.php b/modules/helpdesk/pages/helpdesk/updates/filter-schema.php new file mode 100644 index 0000000..485a518 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/updates/filter-schema.php @@ -0,0 +1,65 @@ + [ + 'search' => ['type' => 'string'], + 'status' => ['type' => 'string', 'default' => 'all'], + 'category' => ['type' => 'string', 'default' => 'all'], + 'ticket_state' => ['type' => 'string', 'default' => 'all'], + ], + 'toolbar' => [ + [ + 'key' => 'search', + 'type' => 'text', + 'label' => 'Search', + 'placeholder' => 'Search updates...', + 'input_id' => 'helpdesk-updates-search-input', + 'search' => true, + 'query' => ['type' => 'string'], + ], + [ + 'key' => 'status', + 'type' => 'select', + 'label' => 'Status', + 'input_id' => 'helpdesk-updates-status-filter', + 'default' => 'all', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => 'all', 'description' => 'All'], + ['id' => 'open', 'description' => 'Unassigned'], + ['id' => 'assigned', 'description' => 'Assigned'], + ], + 'label_attributes' => ['data-filter-optional' => true], + ], + [ + 'key' => 'category', + 'type' => 'select', + 'label' => 'Type', + 'input_id' => 'helpdesk-updates-category-filter', + 'default' => 'all', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => 'all', 'description' => 'All'], + ['id' => 'UPDATE', 'description' => 'Update'], + ['id' => 'UPDATE-HF', 'description' => 'Hotfix'], + ], + 'label_attributes' => ['data-filter-optional' => true], + ], + [ + 'key' => 'ticket_state', + 'type' => 'select', + 'label' => 'Ticket status', + 'input_id' => 'helpdesk-updates-ticket-state-filter', + 'default' => 'all', + 'normalize' => 'all_to_empty', + 'allowed' => [ + ['id' => 'all', 'description' => 'All'], + ['id' => 'Offen', 'description' => 'Open'], + ['id' => 'In Bearbeitung', 'description' => 'In progress'], + ['id' => 'Erledigt', 'description' => 'Resolved'], + ['id' => 'Geschlossen', 'description' => 'Closed'], + ], + 'label_attributes' => ['data-filter-optional' => true], + ], + ], +]); diff --git a/modules/helpdesk/pages/helpdesk/updates/index().php b/modules/helpdesk/pages/helpdesk/updates/index().php new file mode 100644 index 0000000..c4329ff --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/updates/index().php @@ -0,0 +1,72 @@ +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']; +$schemaByKey = $listFilterContext['schemaByKey']; +$filterChipMeta = [ + 'search' => [ + 'label' => t('Search'), + 'type' => 'text', + ], + 'status' => [ + 'label' => t('Status'), + 'type' => 'select', + 'default' => (string) ($schemaByKey['status']['default'] ?? 'all'), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])), + ], + 'category' => [ + 'label' => t('Type'), + 'type' => 'select', + 'default' => (string) ($schemaByKey['category']['default'] ?? 'all'), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['category'] ?? [])), + ], + 'ticket_state' => [ + 'label' => t('Ticket status'), + 'type' => 'select', + 'default' => (string) ($schemaByKey['ticket_state']['default'] ?? 'all'), + 'options' => gridOptionMapFromAllowed((array) ($schemaByKey['ticket_state'] ?? [])), + ], +]; + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$authService = app(AuthorizationService::class); +$actorContext = ['actor_user_id' => $userId]; +$canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_UPDATES_MANAGE, $actorContext)->isAllowed(); + +$csrfKey = ''; +$csrfToken = ''; +if ($canManage) { + $csrfKey = Session::$csrfSessionKey; + $csrfToken = (string) ($session[$csrfKey] ?? ''); +} + +Buffer::set('title', t('Updates')); +Buffer::set('style_groups', json_encode(['helpdesk'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Updates')], +]; diff --git a/modules/helpdesk/pages/helpdesk/updates/index(default).phtml b/modules/helpdesk/pages/helpdesk/updates/index(default).phtml new file mode 100644 index 0000000..6412ba4 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/updates/index(default).phtml @@ -0,0 +1,114 @@ + + + + + + + +
+
+
+ + + +
+
+

+ +
+
+

+ + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + + + + diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index 859ee54..84f0502 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -7,15 +7,17 @@ $canViewTeam = !empty($helpdeskNav['can_view_team']); $canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']); $canManageSoftwareProducts = !empty($helpdeskNav['can_manage_software_products']); $canViewHandovers = !empty($helpdeskNav['can_view_handovers']); +$canViewUpdates = !empty($helpdeskNav['can_view_updates']); $settingsActive = navActive('helpdesk/settings', true); $teamActive = navActive('helpdesk/team', true); $riskRadarActive = navActive('helpdesk/risk-radar', true); -$domainsActive = navActive('helpdesk/domains', true); +$domainsActive = navActive(['helpdesk/domains', 'helpdesk/domain'], true); $softwareProductsActive = navActive('helpdesk/software-products', true); $handoversActive = navActive('helpdesk/handovers', true); +$updatesActive = navActive('helpdesk/updates', true); $customersActive = navActive('helpdesk', true); -if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive']) { +if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) { $customersActive = ['class' => '', 'aria' => '', 'isActive' => false]; } @@ -69,6 +71,12 @@ $helpdeskNavGroups = [ 'active' => $handoversActive, 'visible' => $canViewHandovers, ], + [ + 'label' => t('Updates'), + 'path' => 'helpdesk/updates', + 'active' => $updatesActive, + 'visible' => $canViewUpdates, + ], ], ], [ diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/UpdateServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/UpdateServiceTest.php new file mode 100644 index 0000000..34560c5 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/UpdateServiceTest.php @@ -0,0 +1,349 @@ +createMock(UpdateRepository::class); + $bcGateway = $bcGateway ?? $this->createMock(BcODataGateway::class); + + return new UpdateService($repository, $bcGateway); + } + + private function makeBcTicket(string $no = 'TK-001', string $category = 'UPDATE', string $customerNo = 'D10001', string $customerName = 'Acme Corp'): array + { + return [ + 'No' => $no, + 'Customer_No' => $customerNo, + 'Cust_Name' => $customerName, + 'Category_1_Code' => $category, + 'Ticket_State' => 'Offen', + 'Created_On' => '2026-04-01T10:00:00Z', + 'Last_Activity_Date' => '2026-04-15T14:30:00Z', + 'Support_User_Name' => 'Max Mustermann', + ]; + } + + // ── Assign tests ───────────────────────────────────────────────── + + public function testAssignUpdateHappyPath(): void + { + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findByTicketNo')->willReturn(null); + $repo->expects($this->once())->method('insert')->willReturn(1); + + $service = $this->createService($repo); + $result = $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + 'D10001', + 'Acme Corp', + 'UPDATE', + 'DNS00001', + 'example.com', + '2026-02-mysyde', + 'Initial update', + self::USER_ID + ); + + $this->assertTrue($result['ok']); + $this->assertSame(1, $result['id']); + $this->assertEmpty($result['errors']); + } + + public function testAssignUpdateUpdatesExistingRecord(): void + { + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findByTicketNo')->willReturn([ + 'id' => 5, + 'ticket_no' => 'TK-001', + 'status' => 'open', + ]); + $repo->expects($this->once())->method('updateAssignment')->willReturn(true); + $repo->expects($this->never())->method('insert'); + + $service = $this->createService($repo); + $result = $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + 'D10001', + 'Acme Corp', + 'UPDATE', + 'DNS00002', + 'new.example.com', + '2026-03-update', + 'Changed domain', + self::USER_ID + ); + + $this->assertTrue($result['ok']); + $this->assertSame(5, $result['id']); + } + + public function testAssignUpdateRejectsEmptyTicketNo(): void + { + $service = $this->createService(); + $result = $service->assignUpdate( + self::TENANT_ID, + '', + 'D10001', + 'Acme Corp', + 'UPDATE', + 'DNS00001', + 'example.com', + '', + '', + self::USER_ID + ); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('ticket_no', $result['errors']); + } + + public function testAssignUpdateRejectsEmptyDebitorNo(): void + { + $service = $this->createService(); + $result = $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + '', + 'Acme Corp', + 'UPDATE', + 'DNS00001', + 'example.com', + '', + '', + self::USER_ID + ); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('debitor_no', $result['errors']); + } + + public function testAssignUpdateRejectsInvalidCategory(): void + { + $service = $this->createService(); + $result = $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + 'D10001', + 'Acme Corp', + 'BUGFIX', + 'DNS00001', + 'example.com', + '', + '', + self::USER_ID + ); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('category_code', $result['errors']); + } + + public function testAssignUpdateSetsStatusAssignedWhenDomainProvided(): void + { + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findByTicketNo')->willReturn(null); + $repo->expects($this->once()) + ->method('insert') + ->with($this->callback(function (array $data): bool { + return $data['status'] === 'assigned' && $data['domain_no'] === 'DNS00001'; + })) + ->willReturn(1); + + $service = $this->createService($repo); + $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + 'D10001', + 'Acme Corp', + 'UPDATE', + 'DNS00001', + 'example.com', + '', + '', + self::USER_ID + ); + } + + public function testAssignUpdateSetsStatusOpenWhenNoDomainOrGitea(): void + { + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findByTicketNo')->willReturn(null); + $repo->expects($this->once()) + ->method('insert') + ->with($this->callback(function (array $data): bool { + return $data['status'] === 'open' && $data['domain_no'] === '' && $data['gitea_path'] === ''; + })) + ->willReturn(1); + + $service = $this->createService($repo); + $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + 'D10001', + 'Acme Corp', + 'UPDATE', + '', + '', + '', + '', + self::USER_ID + ); + } + + public function testAssignUpdateHandlesInsertFailure(): void + { + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findByTicketNo')->willReturn(null); + $repo->method('insert')->willReturn(null); + + $service = $this->createService($repo); + $result = $service->assignUpdate( + self::TENANT_ID, + 'TK-001', + 'D10001', + 'Acme Corp', + 'UPDATE', + 'DNS00001', + 'example.com', + '', + '', + self::USER_ID + ); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('general', $result['errors']); + } + + // ── Merge list tests ───────────────────────────────────────────── + + public function testGetUpdatesListMergesBcWithLocalData(): void + { + $bcGateway = $this->createMock(BcODataGateway::class); + $bcGateway->method('fetchUpdateTickets')->willReturn([ + 'tickets' => [ + $this->makeBcTicket('TK-001', 'UPDATE'), + $this->makeBcTicket('TK-002', 'UPDATE-HF'), + $this->makeBcTicket('TK-003', 'UPDATE'), + ], + 'truncated' => false, + ]); + + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findAllByTenant')->willReturn([ + ['ticket_no' => 'TK-001', 'domain_no' => 'DNS00001', 'domain_url' => 'example.com', 'gitea_path' => '2026-02-mysyde', 'notes' => '', 'status' => 'assigned', 'id' => 1], + ]); + + $service = $this->createService($repo, $bcGateway); + $result = $service->getUpdatesList(self::TENANT_ID); + + $this->assertCount(3, $result['rows']); + + // TK-001 should be enriched with local data + $tk001 = $result['rows'][0]; + $this->assertSame('TK-001', $tk001['ticket_no']); + $this->assertSame('assigned', $tk001['status']); + $this->assertSame('DNS00001', $tk001['domain_no']); + $this->assertSame('2026-02-mysyde', $tk001['gitea_path']); + + // TK-002 should be open (no local record) + $tk002 = $result['rows'][1]; + $this->assertSame('TK-002', $tk002['ticket_no']); + $this->assertSame('open', $tk002['status']); + $this->assertSame('', $tk002['domain_no']); + } + + public function testGetUpdatesListFiltersByStatus(): void + { + $bcGateway = $this->createMock(BcODataGateway::class); + $bcGateway->method('fetchUpdateTickets')->willReturn([ + 'tickets' => [ + $this->makeBcTicket('TK-001'), + $this->makeBcTicket('TK-002'), + ], + 'truncated' => false, + ]); + + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findAllByTenant')->willReturn([ + ['ticket_no' => 'TK-001', 'domain_no' => 'DNS00001', 'domain_url' => '', 'gitea_path' => '', 'notes' => '', 'status' => 'assigned', 'id' => 1], + ]); + + $service = $this->createService($repo, $bcGateway); + $result = $service->getUpdatesList(self::TENANT_ID, ['status' => 'open']); + + $this->assertCount(1, $result['rows']); + $this->assertSame('TK-002', $result['rows'][0]['ticket_no']); + } + + public function testGetUpdatesListFiltersByCategory(): void + { + $bcGateway = $this->createMock(BcODataGateway::class); + $bcGateway->method('fetchUpdateTickets')->willReturn([ + 'tickets' => [ + $this->makeBcTicket('TK-001', 'UPDATE'), + $this->makeBcTicket('TK-002', 'UPDATE-HF'), + ], + 'truncated' => false, + ]); + + $repo = $this->createMock(UpdateRepository::class); + $repo->method('findAllByTenant')->willReturn([]); + + $service = $this->createService($repo, $bcGateway); + $result = $service->getUpdatesList(self::TENANT_ID, ['category' => 'UPDATE-HF']); + + $this->assertCount(1, $result['rows']); + $this->assertSame('TK-002', $result['rows'][0]['ticket_no']); + } + + // ── Static helper tests ────────────────────────────────────────── + + public function testStatusLabelsReturnExpectedValues(): void + { + $this->assertNotEmpty(UpdateService::statusLabel('open')); + $this->assertNotEmpty(UpdateService::statusLabel('assigned')); + } + + public function testCategoryLabelsReturnExpectedValues(): void + { + $this->assertNotEmpty(UpdateService::categoryLabel('UPDATE')); + $this->assertNotEmpty(UpdateService::categoryLabel('UPDATE-HF')); + } + + public function testStatusVariantsReturnValidValues(): void + { + $validVariants = ['neutral', 'info', 'warning', 'success', 'error']; + $this->assertContains(UpdateService::statusVariant('open'), $validVariants); + $this->assertContains(UpdateService::statusVariant('assigned'), $validVariants); + } + + public function testTicketStateLabelsReturnExpectedValues(): void + { + $this->assertNotEmpty(UpdateService::ticketStateLabel('Offen')); + $this->assertNotEmpty(UpdateService::ticketStateLabel('In Bearbeitung')); + $this->assertNotEmpty(UpdateService::ticketStateLabel('Erledigt')); + $this->assertNotEmpty(UpdateService::ticketStateLabel('Geschlossen')); + } + + public function testTicketStateVariantsReturnValidValues(): void + { + $validVariants = ['neutral', 'info', 'warning', 'success', 'error']; + $this->assertContains(UpdateService::ticketStateVariant('Offen'), $validVariants); + $this->assertContains(UpdateService::ticketStateVariant('Erledigt'), $validVariants); + $this->assertContains(UpdateService::ticketStateVariant('Geschlossen'), $validVariants); + } +} diff --git a/modules/helpdesk/web/js/pages/helpdesk-updates-index.js b/modules/helpdesk/web/js/pages/helpdesk-updates-index.js new file mode 100644 index 0000000..ec98e0c --- /dev/null +++ b/modules/helpdesk/web/js/pages/helpdesk-updates-index.js @@ -0,0 +1,325 @@ +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 } from '/js/pages/app-list-utils.js'; + +const config = readPageConfig('helpdesk-updates'); +if (config) { + const gridjs = window.gridjs; + if (!gridjs) { + warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed: Grid.js missing', { module: 'helpdesk-updates', component: 'grid' }); + } else { + const appBase = getAppBase(); + const labels = config.labels || {}; + const canManage = config.canManage || false; + + const gridOptions = { + gridjs, + container: '#helpdesk-updates-grid', + dataUrl: config.dataUrl || 'helpdesk/updates-data', + appBase, + columns: [ + { + name: labels.ticketNo || 'Ticket', + sort: false, + formatter: (cell) => { + const no = escapeHtml(String(cell?.no || '')); + const url = String(cell?.url || '').trim(); + const desc = escapeHtml(String(cell?.description || '')); + const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : ''; + if (no === '') return gridjs.html(''); + if (url === '') return gridjs.html(`${no}`); + return gridjs.html(`${no}`); + }, + }, + { + name: labels.ticketState || 'Ticket status', + sort: false, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { + name: labels.assignment || 'Assignment', + sort: false, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { + name: labels.actions || '', + sort: false, + formatter: (cell) => { + if (!cell) return ''; + let buttons = ''; + // Assign/Edit button (only if user can manage) + if (canManage) { + // raw-html-ok: all values passed through escapeHtml, cell data is from our own API + const data = escapeHtml(JSON.stringify(cell)); + const buttonLabel = cell.status === 'open' + ? escapeHtml(labels.assign || 'Assign') + : escapeHtml(labels.edit || 'Edit'); + const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil'; + buttons += ``; + } + // Gitea link button (if URL exists) + const giteaUrl = String(cell.gitea_path || '').trim(); + if (giteaUrl !== '') { + const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea'); + buttons += ``; + } + if (buttons === '') return ''; + return gridjs.html(`${buttons}`); + }, + }, + { + name: labels.category || 'Type', + sort: false, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { + name: labels.debitor || 'Customer', + sort: false, + formatter: (cell) => { + const name = escapeHtml(String(cell?.name || '')); + const url = String(cell?.url || '').trim(); + if (name === '') return gridjs.html(''); + const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom'; + if (url === '') return gridjs.html(`${name}`); + return gridjs.html(`${name}`); + }, + }, + { name: labels.domain || 'Domain', sort: false }, + { name: labels.createdOn || 'Created', sort: false }, + ], + paginationLimit: 50, + language: config.gridLang || {}, + mapData: (data) => { + const rows = data.rows || data.data || []; + // Column order: Ticket | Ticket-Status | Assignment | Actions | Type | Customer | Domain | Created + return rows.map((row) => [ + { no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' }, + { label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' }, + { label: row.status_label || '', variant: row.status_variant || 'neutral' }, + { + ticket_no: row.ticket_no || '', + debitor_no: row.debitor_no || '', + debitor_name: row.debitor_name || '', + category_code: row.category_code || '', + domain_no: row.domain_no || '', + domain_url: row.domain_url || '', + gitea_path: row.gitea_path || '', + notes: row.notes || '', + status: row.status || 'open', + }, + { label: row.category_label || '', variant: row.category_variant || 'neutral' }, + { name: row.debitor_name || '', url: row.debitor_url || '' }, + row.domain_url || '', + row.created_on || '', + ]); + }, + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + }; + + const { gridConfig } = initStandardListPage({ + grid: gridOptions, + filters: { + mode: 'drawer', + chipMeta: config.filterChipMeta || {}, + watchInputs: ['#helpdesk-updates-search-input'], + }, + }); + + if (!gridConfig || !gridConfig.grid) { + warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' }); + } + + // Refresh button — bypasses session cache and reloads grid data from BC + const refreshButton = document.getElementById('updates-refresh-button'); + if (refreshButton) { + refreshButton.addEventListener('click', async () => { + refreshButton.disabled = true; + refreshButton.setAttribute('aria-busy', 'true'); + try { + // Ping the data endpoint with refresh=1 to invalidate the cache + const refreshUrl = new URL(config.dataUrl, appBase); + refreshUrl.searchParams.set('refresh', '1'); + await fetch(refreshUrl.toString(), { + credentials: 'same-origin', + headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' }, + }); + // Now force the grid to re-render (next fetch will get fresh data) + gridConfig?.grid?.forceRender(); + } catch { + // Ignore — grid will still show last data + } finally { + refreshButton.disabled = false; + refreshButton.removeAttribute('aria-busy'); + } + }); + } + + // Assignment dialog logic + if (canManage) { + const dialog = document.getElementById('update-assign-dialog'); + const form = document.getElementById('update-assign-form'); + const contextEl = document.getElementById('update-assign-context'); + const domainSelect = document.getElementById('update-assign-domain'); + const giteaInput = document.getElementById('update-assign-gitea'); + const notesInput = document.getElementById('update-assign-notes'); + const ticketNoInput = document.getElementById('update-assign-ticket-no'); + const debitorNoInput = document.getElementById('update-assign-debitor-no'); + const debitorNameInput = document.getElementById('update-assign-debitor-name'); + const categoryCodeInput = document.getElementById('update-assign-category-code'); + const domainUrlInput = document.getElementById('update-assign-domain-url'); + const submitButton = document.getElementById('update-assign-submit'); + + if (dialog && form) { + const closeDialog = () => { + try { dialog.close(); } catch { /* no-op */ } + document.body.classList.remove('modal-is-open'); + }; + + // Close buttons + dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => { + button.addEventListener('click', (e) => { e.preventDefault(); closeDialog(); }); + }); + dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDialog(); }); + dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); }); + + // Domain select change -> update hidden domain_url + domainSelect.addEventListener('change', () => { + const selected = domainSelect.options[domainSelect.selectedIndex]; + domainUrlInput.value = selected?.dataset?.url || ''; + }); + + /** + * Populate the domain