1
0

feat(helpdesk): add software updates tracking with BC ticket integration

New Updates page in the Helpdesk module that fetches UPDATE/UPDATE-HF tickets
from Business Central (PBI_LV_Tickets) and allows assigning a domain and Gitea
link via a dialog. Ticket status (from BC) and assignment status (local) are
shown as separate columns with filters for both plus type and free-text search.
Assigned updates also appear on the domain detail page. Includes session-cached
BC fetch with refresh button, admin permissions, migration, and 16 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:21:40 +02:00
parent 693d33a0c8
commit da0abc824a
23 changed files with 1971 additions and 6 deletions

View File

@@ -1552,7 +1552,8 @@ JOIN permissions p ON p.`key` IN (
'stats.view', 'system_info.view',
'roles.assign_all',
'helpdesk.access', 'helpdesk.settings.manage', 'helpdesk.team-workload.view', 'helpdesk.risk-radar.view', 'helpdesk.software-products.manage',
'helpdesk.handovers.view', 'helpdesk.handovers.create', 'helpdesk.handovers.manage'
'helpdesk.handovers.view', 'helpdesk.handovers.create', 'helpdesk.handovers.manage',
'helpdesk.updates.view', 'helpdesk.updates.manage'
)
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
ON DUPLICATE KEY UPDATE role_id = role_id;

View File

@@ -0,0 +1,16 @@
-- Helpdesk module: assign updates permissions to Admin role.
-- Idempotent: safe to run multiple times.
INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`)
SELECT r.id, p.id, NOW()
FROM roles r
JOIN permissions p ON p.`key` = 'helpdesk.updates.view'
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
ON DUPLICATE KEY UPDATE role_id = role_id;
INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`)
SELECT r.id, p.id, NOW()
FROM roles r
JOIN permissions p ON p.`key` = 'helpdesk.updates.manage'
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
ON DUPLICATE KEY UPDATE role_id = role_id;

View File

@@ -381,6 +381,7 @@
"Define the fields that make up the handover protocol for this product.": "Definieren Sie die Felder, die das Übergabeprotokoll für dieses Produkt bilden.",
"Add field": "Feld hinzufügen",
"Remove field": "Feld entfernen",
"Duplicate field": "Feld duplizieren",
"Move up": "Nach oben",
"Move down": "Nach unten",
"Field key": "Feld-Schlüssel",
@@ -469,6 +470,7 @@
"Restore this version": "Diese Version wiederherstellen",
"Restore this version?": "Diese Version wiederherstellen?",
"Version restored": "Version wiederhergestellt",
"Failed to restore": "Wiederherstellen fehlgeschlagen",
"Only managers can restore revisions": "Nur Manager können Versionen wiederherstellen",
"Revision not found": "Version nicht gefunden",
"Created": "Erstellt",
@@ -480,5 +482,63 @@
"Yes": "Ja",
"No": "Nein",
"Changed": "Geändert",
"Compare with current": "Mit aktueller Version vergleichen"
"Compare with current": "Mit aktueller Version vergleichen",
"Domain": "Domain",
"Select a customer first...": "Bitte zuerst einen Kunden auswählen...",
"Loading domains...": "Domains werden geladen...",
"No domains found for this customer": "Keine Domains für diesen Kunden gefunden",
"Failed to load domains": "Domains konnten nicht geladen werden",
"Please select a domain": "Bitte wählen Sie eine Domain",
"Delete": "Löschen",
"Select all": "Alle auswählen",
"Delete selected handovers?": "Ausgewählte Übergaben löschen?",
"%d handover(s) deleted": "%d Übergabe(n) gelöscht",
"Failed to delete handovers": "Übergaben konnten nicht gelöscht werden",
"Only managers can delete handovers": "Nur Manager können Übergaben löschen",
"No handovers selected": "Keine Übergaben ausgewählt",
"Domain not found": "Domain nicht gefunden",
"Domain detail": "Domain-Details",
"Back to domains": "Zurück zu Domains",
"Open domain": "Domain öffnen",
"Open debitor": "Debitor öffnen",
"Customer information": "Kundeninformation",
"Contract information": "Vertragsinformation",
"Related domains": "Verwandte Domains",
"No handovers for this domain yet": "Noch keine Übergaben für diese Domain",
"No contract information available": "Keine Vertragsinformation verfügbar",
"Network error — please try again": "Netzwerkfehler — bitte erneut versuchen",
"Contract state": "Vertragsstatus",
"Total": "Gesamt",
"URL": "URL",
"State": "Status",
"Product": "Produkt",
"Updates": "Updates",
"Ticket status": "Ticketstatus",
"Assignment": "Zuweisung",
"Resolved": "Erledigt",
"Closed": "Geschlossen",
"Open": "Offen",
"Assign update": "Update zuweisen",
"Assign": "Zuweisen",
"Unassigned": "Nicht zugewiesen",
"Assigned": "Zugewiesen",
"Update": "Update",
"Hotfix": "Hotfix",
"Gitea link": "Gitea-Link",
"e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02": "z.B. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02",
"Notes": "Notizen",
"No update tickets found": "Keine Update-Tickets gefunden",
"Failed to load update tickets": "Update-Tickets konnten nicht geladen werden",
"Update assigned successfully": "Update erfolgreich zugewiesen",
"Failed to assign update": "Update konnte nicht zugewiesen werden",
"Select domain...": "Domain auswählen...",
"Loading domains...": "Domains werden geladen...",
"No domains found for this customer": "Keine Domains für diesen Kunden gefunden",
"No updates for this domain yet": "Noch keine Updates für diese Domain",
"Search updates...": "Updates suchen...",
"Open in Gitea": "In Gitea öffnen",
"Invalid category": "Ungültige Kategorie",
"Ticket number is required": "Ticket-Nummer ist erforderlich",
"View software updates list": "Software-Updates-Liste anzeigen",
"Assign and manage software updates": "Software-Updates zuweisen und verwalten"
}

View File

@@ -381,6 +381,7 @@
"Define the fields that make up the handover protocol for this product.": "Define the fields that make up the handover protocol for this product.",
"Add field": "Add field",
"Remove field": "Remove field",
"Duplicate field": "Duplicate field",
"Move up": "Move up",
"Move down": "Move down",
"Field key": "Field key",
@@ -469,6 +470,7 @@
"Restore this version": "Restore this version",
"Restore this version?": "Restore this version?",
"Version restored": "Version restored",
"Failed to restore": "Failed to restore",
"Only managers can restore revisions": "Only managers can restore revisions",
"Revision not found": "Revision not found",
"Created": "Created",
@@ -480,5 +482,63 @@
"Yes": "Yes",
"No": "No",
"Changed": "Changed",
"Compare with current": "Compare with current"
"Compare with current": "Compare with current",
"Domain": "Domain",
"Select a customer first...": "Select a customer first...",
"Loading domains...": "Loading domains...",
"No domains found for this customer": "No domains found for this customer",
"Failed to load domains": "Failed to load domains",
"Please select a domain": "Please select a domain",
"Delete": "Delete",
"Select all": "Select all",
"Delete selected handovers?": "Delete selected handovers?",
"%d handover(s) deleted": "%d handover(s) deleted",
"Failed to delete handovers": "Failed to delete handovers",
"Only managers can delete handovers": "Only managers can delete handovers",
"No handovers selected": "No handovers selected",
"Domain not found": "Domain not found",
"Domain detail": "Domain detail",
"Back to domains": "Back to domains",
"Open domain": "Open domain",
"Open debitor": "Open debitor",
"Customer information": "Customer information",
"Contract information": "Contract information",
"Related domains": "Related domains",
"No handovers for this domain yet": "No handovers for this domain yet",
"No contract information available": "No contract information available",
"Network error — please try again": "Network error — please try again",
"Contract state": "Contract state",
"Total": "Total",
"URL": "URL",
"State": "State",
"Product": "Product",
"Updates": "Updates",
"Ticket status": "Ticket status",
"Assignment": "Assignment",
"Resolved": "Resolved",
"Closed": "Closed",
"Open": "Open",
"Assign update": "Assign update",
"Assign": "Assign",
"Unassigned": "Unassigned",
"Assigned": "Assigned",
"Update": "Update",
"Hotfix": "Hotfix",
"Gitea link": "Gitea link",
"e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02": "e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02",
"Notes": "Notes",
"No update tickets found": "No update tickets found",
"Failed to load update tickets": "Failed to load update tickets",
"Update assigned successfully": "Update assigned successfully",
"Failed to assign update": "Failed to assign update",
"Select domain...": "Select domain...",
"Loading domains...": "Loading domains...",
"No domains found for this customer": "No domains found for this customer",
"No updates for this domain yet": "No updates for this domain yet",
"Search updates...": "Search updates...",
"Open in Gitea": "Open in Gitea",
"Invalid category": "Invalid category",
"Ticket number is required": "Ticket number is required",
"View software updates list": "View software updates list",
"Assign and manage software updates": "Assign and manage software updates"
}

