From e7c60468c981eb90e29695530dd76dd658cb16d9 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 16 Apr 2026 13:21:12 +0200 Subject: [PATCH] feat(helpdesk): add domain linking, bulk actions, and revision polish to handovers Adds domain selection (cascaded from debitor) to handover creation and edit, bulk delete with confirmation dialog, and various UI improvements to the handover wizard and list page. Includes migration for domain columns, domain-select AJAX endpoint, and updated tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Repository/HandoverRepository.php | 88 ++++++++++- .../Service/HandoverRevisionService.php | 64 +++++--- .../Helpdesk/Service/HandoverService.php | 76 +++++----- .../Service/SoftwareProductSyncService.php | 9 ++ .../007_add_domain_to_handovers.sql | 6 + .../helpdesk/domains/index(default).phtml | 1 + .../helpdesk/handover-domains-data().php | 44 ++++++ .../pages/helpdesk/handovers-data().php | 7 +- .../helpdesk/handovers/bulk($action).php | 48 ++++++ .../pages/helpdesk/handovers/create().php | 10 ++ .../helpdesk/handovers/create(default).phtml | 19 +++ .../pages/helpdesk/handovers/edit($id).php | 5 +- .../helpdesk/handovers/edit(default).phtml | 18 ++- .../pages/helpdesk/handovers/index().php | 9 +- .../helpdesk/handovers/index(default).phtml | 23 ++- .../helpdesk/software-products/_form.phtml | 1 + .../Service/HandoverRevisionServiceTest.php | 74 ++++++++- .../Helpdesk/Service/HandoverServiceTest.php | 115 +++++++++++--- .../SoftwareProductSyncServiceTest.php | 13 +- modules/helpdesk/web/css/helpdesk.css | 142 ++++++++++++++++-- .../helpdesk/web/js/handover-domain-select.js | 124 +++++++++++++++ .../helpdesk/web/js/handover-schema-editor.js | 11 ++ .../web/js/pages/helpdesk-domains-index.js | 48 ++++-- .../web/js/pages/helpdesk-handovers-index.js | 104 +++++++++++-- 24 files changed, 913 insertions(+), 146 deletions(-) create mode 100644 modules/helpdesk/migrations/007_add_domain_to_handovers.sql create mode 100644 modules/helpdesk/pages/helpdesk/handover-domains-data().php create mode 100644 modules/helpdesk/pages/helpdesk/handovers/bulk($action).php create mode 100644 modules/helpdesk/web/js/handover-domain-select.js diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php index c704e28..fa9cd27 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php @@ -15,12 +15,14 @@ class HandoverRepository { $result = DB::insert( 'INSERT INTO helpdesk_handovers ' - . '(tenant_id, debitor_no, debitor_name, product_code, status, schema_snapshot, field_values, created_by) ' - . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + . '(tenant_id, debitor_no, debitor_name, product_code, domain_no, domain_url, status, schema_snapshot, field_values, created_by) ' + . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', (string) ($data['tenant_id'] ?? 0), (string) ($data['debitor_no'] ?? ''), (string) ($data['debitor_name'] ?? ''), (string) ($data['product_code'] ?? ''), + (string) ($data['domain_no'] ?? ''), + (string) ($data['domain_url'] ?? ''), (string) ($data['status'] ?? 'draft'), $data['schema_snapshot'] ?? null, $data['field_values'] ?? null, @@ -68,7 +70,7 @@ class HandoverRepository [$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0); [$order, $dir] = RepoQuery::sanitizeOrder( $filters, - ['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'], + ['id', 'domain_url', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'], 'created_at', 'desc' ); @@ -76,7 +78,7 @@ class HandoverRepository $where = ['h.tenant_id = ?']; $params = [(string) $tenantId]; - RepoQuery::addLikeFilter($where, $params, ['h.debitor_name', 'h.debitor_no', 'h.product_code'], $search); + RepoQuery::addLikeFilter($where, $params, ['h.debitor_name', 'h.debitor_no', 'h.domain_url', 'h.product_code'], $search); if ($status !== '' && $status !== 'all') { $where[] = 'h.status = ?'; @@ -92,8 +94,11 @@ class HandoverRepository $total = (int) (DB::selectValue('SELECT COUNT(*) FROM helpdesk_handovers h' . $whereSql, ...$params) ?? 0); $rows = DB::select( - 'SELECT h.*, u.display_name AS created_by_name FROM helpdesk_handovers h' + 'SELECT h.*, u.display_name AS created_by_name,' + . ' sp.name AS product_name, sp.bc_description AS product_bc_description' + . ' FROM helpdesk_handovers h' . ' LEFT JOIN users u ON u.id = h.created_by' + . ' LEFT JOIN helpdesk_software_products sp ON sp.code = h.product_code' . $whereSql . sprintf(' ORDER BY h.`%s` %s LIMIT ? OFFSET ?', $order, $dir), ...array_merge($params, [(string) $limit, (string) $offset]) @@ -141,6 +146,79 @@ class HandoverRepository return $result !== false; } + /** + * @param list $ids + */ + public function deleteByIds(int $tenantId, array $ids): int + { + if ($ids === []) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $params = array_map('strval', $ids); + $params[] = (string) $tenantId; + + $result = DB::update( + 'DELETE FROM helpdesk_handovers WHERE id IN (' . $placeholders . ') AND tenant_id = ?', + ...$params + ); + + return is_int($result) ? $result : 0; + } + + /** + * Find all handovers linked to a specific domain. + * + * @api Called from DomainDetailService + * @return list> + */ + public function findByDomainNo(int $tenantId, string $domainNo): array + { + if ($domainNo === '') { + return []; + } + + $rows = DB::select( + 'SELECT h.*, u.display_name AS created_by_name,' + . ' sp.name AS product_name, sp.bc_description AS product_bc_description' + . ' FROM helpdesk_handovers h' + . ' LEFT JOIN users u ON u.id = h.created_by' + . ' LEFT JOIN helpdesk_software_products sp ON sp.code = h.product_code' + . ' WHERE h.tenant_id = ? AND h.domain_no = ?' + . ' ORDER BY h.created_at DESC', + (string) $tenantId, + $domainNo + ); + + $normalized = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $item = $this->normalizeRow($row); + if ($item !== null) { + $normalized[] = $item; + } + } + } + + return $normalized; + } + + public function beginTransaction(): void + { + DB::handle()->begin_transaction(); + } + + public function commitTransaction(): void + { + DB::handle()->commit(); + } + + public function rollbackTransaction(): void + { + DB::handle()->rollback(); + } + private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverRevisionService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverRevisionService.php index 050fb26..cb5fd45 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverRevisionService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverRevisionService.php @@ -132,30 +132,52 @@ class HandoverRevisionService $restoredStatus = (string) ($revision['status'] ?? $handover['status']); - // Update the handover with restored values - $fieldValuesJson = json_encode($restoredValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $this->handoverRepository->updateFieldValues($tenantId, $handoverId, $fieldValuesJson, $userId); + $this->handoverRepository->beginTransaction(); + try { + // Update the handover with restored values. + $fieldValuesJson = json_encode($restoredValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $updatedFields = $this->handoverRepository->updateFieldValues($tenantId, $handoverId, $fieldValuesJson, $userId); + if (!$updatedFields) { + throw new \RuntimeException('Failed to restore handover field values'); + } - $currentStatus = (string) ($handover['status'] ?? ''); - if ($restoredStatus !== $currentStatus) { - $this->handoverRepository->updateStatus($tenantId, $handoverId, $restoredStatus, $userId); + $currentStatus = (string) ($handover['status'] ?? ''); + if ($restoredStatus !== $currentStatus) { + $updatedStatus = $this->handoverRepository->updateStatus($tenantId, $handoverId, $restoredStatus, $userId); + if (!$updatedStatus) { + throw new \RuntimeException('Failed to restore handover status'); + } + } + + // Determine schema version from current handover. + $schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true); + $schemaVersion = is_array($schema) ? (int) ($schema['version'] ?? 1) : 1; + + // Create a new revision marking the restore. + $revisionId = $this->createRevision( + $tenantId, + $handoverId, + $restoredValues, + $restoredStatus, + $schemaVersion, + $userId, + self::CHANGE_TYPE_RESTORE + ); + if ($revisionId === null) { + throw new \RuntimeException('Failed to create restore revision'); + } + + $this->handoverRepository->commitTransaction(); + } catch (\Throwable) { + try { + $this->handoverRepository->rollbackTransaction(); + } catch (\Throwable) { + // no-op + } + + return ['ok' => false, 'errors' => ['general' => t('Failed to restore')]]; } - // Determine schema version from current handover - $schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true); - $schemaVersion = is_array($schema) ? (int) ($schema['version'] ?? 1) : 1; - - // Create a new revision marking the restore - $this->createRevision( - $tenantId, - $handoverId, - $restoredValues, - $restoredStatus, - $schemaVersion, - $userId, - self::CHANGE_TYPE_RESTORE - ); - return ['ok' => true, 'errors' => []]; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php index 42fa63a..3cc8cbf 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php @@ -62,6 +62,8 @@ class HandoverService string $debitorNo, string $debitorName, string $productCode, + string $domainNo, + string $domainUrl, int $userId, array $fieldValues = [], ): array { @@ -77,6 +79,11 @@ class HandoverService $errors['product_code'] = t('Software product is required'); } + $domainNo = trim($domainNo); + if ($domainNo === '') { + $errors['domain_no'] = t('Please select a domain'); + } + if ($errors !== []) { return ['ok' => false, 'id' => null, 'errors' => $errors]; } @@ -113,6 +120,8 @@ class HandoverService 'debitor_no' => $debitorNo, 'debitor_name' => trim($debitorName), 'product_code' => $productCode, + 'domain_no' => $domainNo, + 'domain_url' => trim($domainUrl), 'status' => self::STATUS_DRAFT, 'schema_snapshot' => $schemaRaw, 'field_values' => $fieldValuesJson, @@ -222,7 +231,8 @@ class HandoverService int $handoverId, array $fieldValues, string $status, - string $previousStatus, + bool $fieldsChanged, + bool $statusChanged, int $schemaVersion, int $userId, ): void { @@ -230,10 +240,12 @@ class HandoverService return; } - $statusChanged = $status !== $previousStatus; - $changeType = $statusChanged - ? HandoverRevisionService::CHANGE_TYPE_BOTH - : HandoverRevisionService::CHANGE_TYPE_FIELDS; + $changeType = HandoverRevisionService::CHANGE_TYPE_FIELDS; + if ($fieldsChanged && $statusChanged) { + $changeType = HandoverRevisionService::CHANGE_TYPE_BOTH; + } elseif ($statusChanged) { + $changeType = HandoverRevisionService::CHANGE_TYPE_STATUS; + } $this->revisionService->createRevision( $tenantId, @@ -246,41 +258,6 @@ class HandoverService ); } - /** - * Create a revision for a status-only change. - */ - public function createRevisionForStatusChange( - int $tenantId, - int $handoverId, - string $newStatus, - int $schemaVersion, - int $userId, - ): void { - if ($this->revisionService === null) { - return; - } - - $handover = $this->repository->findById($tenantId, $handoverId); - if ($handover === null) { - return; - } - - $fieldValues = json_decode((string) ($handover['field_values'] ?? '{}'), true); - if (!is_array($fieldValues)) { - $fieldValues = []; - } - - $this->revisionService->createRevision( - $tenantId, - $handoverId, - $fieldValues, - $newStatus, - $schemaVersion, - $userId, - HandoverRevisionService::CHANGE_TYPE_STATUS - ); - } - /** * @return array{total: int, rows: list>} */ @@ -294,6 +271,25 @@ class HandoverService return $this->repository->findById($tenantId, $id); } + /** + * @param list $ids + * @return array{ok: bool, count: int, error?: string} + */ + public function deleteByIds(int $tenantId, array $ids, string $permissionLevel): array + { + if ($permissionLevel !== self::PERMISSION_MANAGE) { + return ['ok' => false, 'count' => 0, 'error' => t('Only managers can delete handovers')]; + } + + if ($ids === []) { + return ['ok' => false, 'count' => 0, 'error' => t('No handovers selected')]; + } + + $count = $this->repository->deleteByIds($tenantId, $ids); + + return ['ok' => true, 'count' => $count]; + } + /** * Check if a user with the given permission level can edit a handover in the given status. */ diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php index 96360a8..d84a4dd 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductSyncService.php @@ -44,6 +44,15 @@ class SoftwareProductSyncService ]; } + if ($bcTypes === []) { + return [ + 'status' => 'skipped', + 'error_code' => 'bc_empty_response', + 'error_message' => 'BC returned no contract types — skipping sync to preserve local state', + 'result' => [], + ]; + } + $syncedCodes = []; $upserted = 0; diff --git a/modules/helpdesk/migrations/007_add_domain_to_handovers.sql b/modules/helpdesk/migrations/007_add_domain_to_handovers.sql new file mode 100644 index 0000000..331f4c2 --- /dev/null +++ b/modules/helpdesk/migrations/007_add_domain_to_handovers.sql @@ -0,0 +1,6 @@ +-- Add domain reference columns to handovers table +-- domain_no: BC domain identifier (e.g. DNS00001) +-- domain_url: domain URL snapshot at creation time + +ALTER TABLE helpdesk_handovers ADD COLUMN IF NOT EXISTS domain_no VARCHAR(30) NOT NULL DEFAULT '' AFTER product_code; +ALTER TABLE helpdesk_handovers ADD COLUMN IF NOT EXISTS domain_url VARCHAR(255) NOT NULL DEFAULT '' AFTER domain_no; diff --git a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml index a4e5c16..36662ad 100644 --- a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml @@ -35,6 +35,7 @@ require templatePath('partials/app-list-filters.phtml'); + diff --git a/modules/helpdesk/pages/helpdesk/handovers/edit($id).php b/modules/helpdesk/pages/helpdesk/handovers/edit($id).php index f8c6337..1eb0d59 100644 --- a/modules/helpdesk/pages/helpdesk/handovers/edit($id).php +++ b/modules/helpdesk/pages/helpdesk/handovers/edit($id).php @@ -109,7 +109,7 @@ if ($request->isMethod('POST') && $canEdit) { } } - $previousStatus = $currentStatus; + $fieldsChanged = $revisionService->computeDiff($fieldValues, $submittedValues) !== []; $updateResult = $service->updateFields($tenantId, $handoverId, $submittedValues, $userId, $permissionLevel); $errorBag->merge($updateResult['errors'] ?? []); @@ -132,7 +132,8 @@ if ($request->isMethod('POST') && $canEdit) { $handoverId, $submittedValues, $finalStatus, - $previousStatus, + $fieldsChanged, + $statusChanged, $schemaVersion, $userId ); diff --git a/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml b/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml index 9638ef9..3f5647e 100644 --- a/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml +++ b/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml @@ -100,6 +100,8 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id'] @@ -111,6 +113,15 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id'] +

