feat(helpdesk): add handover protocol management

Implement handover protocols as a new entity in the helpdesk module,
allowing users to create fillable protocol records from admin-defined
software product schemas.

Key additions:
- DB migration (helpdesk_handovers table) with tenant scope
- HandoverService with status workflow (draft/in_progress/completed/archived)
- Three-tier permissions (view/create/manage)
- Two-step creation wizard (Stripe-style assistant)
- Grid.js list page with search and status filter
- Edit/detail page with aside metadata and status controls
- Reusable core autocomplete lookup component (app-lookup-field)
- Debitor lookup data endpoint for autocomplete
- Dynamic form rendering from schema snapshots
- 11 PHPUnit tests for HandoverService
- DE+EN i18n translations (48 keys each)

Also includes: PHPStan fixes (dead code removal, stale baseline cleanup),
software product edit title improvement, fieldset simplification,
and Stripe-style hover for schema preview.

Workflow: HD-HANDOVERS-001 (.agents/runs/HD-HANDOVERS-001/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 20:42:41 +02:00
parent 57b7920098
commit a26c106083
34 changed files with 2572 additions and 91 deletions

View File

@@ -45,6 +45,7 @@ return [
'css/components/app-breadcrumb.css', 'css/components/app-breadcrumb.css',
'css/components/app-tile.css', 'css/components/app-tile.css',
'css/components/app-tooltips.css', 'css/components/app-tooltips.css',
'css/components/app-lookup-field.css',
'css/components/app-page-editor.css', 'css/components/app-page-editor.css',
'css/vendor-overrides/editorjs.css', 'css/vendor-overrides/editorjs.css',
'css/components/app-page-copy.css', 'css/components/app-page-copy.css',

View File

@@ -410,5 +410,55 @@
"Field %d, option %d: label is required": "Feld %d, Option %d: Bezeichnung ist erforderlich", "Field %d, option %d: label is required": "Feld %d, Option %d: Bezeichnung ist erforderlich",
"Preview": "Vorschau", "Preview": "Vorschau",
"Add fields to see a live preview.": "Felder hinzufügen, um eine Vorschau zu sehen.", "Add fields to see a live preview.": "Felder hinzufügen, um eine Vorschau zu sehen.",
"Field": "Feld" "Field": "Feld",
"Operations": "Betrieb",
"Handovers": "Übergaben",
"Handover": "Übergabe",
"New handover": "Neue Übergabe",
"Handover created": "Übergabe erstellt",
"Handover updated": "Übergabe aktualisiert",
"Handover not found": "Übergabe nicht gefunden",
"Draft": "Entwurf",
"In progress": "In Bearbeitung",
"Completed": "Abgeschlossen",
"Archived": "Archiviert",
"Customer & product": "Kunde & Produkt",
"Protocol": "Protokoll",
"Select customer and product": "Kunde und Produkt auswählen",
"Choose the customer and software product for this handover protocol.": "Wählen Sie den Kunden und das Softwareprodukt für dieses Übergabeprotokoll.",
"Search by name or number...": "Nach Name oder Nummer suchen...",
"No customers found": "Keine Kunden gefunden",
"Search failed": "Suche fehlgeschlagen",
"Fill handover protocol": "Übergabeprotokoll ausfüllen",
"Create handover": "Übergabe erstellen",
"Continue": "Weiter",
"Cancel": "Abbrechen",
"Back": "Zurück",
"Back to list": "Zurück zur Liste",
"Please select a customer": "Bitte wählen Sie einen Kunden",
"Please select a software product": "Bitte wählen Sie ein Softwareprodukt",
"Software product has no handover protocol schema": "Softwareprodukt hat kein Übergabeprotokoll-Schema",
"No software products with handover protocol schemas found.": "Keine Softwareprodukte mit Übergabeprotokoll-Schemata gefunden.",
"Please complete step 1 first": "Bitte schließen Sie zuerst Schritt 1 ab",
"Debitor number is required": "Debitornummer ist erforderlich",
"Software product is required": "Softwareprodukt ist erforderlich",
"Software product not found": "Softwareprodukt nicht gefunden",
"Failed to create handover": "Übergabe konnte nicht erstellt werden",
"You do not have permission to edit this handover": "Sie haben keine Berechtigung, diese Übergabe zu bearbeiten",
"You do not have permission to set this status": "Sie haben keine Berechtigung, diesen Status zu setzen",
"Invalid status": "Ungültiger Status",
"Unknown field: %s": "Unbekanntes Feld: %s",
"%s is required": "%s ist erforderlich",
"Failed to save": "Speichern fehlgeschlagen",
"Failed to update status": "Status konnte nicht aktualisiert werden",
"No protocol fields defined.": "Keine Protokollfelder definiert.",
"Please select...": "Bitte auswählen...",
"Handover protocol": "Übergabeprotokoll",
"Change status": "Status ändern",
"Customer": "Kunde",
"Debitor No.": "Debitornr.",
"Progress": "Fortschritt",
"Created by": "Erstellt von",
"Search handovers...": "Übergaben suchen...",
"ID": "ID"
} }

View File

@@ -410,5 +410,55 @@
"Field %d, option %d: label is required": "Field %d, option %d: label is required", "Field %d, option %d: label is required": "Field %d, option %d: label is required",
"Preview": "Preview", "Preview": "Preview",
"Add fields to see a live preview.": "Add fields to see a live preview.", "Add fields to see a live preview.": "Add fields to see a live preview.",
"Field": "Field" "Field": "Field",
"Operations": "Operations",
"Handovers": "Handovers",
"Handover": "Handover",
"New handover": "New handover",
"Handover created": "Handover created",
"Handover updated": "Handover updated",
"Handover not found": "Handover not found",
"Draft": "Draft",
"In progress": "In progress",
"Completed": "Completed",
"Archived": "Archived",
"Customer & product": "Customer & product",
"Protocol": "Protocol",
"Select customer and product": "Select customer and product",
"Choose the customer and software product for this handover protocol.": "Choose the customer and software product for this handover protocol.",
"Search by name or number...": "Search by name or number...",
"No customers found": "No customers found",
"Search failed": "Search failed",
"Fill handover protocol": "Fill handover protocol",
"Create handover": "Create handover",
"Continue": "Continue",
"Cancel": "Cancel",
"Back": "Back",
"Back to list": "Back to list",
"Please select a customer": "Please select a customer",
"Please select a software product": "Please select a software product",
"Software product has no handover protocol schema": "Software product has no handover protocol schema",
"No software products with handover protocol schemas found.": "No software products with handover protocol schemas found.",
"Please complete step 1 first": "Please complete step 1 first",
"Debitor number is required": "Debitor number is required",
"Software product is required": "Software product is required",
"Software product not found": "Software product not found",
"Failed to create handover": "Failed to create handover",
"You do not have permission to edit this handover": "You do not have permission to edit this handover",
"You do not have permission to set this status": "You do not have permission to set this status",
"Invalid status": "Invalid status",
"Unknown field: %s": "Unknown field: %s",
"%s is required": "%s is required",
"Failed to save": "Failed to save",
"Failed to update status": "Failed to update status",
"No protocol fields defined.": "No protocol fields defined.",
"Please select...": "Please select...",
"Handover protocol": "Handover protocol",
"Change status": "Change status",
"Customer": "Customer",
"Debitor No.": "Debitor No.",
"Progress": "Progress",
"Created by": "Created by",
"Search handovers...": "Search handovers...",
"ID": "ID"
} }

View File

@@ -13,6 +13,9 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view'; public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view';
public const ABILITY_RISK_RADAR = 'helpdesk.risk-radar.view'; public const ABILITY_RISK_RADAR = 'helpdesk.risk-radar.view';
public const ABILITY_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage'; public const ABILITY_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage';
public const ABILITY_HANDOVERS_VIEW = 'helpdesk.handovers.view';
public const ABILITY_HANDOVERS_CREATE = 'helpdesk.handovers.create';
public const ABILITY_HANDOVERS_MANAGE = 'helpdesk.handovers.manage';
public const PERMISSION_ACCESS = 'helpdesk.access'; public const PERMISSION_ACCESS = 'helpdesk.access';
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage'; public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
@@ -20,6 +23,12 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
public const PERMISSION_RISK_RADAR = 'helpdesk.risk-radar.view'; public const PERMISSION_RISK_RADAR = 'helpdesk.risk-radar.view';
/** @api Used in authorize() match for ability resolution */ /** @api Used in authorize() match for ability resolution */
public const PERMISSION_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage'; public const PERMISSION_SOFTWARE_PRODUCTS_MANAGE = 'helpdesk.software-products.manage';
/** @api Used in authorize() match for ability resolution */
public const PERMISSION_HANDOVERS_VIEW = 'helpdesk.handovers.view';
/** @api Used in authorize() match for ability resolution */
public const PERMISSION_HANDOVERS_CREATE = 'helpdesk.handovers.create';
/** @api Used in authorize() match for ability resolution */
public const PERMISSION_HANDOVERS_MANAGE = 'helpdesk.handovers.manage';
public function __construct( public function __construct(
private readonly PermissionService $permissionService private readonly PermissionService $permissionService
@@ -28,7 +37,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
public function supports(string $ability): bool public function supports(string $ability): bool
{ {
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE], true); return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE, self::ABILITY_HANDOVERS_VIEW, self::ABILITY_HANDOVERS_CREATE, self::ABILITY_HANDOVERS_MANAGE], true);
} }
public function authorize(string $ability, array $context = []): AuthorizationDecision public function authorize(string $ability, array $context = []): AuthorizationDecision
@@ -44,6 +53,9 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD, self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD,
self::ABILITY_RISK_RADAR => self::PERMISSION_RISK_RADAR, self::ABILITY_RISK_RADAR => self::PERMISSION_RISK_RADAR,
self::ABILITY_SOFTWARE_PRODUCTS_MANAGE => self::PERMISSION_SOFTWARE_PRODUCTS_MANAGE, self::ABILITY_SOFTWARE_PRODUCTS_MANAGE => self::PERMISSION_SOFTWARE_PRODUCTS_MANAGE,
self::ABILITY_HANDOVERS_VIEW => self::PERMISSION_HANDOVERS_VIEW,
self::ABILITY_HANDOVERS_CREATE => self::PERMISSION_HANDOVERS_CREATE,
self::ABILITY_HANDOVERS_MANAGE => self::PERMISSION_HANDOVERS_MANAGE,
default => null, default => null,
}; };