View File

@@ -16,6 +16,10 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
public const ABILITY_HANDOVERS_VIEW = 'helpdesk.handovers.view';
public const ABILITY_HANDOVERS_CREATE = 'helpdesk.handovers.create';
public const ABILITY_HANDOVERS_MANAGE = 'helpdesk.handovers.manage';
/** @api Used in pages/helpdesk/updates action files */
public const ABILITY_UPDATES_VIEW = 'helpdesk.updates.view';
/** @api Used in pages/helpdesk/updates action files */
public const ABILITY_UPDATES_MANAGE = 'helpdesk.updates.manage';
public const PERMISSION_ACCESS = 'helpdesk.access';
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
@@ -29,6 +33,10 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
public const PERMISSION_HANDOVERS_CREATE = 'helpdesk.handovers.create';
/** @api Used in authorize() match for ability resolution */
public const PERMISSION_HANDOVERS_MANAGE = 'helpdesk.handovers.manage';
/** @api Used in authorize() match */
public const PERMISSION_UPDATES_VIEW = 'helpdesk.updates.view';
/** @api Used in authorize() match */
public const PERMISSION_UPDATES_MANAGE = 'helpdesk.updates.manage';
public function __construct(
private readonly PermissionService $permissionService
@@ -37,7 +45,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, self::ABILITY_HANDOVERS_VIEW, self::ABILITY_HANDOVERS_CREATE, self::ABILITY_HANDOVERS_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, self::ABILITY_UPDATES_VIEW, self::ABILITY_UPDATES_MANAGE], true);
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
@@ -56,6 +64,8 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
self::ABILITY_HANDOVERS_VIEW => self::PERMISSION_HANDOVERS_VIEW,
self::ABILITY_HANDOVERS_CREATE => self::PERMISSION_HANDOVERS_CREATE,
self::ABILITY_HANDOVERS_MANAGE => self::PERMISSION_HANDOVERS_MANAGE,
self::ABILITY_UPDATES_VIEW => self::PERMISSION_UPDATES_VIEW,
self::ABILITY_UPDATES_MANAGE => self::PERMISSION_UPDATES_MANAGE,
default => null,
};

View File

@@ -9,6 +9,7 @@ use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
use MintyPHP\Module\Helpdesk\Service\DomainDetailService;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
@@ -23,8 +24,10 @@ use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\UpdateService;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
@@ -86,6 +89,13 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$c->get(EffectiveHelpdeskSettingsService::class)
));
$container->set(DomainDetailService::class, static fn (AppContainer $c): DomainDetailService => new DomainDetailService(
$c->get(BcODataGateway::class),
$c->get(EffectiveHelpdeskSettingsService::class),
$c->get(HandoverRepository::class),
$c->get(UpdateRepository::class)
));
$container->set(TicketCommunicationService::class, static fn (AppContainer $c): TicketCommunicationService => new TicketCommunicationService(
$c->get(BcSoapGateway::class),
$c->get(BcODataGateway::class)
@@ -123,5 +133,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$c->get(SoftwareProductService::class),
$c->get(HandoverRevisionService::class)
));
$container->set(UpdateRepository::class, static fn (): UpdateRepository => new UpdateRepository());
$container->set(UpdateService::class, static fn (AppContainer $c): UpdateService => new UpdateService(
$c->get(UpdateRepository::class),
$c->get(BcODataGateway::class)
));
}
}

View File

@@ -25,6 +25,7 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
$canViewHandovers = $authorizationService->authorize('helpdesk.handovers.view', $actorContext)->isAllowed();
$canCreateHandovers = $authorizationService->authorize('helpdesk.handovers.create', $actorContext)->isAllowed();
$canManageHandovers = $authorizationService->authorize('helpdesk.handovers.manage', $actorContext)->isAllowed();
$canViewUpdates = $authorizationService->authorize('helpdesk.updates.view', $actorContext)->isAllowed();
} catch (\Throwable) {
$canManageSettings = false;
$canViewTeam = false;
@@ -33,6 +34,7 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
$canViewHandovers = false;
$canCreateHandovers = false;
$canManageHandovers = false;
$canViewUpdates = false;
}
return ['helpdesk.nav' => [
@@ -43,6 +45,7 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider
'can_view_handovers' => $canViewHandovers,
'can_create_handovers' => $canCreateHandovers,
'can_manage_handovers' => $canManageHandovers,
'can_view_updates' => $canViewUpdates,
]];
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace MintyPHP\Module\Helpdesk\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class UpdateRepository
{
/**
* @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_updates '
. '(tenant_id, ticket_no, category_code, debitor_no, debitor_name, domain_no, domain_url, gitea_path, notes, status, created_by) '
. 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(string) ($data['tenant_id'] ?? 0),
(string) ($data['ticket_no'] ?? ''),
(string) ($data['category_code'] ?? ''),
(string) ($data['debitor_no'] ?? ''),
(string) ($data['debitor_name'] ?? ''),
(string) ($data['domain_no'] ?? ''),
(string) ($data['domain_url'] ?? ''),
(string) ($data['gitea_path'] ?? ''),
$data['notes'] ?? null,
(string) ($data['status'] ?? 'open'),
(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 u.*,'
. ' uc.display_name AS created_by_label, uc.uuid AS created_by_uuid,'
. ' uu.display_name AS updated_by_label, uu.uuid AS updated_by_uuid'
. ' FROM helpdesk_updates u'
. ' LEFT JOIN users uc ON uc.id = u.created_by'
. ' LEFT JOIN users uu ON uu.id = u.updated_by'
. ' WHERE u.id = ? AND u.tenant_id = ? LIMIT 1',
(string) $id,
(string) $tenantId
);
return $this->normalizeRow($row);
}
public function findByTicketNo(int $tenantId, string $ticketNo): ?array
{
if ($ticketNo === '') {
return null;
}
$row = DB::selectOne(
'SELECT u.*,'
. ' uc.display_name AS created_by_label'
. ' FROM helpdesk_updates u'
. ' LEFT JOIN users uc ON uc.id = u.created_by'
. ' WHERE u.tenant_id = ? AND u.ticket_no = ? LIMIT 1',
(string) $tenantId,
$ticketNo
);
return $this->normalizeRow($row);
}
/**
* @return list<array<string, mixed>>
*/
public function findByDomainNo(int $tenantId, string $domainNo): array
{
if ($domainNo === '') {
return [];
}
$rows = DB::select(
'SELECT u.*,'
. ' uc.display_name AS created_by_label'
. ' FROM helpdesk_updates u'
. ' LEFT JOIN users uc ON uc.id = u.created_by'
. ' WHERE u.tenant_id = ? AND u.domain_no = ?'
. ' ORDER BY u.created_at DESC',
(string) $tenantId,
$domainNo
);
return $this->normalizeRows($rows);
}
/**
* @return list<array<string, mixed>>
*/
public function findAllByTenant(int $tenantId): array
{
$rows = DB::select(
'SELECT u.ticket_no, u.domain_no, u.domain_url, u.gitea_path, u.notes, u.status,'
. ' u.category_code, u.debitor_no, u.id'
. ' FROM helpdesk_updates u'
. ' WHERE u.tenant_id = ?',
(string) $tenantId
);
return $this->normalizeRows($rows);
}
/**
* @param array<string, mixed> $data
*/
public function updateAssignment(int $tenantId, int $id, array $data): bool
{
$result = DB::update(
'UPDATE helpdesk_updates SET domain_no = ?, domain_url = ?, gitea_path = ?, notes = ?, status = ?, updated_by = ? WHERE id = ? AND tenant_id = ?',
(string) ($data['domain_no'] ?? ''),
(string) ($data['domain_url'] ?? ''),
(string) ($data['gitea_path'] ?? ''),
$data['notes'] ?? null,
(string) ($data['status'] ?? 'assigned'),
(string) ($data['updated_by'] ?? 0),
(string) $id,
(string) $tenantId
);
return $result !== false;
}
/**
* @param mixed $rows
* @return list<array<string, mixed>>
*/
private function normalizeRows(mixed $rows): array
{
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = $this->normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return $normalized;
}
private function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = [];
foreach ($row as $key => $value) {
if (is_array($value)) {
$item = array_merge($item, $value);
} else {
$item[$key] = $value;
}
}
if (!isset($item['id']) && !isset($item['ticket_no'])) {
return null;
}
return $item;
}
}