+ + + + + + — + +

@@ -180,7 +191,12 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id'] - diff --git a/modules/helpdesk/pages/helpdesk/handovers/index().php b/modules/helpdesk/pages/helpdesk/handovers/index().php index 71a330d..8482581 100644 --- a/modules/helpdesk/pages/helpdesk/handovers/index().php +++ b/modules/helpdesk/pages/helpdesk/handovers/index().php @@ -4,6 +4,7 @@ use MintyPHP\Buffer; use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; use MintyPHP\Service\Access\AuthorizationService; +use MintyPHP\Session; use MintyPHP\Support\Guard; Guard::requireLogin(); @@ -41,8 +42,14 @@ $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_HANDOVERS_MANAGE, $actorContext)->isAllowed(); $canCreate = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_CREATE, $actorContext)->isAllowed() - || $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE, $actorContext)->isAllowed(); + || $canManage; + +if ($canManage) { + $csrfKey = Session::$csrfSessionKey; + $csrfToken = $session[$csrfKey] ?? ''; +} Buffer::set('title', t('Handovers')); Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml b/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml index d1229c2..0d15471 100644 --- a/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml @@ -8,6 +8,7 @@ $clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchem $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; $filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : []; $canCreate = $canCreate ?? false; +$canManage = $canManage ?? false; ?> @@ -15,6 +16,11 @@ $canCreate = $canCreate ?? false; $listTitle = t('Handovers'); ob_start(); ?> + + + @@ -33,6 +39,9 @@ require templatePath('partials/app-list-filters.phtml'); + + + diff --git a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml index 05c30c4..9dea5fb 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml +++ b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml @@ -31,6 +31,7 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem data-translations=" t('Add field'), 'remove_field' => t('Remove field'), + 'duplicate_field' => t('Duplicate field'), 'move_up' => t('Move up'), 'move_down' => t('Move down'), 'field_key' => t('Field key'), diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverRevisionServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverRevisionServiceTest.php index ce812da..bb85d1c 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverRevisionServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverRevisionServiceTest.php @@ -153,8 +153,11 @@ class HandoverRevisionServiceTest extends TestCase 'field_values' => '{"name":"Current value"}', 'schema_snapshot' => '{"version":1,"fields":[]}', ]); - $handoverRepo->expects($this->once())->method('updateFieldValues'); - $handoverRepo->expects($this->once())->method('updateStatus'); + $handoverRepo->expects($this->once())->method('beginTransaction'); + $handoverRepo->expects($this->once())->method('commitTransaction'); + $handoverRepo->expects($this->never())->method('rollbackTransaction'); + $handoverRepo->expects($this->once())->method('updateFieldValues')->willReturn(true); + $handoverRepo->expects($this->once())->method('updateStatus')->willReturn(true); $service = $this->createService($revisionRepo, $handoverRepo); $result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 2, self::USER_ID, HandoverService::PERMISSION_MANAGE); @@ -181,7 +184,10 @@ class HandoverRevisionServiceTest extends TestCase 'field_values' => '{"name":"Current"}', 'schema_snapshot' => '{"version":1,"fields":[]}', ]); - $handoverRepo->expects($this->once())->method('updateFieldValues'); + $handoverRepo->expects($this->once())->method('beginTransaction'); + $handoverRepo->expects($this->once())->method('commitTransaction'); + $handoverRepo->expects($this->never())->method('rollbackTransaction'); + $handoverRepo->expects($this->once())->method('updateFieldValues')->willReturn(true); $handoverRepo->expects($this->never())->method('updateStatus'); $service = $this->createService($revisionRepo, $handoverRepo); @@ -189,4 +195,66 @@ class HandoverRevisionServiceTest extends TestCase $this->assertTrue($result['ok']); } + + public function testRestoreRevisionRollsBackWhenFieldUpdateFails(): void + { + $revisionRepo = $this->createMock(HandoverRevisionRepository::class); + $revisionRepo->method('findByRevision')->willReturn([ + 'id' => 1, + 'revision' => 1, + 'field_values' => '{"name":"Old"}', + 'status' => 'draft', + ]); + $revisionRepo->expects($this->never())->method('insert'); + + $handoverRepo = $this->createMock(HandoverRepository::class); + $handoverRepo->method('findById')->willReturn([ + 'id' => self::HANDOVER_ID, + 'status' => 'draft', + 'field_values' => '{"name":"Current"}', + 'schema_snapshot' => '{"version":1,"fields":[]}', + ]); + $handoverRepo->expects($this->once())->method('beginTransaction'); + $handoverRepo->expects($this->once())->method('rollbackTransaction'); + $handoverRepo->expects($this->never())->method('commitTransaction'); + $handoverRepo->expects($this->once())->method('updateFieldValues')->willReturn(false); + $handoverRepo->expects($this->never())->method('updateStatus'); + + $service = $this->createService($revisionRepo, $handoverRepo); + $result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('general', $result['errors']); + } + + public function testRestoreRevisionRollsBackWhenRevisionInsertFails(): void + { + $revisionRepo = $this->createMock(HandoverRevisionRepository::class); + $revisionRepo->method('findByRevision')->willReturn([ + 'id' => 1, + 'revision' => 1, + 'field_values' => '{"name":"Old"}', + 'status' => 'draft', + ]); + $revisionRepo->method('getLatestRevisionNumber')->willReturn(3); + $revisionRepo->expects($this->once())->method('insert')->willReturn(null); + + $handoverRepo = $this->createMock(HandoverRepository::class); + $handoverRepo->method('findById')->willReturn([ + 'id' => self::HANDOVER_ID, + 'status' => 'draft', + 'field_values' => '{"name":"Current"}', + 'schema_snapshot' => '{"version":1,"fields":[]}', + ]); + $handoverRepo->expects($this->once())->method('beginTransaction'); + $handoverRepo->expects($this->once())->method('rollbackTransaction'); + $handoverRepo->expects($this->never())->method('commitTransaction'); + $handoverRepo->expects($this->once())->method('updateFieldValues')->willReturn(true); + $handoverRepo->expects($this->never())->method('updateStatus'); + + $service = $this->createService($revisionRepo, $handoverRepo); + $result = $service->restoreRevision(self::TENANT_ID, self::HANDOVER_ID, 1, self::USER_ID, HandoverService::PERMISSION_MANAGE); + + $this->assertFalse($result['ok']); + } } diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php index 279c31c..e9aae39 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php @@ -53,7 +53,7 @@ class HandoverServiceTest extends TestCase $repo->expects($this->once())->method('insert')->willReturn(99); $service = $this->createService($repo, $this->mockProductService()); - $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID); + $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertTrue($result['ok']); $this->assertSame(99, $result['id']); @@ -63,7 +63,7 @@ class HandoverServiceTest extends TestCase public function testCreateWithInvalidProductCode(): void { $service = $this->createService(productService: $this->mockProductService(exists: false)); - $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', self::USER_ID); + $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'INVALID', 'DNS00001', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertNull($result['id']); @@ -73,7 +73,7 @@ class HandoverServiceTest extends TestCase public function testCreateWithProductWithoutSchema(): void { $service = $this->createService(productService: $this->mockProductService(hasSchema: false)); - $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID); + $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertNull($result['id']); @@ -83,7 +83,7 @@ class HandoverServiceTest extends TestCase public function testCreateWithEmptyDebitorNo(): void { $service = $this->createService(); - $result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', self::USER_ID); + $result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertFalse($result['ok']); $this->assertArrayHasKey('debitor_no', $result['errors']); @@ -217,12 +217,38 @@ class HandoverServiceTest extends TestCase ); $service = $this->createService($repo, $this->mockProductService(), $revisionService); - $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', self::USER_ID); + $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); $this->assertTrue($result['ok']); } - public function testCreateRevisionAfterSaveDeterminesChangeType(): void + public function testCreateWithEmptyDomainNo(): void + { + $service = $this->createService(productService: $this->mockProductService()); + $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', '', 'example.com', self::USER_ID); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('domain_no', $result['errors']); + } + + public function testCreatePassesDomainToRepository(): void + { + $repo = $this->createMock(HandoverRepository::class); + $repo->expects($this->once()) + ->method('insert') + ->with($this->callback(function (array $data): bool { + return ($data['domain_no'] ?? '') === 'DNS00001' + && ($data['domain_url'] ?? '') === 'example.com'; + })) + ->willReturn(99); + + $service = $this->createService($repo, $this->mockProductService()); + $result = $service->create(self::TENANT_ID, 'D10001', 'Acme Corp', 'PROD-A', 'DNS00001', 'example.com', self::USER_ID); + + $this->assertTrue($result['ok']); + } + + public function testCreateRevisionAfterSaveSetsBothWhenFieldsAndStatusChanged(): void { $repo = $this->createMock(HandoverRepository::class); @@ -240,10 +266,10 @@ class HandoverServiceTest extends TestCase ); $service = $this->createService($repo, null, $revisionService); - $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', 'draft', 1, self::USER_ID); + $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'in_progress', true, true, 1, self::USER_ID); } - public function testCreateRevisionAfterSaveFieldsOnlyChangeType(): void + public function testCreateRevisionAfterSaveSetsFieldsWhenOnlyFieldsChanged(): void { $repo = $this->createMock(HandoverRepository::class); @@ -261,18 +287,12 @@ class HandoverServiceTest extends TestCase ); $service = $this->createService($repo, null, $revisionService); - $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', 'draft', 1, self::USER_ID); + $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', true, false, 1, self::USER_ID); } - public function testCreateRevisionForStatusChange(): void + public function testCreateRevisionAfterSaveSetsStatusWhenOnlyStatusChanged(): void { $repo = $this->createMock(HandoverRepository::class); - $repo->method('findById')->willReturn([ - 'id' => 1, - 'status' => 'in_progress', - 'field_values' => '{"name":"Test"}', - 'schema_snapshot' => self::VALID_SCHEMA_JSON, - ]); $revisionService = $this->createMock(HandoverRevisionService::class); $revisionService->expects($this->once()) @@ -288,6 +308,67 @@ class HandoverServiceTest extends TestCase ); $service = $this->createService($repo, null, $revisionService); - $service->createRevisionForStatusChange(self::TENANT_ID, 1, 'completed', 1, self::USER_ID); + $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'completed', false, true, 1, self::USER_ID); + } + + public function testCreateRevisionAfterSaveDefaultsToFieldsWhenNoContentChanged(): void + { + $repo = $this->createMock(HandoverRepository::class); + + $revisionService = $this->createMock(HandoverRevisionService::class); + $revisionService->expects($this->once()) + ->method('createRevision') + ->with( + self::TENANT_ID, + 1, + ['name' => 'Test'], + 'draft', + 1, + self::USER_ID, + HandoverRevisionService::CHANGE_TYPE_FIELDS + ); + + $service = $this->createService($repo, null, $revisionService); + $service->createRevisionAfterSave(self::TENANT_ID, 1, ['name' => 'Test'], 'draft', false, false, 1, self::USER_ID); + } + + // ── Delete tests ───────────────────────────────────────────────── + + public function testDeleteByIdsHappyPath(): void + { + $repo = $this->createMock(HandoverRepository::class); + $repo->expects($this->once()) + ->method('deleteByIds') + ->with(self::TENANT_ID, [1, 2, 3]) + ->willReturn(3); + + $service = $this->createService($repo); + $result = $service->deleteByIds(self::TENANT_ID, [1, 2, 3], HandoverService::PERMISSION_MANAGE); + + $this->assertTrue($result['ok']); + $this->assertSame(3, $result['count']); + } + + public function testDeleteByIdsDeniedForCreatePermission(): void + { + $repo = $this->createMock(HandoverRepository::class); + $repo->expects($this->never())->method('deleteByIds'); + + $service = $this->createService($repo); + $result = $service->deleteByIds(self::TENANT_ID, [1], HandoverService::PERMISSION_CREATE); + + $this->assertFalse($result['ok']); + $this->assertSame(0, $result['count']); + } + + public function testDeleteByIdsWithEmptyArray(): void + { + $repo = $this->createMock(HandoverRepository::class); + $repo->expects($this->never())->method('deleteByIds'); + + $service = $this->createService($repo); + $result = $service->deleteByIds(self::TENANT_ID, [], HandoverService::PERMISSION_MANAGE); + + $this->assertFalse($result['ok']); } } diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php index 48b1ba4..2e52596 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductSyncServiceTest.php @@ -80,25 +80,20 @@ class SoftwareProductSyncServiceTest extends TestCase $this->assertSame(1, $result['result']['deactivated']); } - public function testSyncHandlesEmptyBcResponse(): void + public function testSyncSkipsWhenBcReturnsEmpty(): void { $gateway = $this->createMock(BcODataGateway::class); $gateway->method('listContractTypes')->willReturn([]); $repository = $this->createMock(SoftwareProductRepository::class); $repository->expects($this->never())->method('upsertByCode'); - $repository->expects($this->once()) - ->method('softDeleteNotInCodes') - ->with([]) - ->willReturn(3); + $repository->expects($this->never())->method('softDeleteNotInCodes'); $service = $this->createService($gateway, $repository); $result = $service->sync(); - $this->assertSame('success', $result['status']); - $this->assertSame(0, $result['result']['bc_count']); - $this->assertSame(0, $result['result']['upserted']); - $this->assertSame(3, $result['result']['deactivated']); + $this->assertSame('skipped', $result['status']); + $this->assertSame('bc_empty_response', $result['error_code']); } public function testSyncIsIdempotent(): void diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index f0df3d4..dd4ee6f 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -1448,8 +1448,6 @@ overflow-x: clip; } - - #app-details-aside-section .helpdesk-comm-aside { display: flex; flex-direction: column; @@ -1513,7 +1511,7 @@ .helpdesk-ticket-meta dd { margin: 0.15rem 0 0; - font-size: var(--text-sm); + font-size: var(--text-base); line-height: 1.4; color: var(--app-contrast, #1f2937); word-break: break-word; @@ -2429,7 +2427,11 @@ } .handover-schema-preview-highlight { - background-color: color-mix(in srgb, var(--app-primary, #635bff) 5%, transparent); + background-color: color-mix( + in srgb, + var(--app-primary, #635bff) 5%, + transparent + ); border-left-color: var(--app-primary, #635bff); } @@ -2614,7 +2616,9 @@ border-radius: var(--app-border-radius); color: var(--app-muted-color); text-decoration: none; - transition: background 0.15s ease, color 0.15s ease; + transition: + background 0.15s ease, + color 0.15s ease; } .app-wizard-back:hover { @@ -2653,7 +2657,10 @@ border: 2px solid var(--app-border); color: var(--app-muted-color); background: transparent; - transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; + transition: + border-color 0.2s ease, + background 0.2s ease, + color 0.2s ease; flex-shrink: 0; } @@ -2798,12 +2805,12 @@ /* Vertical line */ .handover-revision-timeline::before { - content: ''; + content: ""; position: absolute; - left: 0.4375rem; + left: 2.9px; top: 0.5rem; bottom: 0.5rem; - width: 2px; + width: 1px; background: var(--app-border); } @@ -2817,7 +2824,7 @@ /* Circle node */ .handover-revision-item::before { - content: ''; + content: ""; position: absolute; left: -1.25rem; top: calc(var(--app-spacing) * 0.4 + 0.35rem); @@ -2869,11 +2876,14 @@ color: var(--app-muted-color); text-decoration: none; border-radius: var(--app-border-radius); - opacity: 0; - transition: opacity 0.15s ease, color 0.15s ease; + opacity: 0.55; + transition: + opacity 0.15s ease, + color 0.15s ease; } - .handover-revision-item:hover .handover-revision-compare { + .handover-revision-item:hover .handover-revision-compare, + .handover-revision-compare:focus-visible { opacity: 1; } @@ -2881,6 +2891,11 @@ color: var(--app-primary); } + .handover-revision-compare:focus-visible { + outline: 2px solid color-mix(in srgb, var(--app-primary) 45%, transparent); + outline-offset: 2px; + } + .handover-revision-number { display: flex; align-items: center; @@ -2985,18 +3000,113 @@ } .handover-diff-indicator::before { - content: '±'; + content: "±"; font-weight: var(--font-semibold, 600); color: hsl(40 70% 45%); } .handover-diff-added .handover-diff-indicator::before { - content: '+'; + content: "+"; color: hsl(145 60% 38%); } .handover-diff-removed .handover-diff-indicator::before { - content: '−'; + content: "−"; color: hsl(0 70% 50%); } + + /* Domain detail page */ + .helpdesk-domain-url-prominent { + font-size: var(--text-lg); + font-weight: var(--font-semibold); + word-break: break-all; + text-decoration: none; + } + + .helpdesk-domain-url-prominent:hover { + text-decoration: underline; + } + + .helpdesk-domain-overview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: calc(var(--app-spacing) * 1.25); + align-items: start; + } + + @media (max-width: 760px) { + .helpdesk-domain-overview-grid { + grid-template-columns: 1fr; + } + } + + /* Related domains — compact aside list */ + .helpdesk-domain-related-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.35); + padding: calc(var(--app-spacing) * 0.4) 0; + border-bottom: 1px solid var(--app-border-subtle, #f0f0f0); + text-decoration: none; + color: inherit; + font-size: var(--text-sm); + } + + .helpdesk-domain-related-item:last-child { + border-bottom: 0; + } + + .helpdesk-domain-related-item:hover .helpdesk-domain-related-item-url { + text-decoration: underline; + } + + .helpdesk-domain-related-item-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + /* Handover list — Stripe-style compact clickable rows */ + .helpdesk-domain-handover-list { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--app-border-subtle, #f0f0f0); + border-radius: var(--app-border-radius); + overflow: hidden; + } + + .helpdesk-domain-handover-item { + display: block; + padding: calc(var(--app-spacing) * 0.65) calc(var(--app-spacing) * 0.75); + background: var(--app-card-background-color, #fff); + text-decoration: none; + color: inherit; + transition: background-color 0.15s ease; + } + + .helpdesk-domain-handover-item:hover { + background: var(--app-card-sectioning-background-color, #fafafa); + } + + .helpdesk-domain-handover-item-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.5); + } + + .helpdesk-domain-handover-item-product { + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--app-contrast, #1f2937); + } + + .helpdesk-domain-handover-item-meta { + margin-top: 0.15rem; + font-size: var(--text-xs); + color: var(--app-muted, #6c757d); + } } diff --git a/modules/helpdesk/web/js/handover-domain-select.js b/modules/helpdesk/web/js/handover-domain-select.js new file mode 100644 index 0000000..8d4c3bd --- /dev/null +++ b/modules/helpdesk/web/js/handover-domain-select.js @@ -0,0 +1,124 @@ +/** + * Dynamic domain select for handover wizard step 1. + * + * Reads config from data-* attributes on #wizard-domain-group: + * data-domains-url, data-text-initial, data-text-loading, + * data-text-select, data-text-empty, data-text-error, + * data-initial-debitor, data-initial-domain + */ + +const group = document.getElementById('wizard-domain-group'); +const lookupContainer = document.querySelector('[data-app-lookup]'); +const domainSelect = document.getElementById('wizard-domain'); +const domainUrlInput = document.getElementById('wizard-domain-url'); +const feedback = document.getElementById('wizard-domain-feedback'); + +if (group && lookupContainer && domainSelect) { + const domainsUrl = group.dataset.domainsUrl || ''; + const textInitial = group.dataset.textInitial || ''; + const textLoading = group.dataset.textLoading || ''; + const textSelect = group.dataset.textSelect || ''; + const textEmpty = group.dataset.textEmpty || ''; + const textError = group.dataset.textError || ''; + + function clearOptions() { + while (domainSelect.options.length > 0) { + domainSelect.remove(0); + } + } + + function addPlaceholder(text) { + const opt = document.createElement('option'); + opt.value = ''; + opt.textContent = text; + domainSelect.appendChild(opt); + } + + function setFeedback(text, variant) { + if (!feedback) return; + feedback.textContent = text; + feedback.hidden = !text; + feedback.dataset.variant = variant || ''; + } + + function resetDomain() { + clearOptions(); + addPlaceholder(textInitial); + domainSelect.disabled = true; + if (domainUrlInput) domainUrlInput.value = ''; + setFeedback('', ''); + } + + async function loadDomains(debitorNo) { + clearOptions(); + addPlaceholder(textLoading); + domainSelect.disabled = true; + group.setAttribute('aria-busy', 'true'); + setFeedback('', ''); + + try { + const res = await fetch(domainsUrl + '?debitor_no=' + encodeURIComponent(debitorNo), { + headers: { 'Accept': 'application/json' }, + }); + const data = await res.json(); + const items = Array.isArray(data) ? data : []; + + clearOptions(); + addPlaceholder(textSelect); + + if (items.length === 0) { + domainSelect.disabled = true; + setFeedback(textEmpty, 'warning'); + return; + } + + items.forEach(function (item) { + const opt = document.createElement('option'); + opt.value = item.value || ''; + opt.textContent = item.label || item.value || ''; + opt.dataset.url = item.url || ''; + domainSelect.appendChild(opt); + }); + domainSelect.disabled = false; + } catch { + clearOptions(); + addPlaceholder(textSelect); + domainSelect.disabled = true; + setFeedback(textError, 'error'); + } finally { + group.removeAttribute('aria-busy'); + } + } + + domainSelect.addEventListener('change', function () { + const selected = domainSelect.options[domainSelect.selectedIndex]; + if (domainUrlInput) { + domainUrlInput.value = selected?.dataset.url || ''; + } + }); + + lookupContainer.addEventListener('lookup:select', function (e) { + const debitorNo = e.detail?.value || ''; + if (debitorNo) { + loadDomains(debitorNo); + } else { + resetDomain(); + } + }); + + lookupContainer.addEventListener('lookup:clear', function () { + resetDomain(); + }); + + // Restore selection if returning to step 1 with session data + const initialDebitor = group.dataset.initialDebitor || ''; + const initialDomain = group.dataset.initialDomain || ''; + if (initialDebitor) { + loadDomains(initialDebitor).then(function () { + if (initialDomain && !domainSelect.disabled) { + domainSelect.value = initialDomain; + domainSelect.dispatchEvent(new Event('change')); + } + }); + } +} diff --git a/modules/helpdesk/web/js/handover-schema-editor.js b/modules/helpdesk/web/js/handover-schema-editor.js index b80784e..1493bde 100644 --- a/modules/helpdesk/web/js/handover-schema-editor.js +++ b/modules/helpdesk/web/js/handover-schema-editor.js @@ -131,6 +131,17 @@ function init(root) { (t.move_down || 'Move down') + ' ' + (t.field_label || 'field') + ' ' + (index + 1), )); } + actions.appendChild(createIconButton( + 'bi-copy', 'secondary outline small', + () => { + const clone = { ...field, label: field.label, key: field.key || '' }; + if (field.options) { clone.options = field.options.map((o) => ({ ...o })); } + fields.splice(index + 1, 0, clone); + render(); + notifyChange(); + }, + (t.duplicate_field || 'Duplicate field') + ' ' + (index + 1), + )); actions.appendChild(createIconButton( 'bi-trash', 'danger outline small', () => { fields.splice(index, 1); render(); notifyChange(); }, diff --git a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js index 3a495e9..185fcbe 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js @@ -12,7 +12,8 @@ if (config) { const appBase = getAppBase(); const labels = config.labels || {}; - const debitorUrlIndex = 6; + const domainBaseUrl = config.domainBaseUrl || ''; + const domainUrlIndex = 7; const gridOptions = { gridjs, @@ -20,7 +21,19 @@ if (config) { dataUrl: config.dataUrl || 'helpdesk/domains-data', appBase, columns: [ - { name: labels.no || 'No.', sort: true }, + { + name: labels.no || 'No.', + sort: true, + formatter: (cell) => { + const no = escapeHtml(cell?.no || ''); + const detailUrl = String(cell?.url || '').trim(); + if (detailUrl === '') { + return gridjs.html(no); + } + const href = escapeHtml(detailUrl); + return gridjs.html(`${no}`); + }, + }, { name: labels.customerName || 'Customer Name', sort: true, @@ -47,25 +60,32 @@ if (config) { }, { name: labels.administration || 'Administration', sort: true }, { name: 'debitor_url', hidden: true }, + { name: 'domain_detail_url', hidden: true }, ], - sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null], + sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null], paginationLimit: 10, language: config.gridLang || {}, - mapData: (data) => (data.data || []).map((row) => [ - row.No || '', - { name: row.Customer_Name || '', url: row.debitor_url || '' }, - row.URL || '', - row.contract_type || '', - { state: row.State || '', variant: row.state_variant || 'neutral' }, - row.Administration || '', - row.debitor_url || '', - ]), + mapData: (data) => (data.data || []).map((row) => { + const domainDetailUrl = domainBaseUrl && row.No + ? domainBaseUrl + encodeURIComponent(row.No) + : ''; + return [ + { no: row.No || '', url: domainDetailUrl }, + { name: row.Customer_Name || '', url: row.debitor_url || '' }, + row.URL || '', + row.contract_type || '', + { state: row.State || '', variant: row.state_variant || 'neutral' }, + row.Administration || '', + row.debitor_url || '', + domainDetailUrl, + ]; + }), search: config.gridSearch || null, filterSchema: config.filterSchema || [], urlSync: true, - rowInteraction: { linkColumn: 1 }, + rowInteraction: { linkColumn: 0 }, rowDblClick: { - getUrl: (rowData) => rowData?.cells?.[debitorUrlIndex]?.data || '', + getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '', }, }; diff --git a/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js b/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js index 058440e..91ae32e 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js @@ -2,6 +2,8 @@ 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'; +import { bindBulkVisibility } from '/js/components/app-bulk-selection.js'; +import { confirmDialog } from '/js/core/app-confirm-dialog.js'; const config = readPageConfig('helpdesk-handovers'); if (config) { @@ -11,8 +13,12 @@ if (config) { } else { const appBase = getAppBase(); const labels = config.labels || {}; + const canManage = config.canManage || false; - const editUrlIndex = 6; + // Column layout: Domain | Status | Customer | Product | Created | Created by | (edit_url hidden) + // With selection: checkbox is prepended by grid factory, shifting indices +1 + const editUrlIndex = canManage ? 7 : 6; + const domainColumnIndex = canManage ? 1 : 0; const gridOptions = { gridjs, @@ -21,21 +27,21 @@ if (config) { appBase, columns: [ { - name: labels.id || 'ID', + name: labels.domain || 'Domain', sort: true, - width: '80px', formatter: (cell) => { - const id = escapeHtml(String(cell?.id || '')); + const domain = escapeHtml(String(cell?.domain || '')); const editUrl = String(cell?.url || '').trim(); + if (domain === '') { + return gridjs.html(''); + } if (editUrl === '') { - return gridjs.html(id); + return gridjs.html(domain); } const href = escapeHtml(withCurrentListReturn(editUrl)); - return gridjs.html(`#${id}`); + return gridjs.html(`${domain}`); }, }, - { name: labels.debitorName || 'Customer', sort: true }, - { name: labels.productCode || 'Software product', sort: true }, { name: labels.status || 'Status', sort: true, @@ -45,18 +51,20 @@ if (config) { return gridjs.html(`${label}`); }, }, + { name: labels.debitorName || 'Customer', sort: true }, + { name: labels.productDisplay || 'Software product', sort: true }, { name: labels.createdAt || 'Created', sort: true }, { name: labels.createdBy || 'Created by', sort: true }, { name: 'edit_url', hidden: true }, ], - sortColumns: ['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by', null], + sortColumns: ['domain_url', 'status', 'debitor_name', 'product_code', 'created_at', 'created_by', null], paginationLimit: 20, language: config.gridLang || {}, mapData: (data) => (data.data || []).map((row) => [ - { id: row.id || '', url: row.edit_url || '' }, - row.debitor_name || '', - row.product_code || '', + { domain: row.domain_url || '', url: row.edit_url || '', id: row.id || '' }, { label: row.status_label || '', variant: row.status_variant || 'neutral' }, + row.debitor_name || '', + row.product_display || '', row.created_at || '', row.created_by_name || '', row.edit_url || '', @@ -64,12 +72,34 @@ if (config) { search: config.gridSearch || null, filterSchema: config.filterSchema || [], urlSync: true, - rowInteraction: { linkColumn: 0 }, + rowInteraction: { linkColumn: domainColumnIndex }, rowDblClick: { getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '', }, }; + if (canManage && gridjs.plugins?.selection?.RowSelection) { + gridOptions.selection = { + enabled: true, + id: 'selectRow', + component: gridjs.plugins.selection.RowSelection, + selectAllLabel: labels.selectAll || 'Select all', + props: { + id: (row) => row.cell(domainColumnIndex).data?.id || '', + }, + getSelectedIds: ({ container }) => + Array.from(container.querySelectorAll('.gridjs-tr-selected')) + .map((tr) => { + const link = tr.querySelector('a[href]'); + if (!link) return ''; + const href = link.getAttribute('href') || ''; + const match = href.match(/\/edit\/(\d+)/); + return match ? match[1] : ''; + }) + .filter(Boolean), + }; + } + const { gridConfig } = initStandardListPage({ grid: gridOptions, filters: { @@ -82,5 +112,53 @@ if (config) { if (!gridConfig || !gridConfig.grid) { warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' }); } + + if (canManage && gridConfig) { + bindBulkVisibility({ + containerSelector: '#helpdesk-handovers-grid', + buttonSelector: '[data-handovers-bulk]', + }); + + const bulkButtons = Array.from(document.querySelectorAll('[data-handovers-bulk]')); + bulkButtons.forEach((button) => { + button.addEventListener('click', async () => { + const action = button.dataset.handoversBulk || ''; + const ids = gridConfig.selection?.getSelectedIds?.() || []; + if (!ids.length || action !== 'delete') return; + + const confirmed = await confirmDialog.confirm({ + message: labels.bulkDeleteConfirm || 'Delete selected handovers?', + actionKind: 'delete', + }); + if (!confirmed) return; + + const body = new URLSearchParams({ + ids: ids.join(','), + [config.csrfKey]: config.csrfToken, + }); + + try { + const res = await fetch(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'X-Requested-With': 'fetch', + }, + body, + }); + const data = await res.json().catch(() => null); + if (res.ok && data?.ok) { + gridConfig.selection?.clear?.(); + gridConfig.grid?.forceRender(); + } else { + window.location.reload(); + } + } catch { + window.location.reload(); + } + }); + }); + } } }