diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 098320f..2cba88e 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -2,21 +2,21 @@ "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...", + "Debtor search": "Kundensuche", + "Search debtors": "Kunden suchen", + "Search by name or debtor number...": "Suche nach Name oder Kundennummer...", "Search": "Suchen", - "Please provide a customer number": "Bitte geben Sie eine Debitorennummer an", + "Please provide a customer number": "Bitte geben Sie eine Kundennummer an", "Minimum 2 characters": "Mindestens 2 Zeichen", "Please enter at least 2 characters.": "Bitte mindestens 2 Zeichen eingeben.", - "No debtors found": "Keine Debitoren gefunden", + "No debtors found": "Keine Kunden 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.", + "Debtor No.": "Kunden-Nr.", "Name": "Name", "City": "Ort", "Phone": "Telefon", @@ -24,18 +24,18 @@ "Master data": "Stammdaten", "Contacts": "Ansprechpartner", "Tickets": "Tickets", - "Debtor details": "Debitoren-Details", + "Debtor details": "Kunden-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 contacts are linked to this debtor in Business Central.": "Diesem Kunden 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.", + "No tickets are linked to this debtor in Business Central.": "Diesem Kunden sind in Business Central keine Tickets zugeordnet.", "Ticket No.": "Ticket-Nr.", "Description": "Beschreibung", "Status": "Status", @@ -43,8 +43,8 @@ "Contact": "Kontakt", "Support": "Bearbeiter", "Ticket": "Ticket", - "Debtor": "Debitor", - "Debtor not found": "Debitor nicht gefunden", + "Debtor": "Kunde", + "Debtor not found": "Kunde nicht gefunden", "Ticket not found": "Ticket nicht gefunden", "Back to search": "Zurück zur Suche", "Back": "Zurück", diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index 8650292..02f062c 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -104,6 +104,57 @@ class DebitorDetailService return ['ok' => true, 'tickets' => $tickets]; } + /** + * Load a summary of ticket KPIs for a customer (exact counts, not sampled). + * + * @return array{ok: bool, total?: int, open?: int, last_activity?: string, error?: string} + */ + public function loadTicketSummary(string $customerNo, string $customerName): array + { + $result = $this->loadTickets($customerNo, $customerName); + + if (!$result['ok']) { + return $result; + } + + return self::summarizeTickets($result['tickets'] ?? []); + } + + /** + * Aggregate ticket KPIs from a raw ticket array. + * + * Used by both the service method and the data endpoint (which has its own cache layer). + * + * @param array> $tickets + * @return array{ok: true, total: int, open: int, last_activity: string} + */ + public static function summarizeTickets(array $tickets): array + { + $closedStates = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen']; + + $openCount = 0; + $lastActivity = ''; + + foreach ($tickets as $ticket) { + $state = (string) ($ticket['Ticket_State'] ?? ''); + if (!in_array($state, $closedStates, true)) { + $openCount++; + } + + $activityDate = (string) ($ticket['Last_Activity_Date'] ?? ''); + if ($activityDate !== '' && $activityDate !== '0001-01-01T00:00:00Z' && $activityDate > $lastActivity) { + $lastActivity = $activityDate; + } + } + + return [ + 'ok' => true, + 'total' => count($tickets), + 'open' => $openCount, + 'last_activity' => $lastActivity, + ]; + } + /** * Load activity log for a ticket (for async data endpoint). * diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index c63a0ad..516ca77 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -21,6 +21,7 @@ return [ ['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-summary-data', 'target' => 'helpdesk/debitor-summary-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'], diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index dae4044..d35c109 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -37,6 +37,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city : data-contacts-url="" data-tickets-url="" data-ticket-base-url="" + data-summary-url="" >
-
-
- -
+
diff --git a/modules/helpdesk/pages/helpdesk/ticket(default).phtml b/modules/helpdesk/pages/helpdesk/ticket(default).phtml index c5d2911..8956773 100644 --- a/modules/helpdesk/pages/helpdesk/ticket(default).phtml +++ b/modules/helpdesk/pages/helpdesk/ticket(default).phtml @@ -132,10 +132,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
-
-
- -
+
diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php index 700355a..d554ab5 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DebitorDetailServiceTest.php @@ -260,6 +260,88 @@ class DebitorDetailServiceTest extends TestCase $this->assertSame('Timeout', $result['error']); } + // --- loadTicketSummary() --- + + public function testLoadTicketSummaryReturnsFalseForEmptyCustomerNo(): void + { + $service = $this->createService(); + $result = $service->loadTicketSummary('', 'Test GmbH'); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketSummaryReturnsFalseWhenNotConfigured(): void + { + $service = $this->createService(null, false); + $result = $service->loadTicketSummary('10001', 'Test GmbH'); + $this->assertFalse($result['ok']); + } + + public function testLoadTicketSummaryReturnsExactCounts(): void + { + $tickets = [ + ['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => '2026-03-15T10:00:00Z'], + ['No' => 'T002', 'Ticket_State' => 'In Bearbeitung', 'Last_Activity_Date' => '2026-03-20T14:30:00Z'], + ['No' => 'T003', 'Ticket_State' => 'Erledigt', 'Last_Activity_Date' => '2026-03-10T08:00:00Z'], + ['No' => 'T004', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => '2026-02-01T12:00:00Z'], + ['No' => 'T005', 'Ticket_State' => 'Open', 'Last_Activity_Date' => '2026-03-25T16:45:00Z'], + ]; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketsForCustomer')->willReturn($tickets); + + $service = $this->createService($gateway); + $result = $service->loadTicketSummary('10001', 'Test GmbH'); + + $this->assertTrue($result['ok']); + $this->assertSame(5, $result['total']); + $this->assertSame(3, $result['open']); // Offen, In Bearbeitung, Open + $this->assertSame('2026-03-25T16:45:00Z', $result['last_activity']); + } + + public function testLoadTicketSummaryHandlesEmptyTicketList(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketsForCustomer')->willReturn([]); + + $service = $this->createService($gateway); + $result = $service->loadTicketSummary('10001', 'Test GmbH'); + + $this->assertTrue($result['ok']); + $this->assertSame(0, $result['total']); + $this->assertSame(0, $result['open']); + $this->assertSame('', $result['last_activity']); + } + + public function testLoadTicketSummaryIgnoresNullDateEntries(): void + { + $tickets = [ + ['No' => 'T001', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => '0001-01-01T00:00:00Z'], + ['No' => 'T002', 'Ticket_State' => 'Offen', 'Last_Activity_Date' => ''], + ]; + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketsForCustomer')->willReturn($tickets); + + $service = $this->createService($gateway); + $result = $service->loadTicketSummary('10001', 'Test GmbH'); + + $this->assertTrue($result['ok']); + $this->assertSame(2, $result['open']); + $this->assertSame('', $result['last_activity']); + } + + public function testLoadTicketSummaryReturnsErrorOnException(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('getTicketsForCustomer')->willThrowException(new \RuntimeException('Timeout')); + + $service = $this->createService($gateway); + $result = $service->loadTicketSummary('10001', 'Test GmbH'); + + $this->assertFalse($result['ok']); + $this->assertSame('Timeout', $result['error']); + } + // --- loadTicketLog() --- public function testLoadTicketLogReturnsFalseForEmptyTicketNo(): void diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 2036660..947c8a7 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -15,29 +15,6 @@ 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; diff --git a/modules/helpdesk/web/js/helpdesk-clickable-rows.js b/modules/helpdesk/web/js/helpdesk-clickable-rows.js deleted file mode 100644 index f802dfd..0000000 --- a/modules/helpdesk/web/js/helpdesk-clickable-rows.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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; - } - } - }); - }); -} diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index cd6dc75..78b77f5 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -6,7 +6,6 @@ * - 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'; @@ -21,6 +20,7 @@ function init(container) { const contactsUrl = container.dataset.contactsUrl || ''; const ticketsUrl = container.dataset.ticketsUrl || ''; const ticketBaseUrl = container.dataset.ticketBaseUrl || ''; + const summaryUrl = container.dataset.summaryUrl || ''; if (!customerNo || !customerName) return; @@ -31,6 +31,7 @@ function init(container) { // State — promise caching ensures only 1 request per resource let contactsPromise = null; + let summaryPromise = null; let overviewTicketsPromise = null; let ticketCategoriesPromise = null; let contactsRendered = { overview: false, full: false }; @@ -56,7 +57,21 @@ function init(container) { } /** - * Fetch all tickets for overview tab (uses the grid data endpoint with high limit). + * Fetch exact ticket KPI summary from dedicated endpoint. + * Returns { ok, total, open, last_activity }. + */ + function fetchSummary() { + if (!summaryPromise) { + const params = new URLSearchParams({ customerNo, customerName }); + summaryPromise = fetch(summaryUrl + '?' + params.toString(), { credentials: 'same-origin' }) + .then(r => r.json()) + .catch(() => ({ ok: false })); + } + return summaryPromise; + } + + /** + * Fetch tickets for overview display (limited to recent entries for rendering). * Returns { data: [...], total: N } format from gridJsonDataResult. */ function fetchOverviewTickets() { @@ -64,7 +79,7 @@ function init(container) { const params = new URLSearchParams({ customerNo, customerName, - limit: '200', + limit: '20', offset: '0', order: 'Created_On', dir: 'desc', @@ -320,16 +335,22 @@ function init(container) { if (badge) badge.textContent = String(count); } - // --- Show/hide loading states --- + // --- Show/hide loading states (aria-busy pattern) --- function showLoading(id) { const el = document.getElementById(id); - if (el) el.hidden = false; + if (el) { + el.setAttribute('aria-busy', 'true'); + el.hidden = false; + } } function hideLoading(id) { const el = document.getElementById(id); - if (el) el.hidden = true; + if (el) { + el.removeAttribute('aria-busy'); + el.hidden = true; + } } function showContent(id) { @@ -337,15 +358,36 @@ function init(container) { if (el) el.hidden = false; } - // --- Tab switch links --- + // --- Delegated click handling for clickable rows + tab switch links --- container.addEventListener('click', (e) => { + // Tab switch links 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(); + if (link) { + e.preventDefault(); + const tabName = link.dataset.switchTab; + const tabButton = container.querySelector(`[data-tab="${tabName}"]`); + if (tabButton) tabButton.click(); + return; + } + + // Clickable rows (delegated — no re-init needed after render) + if (e.target.closest('a')) return; + const row = e.target.closest('.app-clickable-row[data-href]'); + if (row) { + const href = row.dataset.href; + if (href) window.location.href = href; + } + }); + + container.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = e.target.closest('.app-clickable-row[data-href]'); + if (row) { + e.preventDefault(); + const href = row.dataset.href; + if (href) window.location.href = href; + } }); // --- Grid.js tickets tab --- @@ -449,9 +491,10 @@ function init(container) { async function renderOverview() { showLoading('overview-loading'); - const [ticketsResult, contactsResult] = await Promise.all([ + const [ticketsResult, contactsResult, summaryResult] = await Promise.all([ fetchOverviewTickets(), fetchContacts(), + fetchSummary(), ]); hideLoading('overview-loading'); @@ -491,22 +534,11 @@ function init(container) { } } - // 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); + // Update KPI tiles from exact server-side summary + if (summaryResult.ok) { + updateKpi('open-tickets', String(summaryResult.open ?? 0)); + updateKpi('last-activity', summaryResult.last_activity ? formatDate(summaryResult.last_activity) : '—'); + updateBadge('tab-badge-tickets', summaryResult.total ?? 0); } if (contactsResult.ok) { @@ -515,9 +547,6 @@ function init(container) { updateBadge('tab-badge-contacts', contacts.length); contactsRendered.overview = true; } - - // Init clickable rows for newly rendered content - initClickableRows(); } // --- Render full contacts tab --- @@ -541,7 +570,6 @@ function init(container) { showContent('contacts-content'); contactsRendered.full = true; - initClickableRows(); } // --- Tab change observer --- diff --git a/modules/helpdesk/web/js/helpdesk-search.js b/modules/helpdesk/web/js/helpdesk-search.js deleted file mode 100644 index 7830388..0000000 --- a/modules/helpdesk/web/js/helpdesk-search.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Helpdesk search page — clickable row navigation. - */ -import { initClickableRows } from './helpdesk-clickable-rows.js'; - -initClickableRows(); diff --git a/modules/helpdesk/web/js/helpdesk-settings.js b/modules/helpdesk/web/js/helpdesk-settings.js index 63b8251..eb16618 100644 --- a/modules/helpdesk/web/js/helpdesk-settings.js +++ b/modules/helpdesk/web/js/helpdesk-settings.js @@ -1,6 +1,7 @@ /** * Helpdesk settings page — auth mode toggle and connection test. */ +import { showAsyncFlash } from '/js/components/app-async-flash.js'; // Toggle OAuth2 vs Basic Auth field visibility const authModeSelect = document.getElementById('auth_mode'); @@ -23,13 +24,11 @@ if (authModeSelect) { // Connection test button const testButton = document.getElementById('helpdesk-test-connection-button'); -const testResult = document.getElementById('helpdesk-test-connection-result'); -if (testButton && testResult) { +if (testButton) { testButton.addEventListener('click', async () => { testButton.disabled = true; - testResult.textContent = testButton.dataset.testingLabel || 'Testing...'; - testResult.className = ''; + testButton.setAttribute('aria-busy', 'true'); try { const csrfMeta = document.querySelector('meta[name="csrf-token"]'); @@ -53,25 +52,19 @@ if (testButton && testResult) { const data = await response.json(); if (data.ok) { - testResult.textContent = data.message || 'Connection successful'; - testResult.style.color = 'var(--color-success, green)'; + showAsyncFlash('success', data.message || 'Connection successful'); } 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'; + showAsyncFlash('error', msg); } } catch { - testResult.textContent = 'Connection failed'; - testResult.style.color = 'var(--color-error, red)'; + showAsyncFlash('error', 'Connection failed'); } finally { testButton.disabled = false; + testButton.removeAttribute('aria-busy'); } }); }