View File

@@ -20,7 +20,9 @@ use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine; use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler; use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository; use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Settings\SettingServicesFactory; use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\SettingsMetadataGateway; use MintyPHP\Service\Settings\SettingsMetadataGateway;
@@ -104,5 +106,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$container->set(SoftwareProductSyncJobHandler::class, static fn (AppContainer $c): SoftwareProductSyncJobHandler => new SoftwareProductSyncJobHandler( $container->set(SoftwareProductSyncJobHandler::class, static fn (AppContainer $c): SoftwareProductSyncJobHandler => new SoftwareProductSyncJobHandler(
$c->get(SoftwareProductSyncService::class) $c->get(SoftwareProductSyncService::class)
)); ));
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
$container->set(HandoverService::class, static fn (AppContainer $c): HandoverService => new HandoverService(
$c->get(HandoverRepository::class),
$c->get(SoftwareProductService::class)
));
} }
} }

View File

@@ -22,11 +22,17 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
$canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed(); $canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed();
$canViewRiskRadar = $authorizationService->authorize('helpdesk.risk-radar.view', $actorContext)->isAllowed(); $canViewRiskRadar = $authorizationService->authorize('helpdesk.risk-radar.view', $actorContext)->isAllowed();
$canManageSoftwareProducts = $authorizationService->authorize('helpdesk.software-products.manage', $actorContext)->isAllowed(); $canManageSoftwareProducts = $authorizationService->authorize('helpdesk.software-products.manage', $actorContext)->isAllowed();
$canViewHandovers = $authorizationService->authorize('helpdesk.handovers.view', $actorContext)->isAllowed();
$canCreateHandovers = $authorizationService->authorize('helpdesk.handovers.create', $actorContext)->isAllowed();
$canManageHandovers = $authorizationService->authorize('helpdesk.handovers.manage', $actorContext)->isAllowed();
} catch (\Throwable) { } catch (\Throwable) {
$canManageSettings = false; $canManageSettings = false;
$canViewTeam = false; $canViewTeam = false;
$canViewRiskRadar = false; $canViewRiskRadar = false;
$canManageSoftwareProducts = false; $canManageSoftwareProducts = false;
$canViewHandovers = false;
$canCreateHandovers = false;
$canManageHandovers = false;
} }
return ['helpdesk.nav' => [ return ['helpdesk.nav' => [
@@ -34,6 +40,9 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
'can_view_team' => $canViewTeam, 'can_view_team' => $canViewTeam,
'can_view_risk_radar' => $canViewRiskRadar, 'can_view_risk_radar' => $canViewRiskRadar,
'can_manage_software_products' => $canManageSoftwareProducts, 'can_manage_software_products' => $canManageSoftwareProducts,
'can_view_handovers' => $canViewHandovers,
'can_create_handovers' => $canCreateHandovers,
'can_manage_handovers' => $canManageHandovers,
]]; ]];
} }
} }

View File

@@ -0,0 +1,150 @@
<?php
namespace MintyPHP\Module\Helpdesk\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class HandoverRepository
{
/**
* @param array<string, mixed> $data
* @return int|null Inserted ID or null on failure
*/
public function insert(array $data): ?int
{
$result = DB::insert(
'INSERT INTO helpdesk_handovers '
. '(tenant_id, debitor_no, debitor_name, product_code, 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['status'] ?? 'draft'),
$data['schema_snapshot'] ?? null,
$data['field_values'] ?? null,
(string) ($data['created_by'] ?? 0)
);
if ($result === false) {
return null;
}
return (int) $result;
}
public function findById(int $tenantId, int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'SELECT * FROM helpdesk_handovers WHERE id = ? AND tenant_id = ? LIMIT 1',
(string) $id,
(string) $tenantId
);
return $this->normalizeRow($row);
}
/**
* @param array<string, mixed> $filters
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$status = trim((string) ($filters['status'] ?? ''));
$productCode = trim((string) ($filters['product_code'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 100, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'],
'created_at',
'desc'
);
$where = ['h.tenant_id = ?'];
$params = [(string) $tenantId];
RepoQuery::addLikeFilter($where, $params, ['h.debitor_name', 'h.debitor_no', 'h.product_code'], $search);
if ($status !== '' && $status !== 'all') {
$where[] = 'h.status = ?';
$params[] = $status;
}
if ($productCode !== '') {
$where[] = 'h.product_code = ?';
$params[] = $productCode;
}
$whereSql = ' WHERE ' . implode(' AND ', $where);
$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'
. ' LEFT JOIN users u ON u.id = h.created_by'
. $whereSql
. sprintf(' ORDER BY h.`%s` %s LIMIT ? OFFSET ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public function updateFieldValues(int $tenantId, int $id, string $fieldValuesJson, int $updatedBy): bool
{
$result = DB::update(
'UPDATE helpdesk_handovers SET field_values = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$fieldValuesJson,
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
public function updateStatus(int $tenantId, int $id, string $status, int $updatedBy): bool
{
$result = DB::update(
'UPDATE helpdesk_handovers SET status = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
$status,
(string) $updatedBy,
(string) $id,
(string) $tenantId
);
return $result !== false;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['helpdesk_handovers'] ?? $row;
if (!is_array($item) || !isset($item['id'])) {
return null;
}
return $item;
}
}

View File

@@ -146,6 +146,34 @@ class SoftwareProductRepository
return is_int($result) ? $result : 0; return is_int($result) ? $result : 0;
} }
/**
* List active products that have a handover protocol schema defined.
*
* @return list<array<string, mixed>>
*/
public function listWithSchema(): array
{
$rows = DB::select(
'SELECT code, bc_description, name FROM helpdesk_software_products '
. 'WHERE active = 1 AND handover_protocol_schema IS NOT NULL '
. 'ORDER BY COALESCE(NULLIF(name, \'\'), bc_description) ASC'
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$item = $row['helpdesk_software_products'] ?? $row;
if (is_array($item) && isset($item['code'])) {
$result[] = $item;
}
}
return $result;
}
private function normalizeRow(mixed $row): ?array private function normalizeRow(mixed $row): ?array
{ {
if (!is_array($row)) { if (!is_array($row)) {

View File

@@ -22,7 +22,6 @@ class BcODataGateway
public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog'; public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog';
public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test'; public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test';
public const ENTITY_MEETINGS = 'FS_Debitor_Meetings'; public const ENTITY_MEETINGS = 'FS_Debitor_Meetings';
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
/** @api Used by listContractTypes() */ /** @api Used by listContractTypes() */
public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types'; public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types';
@@ -828,57 +827,6 @@ class BcODataGateway
return $this->extractODataValues($response); return $this->extractODataValues($response);
} }
/**
* Get all contract domains.
*
* Fetches all domains from the FS_Contract_Domains entity.
* Filtering, sorting and pagination are handled PHP-side by the caller.
*
* @return array<int, array<string, mixed>>
*/
public function listDomains(): array
{
$select = 'No,Customer_No,Customer_Name,URL,State,Administration';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
. '?$top=5000'
. '&$select=' . rawurlencode($select)
. '&$orderby=' . rawurlencode('Customer_Name asc');
$response = $this->request('GET', $url);
if ($response === null) {
return [];
}
return $this->extractODataValues($response);
}
/**
* Get all contract lines of type "Domain".
*
* Returns contract line metadata for domain-type lines,
* keyed by DNS number (No field). Used to enrich the domain
* list with contract type information (PI_Header_Type).
*
* @return array<int, array<string, mixed>>
*/
public function listDomainContractLines(): array
{
$filter = "Type eq 'Domain'";
$select = 'No,Header_No,PI_Header_Type,PI_Header_Description,PI_Header_State,Line_State';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES)
. '?$filter=' . rawurlencode($filter)
. '&$top=5000'
. '&$select=' . rawurlencode($select)
. '&$orderby=' . rawurlencode('No asc');
$response = $this->request('GET', $url);
if ($response === null) {
return [];
}
return $this->extractODataValues($response);
}
/** /**
* Get all contract types where Create_SaaS_License is true. * Get all contract types where Create_SaaS_License is true.
* *

View File

@@ -0,0 +1,292 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
/** @api Called from pages/helpdesk/handovers action files */
class HandoverService
{
/** @api Used in pages and templates for status checks */
public const STATUS_DRAFT = 'draft';
/** @api Used in pages and templates for status checks */
public const STATUS_IN_PROGRESS = 'in_progress';
/** @api Used in pages and templates for status checks */
public const STATUS_COMPLETED = 'completed';
/** @api Used in pages and templates for status checks */
public const STATUS_ARCHIVED = 'archived';
/** @api Used in validation */
public const ALL_STATUSES = [
self::STATUS_DRAFT,
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
self::STATUS_ARCHIVED,
];
public const PERMISSION_CREATE = 'create';
public const PERMISSION_MANAGE = 'manage';
/**
* Status transitions allowed per permission level.
* create-level: can set draft, in_progress, completed.
* manage-level: can set any status.
*/
private const ALLOWED_STATUS_BY_PERMISSION = [
self::PERMISSION_CREATE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED],
self::PERMISSION_MANAGE => [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS, self::STATUS_COMPLETED, self::STATUS_ARCHIVED],
];
/**
* Statuses in which a create-level user may edit field values.
*/
private const EDITABLE_STATUSES_CREATE = [self::STATUS_DRAFT, self::STATUS_IN_PROGRESS];
public function __construct(
private readonly HandoverRepository $repository,
private readonly SoftwareProductService $softwareProductService,
) {
}
/**
* Create a new handover with a schema snapshot from the software product.
*
* @return array{ok: bool, id: int|null, errors: array<string, string>}
*/
public function create(
int $tenantId,
string $debitorNo,
string $debitorName,
string $productCode,
int $userId,
): array {
$errors = [];
$debitorNo = trim($debitorNo);
if ($debitorNo === '') {
$errors['debitor_no'] = t('Debitor number is required');
}
$productCode = trim($productCode);
if ($productCode === '') {
$errors['product_code'] = t('Software product is required');
}
if ($errors !== []) {
return ['ok' => false, 'id' => null, 'errors' => $errors];
}
$product = $this->softwareProductService->findByCode($productCode);
if ($product === null) {
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product not found')]];
}
$schemaRaw = $product['handover_protocol_schema'] ?? null;
if ($schemaRaw === null || $schemaRaw === '') {
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product has no handover protocol schema')]];
}
$schema = json_decode($schemaRaw, true);
if (!is_array($schema) || empty($schema['fields'])) {
return ['ok' => false, 'id' => null, 'errors' => ['product_code' => t('Software product has no handover protocol schema')]];
}
$id = $this->repository->insert([
'tenant_id' => $tenantId,
'debitor_no' => $debitorNo,
'debitor_name' => trim($debitorName),
'product_code' => $productCode,
'status' => self::STATUS_DRAFT,
'schema_snapshot' => $schemaRaw,
'field_values' => json_encode(new \stdClass(), JSON_FORCE_OBJECT),
'created_by' => $userId,
]);
if ($id === null) {
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to create handover')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
/**
* Update field values for a handover.
*
* @param array<string, mixed> $fieldValues
* @return array{ok: bool, errors: array<string, string>}
*/
public function updateFields(
int $tenantId,
int $id,
array $fieldValues,
int $userId,
string $permissionLevel,
): array {
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
if (!$this->canEdit($handover['status'], $permissionLevel)) {
return ['ok' => false, 'errors' => ['general' => t('You do not have permission to edit this handover')]];
}
$schema = json_decode((string) ($handover['schema_snapshot'] ?? '{}'), true);
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
$validationErrors = $this->validateFieldValues($fieldValues, $schemaFields);
if ($validationErrors !== []) {
return ['ok' => false, 'errors' => $validationErrors];
}
$json = json_encode($fieldValues, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$updated = $this->repository->updateFieldValues($tenantId, $id, $json, $userId);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to save')]];
}
return ['ok' => true, 'errors' => []];
}
/**
* Change the status of a handover.
*
* @return array{ok: bool, errors: array<string, string>}
*/
public function changeStatus(
int $tenantId,
int $id,
string $newStatus,
int $userId,
string $permissionLevel,
): array {
if (!in_array($newStatus, self::ALL_STATUSES, true)) {
return ['ok' => false, 'errors' => ['status' => t('Invalid status')]];
}
$handover = $this->repository->findById($tenantId, $id);
if ($handover === null) {
return ['ok' => false, 'errors' => ['general' => t('Handover not found')]];
}
$allowed = self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? [];
if (!in_array($newStatus, $allowed, true)) {
return ['ok' => false, 'errors' => ['status' => t('You do not have permission to set this status')]];
}
$updated = $this->repository->updateStatus($tenantId, $id, $newStatus, $userId);
if (!$updated) {
return ['ok' => false, 'errors' => ['general' => t('Failed to update status')]];
}
return ['ok' => true, 'errors' => []];
}
/**
* @return array{total: int, rows: list<array<string, mixed>>}
*/
public function listPaged(int $tenantId, array $filters): array
{
return $this->repository->listPaged($tenantId, $filters);
}
public function findById(int $tenantId, int $id): ?array
{
return $this->repository->findById($tenantId, $id);
}
/**
* Check if a user with the given permission level can edit a handover in the given status.
*/
public function canEdit(string $status, string $permissionLevel): bool
{
if ($permissionLevel === self::PERMISSION_MANAGE) {
return true;
}
if ($permissionLevel === self::PERMISSION_CREATE) {
return in_array($status, self::EDITABLE_STATUSES_CREATE, true);
}
return false;
}
/**
* Get the list of statuses a user with the given permission level can set.
*
* @return list<string>
*/
public function getAllowedStatuses(string $permissionLevel): array
{
return self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? [];
}
/**
* Validate field values against the schema snapshot.
*
* @param array<string, mixed> $fieldValues
* @param list<array<string, mixed>> $schemaFields
* @return array<string, string>
*/
private function validateFieldValues(array $fieldValues, array $schemaFields): array
{
$errors = [];
$allowedKeys = [];
$requiredKeys = [];
foreach ($schemaFields as $field) {
$key = $field['key'] ?? null;
if ($key === null) {
continue;
}
$allowedKeys[$key] = true;
if (!empty($field['required'])) {
$requiredKeys[$key] = $field['label'] ?? $key;
}
}
foreach ($fieldValues as $key => $value) {
if (!isset($allowedKeys[$key])) {
$errors[$key] = t('Unknown field: %s', $key);
}
}
foreach ($requiredKeys as $key => $label) {
$value = $fieldValues[$key] ?? '';
if (is_string($value) && trim($value) === '') {
$errors[$key] = t('%s is required', $label);
}
}
return $errors;
}
/**
* Get the human-readable label for a status.
*/
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_DRAFT => t('Draft'),
self::STATUS_IN_PROGRESS => t('In progress'),
self::STATUS_COMPLETED => t('Completed'),
self::STATUS_ARCHIVED => t('Archived'),
default => $status,
};
}
/**
* Get the badge variant for a status.
*/
public static function statusVariant(string $status): string
{
return match ($status) {
self::STATUS_DRAFT => 'neutral',
self::STATUS_IN_PROGRESS => 'warning',
self::STATUS_COMPLETED => 'success',
self::STATUS_ARCHIVED => 'neutral',
default => 'neutral',
};
}
}

