From 14da36e83a513d05db533118a6ea56ff665ae40a Mon Sep 17 00:00:00 2001 From: aminovfariz Date: Thu, 25 Jun 2026 10:30:05 +0200 Subject: [PATCH] feat(helpdesk): add customer engagement analytics page [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Customer analytics" page under Monitoring: surfaces who is actively in contact vs. who has gone quiet, so the team can reach out before a customer slips away. - KPI tiles: total / active / no-contact / never-in-contact - "Customers without recent contact" — longest silence first - "Top customers by communication" — most ticket activity in the period - Configurable no-contact threshold (helpdesk settings, default 60 days) CustomerEngagementService aggregates the global ticket snapshot per customer (same single-pull model as RiskRadarService — no N+1 to BC). Last contact is the most recent ticket activity; silence beyond the threshold flags the customer. Revenue ranking deferred (BC OData exposes no per-customer ledger). All within modules/helpdesk/. Tests + PHPStan green. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/helpdesk/i18n/default_de.json | 46 ++-- modules/helpdesk/i18n/default_en.json | 44 ++-- .../Service/CustomerEngagementService.php | 235 ++++++++++++++++++ .../Helpdesk/Service/DebitorCacheControl.php | 5 + .../Service/HelpdeskSettingsGateway.php | 50 ++++ modules/helpdesk/module.php | 13 + .../pages/helpdesk/analytics-data().php | 86 +++++++ .../pages/helpdesk/analytics/index().php | 21 ++ .../helpdesk/analytics/index(default).phtml | 147 +++++++++++ .../pages/helpdesk/settings/index().php | 6 + .../helpdesk/settings/index(default).phtml | 13 + .../templates/aside-helpdesk-panel.phtml | 9 +- .../Service/CustomerEngagementServiceTest.php | 183 ++++++++++++++ .../Service/HelpdeskSettingsGatewayTest.php | 47 ++++ modules/helpdesk/web/css/helpdesk.css | 73 ++++++ modules/helpdesk/web/js/helpdesk-analytics.js | 218 ++++++++++++++++ 16 files changed, 1150 insertions(+), 46 deletions(-) create mode 100644 modules/helpdesk/lib/Module/Helpdesk/Service/CustomerEngagementService.php create mode 100644 modules/helpdesk/pages/helpdesk/analytics-data().php create mode 100644 modules/helpdesk/pages/helpdesk/analytics/index().php create mode 100644 modules/helpdesk/pages/helpdesk/analytics/index(default).phtml create mode 100644 modules/helpdesk/tests/Module/Helpdesk/Service/CustomerEngagementServiceTest.php create mode 100644 modules/helpdesk/web/js/helpdesk-analytics.js diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 0c83416..d564e18 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -130,9 +130,9 @@ "Close rate": "Close-Rate", "Avg. resolution": "Ø Lösungszeit", "Open backlog": "Offene Tickets", - "kpi_tooltip_close_rate": "Geschlossene \u00f7 erstellte Tickets im Zeitraum. \u00dcber 1\u00d7 = R\u00fcckstau wird abgebaut.", + "kpi_tooltip_close_rate": "Geschlossene ÷ erstellte Tickets im Zeitraum. Über 1× = Rückstau wird abgebaut.", "kpi_tooltip_resolution": "Median der Bearbeitungsdauer aller im Zeitraum geschlossenen Tickets.", - "kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets \u2013 unabh\u00e4ngig vom gew\u00e4hlten Zeitraum. Zeigt den R\u00fcckstau.", + "kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets – unabhängig vom gewählten Zeitraum. Zeigt den Rückstau.", "Backlog shrinking": "Rückstau wird abgebaut", "Backlog growing": "Rückstau wächst", "unassigned": "ohne Bearbeiter", @@ -372,7 +372,7 @@ "Back to list": "Zurück zur Liste", "Save & close": "Speichern & schließen", "Software product updated": "Software-Produkt aktualisiert", - "Software product not found": "Software-Produkt nicht gefunden", + "Software product not found": "Softwareprodukt nicht gefunden", "Product details": "Produktdetails", "Enter product name...": "Produktname eingeben...", "Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein", @@ -436,8 +436,6 @@ "Create handover": "Übergabe erstellen", "Continue": "Weiter", "Cancel": "Abbrechen", - "Back": "Zurück", - "Back to list": "Zurück zur Liste", "Please select a customer": "Bitte wählen Sie einen Kunden", "Please select a software product": "Bitte wählen Sie ein Softwareprodukt", "Software product has no handover protocol schema": "Softwareprodukt hat kein Übergabeprotokoll-Schema", @@ -445,27 +443,22 @@ "Please complete step 1 first": "Bitte schließen Sie zuerst Schritt 1 ab", "Debitor number is required": "Debitornummer ist erforderlich", "Software product is required": "Softwareprodukt ist erforderlich", - "Software product not found": "Softwareprodukt nicht gefunden", "Failed to create handover": "Übergabe konnte nicht erstellt werden", "You do not have permission to edit this handover": "Sie haben keine Berechtigung, diese Übergabe zu bearbeiten", "You do not have permission to set this status": "Sie haben keine Berechtigung, diesen Status zu setzen", "Invalid status": "Ungültiger Status", "Unknown field: %s": "Unbekanntes Feld: %s", "%s is required": "%s ist erforderlich", - "Failed to save": "Speichern fehlgeschlagen", "Failed to update status": "Status konnte nicht aktualisiert werden", "No protocol fields defined.": "Keine Protokollfelder definiert.", "Please select...": "Bitte auswählen...", - "Handover protocol": "Übergabeprotokoll", "Change status": "Status ändern", - "Customer": "Kunde", "Debitor No.": "Debitornr.", "Progress": "Fortschritt", "Created by": "Erstellt von", "Search handovers...": "Übergaben suchen...", "ID": "ID", "History": "Verlauf", - "Current": "Aktuell", "Viewing version %d": "Version %d wird angezeigt", "compared with version %d": "verglichen mit Version %d", "Back to current": "Zurück zur aktuellen Version", @@ -475,7 +468,6 @@ "Failed to restore": "Wiederherstellen fehlgeschlagen", "Only managers can restore revisions": "Nur Manager können Versionen wiederherstellen", "Revision not found": "Version nicht gefunden", - "Created": "Erstellt", "Fields changed": "Felder geändert", "Status changed": "Status geändert", "Fields and status changed": "Felder und Status geändert", @@ -521,11 +513,8 @@ "Ticket status": "Ticketstatus", "Assignment": "Zuweisung", "Resolved": "Erledigt", - "Closed": "Geschlossen", - "Open": "Offen", "Assign update": "Update zuweisen", "Assign": "Zuweisen", - "Unassigned": "Nicht zugewiesen", "Assigned": "Zugewiesen", "Update": "Update", "Hotfix": "Hotfix", @@ -537,8 +526,6 @@ "Update assigned successfully": "Update erfolgreich zugewiesen", "Failed to assign update": "Update konnte nicht zugewiesen werden", "Select domain...": "Domain auswählen...", - "Loading domains...": "Domains werden geladen...", - "No domains found for this customer": "Keine Domains für diesen Kunden gefunden", "No updates for this domain yet": "Noch keine Updates für diese Domain", "Search updates...": "Updates suchen...", "Open in Gitea": "In Gitea öffnen", @@ -547,9 +534,7 @@ "View software updates list": "Software-Updates-Liste anzeigen", "Assign and manage software updates": "Software-Updates zuweisen und verwalten", "Security level": "Sicherheitsstufe", - "Low": "Niedrig", "Normal": "Normal", - "High": "Hoch", "Critical": "Kritisch", "Save failed": "Speichern fehlgeschlagen", "Invalid CSRF token": "Ungültiges CSRF-Token", @@ -557,13 +542,10 @@ "Missing domain number": "Domain-Nummer fehlt", "Invalid security level": "Ungültige Sicherheitsstufe", "Failed to save security level": "Sicherheitsstufe konnte nicht gespeichert werden", - "Under review": "In Prüfung", - "Assignment": "Zuweisung", "Assigned to": "Zugewiesen an", "Assigned at": "Zugewiesen am", "Not assigned yet": "Noch nicht zugewiesen", - "Assign": "Zuweisen", "Change assignment": "Zuweisung ändern", "Assign handover": "Übergabe zuweisen", "Assign to": "Zuweisen an", @@ -586,7 +568,6 @@ "Failed to submit for review": "Einreichen zur Prüfung fehlgeschlagen", "You are not assigned to this handover": "Sie sind dieser Übergabe nicht zugewiesen", "Handover cannot be submitted for review in its current status": "Übergabe kann im aktuellen Status nicht zur Prüfung eingereicht werden", - "In progress": "In Bearbeitung", "Done": "Abgeschlossen", "Assigned to (column)": "Zugewiesen an", "How do you want to proceed?": "Wie möchten Sie vorgehen?", @@ -602,5 +583,24 @@ "Please select an employee to assign": "Bitte wählen Sie einen Mitarbeiter aus", "Delete handover": "Übergabe löschen", "Delete this handover?": "Diese Übergabe löschen?", - "Handover deleted": "Übergabe gelöscht" + "Handover deleted": "Übergabe gelöscht", + "Customer analytics": "Kundenanalyse", + "No-contact threshold": "Kein-Kontakt-Schwelle", + "Could not load customer analytics.": "Kundenanalyse konnte nicht geladen werden.", + "No customer data available.": "Keine Kundendaten verfügbar.", + "No contact": "Kein Kontakt", + "Never in contact": "Nie in Kontakt", + "days ago": "Tage her", + "never": "nie", + "Tickets in period": "Tickets im Zeitraum", + "Last contact": "Letzter Kontakt", + "Customers without recent contact": "Kunden ohne jüngsten Kontakt", + "Longest silence first — consider reaching out.": "Längste Funkstille zuerst — Kontaktaufnahme erwägen.", + "Top customers by communication": "Top-Kunden nach Kommunikation", + "Most ticket activity in the selected period.": "Meiste Ticket-Aktivität im gewählten Zeitraum.", + "Every customer has been in contact recently.": "Alle Kunden waren kürzlich in Kontakt.", + "No communication recorded in the selected period.": "Keine Kommunikation im gewählten Zeitraum erfasst.", + "Customer engagement": "Kundenbindung", + "No-contact threshold (days)": "Kein-Kontakt-Schwelle (Tage)", + "Customers without any communication for longer than this are flagged in the customer analytics view.": "Kunden ohne jegliche Kommunikation über diesen Zeitraum hinaus werden in der Kundenanalyse markiert." } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 6c486e2..63ceadf 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -130,9 +130,9 @@ "Close rate": "Close rate", "Avg. resolution": "Avg. resolution", "Open backlog": "Open tickets", - "kpi_tooltip_close_rate": "Closed \u00f7 created tickets in period. Above 1\u00d7 = open ticket queue is shrinking.", + "kpi_tooltip_close_rate": "Closed ÷ created tickets in period. Above 1× = open ticket queue is shrinking.", "kpi_tooltip_resolution": "Median processing time of all tickets closed in the selected period.", - "kpi_tooltip_backlog": "Current snapshot of all open tickets \u2013 independent of the selected period. Shows the pending queue.", + "kpi_tooltip_backlog": "Current snapshot of all open tickets – independent of the selected period. Shows the pending queue.", "Backlog shrinking": "Queue shrinking", "Backlog growing": "Queue growing", "unassigned": "unassigned", @@ -436,8 +436,6 @@ "Create handover": "Create handover", "Continue": "Continue", "Cancel": "Cancel", - "Back": "Back", - "Back to list": "Back to list", "Please select a customer": "Please select a customer", "Please select a software product": "Please select a software product", "Software product has no handover protocol schema": "Software product has no handover protocol schema", @@ -445,27 +443,22 @@ "Please complete step 1 first": "Please complete step 1 first", "Debitor number is required": "Debitor number is required", "Software product is required": "Software product is required", - "Software product not found": "Software product not found", "Failed to create handover": "Failed to create handover", "You do not have permission to edit this handover": "You do not have permission to edit this handover", "You do not have permission to set this status": "You do not have permission to set this status", "Invalid status": "Invalid status", "Unknown field: %s": "Unknown field: %s", "%s is required": "%s is required", - "Failed to save": "Failed to save", "Failed to update status": "Failed to update status", "No protocol fields defined.": "No protocol fields defined.", "Please select...": "Please select...", - "Handover protocol": "Handover protocol", "Change status": "Change status", - "Customer": "Customer", "Debitor No.": "Debitor No.", "Progress": "Progress", "Created by": "Created by", "Search handovers...": "Search handovers...", "ID": "ID", "History": "History", - "Current": "Current", "Viewing version %d": "Viewing version %d", "compared with version %d": "compared with version %d", "Back to current": "Back to current", @@ -475,7 +468,6 @@ "Failed to restore": "Failed to restore", "Only managers can restore revisions": "Only managers can restore revisions", "Revision not found": "Revision not found", - "Created": "Created", "Fields changed": "Fields changed", "Status changed": "Status changed", "Fields and status changed": "Fields and status changed", @@ -521,11 +513,8 @@ "Ticket status": "Ticket status", "Assignment": "Assignment", "Resolved": "Resolved", - "Closed": "Closed", - "Open": "Open", "Assign update": "Assign update", "Assign": "Assign", - "Unassigned": "Unassigned", "Assigned": "Assigned", "Update": "Update", "Hotfix": "Hotfix", @@ -537,8 +526,6 @@ "Update assigned successfully": "Update assigned successfully", "Failed to assign update": "Failed to assign update", "Select domain...": "Select domain...", - "Loading domains...": "Loading domains...", - "No domains found for this customer": "No domains found for this customer", "No updates for this domain yet": "No updates for this domain yet", "Search updates...": "Search updates...", "Open in Gitea": "Open in Gitea", @@ -547,9 +534,7 @@ "View software updates list": "View software updates list", "Assign and manage software updates": "Assign and manage software updates", "Security level": "Security level", - "Low": "Low", "Normal": "Normal", - "High": "High", "Critical": "Critical", "Save failed": "Save failed", "Invalid CSRF token": "Invalid CSRF token", @@ -557,13 +542,10 @@ "Missing domain number": "Missing domain number", "Invalid security level": "Invalid security level", "Failed to save security level": "Failed to save security level", - "Under review": "Under review", - "Assignment": "Assignment", "Assigned to": "Assigned to", "Assigned at": "Assigned at", "Not assigned yet": "Not assigned yet", - "Assign": "Assign", "Change assignment": "Change assignment", "Assign handover": "Assign handover", "Assign to": "Assign to", @@ -586,7 +568,6 @@ "Failed to submit for review": "Failed to submit for review", "You are not assigned to this handover": "You are not assigned to this handover", "Handover cannot be submitted for review in its current status": "Handover cannot be submitted for review in its current status", - "In progress": "In progress", "Done": "Done", "Assigned to (column)": "Assigned to", "How do you want to proceed?": "How do you want to proceed?", @@ -602,5 +583,24 @@ "Please select an employee to assign": "Please select an employee to assign", "Delete handover": "Delete handover", "Delete this handover?": "Delete this handover?", - "Handover deleted": "Handover deleted" + "Handover deleted": "Handover deleted", + "Customer analytics": "Customer analytics", + "No-contact threshold": "No-contact threshold", + "Could not load customer analytics.": "Could not load customer analytics.", + "No customer data available.": "No customer data available.", + "No contact": "No contact", + "Never in contact": "Never in contact", + "days ago": "days ago", + "never": "never", + "Tickets in period": "Tickets in period", + "Last contact": "Last contact", + "Customers without recent contact": "Customers without recent contact", + "Longest silence first — consider reaching out.": "Longest silence first — consider reaching out.", + "Top customers by communication": "Top customers by communication", + "Most ticket activity in the selected period.": "Most ticket activity in the selected period.", + "Every customer has been in contact recently.": "Every customer has been in contact recently.", + "No communication recorded in the selected period.": "No communication recorded in the selected period.", + "Customer engagement": "Customer engagement", + "No-contact threshold (days)": "No-contact threshold (days)", + "Customers without any communication for longer than this are flagged in the customer analytics view.": "Customers without any communication for longer than this are flagged in the customer analytics view." } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/CustomerEngagementService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/CustomerEngagementService.php new file mode 100644 index 0000000..f3edb64 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/CustomerEngagementService.php @@ -0,0 +1,235 @@ +> $tickets Raw OData tickets (global snapshot) + * @param int $periodDays Look-back window in days (e.g. 30/60/90/180/365) + * @param int $inactivityDays Silence threshold; last contact older than this => "no contact" + * @param int $nowTs Current unix timestamp (injected for testability) + * @param bool $truncated Whether the ticket set was capped + * + * @return array{ + * summary: array{total_customers: int, active: int, silent: int, no_contact_ever: int, tickets_total: int}, + * silent: list>, + * top_communication: list>, + * meta: array{period_days: int, inactivity_days: int, tickets_total: int, truncated: bool, confidence: string, generated_ts: int} + * } + */ + public static function buildEngagement( + array $tickets, + int $periodDays, + int $inactivityDays, + int $nowTs, + bool $truncated = false + ): array { + $periodStart = $nowTs - ($periodDays * self::SECONDS_PER_DAY); + $inactivityCutoff = $nowTs - ($inactivityDays * self::SECONDS_PER_DAY); + + // Aggregate per customer across the whole snapshot. + $customers = []; + foreach ($tickets as $ticket) { + $customerNo = trim((string) ($ticket['Customer_No'] ?? '')); + if ($customerNo === '') { + continue; + } + + if (!isset($customers[$customerNo])) { + $customers[$customerNo] = [ + 'customer_no' => $customerNo, + 'customer_name' => trim((string) ($ticket['Cust_Name'] ?? '')), + 'ticket_count_period' => 0, + 'ticket_count_total' => 0, + 'last_contact_ts' => null, + ]; + } + + // Keep the first non-empty name we encounter. + if ($customers[$customerNo]['customer_name'] === '') { + $customers[$customerNo]['customer_name'] = trim((string) ($ticket['Cust_Name'] ?? '')); + } + + $customers[$customerNo]['ticket_count_total']++; + + $activityTs = self::resolveActivityTimestamp($ticket); + if ($activityTs !== null) { + if ($activityTs >= $periodStart) { + $customers[$customerNo]['ticket_count_period']++; + } + if ($customers[$customerNo]['last_contact_ts'] === null + || $activityTs > $customers[$customerNo]['last_contact_ts']) { + $customers[$customerNo]['last_contact_ts'] = $activityTs; + } + } + } + + $active = 0; + $silent = 0; + $noContactEver = 0; + $silentRows = []; + $topRows = []; + + foreach ($customers as $c) { + $lastContactTs = $c['last_contact_ts']; + $daysSinceContact = $lastContactTs !== null + ? intdiv(max(0, $nowTs - $lastContactTs), self::SECONDS_PER_DAY) + : null; + + $status = self::classify($lastContactTs, $inactivityCutoff); + if ($status === 'active') { + $active++; + } elseif ($status === 'no_contact_ever') { + $noContactEver++; + } else { + $silent++; + } + + $row = [ + 'customer_no' => $c['customer_no'], + 'customer_name' => $c['customer_name'], + 'ticket_count_period' => $c['ticket_count_period'], + 'ticket_count_total' => $c['ticket_count_total'], + 'last_contact_ts' => $lastContactTs, + 'last_contact_iso' => $lastContactTs !== null ? gmdate('Y-m-d', $lastContactTs) : null, + 'days_since_contact' => $daysSinceContact, + 'status' => $status, + ]; + + if ($status !== 'active') { + $silentRows[] = $row; + } + $topRows[] = $row; + } + + // Silent list: longest silence first; "never" customers sink to the bottom + // (no actionable last-contact date). Tie-break by customer_no. + usort($silentRows, static function (array $a, array $b): int { + $aDays = $a['days_since_contact']; + $bDays = $b['days_since_contact']; + if ($aDays === null && $bDays === null) { + return strcmp((string) $a['customer_no'], (string) $b['customer_no']); + } + if ($aDays === null) { + return 1; + } + if ($bDays === null) { + return -1; + } + if ($aDays !== $bDays) { + return $bDays <=> $aDays; + } + + return strcmp((string) $a['customer_no'], (string) $b['customer_no']); + }); + + // Top by communication: most tickets in the period first, then total, + // then most recent contact. Tie-break by customer_no. + usort($topRows, static function (array $a, array $b): int { + if ($a['ticket_count_period'] !== $b['ticket_count_period']) { + return $b['ticket_count_period'] <=> $a['ticket_count_period']; + } + if ($a['ticket_count_total'] !== $b['ticket_count_total']) { + return $b['ticket_count_total'] <=> $a['ticket_count_total']; + } + $aTs = $a['last_contact_ts'] ?? 0; + $bTs = $b['last_contact_ts'] ?? 0; + if ($aTs !== $bTs) { + return $bTs <=> $aTs; + } + + return strcmp((string) $a['customer_no'], (string) $b['customer_no']); + }); + + // Top list only makes sense for customers with communication in the period. + $topRows = array_values(array_filter( + $topRows, + static fn (array $r): bool => $r['ticket_count_period'] > 0 + )); + + return [ + 'summary' => [ + 'total_customers' => count($customers), + 'active' => $active, + 'silent' => $silent, + 'no_contact_ever' => $noContactEver, + 'tickets_total' => count($tickets), + ], + 'silent' => array_slice($silentRows, 0, 100), + 'top_communication' => array_slice($topRows, 0, 25), + 'meta' => [ + 'period_days' => $periodDays, + 'inactivity_days' => $inactivityDays, + 'tickets_total' => count($tickets), + 'truncated' => $truncated, + 'confidence' => $truncated ? 'medium' : 'high', + 'generated_ts' => $nowTs, + ], + ]; + } + + /** + * Classify a customer's engagement from their last contact timestamp. + * + * @return 'active'|'silent'|'no_contact_ever' + */ + private static function classify(?int $lastContactTs, int $inactivityCutoff): string + { + if ($lastContactTs === null) { + return 'no_contact_ever'; + } + + return $lastContactTs >= $inactivityCutoff ? 'active' : 'silent'; + } + + private static function parseTimestamp(string $value): ?int + { + $value = trim($value); + if ($value === '' || str_starts_with($value, '0001-01-01')) { + return null; + } + + $ts = strtotime($value); + + return is_int($ts) ? $ts : null; + } + + /** + * Most recent signal for a ticket: prefer Last_Activity_Date, fall back to Created_On. + * + * @param array $ticket + */ + private static function resolveActivityTimestamp(array $ticket): ?int + { + $lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? '')); + if (!str_starts_with($lastActivity, '0001-01-01')) { + $ts = strtotime($lastActivity); + if (is_int($ts)) { + return $ts; + } + } + + return self::parseTimestamp((string) ($ticket['Created_On'] ?? '')); + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php index 61f2eb0..b9982f4 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php @@ -97,6 +97,11 @@ final class DebitorCacheControl return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays; } + public static function engagementKey(string $tenantScope, int $periodDays): string + { + return 'module.helpdesk.engagement.v1.' . $tenantScope . '.' . $periodDays; + } + /** @api Used by BcODataGateway::fetchUpdateTickets() */ public static function updateTicketsKey(string $tenantScope): string { diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php index 67931a3..d1f7957 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php @@ -24,6 +24,13 @@ class HelpdeskSettingsGateway public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint'; public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config'; public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config'; + /** @api Read in helpdesk/settings + analytics pages */ + public const KEY_INACTIVITY_THRESHOLD_DAYS = 'helpdesk.inactivity_threshold_days'; + + /** @api Default surfaced in views + tests */ + public const DEFAULT_INACTIVITY_THRESHOLD_DAYS = 60; + private const MIN_INACTIVITY_THRESHOLD_DAYS = 7; + private const MAX_INACTIVITY_THRESHOLD_DAYS = 365; public const AUTH_MODE_BASIC = 'basic'; public const AUTH_MODE_OAUTH2 = 'oauth2'; @@ -518,6 +525,49 @@ class HelpdeskSettingsGateway return $normalized; } + /** + * Days of silence after which a customer is flagged as "no contact". + * Clamped to a sane range; falls back to the default on invalid/empty values. + * + * @api Used by helpdesk/analytics + helpdesk/settings pages + */ + public function getInactivityThresholdDays(): int + { + $value = $this->settingsMetadataGateway->getValue(self::KEY_INACTIVITY_THRESHOLD_DAYS); + $value = trim((string) ($value ?? '')); + if ($value === '' || !is_numeric($value)) { + return self::DEFAULT_INACTIVITY_THRESHOLD_DAYS; + } + + return max( + self::MIN_INACTIVITY_THRESHOLD_DAYS, + min(self::MAX_INACTIVITY_THRESHOLD_DAYS, (int) $value) + ); + } + + /** @api Used by the helpdesk/settings page */ + public function setInactivityThresholdDays(?int $days): bool + { + if ($days === null) { + return $this->settingsMetadataGateway->set( + self::KEY_INACTIVITY_THRESHOLD_DAYS, + null, + 'helpdesk.inactivity_threshold_days' + ); + } + + $clamped = max( + self::MIN_INACTIVITY_THRESHOLD_DAYS, + min(self::MAX_INACTIVITY_THRESHOLD_DAYS, $days) + ); + + return $this->settingsMetadataGateway->set( + self::KEY_INACTIVITY_THRESHOLD_DAYS, + (string) $clamped, + 'helpdesk.inactivity_threshold_days' + ); + } + /** * Build the full OData entity URL for a given entity set. */ diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 4f59e99..5ab845f 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -45,6 +45,8 @@ return [ ['path' => 'helpdesk/team-workload-data', 'target' => 'helpdesk/team-workload-data'], ['path' => 'helpdesk/risk-radar', 'target' => 'helpdesk/risk-radar'], ['path' => 'helpdesk/risk-radar-data', 'target' => 'helpdesk/risk-radar-data'], + ['path' => 'helpdesk/analytics', 'target' => 'helpdesk/analytics'], + ['path' => 'helpdesk/analytics-data', 'target' => 'helpdesk/analytics-data'], ['path' => 'helpdesk/software-products', 'target' => 'helpdesk/software-products'], ['path' => 'helpdesk/software-products-data', 'target' => 'helpdesk/software-products-data'], ['path' => 'helpdesk/software-products/edit/{code}', 'target' => 'helpdesk/software-products/edit'], @@ -138,6 +140,17 @@ return [ 'permission' => 'helpdesk.risk-radar.view', 'order' => 805, ], + [ + 'key' => 'helpdesk-analytics', + 'script' => 'modules/helpdesk/js/helpdesk-analytics.js', + 'export' => 'initHelpdeskAnalytics', + 'selector' => '[data-app-component="helpdesk-analytics"]', + 'config_path' => 'components.helpdesk.analytics', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.risk-radar.view', + 'order' => 809, + ], [ 'key' => 'helpdesk-settings', 'script' => 'modules/helpdesk/js/helpdesk-settings.js', diff --git a/modules/helpdesk/pages/helpdesk/analytics-data().php b/modules/helpdesk/pages/helpdesk/analytics-data().php new file mode 100644 index 0000000..03d1507 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/analytics-data().php @@ -0,0 +1,86 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + + return; +} + +$periodDays = (int) $request->query('periodDays', '90'); +$refreshRequested = DebitorCacheControl::parseRefreshFlag($request->query('refresh', '')); + +$allowedPeriods = [30, 90, 180, 365]; +if (!in_array($periodDays, $allowedPeriods, true)) { + $periodDays = 90; +} + +$settingsGateway = app(HelpdeskSettingsGateway::class); +$inactivityDays = $settingsGateway->getInactivityThresholdDays(); + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantScope = DebitorCacheControl::resolveTenantScope($session); + +$cacheKey = DebitorCacheControl::engagementKey($tenantScope, $periodDays); +$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS; +$cached = $sessionStore->get($cacheKey); + +// Inactivity threshold is cheap to apply and may change between requests, so the +// snapshot is cached on (tenant, period) only; the threshold is re-applied below. +if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + $tickets = is_array($cached['tickets'] ?? null) ? $cached['tickets'] : null; + $truncated = (bool) ($cached['truncated'] ?? false); + if ($tickets !== null) { + $engagement = CustomerEngagementService::buildEngagement($tickets, $periodDays, $inactivityDays, time(), $truncated); + $engagement['meta']['cache_used'] = true; + Router::json(array_merge(['ok' => true], $engagement)); + + return; + } +} + +if ($refreshRequested) { + $sessionStore->remove($cacheKey); +} + +$gateway = app(BcODataGateway::class); + +try { + $ticketData = $gateway->getTicketsForRiskRadar($periodDays); +} catch (\Throwable) { + Router::json(['ok' => false, 'error' => 'Failed to load ticket data']); + + return; +} + +$tickets = is_array($ticketData['tickets'] ?? null) ? $ticketData['tickets'] : []; +$truncated = (bool) ($ticketData['truncated'] ?? false); + +$sessionStore->set($cacheKey, [ + 'tickets' => $tickets, + 'truncated' => $truncated, + 'fetched_at' => time(), +]); + +$engagement = CustomerEngagementService::buildEngagement($tickets, $periodDays, $inactivityDays, time(), $truncated); + +Router::json(array_merge(['ok' => true], $engagement, [ + 'meta' => array_merge($engagement['meta'], [ + 'cache_used' => false, + 'cache_bypassed' => $refreshRequested, + ]), +])); diff --git a/modules/helpdesk/pages/helpdesk/analytics/index().php b/modules/helpdesk/pages/helpdesk/analytics/index().php new file mode 100644 index 0000000..970f7c1 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/analytics/index().php @@ -0,0 +1,21 @@ +isConfigured(); +$inactivityDays = $settingsGateway->getInactivityThresholdDays(); + +Buffer::set('title', t('Customer analytics')); +Buffer::set('style_groups', json_encode(['helpdesk'])); +$breadcrumbs = [ + ['label' => t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Customer analytics')], +]; diff --git a/modules/helpdesk/pages/helpdesk/analytics/index(default).phtml b/modules/helpdesk/pages/helpdesk/analytics/index(default).phtml new file mode 100644 index 0000000..c2b1f36 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/analytics/index(default).phtml @@ -0,0 +1,147 @@ + t('30 days'), + 90 => t('90 days'), + 180 => t('6 months'), + 365 => t('1 year'), +]; +?> + + + + +
+
+ t('Customer analytics'), + 'actions' => [ + [ + 'label' => t('Refresh data'), + 'type' => 'button', + 'name' => 'analytics-refresh', + 'class' => 'secondary outline', + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + ?> + +
+
+
+ + checked> + + +
+

+ : + +

+
+ +
+
+ +
+ +
+
+ + + + + + +
+
+
diff --git a/modules/helpdesk/pages/helpdesk/settings/index().php b/modules/helpdesk/pages/helpdesk/settings/index().php index 815e810..7516a25 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index().php +++ b/modules/helpdesk/pages/helpdesk/settings/index().php @@ -193,6 +193,10 @@ if ($request->isMethod('POST')) { ], ]); + if (isset($post['inactivity_threshold_days']) && is_numeric($post['inactivity_threshold_days'])) { + $settingsGateway->setInactivityThresholdDays((int) $post['inactivity_threshold_days']); + } + $tokenRepository->deleteAll(); $action = trim((string) ($post['action'] ?? 'save')); @@ -235,6 +239,8 @@ $controllingConfig = is_array($controllingConfigEnvelope['config'] ?? null) : []; $controllingConfigSource = (string) ($controllingConfigEnvelope['source'] ?? 'default'); +$inactivityThresholdDays = $settingsGateway->getInactivityThresholdDays(); + // Load tenant override values $tenantOverrideEnabled = false; $tenantRow = null; diff --git a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml index a9bc8cb..9934579 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml @@ -482,6 +482,19 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2; + +
+ + + +
+ +
+
diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index b5cc5df..c526e02 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -14,12 +14,13 @@ $dashboardActive = navActive('helpdesk/dashboard', true); $settingsActive = navActive('helpdesk/settings', true); $teamActive = navActive('helpdesk/team', true); $riskRadarActive = navActive('helpdesk/risk-radar', true); +$analyticsActive = navActive('helpdesk/analytics', true); $domainsActive = navActive(['helpdesk/domains', 'helpdesk/domain'], true); $softwareProductsActive = navActive('helpdesk/software-products', true); $handoversActive = navActive('helpdesk/handovers', true); $updatesActive = navActive('helpdesk/updates', true); $customersActive = navActive('helpdesk', true); -if ($dashboardActive['isActive'] || $settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) { +if ($dashboardActive['isActive'] || $settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive'] || $analyticsActive['isActive'] || $domainsActive['isActive'] || $softwareProductsActive['isActive'] || $handoversActive['isActive'] || $updatesActive['isActive']) { $customersActive = ['class' => '', 'aria' => '', 'isActive' => false]; } @@ -73,6 +74,12 @@ $helpdeskNavGroups = [ 'active' => $riskRadarActive, 'visible' => $canViewRiskRadar, ], + [ + 'label' => t('Customer analytics'), + 'path' => 'helpdesk/analytics', + 'active' => $analyticsActive, + 'visible' => $canViewRiskRadar, + ], ], ], [ diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/CustomerEngagementServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/CustomerEngagementServiceTest.php new file mode 100644 index 0000000..872ef0f --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/CustomerEngagementServiceTest.php @@ -0,0 +1,183 @@ + + */ + private static function ticket( + string $no, + string $customerNo, + string $customerName, + int $createdDaysAgo, + ?int $lastActivityDaysAgo = null + ): array { + $createdTs = self::NOW_TS - ($createdDaysAgo * 86400); + $activityTs = $lastActivityDaysAgo !== null + ? self::NOW_TS - ($lastActivityDaysAgo * 86400) + : $createdTs; + + return [ + 'No' => $no, + 'Customer_No' => $customerNo, + 'Cust_Name' => $customerName, + 'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs), + 'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs), + 'Ticket_State' => 'Offen', + ]; + } + + public function testEmptyTicketsReturnsEmptyResult(): void + { + $result = CustomerEngagementService::buildEngagement([], 90, 60, self::NOW_TS); + + $this->assertSame(0, $result['summary']['total_customers']); + $this->assertSame(0, $result['summary']['active']); + $this->assertSame(0, $result['summary']['silent']); + $this->assertSame([], $result['silent']); + $this->assertSame([], $result['top_communication']); + } + + public function testSkipsTicketsWithoutCustomerNo(): void + { + $tickets = [ + self::ticket('T1', '', 'No Customer', 5), + self::ticket('T2', '10610', 'Valid', 5), + ]; + + $result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS); + + $this->assertSame(1, $result['summary']['total_customers']); + } + + public function testAggregatesTicketCountsPerCustomer(): void + { + $tickets = [ + self::ticket('T1', '10610', 'Kunde A', 5), + self::ticket('T2', '10610', 'Kunde A', 10), + self::ticket('T3', '10620', 'Kunde B', 3), + ]; + + $result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS); + + $this->assertSame(2, $result['summary']['total_customers']); + + $top = $result['top_communication']; + $byCustomer = []; + foreach ($top as $row) { + $byCustomer[$row['customer_no']] = $row; + } + $this->assertSame(2, $byCustomer['10610']['ticket_count_period']); + $this->assertSame(1, $byCustomer['10620']['ticket_count_period']); + } + + public function testActiveCustomerWithinThreshold(): void + { + // Last contact 10 days ago, threshold 60 => active. + $tickets = [self::ticket('T1', '10610', 'Kunde A', 10)]; + + $result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS); + + $this->assertSame(1, $result['summary']['active']); + $this->assertSame(0, $result['summary']['silent']); + $this->assertSame([], $result['silent']); + } + + public function testSilentCustomerBeyondThreshold(): void + { + // Last contact 90 days ago, threshold 60 => silent. + $tickets = [self::ticket('T1', '10610', 'Kunde A', 90)]; + + $result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS); + + $this->assertSame(0, $result['summary']['active']); + $this->assertSame(1, $result['summary']['silent']); + $this->assertCount(1, $result['silent']); + $this->assertSame('10610', $result['silent'][0]['customer_no']); + $this->assertSame('silent', $result['silent'][0]['status']); + $this->assertSame(90, $result['silent'][0]['days_since_contact']); + } + + public function testLastContactUsesMostRecentActivity(): void + { + // Two tickets: one created 100 days ago but updated 5 days ago, one created 50 days ago. + // Most recent activity is 5 days ago => active. + $tickets = [ + self::ticket('T1', '10610', 'Kunde A', 100, 5), + self::ticket('T2', '10610', 'Kunde A', 50, 50), + ]; + + $result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS); + + $this->assertSame(1, $result['summary']['active']); + $row = $result['top_communication'][0]; + $this->assertSame(5, $row['days_since_contact']); + } + + public function testSilentListSortedByLongestSilenceFirst(): void + { + $tickets = [ + self::ticket('T1', '10610', 'Recently Silent', 70), + self::ticket('T2', '10620', 'Long Silent', 200), + self::ticket('T3', '10630', 'Mid Silent', 120), + ]; + + $result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS); + + $order = array_column($result['silent'], 'customer_no'); + $this->assertSame(['10620', '10630', '10610'], $order); + } + + public function testTopCommunicationExcludesCustomersWithNoPeriodActivity(): void + { + // Ticket older than the 30-day period => not counted in top list. + $tickets = [ + self::ticket('T1', '10610', 'Active', 5), + self::ticket('T2', '10620', 'Old', 100), + ]; + + $result = CustomerEngagementService::buildEngagement($tickets, 30, 60, self::NOW_TS); + + $tops = array_column($result['top_communication'], 'customer_no'); + $this->assertContains('10610', $tops); + $this->assertNotContains('10620', $tops); + } + + public function testIgnoresUninitializedBcDates(): void + { + // BC sentinel date 0001-01-01 must be treated as "no contact". + $tickets = [[ + 'No' => 'T1', + 'Customer_No' => '10610', + 'Cust_Name' => 'Kunde A', + 'Created_On' => '0001-01-01T00:00:00Z', + 'Last_Activity_Date' => '0001-01-01T00:00:00Z', + 'Ticket_State' => 'Offen', + ]]; + + $result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS); + + $this->assertSame(1, $result['summary']['no_contact_ever']); + $this->assertNull($result['silent'][0]['last_contact_ts']); + $this->assertSame('no_contact_ever', $result['silent'][0]['status']); + } + + public function testMetaReflectsInputs(): void + { + $result = CustomerEngagementService::buildEngagement([], 180, 45, self::NOW_TS, true); + + $this->assertSame(180, $result['meta']['period_days']); + $this->assertSame(45, $result['meta']['inactivity_days']); + $this->assertTrue($result['meta']['truncated']); + $this->assertSame('medium', $result['meta']['confidence']); + $this->assertSame(self::NOW_TS, $result['meta']['generated_ts']); + } +} diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php index 80fab71..13e3766 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/HelpdeskSettingsGatewayTest.php @@ -78,6 +78,53 @@ class HelpdeskSettingsGatewayTest extends TestCase $this->assertNull($gateway->getBasicPassword()); } + public function testGetInactivityThresholdDaysDefaultsWhenUnset(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn(null); + + $gateway = $this->createGateway($settings); + $this->assertSame( + HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS, + $gateway->getInactivityThresholdDays() + ); + } + + public function testGetInactivityThresholdDaysClampsToRange(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn('999'); + + $gateway = $this->createGateway($settings); + // Clamped to the max (365). + $this->assertSame(365, $gateway->getInactivityThresholdDays()); + } + + public function testGetInactivityThresholdDaysFallsBackOnNonNumeric(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->method('getValue')->willReturn('not-a-number'); + + $gateway = $this->createGateway($settings); + $this->assertSame( + HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS, + $gateway->getInactivityThresholdDays() + ); + } + + public function testSetInactivityThresholdDaysClampsAndPersists(): void + { + $settings = $this->createMock(SettingRepositoryInterface::class); + $settings->expects($this->once()) + ->method('set') + ->with(HelpdeskSettingsGateway::KEY_INACTIVITY_THRESHOLD_DAYS, '7', 'helpdesk.inactivity_threshold_days') + ->willReturn(true); + + $gateway = $this->createGateway($settings); + // 1 is below the minimum (7) => clamped up. + $this->assertTrue($gateway->setInactivityThresholdDays(1)); + } + public function testSetBasicPasswordEncryptsAndPersists(): void { $settings = $this->createMock(SettingRepositoryInterface::class); diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 7b4a763..3af5ccf 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -3133,4 +3133,77 @@ vertical-align: middle; color: var(--app-muted, #6c757d); } + + /* --- Customer analytics --- */ + .helpdesk-analytics-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.5); + margin-bottom: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-analytics-threshold-note { + margin: 0; + font-size: var(--text-sm, 0.875rem); + color: var(--app-muted, #6c757d); + } + + /* Stat tiles here are read-only (rendered as
), so suppress the link affordances. */ + .helpdesk-analytics-tile { + cursor: default; + } + + .helpdesk-analytics-tile:hover { + box-shadow: none; + } + + .helpdesk-analytics-block { + margin-top: calc(var(--app-spacing) * 1.5); + } + + .helpdesk-analytics-block-header { + margin-bottom: calc(var(--app-spacing) * 0.5); + } + + .helpdesk-analytics-block-header h2 { + margin: 0; + font-size: var(--text-lg, 1.125rem); + } + + .helpdesk-analytics-block-header .muted { + margin: 0.15rem 0 0; + font-size: var(--text-sm, 0.875rem); + color: var(--app-muted, #6c757d); + } + + .helpdesk-analytics-table { + width: 100%; + border-collapse: collapse; + } + + .helpdesk-analytics-table th, + .helpdesk-analytics-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--app-border, #dee2e6); + text-align: left; + vertical-align: middle; + } + + .helpdesk-analytics-table th.num, + .helpdesk-analytics-table td.num { + text-align: right; + white-space: nowrap; + } + + .helpdesk-analytics-cust-no { + color: var(--app-muted, #6c757d); + font-size: var(--text-sm, 0.875rem); + } + + .helpdesk-analytics-never { + color: var(--app-muted, #6c757d); + font-style: italic; + } } diff --git a/modules/helpdesk/web/js/helpdesk-analytics.js b/modules/helpdesk/web/js/helpdesk-analytics.js new file mode 100644 index 0000000..83afa05 --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-analytics.js @@ -0,0 +1,218 @@ +/** + * Customer analytics — engagement overview (active vs. silent customers, + * top communication). Reads the global ticket snapshot aggregated server-side. + */ +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; + +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-analytics"]')) { + return host; + } + return host.querySelector('.app-details-container[data-app-component="helpdesk-analytics"]'); +}; + +export function initHelpdeskAnalytics(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; + } + + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; + + const dataUrl = container.dataset.analyticsUrl; + const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; + const d = (key, fallback) => container.dataset[key] || fallback; + + const elLoading = document.getElementById('analytics-loading'); + const elError = document.getElementById('analytics-error'); + const elTruncated = document.getElementById('analytics-truncated'); + const elContent = document.getElementById('analytics-content'); + const elTiles = document.getElementById('analytics-tiles'); + const elSilentTable = document.getElementById('analytics-silent-table'); + const elSilentBody = document.getElementById('analytics-silent-body'); + const elSilentEmpty = document.getElementById('analytics-silent-empty'); + const elTopTable = document.getElementById('analytics-top-table'); + const elTopBody = document.getElementById('analytics-top-body'); + const elTopEmpty = document.getElementById('analytics-top-empty'); + const elRefresh = container.querySelector('button[name="analytics-refresh"]'); + const periodSelector = document.getElementById('analytics-period-selector'); + + let currentPeriod = 90; + + const showState = (state) => { + if (elLoading) elLoading.hidden = state !== 'loading'; + if (elError) elError.hidden = state !== 'error'; + if (elContent) elContent.hidden = state !== 'success'; + }; + + const h = (tag, cls, text) => { + const element = document.createElement(tag); + if (cls) element.className = cls; + if (text !== undefined) element.textContent = text; + return element; + }; + + const customerCell = (row) => { + const td = h('td'); + const name = row.customer_name || row.customer_no; + if (debitorBaseUrl && row.customer_no) { + const link = h('a', '', name); + link.href = debitorBaseUrl + encodeURIComponent(row.customer_no); + td.appendChild(link); + } else { + td.textContent = name; + } + if (row.customer_name && row.customer_no) { + td.appendChild(h('span', 'helpdesk-analytics-cust-no', ` ${row.customer_no}`)); + } + return td; + }; + + const lastContactText = (row) => { + if (row.days_since_contact === null || row.days_since_contact === undefined) { + return d('labelNeverShort', 'never'); + } + if (row.days_since_contact === 0) { + return row.last_contact_iso || ''; + } + return `${row.last_contact_iso || ''} · ${row.days_since_contact} ${d('labelDaysAgo', 'days ago')}`; + }; + + // Mirror templates/partials/app-tile.phtml so the .app-tile CSS applies, but + // render a non-navigating
— these are stat tiles, not links. + const buildTile = ({ icon, tone, count, label }) => { + const tile = h('div', 'app-tile helpdesk-analytics-tile'); + if (tone) tile.dataset.tone = tone; + const iconWrap = h('span', 'app-tile-icon'); + iconWrap.appendChild(h('i', `bi ${icon}`)); + tile.appendChild(iconWrap); + tile.appendChild(h('span', 'app-tile-count', String(count))); + tile.appendChild(h('span', 'app-tile-label', label)); + return tile; + }; + + const renderTiles = (summary) => { + if (!elTiles) return; + elTiles.replaceChildren(); + elTiles.appendChild(buildTile({ + icon: 'bi-people', tone: 'blue', + count: summary.total_customers || 0, label: d('labelTotal', 'Customers'), + })); + elTiles.appendChild(buildTile({ + icon: 'bi-chat-dots', tone: 'emerald', + count: summary.active || 0, label: d('labelActive', 'Active'), + })); + elTiles.appendChild(buildTile({ + icon: 'bi-exclamation-triangle', tone: 'amber', + count: summary.silent || 0, label: d('labelSilent', 'No contact'), + })); + elTiles.appendChild(buildTile({ + icon: 'bi-question-circle', tone: 'neutral', + count: summary.no_contact_ever || 0, label: d('labelNever', 'Never in contact'), + })); + }; + + const renderSilent = (rows) => { + if (!elSilentBody || !elSilentTable || !elSilentEmpty) return; + elSilentBody.replaceChildren(); + if (!rows || rows.length === 0) { + elSilentTable.hidden = true; + elSilentEmpty.hidden = false; + return; + } + elSilentEmpty.hidden = true; + elSilentTable.hidden = false; + for (const row of rows) { + const tr = h('tr'); + tr.appendChild(customerCell(row)); + const contactTd = h('td', '', lastContactText(row)); + if (row.status === 'no_contact_ever') { + contactTd.classList.add('helpdesk-analytics-never'); + } + tr.appendChild(contactTd); + tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0))); + elSilentBody.appendChild(tr); + } + }; + + const renderTop = (rows) => { + if (!elTopBody || !elTopTable || !elTopEmpty) return; + elTopBody.replaceChildren(); + if (!rows || rows.length === 0) { + elTopTable.hidden = true; + elTopEmpty.hidden = false; + return; + } + elTopEmpty.hidden = true; + elTopTable.hidden = false; + for (const row of rows) { + const tr = h('tr'); + tr.appendChild(customerCell(row)); + tr.appendChild(h('td', 'num', String(row.ticket_count_period || 0))); + tr.appendChild(h('td', '', lastContactText(row))); + elTopBody.appendChild(tr); + } + }; + + const fetchData = async (refresh = false) => { + showState('loading'); + if (elTruncated) elTruncated.hidden = true; + + const params = new URLSearchParams({ periodDays: String(currentPeriod) }); + if (refresh) params.set('refresh', '1'); + + try { + const json = await getJson(`${dataUrl}?${params.toString()}`, { + signal: requestController.signal, + }); + if (!json.ok) { showState('error'); return; } + + renderTiles(json.summary || {}); + renderSilent(json.silent || []); + renderTop(json.top_communication || []); + showState('success'); + + if (json.meta && json.meta.truncated && elTruncated) { + elTruncated.hidden = false; + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + showState('error'); + } + }; + + on(periodSelector, 'change', (event) => { + const radio = event.target.closest('input[name="analytics-period"]'); + if (!radio) return; + const period = parseInt(radio.value, 10); + if (!period || period === currentPeriod) return; + currentPeriod = period; + void fetchData(); + }); + + on(elRefresh, 'click', () => { + void fetchData(true); + }); + + void fetchData(); + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + }, + }; +}