diff --git a/config/assets.php b/config/assets.php index c598da6..c6e531b 100644 --- a/config/assets.php +++ b/config/assets.php @@ -45,6 +45,7 @@ return [ 'css/components/app-breadcrumb.css', 'css/components/app-tile.css', 'css/components/app-tooltips.css', + 'css/components/app-lookup-field.css', 'css/components/app-page-editor.css', 'css/vendor-overrides/editorjs.css', 'css/components/app-page-copy.css', diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 1c0eca5..e8e9b0f 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -410,5 +410,55 @@ "Field %d, option %d: label is required": "Feld %d, Option %d: Bezeichnung ist erforderlich", "Preview": "Vorschau", "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" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index a17ff2a..560ee20 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -410,5 +410,55 @@ "Field %d, option %d: label is required": "Field %d, option %d: label is required", "Preview": "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" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php index 8d2818d..1110652 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php @@ -13,6 +13,9 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view'; public const ABILITY_RISK_RADAR = 'helpdesk.risk-radar.view'; 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_SETTINGS_MANAGE = 'helpdesk.settings.manage'; @@ -20,6 +23,12 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public const PERMISSION_RISK_RADAR = 'helpdesk.risk-radar.view'; /** @api Used in authorize() match for ability resolution */ 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( private readonly PermissionService $permissionService @@ -28,7 +37,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface 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 @@ -44,6 +53,9 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR => self::PERMISSION_RISK_RADAR, 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, }; diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index 70028f9..e95b3da 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -20,7 +20,9 @@ use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService; use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine; use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler; +use MintyPHP\Module\Helpdesk\Repository\HandoverRepository; use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository; +use MintyPHP\Module\Helpdesk\Service\HandoverService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Settings\SettingServicesFactory; 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( $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) + )); } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php index 4cec725..caa5e99 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php @@ -22,11 +22,17 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider $canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed(); $canViewRiskRadar = $authorizationService->authorize('helpdesk.risk-radar.view', $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) { $canManageSettings = false; $canViewTeam = false; $canViewRiskRadar = false; $canManageSoftwareProducts = false; + $canViewHandovers = false; + $canCreateHandovers = false; + $canManageHandovers = false; } return ['helpdesk.nav' => [ @@ -34,6 +40,9 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider 'can_view_team' => $canViewTeam, 'can_view_risk_radar' => $canViewRiskRadar, 'can_manage_software_products' => $canManageSoftwareProducts, + 'can_view_handovers' => $canViewHandovers, + 'can_create_handovers' => $canCreateHandovers, + 'can_manage_handovers' => $canManageHandovers, ]]; } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php new file mode 100644 index 0000000..2543423 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/HandoverRepository.php @@ -0,0 +1,150 @@ + $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 $filters + * @return array{total: int, rows: list>} + */ + 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; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php index 2efe52a..8071bc5 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php @@ -146,6 +146,34 @@ class SoftwareProductRepository return is_int($result) ? $result : 0; } + /** + * List active products that have a handover protocol schema defined. + * + * @return list> + */ + 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 { if (!is_array($row)) { diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index 23ce16e..e43234e 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -22,7 +22,6 @@ class BcODataGateway public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog'; public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test'; public const ENTITY_MEETINGS = 'FS_Debitor_Meetings'; - public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains'; /** @api Used by listContractTypes() */ public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types'; @@ -828,57 +827,6 @@ class BcODataGateway 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> - */ - 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> - */ - 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. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php new file mode 100644 index 0000000..d754815 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php @@ -0,0 +1,292 @@ + [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} + */ + 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 $fieldValues + * @return array{ok: bool, errors: array} + */ + 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} + */ + 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>} + */ + 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 + */ + public function getAllowedStatuses(string $permissionLevel): array + { + return self::ALLOWED_STATUS_BY_PERMISSION[$permissionLevel] ?? []; + } + + /** + * Validate field values against the schema snapshot. + * + * @param array $fieldValues + * @param list> $schemaFields + * @return array + */ + 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', + }; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php index 0bb98ba..58f7f51 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php @@ -28,6 +28,16 @@ class SoftwareProductService return $this->repository->listPaged($filters); } + /** + * List active products that have a handover protocol schema defined. + * + * @return list> + */ + public function listWithSchema(): array + { + return $this->repository->listWithSchema(); + } + private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select']; private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph']; private const MAX_SCHEMA_FIELDS = 50; diff --git a/modules/helpdesk/migrations/005_create_handovers.sql b/modules/helpdesk/migrations/005_create_handovers.sql new file mode 100644 index 0000000..948f301 --- /dev/null +++ b/modules/helpdesk/migrations/005_create_handovers.sql @@ -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; diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 839b33e..c799365 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -42,6 +42,11 @@ return [ ['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'], ['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'], ['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' => [], @@ -101,6 +106,24 @@ return [ 'active' => 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' => [], diff --git a/modules/helpdesk/pages/helpdesk/debitor-lookup-data().php b/modules/helpdesk/pages/helpdesk/debitor-lookup-data().php new file mode 100644 index 0000000..7190189 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/debitor-lookup-data().php @@ -0,0 +1,46 @@ +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); diff --git a/modules/helpdesk/pages/helpdesk/domains/index().php b/modules/helpdesk/pages/helpdesk/domains/index().php index a72d86c..7ea3f09 100644 --- a/modules/helpdesk/pages/helpdesk/domains/index().php +++ b/modules/helpdesk/pages/helpdesk/domains/index().php @@ -22,7 +22,7 @@ $drawerToolbarFilterSchema = $listFilterContext['drawerToolbarFilterSchema']; $toolbarFilterState = $listFilterContext['toolbarFilterState']; $clientFilterSchema = $listFilterContext['clientFilterSchema']; $searchConfig = $listFilterContext['searchConfig']; -$schemaByKey = $listFilterContext['schemaByKey'] ?? []; +$schemaByKey = $listFilterContext['schemaByKey']; $filterChipMeta = [ 'search' => [ 'label' => t('Search'), diff --git a/modules/helpdesk/pages/helpdesk/handovers-data().php b/modules/helpdesk/pages/helpdesk/handovers-data().php new file mode 100644 index 0000000..9edfc4b --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers-data().php @@ -0,0 +1,42 @@ +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); diff --git a/modules/helpdesk/pages/helpdesk/handovers/_form.phtml b/modules/helpdesk/pages/helpdesk/handovers/_form.phtml new file mode 100644 index 0000000..9cf3269 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/_form.phtml @@ -0,0 +1,99 @@ + value) + * $readonly — bool, if true all inputs are disabled + */ +$schemaFields = is_array($schemaFields ?? null) ? $schemaFields : []; +$fieldValues = is_array($fieldValues ?? null) ? $fieldValues : []; +$readonly = !empty($readonly); +?> + +

+ + $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; + ?> + +

+ + +

+ + + + + + + + + + + + + + 'number', + 'date' => 'date', + default => 'text', + }; + ?> + + aria-required="true" + disabled + > + + + diff --git a/modules/helpdesk/pages/helpdesk/handovers/create().php b/modules/helpdesk/pages/helpdesk/handovers/create().php new file mode 100644 index 0000000..fe9c856 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/create().php @@ -0,0 +1,181 @@ +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')], +]; diff --git a/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml b/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml new file mode 100644 index 0000000..51b5f39 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml @@ -0,0 +1,135 @@ + t('Customer & product'), 'number' => 1], + ['label' => t('Protocol'), 'number' => 2], +]; +?> +
+ + +
+ + + +

+
+ + + + + + + + + + +
+
+

+

+ +
+ +
+ + +
+
+ +
+ + + + + + +
+ + + +
+ + +
+
+
+ + + +
+
+

+

+ +

+ +
+ +
+ + + +
+ + +
+
+
+ +
+ + diff --git a/modules/helpdesk/pages/helpdesk/handovers/edit($id).php b/modules/helpdesk/pages/helpdesk/handovers/edit($id).php new file mode 100644 index 0000000..9b7b8ad --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/edit($id).php @@ -0,0 +1,123 @@ +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], +]; diff --git a/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml b/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml new file mode 100644 index 0000000..7cf7dc2 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml @@ -0,0 +1,127 @@ + +
+
+ + + $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'); + ?> + + + + +
+
+
+ +
+ +
+ +
+
+ + 1): ?> + + + + +
+ +
+
+ +
+ +
+ +
+
+ +
+ + +
diff --git a/modules/helpdesk/pages/helpdesk/handovers/filter-schema.php b/modules/helpdesk/pages/helpdesk/handovers/filter-schema.php new file mode 100644 index 0000000..6392b98 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/filter-schema.php @@ -0,0 +1,43 @@ + [ + '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], + ], + ], +]); diff --git a/modules/helpdesk/pages/helpdesk/handovers/index().php b/modules/helpdesk/pages/helpdesk/handovers/index().php new file mode 100644 index 0000000..71a330d --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/index().php @@ -0,0 +1,53 @@ +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')], +]; diff --git a/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml b/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml new file mode 100644 index 0000000..d76c26f --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/handovers/index(default).phtml @@ -0,0 +1,52 @@ + + + + t('New handover'), + 'href' => lurl('helpdesk/handovers/create'), + 'class' => 'primary', + 'icon' => 'bi-plus-lg', + ]; +} +require templatePath('partials/app-list-titlebar.phtml'); +?> + +
+
+
+ + + + diff --git a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml index 61e5f56..05c30c4 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml +++ b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml @@ -11,19 +11,15 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
-
- - - - -
+ +
diff --git a/modules/helpdesk/pages/helpdesk/software-products/edit($code).php b/modules/helpdesk/pages/helpdesk/software-products/edit($code).php index 020d23f..0c84ed7 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/edit($code).php +++ b/modules/helpdesk/pages/helpdesk/software-products/edit($code).php @@ -72,7 +72,10 @@ if ($request->isMethod('POST')) { $validationSummaryErrors = $errorBag->toArray(); $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('style_groups', json_encode(['helpdesk'])); $breadcrumbs = [ diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index 9d76ad9..859ee54 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -6,14 +6,16 @@ $canManageSettings = !empty($helpdeskNav['can_manage_settings']); $canViewTeam = !empty($helpdeskNav['can_view_team']); $canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']); $canManageSoftwareProducts = !empty($helpdeskNav['can_manage_software_products']); +$canViewHandovers = !empty($helpdeskNav['can_view_handovers']); $settingsActive = navActive('helpdesk/settings', true); $teamActive = navActive('helpdesk/team', true); $riskRadarActive = navActive('helpdesk/risk-radar', true); $domainsActive = navActive('helpdesk/domains', true); $softwareProductsActive = navActive('helpdesk/software-products', true); +$handoversActive = navActive('helpdesk/handovers', 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]; } @@ -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', 'label' => t('Administration'), diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php new file mode 100644 index 0000000..5f53bab --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HandoverServiceTest.php @@ -0,0 +1,196 @@ +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']); + } +} diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 7859efb..f7db7e5 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -2339,7 +2339,7 @@ /* Handover protocol schema editor — split layout */ .handover-schema-split { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr 2fr; gap: var(--app-spacing); align-items: start; } @@ -2420,15 +2420,20 @@ } .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); padding: calc(var(--app-spacing) * 0.3); + padding-inline-start: calc(var(--app-spacing) * 0.5); 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 { - background-color: var(--app-secondary-focus); - box-shadow: inset 0 0 0 1px var(--app-card-border-color); + background-color: color-mix(in srgb, var(--app-primary, #635bff) 5%, transparent); + border-left-color: var(--app-primary, #635bff); } .handover-schema-preview-required { @@ -2454,7 +2459,7 @@ .handover-schema-insert-divider::before, .handover-schema-insert-divider::after { - content: ''; + content: ""; flex: 1; height: 1px; background: var(--app-card-border-color); @@ -2470,10 +2475,20 @@ } /* 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); - font-style: italic; - padding: calc(var(--app-spacing) * 0.5) 0; + margin: 0; } .handover-schema-list { @@ -2578,4 +2593,190 @@ 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); + } + } } diff --git a/modules/helpdesk/web/js/handover-schema-editor.js b/modules/helpdesk/web/js/handover-schema-editor.js index 35bc986..b80784e 100644 --- a/modules/helpdesk/web/js/handover-schema-editor.js +++ b/modules/helpdesk/web/js/handover-schema-editor.js @@ -46,9 +46,22 @@ function init(root) { clearElement(root); if (fields.length === 0) { - const empty = document.createElement('p'); - empty.className = 'handover-schema-empty'; - empty.textContent = t.no_fields || 'No fields defined yet.'; + const empty = document.createElement('div'); + empty.className = 'handover-schema-empty-state'; + 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); } else { const list = document.createElement('div'); @@ -60,9 +73,8 @@ function init(root) { list.appendChild(buildFieldRow(field, index)); }); root.appendChild(list); + root.appendChild(buildInsertDivider(fields.length)); } - - root.appendChild(buildInsertDivider(fields.length)); } function buildInsertDivider(insertIndex) { @@ -314,7 +326,7 @@ function init(root) { if (field.type === 'checkbox') { const input = document.createElement('input'); input.type = 'checkbox'; - input.disabled = true; + input.addEventListener('click', (e) => e.preventDefault()); group.appendChild(input); const label = document.createElement('label'); label.textContent = field.label || ''; diff --git a/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js b/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js new file mode 100644 index 0000000..058440e --- /dev/null +++ b/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js @@ -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(`#${id}`); + }, + }, + { 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(`${label}`); + }, + }, + { 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' }); + } + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 14d4a6a..115c576 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1974,12 +1974,6 @@ parameters: count: 1 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$#' identifier: public.method.unused diff --git a/web/css/components/app-lookup-field.css b/web/css/components/app-lookup-field.css new file mode 100644 index 0000000..6cd51be --- /dev/null +++ b/web/css/components/app-lookup-field.css @@ -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); + } +} diff --git a/web/js/components/app-lookup-field.js b/web/js/components/app-lookup-field.js new file mode 100644 index 0000000..fd9fc50 --- /dev/null +++ b/web/js/components/app-lookup-field.js @@ -0,0 +1,329 @@ +/** + * Generic autocomplete lookup field. + * + * Usage: + *
+ * + * + *
+ * + * 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(); +}