View File

@@ -24,6 +24,8 @@ class BcODataGateway
public const ENTITY_MEETINGS = 'FS_Debitor_Meetings';
/** @api Used by listContractTypes() */
public const ENTITY_CONTRACT_TYPES = 'FS_Contract_Types';
/** @api Used by handover-domains-data endpoint and domains-data endpoint */
public const ENTITY_CONTRACT_DOMAINS = 'FS_Contract_Domains';
private const CONNECT_TIMEOUT = 10;
private const REQUEST_TIMEOUT = 30;
@@ -687,6 +689,62 @@ class BcODataGateway
return ['tickets' => $allTickets, 'truncated' => $truncated];
}
/**
* Fetch all update/hotfix tickets across all customers.
*
* Queries PBI_LV_Tickets with Category_1_Code filter for UPDATE and UPDATE-HF.
* Results are cached in session with standard TTL.
* Uses pagination via @odata.nextLink (same as risk radar).
*
* @return array{tickets: array<int, array<string, mixed>>, truncated: bool}
*/
public function fetchUpdateTickets(): array
{
$tenantScope = DebitorCacheControl::resolveTenantScope($this->sessionStore->all());
$cacheKey = DebitorCacheControl::updateTicketsKey($tenantScope);
$cached = $this->sessionStore->get($cacheKey);
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < DebitorCacheControl::TTL_STANDARD_SECONDS) {
$tickets = $cached['tickets'] ?? [];
$truncated = (bool) ($cached['truncated'] ?? false);
if (is_array($tickets)) {
return ['tickets' => $tickets, 'truncated' => $truncated];
}
}
$select = 'No,Description,Customer_No,Cust_Name,Category_1_Code,Ticket_State,Created_On,Last_Activity_Date,Support_User_Name';
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV);
// BC OData often rejects `or` in $filter — fetch each category separately and merge.
$allTickets = [];
$truncated = false;
foreach (['UPDATE', 'UPDATE-HF'] as $category) {
$filter = "Category_1_Code eq '" . $category . "'";
$url = $baseUrl
. '?$top=500'
. '&$select=' . rawurlencode($select)
. '&$filter=' . rawurlencode($filter)
. '&$orderby=' . rawurlencode('Created_On desc');
$partial = $this->paginateOData($url, 10);
foreach ($partial['tickets'] as $ticket) {
$allTickets[] = $ticket;
}
if ($partial['truncated']) {
$truncated = true;
}
}
$result = ['tickets' => $this->dedupeRowsByNo($allTickets), 'truncated' => $truncated];
$this->sessionStore->set($cacheKey, [
'tickets' => $result['tickets'],
'truncated' => $result['truncated'],
'fetched_at' => time(),
]);
return $result;
}
/**
* Get contracts for a customer by customer number.
*
@@ -798,6 +856,116 @@ class BcODataGateway
return $this->extractODataValues($response);
}
/**
* Get all domains (unfiltered, for the domains list page).
*
* @api Called from domains-data endpoint
* @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 a single domain by its domain number.
*
* @api Called from domain($id) action
* @return array<string, mixed>|null
*/
public function getDomain(string $domainNo): ?array
{
$domainNo = trim($domainNo);
if ($domainNo === '') {
return null;
}
$escaped = $this->escapeODataStrictUserInput($domainNo);
$filter = "No eq '" . $escaped . "'";
$select = 'No,Customer_No,Customer_Name,URL,State,Administration';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
. '?$filter=' . rawurlencode($filter)
. '&$top=1'
. '&$select=' . rawurlencode($select);
$response = $this->request('GET', $url);
if ($response === null) {
return null;
}
$values = $this->extractODataValues($response);
return $values[0] ?? null;
}
/**
* Get active domains for a customer.
*
* @api Called from handover-domains-data endpoint
* @return array<int, array<string, mixed>>
*/
public function getDomainsForCustomer(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return [];
}
$escaped = $this->escapeODataStrictUserInput($customerNo);
$filter = "Customer_No eq '" . $escaped . "' and State eq 'Aktiv'";
$select = 'No,Customer_No,URL,State';
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_DOMAINS)
. '?$filter=' . rawurlencode($filter)
. '&$top=200'
. '&$select=' . rawurlencode($select)
. '&$orderby=' . rawurlencode('URL asc');
$response = $this->request('GET', $url);
if ($response === null) {
return [];
}
return $this->extractODataValues($response);
}
/**
* Get meetings/appointments for a customer.
*

View File

@@ -97,6 +97,12 @@ final class DebitorCacheControl
return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays;
}
/** @api Used by BcODataGateway::fetchUpdateTickets() */
public static function updateTicketsKey(string $tenantScope): string
{
return 'module.helpdesk.update-tickets.v1.' . $tenantScope;
}
/**
* @return array<int, string>
*/

View File