View File

@@ -28,6 +28,16 @@ class SoftwareProductService
return $this->repository->listPaged($filters); return $this->repository->listPaged($filters);
} }
/**
* List active products that have a handover protocol schema defined.
*
* @return list<array<string, mixed>>
*/
public function listWithSchema(): array
{
return $this->repository->listWithSchema();
}
private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select']; private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph']; private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
private const MAX_SCHEMA_FIELDS = 50; private const MAX_SCHEMA_FIELDS = 50;

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS `helpdesk_handovers` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`debitor_no` VARCHAR(30) NOT NULL,
`debitor_name` VARCHAR(255) NOT NULL DEFAULT '',
`product_code` VARCHAR(30) NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft',
`schema_snapshot` TEXT NULL DEFAULT NULL,
`field_values` TEXT NULL DEFAULT NULL,
`created_by` INT UNSIGNED NOT NULL,
`updated_by` INT UNSIGNED NULL DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_hh_tenant` (`tenant_id`),
KEY `idx_hh_debitor` (`tenant_id`, `debitor_no`),
KEY `idx_hh_product` (`tenant_id`, `product_code`),
KEY `idx_hh_status` (`tenant_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -42,6 +42,11 @@ return [
['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'], ['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'],
['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'], ['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'],
['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'], ['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'],
['path' => 'helpdesk/handovers', 'target' => 'helpdesk/handovers'],
['path' => 'helpdesk/handovers-data', 'target' => 'helpdesk/handovers-data'],
['path' => 'helpdesk/handovers/create', 'target' => 'helpdesk/handovers/create'],
['path' => 'helpdesk/handovers/edit/{id}', 'target' => 'helpdesk/handovers/edit'],
['path' => 'helpdesk/debitor-lookup-data', 'target' => 'helpdesk/debitor-lookup-data'],
], ],
'public_paths' => [], 'public_paths' => [],
@@ -101,6 +106,24 @@ return [
'active' => true, 'active' => true,
'is_system' => true, 'is_system' => true,
], ],
[
'key' => 'helpdesk.handovers.view',
'description' => 'View helpdesk handover protocols',
'active' => true,
'is_system' => true,
],
[
'key' => 'helpdesk.handovers.create',
'description' => 'Create and edit own helpdesk handover protocols',
'active' => true,
'is_system' => true,
],
[
'key' => 'helpdesk.handovers.manage',
'description' => 'Manage all helpdesk handover protocols (edit any, archive)',
'active' => true,
'is_system' => true,
],
], ],
'search_resources' => [], 'search_resources' => [],

View File

