1
0

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;

View File

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

View File

@@ -35,6 +35,7 @@ require templatePath('partials/app-list-filters.phtml');
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-helpdesk-domains"><?php gridJsonForJs([
'dataUrl' => lurl('helpdesk/domains-data'),
'domainBaseUrl' => lurl('helpdesk/domain/'),
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,

View File

@@ -0,0 +1,44 @@
<?php
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_CREATE);
gridRequireGetRequest();
$request = requestInput();
$debitorNo = trim((string) $request->query('debitor_no', ''));
if ($debitorNo === '') {
Router::json([]);
return;
}
try {
$domains = app(BcODataGateway::class)->getDomainsForCustomer($debitorNo);
} catch (\Throwable) {
Router::json([]);
return;
}
$items = [];
foreach ($domains as $domain) {
$no = trim((string) ($domain['No'] ?? ''));
$url = trim((string) ($domain['URL'] ?? ''));
if ($no === '') {
continue;
}
$items[] = [
'value' => $no,
'label' => $url !== '' ? $url : $no,
'url' => $url,
];
}
Router::json($items);

View File

@@ -26,10 +26,15 @@ foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
$status = (string) ($row['status'] ?? 'draft');
$productName = trim((string) ($row['product_name'] ?? ''));
$productBcDesc = trim((string) ($row['product_bc_description'] ?? ''));
$productDisplay = $productName !== '' ? $productName : ($productBcDesc !== '' ? $productBcDesc : (string) ($row['product_code'] ?? ''));
$preparedRows[] = [
'id' => $id,
'domain_url' => (string) ($row['domain_url'] ?? ''),
'debitor_name' => (string) ($row['debitor_name'] ?? ''),
'product_code' => (string) ($row['product_code'] ?? ''),
'product_display' => $productDisplay,
'status' => $status,
'status_label' => HandoverService::statusLabel($status),
'status_variant' => HandoverService::statusVariant($status),

View File

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (!Session::checkCsrfToken()) {
Router::json(['ok' => false, 'error' => 'csrf_expired']);
return;
}
$action = strtolower(trim((string) ($action ?? '')));
$allowedActions = ['delete'];
if (!in_array($action, $allowedActions, true)) {
Router::json(['ok' => false, 'error' => 'invalid_action']);
return;
}
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE);
$raw = requestInput()->bodyAll()['ids'] ?? '';
$ids = [];
if (is_array($raw)) {
$ids = array_map('intval', $raw);
} elseif (is_string($raw)) {
$ids = array_map('intval', array_filter(array_map('trim', explode(',', $raw))));
}
$ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
Router::json(['ok' => false, 'error' => 'no_selection']);
return;
}
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$service = app(HandoverService::class);
$result = $service->deleteByIds($tenantId, $ids, HandoverService::PERMISSION_MANAGE);
Router::json($result);

View File