@@ -0,0 +1,270 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
/** @api Called from pages/helpdesk/updates action files */
class UpdateService
{
public const STATUS_OPEN = 'open';
public const STATUS_ASSIGNED = 'assigned';
public const ALL_STATUSES = [
self::STATUS_OPEN,
self::STATUS_ASSIGNED,
];
public const CATEGORY_UPDATE = 'UPDATE';
public const CATEGORY_HOTFIX = 'UPDATE-HF';
public const UPDATE_CATEGORIES = [
self::CATEGORY_UPDATE,
self::CATEGORY_HOTFIX,
];
public function __construct(
private readonly UpdateRepository $repository,
private readonly BcODataGateway $bcGateway,
) {
}
/**
* Get the merged list of BC update tickets with local assignment data.
*
* Fetches all UPDATE/UPDATE-HF tickets from BC, then left-joins with
* local helpdesk_updates records to enrich with domain/gitea assignments.
*
* @param array<string, mixed> $filters Optional filters: status, category
* @return array{rows: list<array<string, mixed>>, truncated: bool}
*/
public function getUpdatesList(int $tenantId, array $filters = []): array
{
$bcResult = $this->bcGateway->fetchUpdateTickets();
$bcTickets = $bcResult['tickets'];
$truncated = $bcResult['truncated'];
$localRows = $this->repository->findAllByTenant($tenantId);
$localByTicketNo = [];
foreach ($localRows as $local) {
$ticketNo = trim((string) ($local['ticket_no'] ?? ''));
if ($ticketNo !== '') {
$localByTicketNo[$ticketNo] = $local;
}
}
$filterStatus = trim((string) ($filters['status'] ?? ''));
$filterCategory = trim((string) ($filters['category'] ?? ''));
$filterTicketState = trim((string) ($filters['ticket_state'] ?? ''));
$filterSearch = trim((string) ($filters['search'] ?? ''));
$merged = [];
foreach ($bcTickets as $ticket) {
$ticketNo = trim((string) ($ticket['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$categoryCode = trim((string) ($ticket['Category_1_Code'] ?? ''));
if (!in_array($categoryCode, self::UPDATE_CATEGORIES, true)) {
continue;
}
$local = $localByTicketNo[$ticketNo] ?? null;
$status = $local !== null ? (string) ($local['status'] ?? self::STATUS_OPEN) : self::STATUS_OPEN;
$ticketState = trim((string) ($ticket['Ticket_State'] ?? ''));
// Apply filters
if ($filterStatus !== '' && $filterStatus !== 'all' && $status !== $filterStatus) {
continue;
}
if ($filterCategory !== '' && $filterCategory !== 'all' && $categoryCode !== $filterCategory) {
continue;
}
if ($filterTicketState !== '' && $filterTicketState !== 'all' && $ticketState !== $filterTicketState) {
continue;
}
$debitorNo = trim((string) ($ticket['Customer_No'] ?? ''));
$debitorName = trim((string) ($ticket['Cust_Name'] ?? ''));
// Search filter: match against ticket no, debitor name/no, domain url, gitea path
if ($filterSearch !== '') {
$searchHaystack = strtolower($ticketNo . ' ' . $debitorName . ' ' . $debitorNo . ' ' . ($local['domain_url'] ?? '') . ' ' . ($local['gitea_path'] ?? ''));
if (strpos($searchHaystack, strtolower($filterSearch)) === false) {
continue;
}
}
$merged[] = [
'ticket_no' => $ticketNo,
'ticket_description' => trim((string) ($ticket['Description'] ?? '')),
'category_code' => $categoryCode,
'debitor_no' => $debitorNo,
'debitor_name' => $debitorName,
'ticket_state' => trim((string) ($ticket['Ticket_State'] ?? '')),
'support_user' => trim((string) ($ticket['Support_User_Name'] ?? '')),
'created_on' => trim((string) ($ticket['Created_On'] ?? '')),
'last_activity' => trim((string) ($ticket['Last_Activity_Date'] ?? '')),
'domain_no' => (string) ($local['domain_no'] ?? ''),
'domain_url' => (string) ($local['domain_url'] ?? ''),
'gitea_path' => (string) ($local['gitea_path'] ?? ''),
'notes' => (string) ($local['notes'] ?? ''),
'status' => $status,
'local_id' => $local !== null ? (int) ($local['id'] ?? 0) : null,
];
}
return ['rows' => $merged, 'truncated' => $truncated];
}
/**
* Assign a domain and gitea path to an update ticket.
*
* Creates a new helpdesk_updates record or updates an existing one.
*
* @return array{ok: bool, id: int|null, errors: array<string, string>}
*/
public function assignUpdate(
int $tenantId,
string $ticketNo,
string $debitorNo,
string $debitorName,
string $categoryCode,
string $domainNo,
string $domainUrl,
string $giteaPath,
string $notes,
int $userId,
): array {
$errors = [];
$ticketNo = trim($ticketNo);
if ($ticketNo === '') {
$errors['ticket_no'] = t('Ticket number is required');
}
$debitorNo = trim($debitorNo);
if ($debitorNo === '') {
$errors['debitor_no'] = t('Debitor number is required');
}
$categoryCode = trim($categoryCode);
if (!in_array($categoryCode, self::UPDATE_CATEGORIES, true)) {
$errors['category_code'] = t('Invalid category');
}
if ($errors !== []) {
return ['ok' => false, 'id' => null, 'errors' => $errors];
}
$domainNo = trim($domainNo);
$domainUrl = trim($domainUrl);
$giteaPath = trim($giteaPath);
$notes = trim($notes);
$status = ($domainNo !== '' || $giteaPath !== '') ? self::STATUS_ASSIGNED : self::STATUS_OPEN;
$existing = $this->repository->findByTicketNo($tenantId, $ticketNo);
if ($existing !== null) {
$existingId = (int) ($existing['id'] ?? 0);
if ($existingId <= 0) {
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to save')]];
}
$updated = $this->repository->updateAssignment($tenantId, $existingId, [
'domain_no' => $domainNo,
'domain_url' => $domainUrl,
'gitea_path' => $giteaPath,
'notes' => $notes,
'status' => $status,
'updated_by' => $userId,
]);
if (!$updated) {
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to save')]];
}
return ['ok' => true, 'id' => $existingId, 'errors' => []];
}
$id = $this->repository->insert([
'tenant_id' => $tenantId,
'ticket_no' => $ticketNo,
'category_code' => $categoryCode,
'debitor_no' => $debitorNo,
'debitor_name' => trim($debitorName),
'domain_no' => $domainNo,
'domain_url' => $domainUrl,
'gitea_path' => $giteaPath,
'notes' => $notes,
'status' => $status,
'created_by' => $userId,
]);
if ($id === null) {
return ['ok' => false, 'id' => null, 'errors' => ['general' => t('Failed to save')]];
}
return ['ok' => true, 'id' => $id, 'errors' => []];
}
public static function statusLabel(string $status): string
{
return match ($status) {
self::STATUS_OPEN => t('Unassigned'),
self::STATUS_ASSIGNED => t('Assigned'),
default => $status,
};
}
public static function statusVariant(string $status): string
{
return match ($status) {
self::STATUS_OPEN => 'warning',
self::STATUS_ASSIGNED => 'success',
default => 'neutral',
};
}
public static function ticketStateLabel(string $state): string
{
return match ($state) {
'Offen' => t('Open'),
'In Bearbeitung' => t('In progress'),
'Erledigt' => t('Resolved'),
'Geschlossen' => t('Closed'),
default => $state !== '' ? $state : '—',
};
}
public static function ticketStateVariant(string $state): string
{
return match ($state) {
'Offen' => 'warning',
'In Bearbeitung' => 'info',
'Erledigt' => 'success',
'Geschlossen' => 'neutral',
default => 'neutral',
};
}
public static function categoryLabel(string $code): string
{
return match ($code) {
self::CATEGORY_UPDATE => t('Update'),
self::CATEGORY_HOTFIX => t('Hotfix'),
default => $code,
};
}
public static function categoryVariant(string $code): string
{
return match ($code) {
self::CATEGORY_UPDATE => 'neutral',
self::CATEGORY_HOTFIX => 'warning',
default => 'neutral',
};
}
}

View File

@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS `helpdesk_updates` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tenant_id` INT UNSIGNED NOT NULL,
`ticket_no` VARCHAR(30) NOT NULL,
`category_code` VARCHAR(30) NOT NULL,
`debitor_no` VARCHAR(30) NOT NULL,
`debitor_name` VARCHAR(255) NOT NULL DEFAULT '',
`domain_no` VARCHAR(30) NOT NULL DEFAULT '',
`domain_url` VARCHAR(255) NOT NULL DEFAULT '',
`gitea_path` VARCHAR(255) NOT NULL DEFAULT '',
`notes` TEXT NULL DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'open',
`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`),
UNIQUE KEY `uq_hu_ticket` (`tenant_id`, `ticket_no`),
KEY `idx_hu_tenant` (`tenant_id`),
KEY `idx_hu_domain` (`tenant_id`, `domain_no`),
KEY `idx_hu_status` (`tenant_id`, `status`),
KEY `idx_hu_debitor` (`tenant_id`, `debitor_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -17,6 +17,8 @@ return [
['path' => 'helpdesk/search-data', 'target' => 'helpdesk/search-data'],
['path' => 'helpdesk/domains', 'target' => 'helpdesk/domains'],
['path' => 'helpdesk/domains-data', 'target' => 'helpdesk/domains-data'],
['path' => 'helpdesk/domain/{id}', 'target' => 'helpdesk/domain'],
['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'],
['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'],
['path' => 'helpdesk/ticket/{id}', 'target' => 'helpdesk/ticket'],
['path' => 'helpdesk/settings', 'target' => 'helpdesk/settings'],
@@ -46,7 +48,13 @@ return [
['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/handovers/bulk/{action}', 'target' => 'helpdesk/handovers/bulk'],
['path' => 'helpdesk/debitor-lookup-data', 'target' => 'helpdesk/debitor-lookup-data'],
['path' => 'helpdesk/handover-domains-data', 'target' => 'helpdesk/handover-domains-data'],
['path' => 'helpdesk/updates', 'target' => 'helpdesk/updates'],
['path' => 'helpdesk/updates-data', 'target' => 'helpdesk/updates-data'],
['path' => 'helpdesk/updates/assign', 'target' => 'helpdesk/updates/assign'],
['path' => 'helpdesk/update-domains-data', 'target' => 'helpdesk/update-domains-data'],
],
'public_paths' => [],
@@ -124,6 +132,18 @@ return [
'active' => true,
'is_system' => true,
],
[
'key' => 'helpdesk.updates.view',
'description' => 'View software updates list',
'active' => true,
'is_system' => true,
],
[
'key' => 'helpdesk.updates.manage',
'description' => 'Assign and manage software updates',
'active' => true,
'is_system' => true,
],
],
'search_resources' => [],

View File

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

View File

@@ -0,0 +1,98 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\UpdateService;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_UPDATES_VIEW);
gridRequireGetRequest();
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
$request = requestInput();
$filters = [
'status' => trim((string) $request->query('status', '')),
'category' => trim((string) $request->query('category', '')),
'ticket_state' => trim((string) $request->query('ticket_state', '')),
'search' => trim((string) $request->query('search', '')),
];
$sessionStore = app(SessionStoreInterface::class);
$session = $sessionStore->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', ''));
if ($refreshRequested) {
$tenantScope = DebitorCacheControl::resolveTenantScope($session);
$sessionStore->remove(DebitorCacheControl::updateTicketsKey($tenantScope));
}
$service = app(UpdateService::class);
try {
$result = $service->getUpdatesList($tenantId, $filters);
} catch (\Throwable) {
Router::json(['rows' => [], 'total' => 0, 'error' => true]);
return;
}
$rows = $result['rows'] ?? [];
$debitorBaseUrl = lurl('helpdesk/debitor/');
$ticketBaseUrl = lurl('helpdesk/ticket/');
$formatBcDate = static function (string $raw): string {
$raw = trim($raw);
if ($raw === '') {
return '';
}
try {
return (new \DateTimeImmutable($raw))->format('d.m.Y H:i');
} catch (\Throwable) {
return $raw;
}
};
$preparedRows = [];
foreach ($rows as $row) {
$ticketNo = (string) ($row['ticket_no'] ?? '');
$status = (string) ($row['status'] ?? 'open');
$categoryCode = (string) ($row['category_code'] ?? '');
$debitorNo = (string) ($row['debitor_no'] ?? '');
$ticketState = (string) ($row['ticket_state'] ?? '');
$preparedRows[] = [
'ticket_no' => $ticketNo,
'ticket_url' => $ticketNo !== '' ? $ticketBaseUrl . rawurlencode($ticketNo) : '',
'ticket_description' => (string) ($row['ticket_description'] ?? ''),
'category_code' => $categoryCode,
'category_label' => UpdateService::categoryLabel($categoryCode),
'category_variant' => UpdateService::categoryVariant($categoryCode),
'debitor_no' => $debitorNo,
'debitor_name' => (string) ($row['debitor_name'] ?? ''),
'debitor_url' => $debitorNo !== '' ? $debitorBaseUrl . rawurlencode($debitorNo) : '',
'domain_no' => (string) ($row['domain_no'] ?? ''),
'domain_url' => (string) ($row['domain_url'] ?? ''),
'gitea_path' => (string) ($row['gitea_path'] ?? ''),
'notes' => (string) ($row['notes'] ?? ''),
'created_on' => $formatBcDate((string) ($row['created_on'] ?? '')),
'ticket_state' => $ticketState,
'ticket_state_label' => UpdateService::ticketStateLabel($ticketState),
'ticket_state_variant' => UpdateService::ticketStateVariant($ticketState),
'status' => $status,
'status_label' => UpdateService::statusLabel($status),
'status_variant' => UpdateService::statusVariant($status),
'local_id' => $row['local_id'] ?? null,
];
}
Router::json([
'rows' => $preparedRows,
'total' => count($preparedRows),
'truncated' => $result['truncated'] ?? false,
]);

View File

@@ -0,0 +1,44 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\UpdateService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_UPDATES_MANAGE);
$request = requestInput();
$body = $request->bodyAll();
// CSRF: Router skips automatic check for AJAX (X-Requested-With header).
// For non-AJAX POST, verify manually.
if ($request->isMethod('POST') && empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
if (!Session::checkCsrfToken()) {
Router::json(['ok' => false, 'errors' => ['general' => 'Invalid CSRF token']]);
return;
}
}
$session = app(SessionStoreInterface::class)->all();
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
$userId = (int) ($session['user']['id'] ?? 0);
$service = app(UpdateService::class);
$result = $service->assignUpdate(
$tenantId,
(string) ($body['ticket_no'] ?? ''),
(string) ($body['debitor_no'] ?? ''),
(string) ($body['debitor_name'] ?? ''),
(string) ($body['category_code'] ?? ''),
(string) ($body['domain_no'] ?? ''),
(string) ($body['domain_url'] ?? ''),
(string) ($body['gitea_path'] ?? ''),
(string) ($body['notes'] ?? ''),
$userId
);
Router::json($result);

View File

@@ -0,0 +1,65 @@
<?php
return gridFilterSchema([
'query' => [
'search' => ['type' => 'string'],
'status' => ['type' => 'string', 'default' => 'all'],
'category' => ['type' => 'string', 'default' => 'all'],
'ticket_state' => ['type' => 'string', 'default' => 'all'],
],
'toolbar' => [
[
'key' => 'search',
'type' => 'text',
'label' => 'Search',
'placeholder' => 'Search updates...',
'input_id' => 'helpdesk-updates-search-input',
'search' => true,
'query' => ['type' => 'string'],
],
[
'key' => 'status',
'type' => 'select',
'label' => 'Status',
'input_id' => 'helpdesk-updates-status-filter',
'default' => 'all',
'normalize' => 'all_to_empty',
'allowed' => [
['id' => 'all', 'description' => 'All'],
['id' => 'open', 'description' => 'Unassigned'],
['id' => 'assigned', 'description' => 'Assigned'],
],
'label_attributes' => ['data-filter-optional' => true],
],
[
'key' => 'category',
'type' => 'select',
'label' => 'Type',
'input_id' => 'helpdesk-updates-category-filter',
'default' => 'all',
'normalize' => 'all_to_empty',
'allowed' => [
['id' => 'all', 'description' => 'All'],
['id' => 'UPDATE', 'description' => 'Update'],
['id' => 'UPDATE-HF', 'description' => 'Hotfix'],
],
'label_attributes' => ['data-filter-optional' => true],
],
[
'key' => 'ticket_state',
'type' => 'select',
'label' => 'Ticket status',
'input_id' => 'helpdesk-updates-ticket-state-filter',
'default' => 'all',
'normalize' => 'all_to_empty',
'allowed' => [
['id' => 'all', 'description' => 'All'],
['id' => 'Offen', 'description' => 'Open'],
['id' => 'In Bearbeitung', 'description' => 'In progress'],
['id' => 'Erledigt', 'description' => 'Resolved'],
['id' => 'Geschlossen', 'description' => 'Closed'],
],
'label_attributes' => ['data-filter-optional' => true],
],
],
]);

View File

@@ -0,0 +1,72 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_UPDATES_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'] ?? [])),
],
'category' => [
'label' => t('Type'),
'type' => 'select',
'default' => (string) ($schemaByKey['category']['default'] ?? 'all'),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['category'] ?? [])),
],
'ticket_state' => [
'label' => t('Ticket status'),
'type' => 'select',
'default' => (string) ($schemaByKey['ticket_state']['default'] ?? 'all'),
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['ticket_state'] ?? [])),
],
];
$session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0);
$authService = app(AuthorizationService::class);
$actorContext = ['actor_user_id' => $userId];
$canManage = $authService->authorize(HelpdeskAuthorizationPolicy::ABILITY_UPDATES_MANAGE, $actorContext)->isAllowed();
$csrfKey = '';
$csrfToken = '';
if ($canManage) {
$csrfKey = Session::$csrfSessionKey;
$csrfToken = (string) ($session[$csrfKey] ?? '');
}
Buffer::set('title', t('Updates'));
Buffer::set('style_groups', json_encode(['helpdesk']));
$breadcrumbs = [
['label' => t('Home'), 'path' => 'admin'],
['label' => t('Helpdesk'), 'path' => 'helpdesk'],
['label' => t('Updates')],
];

