feat(helpdesk): add customer engagement analytics page [skip ci]
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Customer engagement analytics — aggregates communication signals per customer
|
||||
* to surface who is actively engaged and who has gone quiet ("kein Kontakt").
|
||||
*
|
||||
* "Communication" is any ticket activity in the period (a created or updated
|
||||
* ticket counts as contact). The most recent ticket activity date is the
|
||||
* customer's "last contact". Customers whose last contact is older than the
|
||||
* configured inactivity threshold are flagged as silent so the team can decide
|
||||
* to reach out proactively.
|
||||
*
|
||||
* Pure transform (no I/O, no DI): the data endpoint fetches the global ticket
|
||||
* snapshot and passes it in, this aggregates and scores. Mirrors the design of
|
||||
* RiskRadarService (per-customer aggregation over one global ticket pull).
|
||||
*/
|
||||
final class CustomerEngagementService
|
||||
{
|
||||
private const SECONDS_PER_DAY = 86400;
|
||||
|
||||
/**
|
||||
* Build the complete engagement dataset.
|
||||
*
|
||||
* @api Used by the helpdesk/analytics-data endpoint
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $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<array<string, mixed>>,
|
||||
* top_communication: list<array<string, mixed>>,
|
||||
* 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<string, mixed> $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'] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
86
modules/helpdesk/pages/helpdesk/analytics-data().php
Normal file
86
modules/helpdesk/pages/helpdesk/analytics-data().php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
|
||||
use MintyPHP\Module\Helpdesk\Service\DebitorCacheControl;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_RISK_RADAR);
|
||||
|
||||
$request = requestInput();
|
||||
if ($request->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,
|
||||
]),
|
||||
]));
|
||||
21
modules/helpdesk/pages/helpdesk/analytics/index().php
Normal file
21
modules/helpdesk/pages/helpdesk/analytics/index().php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_RISK_RADAR);
|
||||
|
||||
$settingsGateway = app(HelpdeskSettingsGateway::class);
|
||||
$isConfigured = $settingsGateway->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')],
|
||||
];
|
||||
147
modules/helpdesk/pages/helpdesk/analytics/index(default).phtml
Normal file
147
modules/helpdesk/pages/helpdesk/analytics/index(default).phtml
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Customer analytics — engagement overview: who is actively in contact and
|
||||
* who has gone quiet (so the team can reach out before a customer slips away).
|
||||
*/
|
||||
|
||||
$isConfigured = $isConfigured ?? false;
|
||||
$inactivityDays = (int) ($inactivityDays ?? 60);
|
||||
|
||||
$periods = [30, 90, 180, 365];
|
||||
$periodLabels = [
|
||||
30 => t('30 days'),
|
||||
90 => t('90 days'),
|
||||
180 => t('6 months'),
|
||||
365 => t('1 year'),
|
||||
];
|
||||
?>
|
||||
<?php if (!$isConfigured): ?>
|
||||
<div class="notice" data-variant="warning" role="alert">
|
||||
<p><?php e(t('BC connection is not configured. Please configure the connection in the settings.')); ?></p>
|
||||
<a href="<?php e(lurl('helpdesk/settings')); ?>" role="button" class="secondary outline small">
|
||||
<?php e(t('Open settings')); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="app-details-container"
|
||||
data-app-component="helpdesk-analytics"
|
||||
data-analytics-url="<?php e(lurl('helpdesk/analytics-data')); ?>"
|
||||
data-debitor-base-url="<?php e(lurl('helpdesk/debitor/')); ?>"
|
||||
data-inactivity-days="<?php e($inactivityDays); ?>"
|
||||
data-label-error="<?php e(t('Could not load customer analytics.')); ?>"
|
||||
data-label-empty="<?php e(t('No customer data available.')); ?>"
|
||||
data-label-truncated="<?php e(t('Data may be incomplete. Not all tickets could be loaded.')); ?>"
|
||||
data-label-total="<?php e(t('Customers')); ?>"
|
||||
data-label-active="<?php e(t('Active')); ?>"
|
||||
data-label-silent="<?php e(t('No contact')); ?>"
|
||||
data-label-never="<?php e(t('Never in contact')); ?>"
|
||||
data-label-days-ago="<?php e(t('days ago')); ?>"
|
||||
data-label-never-short="<?php e(t('never')); ?>"
|
||||
data-label-tickets-period="<?php e(t('Tickets in period')); ?>"
|
||||
data-label-last-contact="<?php e(t('Last contact')); ?>"
|
||||
data-label-customer="<?php e(t('Customer')); ?>"
|
||||
data-label-silent-title="<?php e(t('Customers without recent contact')); ?>"
|
||||
data-label-silent-hint="<?php e(t('Longest silence first — consider reaching out.')); ?>"
|
||||
data-label-top-title="<?php e(t('Top customers by communication')); ?>"
|
||||
data-label-top-hint="<?php e(t('Most ticket activity in the selected period.')); ?>"
|
||||
data-label-silent-empty="<?php e(t('Every customer has been in contact recently.')); ?>"
|
||||
data-label-top-empty="<?php e(t('No communication recorded in the selected period.')); ?>"
|
||||
>
|
||||
<section>
|
||||
<?php
|
||||
$titlebar = [
|
||||
'title' => t('Customer analytics'),
|
||||
'actions' => [
|
||||
[
|
||||
'label' => t('Refresh data'),
|
||||
'type' => 'button',
|
||||
'name' => 'analytics-refresh',
|
||||
'class' => 'secondary outline',
|
||||
],
|
||||
],
|
||||
];
|
||||
require templatePath('partials/app-details-titlebar.phtml');
|
||||
?>
|
||||
|
||||
<div class="helpdesk-analytics-dashboard">
|
||||
<div class="helpdesk-analytics-toolbar">
|
||||
<div class="helpdesk-segment-control" id="analytics-period-selector">
|
||||
<?php foreach ($periods as $p): ?>
|
||||
<input type="radio" name="analytics-period" id="analytics-period-<?php e($p); ?>" value="<?php e($p); ?>"<?php if ($p === 90): ?> checked<?php endif; ?>>
|
||||
<label for="analytics-period-<?php e($p); ?>"><?php e($periodLabels[$p]); ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="helpdesk-analytics-threshold-note">
|
||||
<?php e(t('No-contact threshold')); ?>:
|
||||
<strong><?php e($inactivityDays); ?> <?php e(t('days')); ?></strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="analytics-loading">
|
||||
<div class="app-tiles">
|
||||
<?php for ($i = 0; $i < 4; $i++): ?>
|
||||
<div class="helpdesk-skeleton" style="height: 5.5rem; border-radius: var(--app-radius, 0.375rem);"></div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="analytics-error" class="notice" data-variant="error" role="alert" hidden>
|
||||
<p><?php e(t('Could not load customer analytics.')); ?></p>
|
||||
</div>
|
||||
|
||||
<div id="analytics-truncated" class="notice" data-variant="warning" role="status" hidden>
|
||||
<p><?php e(t('Data may be incomplete. Not all tickets could be loaded.')); ?></p>
|
||||
</div>
|
||||
|
||||
<div id="analytics-content" hidden>
|
||||
<div id="analytics-tiles" class="app-tiles"></div>
|
||||
|
||||
<section class="helpdesk-analytics-block" aria-labelledby="analytics-silent-heading">
|
||||
<header class="helpdesk-analytics-block-header">
|
||||
<h2 id="analytics-silent-heading"><?php e(t('Customers without recent contact')); ?></h2>
|
||||
<p class="muted"><?php e(t('Longest silence first — consider reaching out.')); ?></p>
|
||||
</header>
|
||||
<div id="analytics-silent-empty" class="notice" data-variant="info" role="status" hidden>
|
||||
<p><?php e(t('Every customer has been in contact recently.')); ?></p>
|
||||
</div>
|
||||
<div class="app-list-table">
|
||||
<table class="helpdesk-analytics-table" id="analytics-silent-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php e(t('Customer')); ?></th>
|
||||
<th><?php e(t('Last contact')); ?></th>
|
||||
<th class="num"><?php e(t('Tickets in period')); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-silent-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="helpdesk-analytics-block" aria-labelledby="analytics-top-heading">
|
||||
<header class="helpdesk-analytics-block-header">
|
||||
<h2 id="analytics-top-heading"><?php e(t('Top customers by communication')); ?></h2>
|
||||
<p class="muted"><?php e(t('Most ticket activity in the selected period.')); ?></p>
|
||||
</header>
|
||||
<div id="analytics-top-empty" class="notice" data-variant="info" role="status" hidden>
|
||||
<p><?php e(t('No communication recorded in the selected period.')); ?></p>
|
||||
</div>
|
||||
<div class="app-list-table">
|
||||
<table class="helpdesk-analytics-table" id="analytics-top-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php e(t('Customer')); ?></th>
|
||||
<th class="num"><?php e(t('Tickets in period')); ?></th>
|
||||
<th><?php e(t('Last contact')); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="analytics-top-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
@@ -482,6 +482,19 @@ $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2;
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="app-details-card" name="helpdesk-customer-engagement" data-details-key="helpdesk-settings-customer-engagement-v1" open>
|
||||
<summary>
|
||||
<span class="app-details-card-summary-title"><?php e(t('Customer engagement')); ?></span>
|
||||
</summary>
|
||||
<div class="app-details-card-container">
|
||||
<label class="app-field" for="inactivity_threshold_days">
|
||||
<span><?php e(t('No-contact threshold (days)')); ?></span>
|
||||
<input type="number" id="inactivity_threshold_days" name="inactivity_threshold_days" min="7" max="365" value="<?php e((int) ($inactivityThresholdDays ?? 60)); ?>">
|
||||
<small><?php e(t('Customers without any communication for longer than this are flagged in the customer analytics view.')); ?></small>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CustomerEngagementServiceTest extends TestCase
|
||||
{
|
||||
// Fixed "now" so tests are deterministic: 2026-06-25 12:00:00 UTC.
|
||||
private const NOW_TS = 1782475200;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <div>), 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;
|
||||
}
|
||||
}
|
||||
|
||||
218
modules/helpdesk/web/js/helpdesk-analytics.js
Normal file
218
modules/helpdesk/web/js/helpdesk-analytics.js
Normal file
@@ -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 <div> — 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user