@@ -39,6 +39,8 @@ if ($step === 1) {
'debitor_no' => (string) $request->body('debitor_no', ''),
'debitor_name' => (string) $request->body('debitor_name', ''),
'product_code' => (string) $request->body('product_code', ''),
'domain_no' => (string) $request->body('domain_no', ''),
'domain_url' => (string) $request->body('domain_url', ''),
];
if ($request->isMethod('POST')) {
@@ -53,6 +55,10 @@ if ($step === 1) {
$errorBag->add('debitor_no', t('Please select a customer'));
}
if (trim($form['domain_no']) === '') {
$errorBag->add('domain_no', t('Please select a domain'));
}
if (trim($form['product_code']) === '') {
$errorBag->add('product_code', t('Please select a software product'));
} else {
@@ -69,6 +75,8 @@ if ($step === 1) {
'debitor_no' => trim($form['debitor_no']),
'debitor_name' => trim($form['debitor_name']),
'product_code' => trim($form['product_code']),
'domain_no' => trim($form['domain_no']),
'domain_url' => trim($form['domain_url']),
]);
Router::redirect(lurl('helpdesk/handovers/create') . '?step=2' . ($returnTarget ? '&return=' . rawurlencode($returnTarget) : ''));
@@ -139,6 +147,8 @@ if ($step === 2) {
$wizardData['debitor_no'],
$wizardData['debitor_name'],
$wizardData['product_code'],
$wizardData['domain_no'] ?? '',
$wizardData['domain_url'] ?? '',
$userId,
$fieldValues
);

View File

@@ -71,6 +71,24 @@ $steps = [
</div>
</div>
<div class="app-wizard-field-group" id="wizard-domain-group"
data-domains-url="<?php e(lurl('helpdesk/handover-domains-data')); ?>"
data-text-initial="<?php e(t('Select a customer first...')); ?>"
data-text-loading="<?php e(t('Loading domains...')); ?>"
data-text-select="<?php e(t('Please select...')); ?>"
data-text-empty="<?php e(t('No domains found for this customer')); ?>"
data-text-error="<?php e(t('Failed to load domains')); ?>"
data-initial-debitor="<?php e($form['debitor_no'] ?? ''); ?>"
data-initial-domain="<?php e($form['domain_no'] ?? ''); ?>"
>
<label for="wizard-domain"><?php e(t('Domain')); ?> <span class="handover-form-required">*</span></label>
<select id="wizard-domain" name="domain_no" disabled>
<option value=""><?php e(t('Select a customer first...')); ?></option>
</select>
<input type="hidden" name="domain_url" id="wizard-domain-url" value="<?php e($form['domain_url'] ?? ''); ?>">
<div id="wizard-domain-feedback" class="app-wizard-field-feedback" role="alert" hidden></div>
</div>
<div class="app-wizard-field-group">
<label for="wizard-product-code"><?php e(t('Software product')); ?> <span class="handover-form-required">*</span></label>
<?php if ($productsWithSchema === []): ?>
@@ -129,3 +147,4 @@ $steps = [
</div>
<script type="module" src="<?php e(assetVersion('js/components/app-lookup-field.js')); ?>"></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/handover-domain-select.js')); ?>"></script>

View File

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

View File

@@ -100,6 +100,8 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id']
<?php
$debitorNo = trim((string) ($handover['debitor_no'] ?? ''));
$debitorName = trim((string) ($handover['debitor_name'] ?? ''));
$domainNo = trim((string) ($handover['domain_no'] ?? ''));
$domainUrl = trim((string) ($handover['domain_url'] ?? ''));
$canViewSoftwareProducts = $canViewSoftwareProducts ?? false;
$productCode = $handover['product_code'] ?? '';
?>
@@ -111,6 +113,15 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id']
<?php e($debitorName !== '' ? $debitorName : '—'); ?>
<?php endif; ?>
</h2>
<p>
<?php if ($domainNo !== ''): ?>
<a href="<?php e(lurl('helpdesk/domain/' . rawurlencode($domainNo))); ?>"><?php e($domainUrl !== '' ? $domainUrl : $domainNo); ?></a>
<?php elseif ($domainUrl !== ''): ?>
<?php e($domainUrl); ?>
<?php else: ?>
<?php endif; ?>
</p>
<p>
<?php if ($canViewSoftwareProducts && $productCode !== ''): ?>
<a href="<?php e(lurl('helpdesk/software-products/edit/' . rawurlencode($productCode))); ?>"><?php e($productName ?? $productCode); ?></a>
@@ -180,7 +191,12 @@ $editBasePath = $editBasePath ?? ('helpdesk/handovers/edit/' . ($handover['id']
<input type="hidden" name="action" value="restore">
<input type="hidden" name="restore_revision" value="<?php e($activeRevisionNumber); ?>">
<?php \MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" class="secondary outline small" onclick="return confirm('<?php e(t('Restore this version?')); ?>');">
<button
type="submit"
class="secondary outline small"
data-confirm-message="<?php e(t('Restore this version?')); ?>"
data-confirm-variant="warning"
>
<i class="bi bi-arrow-counterclockwise"></i> <?php e(t('Restore this version')); ?>
</button>
</form>

View File

@@ -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']));

View File

@@ -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;
?>
<?php require templatePath('partials/app-flash.phtml'); ?>
@@ -15,6 +16,11 @@ $canCreate = $canCreate ?? false;
$listTitle = t('Handovers');
ob_start();
?>
<?php if ($canManage): ?>
<button type="button" class="danger outline small" data-handovers-bulk="delete" hidden>
<i class="bi bi-trash3-fill"></i> <?php e(t('Delete')); ?>
</button>
<?php endif; ?>
<?php if ($canCreate): ?>
<a role="button" class="app-action-success" href="<?php e(requestPathWithReturnTarget('helpdesk/handovers/create', \MintyPHP\Http\Request::pathWithQuery())); ?>">
<i class="bi bi-plus"></i> <?php e(t('New handover')); ?>
@@ -33,6 +39,9 @@ require templatePath('partials/app-list-filters.phtml');
</div>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<?php if ($canManage): ?>
<script src="<?php e(assetVersion('vendor/gridjs/plugins/selection/selection.umd.js')); ?>"></script>
<?php endif; ?>
<script type="application/json" id="page-config-helpdesk-handovers"><?php gridJsonForJs([
'dataUrl' => lurl('helpdesk/handovers-data'),
'gridLang' => gridLang(),
@@ -40,13 +49,21 @@ require templatePath('partials/app-list-filters.phtml');
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'editBaseUrl' => lurl('helpdesk/handovers/edit/'),
'canManage' => $canManage,
'bulkUrl' => $canManage ? lurl('helpdesk/handovers/bulk/') : '',
'csrfKey' => $csrfKey ?? '',
'csrfToken' => $csrfToken ?? '',
'labels' => [
'id' => t('ID'),
'debitorName' => t('Customer'),
'productCode' => t('Software product'),
'domain' => t('Domain'),
'status' => t('Status'),
'debitorName' => t('Customer'),
'productDisplay' => t('Software product'),
'createdAt' => t('Created'),
'createdBy' => t('Created by'),
'selectAll' => t('Select all'),
'bulkDeleteConfirm' => t('Delete selected handovers?'),
'bulkDeleteSuccess' => t('%d handover(s) deleted'),
'bulkDeleteError' => t('Failed to delete handovers'),
],
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/pages/helpdesk-handovers-index.js')); ?>"></script>

View File

@@ -31,6 +31,7 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
data-translations="<?php e(json_encode([
'add_field' => 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'),

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'));
}
});
}
}

View File

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

View File

@@ -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(`<a href="${href}">${no}</a>`);
},
},
{
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 || '',
},
};

View File

@@ -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('<span class="text-muted">—</span>');
}
if (editUrl === '') {
return gridjs.html(id);
return gridjs.html(domain);
}
const href = escapeHtml(withCurrentListReturn(editUrl));
return gridjs.html(`<a href="${href}">#${id}</a>`);
return gridjs.html(`<a href="${href}">${domain}</a>`);
},
},
{ 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(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
},
},
{ 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();
}
});
});
}
}
}