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:
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
modules/helpdesk/pages/helpdesk/debitor-summary-data().php
Normal file
58
modules/helpdesk/pages/helpdesk/debitor-summary-data().php
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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