View File

@@ -0,0 +1,114 @@
<?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 : [];
$canManage = $canManage ?? false;
?>
<?php require templatePath('partials/app-flash.phtml'); ?>
<?php
$listTitle = t('Updates');
ob_start();
?>
<button type="button" class="secondary outline" id="updates-refresh-button" data-tooltip="<?php e(t('Refresh data')); ?>" data-tooltip-pos="left">
<i class="bi bi-arrow-clockwise"></i> <?php e(t('Refresh data')); ?>
</button>
<?php
$listTitleActionsHtml = ob_get_clean();
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php
$filterUiNamespace = 'helpdesk-updates';
require templatePath('partials/app-list-filters.phtml');
?>
<div class="app-list-table">
<div id="helpdesk-updates-grid"></div>
</div>
<?php if ($canManage): ?>
<dialog
id="update-assign-dialog"
aria-labelledby="update-assign-dialog-title"
>
<article class="app-dialog-md">
<header>
<h2 id="update-assign-dialog-title"><?php e(t('Assign update')); ?></h2>
<button type="button" class="close" data-update-assign-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
</header>
<form id="update-assign-form" method="post">
<p class="app-muted" id="update-assign-context"></p>
<label for="update-assign-domain"><?php e(t('Domain')); ?></label>
<select id="update-assign-domain" name="domain_no" required>
<option value=""><?php e(t('Loading domains...')); ?></option>
</select>
<label for="update-assign-gitea"><?php e(t('Gitea link')); ?></label>
<input type="url" id="update-assign-gitea" name="gitea_path" placeholder="<?php e(t('e.g. https://gitea.example.com/org/repo/src/branch/main/updates/2026-02')); ?>">
<label for="update-assign-notes"><?php e(t('Notes')); ?></label>
<textarea id="update-assign-notes" name="notes" rows="3"></textarea>
<input type="hidden" name="ticket_no" id="update-assign-ticket-no">
<input type="hidden" name="debitor_no" id="update-assign-debitor-no">
<input type="hidden" name="debitor_name" id="update-assign-debitor-name">
<input type="hidden" name="category_code" id="update-assign-category-code">
<input type="hidden" name="domain_url" id="update-assign-domain-url">
<footer>
<button type="button" class="secondary outline" data-update-assign-close><?php e(t('Cancel')); ?></button>
<button type="submit" class="primary" id="update-assign-submit"><?php e(t('Save')); ?></button>
</footer>
</form>
</article>
</dialog>
<?php endif; ?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-helpdesk-updates"><?php gridJsonForJs([
'dataUrl' => lurl('helpdesk/updates-data'),
'assignUrl' => $canManage ? lurl('helpdesk/updates/assign') : '',
'domainsDataUrl' => $canManage ? lurl('helpdesk/update-domains-data') : '',
'gridLang' => gridLang(),
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'canManage' => $canManage,
'csrfKey' => $csrfKey ?? '',
'csrfToken' => $csrfToken ?? '',
'labels' => [
'ticketNo' => t('Ticket'),
'category' => t('Type'),
'debitor' => t('Customer'),
'domain' => t('Domain'),
'giteaPath' => t('Gitea link'),
'createdOn' => t('Created'),
'ticketState' => t('Ticket status'),
'assignment' => t('Assignment'),
'actions' => t('Actions'),
'assign' => t('Assign'),
'edit' => t('Edit'),
'noUpdates' => t('No update tickets found'),
'loadError' => t('Failed to load update tickets'),
'assignSuccess' => t('Update assigned successfully'),
'assignError' => t('Failed to assign update'),
'giteaLink' => t('Open in Gitea'),
'selectDomain' => t('Select domain...'),
'loadingDomains' => t('Loading domains...'),
'noDomains' => t('No domains found for this customer'),
'update' => t('Update'),
'hotfix' => t('Hotfix'),
'unassigned' => t('Unassigned'),
'assigned' => t('Assigned'),
'completed' => t('Completed'),
'all' => t('All'),
'filterStatus' => t('Status'),
'filterType' => t('Type'),
],
]); ?></script>
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/pages/helpdesk-updates-index.js')); ?>"></script>

