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

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