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:
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,34 @@ class SoftwareProductRepository
|
||||
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
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
|
||||
@@ -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<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.
|
||||
*
|
||||
|
||||
292
modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php
Normal file
292
modules/helpdesk/lib/Module/Helpdesk/Service/HandoverService.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ class SoftwareProductService
|
||||
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 DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
|
||||
private const MAX_SCHEMA_FIELDS = 50;
|
||||
|
||||
19
modules/helpdesk/migrations/005_create_handovers.sql
Normal file
19
modules/helpdesk/migrations/005_create_handovers.sql
Normal 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;
|
||||
@@ -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' => [],
|
||||
|
||||
46
modules/helpdesk/pages/helpdesk/debitor-lookup-data().php
Normal file
46
modules/helpdesk/pages/helpdesk/debitor-lookup-data().php
Normal 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);
|
||||
@@ -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'),
|
||||
|
||||
42
modules/helpdesk/pages/helpdesk/handovers-data().php
Normal file
42
modules/helpdesk/pages/helpdesk/handovers-data().php
Normal 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);
|
||||
99
modules/helpdesk/pages/helpdesk/handovers/_form.phtml
Normal file
99
modules/helpdesk/pages/helpdesk/handovers/_form.phtml
Normal 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; ?>
|
||||
181
modules/helpdesk/pages/helpdesk/handovers/create().php
Normal file
181
modules/helpdesk/pages/helpdesk/handovers/create().php
Normal 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')],
|
||||
];
|
||||
135
modules/helpdesk/pages/helpdesk/handovers/create(default).phtml
Normal file
135
modules/helpdesk/pages/helpdesk/handovers/create(default).phtml
Normal 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'] ?? ''); ?> — <?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>
|
||||
123
modules/helpdesk/pages/helpdesk/handovers/edit($id).php
Normal file
123
modules/helpdesk/pages/helpdesk/handovers/edit($id).php
Normal 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],
|
||||
];
|
||||
127
modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml
Normal file
127
modules/helpdesk/pages/helpdesk/handovers/edit(default).phtml
Normal 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>
|
||||
43
modules/helpdesk/pages/helpdesk/handovers/filter-schema.php
Normal file
43
modules/helpdesk/pages/helpdesk/handovers/filter-schema.php
Normal 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],
|
||||
],
|
||||
],
|
||||
]);
|
||||
53
modules/helpdesk/pages/helpdesk/handovers/index().php
Normal file
53
modules/helpdesk/pages/helpdesk/handovers/index().php
Normal 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')],
|
||||
];
|
||||
@@ -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>
|
||||
@@ -11,19 +11,15 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
|
||||
</div>
|
||||
|
||||
<div data-tab-panel="basic">
|
||||
<fieldset>
|
||||
<legend><?php e(t('Product details')); ?></legend>
|
||||
|
||||
<label for="software-product-name"><?php e(t('Product name')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="software-product-name"
|
||||
name="name"
|
||||
value="<?php e($values['name'] ?? ''); ?>"
|
||||
maxlength="255"
|
||||
placeholder="<?php e(t('Enter product name...')); ?>"
|
||||
>
|
||||
</fieldset>
|
||||
<label for="software-product-name"><?php e(t('Product name')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="software-product-name"
|
||||
name="name"
|
||||
value="<?php e($values['name'] ?? ''); ?>"
|
||||
maxlength="255"
|
||||
placeholder="<?php e(t('Enter product name...')); ?>"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div data-tab-panel="handover-protocol" data-tab-panel-wide>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
86
modules/helpdesk/web/js/pages/helpdesk-handovers-index.js
Normal file
86
modules/helpdesk/web/js/pages/helpdesk-handovers-index.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
147
web/css/components/app-lookup-field.css
Normal file
147
web/css/components/app-lookup-field.css
Normal 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);
|
||||
}
|
||||
}
|
||||
329
web/js/components/app-lookup-field.js
Normal file
329
web/js/components/app-lookup-field.js
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user