View File

@@ -7,15 +7,17 @@ $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']);
$canViewUpdates = !empty($helpdeskNav['can_view_updates']);
$settingsActive = navActive('helpdesk/settings', true);
$teamActive = navActive('helpdesk/team', true);
$riskRadarActive = navActive('helpdesk/risk-radar', true);
$domainsActive = navActive('helpdesk/domains', true);
$domainsActive = navActive(['helpdesk/domains', 'helpdesk/domain'], true);
$softwareProductsActive = navActive('helpdesk/software-products', true);
$handoversActive = navActive('helpdesk/handovers', true);
$updatesActive = navActive('helpdesk/updates', true);
$customersActive = navActive('helpdesk', true);
if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive']) {
if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) {
$customersActive = ['class' => '', 'aria' => '', 'isActive' => false];
}
@@ -69,6 +71,12 @@ $helpdeskNavGroups = [
'active' => $handoversActive,
'visible' => $canViewHandovers,
],
[
'label' => t('Updates'),
'path' => 'helpdesk/updates',
'active' => $updatesActive,
'visible' => $canViewUpdates,
],
],
],
[

View File

@@ -0,0 +1,349 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\UpdateService;
use PHPUnit\Framework\TestCase;
class UpdateServiceTest extends TestCase
{
private const TENANT_ID = 1;
private const USER_ID = 42;
private function createService(
?UpdateRepository $repository = null,
?BcODataGateway $bcGateway = null,
): UpdateService {
$repository = $repository ?? $this->createMock(UpdateRepository::class);
$bcGateway = $bcGateway ?? $this->createMock(BcODataGateway::class);
return new UpdateService($repository, $bcGateway);
}
private function makeBcTicket(string $no = 'TK-001', string $category = 'UPDATE', string $customerNo = 'D10001', string $customerName = 'Acme Corp'): array
{
return [
'No' => $no,
'Customer_No' => $customerNo,
'Cust_Name' => $customerName,
'Category_1_Code' => $category,
'Ticket_State' => 'Offen',
'Created_On' => '2026-04-01T10:00:00Z',
'Last_Activity_Date' => '2026-04-15T14:30:00Z',
'Support_User_Name' => 'Max Mustermann',
];
}
// ── Assign tests ─────────────────────────────────────────────────
public function testAssignUpdateHappyPath(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->expects($this->once())->method('insert')->willReturn(1);
$service = $this->createService($repo);
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'2026-02-mysyde',
'Initial update',
self::USER_ID
);
$this->assertTrue($result['ok']);
$this->assertSame(1, $result['id']);
$this->assertEmpty($result['errors']);
}
public function testAssignUpdateUpdatesExistingRecord(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn([
'id' => 5,
'ticket_no' => 'TK-001',
'status' => 'open',
]);
$repo->expects($this->once())->method('updateAssignment')->willReturn(true);
$repo->expects($this->never())->method('insert');
$service = $this->createService($repo);
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00002',
'new.example.com',
'2026-03-update',
'Changed domain',
self::USER_ID
);
$this->assertTrue($result['ok']);
$this->assertSame(5, $result['id']);
}
public function testAssignUpdateRejectsEmptyTicketNo(): void
{
$service = $this->createService();
$result = $service->assignUpdate(
self::TENANT_ID,
'',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('ticket_no', $result['errors']);
}
public function testAssignUpdateRejectsEmptyDebitorNo(): void
{
$service = $this->createService();
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('debitor_no', $result['errors']);
}
public function testAssignUpdateRejectsInvalidCategory(): void
{
$service = $this->createService();
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'BUGFIX',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('category_code', $result['errors']);
}
public function testAssignUpdateSetsStatusAssignedWhenDomainProvided(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['status'] === 'assigned' && $data['domain_no'] === 'DNS00001';
}))
->willReturn(1);
$service = $this->createService($repo);
$service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
}
public function testAssignUpdateSetsStatusOpenWhenNoDomainOrGitea(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->expects($this->once())
->method('insert')
->with($this->callback(function (array $data): bool {
return $data['status'] === 'open' && $data['domain_no'] === '' && $data['gitea_path'] === '';
}))
->willReturn(1);
$service = $this->createService($repo);
$service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'',
'',
'',
'',
self::USER_ID
);
}
public function testAssignUpdateHandlesInsertFailure(): void
{
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findByTicketNo')->willReturn(null);
$repo->method('insert')->willReturn(null);
$service = $this->createService($repo);
$result = $service->assignUpdate(
self::TENANT_ID,
'TK-001',
'D10001',
'Acme Corp',
'UPDATE',
'DNS00001',
'example.com',
'',
'',
self::USER_ID
);
$this->assertFalse($result['ok']);
$this->assertArrayHasKey('general', $result['errors']);
}
// ── Merge list tests ─────────────────────────────────────────────
public function testGetUpdatesListMergesBcWithLocalData(): void
{
$bcGateway = $this->createMock(BcODataGateway::class);
$bcGateway->method('fetchUpdateTickets')->willReturn([
'tickets' => [
$this->makeBcTicket('TK-001', 'UPDATE'),
$this->makeBcTicket('TK-002', 'UPDATE-HF'),
$this->makeBcTicket('TK-003', 'UPDATE'),
],
'truncated' => false,
]);
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findAllByTenant')->willReturn([
['ticket_no' => 'TK-001', 'domain_no' => 'DNS00001', 'domain_url' => 'example.com', 'gitea_path' => '2026-02-mysyde', 'notes' => '', 'status' => 'assigned', 'id' => 1],
]);
$service = $this->createService($repo, $bcGateway);
$result = $service->getUpdatesList(self::TENANT_ID);
$this->assertCount(3, $result['rows']);
// TK-001 should be enriched with local data
$tk001 = $result['rows'][0];
$this->assertSame('TK-001', $tk001['ticket_no']);
$this->assertSame('assigned', $tk001['status']);
$this->assertSame('DNS00001', $tk001['domain_no']);
$this->assertSame('2026-02-mysyde', $tk001['gitea_path']);
// TK-002 should be open (no local record)
$tk002 = $result['rows'][1];
$this->assertSame('TK-002', $tk002['ticket_no']);
$this->assertSame('open', $tk002['status']);
$this->assertSame('', $tk002['domain_no']);
}
public function testGetUpdatesListFiltersByStatus(): void
{
$bcGateway = $this->createMock(BcODataGateway::class);
$bcGateway->method('fetchUpdateTickets')->willReturn([
'tickets' => [
$this->makeBcTicket('TK-001'),
$this->makeBcTicket('TK-002'),
],
'truncated' => false,
]);
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findAllByTenant')->willReturn([
['ticket_no' => 'TK-001', 'domain_no' => 'DNS00001', 'domain_url' => '', 'gitea_path' => '', 'notes' => '', 'status' => 'assigned', 'id' => 1],
]);
$service = $this->createService($repo, $bcGateway);
$result = $service->getUpdatesList(self::TENANT_ID, ['status' => 'open']);
$this->assertCount(1, $result['rows']);
$this->assertSame('TK-002', $result['rows'][0]['ticket_no']);
}
public function testGetUpdatesListFiltersByCategory(): void
{
$bcGateway = $this->createMock(BcODataGateway::class);
$bcGateway->method('fetchUpdateTickets')->willReturn([
'tickets' => [
$this->makeBcTicket('TK-001', 'UPDATE'),
$this->makeBcTicket('TK-002', 'UPDATE-HF'),
],
'truncated' => false,
]);
$repo = $this->createMock(UpdateRepository::class);
$repo->method('findAllByTenant')->willReturn([]);
$service = $this->createService($repo, $bcGateway);
$result = $service->getUpdatesList(self::TENANT_ID, ['category' => 'UPDATE-HF']);
$this->assertCount(1, $result['rows']);
$this->assertSame('TK-002', $result['rows'][0]['ticket_no']);
}
// ── Static helper tests ──────────────────────────────────────────
public function testStatusLabelsReturnExpectedValues(): void
{
$this->assertNotEmpty(UpdateService::statusLabel('open'));
$this->assertNotEmpty(UpdateService::statusLabel('assigned'));
}
public function testCategoryLabelsReturnExpectedValues(): void
{
$this->assertNotEmpty(UpdateService::categoryLabel('UPDATE'));
$this->assertNotEmpty(UpdateService::categoryLabel('UPDATE-HF'));
}
public function testStatusVariantsReturnValidValues(): void
{
$validVariants = ['neutral', 'info', 'warning', 'success', 'error'];
$this->assertContains(UpdateService::statusVariant('open'), $validVariants);
$this->assertContains(UpdateService::statusVariant('assigned'), $validVariants);
}
public function testTicketStateLabelsReturnExpectedValues(): void
{
$this->assertNotEmpty(UpdateService::ticketStateLabel('Offen'));
$this->assertNotEmpty(UpdateService::ticketStateLabel('In Bearbeitung'));
$this->assertNotEmpty(UpdateService::ticketStateLabel('Erledigt'));
$this->assertNotEmpty(UpdateService::ticketStateLabel('Geschlossen'));
}
public function testTicketStateVariantsReturnValidValues(): void
{
$validVariants = ['neutral', 'info', 'warning', 'success', 'error'];
$this->assertContains(UpdateService::ticketStateVariant('Offen'), $validVariants);
$this->assertContains(UpdateService::ticketStateVariant('Erledigt'), $validVariants);
$this->assertContains(UpdateService::ticketStateVariant('Geschlossen'), $validVariants);
}
}