@@ -0,0 +1,46 @@
<?php
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
gridRequireGetRequest();
$request = requestInput();
$query = trim((string) $request->query('q', ''));
if ($query === '' || mb_strlen($query) < 2) {
header('Content-Type: application/json; charset=utf-8');
echo '[]';
return;
}
$result = app(DebitorSearchService::class)->search($query);
$items = [];
if (($result['status'] ?? '') === 'success') {
foreach ((array) ($result['results'] ?? []) as $row) {
if (!is_array($row)) {
continue;
}
$no = trim((string) ($row['No'] ?? ''));
$name = trim((string) ($row['Name'] ?? ''));
if ($no === '') {
continue;
}
$items[] = [
'value' => $no,
'label' => $no . ' — ' . $name,
'name' => $name,
'meta' => trim((string) ($row['City'] ?? '')),
];
}
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode($items, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

View File

@@ -22,7 +22,7 @@ $drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState']; $toolbarFilterState = $listFilterContext['toolbarFilterState'];
$clientFilterSchema = $listFilterContext['clientFilterSchema']; $clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig']; $searchConfig = $listFilterContext['searchConfig'];
$schemaByKey = $listFilterContext['schemaByKey'] ?? []; $schemaByKey = $listFilterContext['schemaByKey'];
$filterChipMeta = [ $filterChipMeta = [
'search' => [ 'search' => [
'label' => t('Search'), 'label' => t('Search'),

View File

@@ -0,0 +1,42 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_VIEW);
gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/handovers/filter-schema.php');
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$service = app(HandoverService::class);
$result = $service->listPaged($tenantId, $filters);
$rows = $result['rows'] ?? [];
$total = $result['total'] ?? 0;
$editBaseUrl = lurl('helpdesk/handovers/edit/');
$preparedRows = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
$status = (string) ($row['status'] ?? 'draft');
$preparedRows[] = [
'id' => $id,
'debitor_name' => (string) ($row['debitor_name'] ?? ''),
'product_code' => (string) ($row['product_code'] ?? ''),
'status' => $status,
'status_label' => HandoverService::statusLabel($status),
'status_variant' => HandoverService::statusVariant($status),
'created_at' => (string) ($row['created_at'] ?? ''),
'created_by_name' => (string) ($row['created_by_name'] ?? ''),
'edit_url' => $id > 0 ? $editBaseUrl . $id : '',
];
}
gridJsonDataResult($preparedRows, $total);

View File

@@ -0,0 +1,99 @@
<?php
/**
* Renders a fillable form from a handover schema snapshot.
*
* Expected variables:
* $schemaFields — list of field definitions from schema_snapshot['fields']
* $fieldValues — associative array of current field values (key => value)
* $readonly — bool, if true all inputs are disabled
*/
$schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$readonly = !empty($readonly);
?>
<?php if ($schemaFields === []): ?>
<p class="app-muted"><?php e(t('No protocol fields defined.')); ?></p>
<?php else: ?>
<?php foreach ($schemaFields as $index => $field):
$type = (string) ($field['type'] ?? '');
$label = (string) ($field['label'] ?? '');
$key = (string) ($field['key'] ?? '');
$required = !empty($field['required']);
$value = $key !== '' ? (string) ($fieldValues[$key] ?? '') : '';
$inputId = 'handover-field-' . $index;
?>
<?php if ($type === 'heading'): ?>
<h3><?php e($label); ?></h3>
<?php elseif ($type === 'paragraph'): ?>
<p class="app-muted"><?php e($label); ?></p>
<?php elseif ($type === 'checkbox'): ?>
<label class="handover-form-checkbox-label">
<input
type="checkbox"
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
value="1"
<?php if ($value === '1'): ?> checked<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<?php elseif ($type === 'select'): ?>
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<select
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach (($field['options'] ?? []) as $opt): ?>
<option value="<?php e((string) ($opt['value'] ?? '')); ?>"<?php if ($value === (string) ($opt['value'] ?? '')): ?> selected<?php endif; ?>>
<?php e((string) ($opt['label'] ?? '')); ?>
</option>
<?php endforeach; ?>
</select>
<?php elseif ($type === 'textarea'): ?>
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<textarea
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
rows="4"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
><?php e($value); ?></textarea>
<?php else: ?>
<?php
$inputType = match ($type) {
'number' => 'number',
'date' => 'date',
default => 'text',
};
?>
<label for="<?php e($inputId); ?>">
<?php e($label); ?>
<?php if ($required): ?><span class="handover-form-required"> *</span><?php endif; ?>
</label>
<input
type="<?php e($inputType); ?>"
id="<?php e($inputId); ?>"
name="field_<?php e($key); ?>"
value="<?php e($value); ?>"
<?php if ($required): ?> aria-required="true"<?php endif; ?>
<?php if ($readonly): ?> disabled<?php endif; ?>
>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>

View File

@@ -0,0 +1,181 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_CREATE);
$request = requestInput();
$returnTarget = requestResolveReturnTarget('helpdesk/handovers');
$closeTarget = requestResolveReturnTarget('helpdesk/handovers');
$createTarget = requestPathWithReturnTarget('helpdesk/handovers/create', $returnTarget);
$step = (int) $request->query('step', '1');
if ($step < 1 || $step > 2) {
$step = 1;
}
$errorBag = formErrors();
$productService = app(SoftwareProductService::class);
$handoverService = app(HandoverService::class);
$sessionStore = app(SessionStoreInterface::class);
$appSession = $sessionStore->all();
$sessionKey = 'module.helpdesk.handover_create';
// ── Step 1: Select debitor + product ─────────────────────────────────
if ($step === 1) {
$productsWithSchema = $productService->listWithSchema();
$form = [
'debitor_no' => (string) $request->body('debitor_no', ''),
'debitor_name' => (string) $request->body('debitor_name', ''),
'product_code' => (string) $request->body('product_code', ''),
];
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget, 'csrf_expired');
Router::redirect($createTarget);
return;
}
if (trim($form['debitor_no']) === '') {
$errorBag->add('debitor_no', t('Please select a customer'));
}
if (trim($form['product_code']) === '') {
$errorBag->add('product_code', t('Please select a software product'));
} else {
$product = $productService->findByCode($form['product_code']);
if ($product === null) {
$errorBag->add('product_code', t('Software product not found'));
} elseif (empty($product['handover_protocol_schema'])) {
$errorBag->add('product_code', t('Software product has no handover protocol schema'));
}
}
if (!$errorBag->hasAny()) {
$sessionStore->set($sessionKey, [
'debitor_no' => trim($form['debitor_no']),
'debitor_name' => trim($form['debitor_name']),
'product_code' => trim($form['product_code']),
]);
Router::redirect(lurl('helpdesk/handovers/create') . '?step=2' . ($returnTarget ? '&return=' . rawurlencode($returnTarget) : ''));
return;
}
}
$errors = $errorBag->toFlatList();
$validationSummaryErrors = $errorBag->toArray();
}
// ── Step 2: Fill protocol form ───────────────────────────────────────
if ($step === 2) {
$wizardData = $sessionStore->get($sessionKey);
if (!is_array($wizardData) || empty($wizardData['debitor_no']) || empty($wizardData['product_code'])) {
Flash::error(t('Please complete step 1 first'), $createTarget, 'wizard_step_missing');
Router::redirect($createTarget);
return;
}
$product = $productService->findByCode($wizardData['product_code']);
if ($product === null) {
Flash::error(t('Software product not found'), $createTarget, 'product_not_found');
Router::redirect($createTarget);
return;
}
$schemaRaw = $product['handover_protocol_schema'] ?? null;
$schema = is_array(json_decode((string) $schemaRaw, true)) ? json_decode($schemaRaw, true) : null;
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
$fieldValues = [];
$productDisplayName = trim((string) ($product['name'] ?? '')) !== ''
? $product['name']
: (string) ($product['bc_description'] ?? $wizardData['product_code']);
if ($request->isMethod('POST')) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $createTarget . '?step=2', 'csrf_expired');
Router::redirect($createTarget . '?step=2');
return;
}
// Collect field values from POST
foreach ($schemaFields as $field) {
$key = $field['key'] ?? null;
if ($key === null) {
continue;
}
$type = $field['type'] ?? 'text';
if ($type === 'checkbox') {
$fieldValues[$key] = $request->body('field_' . $key, '') !== '' ? '1' : '0';
} else {
$fieldValues[$key] = (string) $request->body('field_' . $key, '');
}
}
$tenantId = (int) ($appSession['current_tenant']['id'] ?? 0);
$userId = (int) ($appSession['user']['id'] ?? 0);
$result = $handoverService->create(
$tenantId,
$wizardData['debitor_no'],
$wizardData['debitor_name'],
$wizardData['product_code'],
$userId
);
if (!($result['ok'] ?? false)) {
$errors = array_values($result['errors'] ?? []);
$validationSummaryErrors = $result['errors'] ?? [];
} else {
$handoverId = $result['id'];
// Save field values
$authService = app(\MintyPHP\Service\Access\AuthorizationService::class);
$canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE, ['actor_user_id' => $userId])->isAllowed();
$permLevel = $canManage
? HandoverService::PERMISSION_MANAGE
: HandoverService::PERMISSION_CREATE;
$handoverService->updateFields($tenantId, $handoverId, $fieldValues, $userId, $permLevel);
// Clear wizard session
$sessionStore->remove($sessionKey);
$editUrl = lurl('helpdesk/handovers/edit/' . $handoverId);
Flash::success(t('Handover created'), $editUrl, 'handover_created');
Router::redirect($editUrl);
return;
}
}
$errors = $errors ?? $errorBag->toFlatList();
$validationSummaryErrors = $validationSummaryErrors ?? $errorBag->toArray();
}
Buffer::set('title', t('New handover'));
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Handovers'), 'path' => 'helpdesk/handovers'],
['label' => t('New handover')],
];

View File

@@ -0,0 +1,135 @@
<?php
$step = $step ?? 1;
$closeTarget = $closeTarget ?? 'helpdesk/handovers';
$createTarget = $createTarget ?? 'helpdesk/handovers/create';
$form = is_array($form ?? null) ? $form : [];
$errors = is_array($errors ?? null) ? $errors : [];
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
$productsWithSchema = is_array($productsWithSchema ?? null) ? $productsWithSchema : [];
$schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$wizardData = is_array($wizardData ?? null) ? $wizardData : [];
$productDisplayName = $productDisplayName ?? '';
$steps = [
['label' => t('Customer & product'), 'number' => 1],
['label' => t('Protocol'), 'number' => 2],
];
?>
<div class="app-wizard-container">
<?php require templatePath('partials/app-flash.phtml'); ?>
<div class="app-wizard-header">
<a href="<?php e(lurl($closeTarget)); ?>" class="app-wizard-back" data-tooltip="<?php e(t('Back to list')); ?>">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="app-wizard-title"><?php e(t('New handover')); ?></h1>
</div>
<!-- Step indicator -->
<nav class="app-wizard-steps" aria-label="<?php e(t('Progress')); ?>">
<ol>
<?php foreach ($steps as $s): ?>
<li class="app-wizard-step<?php if ($s['number'] === $step): ?> is-active<?php elseif ($s['number'] < $step): ?> is-completed<?php endif; ?>"
<?php if ($s['number'] === $step): ?>aria-current="step"<?php endif; ?>>
<span class="app-wizard-step-number"><?php e($s['number']); ?></span>
<span class="app-wizard-step-label"><?php e($s['label']); ?></span>
</li>
<?php if ($s['number'] < count($steps)): ?>
<li class="app-wizard-step-connector<?php if ($s['number'] < $step): ?> is-completed<?php endif; ?>" aria-hidden="true"></li>
<?php endif; ?>
<?php endforeach; ?>
</ol>
</nav>
<?php if ($validationSummaryErrors): ?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php endif; ?>
<!-- Step 1: Customer & Product -->
<?php if ($step === 1): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-1">
<h2 class="app-wizard-card-heading"><?php e(t('Select customer and product')); ?></h2>
<p class="app-wizard-card-description"><?php e(t('Choose the customer and software product for this handover protocol.')); ?></p>
<div class="app-wizard-field-group">
<label><?php e(t('Customer')); ?> <span class="handover-form-required">*</span></label>
<div
data-app-lookup
data-lookup-url="<?php e(lurl('helpdesk/debitor-lookup-data')); ?>"
data-lookup-min-chars="2"
data-lookup-debounce="300"
data-lookup-value-key="value"
data-lookup-display-key="label"
data-lookup-placeholder="<?php e(t('Search by name or number...')); ?>"
data-lookup-empty-text="<?php e(t('No customers found')); ?>"
data-lookup-error-text="<?php e(t('Search failed')); ?>"
data-lookup-initial-value="<?php e($form['debitor_no'] ?? ''); ?>"
data-lookup-initial-display="<?php e($form['debitor_name'] ?? ''); ?>"
>
<input type="hidden" name="debitor_no" data-lookup-value value="<?php e($form['debitor_no'] ?? ''); ?>">
<input type="hidden" name="debitor_name" data-lookup-display-value value="<?php e($form['debitor_name'] ?? ''); ?>">
</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 === []): ?>
<div class="notice" data-variant="info" role="alert">
<p><?php e(t('No software products with handover protocol schemas found.')); ?></p>
</div>
<?php else: ?>
<select id="wizard-product-code" name="product_code">
<option value=""><?php e(t('Please select...')); ?></option>
<?php foreach ($productsWithSchema as $p):
$code = (string) ($p['code'] ?? '');
$name = trim((string) ($p['name'] ?? ''));
$desc = (string) ($p['bc_description'] ?? '');
$displayLabel = $name !== '' ? $name : ($desc !== '' ? $desc : $code);
?>
<option value="<?php e($code); ?>"<?php if (($form['product_code'] ?? '') === $code): ?> selected<?php endif; ?>>
<?php e($displayLabel); ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($closeTarget)); ?>" class="secondary outline"><?php e(t('Cancel')); ?></a>
<button type="submit" class="primary"><?php e(t('Continue')); ?></button>
</div>
</form>
</div>
<!-- Step 2: Fill protocol -->
<?php elseif ($step === 2): ?>
<div class="app-wizard-card">
<form method="post" id="wizard-step-2">
<h2 class="app-wizard-card-heading"><?php e(t('Fill handover protocol')); ?></h2>
<p class="app-wizard-card-description">
<?php e($wizardData['debitor_name'] ?? ''); ?> &mdash; <?php e($productDisplayName); ?>
</p>
<div class="app-wizard-protocol-form">
<?php
$readonly = false;
require __DIR__ . '/_form.phtml';
?>
</div>
<?php \MintyPHP\Session::getCsrfInput(); ?>
<div class="app-wizard-actions">
<a href="<?php e(lurl($createTarget)); ?>" class="secondary outline"><?php e(t('Back')); ?></a>
<button type="submit" class="primary"><?php e(t('Create handover')); ?></button>
</div>
</form>
</div>
<?php endif; ?>
</div>
<script type="module" src="<?php e(assetVersion('js/components/app-lookup-field.js')); ?>"></script>

