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:
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Helpdesk search page — clickable row navigation.
|
||||
*/
|
||||
import { initClickableRows } from './helpdesk-clickable-rows.js';
|
||||
|
||||
initClickableRows();
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user