View File

@@ -0,0 +1,325 @@
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 } from '/js/pages/app-list-utils.js';
const config = readPageConfig('helpdesk-updates');
if (config) {
const gridjs = window.gridjs;
if (!gridjs) {
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed: Grid.js missing', { module: 'helpdesk-updates', component: 'grid' });
} else {
const appBase = getAppBase();
const labels = config.labels || {};
const canManage = config.canManage || false;
const gridOptions = {
gridjs,
container: '#helpdesk-updates-grid',
dataUrl: config.dataUrl || 'helpdesk/updates-data',
appBase,
columns: [
{
name: labels.ticketNo || 'Ticket',
sort: false,
formatter: (cell) => {
const no = escapeHtml(String(cell?.no || ''));
const url = String(cell?.url || '').trim();
const desc = escapeHtml(String(cell?.description || ''));
const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : '';
if (no === '') return gridjs.html('<span class="text-muted">—</span>');
if (url === '') return gridjs.html(`<span${tip}>${no}</span>`);
return gridjs.html(`<a href="${escapeHtml(url)}"${tip}>${no}</a>`);
},
},
{
name: labels.ticketState || 'Ticket status',
sort: false,
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.assignment || 'Assignment',
sort: false,
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.actions || '',
sort: false,
formatter: (cell) => {
if (!cell) return '';
let buttons = '';
// Assign/Edit button (only if user can manage)
if (canManage) {
// raw-html-ok: all values passed through escapeHtml, cell data is from our own API
const data = escapeHtml(JSON.stringify(cell));
const buttonLabel = cell.status === 'open'
? escapeHtml(labels.assign || 'Assign')
: escapeHtml(labels.edit || 'Edit');
const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil';
buttons += `<button type="button" class="secondary outline small" data-update-assign='${data}' data-tooltip="${buttonLabel}" data-tooltip-pos="top" aria-label="${buttonLabel}"><i class="bi ${icon}"></i></button>`;
}
// Gitea link button (if URL exists)
const giteaUrl = String(cell.gitea_path || '').trim();
if (giteaUrl !== '') {
const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea');
buttons += `<a href="${escapeHtml(giteaUrl)}" target="_blank" rel="noopener" role="button" class="secondary outline small" data-tooltip="${giteaLabel}" data-tooltip-pos="top" aria-label="${giteaLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`;
}
if (buttons === '') return '';
return gridjs.html(`<span class="app-inline-actions">${buttons}</span>`);
},
},
{
name: labels.category || 'Type',
sort: false,
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.debitor || 'Customer',
sort: false,
formatter: (cell) => {
const name = escapeHtml(String(cell?.name || ''));
const url = String(cell?.url || '').trim();
if (name === '') return gridjs.html('<span class="text-muted">—</span>');
const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom';
if (url === '') return gridjs.html(`<span style="${style}" title="${name}">${name}</span>`);
return gridjs.html(`<a href="${escapeHtml(url)}" style="${style}" title="${name}">${name}</a>`);
},
},
{ name: labels.domain || 'Domain', sort: false },
{ name: labels.createdOn || 'Created', sort: false },
],
paginationLimit: 50,
language: config.gridLang || {},
mapData: (data) => {
const rows = data.rows || data.data || [];
// Column order: Ticket | Ticket-Status | Assignment | Actions | Type | Customer | Domain | Created
return rows.map((row) => [
{ no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' },
{ label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' },
{ label: row.status_label || '', variant: row.status_variant || 'neutral' },
{
ticket_no: row.ticket_no || '',
debitor_no: row.debitor_no || '',
debitor_name: row.debitor_name || '',
category_code: row.category_code || '',
domain_no: row.domain_no || '',
domain_url: row.domain_url || '',
gitea_path: row.gitea_path || '',
notes: row.notes || '',
status: row.status || 'open',
},
{ label: row.category_label || '', variant: row.category_variant || 'neutral' },
{ name: row.debitor_name || '', url: row.debitor_url || '' },
row.domain_url || '',
row.created_on || '',
]);
},
search: config.gridSearch || null,
filterSchema: config.filterSchema || [],
urlSync: true,
};
const { gridConfig } = initStandardListPage({
grid: gridOptions,
filters: {
mode: 'drawer',
chipMeta: config.filterChipMeta || {},
watchInputs: ['#helpdesk-updates-search-input'],
},
});
if (!gridConfig || !gridConfig.grid) {
warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' });
}
// Refresh button — bypasses session cache and reloads grid data from BC
const refreshButton = document.getElementById('updates-refresh-button');
if (refreshButton) {
refreshButton.addEventListener('click', async () => {
refreshButton.disabled = true;
refreshButton.setAttribute('aria-busy', 'true');
try {
// Ping the data endpoint with refresh=1 to invalidate the cache
const refreshUrl = new URL(config.dataUrl, appBase);
refreshUrl.searchParams.set('refresh', '1');
await fetch(refreshUrl.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
});
// Now force the grid to re-render (next fetch will get fresh data)
gridConfig?.grid?.forceRender();
} catch {
// Ignore — grid will still show last data
} finally {
refreshButton.disabled = false;
refreshButton.removeAttribute('aria-busy');
}
});
}
// Assignment dialog logic
if (canManage) {
const dialog = document.getElementById('update-assign-dialog');
const form = document.getElementById('update-assign-form');
const contextEl = document.getElementById('update-assign-context');
const domainSelect = document.getElementById('update-assign-domain');
const giteaInput = document.getElementById('update-assign-gitea');
const notesInput = document.getElementById('update-assign-notes');
const ticketNoInput = document.getElementById('update-assign-ticket-no');
const debitorNoInput = document.getElementById('update-assign-debitor-no');
const debitorNameInput = document.getElementById('update-assign-debitor-name');
const categoryCodeInput = document.getElementById('update-assign-category-code');
const domainUrlInput = document.getElementById('update-assign-domain-url');
const submitButton = document.getElementById('update-assign-submit');
if (dialog && form) {
const closeDialog = () => {
try { dialog.close(); } catch { /* no-op */ }
document.body.classList.remove('modal-is-open');
};
// Close buttons
dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => {
button.addEventListener('click', (e) => { e.preventDefault(); closeDialog(); });
});
dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDialog(); });
dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); });
// Domain select change -> update hidden domain_url
domainSelect.addEventListener('change', () => {
const selected = domainSelect.options[domainSelect.selectedIndex];
domainUrlInput.value = selected?.dataset?.url || '';
});
/**
* Populate the domain <select> with options loaded from the API.
* Uses textContent and DOM methods instead of innerHTML to avoid XSS.
*/
const populateDomainSelect = (domains, preselectedNo) => {
domainSelect.textContent = '';
if (!Array.isArray(domains) || domains.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = labels.noDomains || 'No domains found';
domainSelect.appendChild(opt);
} else {
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = labels.selectDomain || 'Select domain...';
domainSelect.appendChild(placeholder);
for (const d of domains) {
const opt = document.createElement('option');
opt.value = d.value || '';
opt.textContent = d.label || d.value || '';
opt.dataset.url = d.url || '';
if (preselectedNo && d.value === preselectedNo) {
opt.selected = true;
domainUrlInput.value = d.url || '';
}
domainSelect.appendChild(opt);
}
}
domainSelect.disabled = false;
};
// Open dialog on assign button click (delegated)
document.addEventListener('click', async (e) => {
const button = e.target.closest('[data-update-assign]');
if (!button) return;
let data;
try { data = JSON.parse(button.dataset.updateAssign); } catch { return; }
// Populate form
ticketNoInput.value = data.ticket_no || '';
debitorNoInput.value = data.debitor_no || '';
debitorNameInput.value = data.debitor_name || '';
categoryCodeInput.value = data.category_code || '';
giteaInput.value = data.gitea_path || '';
notesInput.value = data.notes || '';
domainUrlInput.value = data.domain_url || '';
const categoryDisplay = data.category_code === 'UPDATE-HF'
? (labels.hotfix || 'Hotfix')
: (labels.update || 'Update');
contextEl.textContent = `${data.ticket_no} \u00b7 ${data.debitor_name} \u00b7 ${categoryDisplay}`;
// Show loading state in domain select
domainSelect.textContent = '';
const loadingOpt = document.createElement('option');
loadingOpt.value = '';
loadingOpt.textContent = labels.loadingDomains || 'Loading...';
domainSelect.appendChild(loadingOpt);
domainSelect.disabled = true;
document.body.classList.add('modal-is-open');
dialog.showModal();
try {
const domainsUrl = new URL(config.domainsDataUrl, appBase);
domainsUrl.searchParams.set('debitor_no', data.debitor_no);
const res = await fetch(domainsUrl.toString(), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' },
});
const domains = await res.json();
populateDomainSelect(domains, data.domain_no);
} catch {
populateDomainSelect([], null);
}
});
// Form submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
const formData = new URLSearchParams(new FormData(form));
if (config.csrfKey) {
formData.set(config.csrfKey, config.csrfToken || '');
}
try {
const res = await fetch(new URL(config.assignUrl, appBase).toString(), {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch',
},
body: formData,
});
const result = await res.json().catch(() => null);
if (res.ok && result?.ok) {
closeDialog();
gridConfig?.grid?.forceRender();
} else {
const errorMsg = result?.errors?.general || labels.assignError || 'Failed to assign';
window.alert(errorMsg);
}
} catch {
window.alert(labels.assignError || 'Failed to assign');
} finally {
submitButton.disabled = false;
submitButton.removeAttribute('aria-busy');
}
});
}
}
}
}

View File

@@ -301,6 +301,19 @@
margin: 0;
}
/* Reset button margin inside grid cells (Pico/framework default adds margin-bottom) */
td.gridjs-td button,
td.gridjs-td [role="button"] {
margin: 0;
}
/* Inline action buttons side by side in a cell */
td.gridjs-td .app-inline-actions {
display: inline-flex;
gap: 0.35rem;
align-items: center;
}