View File

@@ -0,0 +1,123 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_VIEW);
$request = requestInput();
$returnTarget = requestResolveReturnTarget();
$closeTarget = requestResolveReturnTarget('helpdesk/handovers');
$handoverId = (int) ($id ?? 0);
$editTarget = requestPathWithReturnTarget('helpdesk/handovers/edit/' . $handoverId, $returnTarget);
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(HandoverService::class);
$handover = $service->findById($tenantId, $handoverId);
if ($handover === null) {
Flash::error(t('Handover not found'), $closeTarget, 'handover_not_found');
Router::redirect($closeTarget);
return;
}
// Determine permission level
$authService = app(\MintyPHP\Service\Access\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();
$permissionLevel = $canManage ? HandoverService::PERMISSION_MANAGE : ($canCreate ? HandoverService::PERMISSION_CREATE : '');
$currentStatus = (string) ($handover['status'] ?? 'draft');
$canEdit = $service->canEdit($currentStatus, $permissionLevel);
$allowedStatuses = $service->getAllowedStatuses($permissionLevel);
// Parse schema snapshot
$schemaRaw = (string) ($handover['schema_snapshot'] ?? '{}');
$schema = json_decode($schemaRaw, true);
$schemaFields = is_array($schema) ? ($schema['fields'] ?? []) : [];
// Parse field values
$fieldValuesRaw = (string) ($handover['field_values'] ?? '{}');
$fieldValues = json_decode($fieldValuesRaw, true);
if (!is_array($fieldValues)) {
$fieldValues = [];
}
$errorBag = formErrors();
if ($request->isMethod('POST') && $canEdit) {
if (!Session::checkCsrfToken()) {
Flash::error(t('Form expired, please try again'), $editTarget, 'csrf_expired');
Router::redirect($editTarget);
return;
}
// Collect field values
$submittedValues = [];
foreach ($schemaFields as $field) {
$key = $field['key'] ?? null;
if ($key === null) {
continue;
}
$type = $field['type'] ?? 'text';
if ($type === 'checkbox') {
$submittedValues[$key] = $request->body('field_' . $key, '') !== '' ? '1' : '0';
} else {
$submittedValues[$key] = (string) $request->body('field_' . $key, '');
}
}
$updateResult = $service->updateFields($tenantId, $handoverId, $submittedValues, $userId, $permissionLevel);
$errorBag->merge($updateResult['errors'] ?? []);
// Handle status change
$newStatus = (string) $request->body('status', '');
if ($newStatus !== '' && $newStatus !== $currentStatus) {
$statusResult = $service->changeStatus($tenantId, $handoverId, $newStatus, $userId, $permissionLevel);
$errorBag->merge($statusResult['errors'] ?? []);
}
if (!$errorBag->hasAny()) {
$handover = $service->findById($tenantId, $handoverId);
$fieldValues = $submittedValues;
$currentStatus = (string) ($handover['status'] ?? $currentStatus);
$action = (string) $request->body('action', 'save');
if ($action === 'save_close') {
Flash::success(t('Handover updated'), $closeTarget, 'handover_updated');
Router::redirect($closeTarget);
} else {
Flash::success(t('Handover updated'), $editTarget, 'handover_updated');
Router::redirect($editTarget);
}
return;
}
}
$validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList();
$displayName = trim((string) ($handover['debitor_name'] ?? ''));
$titleText = $displayName !== '' ? $displayName : (t('Handover') . ' #' . $handoverId);
Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Handovers'), 'path' => 'helpdesk/handovers'],
['label' => $titleText],
];

View File

@@ -0,0 +1,127 @@
<?php
use MintyPHP\Module\Helpdesk\Service\HandoverService;
$handover = is_array($handover ?? null) ? $handover : [];
$schemaFields = is_array($schemaFields ?? null) ? $schemaFields : [];
$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : [];
$validationSummaryErrors = is_array($validationSummaryErrors ?? null) ? $validationSummaryErrors : [];
$errors = is_array($errors ?? null) ? $errors : [];
$closeTarget = $closeTarget ?? 'helpdesk/handovers';
$canEdit = $canEdit ?? false;
$allowedStatuses = is_array($allowedStatuses ?? null) ? $allowedStatuses : [];
$currentStatus = $currentStatus ?? 'draft';
$formId = 'handover-form';
$readonly = !$canEdit;
?>
<div class="app-details-container">
<section>
<?php require templatePath('partials/app-flash.phtml'); ?>
<?php
$titlebar = [
'title' => $titleText ?? t('Handover'),
'backHref' => $closeTarget,
'backTitle' => t('Back to list'),
'actions' => $canEdit ? [
[
'form' => $formId,
'name' => 'action',
'value' => 'save',
'class' => 'secondary outline',
'label' => t('Save'),
],
[
'form' => $formId,
'name' => 'action',
'value' => 'save_close',
'class' => 'primary',
'label' => t('Save & close'),
],
] : [],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<?php require templatePath('partials/app-details-validation-summary.phtml'); ?>
<?php if ($canEdit): ?>
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-handover-form">
<div class="app-tabs-nav">
<button type="button" data-tab="protocol" data-tab-default><?php e(t('Handover protocol')); ?></button>
</div>
<div data-tab-panel="protocol">
<?php require __DIR__ . '/_form.phtml'; ?>
</div>
</div>
<?php if ($allowedStatuses && count($allowedStatuses) > 1): ?>
<input type="hidden" name="status" value="<?php e($currentStatus); ?>">
<?php endif; ?>
<?php \MintyPHP\Session::getCsrfInput(); ?>
</form>
<?php else: ?>
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-handover-form">
<div class="app-tabs-nav">
<button type="button" data-tab="protocol" data-tab-default><?php e(t('Handover protocol')); ?></button>
</div>
<div data-tab-panel="protocol">
<?php require __DIR__ . '/_form.phtml'; ?>
</div>
</div>
<?php endif; ?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e(t('Handover')); ?> #<?php e($handover['id'] ?? ''); ?></h2>
<p><?php e($handover['product_code'] ?? ''); ?></p>
</hgroup>
<div class="badge-list">
<span class="badge" data-variant="<?php e(HandoverService::statusVariant($currentStatus)); ?>">
<?php e(HandoverService::statusLabel($currentStatus)); ?>
</span>
</div>
<?php if ($canEdit && $allowedStatuses && count($allowedStatuses) > 1): ?>
<hr>
<p><small><?php e(t('Change status')); ?></small></p>
<div class="helpdesk-handover-status-actions">
<?php foreach ($allowedStatuses as $status):
if ($status === $currentStatus) {
continue;
}
?>
<button
type="button"
class="secondary outline small"
onclick="document.querySelector('input[name=status]').value='<?php e($status); ?>';document.getElementById('<?php e($formId); ?>').requestSubmit();"
>
<?php e(HandoverService::statusLabel($status)); ?>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<hr>
<p><small><?php e(t('Customer')); ?></small><br><?php e($handover['debitor_name'] ?? ''); ?></p>
<p><small><?php e(t('Debitor No.')); ?></small><br><?php e($handover['debitor_no'] ?? ''); ?></p>
<hr>
<?php if (!empty($handover['created_at'])): ?>
<p><small><?php e(t('Created')); ?></small><br><?php e(dt($handover['created_at'])); ?></p>
<?php endif; ?>
<?php if (!empty($handover['updated_at'])): ?>
<p><small><?php e(t('Modified')); ?></small><br><?php e(dt($handover['updated_at'])); ?></p>
<?php endif; ?>
<hr>
</div>
</aside>
</div>

View File

@@ -0,0 +1,43 @@
<?php
return gridFilterSchema([
'query' => [
'search' => ['type' => 'string'],
'status' => ['type' => 'string', 'default' => 'all'],
'limit' => ['type' => 'int', 'default' => 20, 'min' => 1, 'max' => 100],
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
'order' => [
'type' => 'order',
'allowed' => ['id', 'debitor_name', 'product_code', 'status', 'created_at', 'created_by'],
'default' => 'created_at',
],
'dir' => ['type' => 'dir', 'default' => 'desc'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search handovers...',
'input_id' => 'helpdesk-handovers-search-input',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'status',
'type' => 'select',
'label' => 'Status',
'input_id' => 'helpdesk-handovers-status-filter',
'default' => 'all',
'normalize' => 'all_to_empty',
'allowed' => [
['id' => 'all', 'description' => 'All'],
['id' => 'draft', 'description' => 'Draft'],
['id' => 'in_progress', 'description' => 'In progress'],
['id' => 'completed', 'description' => 'Completed'],
['id' => 'archived', 'description' => 'Archived'],
],
'label_attributes' => ['data-filter-optional' => true],
],
],
]);

View File

@@ -0,0 +1,53 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_VIEW);
$request = requestInput();
$query = $request->queryAll();
$filterSchema = require __DIR__ . '/filter-schema.php';
$listFilterContext = gridBuildListFilterContext($filterSchema, [
'query' => $query,
'search_keys' => ['search'],
]);
$toolbarFilterSchema = $listFilterContext['toolbarFilterSchema'];
$searchToolbarFilterSchema = $listFilterContext['searchToolbarFilterSchema'];
$drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema'];
$toolbarFilterState = $listFilterContext['toolbarFilterState'];
$clientFilterSchema = $listFilterContext['clientFilterSchema'];
$searchConfig = $listFilterContext['searchConfig'];
$schemaByKey = $listFilterContext['schemaByKey'];
$filterChipMeta = [
'search' => [
'label' => t('Search'),
'type' => 'text',
],
'status' => [
'label' => t('Status'),
'type' => 'select',
'default' => (string) ($schemaByKey['status']['default'] ?? 'all'),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])),
],
];
$session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0);
$authService = app(AuthorizationService::class);
$actorContext = ['actor_user_id' => $userId];
$canCreate = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_CREATE, $actorContext)->isAllowed()
|| $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_HANDOVERS_MANAGE, $actorContext)->isAllowed();
Buffer::set('title', t('Handovers'));
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Handovers')],
];

