feat(helpdesk): align module with core list/drawer standards
- add helpdesk module pages, services, settings and tests - standardize debtor list on drawer/grid contracts and robust filter drawer behavior - add helpdesk aside panel navigation and settings visibility provider - switch primary list slug to helpdesk/debitor and remove helpdesk/search compatibility - include required core contract updates for list contracts and detail/drawer integration
This commit is contained in:
82
bin/helpdesk-diagnose.php
Normal file
82
bin/helpdesk-diagnose.php
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Quick CLI diagnostic for helpdesk BC OData connectivity.
|
||||
*
|
||||
* Usage: php bin/helpdesk-diagnose.php <customer_no>
|
||||
* Example: php bin/helpdesk-diagnose.php 10254
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
chdir(__DIR__ . '/..');
|
||||
require 'vendor/autoload.php';
|
||||
require 'config/config.php';
|
||||
require 'lib/Support/helpers.php';
|
||||
|
||||
$container = require 'lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
|
||||
$customerNo = trim($argv[1] ?? '');
|
||||
if ($customerNo === '') {
|
||||
echo "Usage: php bin/helpdesk-diagnose.php <customer_no>\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$settings = app(HelpdeskSettingsGateway::class);
|
||||
|
||||
echo "=== Helpdesk OData Diagnosis ===\n\n";
|
||||
echo "Base URL: " . $settings->getODataBaseUrl() . "\n";
|
||||
echo "Company: " . $settings->getCompanyName() . "\n";
|
||||
echo "Auth mode: " . $settings->getAuthMode() . "\n";
|
||||
echo "Configured: " . ($settings->isConfigured() ? 'YES' : 'NO') . "\n";
|
||||
echo "\n";
|
||||
echo "Entity URLs:\n";
|
||||
echo " Customer: " . $settings->buildEntityUrl(BcODataGateway::ENTITY_CUSTOMER) . "\n";
|
||||
echo " Contact: " . $settings->buildEntityUrl(BcODataGateway::ENTITY_CONTACT) . "\n";
|
||||
echo " Tickets: " . $settings->buildEntityUrl(BcODataGateway::ENTITY_TICKETS) . "\n";
|
||||
echo "\n";
|
||||
|
||||
if (!$settings->isConfigured()) {
|
||||
echo "ERROR: Configuration incomplete. Cannot run diagnosis.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$gateway = app(BcODataGateway::class);
|
||||
|
||||
echo "Running diagnosis for customer: {$customerNo}\n";
|
||||
echo str_repeat('─', 60) . "\n\n";
|
||||
|
||||
$diagnosis = $gateway->diagnoseCustomer($customerNo);
|
||||
|
||||
foreach ($diagnosis as $key => $result) {
|
||||
echo "── {$key} ──\n";
|
||||
if (is_string($result)) {
|
||||
echo " Error: {$result}\n\n";
|
||||
continue;
|
||||
}
|
||||
echo " URL: " . ($result['url'] ?? '?') . "\n";
|
||||
echo " HTTP: " . ($result['http_code'] ?? '?') . "\n";
|
||||
echo " Count: " . ($result['count'] ?? 0) . "\n";
|
||||
|
||||
if (!empty($result['error'])) {
|
||||
echo " Error: " . $result['error'] . "\n";
|
||||
}
|
||||
|
||||
if (!empty($result['fields'])) {
|
||||
echo " Fields: " . implode(', ', $result['fields']) . "\n";
|
||||
}
|
||||
|
||||
if (!empty($result['first_record'])) {
|
||||
echo " 1st record:\n";
|
||||
foreach ($result['first_record'] as $field => $value) {
|
||||
$display = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
echo " {$field}: {$display}\n";
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
300
bin/helpdesk-explore-entities.php
Normal file
300
bin/helpdesk-explore-entities.php
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Explore BC OData entities — find ticket logs, communication, and contracts.
|
||||
*
|
||||
* Usage: php bin/helpdesk-explore-entities.php [ticket_no] [customer_name]
|
||||
* Example: php bin/helpdesk-explore-entities.php T-00123 "icoreon GmbH"
|
||||
*
|
||||
* Without arguments: lists all available entity sets from $metadata.
|
||||
* With ticket_no: probes ticket log/communication entities for that ticket.
|
||||
* With customer_name: probes contract entities for that customer.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
chdir(__DIR__ . '/..');
|
||||
require 'vendor/autoload.php';
|
||||
require 'config/config.php';
|
||||
require 'lib/Support/helpers.php';
|
||||
|
||||
$container = require 'lib/App/registerContainer.php';
|
||||
setAppContainer($container);
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
|
||||
$ticketNo = trim($argv[1] ?? '');
|
||||
$customerName = trim($argv[2] ?? '');
|
||||
|
||||
$settings = app(HelpdeskSettingsGateway::class);
|
||||
|
||||
if (!$settings->isConfigured()) {
|
||||
echo "ERROR: BC connection not configured.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$baseUrl = $settings->getODataBaseUrl();
|
||||
$companyUrl = $baseUrl . "/Company('" . rawurlencode($settings->getCompanyName()) . "')";
|
||||
|
||||
echo "=== BC OData Entity Explorer ===\n\n";
|
||||
echo "Base URL: {$baseUrl}\n";
|
||||
echo "Company: {$settings->getCompanyName()}\n\n";
|
||||
|
||||
// ── Helper: raw cURL request ──
|
||||
function bcRequest(string $url, HelpdeskSettingsGateway $settings): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
if ($ch === false) return ['error' => 'curl_init failed'];
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
// Apply auth
|
||||
if ($settings->getAuthMode() === 'basic') {
|
||||
curl_setopt($ch, CURLOPT_USERPWD, ($settings->getBasicUser() ?? '') . ':' . ($settings->getBasicPassword() ?? ''));
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
}
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
unset($ch);
|
||||
|
||||
if (!is_string($body)) {
|
||||
return ['http_code' => $httpCode, 'error' => $curlError ?: 'Empty response'];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
return ['http_code' => $httpCode, 'error' => mb_substr($body, 0, 300)];
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['http_code' => $httpCode, 'error' => 'Invalid JSON'];
|
||||
}
|
||||
|
||||
return ['http_code' => $httpCode, 'data' => $decoded];
|
||||
}
|
||||
|
||||
function printResult(string $label, string $url, array $result): void
|
||||
{
|
||||
echo "── {$label} ──\n";
|
||||
echo " URL: {$url}\n";
|
||||
echo " HTTP: " . ($result['http_code'] ?? '?') . "\n";
|
||||
|
||||
if (!empty($result['error'])) {
|
||||
echo " Error: " . $result['error'] . "\n\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $result['data'] ?? [];
|
||||
$values = $data['value'] ?? [];
|
||||
|
||||
if (!is_array($values)) {
|
||||
echo " Response has no 'value' array.\n\n";
|
||||
return;
|
||||
}
|
||||
|
||||
echo " Count: " . count($values) . "\n";
|
||||
|
||||
if (count($values) > 0) {
|
||||
$first = $values[0];
|
||||
echo " Fields: " . implode(', ', array_keys($first)) . "\n";
|
||||
echo " 1st record:\n";
|
||||
foreach ($first as $field => $value) {
|
||||
$display = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
if (strlen($display) > 120) $display = mb_substr($display, 0, 117) . '...';
|
||||
echo " {$field}: {$display}\n";
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Step 1: Fetch $metadata to find all entities
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
echo str_repeat('═', 60) . "\n";
|
||||
echo "Step 1: Scanning all entity sets from \$metadata...\n";
|
||||
echo str_repeat('═', 60) . "\n\n";
|
||||
|
||||
$metadataUrl = $baseUrl . '/$metadata';
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $metadataUrl,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/xml'],
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
if ($settings->getAuthMode() === 'basic') {
|
||||
curl_setopt($ch, CURLOPT_USERPWD, ($settings->getBasicUser() ?? '') . ':' . ($settings->getBasicPassword() ?? ''));
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
}
|
||||
$metadataBody = curl_exec($ch);
|
||||
$metadataCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
unset($ch);
|
||||
|
||||
if (!is_string($metadataBody) || $metadataCode !== 200) {
|
||||
echo "Could not fetch \$metadata (HTTP {$metadataCode})\n\n";
|
||||
} else {
|
||||
// Parse XML for EntitySet names
|
||||
preg_match_all('/EntitySet\s+Name="([^"]+)"/', $metadataBody, $matches);
|
||||
$allEntities = $matches[1] ?? [];
|
||||
|
||||
// Filter for interesting entities
|
||||
$ticketLogEntities = array_filter($allEntities, fn($e) =>
|
||||
stripos($e, 'ticket') !== false ||
|
||||
stripos($e, 'log') !== false ||
|
||||
stripos($e, 'communication') !== false ||
|
||||
stripos($e, 'chat') !== false ||
|
||||
stripos($e, 'message') !== false ||
|
||||
stripos($e, 'comment') !== false ||
|
||||
stripos($e, 'note') !== false ||
|
||||
stripos($e, 'task') !== false ||
|
||||
stripos($e, 'time') !== false
|
||||
);
|
||||
|
||||
$contractEntities = array_filter($allEntities, fn($e) =>
|
||||
stripos($e, 'contract') !== false ||
|
||||
stripos($e, 'vertrag') !== false ||
|
||||
stripos($e, 'agreement') !== false ||
|
||||
stripos($e, 'service') !== false ||
|
||||
stripos($e, 'subscription') !== false ||
|
||||
stripos($e, 'wartung') !== false ||
|
||||
stripos($e, 'support') !== false
|
||||
);
|
||||
|
||||
echo "Total entities found: " . count($allEntities) . "\n\n";
|
||||
|
||||
echo "🎫 Ticket/Log/Communication related:\n";
|
||||
if (empty($ticketLogEntities)) {
|
||||
echo " (none found)\n";
|
||||
} else {
|
||||
foreach ($ticketLogEntities as $e) {
|
||||
echo " - {$e}\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n📋 Contract/Service related:\n";
|
||||
if (empty($contractEntities)) {
|
||||
echo " (none found)\n";
|
||||
} else {
|
||||
foreach ($contractEntities as $e) {
|
||||
echo " - {$e}\n";
|
||||
}
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Step 2: Probe ticket log entities
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
echo str_repeat('═', 60) . "\n";
|
||||
echo "Step 2: Probing ticket log/communication entities...\n";
|
||||
echo str_repeat('═', 60) . "\n\n";
|
||||
|
||||
$ticketLogCandidates = [
|
||||
'PBI_FP_TicketLog',
|
||||
'PBI_LV_SupportTicketLog',
|
||||
'TicketsICI_Support_Time_Log_Subpage',
|
||||
'TicketsICI_Support_Task_Subpage',
|
||||
];
|
||||
|
||||
// Also add any entities from metadata scan
|
||||
if (!empty($ticketLogEntities)) {
|
||||
foreach ($ticketLogEntities as $e) {
|
||||
if (!in_array($e, $ticketLogCandidates, true)) {
|
||||
$ticketLogCandidates[] = $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($ticketLogCandidates as $entity) {
|
||||
$url = $companyUrl . '/' . $entity . '?$top=3';
|
||||
$result = bcRequest($url, $settings);
|
||||
printResult($entity . ' (top 3)', $url, $result);
|
||||
|
||||
// If we got results and have a ticket number, try filtering
|
||||
if ($ticketNo !== '' && !empty($result['data']['value'])) {
|
||||
$fields = array_keys($result['data']['value'][0]);
|
||||
// Try common filter fields
|
||||
$filterFields = array_intersect($fields, [
|
||||
'Ticket_No', 'No', 'Ticket_No_', 'TicketNo', 'Ticket_Number',
|
||||
'Support_Ticket_No', 'Support_Ticket_No_',
|
||||
'Document_No', 'Document_No_',
|
||||
]);
|
||||
foreach ($filterFields as $filterField) {
|
||||
$filterUrl = $companyUrl . '/' . $entity
|
||||
. '?$filter=' . rawurlencode("{$filterField} eq '{$ticketNo}'")
|
||||
. '&$top=10';
|
||||
$filterResult = bcRequest($filterUrl, $settings);
|
||||
printResult("{$entity} filtered by {$filterField}='{$ticketNo}'", $filterUrl, $filterResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Step 3: Probe contract entities
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
echo str_repeat('═', 60) . "\n";
|
||||
echo "Step 3: Probing contract/service entities...\n";
|
||||
echo str_repeat('═', 60) . "\n\n";
|
||||
|
||||
$contractCandidates = [
|
||||
'Service_Contract',
|
||||
'Service_Contracts',
|
||||
'ServiceContract',
|
||||
'Service_Contract_List',
|
||||
'Service_Contract_Card',
|
||||
'Customer_Contract',
|
||||
'PBI_FP_ServiceContract',
|
||||
];
|
||||
|
||||
// Also add any entities from metadata scan
|
||||
if (!empty($contractEntities)) {
|
||||
foreach ($contractEntities as $e) {
|
||||
if (!in_array($e, $contractCandidates, true)) {
|
||||
$contractCandidates[] = $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($contractCandidates as $entity) {
|
||||
$url = $companyUrl . '/' . $entity . '?$top=3';
|
||||
$result = bcRequest($url, $settings);
|
||||
printResult($entity . ' (top 3)', $url, $result);
|
||||
|
||||
// If we got results and have a customer name, try filtering
|
||||
if ($customerName !== '' && !empty($result['data']['value'])) {
|
||||
$fields = array_keys($result['data']['value'][0]);
|
||||
$filterFields = array_intersect($fields, [
|
||||
'Customer_No', 'Customer_No_', 'CustomerNo',
|
||||
'Customer_Name', 'Name', 'Bill_to_Customer_No_',
|
||||
'Company_Name', 'Company_Contact_Name',
|
||||
]);
|
||||
$escapedName = str_replace("'", "''", $customerName);
|
||||
foreach ($filterFields as $filterField) {
|
||||
$filterUrl = $companyUrl . '/' . $entity
|
||||
. '?$filter=' . rawurlencode("{$filterField} eq '{$escapedName}'")
|
||||
. '&$top=10';
|
||||
$filterResult = bcRequest($filterUrl, $settings);
|
||||
printResult("{$entity} filtered by {$filterField}='{$customerName}'", $filterUrl, $filterResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo str_repeat('═', 60) . "\n";
|
||||
echo "Done.\n";
|
||||
@@ -29,13 +29,15 @@
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"MintyPHP\\Tests\\": "tests/",
|
||||
"MintyPHP\\Tests\\Module\\Audit\\": "modules/audit/tests/Module/Audit/"
|
||||
"MintyPHP\\Tests\\Module\\Audit\\": "modules/audit/tests/Module/Audit/",
|
||||
"MintyPHP\\Tests\\Module\\Helpdesk\\": "modules/helpdesk/tests/Module/Helpdesk/"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"MintyPHP\\": "lib/",
|
||||
"MintyPHP\\Module\\Audit\\": "modules/audit/lib/Module/Audit/"
|
||||
"MintyPHP\\Module\\Audit\\": "modules/audit/lib/Module/Audit/",
|
||||
"MintyPHP\\Module\\Helpdesk\\": "modules/helpdesk/lib/Module/Helpdesk/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
* Each entry must match a directory name under modules/ containing a module.php manifest.
|
||||
*/
|
||||
return [
|
||||
'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center'],
|
||||
'enabled_modules' => ['audit', 'addressbook', 'bookmarks', 'notifications', 'api-docs', 'help-center', 'helpdesk'],
|
||||
];
|
||||
|
||||
@@ -1550,7 +1550,8 @@ JOIN permissions p ON p.`key` IN (
|
||||
'api_tokens.manage',
|
||||
'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge',
|
||||
'stats.view', 'system_info.view',
|
||||
'roles.assign_all'
|
||||
'roles.assign_all',
|
||||
'helpdesk.access', 'helpdesk.settings.manage'
|
||||
)
|
||||
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
|
||||
ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||
@@ -1613,7 +1614,8 @@ JOIN permissions p ON p.`key` IN (
|
||||
'address_book.view',
|
||||
'departments.view', 'roles.view',
|
||||
'api_tokens.manage',
|
||||
'custom_fields.edit_values'
|
||||
'custom_fields.edit_values',
|
||||
'helpdesk.access'
|
||||
)
|
||||
WHERE r.code = 'SUPPORT'
|
||||
ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||
|
||||
20
db/updates/2026-04-02-helpdesk-permissions.sql
Normal file
20
db/updates/2026-04-02-helpdesk-permissions.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Helpdesk module: assign permissions to Admin and Support roles.
|
||||
-- Admin gets full access (helpdesk.access + helpdesk.settings.manage).
|
||||
-- Support gets helpdesk.access only.
|
||||
-- Idempotent: safe to run multiple times.
|
||||
|
||||
-- Admin: full helpdesk access
|
||||
INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`)
|
||||
SELECT r.id, p.id, NOW()
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.`key` IN ('helpdesk.access', 'helpdesk.settings.manage')
|
||||
WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1
|
||||
ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||
|
||||
-- Support: helpdesk read access
|
||||
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.access'
|
||||
WHERE r.code = 'SUPPORT'
|
||||
ON DUPLICATE KEY UPDATE role_id = role_id;
|
||||
116
modules/helpdesk/i18n/default_de.json
Normal file
116
modules/helpdesk/i18n/default_de.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"Helpdesk": "Helpdesk",
|
||||
"Helpdesk settings": "Helpdesk-Einstellungen",
|
||||
"Customers": "Kunden",
|
||||
"Debtor search": "Debitorensuche",
|
||||
"Search debtors": "Debitoren suchen",
|
||||
"Search by name or debtor number...": "Suche nach Name oder Debitorennummer...",
|
||||
"Search": "Suchen",
|
||||
"Please provide a customer number": "Bitte geben Sie eine Debitorennummer an",
|
||||
"Minimum 2 characters": "Mindestens 2 Zeichen",
|
||||
"Please enter at least 2 characters.": "Bitte mindestens 2 Zeichen eingeben.",
|
||||
"No debtors found": "Keine Debitoren gefunden",
|
||||
"Try a different search term.": "Versuchen Sie einen anderen Suchbegriff.",
|
||||
"Could not connect to Business Central.": "Verbindung zu Business Central fehlgeschlagen.",
|
||||
"BC connection is not configured.": "BC-Verbindung ist nicht konfiguriert.",
|
||||
"BC connection is not configured. Please configure the connection in the settings.": "BC-Verbindung ist nicht konfiguriert. Bitte konfigurieren Sie die Verbindung in den Einstellungen.",
|
||||
"BC connection is not configured": "BC-Verbindung ist nicht konfiguriert",
|
||||
"Open settings": "Einstellungen öffnen",
|
||||
"Debtor No.": "Debitoren-Nr.",
|
||||
"Name": "Name",
|
||||
"City": "Ort",
|
||||
"Phone": "Telefon",
|
||||
"E-Mail": "E-Mail",
|
||||
"Master data": "Stammdaten",
|
||||
"Contacts": "Ansprechpartner",
|
||||
"Tickets": "Tickets",
|
||||
"Debtor details": "Debitoren-Details",
|
||||
"Search name": "Suchname",
|
||||
"Address": "Adresse",
|
||||
"Postal code": "PLZ",
|
||||
"Salesperson": "Verkäufer",
|
||||
"No contacts found": "Keine Ansprechpartner gefunden",
|
||||
"No contacts are linked to this debtor in Business Central.": "Diesem Debitor sind in Business Central keine Ansprechpartner zugeordnet.",
|
||||
"No.": "Nr.",
|
||||
"Type": "Typ",
|
||||
"Job title": "Position",
|
||||
"No tickets found": "Keine Tickets gefunden",
|
||||
"No tickets are linked to this debtor in Business Central.": "Diesem Debitor sind in Business Central keine Tickets zugeordnet.",
|
||||
"Ticket No.": "Ticket-Nr.",
|
||||
"Description": "Beschreibung",
|
||||
"Status": "Status",
|
||||
"Category": "Kategorie",
|
||||
"Contact": "Kontakt",
|
||||
"Support": "Bearbeiter",
|
||||
"Ticket": "Ticket",
|
||||
"Debtor": "Debitor",
|
||||
"Debtor not found": "Debitor nicht gefunden",
|
||||
"Ticket not found": "Ticket nicht gefunden",
|
||||
"Back to search": "Zurück zur Suche",
|
||||
"Back": "Zurück",
|
||||
"Settings": "Einstellungen",
|
||||
"Save": "Speichern",
|
||||
"Settings saved": "Einstellungen gespeichert",
|
||||
"Configuration incomplete": "Konfiguration unvollständig",
|
||||
"OData connection": "OData-Verbindung",
|
||||
"OData Base URL": "OData-Basis-URL",
|
||||
"Base URL without Company path segment": "Basis-URL ohne Company-Pfadsegment",
|
||||
"Company name": "Firmenname",
|
||||
"BC company name (URL-encoded automatically)": "BC-Firmenname (wird automatisch URL-kodiert)",
|
||||
"Authentication": "Authentifizierung",
|
||||
"Auth mode": "Auth-Modus",
|
||||
"Basic Auth": "Basic Auth",
|
||||
"OAuth2 (client credentials)": "OAuth2 (Client Credentials)",
|
||||
"Basic Auth credentials": "Basic-Auth-Zugangsdaten",
|
||||
"Username": "Benutzername",
|
||||
"Password": "Passwort",
|
||||
"Leave unchanged to keep current password": "Leer lassen, um das aktuelle Passwort beizubehalten",
|
||||
"OAuth2 credentials": "OAuth2-Zugangsdaten",
|
||||
"Azure Tenant ID": "Azure-Mandanten-ID",
|
||||
"Client ID": "Client-ID",
|
||||
"Client Secret": "Client-Secret",
|
||||
"Token endpoint URL": "Token-Endpoint-URL",
|
||||
"Connection test": "Verbindungstest",
|
||||
"Test connection": "Verbindung testen",
|
||||
"Connection successful": "Verbindung erfolgreich",
|
||||
"Connection failed": "Verbindung fehlgeschlagen",
|
||||
"Testing...": "Teste...",
|
||||
"Form expired, please try again": "Formular abgelaufen, bitte erneut versuchen",
|
||||
"OData Base URL must use HTTPS": "OData-Basis-URL muss HTTPS verwenden",
|
||||
"Address & contact": "Adresse & Kontakt",
|
||||
"Company": "Unternehmen",
|
||||
"Company details": "Firmendetails",
|
||||
"Contact & dates": "Kontakt & Termine",
|
||||
"Could not load contacts.": "Ansprechpartner konnten nicht geladen werden.",
|
||||
"Could not load tickets.": "Tickets konnten nicht geladen werden.",
|
||||
"Last activity": "Letzte Aktivität",
|
||||
"Ticket details": "Ticket-Details",
|
||||
"Overview": "Übersicht",
|
||||
"Open tickets": "Offene Tickets",
|
||||
"All tickets": "Alle Tickets",
|
||||
"All contacts": "Alle Ansprechpartner",
|
||||
"Recent activity": "Letzte Aktivität",
|
||||
"Support info & master data": "Support-Info & Stammdaten",
|
||||
"Support type": "Supportart",
|
||||
"Location": "Standort",
|
||||
"No open tickets": "Keine offenen Tickets",
|
||||
"No recent activity found.": "Keine aktuellen Aktivitäten gefunden.",
|
||||
"Created": "Erstellt",
|
||||
"Loading...": "Laden...",
|
||||
"Mobile": "Mobil",
|
||||
"Activity": "Aktivität",
|
||||
"Activity timeline": "Aktivitätsverlauf",
|
||||
"Message": "Nachricht",
|
||||
"Status change": "Statusänderung",
|
||||
"No activity found.": "Keine Aktivitäten gefunden.",
|
||||
"Could not load activity log.": "Aktivitätsverlauf konnte nicht geladen werden.",
|
||||
"sent a message": "hat eine Nachricht gesendet",
|
||||
"changed status": "hat den Status geändert",
|
||||
"Forwarded": "hat das Ticket weitergeleitet",
|
||||
"attached a file": "hat eine Datei angehängt",
|
||||
"Process": "Prozess",
|
||||
"All": "Alle",
|
||||
"Open": "Offen",
|
||||
"In Progress": "In Bearbeitung",
|
||||
"Closed": "Geschlossen"
|
||||
}
|
||||
116
modules/helpdesk/i18n/default_en.json
Normal file
116
modules/helpdesk/i18n/default_en.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"Helpdesk": "Helpdesk",
|
||||
"Helpdesk settings": "Helpdesk settings",
|
||||
"Customers": "Customers",
|
||||
"Debtor search": "Debtor search",
|
||||
"Search debtors": "Search debtors",
|
||||
"Search by name or debtor number...": "Search by name or debtor number...",
|
||||
"Search": "Search",
|
||||
"Please provide a customer number": "Please provide a customer number",
|
||||
"Minimum 2 characters": "Minimum 2 characters",
|
||||
"Please enter at least 2 characters.": "Please enter at least 2 characters.",
|
||||
"No debtors found": "No debtors found",
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Could not connect to Business Central.": "Could not connect to Business Central.",
|
||||
"BC connection is not configured.": "BC connection is not configured.",
|
||||
"BC connection is not configured. Please configure the connection in the settings.": "BC connection is not configured. Please configure the connection in the settings.",
|
||||
"BC connection is not configured": "BC connection is not configured",
|
||||
"Open settings": "Open settings",
|
||||
"Debtor No.": "Debtor No.",
|
||||
"Name": "Name",
|
||||
"City": "City",
|
||||
"Phone": "Phone",
|
||||
"E-Mail": "E-Mail",
|
||||
"Master data": "Master data",
|
||||
"Contacts": "Contacts",
|
||||
"Tickets": "Tickets",
|
||||
"Debtor details": "Debtor details",
|
||||
"Search name": "Search name",
|
||||
"Address": "Address",
|
||||
"Postal code": "Postal code",
|
||||
"Salesperson": "Salesperson",
|
||||
"No contacts found": "No contacts found",
|
||||
"No contacts are linked to this debtor in Business Central.": "No contacts are linked to this debtor in Business Central.",
|
||||
"No.": "No.",
|
||||
"Type": "Type",
|
||||
"Job title": "Job title",
|
||||
"No tickets found": "No tickets found",
|
||||
"No tickets are linked to this debtor in Business Central.": "No tickets are linked to this debtor in Business Central.",
|
||||
"Ticket No.": "Ticket No.",
|
||||
"Description": "Description",
|
||||
"Status": "Status",
|
||||
"Category": "Category",
|
||||
"Contact": "Contact",
|
||||
"Support": "Support",
|
||||
"Ticket": "Ticket",
|
||||
"Debtor": "Debtor",
|
||||
"Debtor not found": "Debtor not found",
|
||||
"Ticket not found": "Ticket not found",
|
||||
"Back to search": "Back to search",
|
||||
"Back": "Back",
|
||||
"Settings": "Settings",
|
||||
"Save": "Save",
|
||||
"Settings saved": "Settings saved",
|
||||
"Configuration incomplete": "Configuration incomplete",
|
||||
"OData connection": "OData connection",
|
||||
"OData Base URL": "OData Base URL",
|
||||
"Base URL without Company path segment": "Base URL without Company path segment",
|
||||
"Company name": "Company name",
|
||||
"BC company name (URL-encoded automatically)": "BC company name (URL-encoded automatically)",
|
||||
"Authentication": "Authentication",
|
||||
"Auth mode": "Auth mode",
|
||||
"Basic Auth": "Basic Auth",
|
||||
"OAuth2 (client credentials)": "OAuth2 (client credentials)",
|
||||
"Basic Auth credentials": "Basic Auth credentials",
|
||||
"Username": "Username",
|
||||
"Password": "Password",
|
||||
"Leave unchanged to keep current password": "Leave unchanged to keep current password",
|
||||
"OAuth2 credentials": "OAuth2 credentials",
|
||||
"Azure Tenant ID": "Azure Tenant ID",
|
||||
"Client ID": "Client ID",
|
||||
"Client Secret": "Client Secret",
|
||||
"Token endpoint URL": "Token endpoint URL",
|
||||
"Connection test": "Connection test",
|
||||
"Test connection": "Test connection",
|
||||
"Connection successful": "Connection successful",
|
||||
"Connection failed": "Connection failed",
|
||||
"Testing...": "Testing...",
|
||||
"Form expired, please try again": "Form expired, please try again",
|
||||
"OData Base URL must use HTTPS": "OData Base URL must use HTTPS",
|
||||
"Address & contact": "Address & contact",
|
||||
"Company": "Company",
|
||||
"Company details": "Company details",
|
||||
"Contact & dates": "Contact & dates",
|
||||
"Could not load contacts.": "Could not load contacts.",
|
||||
"Could not load tickets.": "Could not load tickets.",
|
||||
"Last activity": "Last activity",
|
||||
"Ticket details": "Ticket details",
|
||||
"Overview": "Overview",
|
||||
"Open tickets": "Open tickets",
|
||||
"All tickets": "All tickets",
|
||||
"All contacts": "All contacts",
|
||||
"Recent activity": "Recent activity",
|
||||
"Support info & master data": "Support info & master data",
|
||||
"Support type": "Support type",
|
||||
"Location": "Location",
|
||||
"No open tickets": "No open tickets",
|
||||
"No recent activity found.": "No recent activity found.",
|
||||
"Created": "Created",
|
||||
"Loading...": "Loading...",
|
||||
"Mobile": "Mobile",
|
||||
"Activity": "Activity",
|
||||
"Activity timeline": "Activity timeline",
|
||||
"Message": "Message",
|
||||
"Status change": "Status change",
|
||||
"No activity found.": "No activity found.",
|
||||
"Could not load activity log.": "Could not load activity log.",
|
||||
"sent a message": "sent a message",
|
||||
"changed status": "changed status",
|
||||
"Forwarded": "forwarded the ticket",
|
||||
"attached a file": "attached a file",
|
||||
"Process": "Process",
|
||||
"All": "All",
|
||||
"Open": "Open",
|
||||
"In Progress": "In Progress",
|
||||
"Closed": "Closed"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk;
|
||||
|
||||
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
|
||||
final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_ACCESS = 'helpdesk.access';
|
||||
public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
|
||||
public const PERMISSION_ACCESS = 'helpdesk.access';
|
||||
public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE], true);
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
{
|
||||
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
|
||||
if ($actorUserId <= 0) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
$permissionKey = match ($ability) {
|
||||
self::ABILITY_ACCESS => self::PERMISSION_ACCESS,
|
||||
self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($permissionKey === null) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
if (!$this->permissionService->userHas($actorUserId, $permissionKey)) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
return AuthorizationDecision::allow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
|
||||
final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(HelpdeskAuthorizationPolicy::class, static fn (AppContainer $c): HelpdeskAuthorizationPolicy => new HelpdeskAuthorizationPolicy(
|
||||
$c->get(PermissionService::class)
|
||||
));
|
||||
|
||||
$container->set(HelpdeskSettingsGateway::class, static fn (AppContainer $c): HelpdeskSettingsGateway => new HelpdeskSettingsGateway(
|
||||
$c->get(SettingsMetadataGateway::class),
|
||||
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
|
||||
));
|
||||
|
||||
$container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway(
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
|
||||
$container->set(HelpdeskTokenRepository::class, static fn (AppContainer $c): HelpdeskTokenRepository => new HelpdeskTokenRepository(
|
||||
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
|
||||
));
|
||||
|
||||
$container->set(HelpdeskOAuthTokenService::class, static fn (AppContainer $c): HelpdeskOAuthTokenService => new HelpdeskOAuthTokenService(
|
||||
$c->get(HelpdeskSettingsGateway::class),
|
||||
$c->get(HelpdeskTokenRepository::class)
|
||||
));
|
||||
|
||||
$container->set(DebitorSearchService::class, static fn (AppContainer $c): DebitorSearchService => new DebitorSearchService(
|
||||
$c->get(BcODataGateway::class),
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
|
||||
$container->set(DebitorDetailService::class, static fn (AppContainer $c): DebitorDetailService => new DebitorDetailService(
|
||||
$c->get(BcODataGateway::class),
|
||||
$c->get(HelpdeskSettingsGateway::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Providers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
|
||||
final class HelpdeskLayoutProvider implements LayoutContextProvider
|
||||
{
|
||||
public function provide(array $session, AppContainer $container): array
|
||||
{
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return ['helpdesk.nav' => ['can_manage_settings' => false]];
|
||||
}
|
||||
|
||||
try {
|
||||
$authorizationService = $container->get(AuthorizationService::class);
|
||||
$canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', [
|
||||
'actor_user_id' => $userId,
|
||||
])->isAllowed();
|
||||
} catch (\Throwable) {
|
||||
$canManageSettings = false;
|
||||
}
|
||||
|
||||
return ['helpdesk.nav' => ['can_manage_settings' => $canManageSettings]];
|
||||
}
|
||||
}
|
||||
670
modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php
Normal file
670
modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php
Normal file
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Gateway for Business Central OData V4 API calls.
|
||||
*
|
||||
* Reads debtors, contacts and tickets from BC published web services.
|
||||
* Uses Basic Auth by default; supports Bearer token for OAuth2 mode.
|
||||
*/
|
||||
class BcODataGateway
|
||||
{
|
||||
public const ENTITY_CUSTOMER = 'Integration_Customer_Card';
|
||||
public const ENTITY_CONTACT = 'Integration_Contact_Card';
|
||||
public const ENTITY_TICKETS = 'PBI_FP_Tickets';
|
||||
public const ENTITY_TICKET_LOG = 'PBI_FP_TicketLog';
|
||||
public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog';
|
||||
|
||||
private const CONNECT_TIMEOUT = 10;
|
||||
private const REQUEST_TIMEOUT = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search customers by name or number.
|
||||
*
|
||||
* Tries `contains()` first; falls back to `startswith()` if BC rejects
|
||||
* the filter (many BC OData endpoints do not support `contains`).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function searchCustomers(string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($query);
|
||||
$upperQuery = mb_strtoupper($escaped);
|
||||
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||||
|
||||
$strategies = [
|
||||
// Strategy 1: contains() — most flexible but not supported by all BC versions
|
||||
"contains(Search_Name,'" . $upperQuery . "') or contains(Name,'" . $escaped . "') or contains(No,'" . $escaped . "')",
|
||||
// Strategy 2: startswith() — universally supported fallback
|
||||
"startswith(Search_Name,'" . $upperQuery . "') or startswith(Name,'" . $escaped . "') or startswith(No,'" . $escaped . "')",
|
||||
// Strategy 3: range filter on Search_Name (last resort)
|
||||
"Search_Name ge '" . $upperQuery . "' and Search_Name lt '" . $upperQuery . "z'",
|
||||
];
|
||||
|
||||
$lastException = null;
|
||||
foreach ($strategies as $filter) {
|
||||
$url = $baseUrl . '?$filter=' . rawurlencode($filter) . '&$top=50' . $select;
|
||||
try {
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response !== null) {
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
// request() returned null → server/network error, propagate
|
||||
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
|
||||
} catch (\RuntimeException $e) {
|
||||
$lastException = $e;
|
||||
// Filter not supported by this BC version (4xx) — try next strategy
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// All strategies failed — re-throw so DebitorSearchService shows error, not "no results"
|
||||
throw $lastException ?? new \RuntimeException('All OData search strategies failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read customer list for grid pagination with optional search/city filters.
|
||||
*
|
||||
* @return array{rows: array<int, array<string, mixed>>, total: int}
|
||||
*/
|
||||
public function listCustomers(
|
||||
string $search = '',
|
||||
string $city = '',
|
||||
int $limit = 10,
|
||||
int $offset = 0,
|
||||
string $order = 'Name',
|
||||
string $dir = 'asc'
|
||||
): array {
|
||||
$search = trim($search);
|
||||
$city = trim($city);
|
||||
$limit = max(1, min(100, $limit));
|
||||
$offset = max(0, $offset);
|
||||
$sortField = $this->normalizeCustomerSortField($order);
|
||||
$sortDir = strtolower(trim($dir)) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER);
|
||||
$select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code';
|
||||
|
||||
$cityClause = '';
|
||||
if ($city !== '') {
|
||||
$escapedCity = $this->escapeODataString($city);
|
||||
$cityClause = "startswith(City,'" . $escapedCity . "')";
|
||||
}
|
||||
|
||||
$searchStrategies = [''];
|
||||
if ($search !== '') {
|
||||
$escapedSearch = $this->escapeODataString($search);
|
||||
$upperSearch = mb_strtoupper($escapedSearch);
|
||||
$searchStrategies = [
|
||||
"contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')",
|
||||
"startswith(Search_Name,'" . $upperSearch . "') or startswith(Name,'" . $escapedSearch . "') or startswith(No,'" . $escapedSearch . "')",
|
||||
"Search_Name ge '" . $upperSearch . "' and Search_Name lt '" . $upperSearch . "z'",
|
||||
];
|
||||
}
|
||||
$baseNonEmptyNameClause = "Name ne ''";
|
||||
|
||||
$lastException = null;
|
||||
foreach ($searchStrategies as $searchClause) {
|
||||
$clauses = [$baseNonEmptyNameClause];
|
||||
if ($searchClause !== '') {
|
||||
$clauses[] = '(' . $searchClause . ')';
|
||||
}
|
||||
if ($cityClause !== '') {
|
||||
$clauses[] = $cityClause;
|
||||
}
|
||||
|
||||
$url = $baseUrl
|
||||
. '?$top=' . $limit
|
||||
. '&$skip=' . $offset
|
||||
. '&$count=true'
|
||||
. '&$orderby=' . rawurlencode($sortField . ' ' . $sortDir)
|
||||
. $select;
|
||||
if ($clauses !== []) {
|
||||
$url .= '&$filter=' . rawurlencode(implode(' and ', $clauses));
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
throw new \RuntimeException('BC OData request failed for URL: ' . $url);
|
||||
}
|
||||
|
||||
$rows = $this->extractODataValues($response);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total' => $this->resolveCustomerListTotal($response, $offset, $limit, count($rows)),
|
||||
];
|
||||
} catch (\RuntimeException $e) {
|
||||
$lastException = $e;
|
||||
|
||||
if ($search === '') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single customer by customer number.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getCustomer(string $customerNo): ?array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerNo);
|
||||
$filter = "No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code,Support_Type';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$values = $this->extractODataValues($response);
|
||||
|
||||
return $values[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts for a customer by customer name.
|
||||
*
|
||||
* BC OData does not support filtering by IntegrationCustomerNo, so we
|
||||
* filter by Company_Name (the customer's name in the contact card).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getContactsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$customerName = trim($customerName);
|
||||
if ($customerName === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerName);
|
||||
$filter = "Company_Name eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=100'
|
||||
. '&$select=No,Name,Type,Company_No,Company_Name,IntegrationCustomerNo,E_Mail,Phone_No,Mobile_Phone_No,Job_Title';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tickets for a customer by customer name.
|
||||
*
|
||||
* Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports
|
||||
* server-side $filter and $orderby — unlike the Card-based "Tickets" entity.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array
|
||||
{
|
||||
$customerName = trim($customerName);
|
||||
if ($customerName === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($customerName);
|
||||
$filter = "Company_Contact_Name eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$top=200'
|
||||
. '&$orderby=' . rawurlencode('Created_On desc')
|
||||
. '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->extractODataValues($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ticket by ticket number.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getTicket(string $ticketNo): ?array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
if ($ticketNo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($ticketNo);
|
||||
$filter = "No eq '" . $escaped . "'";
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$select=No,Description,Company_Contact_Name,Current_Contact_Name,Category_1_Description,Ticket_State,Support_User_Name,Created_On,Last_Activity_Date';
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$values = $this->extractODataValues($response);
|
||||
|
||||
return $values[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the activity log for a ticket.
|
||||
*
|
||||
* Merges data from two BC entities:
|
||||
* - PBI_FP_TicketLog: detailed log (user, time, Record_ID)
|
||||
* - PBI_LV_SupportTicketLog: has human-readable State_Subtype (e.g. "In Bearbeitung")
|
||||
*
|
||||
* Entries are joined by Entry_No and returned newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function getTicketLog(string $ticketNo): array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
if ($ticketNo === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$escaped = $this->escapeODataString($ticketNo);
|
||||
$filter = "Support_Ticket_No eq '" . $escaped . "'";
|
||||
|
||||
// Fetch detailed log (user, time, Record_ID)
|
||||
$detailUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||
. '&$top=100'
|
||||
. '&$select=Entry_No,Created_By_Type,Created_By,Creation_Date,Creation_Time,Support_Ticket_No,Type,Record_ID';
|
||||
|
||||
$detailResponse = $this->request('GET', $detailUrl);
|
||||
if ($detailResponse === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = $this->extractODataValues($detailResponse);
|
||||
if ($entries === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch status subtypes (human-readable state names)
|
||||
$lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV)
|
||||
. '?$filter=' . rawurlencode($filter)
|
||||
. '&$orderby=' . rawurlencode('Entry_No desc')
|
||||
. '&$top=100'
|
||||
. '&$select=Entry_No,State_Subtype';
|
||||
|
||||
$lvResponse = $this->request('GET', $lvUrl);
|
||||
$subtypeMap = [];
|
||||
if ($lvResponse !== null) {
|
||||
foreach ($this->extractODataValues($lvResponse) as $lv) {
|
||||
$entryNo = $lv['Entry_No'] ?? null;
|
||||
$subtype = (string) ($lv['State_Subtype'] ?? '');
|
||||
if ($entryNo !== null && $subtype !== '' && $subtype !== '0') {
|
||||
$subtypeMap[$entryNo] = $subtype;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge State_Subtype into detail entries
|
||||
foreach ($entries as &$entry) {
|
||||
$entryNo = $entry['Entry_No'] ?? null;
|
||||
$entry['State_Subtype'] = $subtypeMap[$entryNo] ?? '';
|
||||
}
|
||||
unset($entry);
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection by fetching one customer record.
|
||||
*
|
||||
* @return array{ok: bool, error?: string, url?: string, http_code?: int}
|
||||
*/
|
||||
public function testConnection(): array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'Configuration incomplete'];
|
||||
}
|
||||
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$top=1&$select=No';
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return ['ok' => false, 'error' => 'curl_init failed'];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'OData-Version: 4.0',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$this->applyAuth($ch);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$curlErrno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($curlErrno !== 0) {
|
||||
return ['ok' => false, 'error' => 'cURL error ' . $curlErrno . ': ' . $curlError, 'url' => $url];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$hint = match ($httpCode) {
|
||||
401 => 'Unauthorized — check username/password',
|
||||
403 => 'Forbidden — user has no access to this endpoint',
|
||||
404 => 'Not found — check OData URL and company name',
|
||||
default => 'HTTP ' . $httpCode,
|
||||
};
|
||||
|
||||
return ['ok' => false, 'error' => $hint, 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
if (!is_string($responseBody)) {
|
||||
return ['ok' => false, 'error' => 'Empty response body', 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['ok' => false, 'error' => 'Invalid JSON response', 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'url' => $url, 'http_code' => $httpCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnose OData calls for a specific customer number.
|
||||
*
|
||||
* Makes raw requests to all three entities and returns URLs, HTTP codes,
|
||||
* result counts, field names, and first records — without any filtering fallback.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function diagnoseCustomer(string $customerNo): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '' || !$this->settingsGateway->isConfigured()) {
|
||||
return ['error' => 'Not configured or empty customer number'];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
// 1. Customer entity
|
||||
$customerUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER)
|
||||
. '?$filter=' . rawurlencode("No eq '" . $customerNo . "'");
|
||||
$results['customer'] = $this->rawRequest($customerUrl);
|
||||
|
||||
// Get customer name for contact/ticket filter
|
||||
$customerName = '';
|
||||
if (($results['customer']['first_record']['Name'] ?? '') !== '') {
|
||||
$customerName = $results['customer']['first_record']['Name'];
|
||||
}
|
||||
|
||||
// 2. Contact entity — filter by Company_Name
|
||||
$contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT);
|
||||
if ($customerName !== '') {
|
||||
$contactFilteredUrl = $contactBaseUrl
|
||||
. '?$filter=' . rawurlencode("Company_Name eq '" . $customerName . "'")
|
||||
. '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title');
|
||||
$results['contact_filtered'] = $this->rawRequest($contactFilteredUrl);
|
||||
} else {
|
||||
$results['contact_filtered'] = ['error' => 'No customer name available for contact lookup'];
|
||||
}
|
||||
|
||||
// 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name
|
||||
$ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS);
|
||||
if ($customerName !== '') {
|
||||
$ticketsFilteredUrl = $ticketsBaseUrl
|
||||
. '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $customerName . "'")
|
||||
. '&$top=10&$orderby=' . rawurlencode('Created_On desc')
|
||||
. '&$select=' . rawurlencode('No,Description,Company_Contact_Name,Ticket_State,Created_On,Last_Activity_Date');
|
||||
$results['tickets_filtered'] = $this->rawRequest($ticketsFilteredUrl);
|
||||
} else {
|
||||
$results['tickets_filtered'] = ['error' => 'No customer name available for ticket lookup'];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw HTTP request that returns diagnostic information (URL, status, body excerpt, field names).
|
||||
*
|
||||
* @return array{url: string, http_code: int, error?: string, count: int, fields: array<string>, first_record?: array<string, mixed>}
|
||||
*/
|
||||
private function rawRequest(string $url): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return ['url' => $url, 'http_code' => 0, 'error' => 'curl_init failed', 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'OData-Version: 4.0'],
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$this->applyAuth($ch);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
unset($ch);
|
||||
|
||||
if (!is_string($body)) {
|
||||
return ['url' => $url, 'http_code' => $httpCode, 'error' => $curlError ?: 'Empty response', 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
// Return a snippet of the error body for diagnosis
|
||||
$snippet = mb_substr($body, 0, 500);
|
||||
|
||||
return ['url' => $url, 'http_code' => $httpCode, 'error' => $snippet, 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
$decoded = json_decode($body, true);
|
||||
if (!is_array($decoded)) {
|
||||
return ['url' => $url, 'http_code' => $httpCode, 'error' => 'Invalid JSON', 'count' => 0, 'fields' => []];
|
||||
}
|
||||
|
||||
$values = $decoded['value'] ?? [];
|
||||
if (!is_array($values)) {
|
||||
$values = [];
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
$firstRecord = null;
|
||||
if ($values !== []) {
|
||||
$firstRecord = $values[0];
|
||||
$fields = array_keys($values[0]);
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'http_code' => $httpCode,
|
||||
'count' => count($values),
|
||||
'fields' => $fields,
|
||||
'first_record' => $firstRecord,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeCustomerSortField(string $order): string
|
||||
{
|
||||
return match (trim($order)) {
|
||||
'No' => 'No',
|
||||
'City' => 'City',
|
||||
default => 'Name',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $response
|
||||
*/
|
||||
private function resolveCustomerListTotal(array $response, int $offset, int $limit, int $rowCount): int
|
||||
{
|
||||
$rawCount = $response['@odata.count'] ?? $response['odata.count'] ?? null;
|
||||
if (is_numeric($rawCount)) {
|
||||
return max(0, (int) $rawCount);
|
||||
}
|
||||
|
||||
$nextLink = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? ''));
|
||||
if ($nextLink !== '') {
|
||||
return $offset + $rowCount + 1;
|
||||
}
|
||||
|
||||
if ($rowCount === 0) {
|
||||
return max(0, $offset);
|
||||
}
|
||||
|
||||
if ($rowCount < $limit) {
|
||||
return $offset + $rowCount;
|
||||
}
|
||||
|
||||
return $offset + $rowCount + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null Decoded JSON body, or null on failure.
|
||||
*/
|
||||
private function request(string $method, string $url): ?array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
|
||||
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'OData-Version: 4.0',
|
||||
],
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$this->applyAuth($ch);
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
unset($ch);
|
||||
|
||||
if (!is_string($responseBody) || $httpCode < 200 || $httpCode >= 300) {
|
||||
if ($httpCode >= 400 && $httpCode < 500) {
|
||||
throw new \RuntimeException('BC OData client error: HTTP ' . $httpCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($responseBody, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply authentication to the cURL handle based on the configured auth mode.
|
||||
*
|
||||
* @param \CurlHandle $ch
|
||||
*/
|
||||
private function applyAuth($ch): void
|
||||
{
|
||||
$authMode = $this->settingsGateway->getAuthMode();
|
||||
|
||||
if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
|
||||
$user = $this->settingsGateway->getBasicUser() ?? '';
|
||||
$password = $this->settingsGateway->getBasicPassword() ?? '';
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . $password);
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
}
|
||||
// OAuth2 bearer token mode is handled externally via HelpdeskOAuthTokenService.
|
||||
// For V1, only Basic Auth is fully implemented.
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'value' array from an OData JSON response.
|
||||
*
|
||||
* @param array<string, mixed> $response
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function extractODataValues(array $response): array
|
||||
{
|
||||
$values = $response['value'] ?? [];
|
||||
if (!is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and escape a string for use inside an OData $filter string literal.
|
||||
*
|
||||
* Rejects characters that could manipulate OData filter logic (parentheses,
|
||||
* boolean operators). Only allows letters, digits, spaces, dots, hyphens,
|
||||
* underscores, umlauts and common punctuation.
|
||||
*
|
||||
* @throws \InvalidArgumentException If the value contains disallowed characters.
|
||||
*/
|
||||
private function escapeODataString(string $value): string
|
||||
{
|
||||
if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) {
|
||||
throw new \InvalidArgumentException('Search query contains invalid characters.');
|
||||
}
|
||||
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Service for loading debtor detail data (master data, contacts, tickets).
|
||||
*
|
||||
* Provides both a combined loadDetail() method (legacy) and individual
|
||||
* methods for async loading via data endpoints.
|
||||
*/
|
||||
class DebitorDetailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only the customer master data (1 OData call).
|
||||
*
|
||||
* Used by the main page load — contacts and tickets are loaded async.
|
||||
*
|
||||
* @return array{status: string, customer?: array<string, mixed>, error?: string}
|
||||
*/
|
||||
public function loadCustomer(string $customerNo): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$customer = $this->bcODataGateway->getCustomer($customerNo);
|
||||
} catch (\Throwable) {
|
||||
return ['status' => 'error', 'error' => 'BC connection failed'];
|
||||
}
|
||||
|
||||
if ($customer === null) {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'customer' => $customer,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load contacts for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, contacts?: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function loadContacts(string $customerNo, string $customerName): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
$customerName = trim($customerName);
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
return ['ok' => false, 'error' => 'Missing customer number or name'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'contacts' => $contacts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tickets for a customer (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, tickets?: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function loadTickets(string $customerNo, string $customerName): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
$customerName = trim($customerName);
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
return ['ok' => false, 'error' => 'Missing customer number or name'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'tickets' => $tickets];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load activity log for a ticket (for async data endpoint).
|
||||
*
|
||||
* @return array{ok: bool, log?: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function loadTicketLog(string $ticketNo): array
|
||||
{
|
||||
$ticketNo = trim($ticketNo);
|
||||
|
||||
if ($ticketNo === '') {
|
||||
return ['ok' => false, 'error' => 'Missing ticket number'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['ok' => false, 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$log = $this->bcODataGateway->getTicketLog($ticketNo);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'log' => $log];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full detail for a debtor: master data, contacts, and tickets.
|
||||
*
|
||||
* @deprecated Use loadCustomer() + async endpoints for contacts/tickets instead.
|
||||
*
|
||||
* @return array{status: string, customer?: array<string, mixed>, contacts?: array<int, array<string, mixed>>, tickets?: array<int, array<string, mixed>>, error?: string, contactsError?: string, ticketsError?: string}
|
||||
*/
|
||||
public function loadDetail(string $customerNo): array
|
||||
{
|
||||
$customerNo = trim($customerNo);
|
||||
if ($customerNo === '') {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$customer = $this->bcODataGateway->getCustomer($customerNo);
|
||||
} catch (\Throwable) {
|
||||
return ['status' => 'error', 'error' => 'BC connection failed'];
|
||||
}
|
||||
|
||||
if ($customer === null) {
|
||||
return ['status' => 'not_found'];
|
||||
}
|
||||
|
||||
// Load contacts — BC contacts are linked via Company_Name, not customer number.
|
||||
// IntegrationCustomerNo is not filterable in BC OData, so we use the customer name.
|
||||
$contacts = [];
|
||||
$contactsError = '';
|
||||
$customerName = (string) ($customer['Name'] ?? '');
|
||||
try {
|
||||
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
$contactsError = $e->getMessage();
|
||||
}
|
||||
|
||||
// Load tickets via PBI_FP_Tickets entity which supports server-side filtering.
|
||||
$tickets = [];
|
||||
$ticketsError = '';
|
||||
try {
|
||||
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
|
||||
} catch (\Throwable $e) {
|
||||
$ticketsError = $e->getMessage();
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'customer' => $customer,
|
||||
'contacts' => $contacts,
|
||||
'tickets' => $tickets,
|
||||
'contactsError' => $contactsError,
|
||||
'ticketsError' => $ticketsError,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Service for searching BC debtors via OData.
|
||||
*/
|
||||
class DebitorSearchService
|
||||
{
|
||||
public const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
public function __construct(
|
||||
private readonly BcODataGateway $bcODataGateway,
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search debtors by name or number.
|
||||
*
|
||||
* @return array{status: string, results: array<int, array<string, mixed>>, error?: string}
|
||||
*/
|
||||
public function search(string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
|
||||
if ($query === '') {
|
||||
return ['status' => 'empty', 'results' => []];
|
||||
}
|
||||
|
||||
if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
|
||||
return ['status' => 'too_short', 'results' => []];
|
||||
}
|
||||
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return ['status' => 'not_configured', 'results' => [], 'error' => 'BC connection not configured'];
|
||||
}
|
||||
|
||||
try {
|
||||
$results = $this->bcODataGateway->searchCustomers($query);
|
||||
} catch (\Throwable $e) {
|
||||
return ['status' => 'error', 'results' => [], 'error' => 'BC connection failed'];
|
||||
}
|
||||
|
||||
if ($results === []) {
|
||||
return ['status' => 'no_results', 'results' => []];
|
||||
}
|
||||
|
||||
return ['status' => 'success', 'results' => $results];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build grid data for the helpdesk debtor list.
|
||||
*
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{status: string, rows: array<int, array<string, mixed>>, total: int, error?: string}
|
||||
*/
|
||||
public function listForGrid(array $filters): array
|
||||
{
|
||||
if (!$this->settingsGateway->isConfigured()) {
|
||||
return [
|
||||
'status' => 'not_configured',
|
||||
'rows' => [],
|
||||
'total' => 0,
|
||||
'error' => 'BC connection not configured',
|
||||
];
|
||||
}
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$city = trim((string) ($filters['city'] ?? ''));
|
||||
$limit = (int) ($filters['limit'] ?? 10);
|
||||
$offset = (int) ($filters['offset'] ?? 0);
|
||||
$order = (string) ($filters['order'] ?? 'Name');
|
||||
$dir = (string) ($filters['dir'] ?? 'asc');
|
||||
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->bcODataGateway->listCustomers($search, $city, $limit, $offset, $order, $dir);
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'status' => 'error',
|
||||
'rows' => [],
|
||||
'total' => 0,
|
||||
'error' => 'BC connection failed',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'success',
|
||||
'rows' => (array) $result['rows'],
|
||||
'total' => max(0, (int) $result['total']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* OAuth2 client_credentials token service for BC OData access.
|
||||
*
|
||||
* This is a skeleton for V1. Basic Auth is the default and recommended
|
||||
* auth mode. OAuth2 support can be activated in settings but the full
|
||||
* token flow (request, cache, refresh) is deferred to a future version.
|
||||
*/
|
||||
class HelpdeskOAuthTokenService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HelpdeskSettingsGateway $settingsGateway,
|
||||
private readonly HelpdeskTokenRepository $tokenRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token for BC API calls.
|
||||
*
|
||||
* @return string|null The bearer token, or null if unavailable.
|
||||
*/
|
||||
public function getAccessToken(int $tenantId): ?string
|
||||
{
|
||||
if ($this->settingsGateway->getAuthMode() !== HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
$cached = $this->tokenRepository->findValidToken($tenantId);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Token request would go here in a future version.
|
||||
// For V1, return null to indicate OAuth2 is not yet fully implemented.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
|
||||
/**
|
||||
* Gateway for helpdesk BC connection settings stored in the DB settings table.
|
||||
*
|
||||
* All secrets (passwords, client secrets) are encrypted via SettingsCryptoGateway.
|
||||
* Setting keys are prefixed with 'helpdesk.' to avoid collisions.
|
||||
*/
|
||||
class HelpdeskSettingsGateway
|
||||
{
|
||||
public const KEY_AUTH_MODE = 'helpdesk.bc_auth_mode';
|
||||
public const KEY_ODATA_BASE_URL = 'helpdesk.bc_odata_base_url';
|
||||
public const KEY_COMPANY_NAME = 'helpdesk.bc_company_name';
|
||||
public const KEY_BASIC_USER = 'helpdesk.bc_basic_user';
|
||||
public const KEY_BASIC_PASSWORD_ENC = 'helpdesk.bc_basic_password_enc';
|
||||
public const KEY_OAUTH_TENANT_ID = 'helpdesk.bc_oauth_tenant_id';
|
||||
public const KEY_OAUTH_CLIENT_ID = 'helpdesk.bc_oauth_client_id';
|
||||
public const KEY_OAUTH_CLIENT_SECRET_ENC = 'helpdesk.bc_oauth_client_secret_enc';
|
||||
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
|
||||
|
||||
public const AUTH_MODE_BASIC = 'basic';
|
||||
public const AUTH_MODE_OAUTH2 = 'oauth2';
|
||||
|
||||
public const DEFAULT_ODATA_BASE_URL = 'https://bc.icoreon.de:7048/BusinessCentral-NUP/ODataV4';
|
||||
public const DEFAULT_COMPANY_NAME = 'breadcrumb mediasolutions GmbH';
|
||||
|
||||
public function __construct(
|
||||
private readonly SettingsMetadataGateway $settingsMetadataGateway,
|
||||
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAuthMode(): string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_AUTH_MODE);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value === self::AUTH_MODE_OAUTH2 ? self::AUTH_MODE_OAUTH2 : self::AUTH_MODE_BASIC;
|
||||
}
|
||||
|
||||
public function setAuthMode(string $mode): bool
|
||||
{
|
||||
$mode = trim($mode);
|
||||
if (!in_array($mode, [self::AUTH_MODE_BASIC, self::AUTH_MODE_OAUTH2], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(self::KEY_AUTH_MODE, $mode, 'helpdesk.bc_auth_mode');
|
||||
}
|
||||
|
||||
public function getODataBaseUrl(): string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_ODATA_BASE_URL);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_ODATA_BASE_URL;
|
||||
}
|
||||
|
||||
public function setODataBaseUrl(?string $url): bool
|
||||
{
|
||||
$url = trim((string) ($url ?? ''));
|
||||
if ($url !== '' && !preg_match('#^https://#i', $url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_ODATA_BASE_URL,
|
||||
$url !== '' ? rtrim($url, '/') : null,
|
||||
'helpdesk.bc_odata_base_url'
|
||||
);
|
||||
}
|
||||
|
||||
public function getCompanyName(): string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_COMPANY_NAME);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : self::DEFAULT_COMPANY_NAME;
|
||||
}
|
||||
|
||||
public function setCompanyName(?string $name): bool
|
||||
{
|
||||
$name = trim((string) ($name ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_COMPANY_NAME,
|
||||
$name !== '' ? $name : null,
|
||||
'helpdesk.bc_company_name'
|
||||
);
|
||||
}
|
||||
|
||||
public function getBasicUser(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_USER);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setBasicUser(?string $user): bool
|
||||
{
|
||||
$user = trim((string) ($user ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_BASIC_USER,
|
||||
$user !== '' ? $user : null,
|
||||
'helpdesk.bc_basic_user'
|
||||
);
|
||||
}
|
||||
|
||||
public function getBasicPassword(): ?string
|
||||
{
|
||||
$encrypted = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_PASSWORD_ENC);
|
||||
$encrypted = trim((string) ($encrypted ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decrypted = trim($decrypted);
|
||||
|
||||
return $decrypted !== '' ? $decrypted : null;
|
||||
}
|
||||
|
||||
public function setBasicPassword(?string $password): bool
|
||||
{
|
||||
$password = trim((string) ($password ?? ''));
|
||||
if ($password === '') {
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_BASIC_PASSWORD_ENC,
|
||||
null,
|
||||
'helpdesk.bc_basic_password_enc'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->settingsCryptoGateway->encryptString($password);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_BASIC_PASSWORD_ENC,
|
||||
$encrypted,
|
||||
'helpdesk.bc_basic_password_enc'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthTenantId(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TENANT_ID);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setOAuthTenantId(?string $tenantId): bool
|
||||
{
|
||||
$tenantId = trim((string) ($tenantId ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_TENANT_ID,
|
||||
$tenantId !== '' ? $tenantId : null,
|
||||
'helpdesk.bc_oauth_tenant_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthClientId(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_ID);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setOAuthClientId(?string $clientId): bool
|
||||
{
|
||||
$clientId = trim((string) ($clientId ?? ''));
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_CLIENT_ID,
|
||||
$clientId !== '' ? $clientId : null,
|
||||
'helpdesk.bc_oauth_client_id'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthClientSecret(): ?string
|
||||
{
|
||||
$encrypted = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_SECRET_ENC);
|
||||
$encrypted = trim((string) ($encrypted ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decrypted = trim($decrypted);
|
||||
|
||||
return $decrypted !== '' ? $decrypted : null;
|
||||
}
|
||||
|
||||
public function setOAuthClientSecret(?string $secret): bool
|
||||
{
|
||||
$secret = trim((string) ($secret ?? ''));
|
||||
if ($secret === '') {
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_CLIENT_SECRET_ENC,
|
||||
null,
|
||||
'helpdesk.bc_oauth_client_secret_enc'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->settingsCryptoGateway->encryptString($secret);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_CLIENT_SECRET_ENC,
|
||||
$encrypted,
|
||||
'helpdesk.bc_oauth_client_secret_enc'
|
||||
);
|
||||
}
|
||||
|
||||
public function getOAuthTokenEndpoint(): ?string
|
||||
{
|
||||
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TOKEN_ENDPOINT);
|
||||
$value = trim((string) ($value ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function setOAuthTokenEndpoint(?string $endpoint): bool
|
||||
{
|
||||
$endpoint = trim((string) ($endpoint ?? ''));
|
||||
if ($endpoint !== '' && !preg_match('#^https://#i', $endpoint)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->settingsMetadataGateway->set(
|
||||
self::KEY_OAUTH_TOKEN_ENDPOINT,
|
||||
$endpoint !== '' ? $endpoint : null,
|
||||
'helpdesk.bc_oauth_token_endpoint'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full OData entity URL for a given entity set.
|
||||
*/
|
||||
public function buildEntityUrl(string $entitySet): string
|
||||
{
|
||||
$baseUrl = $this->getODataBaseUrl();
|
||||
$company = rawurlencode($this->getCompanyName());
|
||||
|
||||
return $baseUrl . "/Company('" . $company . "')/" . $entitySet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required settings for the current auth mode are present.
|
||||
*
|
||||
* @return array<string> List of missing setting descriptions (empty = valid)
|
||||
*/
|
||||
public function validateConfiguration(): array
|
||||
{
|
||||
$missing = [];
|
||||
|
||||
if ($this->getBasicUser() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) {
|
||||
$missing[] = 'BC Basic Auth User';
|
||||
}
|
||||
if ($this->getBasicPassword() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) {
|
||||
$missing[] = 'BC Basic Auth Password';
|
||||
}
|
||||
if ($this->getAuthMode() === self::AUTH_MODE_OAUTH2) {
|
||||
if ($this->getOAuthClientId() === null) {
|
||||
$missing[] = 'BC OAuth2 Client ID';
|
||||
}
|
||||
if ($this->getOAuthClientSecret() === null) {
|
||||
$missing[] = 'BC OAuth2 Client Secret';
|
||||
}
|
||||
if ($this->getOAuthTokenEndpoint() === null) {
|
||||
$missing[] = 'BC OAuth2 Token Endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the configuration is complete for the current auth mode.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->validateConfiguration() === [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
|
||||
/**
|
||||
* Repository for OAuth2 token cache (helpdesk_oauth_token_cache table).
|
||||
*
|
||||
* All tokens are stored encrypted and tenant-scoped.
|
||||
* This is part of the OAuth2 skeleton for V1.
|
||||
*/
|
||||
class HelpdeskTokenRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a valid (non-expired) cached token for the given tenant.
|
||||
*/
|
||||
public function findValidToken(int $tenantId): ?string
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = DB::selectOne(
|
||||
'SELECT access_token_encrypted FROM helpdesk_oauth_token_cache WHERE tenant_id = ? AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1',
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
if (!is_array($result) || !isset($result['access_token_encrypted'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$encrypted = trim((string) $result['access_token_encrypted']);
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->settingsCryptoGateway->decryptString($encrypted);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new token in the cache (encrypted, tenant-scoped).
|
||||
*/
|
||||
public function storeToken(int $tenantId, string $accessToken, \DateTimeInterface $expiresAt): bool
|
||||
{
|
||||
if ($tenantId <= 0 || $accessToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$encrypted = $this->settingsCryptoGateway->encryptString($accessToken);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove existing tokens for this tenant first
|
||||
DB::delete(
|
||||
'DELETE FROM helpdesk_oauth_token_cache WHERE tenant_id = ?',
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
return (bool) DB::insert(
|
||||
'INSERT INTO helpdesk_oauth_token_cache (tenant_id, access_token_encrypted, expires_at) VALUES (?, ?, ?)',
|
||||
(string) $tenantId,
|
||||
$encrypted,
|
||||
$expiresAt->format('Y-m-d H:i:s')
|
||||
);
|
||||
}
|
||||
}
|
||||
11
modules/helpdesk/migrations/001_create_oauth_token_cache.sql
Normal file
11
modules/helpdesk/migrations/001_create_oauth_token_cache.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Helpdesk module: OAuth2 token cache (used only when OAuth2 auth mode is active)
|
||||
CREATE TABLE IF NOT EXISTS `helpdesk_oauth_token_cache` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` INT UNSIGNED NOT NULL,
|
||||
`access_token_encrypted` TEXT NOT NULL,
|
||||
`expires_at` DATETIME NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_helpdesk_oauth_tenant` (`tenant_id`),
|
||||
INDEX `idx_helpdesk_oauth_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
85
modules/helpdesk/module.php
Normal file
85
modules/helpdesk/module.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Helpdesk module manifest.
|
||||
*
|
||||
* Internal employee lookup for BC debtors, contacts and tickets
|
||||
* via OData V4. Read-only V1 with Basic Auth (OAuth2 optional).
|
||||
*/
|
||||
return [
|
||||
'id' => 'helpdesk',
|
||||
'version' => '1.0.0',
|
||||
'enabled_by_default' => false,
|
||||
'load_order' => 50,
|
||||
'requires' => [],
|
||||
|
||||
'routes' => [
|
||||
['path' => 'helpdesk/debitor', 'target' => 'helpdesk/search'],
|
||||
['path' => 'helpdesk/search-data', 'target' => 'helpdesk/search-data'],
|
||||
['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'],
|
||||
['path' => 'helpdesk/ticket/{id}', 'target' => 'helpdesk/ticket'],
|
||||
['path' => 'helpdesk/settings', 'target' => 'helpdesk/settings'],
|
||||
['path' => 'helpdesk/settings/test-connection-data', 'target' => 'helpdesk/settings/test-connection-data'],
|
||||
['path' => 'helpdesk/settings/diagnose-data', 'target' => 'helpdesk/settings/diagnose-data'],
|
||||
['path' => 'helpdesk/debitor-contacts-data', 'target' => 'helpdesk/debitor-contacts-data'],
|
||||
['path' => 'helpdesk/debitor-tickets-data', 'target' => 'helpdesk/debitor-tickets-data'],
|
||||
['path' => 'helpdesk/debitor-ticket-categories-data', 'target' => 'helpdesk/debitor-ticket-categories-data'],
|
||||
['path' => 'helpdesk/ticket-log-data', 'target' => 'helpdesk/ticket-log-data'],
|
||||
],
|
||||
'public_paths' => [],
|
||||
|
||||
'container_registrars' => [
|
||||
\MintyPHP\Module\Helpdesk\HelpdeskContainerRegistrar::class,
|
||||
],
|
||||
|
||||
'ui_slots' => [
|
||||
'aside.tab_panel' => [
|
||||
[
|
||||
'key' => 'helpdesk',
|
||||
'label' => 'Helpdesk',
|
||||
'icon' => 'bi-headset',
|
||||
'href' => 'helpdesk/debitor',
|
||||
'permission' => 'helpdesk.access',
|
||||
'panel_template' => 'templates/aside-helpdesk-panel.phtml',
|
||||
'details_storage' => 'aside-helpdesk-sections-v1',
|
||||
'details_open_active' => true,
|
||||
'order' => 800,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'authorization_policies' => [
|
||||
\MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy::class,
|
||||
],
|
||||
|
||||
'permissions' => [
|
||||
[
|
||||
'key' => 'helpdesk.access',
|
||||
'description' => 'Access helpdesk debtor search and detail views',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'helpdesk.settings.manage',
|
||||
'description' => 'Manage helpdesk BC connection settings',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
],
|
||||
|
||||
'search_resources' => [],
|
||||
'asset_groups' => [
|
||||
'helpdesk' => [
|
||||
'modules/helpdesk/css/helpdesk.css',
|
||||
],
|
||||
],
|
||||
'scheduler_jobs' => [],
|
||||
'layout_context_providers' => [
|
||||
\MintyPHP\Module\Helpdesk\Providers\HelpdeskLayoutProvider::class,
|
||||
],
|
||||
'session_providers' => [],
|
||||
'event_listeners' => [],
|
||||
'deactivation_handler' => null,
|
||||
'migrations_path' => 'migrations',
|
||||
'i18n_path' => 'i18n',
|
||||
];
|
||||
96
modules/helpdesk/pages/helpdesk/debitor($id).php
Normal file
96
modules/helpdesk/pages/helpdesk/debitor($id).php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Http\Request;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
$customerNo = trim((string) ($id ?? ''));
|
||||
|
||||
if ($customerNo === '') {
|
||||
Router::redirect('helpdesk/debitor');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$detailService = app(DebitorDetailService::class);
|
||||
$detail = $detailService->loadCustomer($customerNo);
|
||||
|
||||
$detailStatus = (string) ($detail['status'] ?? '');
|
||||
|
||||
if ($detailStatus === 'not_found') {
|
||||
Flash::error(t('Debtor not found'), 'helpdesk/debitor', 'debtor_not_found');
|
||||
Router::redirect('helpdesk/debitor');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($detailStatus === 'not_configured') {
|
||||
Flash::error(t('BC connection is not configured'), 'helpdesk/debitor', 'not_configured');
|
||||
Router::redirect('helpdesk/debitor');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customer = $detail['customer'] ?? [];
|
||||
$hasError = $detailStatus === 'error';
|
||||
$errorMessage = $detail['error'] ?? '';
|
||||
|
||||
$customerName = (string) ($customer['Name'] ?? $customerNo);
|
||||
|
||||
$returnTarget = Request::safeReturnTarget((string) $request->query('return', ''));
|
||||
if ($returnTarget !== '') {
|
||||
$backUrl = lurl($returnTarget);
|
||||
} else {
|
||||
$searchQuery = trim((string) $request->query('search', ''));
|
||||
|
||||
$backUrl = $searchQuery !== ''
|
||||
? lurl('helpdesk/debitor') . '?search=' . rawurlencode($searchQuery)
|
||||
: lurl('helpdesk/debitor');
|
||||
}
|
||||
|
||||
$ticketsFilterSchema = require __DIR__ . '/debitor-tickets-filter-schema.php';
|
||||
$ticketsFilterState = gridParseFilters($request->queryAll(), gridSchemaQuery($ticketsFilterSchema));
|
||||
$ticketsToolbarOptionSets = [
|
||||
'category_items' => [],
|
||||
];
|
||||
$ticketsListFilterContext = gridBuildListFilterContext($ticketsFilterSchema, [
|
||||
'filter_state' => $ticketsFilterState,
|
||||
'search_keys' => ['search'],
|
||||
'toolbar_option_sets' => $ticketsToolbarOptionSets,
|
||||
]);
|
||||
$searchToolbarFilterSchema = $ticketsListFilterContext['searchToolbarFilterSchema'];
|
||||
$drawerToolbarFilterSchema = $ticketsListFilterContext['drawerToolbarFilterSchema'];
|
||||
$toolbarFilterState = $ticketsListFilterContext['toolbarFilterState'];
|
||||
$toolbarOptionSets = $ticketsListFilterContext['toolbarOptionSets'];
|
||||
$schemaByKey = $ticketsListFilterContext['schemaByKey'];
|
||||
$clientFilterSchema = $ticketsListFilterContext['clientFilterSchema'];
|
||||
$searchConfig = $ticketsListFilterContext['searchConfig'];
|
||||
$filterChipMeta = [
|
||||
'search' => [
|
||||
'label' => t('Search'),
|
||||
'type' => 'text',
|
||||
],
|
||||
'status' => [
|
||||
'label' => t('Status'),
|
||||
'type' => 'select',
|
||||
'default' => (string) (($schemaByKey['status']['default'] ?? '')),
|
||||
'options' => gridOptionMapFromAllowed((array) ($schemaByKey['status'] ?? [])),
|
||||
],
|
||||
'category' => [
|
||||
'label' => t('Category'),
|
||||
'type' => 'select',
|
||||
'default' => (string) (($schemaByKey['category']['default'] ?? '')),
|
||||
'options' => gridOptionMapFromItems((array) ($toolbarOptionSets['category_items'] ?? [])),
|
||||
],
|
||||
];
|
||||
|
||||
Buffer::set('title', $customerName . ' — ' . t('Helpdesk'));
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
284
modules/helpdesk/pages/helpdesk/debitor(default).phtml
Normal file
284
modules/helpdesk/pages/helpdesk/debitor(default).phtml
Normal file
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var string $customerNo
|
||||
* @var array $customer
|
||||
* @var bool $hasError
|
||||
* @var string $errorMessage
|
||||
* @var string $customerName
|
||||
* @var string $backUrl
|
||||
*/
|
||||
|
||||
$customerNo = $customerNo ?? '';
|
||||
$customer = is_array($customer ?? null) ? $customer : [];
|
||||
$hasError = $hasError ?? false;
|
||||
$errorMessage = $errorMessage ?? '';
|
||||
$customerName = $customerName ?? '';
|
||||
$backUrl = $backUrl ?? lurl('helpdesk/debitor');
|
||||
|
||||
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
|
||||
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
|
||||
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
|
||||
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
|
||||
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
|
||||
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
|
||||
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
||||
|
||||
$supportType = (string) ($customer['Support_Type'] ?? '');
|
||||
$salesperson = (string) ($customer['Salesperson_Code'] ?? '');
|
||||
$city = (string) ($customer['City'] ?? '');
|
||||
$postCode = (string) ($customer['Post_Code'] ?? '');
|
||||
$locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city : ($city ?: $postCode);
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-customer-no="<?php e($customerNo); ?>"
|
||||
data-customer-name="<?php e($customerName); ?>"
|
||||
data-contacts-url="<?php e(lurl('helpdesk/debitor-contacts-data')); ?>"
|
||||
data-tickets-url="<?php e(lurl('helpdesk/debitor-tickets-data')); ?>"
|
||||
data-ticket-base-url="<?php e(lurl('helpdesk/ticket/')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'],
|
||||
['label' => $customerName],
|
||||
];
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => $customerName,
|
||||
'backHref' => $backUrl,
|
||||
'backTitle' => t('Back'),
|
||||
'actions' => [],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if ($hasError): ?>
|
||||
<div class="notice" data-variant="error" role="alert">
|
||||
<p><?php e(t('Could not connect to Business Central.')); ?></p>
|
||||
<?php if ($errorMessage !== ''): ?>
|
||||
<small><?php e($errorMessage); ?></small>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php elseif ($customer !== []): ?>
|
||||
|
||||
<!-- KPI tiles — values updated by JS after async load -->
|
||||
<div class="app-tiles">
|
||||
<span data-kpi="open-tickets">
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Open tickets'),
|
||||
'count' => '—',
|
||||
'icon' => 'bi bi-ticket-detailed-fill',
|
||||
'iconBg' => '#f3e8ff',
|
||||
'iconColor' => '#7c3aed',
|
||||
]);
|
||||
?>
|
||||
</span>
|
||||
<span data-kpi="last-activity">
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Last activity'),
|
||||
'count' => '—',
|
||||
'icon' => 'bi bi-clock-history',
|
||||
'iconBg' => '#fde8d8',
|
||||
'iconColor' => '#b35c14',
|
||||
]);
|
||||
?>
|
||||
</span>
|
||||
<span data-kpi="contacts">
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Contacts'),
|
||||
'count' => '—',
|
||||
'icon' => 'bi bi-people-fill',
|
||||
'iconBg' => '#d9f2e6',
|
||||
'iconColor' => '#1f6a3a',
|
||||
]);
|
||||
?>
|
||||
</span>
|
||||
<?php
|
||||
MintyPHP\Support\Tile::render([
|
||||
'href' => '#',
|
||||
'label' => t('Support type'),
|
||||
'count' => $supportType !== '' ? $supportType : '—',
|
||||
'icon' => 'bi bi-headset',
|
||||
'iconBg' => '#d9e8fb',
|
||||
'iconColor' => '#1a56db',
|
||||
]);
|
||||
?>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Overview / Tickets / Contacts -->
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-debitor-detail">
|
||||
<div class="app-tabs-nav">
|
||||
<button type="button" data-tab="overview" data-tab-default>
|
||||
<?php e(t('Overview')); ?>
|
||||
</button>
|
||||
<button type="button" data-tab="tickets">
|
||||
<?php e(t('Tickets')); ?> <span class="badge" id="tab-badge-tickets"></span>
|
||||
</button>
|
||||
<button type="button" data-tab="contacts">
|
||||
<?php e(t('Contacts')); ?> <span class="badge" id="tab-badge-contacts"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview panel -->
|
||||
<div data-tab-panel="overview">
|
||||
<div class="helpdesk-tab-loading" id="overview-loading">
|
||||
<div class="helpdesk-spinner"></div>
|
||||
<span><?php e(t('Loading...')); ?></span>
|
||||
</div>
|
||||
<div id="overview-content" hidden>
|
||||
<div class="grid grid-2">
|
||||
<!-- Open tickets (max 10) -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header">
|
||||
<?php e(t('Open tickets')); ?>
|
||||
<a href="#" class="helpdesk-tab-link" data-switch-tab="tickets"><?php e(t('All tickets')); ?> →</a>
|
||||
</div>
|
||||
<div id="overview-open-tickets"></div>
|
||||
</div>
|
||||
|
||||
<!-- Recent activity (5 newest by Last_Activity_Date) -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Recent activity')); ?></div>
|
||||
<div id="overview-recent-activity"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<!-- Top contacts (5) -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header">
|
||||
<?php e(t('Contacts')); ?>
|
||||
<a href="#" class="helpdesk-tab-link" data-switch-tab="contacts"><?php e(t('All contacts')); ?> →</a>
|
||||
</div>
|
||||
<div id="overview-contacts"></div>
|
||||
</div>
|
||||
|
||||
<!-- Support info & master data -->
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Support info & master data')); ?></div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Debtor No.')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['No'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Name')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['Name'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Support type')); ?></strong></td>
|
||||
<td><?php e($supportType !== '' ? $supportType : '—'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Salesperson')); ?></strong></td>
|
||||
<td><?php e($salesperson !== '' ? $salesperson : '—'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Location')); ?></strong></td>
|
||||
<td><?php e($locationDisplay !== '' ? $locationDisplay : '—'); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Phone')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['Phone_No'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('E-Mail')); ?></strong></td>
|
||||
<td><?php e((string) ($customer['E_Mail'] ?? '')); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tickets panel (Grid.js) -->
|
||||
<div data-tab-panel="tickets" hidden>
|
||||
<?php
|
||||
$filterUiNamespace = 'helpdesk-tickets';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div id="helpdesk-tickets-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts panel -->
|
||||
<div data-tab-panel="contacts" hidden>
|
||||
<div class="helpdesk-tab-loading" id="contacts-loading">
|
||||
<div class="helpdesk-spinner"></div>
|
||||
<span><?php e(t('Loading...')); ?></span>
|
||||
</div>
|
||||
<div id="contacts-content" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Grid.js tickets config -->
|
||||
<script type="application/json" id="page-config-helpdesk-tickets"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('helpdesk/debitor-tickets-data'),
|
||||
'categoryOptionsUrl' => lurl('helpdesk/debitor-ticket-categories-data'),
|
||||
'customerNo' => $customerNo,
|
||||
'customerName' => $customerName,
|
||||
'ticketBaseUrl' => lurl('helpdesk/ticket/'),
|
||||
'gridLang' => gridLang(),
|
||||
'labels' => [
|
||||
'ticketNo' => t('Ticket No.'),
|
||||
'description' => t('Description'),
|
||||
'status' => t('Status'),
|
||||
'category' => t('Category'),
|
||||
'contact' => t('Contact'),
|
||||
'support' => t('Support'),
|
||||
'created' => t('Created'),
|
||||
'lastActivity' => t('Last activity'),
|
||||
],
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
]); ?></script>
|
||||
|
||||
<!-- i18n strings for JS rendering -->
|
||||
<script type="application/json" id="helpdesk-i18n"><?php gridJsonForJs([
|
||||
'Ticket No.' => t('Ticket No.'),
|
||||
'Description' => t('Description'),
|
||||
'Status' => t('Status'),
|
||||
'Category' => t('Category'),
|
||||
'Contact' => t('Contact'),
|
||||
'Support' => t('Support'),
|
||||
'Created' => t('Created'),
|
||||
'Last activity' => t('Last activity'),
|
||||
'No.' => t('No.'),
|
||||
'Name' => t('Name'),
|
||||
'Type' => t('Type'),
|
||||
'Job title' => t('Job title'),
|
||||
'E-Mail' => t('E-Mail'),
|
||||
'Phone' => t('Phone'),
|
||||
'Mobile' => t('Mobile'),
|
||||
'No open tickets' => t('No open tickets'),
|
||||
'No tickets found' => t('No tickets found'),
|
||||
'No tickets are linked to this debtor in Business Central.' => t('No tickets are linked to this debtor in Business Central.'),
|
||||
'No contacts found' => t('No contacts found'),
|
||||
'No contacts are linked to this debtor in Business Central.' => t('No contacts are linked to this debtor in Business Central.'),
|
||||
'No recent activity found.' => t('No recent activity found.'),
|
||||
'Could not load contacts.' => t('Could not load contacts.'),
|
||||
'Could not load tickets.' => t('Could not load tickets.'),
|
||||
'Loading...' => t('Loading...'),
|
||||
]); ?></script>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-detail.js')); ?>"></script>
|
||||
32
modules/helpdesk/pages/helpdesk/debitor-contacts-data().php
Normal file
32
modules/helpdesk/pages/helpdesk/debitor-contacts-data().php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'GET') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customerNo = trim((string) $request->query('customerNo', ''));
|
||||
$customerName = trim((string) $request->query('customerName', ''));
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'missing_parameters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadContacts($customerNo, $customerName);
|
||||
|
||||
Router::json($result);
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFilters(requestInput()->queryAll(), [
|
||||
'customerNo' => ['type' => 'string'],
|
||||
'customerName' => ['type' => 'string'],
|
||||
]);
|
||||
|
||||
$customerNo = (string) ($filters['customerNo'] ?? '');
|
||||
$customerName = (string) ($filters['customerName'] ?? '');
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'categories' => [],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class);
|
||||
$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo;
|
||||
$cacheTtl = 120;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$allTickets = null;
|
||||
|
||||
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$allTickets = $cached['tickets'] ?? [];
|
||||
}
|
||||
|
||||
if ($allTickets === null) {
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadTickets($customerNo, $customerName);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
Router::json([
|
||||
'ok' => false,
|
||||
'categories' => [],
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$allTickets = $result['tickets'] ?? [];
|
||||
|
||||
$sessionStore->set($cacheKey, [
|
||||
'tickets' => $allTickets,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
$categories = [];
|
||||
foreach ((array) $allTickets as $ticket) {
|
||||
if (!is_array($ticket)) {
|
||||
continue;
|
||||
}
|
||||
$category = trim((string) ($ticket['Category_1_Description'] ?? ''));
|
||||
if ($category === '') {
|
||||
continue;
|
||||
}
|
||||
$categories[$category] = true;
|
||||
}
|
||||
|
||||
$categoryValues = array_keys($categories);
|
||||
usort($categoryValues, 'strnatcasecmp');
|
||||
|
||||
Router::json([
|
||||
'ok' => true,
|
||||
'categories' => array_values($categoryValues),
|
||||
]);
|
||||
|
||||
175
modules/helpdesk/pages/helpdesk/debitor-tickets-data().php
Normal file
175
modules/helpdesk/pages/helpdesk/debitor-tickets-data().php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
gridRequireGetRequest();
|
||||
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/debitor-tickets-filter-schema.php');
|
||||
|
||||
$customerNo = (string) ($filters['customerNo'] ?? '');
|
||||
$customerName = (string) ($filters['customerName'] ?? '');
|
||||
|
||||
if ($customerNo === '' || $customerName === '') {
|
||||
gridJsonDataResult([], 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Session cache (lazy TTL = 120s) ---
|
||||
|
||||
$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class);
|
||||
$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo;
|
||||
$cacheTtl = 120;
|
||||
$cached = $sessionStore->get($cacheKey);
|
||||
$allTickets = null;
|
||||
|
||||
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
|
||||
$allTickets = $cached['tickets'] ?? [];
|
||||
}
|
||||
|
||||
if ($allTickets === null) {
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadTickets($customerNo, $customerName);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
gridJsonDataResult([], 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$allTickets = $result['tickets'] ?? [];
|
||||
|
||||
$sessionStore->set($cacheKey, [
|
||||
'tickets' => $allTickets,
|
||||
'fetched_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- PHP-side filtering ---
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
$status = trim((string) ($filters['status'] ?? ''));
|
||||
$category = trim((string) ($filters['category'] ?? ''));
|
||||
$order = (string) ($filters['order'] ?? 'Created_On');
|
||||
$dir = (string) ($filters['dir'] ?? 'desc');
|
||||
$limit = (int) ($filters['limit'] ?? 10);
|
||||
$offset = (int) ($filters['offset'] ?? 0);
|
||||
|
||||
$filtered = $allTickets;
|
||||
|
||||
// Search: stripos on No + Description + Current_Contact_Name + Support_User_Name
|
||||
if ($search !== '') {
|
||||
$searchLower = mb_strtolower($search);
|
||||
$filtered = array_values(array_filter($filtered, static function (array $ticket) use ($searchLower): bool {
|
||||
$haystack = mb_strtolower(
|
||||
($ticket['No'] ?? '') . ' '
|
||||
. ($ticket['Description'] ?? '') . ' '
|
||||
. ($ticket['Current_Contact_Name'] ?? '') . ' '
|
||||
. ($ticket['Support_User_Name'] ?? '')
|
||||
);
|
||||
|
||||
return str_contains($haystack, $searchLower);
|
||||
}));
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($status !== '') {
|
||||
$statusMap = [
|
||||
'open' => ['Offen', 'Open'],
|
||||
'in_progress' => ['In Bearbeitung', 'In Progress'],
|
||||
'closed' => ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'],
|
||||
];
|
||||
$allowedStates = $statusMap[$status] ?? [];
|
||||
if ($allowedStates !== []) {
|
||||
$filtered = array_values(array_filter($filtered, static function (array $ticket) use ($allowedStates): bool {
|
||||
return in_array((string) ($ticket['Ticket_State'] ?? ''), $allowedStates, true);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if ($category !== '') {
|
||||
$filtered = array_values(array_filter($filtered, static function (array $ticket) use ($category): bool {
|
||||
$ticketCategory = trim((string) ($ticket['Category_1_Description'] ?? ''));
|
||||
return $ticketCategory === $category;
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Sorting ---
|
||||
|
||||
$total = count($filtered);
|
||||
|
||||
usort($filtered, static function (array $a, array $b) use ($order, $dir): int {
|
||||
$va = (string) ($a[$order] ?? '');
|
||||
$vb = (string) ($b[$order] ?? '');
|
||||
$cmp = strnatcasecmp($va, $vb);
|
||||
|
||||
return $dir === 'desc' ? -$cmp : $cmp;
|
||||
});
|
||||
|
||||
// --- Pagination ---
|
||||
|
||||
$rows = array_slice($filtered, $offset, $limit);
|
||||
|
||||
// --- Row preparation ---
|
||||
|
||||
$stateVariantMap = [
|
||||
'Offen' => 'primary',
|
||||
'Open' => 'primary',
|
||||
'In Bearbeitung' => 'warning',
|
||||
'In Progress' => 'warning',
|
||||
'Erledigt' => 'success',
|
||||
'Resolved' => 'success',
|
||||
'Closed' => 'success',
|
||||
'Geschlossen' => 'success',
|
||||
];
|
||||
|
||||
$ticketBaseUrl = lurl('helpdesk/ticket/');
|
||||
|
||||
$preparedRows = [];
|
||||
foreach ($rows as $ticket) {
|
||||
$ticketNo = (string) ($ticket['No'] ?? '');
|
||||
$state = (string) ($ticket['Ticket_State'] ?? '');
|
||||
$createdRaw = (string) ($ticket['Created_On'] ?? '');
|
||||
$lastActivityRaw = (string) ($ticket['Last_Activity_Date'] ?? '');
|
||||
|
||||
$createdFormatted = '';
|
||||
if ($createdRaw !== '' && $createdRaw !== '0001-01-01T00:00:00Z') {
|
||||
try {
|
||||
$createdFormatted = (new DateTimeImmutable($createdRaw))->format('d.m.Y H:i');
|
||||
} catch (\Throwable) {
|
||||
$createdFormatted = $createdRaw;
|
||||
}
|
||||
}
|
||||
|
||||
$lastActivityFormatted = '';
|
||||
if ($lastActivityRaw !== '' && $lastActivityRaw !== '0001-01-01T00:00:00Z') {
|
||||
try {
|
||||
$lastActivityFormatted = (new DateTimeImmutable($lastActivityRaw))->format('d.m.Y H:i');
|
||||
} catch (\Throwable) {
|
||||
$lastActivityFormatted = $lastActivityRaw;
|
||||
}
|
||||
}
|
||||
|
||||
$preparedRows[] = [
|
||||
'No' => $ticketNo,
|
||||
'Description' => (string) ($ticket['Description'] ?? ''),
|
||||
'Ticket_State' => $state,
|
||||
'state_variant' => $stateVariantMap[$state] ?? 'neutral',
|
||||
'Category_1_Description' => (string) ($ticket['Category_1_Description'] ?? ''),
|
||||
'Support_User_Name' => (string) ($ticket['Support_User_Name'] ?? ''),
|
||||
'Current_Contact_Name' => (string) ($ticket['Current_Contact_Name'] ?? ''),
|
||||
'Created_On' => $createdRaw,
|
||||
'Last_Activity_Date' => $lastActivityRaw,
|
||||
'created_formatted' => $createdFormatted,
|
||||
'last_activity_formatted' => $lastActivityFormatted,
|
||||
'ticket_url' => $ticketBaseUrl . rawurlencode($ticketNo) . '?from=' . rawurlencode($customerNo),
|
||||
];
|
||||
}
|
||||
|
||||
gridJsonDataResult($preparedRows, $total);
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
return gridFilterSchema([
|
||||
'query' => [
|
||||
'customerNo' => ['type' => 'string'],
|
||||
'customerName' => ['type' => 'string'],
|
||||
'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100],
|
||||
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
|
||||
'search' => ['type' => 'string'],
|
||||
'category' => ['type' => 'string'],
|
||||
'order' => ['type' => 'order', 'allowed' => ['No', 'Created_On', 'Last_Activity_Date', 'Ticket_State'], 'default' => 'Created_On'],
|
||||
'dir' => ['type' => 'dir', 'default' => 'desc'],
|
||||
'status' => ['type' => 'string', 'default' => ''],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
'label' => 'Search',
|
||||
'input_id' => 'helpdesk-ticket-search',
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'status',
|
||||
'type' => 'select',
|
||||
'label' => 'Status',
|
||||
'input_id' => 'helpdesk-ticket-status-filter',
|
||||
'default' => '',
|
||||
'normalize' => 'all_to_empty',
|
||||
'allowed' => [
|
||||
['id' => '', 'description' => 'All'],
|
||||
['id' => 'open', 'description' => 'Open'],
|
||||
['id' => 'in_progress', 'description' => 'In Progress'],
|
||||
['id' => 'closed', 'description' => 'Closed'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'category',
|
||||
'type' => 'select',
|
||||
'label' => 'Category',
|
||||
'input_id' => 'helpdesk-ticket-category-filter',
|
||||
'default' => '',
|
||||
'options_key' => 'category_items',
|
||||
'allowed' => [
|
||||
['id' => '', 'description' => 'All'],
|
||||
],
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
31
modules/helpdesk/pages/helpdesk/filter-schema.php
Normal file
31
modules/helpdesk/pages/helpdesk/filter-schema.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
return gridFilterSchema([
|
||||
'query' => [
|
||||
'search' => ['type' => 'string'],
|
||||
'city' => ['type' => 'string'],
|
||||
'limit' => ['type' => 'int', 'default' => 10, 'min' => 1, 'max' => 100],
|
||||
'offset' => ['type' => 'int', 'default' => 0, 'min' => 0],
|
||||
'order' => ['type' => 'order', 'allowed' => ['No', 'Name', 'City'], 'default' => 'Name'],
|
||||
'dir' => ['type' => 'dir', 'default' => 'asc'],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
'label' => 'Search',
|
||||
'placeholder' => 'Search...',
|
||||
'input_id' => 'helpdesk-search-input',
|
||||
'search' => true,
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
[
|
||||
'key' => 'city',
|
||||
'type' => 'text',
|
||||
'label' => 'City',
|
||||
'placeholder' => 'City',
|
||||
'input_id' => 'helpdesk-city-filter',
|
||||
'query' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
40
modules/helpdesk/pages/helpdesk/search().php
Normal file
40
modules/helpdesk/pages/helpdesk/search().php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$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'];
|
||||
$filterChipMeta = [
|
||||
'search' => [
|
||||
'label' => t('Search'),
|
||||
'type' => 'text',
|
||||
],
|
||||
'city' => [
|
||||
'label' => t('City'),
|
||||
'type' => 'text',
|
||||
],
|
||||
];
|
||||
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
$isConfigured = $settingsGateway->isConfigured();
|
||||
|
||||
Buffer::set('title', t('Customers'));
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
57
modules/helpdesk/pages/helpdesk/search(default).phtml
Normal file
57
modules/helpdesk/pages/helpdesk/search(default).phtml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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 : [];
|
||||
$isConfigured = $isConfigured ?? false;
|
||||
?>
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk')],
|
||||
];
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if (!$isConfigured): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<p><?php e(t('BC connection is not configured. Please configure the connection in the settings.')); ?></p>
|
||||
<a href="<?php e(lurl('helpdesk/settings')); ?>" role="button" class="secondary outline small">
|
||||
<?php e(t('Open settings')); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$listTitle = t('Customers');
|
||||
require templatePath('partials/app-list-titlebar.phtml');
|
||||
?>
|
||||
<?php
|
||||
$filterUiNamespace = 'helpdesk-search';
|
||||
require templatePath('partials/app-list-filters.phtml');
|
||||
?>
|
||||
<div class="app-list-table">
|
||||
<div id="helpdesk-search-grid"></div>
|
||||
</div>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
|
||||
<script type="application/json" id="page-config-helpdesk-search"><?php gridJsonForJs([
|
||||
'dataUrl' => lurl('helpdesk/search-data'),
|
||||
'gridLang' => gridLang(),
|
||||
'gridSearch' => $searchConfig,
|
||||
'filterSchema' => $clientFilterSchema,
|
||||
'filterChipMeta' => $filterChipMeta,
|
||||
'labels' => [
|
||||
'debtorNo' => t('Debtor No.'),
|
||||
'name' => t('Name'),
|
||||
'city' => t('City'),
|
||||
'phone' => t('Phone'),
|
||||
'email' => t('E-Mail'),
|
||||
],
|
||||
]); ?></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/pages/helpdesk-search-index.js')); ?>"></script>
|
||||
42
modules/helpdesk/pages/helpdesk/search-data().php
Normal file
42
modules/helpdesk/pages/helpdesk/search-data().php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
gridRequireGetRequest();
|
||||
|
||||
$query = requestInput()->queryAll();
|
||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php', $query);
|
||||
$result = app(DebitorSearchService::class)->listForGrid($filters);
|
||||
|
||||
if (($result['status'] ?? '') !== 'success') {
|
||||
gridJsonDataResult([], 0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ((array) ($result['rows'] ?? []) as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$debtorNo = trim((string) ($row['No'] ?? ''));
|
||||
if ($debtorNo === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'No' => $debtorNo,
|
||||
'Name' => (string) ($row['Name'] ?? ''),
|
||||
'City' => (string) ($row['City'] ?? ''),
|
||||
'Phone_No' => (string) ($row['Phone_No'] ?? ''),
|
||||
'E_Mail' => (string) ($row['E_Mail'] ?? ''),
|
||||
'detail_url' => lurl('helpdesk/debitor/' . rawurlencode($debtorNo)),
|
||||
];
|
||||
}
|
||||
|
||||
gridJsonDataResult($rows, (int) ($result['total'] ?? 0));
|
||||
50
modules/helpdesk/pages/helpdesk/settings/diagnose-data().php
Normal file
50
modules/helpdesk/pages/helpdesk/settings/diagnose-data().php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf_expired']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
if (!$settingsGateway->isConfigured()) {
|
||||
Router::json(['ok' => false, 'error' => t('Configuration incomplete')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$body = $request->bodyAll();
|
||||
$customerNo = trim((string) ($body['customer_no'] ?? ''));
|
||||
if ($customerNo === '') {
|
||||
Router::json(['ok' => false, 'error' => t('Please provide a customer number')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = app(BcODataGateway::class);
|
||||
$diagnosis = $gateway->diagnoseCustomer($customerNo);
|
||||
|
||||
Router::json([
|
||||
'ok' => true,
|
||||
'customer_no' => $customerNo,
|
||||
'diagnosis' => $diagnosis,
|
||||
]);
|
||||
98
modules/helpdesk/pages/helpdesk/settings/index().php
Normal file
98
modules/helpdesk/pages/helpdesk/settings/index().php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
|
||||
if ($request->isMethod('POST') && !Session::checkCsrfToken()) {
|
||||
Flash::error(t('Form expired, please try again'), 'helpdesk/settings', 'csrf_expired');
|
||||
Router::redirect('helpdesk/settings');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($request->isMethod('POST')) {
|
||||
$post = $request->bodyAll();
|
||||
|
||||
$authMode = trim((string) ($post['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC));
|
||||
$odataBaseUrl = trim((string) ($post['odata_base_url'] ?? ''));
|
||||
$companyName = trim((string) ($post['company_name'] ?? ''));
|
||||
$basicUser = trim((string) ($post['basic_user'] ?? ''));
|
||||
$basicPassword = trim((string) ($post['basic_password'] ?? ''));
|
||||
$oauthTenantId = trim((string) ($post['oauth_tenant_id'] ?? ''));
|
||||
$oauthClientId = trim((string) ($post['oauth_client_id'] ?? ''));
|
||||
$oauthClientSecret = trim((string) ($post['oauth_client_secret'] ?? ''));
|
||||
$oauthTokenEndpoint = trim((string) ($post['oauth_token_endpoint'] ?? ''));
|
||||
|
||||
$errorBag = formErrors();
|
||||
|
||||
if ($odataBaseUrl !== '' && !preg_match('#^https://#i', $odataBaseUrl)) {
|
||||
$errorBag->add('odata_base_url', t('OData Base URL must use HTTPS'));
|
||||
}
|
||||
|
||||
if (!$errorBag->hasAny()) {
|
||||
$settingsGateway->setAuthMode($authMode);
|
||||
|
||||
if ($odataBaseUrl !== '') {
|
||||
$settingsGateway->setODataBaseUrl($odataBaseUrl);
|
||||
}
|
||||
if ($companyName !== '') {
|
||||
$settingsGateway->setCompanyName($companyName);
|
||||
}
|
||||
|
||||
$settingsGateway->setBasicUser($basicUser !== '' ? $basicUser : null);
|
||||
// Only update password if a new value was provided (not the placeholder)
|
||||
if ($basicPassword !== '' && $basicPassword !== '********') {
|
||||
$settingsGateway->setBasicPassword($basicPassword);
|
||||
}
|
||||
|
||||
$settingsGateway->setOAuthTenantId($oauthTenantId !== '' ? $oauthTenantId : null);
|
||||
$settingsGateway->setOAuthClientId($oauthClientId !== '' ? $oauthClientId : null);
|
||||
if ($oauthClientSecret !== '' && $oauthClientSecret !== '********') {
|
||||
$settingsGateway->setOAuthClientSecret($oauthClientSecret);
|
||||
}
|
||||
if ($oauthTokenEndpoint !== '') {
|
||||
$settingsGateway->setOAuthTokenEndpoint($oauthTokenEndpoint);
|
||||
}
|
||||
|
||||
$action = trim((string) ($post['action'] ?? 'save'));
|
||||
Flash::success(t('Settings saved'), 'helpdesk/settings', 'settings_saved');
|
||||
|
||||
if ($action === 'save_close') {
|
||||
Router::redirect('helpdesk/debitor');
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
flashFormErrors($errorBag, 'helpdesk/settings', 'settings_error');
|
||||
}
|
||||
|
||||
Router::redirect('helpdesk/settings');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Load current values for display
|
||||
$authMode = $settingsGateway->getAuthMode();
|
||||
$odataBaseUrl = $settingsGateway->getODataBaseUrl();
|
||||
$companyName = $settingsGateway->getCompanyName();
|
||||
$basicUser = $settingsGateway->getBasicUser() ?? '';
|
||||
$hasBasicPassword = $settingsGateway->getBasicPassword() !== null;
|
||||
$oauthTenantId = $settingsGateway->getOAuthTenantId() ?? '';
|
||||
$oauthClientId = $settingsGateway->getOAuthClientId() ?? '';
|
||||
$hasOAuthClientSecret = $settingsGateway->getOAuthClientSecret() !== null;
|
||||
$oauthTokenEndpoint = $settingsGateway->getOAuthTokenEndpoint() ?? '';
|
||||
$configErrors = $settingsGateway->validateConfiguration();
|
||||
|
||||
Buffer::set('title', t('Helpdesk settings'));
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
198
modules/helpdesk/pages/helpdesk/settings/index(default).phtml
Normal file
198
modules/helpdesk/pages/helpdesk/settings/index(default).phtml
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var string $authMode
|
||||
* @var string $odataBaseUrl
|
||||
* @var string $companyName
|
||||
* @var string $basicUser
|
||||
* @var bool $hasBasicPassword
|
||||
* @var string $oauthTenantId
|
||||
* @var string $oauthClientId
|
||||
* @var bool $hasOAuthClientSecret
|
||||
* @var string $oauthTokenEndpoint
|
||||
* @var array $configErrors
|
||||
*/
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Session;
|
||||
|
||||
$authMode = $authMode ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC;
|
||||
$odataBaseUrl = $odataBaseUrl ?? HelpdeskSettingsGateway::DEFAULT_ODATA_BASE_URL;
|
||||
$companyName = $companyName ?? HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME;
|
||||
$basicUser = $basicUser ?? '';
|
||||
$hasBasicPassword = $hasBasicPassword ?? false;
|
||||
$oauthTenantId = $oauthTenantId ?? '';
|
||||
$oauthClientId = $oauthClientId ?? '';
|
||||
$hasOAuthClientSecret = $hasOAuthClientSecret ?? false;
|
||||
$oauthTokenEndpoint = $oauthTokenEndpoint ?? '';
|
||||
$configErrors = is_array($configErrors ?? null) ? $configErrors : [];
|
||||
|
||||
?>
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'],
|
||||
['label' => t('Settings')],
|
||||
];
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Helpdesk settings'),
|
||||
'backHref' => lurl('helpdesk/debitor'),
|
||||
'actions' => [
|
||||
[
|
||||
'label' => t('Save'),
|
||||
'type' => 'submit',
|
||||
'form' => 'helpdesk-settings-form',
|
||||
'name' => 'action',
|
||||
'value' => 'save',
|
||||
'class' => 'secondary outline',
|
||||
],
|
||||
[
|
||||
'label' => t('Save & close'),
|
||||
'type' => 'submit',
|
||||
'form' => 'helpdesk-settings-form',
|
||||
'name' => 'action',
|
||||
'value' => 'save_close',
|
||||
'class' => 'primary',
|
||||
],
|
||||
],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if ($configErrors !== []): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<strong><?php e(t('Configuration incomplete')); ?>:</strong>
|
||||
<ul>
|
||||
<?php foreach ($configErrors as $err): ?>
|
||||
<li><?php e($err); ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form id="helpdesk-settings-form" method="post" action="<?php e(lurl('helpdesk/settings')); ?>">
|
||||
<?php Session::getCsrfInput(); ?>
|
||||
|
||||
<fieldset>
|
||||
<legend><?php e(t('OData connection')); ?></legend>
|
||||
|
||||
<label for="odata_base_url"><?php e(t('OData Base URL')); ?></label>
|
||||
<input
|
||||
type="url"
|
||||
id="odata_base_url"
|
||||
name="odata_base_url"
|
||||
value="<?php e($odataBaseUrl); ?>"
|
||||
placeholder="https://bc.example.com:7048/BusinessCentral/ODataV4"
|
||||
>
|
||||
<small><?php e(t('Base URL without Company path segment')); ?></small>
|
||||
|
||||
<label for="company_name"><?php e(t('Company name')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
value="<?php e($companyName); ?>"
|
||||
placeholder="<?php e(HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME); ?>"
|
||||
>
|
||||
<small><?php e(t('BC company name (URL-encoded automatically)')); ?></small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><?php e(t('Authentication')); ?></legend>
|
||||
|
||||
<label for="auth_mode"><?php e(t('Auth mode')); ?></label>
|
||||
<select id="auth_mode" name="auth_mode">
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_BASIC); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC): ?>selected<?php endif; ?>>
|
||||
<?php e(t('Basic Auth')); ?>
|
||||
</option>
|
||||
<option value="<?php e(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2); ?>" <?php if ($authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2): ?>selected<?php endif; ?>>
|
||||
<?php e(t('OAuth2 (client credentials)')); ?>
|
||||
</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="helpdesk-basic-auth-fields">
|
||||
<legend><?php e(t('Basic Auth credentials')); ?></legend>
|
||||
|
||||
<label for="basic_user"><?php e(t('Username')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="basic_user"
|
||||
name="basic_user"
|
||||
value="<?php e($basicUser); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<label for="basic_password"><?php e(t('Password')); ?></label>
|
||||
<input
|
||||
type="password"
|
||||
id="basic_password"
|
||||
name="basic_password"
|
||||
value="<?php if ($hasBasicPassword): ?>********<?php endif; ?>"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<?php if ($hasBasicPassword): ?>
|
||||
<small><?php e(t('Leave unchanged to keep current password')); ?></small>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="helpdesk-oauth2-fields">
|
||||
<legend><?php e(t('OAuth2 credentials')); ?></legend>
|
||||
|
||||
<label for="oauth_tenant_id"><?php e(t('Azure Tenant ID')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="oauth_tenant_id"
|
||||
name="oauth_tenant_id"
|
||||
value="<?php e($oauthTenantId); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<label for="oauth_client_id"><?php e(t('Client ID')); ?></label>
|
||||
<input
|
||||
type="text"
|
||||
id="oauth_client_id"
|
||||
name="oauth_client_id"
|
||||
value="<?php e($oauthClientId); ?>"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<label for="oauth_client_secret"><?php e(t('Client Secret')); ?></label>
|
||||
<input
|
||||
type="password"
|
||||
id="oauth_client_secret"
|
||||
name="oauth_client_secret"
|
||||
value="<?php if ($hasOAuthClientSecret): ?>********<?php endif; ?>"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
|
||||
<label for="oauth_token_endpoint"><?php e(t('Token endpoint URL')); ?></label>
|
||||
<input
|
||||
type="url"
|
||||
id="oauth_token_endpoint"
|
||||
name="oauth_token_endpoint"
|
||||
value="<?php e($oauthTokenEndpoint); ?>"
|
||||
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><?php e(t('Connection test')); ?></legend>
|
||||
<button type="button" id="helpdesk-test-connection-button" class="secondary outline">
|
||||
<?php e(t('Test connection')); ?>
|
||||
</button>
|
||||
<span id="helpdesk-test-connection-result" aria-live="polite"></span>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-settings.js')); ?>"></script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_SETTINGS_MANAGE);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf_expired']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
if (!$settingsGateway->isConfigured()) {
|
||||
Router::json(['ok' => false, 'error' => t('Configuration incomplete')]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = app(BcODataGateway::class);
|
||||
$result = $gateway->testConnection();
|
||||
|
||||
// Optional: diagnose search if ?diag=search query param is set
|
||||
$searchDiag = null;
|
||||
$diagQuery = trim((string) $request->query('diag', ''));
|
||||
if ($diagQuery === 'search') {
|
||||
try {
|
||||
$searchResults = $gateway->searchCustomers('10');
|
||||
$searchDiag = ['status' => 'ok', 'count' => count($searchResults), 'first' => $searchResults[0] ?? null];
|
||||
} catch (\Throwable $e) {
|
||||
$searchDiag = ['status' => 'error', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
Router::json([
|
||||
'ok' => $result['ok'],
|
||||
'error' => $result['ok'] ? null : ($result['error'] ?? t('Connection failed')),
|
||||
'message' => $result['ok'] ? t('Connection successful') : null,
|
||||
'debug' => [
|
||||
'url' => $result['url'] ?? null,
|
||||
'http_code' => $result['http_code'] ?? null,
|
||||
'search_diag' => $searchDiag,
|
||||
],
|
||||
]);
|
||||
46
modules/helpdesk/pages/helpdesk/ticket($id).php
Normal file
46
modules/helpdesk/pages/helpdesk/ticket($id).php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
$ticketNo = trim((string) ($id ?? ''));
|
||||
$fromCustomerNo = trim((string) $request->query('from', ''));
|
||||
|
||||
if ($ticketNo === '') {
|
||||
Router::redirect('helpdesk/debitor');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = app(BcODataGateway::class);
|
||||
|
||||
try {
|
||||
$ticket = $gateway->getTicket($ticketNo);
|
||||
} catch (\Throwable) {
|
||||
$ticket = null;
|
||||
$connectionError = true;
|
||||
}
|
||||
|
||||
$connectionError = $connectionError ?? false;
|
||||
|
||||
if ($ticket === null && !$connectionError) {
|
||||
Flash::error(t('Ticket not found'), 'helpdesk/debitor', 'ticket_not_found');
|
||||
Router::redirect('helpdesk/debitor');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ticketCustomerNo = $fromCustomerNo;
|
||||
$ticketDescription = (string) ($ticket['Description'] ?? $ticketNo);
|
||||
$ticketLogUrl = lurl('helpdesk/ticket-log-data');
|
||||
|
||||
Buffer::set('title', $ticketDescription . ' — ' . t('Helpdesk'));
|
||||
Buffer::set('style_groups', json_encode(['helpdesk']));
|
||||
161
modules/helpdesk/pages/helpdesk/ticket(default).phtml
Normal file
161
modules/helpdesk/pages/helpdesk/ticket(default).phtml
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @var string $ticketNo
|
||||
* @var string $fromCustomerNo
|
||||
* @var array|null $ticket
|
||||
* @var bool $connectionError
|
||||
* @var string $ticketCustomerNo
|
||||
* @var string $ticketDescription
|
||||
* @var string $ticketLogUrl
|
||||
*/
|
||||
|
||||
$ticketNo = $ticketNo ?? '';
|
||||
$fromCustomerNo = $fromCustomerNo ?? '';
|
||||
$ticket = is_array($ticket ?? null) ? $ticket : null;
|
||||
$connectionError = $connectionError ?? false;
|
||||
$ticketCustomerNo = $ticketCustomerNo ?? '';
|
||||
$ticketDescription = $ticketDescription ?? '';
|
||||
$ticketLogUrl = $ticketLogUrl ?? '';
|
||||
|
||||
$backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($ticketCustomerNo)) : lurl('helpdesk/debitor');
|
||||
|
||||
?>
|
||||
<div class="app-details-container"
|
||||
data-ticket-no="<?php e($ticketNo); ?>"
|
||||
data-ticket-log-url="<?php e($ticketLogUrl); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'],
|
||||
];
|
||||
if ($ticketCustomerNo !== '') {
|
||||
$breadcrumbs[] = ['label' => (string) ($ticket['Company_Contact_Name'] ?? $ticketCustomerNo), 'path' => 'helpdesk/debitor/' . rawurlencode($ticketCustomerNo)];
|
||||
}
|
||||
$breadcrumbs[] = ['label' => t('Ticket') . ' ' . $ticketNo];
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Ticket') . ' ' . $ticketNo,
|
||||
'backHref' => $backUrl,
|
||||
'backTitle' => t('Back'),
|
||||
'actions' => [],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<?php require templatePath('partials/app-flash.phtml'); ?>
|
||||
|
||||
<?php if ($connectionError): ?>
|
||||
<div class="notice" data-variant="error" role="alert">
|
||||
<?php e(t('Could not connect to Business Central.')); ?>
|
||||
</div>
|
||||
<?php elseif ($ticket !== null): ?>
|
||||
<?php
|
||||
$createdRaw = (string) ($ticket['Created_On'] ?? '');
|
||||
$createdFormatted = $createdRaw !== '' && $createdRaw !== '0001-01-01T00:00:00Z'
|
||||
? (new DateTimeImmutable($createdRaw))->format('d.m.Y H:i')
|
||||
: '—';
|
||||
$lastActivityRaw = (string) ($ticket['Last_Activity_Date'] ?? '');
|
||||
$lastActivityFormatted = $lastActivityRaw !== '' && $lastActivityRaw !== '0001-01-01T00:00:00Z'
|
||||
? (new DateTimeImmutable($lastActivityRaw))->format('d.m.Y H:i')
|
||||
: '—';
|
||||
$ticketState = (string) ($ticket['Ticket_State'] ?? '');
|
||||
$stateVariant = match ($ticketState) {
|
||||
'Offen', 'Open' => 'primary',
|
||||
'In Bearbeitung', 'In Progress' => 'warning',
|
||||
'Erledigt', 'Resolved', 'Closed', 'Geschlossen' => 'success',
|
||||
default => 'neutral',
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="grid grid-2">
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Ticket details')); ?></div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Ticket No.')); ?></strong></td>
|
||||
<td><?php e((string) ($ticket['No'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Description')); ?></strong></td>
|
||||
<td><?php e((string) ($ticket['Description'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Status')); ?></strong></td>
|
||||
<td><span class="badge" data-variant="<?php e($stateVariant); ?>"><?php e($ticketState); ?></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Category')); ?></strong></td>
|
||||
<td><?php e((string) ($ticket['Category_1_Description'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Support')); ?></strong></td>
|
||||
<td><?php e((string) ($ticket['Support_User_Name'] ?? '')); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Contact & dates')); ?></div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Company')); ?></strong></td>
|
||||
<td><?php e((string) ($ticket['Company_Contact_Name'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Contact')); ?></strong></td>
|
||||
<td><?php e((string) ($ticket['Current_Contact_Name'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Created')); ?></strong></td>
|
||||
<td><?php e($createdFormatted); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong><?php e(t('Last activity')); ?></strong></td>
|
||||
<td><?php e($lastActivityFormatted); ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity timeline — loaded async -->
|
||||
<div class="grid">
|
||||
<div class="app-stats-table">
|
||||
<div class="app-stats-table-header"><?php e(t('Activity timeline')); ?></div>
|
||||
<div id="ticket-timeline">
|
||||
<div class="helpdesk-tab-loading" id="timeline-loading">
|
||||
<div class="helpdesk-spinner"></div>
|
||||
<span><?php e(t('Loading...')); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- i18n strings for JS rendering -->
|
||||
<script type="application/json" id="helpdesk-ticket-i18n"><?php gridJsonForJs([
|
||||
'Message' => t('Message'),
|
||||
'Status change' => t('Status change'),
|
||||
'Activity' => t('Activity'),
|
||||
'No activity found.' => t('No activity found.'),
|
||||
'Could not load activity log.' => t('Could not load activity log.'),
|
||||
'sent a message' => t('sent a message'),
|
||||
'changed status' => t('changed status'),
|
||||
'Forwarded' => t('Forwarded'),
|
||||
'attached a file' => t('attached a file'),
|
||||
'Process' => t('Process'),
|
||||
'Loading...' => t('Loading...'),
|
||||
]); ?></script>
|
||||
|
||||
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/helpdesk-ticket.js')); ?>"></script>
|
||||
31
modules/helpdesk/pages/helpdesk/ticket-log-data().php
Normal file
31
modules/helpdesk/pages/helpdesk/ticket-log-data().php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->method() !== 'GET') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ticketNo = trim((string) $request->query('ticketNo', ''));
|
||||
|
||||
if ($ticketNo === '') {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'missing_parameters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(DebitorDetailService::class);
|
||||
$result = $service->loadTicketLog($ticketNo);
|
||||
|
||||
Router::json($result);
|
||||
54
modules/helpdesk/templates/aside-helpdesk-panel.phtml
Normal file
54
modules/helpdesk/templates/aside-helpdesk-panel.phtml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
|
||||
$helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpdesk.nav'] : [];
|
||||
$canManageSettings = !empty($helpdeskNav['can_manage_settings']);
|
||||
|
||||
$helpdeskNavItems = [
|
||||
[
|
||||
'label' => t('Customers'),
|
||||
'path' => 'helpdesk/debitor',
|
||||
'active' => navActive('helpdesk/debitor', true),
|
||||
'visible' => true,
|
||||
],
|
||||
[
|
||||
'label' => t('Settings'),
|
||||
'path' => 'helpdesk/settings',
|
||||
'active' => navActive('helpdesk/settings', true),
|
||||
'visible' => $canManageSettings,
|
||||
],
|
||||
];
|
||||
|
||||
$visibleItems = array_values(array_filter($helpdeskNavItems, static fn (array $item): bool => !empty($item['visible'])));
|
||||
$groupIsActive = false;
|
||||
foreach ($visibleItems as $item) {
|
||||
$activeState = $item['active'] ?? [];
|
||||
if (!empty($activeState['isActive'])) {
|
||||
$groupIsActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<ul class="app-sidebar-admin-nav" aria-label="<?php e(t('Helpdesk')); ?>">
|
||||
<?php if ($visibleItems): ?>
|
||||
<li class="app-sidebar-group app-sidebar-admin-group">
|
||||
<details data-details-key="helpdesk-navigation"<?php if ($groupIsActive): ?> open<?php endif; ?>>
|
||||
<summary>
|
||||
<i class="bi bi-headset"></i>
|
||||
<span><?php e(t('Helpdesk')); ?></span>
|
||||
</summary>
|
||||
<ul>
|
||||
<?php foreach ($visibleItems as $item):
|
||||
$active = $item['active'] ?? ['class' => '', 'aria' => ''];
|
||||
?>
|
||||
<li>
|
||||
<a href="<?php e(lurl($item['path'])); ?>" class="<?php e($active['class'] ?? ''); ?>" <?php echo $active['aria'] ?? ''; ?>>
|
||||
<?php e($item['label']); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BcODataGatewayTest extends TestCase
|
||||
{
|
||||
private function createGatewayWithSettings(bool $isConfigured = true): BcODataGateway
|
||||
{
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($isConfigured);
|
||||
$settings->method('getODataBaseUrl')->willReturn('https://bc.example.com/ODataV4');
|
||||
$settings->method('getCompanyName')->willReturn('Test Company');
|
||||
$settings->method('buildEntityUrl')->willReturnCallback(
|
||||
static fn (string $entity): string => "https://bc.example.com/ODataV4/Company('Test%20Company')/" . $entity
|
||||
);
|
||||
$settings->method('getAuthMode')->willReturn('basic');
|
||||
$settings->method('getBasicUser')->willReturn('testuser');
|
||||
$settings->method('getBasicPassword')->willReturn('testpass');
|
||||
|
||||
return new BcODataGateway($settings);
|
||||
}
|
||||
|
||||
public function testSearchCustomersReturnsEmptyForEmptyQuery(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->searchCustomers('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testSearchCustomersReturnsEmptyForWhitespaceQuery(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->searchCustomers(' ');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetCustomerReturnsNullForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$result = $gateway->getCustomer('');
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetContactsForCustomerReturnsEmptyForEmptyName(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getContactsForCustomer('10254', '');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForCustomerReturnsEmptyForEmptyName(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getTicketsForCustomer('10254', '');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketReturnsNullForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$result = $gateway->getTicket('');
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testSearchCustomersThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$gateway->searchCustomers('test');
|
||||
}
|
||||
|
||||
public function testGetCustomerReturnsNullWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$result = $gateway->getCustomer('10001');
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetTicketReturnsNullWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$result = $gateway->getTicket('T0001');
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testTestConnectionReturnsNotOkWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$result = $gateway->testConnection();
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('Configuration incomplete', $result['error']);
|
||||
}
|
||||
|
||||
public function testSearchCustomersRejectsODataInjectionWithParentheses(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$gateway->searchCustomers("') or true or contains(Name,'");
|
||||
}
|
||||
|
||||
public function testSearchCustomersRejectsODataInjectionWithSingleQuotes(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$gateway->searchCustomers("test' eq 'hack");
|
||||
}
|
||||
|
||||
public function testGetCustomerRejectsODataInjectionCharacters(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$gateway->getCustomer("10254' or '1'='1");
|
||||
}
|
||||
|
||||
public function testSearchCustomersAllowsGermanUmlauts(): void
|
||||
{
|
||||
// Should not throw InvalidArgumentException — umlauts are valid search characters
|
||||
// Will throw RuntimeException because cURL can't reach fake URL, which is expected
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
try {
|
||||
$gateway->searchCustomers('Müller Straße');
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->fail('Umlauts should be allowed in search queries');
|
||||
} catch (\RuntimeException) {
|
||||
// Expected: cURL fails against fake URL
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSearchCustomersAllowsNumbersAndDashes(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
try {
|
||||
$gateway->searchCustomers('10254-A');
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->fail('Numbers and dashes should be allowed in search queries');
|
||||
} catch (\RuntimeException) {
|
||||
// Expected: cURL fails against fake URL
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetTicketLogReturnsEmptyForEmptyNo(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$results = $gateway->getTicketLog('');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketLogReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getTicketLog('T26-1214');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetContactsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getContactsForCustomer('10254', 'Test GmbH');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testGetTicketsForCustomerReturnsEmptyWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$results = $gateway->getTicketsForCustomer('10254', 'Test GmbH');
|
||||
$this->assertSame([], $results);
|
||||
}
|
||||
|
||||
public function testListCustomersThrowsWhenNotConfigured(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings(false);
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$gateway->listCustomers('', '', 10, 0, 'Name', 'asc');
|
||||
}
|
||||
|
||||
public function testListCustomersRejectsInvalidCityFilterCharacters(): void
|
||||
{
|
||||
$gateway = $this->createGatewayWithSettings();
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$gateway->listCustomers('', 'Berlin)', 10, 0, 'Name', 'asc');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DebitorDetailServiceTest extends TestCase
|
||||
{
|
||||
private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorDetailService
|
||||
{
|
||||
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($isConfigured);
|
||||
|
||||
return new DebitorDetailService($gateway, $settings);
|
||||
}
|
||||
|
||||
// --- loadDetail() (legacy) ---
|
||||
|
||||
public function testLoadDetailReturnsNotFoundForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadDetail('');
|
||||
$this->assertSame('not_found', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadDetailReturnsNotConfiguredWhenBcNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadDetail('10254');
|
||||
$this->assertSame('not_configured', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadDetailReturnsNotFoundWhenCustomerMissing(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())->method('getCustomer')->with('99999')->willReturn(null);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadDetail('99999');
|
||||
$this->assertSame('not_found', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadDetailReturnsSuccessWithAllData(): void
|
||||
{
|
||||
$customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH'];
|
||||
$contacts = [['No' => 'KT0001', 'Name' => 'Max Mustermann']];
|
||||
$tickets = [['No' => 'T0001', 'Description' => 'Problem X']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer);
|
||||
$gateway->expects($this->once())->method('getContactsForCustomer')->with('10254', 'Musterfirma GmbH')->willReturn($contacts);
|
||||
$gateway->expects($this->once())->method('getTicketsForCustomer')->with('10254', 'Musterfirma GmbH')->willReturn($tickets);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadDetail('10254');
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame($customer, $result['customer']);
|
||||
$this->assertSame($contacts, $result['contacts']);
|
||||
$this->assertSame($tickets, $result['tickets']);
|
||||
}
|
||||
|
||||
public function testLoadDetailReturnsErrorOnBcConnectionFailure(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getCustomer')->willThrowException(new \RuntimeException('timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadDetail('10254');
|
||||
$this->assertSame('error', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadDetailStillSucceedsWhenContactsFail(): void
|
||||
{
|
||||
$customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH'];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer);
|
||||
$gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('contacts fail'));
|
||||
$gateway->method('getTicketsForCustomer')->willReturn([]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadDetail('10254');
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame([], $result['contacts']);
|
||||
$this->assertSame('contacts fail', $result['contactsError']);
|
||||
}
|
||||
|
||||
public function testLoadDetailStillSucceedsWhenTicketsFail(): void
|
||||
{
|
||||
$customer = ['No' => '10254', 'Name' => 'Musterfirma GmbH'];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())->method('getCustomer')->with('10254')->willReturn($customer);
|
||||
$gateway->method('getContactsForCustomer')->willReturn([]);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('tickets fail'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadDetail('10254');
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame([], $result['tickets']);
|
||||
$this->assertSame('tickets fail', $result['ticketsError']);
|
||||
}
|
||||
|
||||
// --- loadCustomer() ---
|
||||
|
||||
public function testLoadCustomerReturnsNotFoundForEmptyNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadCustomer('');
|
||||
$this->assertSame('not_found', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadCustomerReturnsNotConfiguredWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadCustomer('10001');
|
||||
$this->assertSame('not_configured', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadCustomerReturnsNotFoundWhenCustomerNull(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getCustomer')->willReturn(null);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadCustomer('99999');
|
||||
$this->assertSame('not_found', $result['status']);
|
||||
}
|
||||
|
||||
public function testLoadCustomerReturnsSuccessWithCustomerData(): void
|
||||
{
|
||||
$customer = ['No' => '10001', 'Name' => 'Test GmbH', 'Support_Type' => 'Premium'];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getCustomer')->willReturn($customer);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadCustomer('10001');
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame($customer, $result['customer']);
|
||||
$this->assertArrayNotHasKey('contacts', $result);
|
||||
$this->assertArrayNotHasKey('tickets', $result);
|
||||
}
|
||||
|
||||
public function testLoadCustomerReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getCustomer')->willThrowException(new \RuntimeException('Connection failed'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadCustomer('10001');
|
||||
|
||||
$this->assertSame('error', $result['status']);
|
||||
$this->assertSame('BC connection failed', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadContacts() ---
|
||||
|
||||
public function testLoadContactsReturnsFalseForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadContacts('', 'Test GmbH');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContactsReturnsFalseForEmptyCustomerName(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadContacts('10001', '');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContactsReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadContactsReturnsOkWithContacts(): void
|
||||
{
|
||||
$contacts = [['No' => 'KT001', 'Name' => 'Max Mustermann']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomer')->willReturn($contacts);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($contacts, $result['contacts']);
|
||||
}
|
||||
|
||||
public function testLoadContactsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getContactsForCustomer')->willThrowException(new \RuntimeException('API error'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadContacts('10001', 'Test GmbH');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('API error', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadTickets() ---
|
||||
|
||||
public function testLoadTicketsReturnsFalseForEmptyCustomerNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadTickets('', 'Test GmbH');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsReturnsFalseForEmptyCustomerName(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadTickets('10001', '');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsReturnsOkWithTickets(): void
|
||||
{
|
||||
$tickets = [['No' => 'T001', 'Description' => 'Test ticket', 'Ticket_State' => 'Open']];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willReturn($tickets);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($tickets, $result['tickets']);
|
||||
}
|
||||
|
||||
public function testLoadTicketsReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTickets('10001', 'Test GmbH');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('Timeout', $result['error']);
|
||||
}
|
||||
|
||||
// --- loadTicketLog() ---
|
||||
|
||||
public function testLoadTicketLogReturnsFalseForEmptyTicketNo(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->loadTicketLog('');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadTicketLogReturnsFalseWhenNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->loadTicketLog('T001');
|
||||
$this->assertFalse($result['ok']);
|
||||
}
|
||||
|
||||
public function testLoadTicketLogReturnsOkWithLog(): void
|
||||
{
|
||||
$log = [
|
||||
['Entry_No' => 1, 'Type' => 'Nachricht', 'Created_By' => 'NKS', 'Support_Ticket_No' => 'T001'],
|
||||
['Entry_No' => 2, 'Type' => 'Status', 'Created_By' => 'NKS', 'Support_Ticket_No' => 'T001'],
|
||||
];
|
||||
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketLog')->willReturn($log);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketLog('T001');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame($log, $result['log']);
|
||||
}
|
||||
|
||||
public function testLoadTicketLogReturnsErrorOnException(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->method('getTicketLog')->willThrowException(new \RuntimeException('API error'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->loadTicketLog('T001');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('API error', $result['error']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DebitorSearchServiceTest extends TestCase
|
||||
{
|
||||
private function createService(?BcODataGateway $gateway = null, bool $isConfigured = true): DebitorSearchService
|
||||
{
|
||||
$gateway = $gateway ?? $this->createMock(BcODataGateway::class);
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('isConfigured')->willReturn($isConfigured);
|
||||
|
||||
return new DebitorSearchService($gateway, $settings);
|
||||
}
|
||||
|
||||
public function testSearchReturnsEmptyStatusForEmptyQuery(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->search('');
|
||||
$this->assertSame('empty', $result['status']);
|
||||
$this->assertSame([], $result['results']);
|
||||
}
|
||||
|
||||
public function testSearchReturnsTooShortForSingleCharacter(): void
|
||||
{
|
||||
$service = $this->createService();
|
||||
$result = $service->search('A');
|
||||
$this->assertSame('too_short', $result['status']);
|
||||
}
|
||||
|
||||
public function testSearchReturnsNotConfiguredWhenBcNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->search('Test Company');
|
||||
$this->assertSame('not_configured', $result['status']);
|
||||
$this->assertNotEmpty($result['error']);
|
||||
}
|
||||
|
||||
public function testSearchReturnsSuccessWithResults(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())
|
||||
->method('searchCustomers')
|
||||
->with('Musterfirma')
|
||||
->willReturn([
|
||||
['No' => '10254', 'Name' => 'Musterfirma GmbH', 'City' => 'Berlin'],
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->search('Musterfirma');
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertCount(1, $result['results']);
|
||||
$this->assertSame('10254', $result['results'][0]['No']);
|
||||
}
|
||||
|
||||
public function testSearchReturnsNoResultsWhenEmpty(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())
|
||||
->method('searchCustomers')
|
||||
->willReturn([]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->search('xyz-nonexistent');
|
||||
$this->assertSame('no_results', $result['status']);
|
||||
$this->assertSame([], $result['results']);
|
||||
}
|
||||
|
||||
public function testSearchReturnsErrorOnBcConnectionFailure(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())
|
||||
->method('searchCustomers')
|
||||
->willThrowException(new \RuntimeException('Connection refused'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->search('Musterfirma');
|
||||
$this->assertSame('error', $result['status']);
|
||||
$this->assertNotEmpty($result['error']);
|
||||
}
|
||||
|
||||
public function testListForGridReturnsNotConfiguredWhenBcNotConfigured(): void
|
||||
{
|
||||
$service = $this->createService(null, false);
|
||||
$result = $service->listForGrid([]);
|
||||
|
||||
$this->assertSame('not_configured', $result['status']);
|
||||
$this->assertSame([], $result['rows']);
|
||||
$this->assertSame(0, $result['total']);
|
||||
}
|
||||
|
||||
public function testListForGridReturnsSuccessWithRowsAndTotal(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())
|
||||
->method('listCustomers')
|
||||
->with('Muster', 'Berlin', 10, 0, 'Name', 'asc')
|
||||
->willReturn([
|
||||
'rows' => [
|
||||
['No' => '10000', 'Name' => 'Muster GmbH', 'City' => 'Berlin'],
|
||||
],
|
||||
'total' => 42,
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->listForGrid([
|
||||
'search' => 'Muster',
|
||||
'city' => 'Berlin',
|
||||
'limit' => 10,
|
||||
'offset' => 0,
|
||||
'order' => 'Name',
|
||||
'dir' => 'asc',
|
||||
]);
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertCount(1, $result['rows']);
|
||||
$this->assertSame(42, $result['total']);
|
||||
}
|
||||
|
||||
public function testListForGridReturnsErrorWhenGatewayFails(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())
|
||||
->method('listCustomers')
|
||||
->willThrowException(new \RuntimeException('Connection refused'));
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->listForGrid(['search' => 'Muster']);
|
||||
|
||||
$this->assertSame('error', $result['status']);
|
||||
$this->assertSame([], $result['rows']);
|
||||
$this->assertSame(0, $result['total']);
|
||||
}
|
||||
|
||||
public function testListForGridNormalizesLimitAndOffsetBounds(): void
|
||||
{
|
||||
$gateway = $this->createMock(BcODataGateway::class);
|
||||
$gateway->expects($this->once())
|
||||
->method('listCustomers')
|
||||
->with('', '', 100, 0, 'Name', 'asc')
|
||||
->willReturn([
|
||||
'rows' => [],
|
||||
'total' => 0,
|
||||
]);
|
||||
|
||||
$service = $this->createService($gateway);
|
||||
$result = $service->listForGrid([
|
||||
'limit' => 999,
|
||||
'offset' => -5,
|
||||
'order' => 'Name',
|
||||
'dir' => 'asc',
|
||||
]);
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HelpdeskOAuthTokenServiceTest extends TestCase
|
||||
{
|
||||
private function createService(?string $authMode = null, ?string $cachedToken = null): HelpdeskOAuthTokenService
|
||||
{
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn($authMode ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC);
|
||||
|
||||
$tokenRepo = $this->createMock(HelpdeskTokenRepository::class);
|
||||
$tokenRepo->method('findValidToken')->willReturn($cachedToken);
|
||||
|
||||
return new HelpdeskOAuthTokenService($settings, $tokenRepo);
|
||||
}
|
||||
|
||||
public function testReturnsNullWhenAuthModeIsBasic(): void
|
||||
{
|
||||
$service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
|
||||
$this->assertNull($service->getAccessToken(1));
|
||||
}
|
||||
|
||||
public function testReturnsCachedTokenWhenAvailable(): void
|
||||
{
|
||||
$service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, 'cached-bearer-token');
|
||||
$this->assertSame('cached-bearer-token', $service->getAccessToken(1));
|
||||
}
|
||||
|
||||
public function testReturnsNullWhenNoCachedTokenExists(): void
|
||||
{
|
||||
$service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_OAUTH2, null);
|
||||
$this->assertNull($service->getAccessToken(1));
|
||||
}
|
||||
|
||||
public function testReturnsNullForBasicAuthRegardlessOfTenantId(): void
|
||||
{
|
||||
$service = $this->createService(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
|
||||
$this->assertNull($service->getAccessToken(99));
|
||||
}
|
||||
|
||||
public function testDoesNotQueryCacheWhenAuthModeIsBasic(): void
|
||||
{
|
||||
$settings = $this->createMock(HelpdeskSettingsGateway::class);
|
||||
$settings->method('getAuthMode')->willReturn(HelpdeskSettingsGateway::AUTH_MODE_BASIC);
|
||||
|
||||
$tokenRepo = $this->createMock(HelpdeskTokenRepository::class);
|
||||
$tokenRepo->expects($this->never())->method('findValidToken');
|
||||
|
||||
$service = new HelpdeskOAuthTokenService($settings, $tokenRepo);
|
||||
$service->getAccessToken(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Repository\Settings\SettingRepositoryInterface;
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HelpdeskSettingsGatewayTest extends TestCase
|
||||
{
|
||||
private function createGateway(?SettingRepositoryInterface $settings = null, ?SettingsCryptoGatewayInterface $crypto = null): HelpdeskSettingsGateway
|
||||
{
|
||||
$settings = $settings ?? $this->createMock(SettingRepositoryInterface::class);
|
||||
$crypto = $crypto ?? $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
|
||||
return new HelpdeskSettingsGateway(new SettingsMetadataGateway($settings), $crypto);
|
||||
}
|
||||
|
||||
public function testGetAuthModeDefaultsToBasic(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturn(null);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertSame('basic', $gateway->getAuthMode());
|
||||
}
|
||||
|
||||
public function testGetAuthModeReturnsOAuth2WhenSet(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturn('oauth2');
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertSame('oauth2', $gateway->getAuthMode());
|
||||
}
|
||||
|
||||
public function testSetAuthModeRejectsInvalidMode(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->never())->method('set');
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertFalse($gateway->setAuthMode('invalid'));
|
||||
}
|
||||
|
||||
public function testSetODataBaseUrlRejectsHttp(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->never())->method('set');
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertFalse($gateway->setODataBaseUrl('http://insecure.example.com'));
|
||||
}
|
||||
|
||||
public function testSetODataBaseUrlAcceptsHttps(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(HelpdeskSettingsGateway::KEY_ODATA_BASE_URL, 'https://bc.example.com/OData', 'helpdesk.bc_odata_base_url')
|
||||
->willReturn(true);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertTrue($gateway->setODataBaseUrl('https://bc.example.com/OData'));
|
||||
}
|
||||
|
||||
public function testGetBasicPasswordReturnsNullWhenDecryptFails(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturn('encrypted-value');
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->method('decryptString')->willThrowException(new \RuntimeException('bad key'));
|
||||
|
||||
$gateway = $this->createGateway($settings, $crypto);
|
||||
$this->assertNull($gateway->getBasicPassword());
|
||||
}
|
||||
|
||||
public function testSetBasicPasswordEncryptsAndPersists(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, 'enc-pw', 'helpdesk.bc_basic_password_enc')
|
||||
->willReturn(true);
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->expects($this->once())
|
||||
->method('encryptString')
|
||||
->with('my-password')
|
||||
->willReturn('enc-pw');
|
||||
|
||||
$gateway = $this->createGateway($settings, $crypto);
|
||||
$this->assertTrue($gateway->setBasicPassword('my-password'));
|
||||
}
|
||||
|
||||
public function testSetBasicPasswordClearsWhenEmpty(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->once())
|
||||
->method('set')
|
||||
->with(HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, null, 'helpdesk.bc_basic_password_enc')
|
||||
->willReturn(true);
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->expects($this->never())->method('encryptString');
|
||||
|
||||
$gateway = $this->createGateway($settings, $crypto);
|
||||
$this->assertTrue($gateway->setBasicPassword(''));
|
||||
}
|
||||
|
||||
public function testSetBasicPasswordReturnsFalseOnEncryptionFailure(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->never())->method('set');
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->method('encryptString')->willThrowException(new \RuntimeException('encryption failed'));
|
||||
|
||||
$gateway = $this->createGateway($settings, $crypto);
|
||||
$this->assertFalse($gateway->setBasicPassword('my-password'));
|
||||
}
|
||||
|
||||
public function testBuildEntityUrlConstructsCorrectUrl(): void
|
||||
{
|
||||
$callMap = [
|
||||
[HelpdeskSettingsGateway::KEY_ODATA_BASE_URL, 'https://bc.example.com/ODataV4'],
|
||||
[HelpdeskSettingsGateway::KEY_COMPANY_NAME, 'Test Company'],
|
||||
];
|
||||
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnCallback(static function (string $key) use ($callMap): ?string {
|
||||
foreach ($callMap as [$k, $v]) {
|
||||
if ($k === $key) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$url = $gateway->buildEntityUrl('Integration_Customer_Card');
|
||||
$this->assertSame("https://bc.example.com/ODataV4/Company('Test%20Company')/Integration_Customer_Card", $url);
|
||||
}
|
||||
|
||||
public function testValidateConfigurationReportsMissingBasicCredentials(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturn(null);
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$missing = $gateway->validateConfiguration();
|
||||
$this->assertNotEmpty($missing);
|
||||
$this->assertContains('BC Basic Auth User', $missing);
|
||||
$this->assertContains('BC Basic Auth Password', $missing);
|
||||
}
|
||||
|
||||
public function testIsConfiguredReturnsTrueWhenBasicCredentialsPresent(): void
|
||||
{
|
||||
$callMap = [
|
||||
[HelpdeskSettingsGateway::KEY_AUTH_MODE, 'basic'],
|
||||
[HelpdeskSettingsGateway::KEY_BASIC_USER, 'testuser'],
|
||||
[HelpdeskSettingsGateway::KEY_BASIC_PASSWORD_ENC, 'encrypted'],
|
||||
];
|
||||
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->method('getValue')->willReturnCallback(static function (string $key) use ($callMap): ?string {
|
||||
foreach ($callMap as [$k, $v]) {
|
||||
if ($k === $key) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->method('decryptString')->willReturn('decrypted-password');
|
||||
|
||||
$gateway = $this->createGateway($settings, $crypto);
|
||||
$this->assertTrue($gateway->isConfigured());
|
||||
}
|
||||
|
||||
public function testSetOAuthTokenEndpointRejectsHttp(): void
|
||||
{
|
||||
$settings = $this->createMock(SettingRepositoryInterface::class);
|
||||
$settings->expects($this->never())->method('set');
|
||||
|
||||
$gateway = $this->createGateway($settings);
|
||||
$this->assertFalse($gateway->setOAuthTokenEndpoint('http://login.microsoftonline.com/token'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
|
||||
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for HelpdeskTokenRepository edge-case validation paths.
|
||||
*
|
||||
* Note: DB-dependent paths (findValidToken with actual DB, storeToken INSERT)
|
||||
* require integration tests against a real database. These unit tests cover
|
||||
* the input validation and error handling that runs before DB access.
|
||||
*/
|
||||
class HelpdeskTokenRepositoryTest extends TestCase
|
||||
{
|
||||
private function createRepository(?SettingsCryptoGatewayInterface $crypto = null): HelpdeskTokenRepository
|
||||
{
|
||||
$crypto = $crypto ?? $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
|
||||
return new HelpdeskTokenRepository($crypto);
|
||||
}
|
||||
|
||||
public function testFindValidTokenReturnsNullForZeroTenantId(): void
|
||||
{
|
||||
$repo = $this->createRepository();
|
||||
$this->assertNull($repo->findValidToken(0));
|
||||
}
|
||||
|
||||
public function testFindValidTokenReturnsNullForNegativeTenantId(): void
|
||||
{
|
||||
$repo = $this->createRepository();
|
||||
$this->assertNull($repo->findValidToken(-1));
|
||||
}
|
||||
|
||||
public function testStoreTokenReturnsFalseForZeroTenantId(): void
|
||||
{
|
||||
$repo = $this->createRepository();
|
||||
$this->assertFalse($repo->storeToken(0, 'token', new \DateTimeImmutable('+1 hour')));
|
||||
}
|
||||
|
||||
public function testStoreTokenReturnsFalseForNegativeTenantId(): void
|
||||
{
|
||||
$repo = $this->createRepository();
|
||||
$this->assertFalse($repo->storeToken(-5, 'token', new \DateTimeImmutable('+1 hour')));
|
||||
}
|
||||
|
||||
public function testStoreTokenReturnsFalseForEmptyAccessToken(): void
|
||||
{
|
||||
$repo = $this->createRepository();
|
||||
$this->assertFalse($repo->storeToken(1, '', new \DateTimeImmutable('+1 hour')));
|
||||
}
|
||||
|
||||
public function testStoreTokenReturnsFalseWhenEncryptionFails(): void
|
||||
{
|
||||
$crypto = $this->createMock(SettingsCryptoGatewayInterface::class);
|
||||
$crypto->method('encryptString')->willThrowException(new \RuntimeException('encryption failed'));
|
||||
|
||||
$repo = $this->createRepository($crypto);
|
||||
$this->assertFalse($repo->storeToken(1, 'valid-token', new \DateTimeImmutable('+1 hour')));
|
||||
}
|
||||
}
|
||||
137
modules/helpdesk/web/css/helpdesk.css
Normal file
137
modules/helpdesk/web/css/helpdesk.css
Normal file
@@ -0,0 +1,137 @@
|
||||
@layer pages {
|
||||
/* Clickable rows — shared between search results and ticket list */
|
||||
.app-clickable-row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.app-clickable-row:hover,
|
||||
.app-clickable-row:focus-visible {
|
||||
background: color-mix(in srgb, var(--app-contrast, #000) 4%, transparent);
|
||||
}
|
||||
|
||||
.app-clickable-row:focus-visible {
|
||||
outline: 2px solid var(--app-primary, #0d6efd);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Async loading spinner */
|
||||
.helpdesk-tab-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: var(--app-muted, #6c757d);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.helpdesk-spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid var(--app-border, #dee2e6);
|
||||
border-top-color: var(--app-primary, #0d6efd);
|
||||
border-radius: 50%;
|
||||
animation: helpdesk-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes helpdesk-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Overview tab — header links */
|
||||
.app-stats-table-header .helpdesk-tab-link {
|
||||
float: right;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-stats-table-header .helpdesk-tab-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* KPI tile wrappers — ensure data-kpi spans don't break flex layout */
|
||||
.app-tiles > [data-kpi] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Activity timeline */
|
||||
.helpdesk-timeline {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--app-muted, #6c757d);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.75rem 0 0.375rem;
|
||||
border-bottom: 1px solid var(--app-border, #dee2e6);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-date:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-icon {
|
||||
flex: 0 0 1.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-entry[data-variant="message"] .helpdesk-timeline-icon {
|
||||
background: #d9e8fb;
|
||||
color: #1a56db;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-entry[data-variant="status"] .helpdesk-timeline-icon {
|
||||
background: #fde8d8;
|
||||
color: #b35c14;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-entry[data-variant="forward"] .helpdesk-timeline-icon {
|
||||
background: #f3e8ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-entry[data-variant="file"] .helpdesk-timeline-icon {
|
||||
background: #d9f2e6;
|
||||
color: #1f6a3a;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-entry[data-variant="other"] .helpdesk-timeline-icon {
|
||||
background: var(--app-border, #dee2e6);
|
||||
color: var(--app-muted, #6c757d);
|
||||
}
|
||||
|
||||
.helpdesk-timeline-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-body strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.helpdesk-timeline-time {
|
||||
margin-left: 0.5rem;
|
||||
color: var(--app-muted, #6c757d);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
29
modules/helpdesk/web/js/helpdesk-clickable-rows.js
Normal file
29
modules/helpdesk/web/js/helpdesk-clickable-rows.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared clickable-row behaviour for helpdesk tables.
|
||||
* Navigates to `data-href` on click or Enter/Space key.
|
||||
*/
|
||||
export function initClickableRows() {
|
||||
const rows = document.querySelectorAll('.app-clickable-row[data-href]');
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener('click', (event) => {
|
||||
if (event.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
const href = row.dataset.href;
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
|
||||
row.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
const href = row.dataset.href;
|
||||
if (href) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
571
modules/helpdesk/web/js/helpdesk-detail.js
Normal file
571
modules/helpdesk/web/js/helpdesk-detail.js
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Helpdesk debtor detail page — async data loading + dashboard rendering.
|
||||
*
|
||||
* - Overview tab: open tickets, recent activity, top contacts (manual HTML)
|
||||
* - Tickets tab: Grid.js server-side grid with search, status filter, pagination
|
||||
* - Contacts tab: full contact table (manual HTML)
|
||||
* - KPI tile updates from overview data
|
||||
*/
|
||||
import { initClickableRows } from './helpdesk-clickable-rows.js';
|
||||
import { initStandardListPage } from '/js/core/app-grid-factory.js';
|
||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-customer-no]');
|
||||
if (container) {
|
||||
init(container);
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
const customerNo = container.dataset.customerNo || '';
|
||||
const customerName = container.dataset.customerName || '';
|
||||
const contactsUrl = container.dataset.contactsUrl || '';
|
||||
const ticketsUrl = container.dataset.ticketsUrl || '';
|
||||
const ticketBaseUrl = container.dataset.ticketBaseUrl || '';
|
||||
|
||||
if (!customerNo || !customerName) return;
|
||||
|
||||
// i18n
|
||||
const i18nEl = document.getElementById('helpdesk-i18n');
|
||||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
||||
const t = (key) => i18n[key] || key;
|
||||
|
||||
// State — promise caching ensures only 1 request per resource
|
||||
let contactsPromise = null;
|
||||
let overviewTicketsPromise = null;
|
||||
let ticketCategoriesPromise = null;
|
||||
let contactsRendered = { overview: false, full: false };
|
||||
let ticketsGridInitialized = false;
|
||||
let ticketsGridInitializing = false;
|
||||
|
||||
// Date formatter
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
// --- Fetch helpers ---
|
||||
|
||||
function fetchContacts() {
|
||||
if (!contactsPromise) {
|
||||
const params = new URLSearchParams({ customerNo, customerName });
|
||||
contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.catch(() => ({ ok: false, error: 'Network error' }));
|
||||
}
|
||||
return contactsPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all tickets for overview tab (uses the grid data endpoint with high limit).
|
||||
* Returns { data: [...], total: N } format from gridJsonDataResult.
|
||||
*/
|
||||
function fetchOverviewTickets() {
|
||||
if (!overviewTicketsPromise) {
|
||||
const params = new URLSearchParams({
|
||||
customerNo,
|
||||
customerName,
|
||||
limit: '200',
|
||||
offset: '0',
|
||||
order: 'Created_On',
|
||||
dir: 'desc',
|
||||
});
|
||||
overviewTicketsPromise = fetch(ticketsUrl + '?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.catch(() => ({ data: [], total: 0 }));
|
||||
}
|
||||
return overviewTicketsPromise;
|
||||
}
|
||||
|
||||
function fetchTicketCategories(config, appBase) {
|
||||
if (!ticketCategoriesPromise) {
|
||||
const categoryOptionsUrl = config.categoryOptionsUrl || '';
|
||||
if (!categoryOptionsUrl) {
|
||||
ticketCategoriesPromise = Promise.resolve({ ok: false, categories: [] });
|
||||
return ticketCategoriesPromise;
|
||||
}
|
||||
|
||||
const url = new URL(categoryOptionsUrl, appBase);
|
||||
url.searchParams.set('customerNo', config.customerNo || customerNo);
|
||||
url.searchParams.set('customerName', config.customerName || customerName);
|
||||
|
||||
ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.catch(() => ({ ok: false, categories: [] }));
|
||||
}
|
||||
|
||||
return ticketCategoriesPromise;
|
||||
}
|
||||
|
||||
function hydrateTicketCategoryFilter(config, categories) {
|
||||
const categoryFilter = document.querySelector('#helpdesk-ticket-category-filter');
|
||||
if (!(categoryFilter instanceof HTMLSelectElement)) return;
|
||||
|
||||
const uniqueCategories = Array.from(new Set(
|
||||
(Array.isArray(categories) ? categories : [])
|
||||
.map(item => String(item || '').trim())
|
||||
.filter(Boolean),
|
||||
)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
const currentValue = String(categoryFilter.value || '');
|
||||
categoryFilter.innerHTML = '';
|
||||
|
||||
const allOption = document.createElement('option');
|
||||
allOption.value = '';
|
||||
allOption.textContent = t('All');
|
||||
categoryFilter.appendChild(allOption);
|
||||
|
||||
const categoryOptionMap = {};
|
||||
for (const category of uniqueCategories) {
|
||||
const option = document.createElement('option');
|
||||
option.value = category;
|
||||
option.textContent = category;
|
||||
categoryFilter.appendChild(option);
|
||||
categoryOptionMap[category] = category;
|
||||
}
|
||||
|
||||
if (currentValue !== '' && Object.prototype.hasOwnProperty.call(categoryOptionMap, currentValue)) {
|
||||
categoryFilter.value = currentValue;
|
||||
} else {
|
||||
categoryFilter.value = '';
|
||||
}
|
||||
|
||||
if (!config.filterChipMeta || typeof config.filterChipMeta !== 'object') {
|
||||
config.filterChipMeta = {};
|
||||
}
|
||||
|
||||
const currentCategoryMeta = (config.filterChipMeta.category && typeof config.filterChipMeta.category === 'object')
|
||||
? config.filterChipMeta.category
|
||||
: {};
|
||||
|
||||
config.filterChipMeta.category = {
|
||||
...currentCategoryMeta,
|
||||
type: 'select',
|
||||
default: '',
|
||||
options: categoryOptionMap,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Rendering helpers ---
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(raw) {
|
||||
if (!raw || raw === '0001-01-01T00:00:00Z') return '';
|
||||
try {
|
||||
return dateFmt.format(new Date(raw));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function stateVariant(state) {
|
||||
switch (state) {
|
||||
case 'Offen': case 'Open': return 'primary';
|
||||
case 'In Bearbeitung': case 'In Progress': return 'warning';
|
||||
case 'Erledigt': case 'Resolved': case 'Closed': case 'Geschlossen': return 'success';
|
||||
default: return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function isOpen(state) {
|
||||
return !['Erledigt', 'Resolved', 'Closed', 'Geschlossen'].includes(state);
|
||||
}
|
||||
|
||||
function ticketUrl(ticketNo) {
|
||||
return ticketBaseUrl + encodeURIComponent(ticketNo) + '?from=' + encodeURIComponent(customerNo);
|
||||
}
|
||||
|
||||
function emptyState(message, hint) {
|
||||
return `<div class="app-empty-state app-empty-state-compact">
|
||||
<p class="app-empty-state-message">${esc(message)}</p>
|
||||
${hint ? `<p class="app-empty-state-hint">${esc(hint)}</p>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function errorNotice(message) {
|
||||
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
|
||||
}
|
||||
|
||||
// --- Ticket table renderer (compact, for overview) ---
|
||||
|
||||
function renderTicketTableCompact(tickets) {
|
||||
if (tickets.length === 0) return emptyState(t('No open tickets'), '');
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
||||
html += `<th>${esc(t('Description'))}</th>`;
|
||||
html += `<th>${esc(t('Status'))}</th>`;
|
||||
html += `<th>${esc(t('Created'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const tk of tickets) {
|
||||
const no = tk.No || '';
|
||||
const url = ticketUrl(no);
|
||||
const state = tk.Ticket_State || '';
|
||||
html += `<tr class="app-clickable-row" data-href="${esc(url)}" tabindex="0">`;
|
||||
html += `<td><a href="${esc(url)}">${esc(no)}</a></td>`;
|
||||
html += `<td>${esc(tk.Description || '')}</td>`;
|
||||
html += `<td><span class="badge" data-variant="${stateVariant(state)}">${esc(state)}</span></td>`;
|
||||
html += `<td>${esc(formatDate(tk.Created_On))}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// --- Recent activity renderer ---
|
||||
|
||||
function renderRecentActivity(tickets) {
|
||||
const sorted = [...tickets].sort((a, b) => {
|
||||
const da = a.Last_Activity_Date || '';
|
||||
const db = b.Last_Activity_Date || '';
|
||||
return db.localeCompare(da);
|
||||
}).slice(0, 5);
|
||||
|
||||
if (sorted.length === 0) return emptyState(t('No recent activity found.'), '');
|
||||
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Ticket No.'))}</th>`;
|
||||
html += `<th>${esc(t('Description'))}</th>`;
|
||||
html += `<th>${esc(t('Status'))}</th>`;
|
||||
html += `<th>${esc(t('Last activity'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const tk of sorted) {
|
||||
const no = tk.No || '';
|
||||
const url = ticketUrl(no);
|
||||
const state = tk.Ticket_State || '';
|
||||
html += `<tr class="app-clickable-row" data-href="${esc(url)}" tabindex="0">`;
|
||||
html += `<td><a href="${esc(url)}">${esc(no)}</a></td>`;
|
||||
html += `<td>${esc(tk.Description || '')}</td>`;
|
||||
html += `<td><span class="badge" data-variant="${stateVariant(state)}">${esc(state)}</span></td>`;
|
||||
html += `<td>${esc(formatDate(tk.Last_Activity_Date))}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// --- Contact table renderers ---
|
||||
|
||||
function renderContactTableCompact(contacts) {
|
||||
if (contacts.length === 0) {
|
||||
return emptyState(t('No contacts found'), '');
|
||||
}
|
||||
const top5 = contacts.slice(0, 5);
|
||||
let html = '<table><thead><tr>';
|
||||
html += `<th>${esc(t('Name'))}</th>`;
|
||||
html += `<th>${esc(t('Job title'))}</th>`;
|
||||
html += `<th>${esc(t('E-Mail'))}</th>`;
|
||||
html += `<th>${esc(t('Phone'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const c of top5) {
|
||||
html += '<tr>';
|
||||
html += `<td>${esc(c.Name || '')}</td>`;
|
||||
html += `<td>${esc(c.Job_Title || '')}</td>`;
|
||||
html += `<td>${esc(c.E_Mail || '')}</td>`;
|
||||
html += `<td>${esc(c.Phone_No || '')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderContactTableFull(contacts) {
|
||||
if (contacts.length === 0) {
|
||||
return emptyState(t('No contacts found'), t('No contacts are linked to this debtor in Business Central.'));
|
||||
}
|
||||
let html = '<div class="app-stats-table"><div class="app-stats-table-header">';
|
||||
html += `${esc(t('Contacts'))} (${contacts.length})</div>`;
|
||||
html += '<table role="grid"><thead><tr>';
|
||||
html += `<th>${esc(t('No.'))}</th>`;
|
||||
html += `<th>${esc(t('Name'))}</th>`;
|
||||
html += `<th>${esc(t('Type'))}</th>`;
|
||||
html += `<th>${esc(t('Job title'))}</th>`;
|
||||
html += `<th>${esc(t('E-Mail'))}</th>`;
|
||||
html += `<th>${esc(t('Phone'))}</th>`;
|
||||
html += `<th>${esc(t('Mobile'))}</th>`;
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const c of contacts) {
|
||||
html += '<tr>';
|
||||
html += `<td>${esc(c.No || '')}</td>`;
|
||||
html += `<td>${esc(c.Name || '')}</td>`;
|
||||
html += `<td>${esc(c.Type || '')}</td>`;
|
||||
html += `<td>${esc(c.Job_Title || '')}</td>`;
|
||||
html += `<td>${esc(c.E_Mail || '')}</td>`;
|
||||
html += `<td>${esc(c.Phone_No || '')}</td>`;
|
||||
html += `<td>${esc(c.Mobile_Phone_No || '')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// --- KPI tile updates ---
|
||||
|
||||
function updateKpi(kpiKey, value) {
|
||||
const wrapper = container.querySelector(`[data-kpi="${kpiKey}"]`);
|
||||
if (!wrapper) return;
|
||||
const countEl = wrapper.querySelector('.app-tile-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tab badge updates ---
|
||||
|
||||
function updateBadge(id, count) {
|
||||
const badge = document.getElementById(id);
|
||||
if (badge) badge.textContent = String(count);
|
||||
}
|
||||
|
||||
// --- Show/hide loading states ---
|
||||
|
||||
function showLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.hidden = false;
|
||||
}
|
||||
|
||||
function hideLoading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.hidden = true;
|
||||
}
|
||||
|
||||
function showContent(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.hidden = false;
|
||||
}
|
||||
|
||||
// --- Tab switch links ---
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('[data-switch-tab]');
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
const tabName = link.dataset.switchTab;
|
||||
const tabButton = container.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (tabButton) tabButton.click();
|
||||
});
|
||||
|
||||
// --- Grid.js tickets tab ---
|
||||
|
||||
async function initTicketsGrid() {
|
||||
if (ticketsGridInitialized || ticketsGridInitializing) return;
|
||||
ticketsGridInitializing = true;
|
||||
|
||||
const config = readPageConfig('helpdesk-tickets');
|
||||
if (!config) {
|
||||
ticketsGridInitializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = config.labels || {};
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
ticketsGridInitializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseEl = document.querySelector('base');
|
||||
const appBase = (baseEl && baseEl.href) ? baseEl.href : '/';
|
||||
|
||||
const gridDataUrl = new URL(config.dataUrl || '', appBase);
|
||||
gridDataUrl.searchParams.set('customerNo', config.customerNo || customerNo);
|
||||
gridDataUrl.searchParams.set('customerName', config.customerName || customerName);
|
||||
|
||||
try {
|
||||
const categoryResponse = await fetchTicketCategories(config, appBase);
|
||||
hydrateTicketCategoryFilter(config, categoryResponse?.categories || []);
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-tickets-grid',
|
||||
dataUrl: gridDataUrl.toString(),
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.ticketNo || 'Ticket No.',
|
||||
sort: true,
|
||||
formatter: (cell) => gridjs.html(`<a href="${cell.url}">${cell.no}</a>`),
|
||||
},
|
||||
{ name: labels.description || 'Description', sort: false },
|
||||
{
|
||||
name: labels.status || 'Status',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
if (!cell || typeof cell !== 'object') return gridjs.html('-');
|
||||
return gridjs.html(`<span class="badge" data-variant="${cell.variant || 'neutral'}">${cell.label || '-'}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.category || 'Category', sort: false },
|
||||
{ name: labels.support || 'Support', sort: false },
|
||||
{ name: labels.created || 'Created', sort: true },
|
||||
{ name: labels.lastActivity || 'Last activity', sort: true },
|
||||
{ name: 'ticket_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', null, 'Ticket_State', null, null, 'Created_On', 'Last_Activity_Date', null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ no: row.No || '', url: row.ticket_url || '#' },
|
||||
row.Description || '',
|
||||
{ variant: row.state_variant || 'neutral', label: row.Ticket_State || '' },
|
||||
row.Category_1_Description || '',
|
||||
row.Support_User_Name || '',
|
||||
row.created_formatted || '',
|
||||
row.last_activity_formatted || '',
|
||||
row.ticket_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
actions: { enabled: false },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => {
|
||||
const url = rowData?.cells?.[7]?.data;
|
||||
return url || '';
|
||||
},
|
||||
},
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
};
|
||||
|
||||
initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-ticket-search'],
|
||||
},
|
||||
});
|
||||
ticketsGridInitialized = true;
|
||||
} finally {
|
||||
ticketsGridInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render overview tab ---
|
||||
|
||||
async function renderOverview() {
|
||||
showLoading('overview-loading');
|
||||
|
||||
const [ticketsResult, contactsResult] = await Promise.all([
|
||||
fetchOverviewTickets(),
|
||||
fetchContacts(),
|
||||
]);
|
||||
|
||||
hideLoading('overview-loading');
|
||||
showContent('overview-content');
|
||||
|
||||
// Extract tickets from grid data format { data: [...], total: N }
|
||||
const allTickets = ticketsResult.data || [];
|
||||
|
||||
// Open tickets section
|
||||
const openTicketsEl = document.getElementById('overview-open-tickets');
|
||||
if (openTicketsEl) {
|
||||
if (!Array.isArray(allTickets) || allTickets.length === 0) {
|
||||
openTicketsEl.innerHTML = emptyState(t('No open tickets'), '');
|
||||
} else {
|
||||
const openTickets = allTickets.filter(tk => isOpen(tk.Ticket_State || ''));
|
||||
openTicketsEl.innerHTML = renderTicketTableCompact(openTickets.slice(0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Recent activity section
|
||||
const recentEl = document.getElementById('overview-recent-activity');
|
||||
if (recentEl) {
|
||||
if (!Array.isArray(allTickets) || allTickets.length === 0) {
|
||||
recentEl.innerHTML = emptyState(t('No recent activity found.'), '');
|
||||
} else {
|
||||
recentEl.innerHTML = renderRecentActivity(allTickets);
|
||||
}
|
||||
}
|
||||
|
||||
// Top contacts section
|
||||
const contactsEl = document.getElementById('overview-contacts');
|
||||
if (contactsEl) {
|
||||
if (!contactsResult.ok) {
|
||||
contactsEl.innerHTML = errorNotice(t('Could not load contacts.'));
|
||||
} else {
|
||||
contactsEl.innerHTML = renderContactTableCompact(contactsResult.contacts || []);
|
||||
}
|
||||
}
|
||||
|
||||
// Update KPI tiles
|
||||
if (Array.isArray(allTickets)) {
|
||||
const openCount = allTickets.filter(tk => isOpen(tk.Ticket_State || '')).length;
|
||||
updateKpi('open-tickets', String(openCount));
|
||||
|
||||
// Last activity date — find max Last_Activity_Date
|
||||
let maxDate = '';
|
||||
for (const tk of allTickets) {
|
||||
const d = tk.Last_Activity_Date || '';
|
||||
if (d > maxDate && d !== '0001-01-01T00:00:00Z') maxDate = d;
|
||||
}
|
||||
updateKpi('last-activity', maxDate ? formatDate(maxDate) : '—');
|
||||
|
||||
// Update tickets tab badge
|
||||
const totalTickets = ticketsResult.total ?? allTickets.length;
|
||||
updateBadge('tab-badge-tickets', totalTickets);
|
||||
}
|
||||
|
||||
if (contactsResult.ok) {
|
||||
const contacts = contactsResult.contacts || [];
|
||||
updateKpi('contacts', String(contacts.length));
|
||||
updateBadge('tab-badge-contacts', contacts.length);
|
||||
contactsRendered.overview = true;
|
||||
}
|
||||
|
||||
// Init clickable rows for newly rendered content
|
||||
initClickableRows();
|
||||
}
|
||||
|
||||
// --- Render full contacts tab ---
|
||||
|
||||
async function renderContactsTab() {
|
||||
if (contactsRendered.full) return;
|
||||
|
||||
showLoading('contacts-loading');
|
||||
const result = await fetchContacts();
|
||||
hideLoading('contacts-loading');
|
||||
|
||||
const contentEl = document.getElementById('contacts-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
if (!result.ok) {
|
||||
contentEl.innerHTML = errorNotice(t('Could not load contacts.'));
|
||||
} else {
|
||||
contentEl.innerHTML = renderContactTableFull(result.contacts || []);
|
||||
updateBadge('tab-badge-contacts', (result.contacts || []).length);
|
||||
}
|
||||
|
||||
showContent('contacts-content');
|
||||
contactsRendered.full = true;
|
||||
initClickableRows();
|
||||
}
|
||||
|
||||
// --- Tab change observer ---
|
||||
|
||||
// Watch for tab panel visibility changes to trigger lazy rendering
|
||||
const tabPanels = container.querySelectorAll('[data-tab-panel]');
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const panel of tabPanels) {
|
||||
if (panel.hidden) continue;
|
||||
const tabName = panel.dataset.tabPanel;
|
||||
if (tabName === 'tickets' && !ticketsGridInitialized) {
|
||||
initTicketsGrid();
|
||||
} else if (tabName === 'contacts' && !contactsRendered.full) {
|
||||
renderContactsTab();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const panel of tabPanels) {
|
||||
observer.observe(panel, { attributes: true, attributeFilter: ['hidden'] });
|
||||
}
|
||||
|
||||
// --- Initial load ---
|
||||
|
||||
// Start loading immediately — overview is the default tab
|
||||
renderOverview();
|
||||
}
|
||||
6
modules/helpdesk/web/js/helpdesk-search.js
Normal file
6
modules/helpdesk/web/js/helpdesk-search.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Helpdesk search page — clickable row navigation.
|
||||
*/
|
||||
import { initClickableRows } from './helpdesk-clickable-rows.js';
|
||||
|
||||
initClickableRows();
|
||||
77
modules/helpdesk/web/js/helpdesk-settings.js
Normal file
77
modules/helpdesk/web/js/helpdesk-settings.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Helpdesk settings page — auth mode toggle and connection test.
|
||||
*/
|
||||
|
||||
// Toggle OAuth2 vs Basic Auth field visibility
|
||||
const authModeSelect = document.getElementById('auth_mode');
|
||||
const basicFields = document.getElementById('helpdesk-basic-auth-fields');
|
||||
const oauth2Fields = document.getElementById('helpdesk-oauth2-fields');
|
||||
|
||||
const toggleAuthFields = () => {
|
||||
if (!authModeSelect || !basicFields || !oauth2Fields) {
|
||||
return;
|
||||
}
|
||||
const isOAuth2 = authModeSelect.value === 'oauth2';
|
||||
basicFields.style.display = isOAuth2 ? 'none' : '';
|
||||
oauth2Fields.style.display = isOAuth2 ? '' : 'none';
|
||||
};
|
||||
|
||||
if (authModeSelect) {
|
||||
authModeSelect.addEventListener('change', toggleAuthFields);
|
||||
toggleAuthFields();
|
||||
}
|
||||
|
||||
// Connection test button
|
||||
const testButton = document.getElementById('helpdesk-test-connection-button');
|
||||
const testResult = document.getElementById('helpdesk-test-connection-result');
|
||||
|
||||
if (testButton && testResult) {
|
||||
testButton.addEventListener('click', async () => {
|
||||
testButton.disabled = true;
|
||||
testResult.textContent = testButton.dataset.testingLabel || 'Testing...';
|
||||
testResult.className = '';
|
||||
|
||||
try {
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput?.value || csrfMeta?.content || '';
|
||||
|
||||
const formData = new FormData();
|
||||
if (csrfToken) {
|
||||
formData.append('csrf_token', csrfToken);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
new URL('helpdesk/settings/test-connection-data', document.baseURI).href,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ok) {
|
||||
testResult.textContent = data.message || 'Connection successful';
|
||||
testResult.style.color = 'var(--color-success, green)';
|
||||
} else {
|
||||
let msg = data.error || 'Connection failed';
|
||||
if (data.debug?.url) {
|
||||
msg += '\nURL: ' + data.debug.url;
|
||||
}
|
||||
if (data.debug?.http_code) {
|
||||
msg += ' (HTTP ' + data.debug.http_code + ')';
|
||||
}
|
||||
testResult.textContent = msg;
|
||||
testResult.style.color = 'var(--color-error, red)';
|
||||
testResult.style.whiteSpace = 'pre-wrap';
|
||||
}
|
||||
} catch {
|
||||
testResult.textContent = 'Connection failed';
|
||||
testResult.style.color = 'var(--color-error, red)';
|
||||
} finally {
|
||||
testButton.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
161
modules/helpdesk/web/js/helpdesk-ticket.js
Normal file
161
modules/helpdesk/web/js/helpdesk-ticket.js
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Helpdesk ticket detail page — async activity timeline.
|
||||
*
|
||||
* Loads the ticket activity log (PBI_FP_TicketLog) via fetch and renders
|
||||
* a chronological timeline of status changes and messages.
|
||||
*/
|
||||
|
||||
const container = document.querySelector('.app-details-container[data-ticket-no]');
|
||||
if (container) {
|
||||
init(container);
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
const ticketNo = container.dataset.ticketNo || '';
|
||||
const logUrl = container.dataset.ticketLogUrl || '';
|
||||
|
||||
if (!ticketNo || !logUrl) return;
|
||||
|
||||
// i18n
|
||||
const i18nEl = document.getElementById('helpdesk-ticket-i18n');
|
||||
const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {};
|
||||
const t = (key) => i18n[key] || key;
|
||||
|
||||
// Date/time formatter
|
||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(raw) {
|
||||
if (!raw || raw === '0001-01-01' || raw === '0001-01-01T00:00:00Z') return '';
|
||||
try {
|
||||
return dateFmt.format(new Date(raw));
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(raw) {
|
||||
if (!raw) return '';
|
||||
// BC time format: "15:21:09.76" — parse as today's time
|
||||
const parts = raw.split(':');
|
||||
if (parts.length < 2) return raw;
|
||||
return parts[0] + ':' + parts[1];
|
||||
}
|
||||
|
||||
function typeIcon(type) {
|
||||
switch (type) {
|
||||
case 'Nachricht': return 'bi-chat-dots-fill';
|
||||
case 'Status': return 'bi-arrow-repeat';
|
||||
case 'Weiterleitung': return 'bi-forward-fill';
|
||||
case 'Datei': return 'bi-paperclip';
|
||||
default: return 'bi-circle-fill';
|
||||
}
|
||||
}
|
||||
|
||||
function typeVariant(type) {
|
||||
switch (type) {
|
||||
case 'Nachricht': return 'message';
|
||||
case 'Status': return 'status';
|
||||
case 'Weiterleitung': return 'forward';
|
||||
case 'Datei': return 'file';
|
||||
default: return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type, stateSubtype) {
|
||||
switch (type) {
|
||||
case 'Nachricht': return t('sent a message');
|
||||
case 'Status': {
|
||||
if (stateSubtype) {
|
||||
return t('changed status') + ' → ' + stateSubtype;
|
||||
}
|
||||
return t('changed status');
|
||||
}
|
||||
case 'Weiterleitung': return t('Forwarded');
|
||||
case 'Datei': return t('attached a file');
|
||||
default: return type || t('Activity');
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline(entries) {
|
||||
if (entries.length === 0) {
|
||||
return `<div class="app-empty-state app-empty-state-compact">
|
||||
<p class="app-empty-state-message">${esc(t('No activity found.'))}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let html = '<div class="helpdesk-timeline">';
|
||||
let lastDate = '';
|
||||
|
||||
for (const entry of entries) {
|
||||
const date = formatDate(entry.Creation_Date);
|
||||
const time = formatTime(entry.Creation_Time);
|
||||
const type = entry.Type || '';
|
||||
const user = entry.Created_By || '';
|
||||
const variant = typeVariant(type);
|
||||
const icon = typeIcon(type);
|
||||
const label = typeLabel(type, entry.State_Subtype || '');
|
||||
|
||||
// Date separator
|
||||
if (date !== lastDate) {
|
||||
html += `<div class="helpdesk-timeline-date">${esc(date)}</div>`;
|
||||
lastDate = date;
|
||||
}
|
||||
|
||||
html += `<div class="helpdesk-timeline-entry" data-variant="${variant}">`;
|
||||
html += `<div class="helpdesk-timeline-icon"><i class="bi ${icon}" aria-hidden="true"></i></div>`;
|
||||
html += '<div class="helpdesk-timeline-body">';
|
||||
html += `<strong>${esc(user)}</strong> ${esc(label)}`;
|
||||
if (time) {
|
||||
html += `<span class="helpdesk-timeline-time">${esc(time)}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderError(message) {
|
||||
return `<div class="notice" data-variant="warning" role="alert"><p>${esc(message)}</p></div>`;
|
||||
}
|
||||
|
||||
// --- Load and render ---
|
||||
|
||||
async function loadTimeline() {
|
||||
const timelineEl = document.getElementById('ticket-timeline');
|
||||
const loadingEl = document.getElementById('timeline-loading');
|
||||
if (!timelineEl) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ ticketNo });
|
||||
const response = await fetch(logUrl + '?' + params.toString(), { credentials: 'same-origin' });
|
||||
const data = await response.json();
|
||||
|
||||
if (loadingEl) loadingEl.hidden = true;
|
||||
|
||||
if (!data.ok) {
|
||||
timelineEl.innerHTML = renderError(t('Could not load activity log.'));
|
||||
return;
|
||||
}
|
||||
|
||||
timelineEl.innerHTML = renderTimeline(data.log || []);
|
||||
} catch {
|
||||
if (loadingEl) loadingEl.hidden = true;
|
||||
timelineEl.innerHTML = renderError(t('Could not load activity log.'));
|
||||
}
|
||||
}
|
||||
|
||||
loadTimeline();
|
||||
}
|
||||
73
modules/helpdesk/web/js/pages/helpdesk-search-index.js
Normal file
73
modules/helpdesk/web/js/pages/helpdesk-search-index.js
Normal file
@@ -0,0 +1,73 @@
|
||||
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-search');
|
||||
if (config) {
|
||||
const gridjs = window.gridjs;
|
||||
if (!gridjs) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed: Grid.js missing', { module: 'helpdesk-search', component: 'grid' });
|
||||
} else {
|
||||
const appBase = getAppBase();
|
||||
const labels = config.labels || {};
|
||||
|
||||
const gridOptions = {
|
||||
gridjs,
|
||||
container: '#helpdesk-search-grid',
|
||||
dataUrl: config.dataUrl || 'helpdesk/search-data',
|
||||
appBase,
|
||||
columns: [
|
||||
{
|
||||
name: labels.debtorNo || 'Debtor No.',
|
||||
sort: true,
|
||||
formatter: (cell) => {
|
||||
const debtorNo = escapeHtml(cell?.no || '');
|
||||
const detailUrl = String(cell?.url || '').trim();
|
||||
if (detailUrl === '') {
|
||||
return gridjs.html(debtorNo);
|
||||
}
|
||||
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
||||
return gridjs.html(`<a href="${href}">${debtorNo}</a>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.name || 'Name', sort: true },
|
||||
{ name: labels.city || 'City', sort: true },
|
||||
{ name: labels.phone || 'Phone', sort: false },
|
||||
{ name: labels.email || 'E-Mail', sort: false },
|
||||
{ name: 'detail_url', hidden: true },
|
||||
],
|
||||
sortColumns: ['No', 'Name', 'City', null, null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang || {},
|
||||
mapData: (data) => (data.data || []).map((row) => [
|
||||
{ no: row.No || '', url: row.detail_url || '' },
|
||||
row.Name || '',
|
||||
row.City || '',
|
||||
row.Phone_No || '',
|
||||
row.E_Mail || '',
|
||||
row.detail_url || '',
|
||||
]),
|
||||
search: config.gridSearch || null,
|
||||
filterSchema: config.filterSchema || [],
|
||||
urlSync: true,
|
||||
rowInteraction: { linkColumn: 0 },
|
||||
rowDblClick: {
|
||||
getUrl: (rowData) => rowData?.cells?.[5]?.data || '',
|
||||
},
|
||||
};
|
||||
|
||||
const { gridConfig } = initStandardListPage({
|
||||
grid: gridOptions,
|
||||
filters: {
|
||||
mode: 'drawer',
|
||||
chipMeta: config.filterChipMeta || {},
|
||||
watchInputs: ['#helpdesk-search-input'],
|
||||
},
|
||||
});
|
||||
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed', { module: 'helpdesk-search', component: 'grid' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/data().php',
|
||||
'modules/helpdesk/pages/helpdesk/search-data().php',
|
||||
'pages/search/data().php',
|
||||
'pages/admin/users/data().php',
|
||||
'pages/admin/tenants/data().php',
|
||||
@@ -67,6 +68,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||
'modules/helpdesk/pages/helpdesk/search-data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||
'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||
'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||
'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
|
||||
@@ -90,6 +92,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||
'modules/helpdesk/pages/helpdesk/search(default).phtml',
|
||||
'pages/search/index(default).phtml',
|
||||
'pages/admin/users/index(default).phtml',
|
||||
'pages/admin/tenants/index(default).phtml',
|
||||
@@ -112,6 +115,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/index().php',
|
||||
'modules/helpdesk/pages/helpdesk/search().php',
|
||||
'pages/search/index().php',
|
||||
'pages/admin/users/index().php',
|
||||
'pages/admin/tenants/index().php',
|
||||
@@ -134,6 +138,7 @@ trait ListContractFiles
|
||||
{
|
||||
return [
|
||||
'modules/addressbook/pages/address-book/index(default).phtml',
|
||||
'modules/helpdesk/pages/helpdesk/search(default).phtml',
|
||||
'pages/admin/users/index(default).phtml',
|
||||
'pages/admin/tenants/index(default).phtml',
|
||||
'pages/admin/departments/index(default).phtml',
|
||||
|
||||
@@ -12,7 +12,7 @@ class ListDataEndpointContractTest extends TestCase
|
||||
public function testDataEndpointsRequireGetGuard(): void
|
||||
{
|
||||
$files = $this->hardCutGridEndpointFiles();
|
||||
$this->assertCount(14, $files, 'Unexpected number of hard-cut grid data endpoints.');
|
||||
$this->assertCount(15, $files, 'Unexpected number of hard-cut grid data endpoints.');
|
||||
$this->assertNotContains('pages/admin/search/data().php', $files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@layer components {
|
||||
/* Detail page layout — aside panel, card sections, sticky titlebar integration. */
|
||||
.app-details-container aside {
|
||||
.app-details-container > aside {
|
||||
padding-top: var(--app-spacing);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
padding: calc(var(--app-spacing) * 2) calc(var(--app-spacing) * 2)
|
||||
calc(var(--app-spacing) * 2) calc(var(--app-spacing) * 0);
|
||||
} */
|
||||
.app-details-container:has(aside) {
|
||||
.app-details-container:has(> aside) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 300px);
|
||||
min-height: 82vh;
|
||||
@@ -158,10 +158,10 @@
|
||||
.app-details-container.is-aside-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.app-details-container.is-aside-collapsed aside {
|
||||
.app-details-container.is-aside-collapsed > aside {
|
||||
display: none;
|
||||
}
|
||||
.app-details-container aside {
|
||||
.app-details-container > aside {
|
||||
border-left: 1px solid var(--app-border);
|
||||
padding: calc(var(--app-spacing) * 2);
|
||||
border-top: none;
|
||||
|
||||
@@ -36,6 +36,8 @@ export function initFilterDrawer(options = {}) {
|
||||
const applyBaseLabel = applyButton ? String(applyButton.textContent || '').trim() : '';
|
||||
const openButtonBaseLabels = new WeakMap();
|
||||
const panel = drawer.querySelector('[data-filter-drawer-panel]') || drawer;
|
||||
const originalParent = drawer.parentNode;
|
||||
const originalNextSibling = drawer.nextSibling;
|
||||
|
||||
const cleanupFns = [];
|
||||
const bind = (target, eventName, handler, bindOptions = undefined) => {
|
||||
@@ -47,6 +49,29 @@ export function initFilterDrawer(options = {}) {
|
||||
let lockedScrollY = 0;
|
||||
let lastTrigger = null;
|
||||
|
||||
const ensureDrawerInBody = () => {
|
||||
if (!document.body || !drawer.isConnected || drawer.parentNode === document.body) {
|
||||
return;
|
||||
}
|
||||
document.body.appendChild(drawer);
|
||||
};
|
||||
|
||||
const restoreDrawerPosition = () => {
|
||||
if (!(originalParent instanceof Node) || !drawer.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (!originalParent.isConnected || drawer.parentNode === originalParent) {
|
||||
return;
|
||||
}
|
||||
if (originalNextSibling && originalNextSibling.parentNode === originalParent) {
|
||||
originalParent.insertBefore(drawer, originalNextSibling);
|
||||
return;
|
||||
}
|
||||
originalParent.appendChild(drawer);
|
||||
};
|
||||
|
||||
ensureDrawerInBody();
|
||||
|
||||
if (!drawer.hasAttribute('role')) {
|
||||
drawer.setAttribute('role', 'dialog');
|
||||
}
|
||||
@@ -115,6 +140,9 @@ export function initFilterDrawer(options = {}) {
|
||||
};
|
||||
|
||||
const setOpen = (nextOpen) => {
|
||||
if (nextOpen) {
|
||||
ensureDrawerInBody();
|
||||
}
|
||||
isOpen = nextOpen;
|
||||
drawer.hidden = !nextOpen;
|
||||
drawer.setAttribute('aria-hidden', nextOpen ? 'false' : 'true');
|
||||
@@ -148,6 +176,21 @@ export function initFilterDrawer(options = {}) {
|
||||
focusFirstField();
|
||||
};
|
||||
|
||||
const normalizeForcedClose = () => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
isOpen = false;
|
||||
unlockPageScroll();
|
||||
document.body.classList.remove('filter-drawer-open');
|
||||
openButtons.forEach((button) => {
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const open = (trigger = null) => {
|
||||
if (isOpen) {
|
||||
return;
|
||||
@@ -254,6 +297,23 @@ export function initFilterDrawer(options = {}) {
|
||||
};
|
||||
bind(document, 'keydown', onKeyDown);
|
||||
|
||||
const documentObserver = new MutationObserver(() => {
|
||||
if (!drawer.isConnected || drawer.hidden) {
|
||||
normalizeForcedClose();
|
||||
}
|
||||
});
|
||||
if (document.body) {
|
||||
documentObserver.observe(document.body, { childList: true, subtree: true });
|
||||
cleanupFns.push(() => documentObserver.disconnect());
|
||||
}
|
||||
const drawerAttributeObserver = new MutationObserver(() => {
|
||||
if (drawer.hidden) {
|
||||
normalizeForcedClose();
|
||||
}
|
||||
});
|
||||
drawerAttributeObserver.observe(drawer, { attributes: true, attributeFilter: ['hidden'] });
|
||||
cleanupFns.push(() => drawerAttributeObserver.disconnect());
|
||||
|
||||
const setApplyEnabled = (enabled) => {
|
||||
if (!applyButton) {
|
||||
return;
|
||||
@@ -314,6 +374,9 @@ export function initFilterDrawer(options = {}) {
|
||||
if (isOpen) {
|
||||
close('discard');
|
||||
}
|
||||
unlockPageScroll();
|
||||
document.body.classList.remove('filter-drawer-open');
|
||||
restoreDrawerPosition();
|
||||
delete drawer.dataset.filterDrawerBound;
|
||||
delete drawer._filterDrawerApi;
|
||||
};
|
||||
|
||||
@@ -1370,6 +1370,35 @@ const normalizeListMode = (value) => {
|
||||
return 'drawer';
|
||||
};
|
||||
|
||||
const resolveDrawerRoot = (gridConfig, filterOptions = {}) => {
|
||||
const explicitRoot = filterOptions?.drawerRoot;
|
||||
if (explicitRoot) {
|
||||
return resolveDomQueryElement(explicitRoot) || document;
|
||||
}
|
||||
|
||||
const containerEl = gridConfig?.container instanceof Element
|
||||
? gridConfig.container
|
||||
: null;
|
||||
if (!containerEl) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const drawerSelector = String(filterOptions?.drawer?.drawerSelector || '[data-filter-drawer]').trim() || '[data-filter-drawer]';
|
||||
const openSelector = String(filterOptions?.drawer?.openSelector || '[data-filter-drawer-open]').trim() || '[data-filter-drawer-open]';
|
||||
|
||||
let current = containerEl;
|
||||
while (current && current !== document.documentElement) {
|
||||
const hasDrawer = Boolean(current.querySelector(drawerSelector));
|
||||
const hasOpenButton = Boolean(current.querySelector(openSelector));
|
||||
if (hasDrawer && hasOpenButton) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return containerEl.parentElement || document;
|
||||
};
|
||||
|
||||
/**
|
||||
* Standardized list-page bootstrap:
|
||||
* - builds grid filters from schema (or uses explicit filters)
|
||||
@@ -1425,7 +1454,7 @@ export function initStandardListPage(options = {}) {
|
||||
const chipUiConfig = filterOptions.chips && typeof filterOptions.chips === 'object'
|
||||
? filterOptions.chips
|
||||
: {};
|
||||
const drawerRoot = filterOptions.drawerRoot || document;
|
||||
const drawerRoot = resolveDrawerRoot(gridConfig, filterOptions);
|
||||
const domLabels = readDomLabelDefaults({
|
||||
drawerRoot,
|
||||
chipsSelector: chipUiConfig.container || '[data-active-filter-chips]',
|
||||
|
||||
Reference in New Issue
Block a user