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) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:21:12 +02:00
parent 03e15eaf08
commit e7c60468c9
24 changed files with 913 additions and 146 deletions

View File

@@ -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<int> $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<array<string, mixed>>
*/
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)) {

View File

@@ -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' => []];
}

View File

@@ -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<array<string, mixed>>}
*/
@@ -294,6 +271,25 @@ class HandoverService
return $this->repository->findById($tenantId, $id);
}
/**
* @param list<int> $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.
*/

View File

@@ -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;