View File

@@ -0,0 +1,52 @@
<?php
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$canCreate = $canCreate ?? false;
?>
<?php require templatePath('partials/app-flash.phtml'); ?>
<?php
$listTitle = t('Handovers');
$listActions = [];
if ($canCreate) {
$listActions[] = [
'label' => t('New handover'),
'href' => lurl('helpdesk/handovers/create'),
'class' => 'primary',
'icon' => 'bi-plus-lg',
];
}
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'helpdesk-handovers';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="helpdesk-handovers-grid"></div>
</div>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-helpdesk-handovers"><?php gridJsonForJs([
'dataUrl' => lurl('helpdesk/handovers-data'),
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'editBaseUrl' => lurl('helpdesk/handovers/edit/'),
'labels' => [
'id' => t('ID'),
'debitorName' => t('Customer'),
'productCode' => t('Software product'),
'status' => t('Status'),
'createdAt' => t('Created'),
'createdBy' => t('Created by'),
],
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/pages/helpdesk-handovers-index.js')); ?>"></script>

View File

@@ -11,19 +11,15 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
</div> </div>
<div data-tab-panel="basic"> <div data-tab-panel="basic">
<fieldset> <label for="software-product-name"><?php e(t('Product name')); ?></label>
<legend><?php e(t('Product details')); ?></legend> <input
type="text"
<label for="software-product-name"><?php e(t('Product name')); ?></label> id="software-product-name"
<input name="name"
type="text" value="<?php e($values['name'] ?? ''); ?>"
id="software-product-name" maxlength="255"
name="name" placeholder="<?php e(t('Enter product name...')); ?>"
value="<?php e($values['name'] ?? ''); ?>" >
maxlength="255"
placeholder="<?php e(t('Enter product name...')); ?>"
>
</fieldset>
</div> </div>
<div data-tab-panel="handover-protocol" data-tab-panel-wide> <div data-tab-panel="handover-protocol" data-tab-panel-wide>

View File

