feat(helpdesk): align module with core UI standards and fix KPI accuracy

- Add server-side ticket summary endpoint for exact KPI counts instead
  of client-side calculation from first 200 tickets
- Extract summarizeTickets() as shared static method to avoid duplication
- Replace custom .helpdesk-spinner CSS with core [aria-busy] pattern
- Migrate settings connection-test feedback to showAsyncFlash()
- Replace helpdesk-clickable-rows.js with delegated event handling
- Remove unused helpdesk-search.js (dead code, never loaded)
- Harmonize German wording: Debitoren → Kunden (8 i18n keys)
- Add 6 PHPUnit tests for loadTicketSummary()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 21:05:54 +02:00
parent a0d7670dd7
commit a37486c0e3
13 changed files with 275 additions and 129 deletions

View File

@@ -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",

View File

@@ -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<int, array<string, mixed>> $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).
*

View File

@@ -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'],

View File

@@ -37,6 +37,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
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/')); ?>"
data-summary-url="<?php e(lurl('helpdesk/debitor-summary-data')); ?>"
>
<section>
<?php
@@ -135,10 +136,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
<!-- 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-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="overview-content" hidden>
<div class="grid grid-2">
<!-- Open tickets (max 10) -->
@@ -218,10 +216,7 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
<!-- 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-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
<div id="contacts-content" hidden></div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
<?php
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
gridRequireGetRequest();
$request = requestInput();
$customerNo = trim((string) $request->query('customerNo', ''));
$customerName = trim((string) $request->query('customerName', ''));
if ($customerNo === '' || $customerName === '') {
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'Missing parameters']);
return;
}
// Reuse session cache from tickets endpoint when available (avoids duplicate OData call)
$sessionStore = app(\MintyPHP\Http\SessionStoreInterface::class);
$cacheKey = 'module.helpdesk.tickets_cache.' . $customerNo;
$cacheTtl = 120;
$cached = $sessionStore->get($cacheKey);
if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) {
$summary = DebitorDetailService::summarizeTickets($cached['tickets'] ?? []);
header('Content-Type: application/json');
echo json_encode($summary);
return;
}
// Cache miss — load via service, populate cache, then summarize
$service = app(DebitorDetailService::class);
$ticketsResult = $service->loadTickets($customerNo, $customerName);
if (!($ticketsResult['ok'] ?? false)) {
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $ticketsResult['error'] ?? 'Failed to load tickets']);
return;
}
$tickets = $ticketsResult['tickets'] ?? [];
$sessionStore->set($cacheKey, [
'tickets' => $tickets,
'fetched_at' => time(),
]);
$summary = DebitorDetailService::summarizeTickets($tickets);
header('Content-Type: application/json');
echo json_encode($summary);

View File

@@ -189,7 +189,6 @@ $configErrors = is_array($configErrors ?? null) ? $configErrors : [];
<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>

View File

@@ -132,10 +132,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t
<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 id="timeline-loading" aria-busy="true"><?php e(t('Loading...')); ?></div>
</div>
</div>
</div>

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}
});
});
}

View File

@@ -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 ---

View File

@@ -1,6 +0,0 @@
/**
* Helpdesk search page — clickable row navigation.
*/
import { initClickableRows } from './helpdesk-clickable-rows.js';
initClickableRows();

View File

@@ -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');
}
});
}