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(); + } + }); + }); + } } }