@@ -72,7 +72,10 @@ if ($request->isMethod('POST')) {
$validationSummaryErrors = $errorBag->toArray(); $validationSummaryErrors = $errorBag->toArray();
$errors = $errorBag->toFlatList(); $errors = $errorBag->toFlatList();
$titleText = t('Edit software product'); $displayName = trim((string) ($product['name'] ?? '')) !== ''
? $product['name']
: (trim((string) ($product['bc_description'] ?? '')) !== '' ? $product['bc_description'] : null);
$titleText = $displayName !== null ? $displayName : t('Edit software product');
Buffer::set('title', $titleText); Buffer::set('title', $titleText);
Buffer::set('style_groups', json_encode(['helpdesk'])); Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [ $breadcrumbs = [

View File

@@ -6,14 +6,16 @@ $canManageSettings = !empty($helpdeskNav['can_manage_settings']);
$canViewTeam = !empty($helpdeskNav['can_view_team']); $canViewTeam = !empty($helpdeskNav['can_view_team']);
$canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']); $canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']);
$canManageSoftwareProducts = !empty($helpdeskNav['can_manage_software_products']); $canManageSoftwareProducts = !empty($helpdeskNav['can_manage_software_products']);
$canViewHandovers = !empty($helpdeskNav['can_view_handovers']);
$settingsActive = navActive('helpdesk/settings', true); $settingsActive = navActive('helpdesk/settings', true);
$teamActive = navActive('helpdesk/team', true); $teamActive = navActive('helpdesk/team', true);
$riskRadarActive = navActive('helpdesk/risk-radar', true); $riskRadarActive = navActive('helpdesk/risk-radar', true);
$domainsActive = navActive('helpdesk/domains', true); $domainsActive = navActive('helpdesk/domains', true);
$softwareProductsActive = navActive('helpdesk/software-products', true); $softwareProductsActive = navActive('helpdesk/software-products', true);
$handoversActive = navActive('helpdesk/handovers', true);
$customersActive = navActive('helpdesk', true); $customersActive = navActive('helpdesk', true);
if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive']) { if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive']) {
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false]; $customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
} }
@@ -56,6 +58,19 @@ $helpdeskNavGroups = [
], ],
], ],
], ],
[
'key' => 'helpdesk-operations',
'label' => t('Operations'),
'icon' => 'bi-clipboard-check',
'items' => [
[
'label' => t('Handovers'),
'path' => 'helpdesk/handovers',
'active' => $handoversActive,
'visible' => $canViewHandovers,
],
],
],
[ [
'key' => 'helpdesk-administration', 'key' => 'helpdesk-administration',
'label' => t('Administration'), 'label' => t('Administration'),

View File

@@ -0,0 +1,196 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\SoftwareProductService;
use PHPUnit\Framework\TestCase;
class HandoverServiceTest extends TestCase
{
private const TENANT_ID = 1;
private const USER_ID = 42;
private const VALID_SCHEMA_JSON = '{"version":1,"fields":[{"type":"text","label":"Name","key":"name","required":true},{"type":"select","label":"Priority","key":"priority","required":false,"options":[{"value":"high","label":"High"},{"value":"low","label":"Low"}]}]}';
private function createService(
?HandoverRepository $repository = null,
?SoftwareProductService $productService = null,
): HandoverService {
$repository = $repository ?? $this->createMock(HandoverRepository::class);
$productService = $productService ?? $this->createMock(SoftwareProductService::class);
return new HandoverService($repository, $productService);
}
private function mockProductService(bool $exists = true, bool $hasSchema = true): SoftwareProductService
{
$mock = $this->createMock(SoftwareProductService::class);
if (!$exists) {
$mock->method('findByCode')->willReturn(null);
} else {
$mock->method('findByCode')->willReturn([
'id' => 1,
'code' => 'PROD-A',
'name' => 'Product A',
'active' => 1,
'handover_protocol_schema' => $hasSchema ? self::VALID_SCHEMA_JSON : null,
]);
}
return $mock;
}
// ── Create tests ─────────────────────────────────────────────────
public function testCreateHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$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);
$this->assertTrue($result['ok']);
$this->assertSame(99, $result['id']);
$this->assertEmpty($result['errors']);
}
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);
$this->assertFalse($result['ok']);
$this->assertNull($result['id']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
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);
$this->assertFalse($result['ok']);
$this->assertNull($result['id']);
$this->assertArrayHasKey('product_code', $result['errors']);
}
public function testCreateWithEmptyDebitorNo(): void
{
$service = $this->createService();
$result = $service->create(self::TENANT_ID, '', 'Acme Corp', 'PROD-A', self::USER_ID);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('debitor_no', $result['errors']);
}
// ── Update fields tests ──────────────────────────────────────────
public function testUpdateFieldsHappyPath(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'draft',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$repo->expects($this->once())->method('updateFieldValues')->willReturn(true);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test', 'priority' => 'high'], self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertTrue($result['ok']);
}
public function testUpdateFieldsRejectsUnknownKeys(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'draft',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['unknown_field' => 'value'], self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('unknown_field', $result['errors']);
}
// ── Status transition tests ──────────────────────────────────────
public function testChangeStatusByCreateUser(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'status' => 'draft']);
$repo->method('updateStatus')->willReturn(true);
$service = $this->createService($repo);
// Can set in_progress
$result = $service->changeStatus(self::TENANT_ID, 1, 'in_progress', self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertTrue($result['ok']);
}
public function testChangeStatusCreateUserCannotArchive(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'status' => 'completed']);
$service = $this->createService($repo);
$result = $service->changeStatus(self::TENANT_ID, 1, 'archived', self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('status', $result['errors']);
}
public function testChangeStatusManageUserCanArchive(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn(['id' => 1, 'status' => 'completed']);
$repo->method('updateStatus')->willReturn(true);
$service = $this->createService($repo);
$result = $service->changeStatus(self::TENANT_ID, 1, 'archived', self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
// ── Edit permission tests ────────────────────────────────────────
public function testEditDeniedForCreateUserOnCompletedHandover(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'completed',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test'], self::USER_ID, HandoverService::PERMISSION_CREATE);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
public function testEditAllowedForManageUserOnCompletedHandover(): void
{
$repo = $this->createMock(HandoverRepository::class);
$repo->method('findById')->willReturn([
'id' => 1,
'status' => 'completed',
'schema_snapshot' => self::VALID_SCHEMA_JSON,
]);
$repo->expects($this->once())->method('updateFieldValues')->willReturn(true);
$service = $this->createService($repo);
$result = $service->updateFields(self::TENANT_ID, 1, ['name' => 'Test'], self::USER_ID, HandoverService::PERMISSION_MANAGE);
$this->assertTrue($result['ok']);
}
}

View File

@@ -2339,7 +2339,7 @@
/* Handover protocol schema editor — split layout */ /* Handover protocol schema editor — split layout */
.handover-schema-split { .handover-schema-split {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 2fr;
gap: var(--app-spacing); gap: var(--app-spacing);
align-items: start; align-items: start;
} }
@@ -2420,15 +2420,20 @@
} }
.handover-schema-preview-form [data-field-index] { .handover-schema-preview-form [data-field-index] {
transition: background-color 0.2s ease, box-shadow 0.2s ease; transition:
background-color 0.15s ease,
border-color 0.15s ease;
border-radius: var(--app-border-radius); border-radius: var(--app-border-radius);
padding: calc(var(--app-spacing) * 0.3); padding: calc(var(--app-spacing) * 0.3);
padding-inline-start: calc(var(--app-spacing) * 0.5);
margin: calc(var(--app-spacing) * -0.3); margin: calc(var(--app-spacing) * -0.3);
margin-inline-start: calc(var(--app-spacing) * -0.5);
border-left: 2px solid transparent;
} }
.handover-schema-preview-highlight { .handover-schema-preview-highlight {
background-color: var(--app-secondary-focus); background-color: color-mix(in srgb, var(--app-primary, #635bff) 5%, transparent);
box-shadow: inset 0 0 0 1px var(--app-card-border-color); border-left-color: var(--app-primary, #635bff);
} }
.handover-schema-preview-required { .handover-schema-preview-required {
@@ -2454,7 +2459,7 @@
.handover-schema-insert-divider::before, .handover-schema-insert-divider::before,
.handover-schema-insert-divider::after { .handover-schema-insert-divider::after {
content: ''; content: "";
flex: 1; flex: 1;
height: 1px; height: 1px;
background: var(--app-card-border-color); background: var(--app-card-border-color);
@@ -2470,10 +2475,20 @@
} }
/* Handover protocol schema editor — field cards */ /* Handover protocol schema editor — field cards */
.handover-schema-empty { .handover-schema-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: calc(var(--app-spacing) * 0.75);
padding: calc(var(--app-spacing) * 2) var(--app-spacing);
border: 2px dashed var(--app-card-border-color);
border-radius: var(--app-border-radius);
text-align: center;
}
.handover-schema-empty-state > p {
color: var(--app-muted-color); color: var(--app-muted-color);
font-style: italic; margin: 0;
padding: calc(var(--app-spacing) * 0.5) 0;
} }
.handover-schema-list { .handover-schema-list {
@@ -2578,4 +2593,190 @@
min-width: 0; min-width: 0;
} }
/* ── Wizard (Stripe-style two-step assistant) ───────────────────────── */
.app-wizard-container {
max-width: 42rem;
margin: 0 auto;
padding: calc(var(--app-spacing) * 2) var(--app-spacing);
}
.app-wizard-header {
display: flex;
align-items: center;
gap: calc(var(--app-spacing) * 0.75);
margin-bottom: calc(var(--app-spacing) * 1.5);
}
.app-wizard-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--app-border-radius);
color: var(--app-muted-color);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
}
.app-wizard-back:hover {
background: var(--app-background-muted, #f8f9fa);
color: var(--app-color);
}
.app-wizard-title {
font-size: var(--text-xl, 1.25rem);
font-weight: var(--font-semibold, 600);
margin: 0;
}
/* Step indicator */
.app-wizard-steps ol {
display: flex;
align-items: center;
list-style: none;
padding: 0;
margin: 0 0 calc(var(--app-spacing) * 1.5);
gap: 0;
}
.app-wizard-step {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-wizard-step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-semibold, 600);
border: 2px solid var(--app-border);
color: var(--app-muted-color);
background: transparent;
transition: all 0.2s ease;
flex-shrink: 0;
}
.app-wizard-step-label {
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted-color);
white-space: nowrap;
}
.app-wizard-step.is-active .app-wizard-step-number {
border-color: var(--app-primary, #635bff);
background: var(--app-primary, #635bff);
color: #fff;
}
.app-wizard-step.is-active .app-wizard-step-label {
color: var(--app-color);
font-weight: var(--font-medium, 500);
}
.app-wizard-step.is-completed .app-wizard-step-number {
border-color: var(--app-success, #198754);
background: var(--app-success, #198754);
color: #fff;
}
.app-wizard-step.is-completed .app-wizard-step-label {
color: var(--app-muted-color);
}
.app-wizard-step-connector {
flex: 1;
height: 2px;
background: var(--app-border);
margin: 0 0.75rem;
transition: background 0.2s ease;
}
.app-wizard-step-connector.is-completed {
background: var(--app-success, #198754);
}
/* Card */
.app-wizard-card {
background: var(--app-card-background-color, #fff);
border: 1px solid var(--app-card-border-color, #e5e7eb);
border-radius: calc(var(--app-border-radius) * 2);
padding: calc(var(--app-spacing) * 1.5);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
}
.app-wizard-card-heading {
font-size: var(--text-lg, 1.125rem);
font-weight: var(--font-semibold, 600);
margin: 0 0 0.25rem;
}
.app-wizard-card-description {
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted-color);
margin: 0 0 calc(var(--app-spacing) * 1.5);
}
.app-wizard-field-group {
margin-bottom: calc(var(--app-spacing) * 1.25);
}
.app-wizard-field-group > label {
display: block;
margin-bottom: 0.375rem;
font-size: var(--text-sm, 0.875rem);
font-weight: var(--font-medium, 500);
}
.app-wizard-protocol-form {
max-width: 100%;
}
.app-wizard-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: calc(var(--app-spacing) * 1.5);
padding-top: calc(var(--app-spacing) * 1.25);
border-top: 1px solid var(--app-card-border-color, #e5e7eb);
}
/* Handover form — shared styles */
.handover-form-required {
color: var(--app-danger-color, hsl(0 70% 55%));
}
.handover-form-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.handover-form-checkbox-label input[type="checkbox"] {
width: 1.25em;
height: 1.25em;
flex-shrink: 0;
margin-bottom: 0;
}
@media (max-width: 576px) {
.app-wizard-container {
padding: var(--app-spacing);
}
.app-wizard-step-label {
display: none;
}
.app-wizard-card {
padding: var(--app-spacing);
}
}
} }

View File

@@ -46,9 +46,22 @@ function init(root) {
clearElement(root); clearElement(root);
if (fields.length === 0) { if (fields.length === 0) {
const empty = document.createElement('p'); const empty = document.createElement('div');
empty.className = 'handover-schema-empty'; empty.className = 'handover-schema-empty-state';
empty.textContent = t.no_fields || 'No fields defined yet.'; const emptyText = document.createElement('p');
emptyText.textContent = t.no_fields || 'No fields defined yet.';
empty.appendChild(emptyText);
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'secondary outline';
addButton.appendChild(createIcon('bi-plus-lg'));
addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field')));
addButton.addEventListener('click', () => {
fields.push({ type: 'text', key: '', label: '', required: false, options: [] });
render();
notifyChange();
});
empty.appendChild(addButton);
root.appendChild(empty); root.appendChild(empty);
} else { } else {
const list = document.createElement('div'); const list = document.createElement('div');
@@ -60,9 +73,8 @@ function init(root) {
list.appendChild(buildFieldRow(field, index)); list.appendChild(buildFieldRow(field, index));
}); });
root.appendChild(list); root.appendChild(list);
root.appendChild(buildInsertDivider(fields.length));
} }
root.appendChild(buildInsertDivider(fields.length));
} }
function buildInsertDivider(insertIndex) { function buildInsertDivider(insertIndex) {
@@ -314,7 +326,7 @@ function init(root) {
if (field.type === 'checkbox') { if (field.type === 'checkbox') {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.disabled = true; input.addEventListener('click', (e) => e.preventDefault());
group.appendChild(input); group.appendChild(input);
const label = document.createElement('label'); const label = document.createElement('label');
label.textContent = field.label || ''; label.textContent = field.label || '';

View File

@@ -0,0 +1,86 @@
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';
const config = readPageConfig('helpdesk-handovers');
if (config) {
const gridjs = window.gridjs;
if (!gridjs) {
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed: Grid.js missing', { module: 'helpdesk-handovers', component: 'grid' });
} else {
const appBase = getAppBase();
const labels = config.labels || {};
const editUrlIndex = 6;
const gridOptions = {
gridjs,
container: '#helpdesk-handovers-grid',
dataUrl: config.dataUrl || 'helpdesk/handovers-data',
appBase,
columns: [
{
name: labels.id || 'ID',
sort: true,
width: '80px',
formatter: (cell) => {
const id = escapeHtml(String(cell?.id || ''));
const editUrl = String(cell?.url || '').trim();
if (editUrl === '') {
return gridjs.html(id);
}
const href = escapeHtml(withCurrentListReturn(editUrl));
return gridjs.html(`<a href="${href}">#${id}</a>`);
},
},
{ name: labels.debitorName || 'Customer', sort: true },
{ name: labels.productCode || 'Software product', sort: true },
{
name: labels.status || 'Status',
sort: true,
formatter: (cell) => {
const label = escapeHtml(cell?.label || '');
const variant = cell?.variant || 'neutral';
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${label}</span>`);
},
},
{ 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],
paginationLimit: 20,
language: config.gridLang || {},
mapData: (data) => (data.data || []).map((row) => [
{ id: row.id || '', url: row.edit_url || '' },
row.debitor_name || '',
row.product_code || '',
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
row.created_at || '',
row.created_by_name || '',
row.edit_url || '',
]),
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
rowInteraction: { linkColumn: 0 },
rowDblClick: {
getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '',
},
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-handovers-search-input'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' });
}
}
}

View File

@@ -1974,12 +1974,6 @@ parameters:
count: 1 count: 1
path: modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php path: modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php
-
message: '#^Public method "MintyPHP\\Module\\Helpdesk\\Service\\HelpdeskSettingsGateway\:\:isConfigured\(\)" is never used$#'
identifier: public.method.unused
count: 1
path: modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php
- -
message: '#^Public method "MintyPHP\\Module\\Helpdesk\\Service\\HelpdeskSettingsGateway\:\:setAuthMode\(\)" is never used$#' message: '#^Public method "MintyPHP\\Module\\Helpdesk\\Service\\HelpdeskSettingsGateway\:\:setAuthMode\(\)" is never used$#'
identifier: public.method.unused identifier: public.method.unused

View File

@@ -0,0 +1,147 @@
@layer components {
/*
* Autocomplete lookup field.
*
* A text input with a dropdown list of search results.
* Driven by data attributes via app-lookup-field.js.
*/
.app-lookup-field {
position: relative;
}
/* ── Input wrapper ──────────────────────────────────────────────────── */
.app-lookup-field-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.app-lookup-field-input {
padding-right: 2.5rem;
margin-bottom: 0;
}
.app-lookup-field-input[aria-expanded="true"] {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-color: var(--app-primary, #635bff);
}
/* ── Clear / spinner icons ──────────────────────────────────────────── */
.app-lookup-field-indicator {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
pointer-events: none;
color: var(--app-muted-color);
}
.app-lookup-field-clear {
pointer-events: auto;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: var(--app-muted-color);
font-size: var(--text-base, 1rem);
line-height: var(--leading-none);
transition: color 0.15s ease;
}
.app-lookup-field-clear:hover {
color: var(--app-color);
}
.app-lookup-field-spinner {
width: 1rem;
height: 1rem;
border: 2px solid var(--app-border);
border-top-color: var(--app-primary, #635bff);
border-radius: 50%;
animation: app-lookup-spin 0.6s linear infinite;
}
@keyframes app-lookup-spin {
to { transform: rotate(360deg); }
}
/* ── Dropdown ───────────────────────────────────────────────────────── */
.app-lookup-field-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
background: var(--app-card-background-color, #fff);
border: 1px solid var(--app-primary, #635bff);
border-top: none;
border-radius: 0 0 var(--app-border-radius) var(--app-border-radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
max-height: 16rem;
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.app-lookup-field-dropdown[hidden] {
display: none;
}
/* ── Option items ───────────────────────────────────────────────────── */
.app-lookup-field-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: var(--text-sm, 0.875rem);
color: var(--app-color);
transition: background-color 0.1s ease;
}
.app-lookup-field-option:hover,
.app-lookup-field-option[aria-selected="true"] {
background: color-mix(in srgb, var(--app-primary, #635bff) 8%, transparent);
}
.app-lookup-field-option[aria-selected="true"] {
color: var(--app-primary, #635bff);
}
.app-lookup-field-option-label {
font-weight: var(--font-medium, 500);
}
.app-lookup-field-option-meta {
font-size: var(--text-xs, 0.75rem);
color: var(--app-muted-color);
}
/* ── Empty / error messages ─────────────────────────────────────────── */
.app-lookup-field-message {
padding: 0.75rem;
font-size: var(--text-sm, 0.875rem);
color: var(--app-muted-color);
text-align: center;
}
/* ── Selected state ─────────────────────────────────────────────────── */
.app-lookup-field-input[data-lookup-selected] {
background: var(--app-card-background-color, #fff);
}
}

View File

@@ -0,0 +1,329 @@
/**
* Generic autocomplete lookup field.
*
* Usage:
* <div data-app-lookup
* data-lookup-url="/helpdesk/debitor-lookup-data"
* data-lookup-min-chars="2"
* data-lookup-debounce="300"
* data-lookup-value-key="value"
* data-lookup-display-key="label"
* data-lookup-placeholder="Search...">
* <input type="hidden" name="debitor_no" data-lookup-value>
* <input type="hidden" name="debitor_name" data-lookup-display-value>
* </div>
*
* The component creates a visible text input, a dropdown, and indicator icons.
* On selection, it sets the hidden input values and fires a 'lookup:select' CustomEvent.
*/
/** @param {HTMLElement} container */
export function initLookupField(container) {
const url = container.dataset.lookupUrl || '';
const minChars = parseInt(container.dataset.lookupMinChars || '2', 10);
const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10);
const valueKey = container.dataset.lookupValueKey || 'value';
const displayKey = container.dataset.lookupDisplayKey || 'label';
const placeholder = container.dataset.lookupPlaceholder || '';
const initialDisplay = container.dataset.lookupInitialDisplay || '';
const initialValue = container.dataset.lookupInitialValue || '';
const hiddenValue = container.querySelector('[data-lookup-value]');
const hiddenDisplay = container.querySelector('[data-lookup-display-value]');
// Build DOM
container.classList.add('app-lookup-field');
const wrap = document.createElement('div');
wrap.className = 'app-lookup-field-input-wrap';
const input = document.createElement('input');
input.type = 'text';
input.className = 'app-lookup-field-input';
input.placeholder = placeholder;
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('autocomplete', 'off');
const indicator = document.createElement('div');
indicator.className = 'app-lookup-field-indicator';
wrap.appendChild(input);
wrap.appendChild(indicator);
const dropdown = document.createElement('ul');
dropdown.className = 'app-lookup-field-dropdown';
dropdown.setAttribute('role', 'listbox');
dropdown.hidden = true;
container.appendChild(wrap);
container.appendChild(dropdown);
// State
let results = [];
let activeIndex = -1;
let debounceTimer = null;
let abortController = null;
let selected = false;
// Restore initial state
if (initialValue && initialDisplay) {
input.value = initialDisplay;
selected = true;
input.dataset.lookupSelected = '';
showClear();
}
// ── Indicator helpers ──────────────────────────────────────────────
function showSpinner() {
clearElement(indicator);
const spinner = document.createElement('div');
spinner.className = 'app-lookup-field-spinner';
indicator.appendChild(spinner);
}
function showClear() {
clearElement(indicator);
const button = document.createElement('button');
button.type = 'button';
button.className = 'app-lookup-field-clear';
button.setAttribute('aria-label', 'Clear');
button.addEventListener('click', clearSelection);
const icon = document.createElement('i');
icon.className = 'bi bi-x-lg';
button.appendChild(icon);
indicator.appendChild(button);
}
function hideIndicator() {
clearElement(indicator);
}
// ── Dropdown helpers ───────────────────────────────────────────────
function openDropdown() {
dropdown.hidden = false;
input.setAttribute('aria-expanded', 'true');
}
function closeDropdown() {
dropdown.hidden = true;
input.setAttribute('aria-expanded', 'false');
activeIndex = -1;
}
function clearElement(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function renderResults(items) {
results = items;
activeIndex = -1;
clearElement(dropdown);
if (items.length === 0) {
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupEmptyText || 'No results';
dropdown.appendChild(msg);
openDropdown();
return;
}
items.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'app-lookup-field-option';
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.dataset.index = index;
const label = document.createElement('span');
label.className = 'app-lookup-field-option-label';
label.textContent = item[displayKey] || '';
li.appendChild(label);
if (item.meta) {
const meta = document.createElement('span');
meta.className = 'app-lookup-field-option-meta';
meta.textContent = item.meta;
li.appendChild(meta);
}
li.addEventListener('mousedown', (e) => {
e.preventDefault();
selectItem(index);
});
li.addEventListener('mouseenter', () => {
setActive(index);
});
dropdown.appendChild(li);
});
openDropdown();
}
function renderError() {
clearElement(dropdown);
const msg = document.createElement('li');
msg.className = 'app-lookup-field-message';
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
dropdown.appendChild(msg);
openDropdown();
}
function setActive(index) {
const options = dropdown.querySelectorAll('.app-lookup-field-option');
options.forEach((opt, i) => {
opt.setAttribute('aria-selected', i === index ? 'true' : 'false');
});
activeIndex = index;
// Scroll active into view
const active = options[index];
if (active) {
active.scrollIntoView({ block: 'nearest' });
}
}
// ── Selection ──────────────────────────────────────────────────────
function selectItem(index) {
const item = results[index];
if (!item) return;
input.value = item[displayKey] || '';
input.dataset.lookupSelected = '';
selected = true;
if (hiddenValue) hiddenValue.value = item[valueKey] || '';
if (hiddenDisplay) hiddenDisplay.value = item[displayKey] || '';
closeDropdown();
showClear();
container.dispatchEvent(new CustomEvent('lookup:select', {
detail: item,
bubbles: true,
}));
}
function clearSelection() {
input.value = '';
delete input.dataset.lookupSelected;
selected = false;
if (hiddenValue) hiddenValue.value = '';
if (hiddenDisplay) hiddenDisplay.value = '';
hideIndicator();
input.focus();
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
}
// ── Fetch ──────────────────────────────────────────────────────────
function fetchResults(query) {
if (abortController) abortController.abort();
abortController = new AbortController();
showSpinner();
const separator = url.includes('?') ? '&' : '?';
fetch(url + separator + 'q=' + encodeURIComponent(query), {
signal: abortController.signal,
headers: { 'Accept': 'application/json' },
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((data) => {
hideIndicator();
const items = Array.isArray(data) ? data : (data.data || []);
renderResults(items);
})
.catch((err) => {
if (err.name === 'AbortError') return;
hideIndicator();
renderError();
});
}
// ── Input events ───────────────────────────────────────────────────
input.addEventListener('input', () => {
if (selected) {
selected = false;
delete input.dataset.lookupSelected;
if (hiddenValue) hiddenValue.value = '';
if (hiddenDisplay) hiddenDisplay.value = '';
}
const query = input.value.trim();
if (query.length < minChars) {
closeDropdown();
hideIndicator();
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchResults(query), debounceMs);
});
input.addEventListener('keydown', (e) => {
if (dropdown.hidden) return;
const options = dropdown.querySelectorAll('.app-lookup-field-option');
if (!options.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0) {
selectItem(activeIndex);
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeDropdown();
}
});
input.addEventListener('focus', () => {
if (input.value.trim().length >= minChars && !selected && results.length > 0) {
openDropdown();
}
});
input.addEventListener('blur', () => {
// Delay to allow mousedown on option to fire first
setTimeout(() => closeDropdown(), 150);
});
return { input, clearSelection };
}
// ── Auto-init ──────────────────────────────────────────────────────
function initAll() {
document.querySelectorAll('[data-app-lookup]').forEach((el) => {
if (el.dataset.lookupInitialized) return;
el.dataset.lookupInitialized = '1';
initLookupField(el);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}