diff --git a/db/init/init.sql b/db/init/init.sql index 7b562bf..6459464 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1551,7 +1551,7 @@ JOIN permissions p ON p.`key` IN ( 'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge', 'stats.view', 'system_info.view', 'roles.assign_all', - 'helpdesk.access', 'helpdesk.settings.manage', 'helpdesk.team-workload.view' + 'helpdesk.access', 'helpdesk.settings.manage', 'helpdesk.team-workload.view', 'helpdesk.risk-radar.view' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/db/updates/2026-04-06-helpdesk-risk-radar-permission.sql b/db/updates/2026-04-06-helpdesk-risk-radar-permission.sql new file mode 100644 index 0000000..3fdcdb0 --- /dev/null +++ b/db/updates/2026-04-06-helpdesk-risk-radar-permission.sql @@ -0,0 +1,9 @@ +-- Helpdesk module: assign risk radar permission to Admin role. +-- Idempotent: safe to run multiple times. + +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'helpdesk.risk-radar.view' +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 06a1761..3785570 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -305,5 +305,23 @@ "trend_tooltip_up": "Mehr Tickets erhalten als gelöst im Zeitraum — Rückstau wächst.", "trend_tooltip_down": "Mehr Tickets gelöst als erhalten im Zeitraum — Rückstau wird abgebaut.", "Download file": "Datei herunterladen", - "Trend based on last {days} days": "Trend basiert auf den letzten {days} Tagen" + "Trend based on last {days} days": "Trend basiert auf den letzten {days} Tagen", + "Trend": "Trend", + "Risk radar": "Risiko-Radar", + "Risk score": "Risiko-Score", + "High risk": "Hohes Risiko", + "Medium risk": "Mittleres Risiko", + "Low risk": "Niedriges Risiko", + "SLA overdue": "SLA überfällig", + "Net flow": "Netto-Zufluss", + "Open pressure": "Offene Tickets", + "Inactivity": "Inaktivität", + "No risk data available.": "Keine Risikodaten verfügbar.", + "Could not load risk radar.": "Risiko-Radar konnte nicht geladen werden.", + "Data may be incomplete. Not all tickets could be loaded.": "Daten möglicherweise unvollständig. Nicht alle Tickets konnten geladen werden.", + "Why at risk": "Warum kritisch", + "open": "offen", + "overdue": "überfällig", + "net": "netto", + "Search customers...": "Kunden suchen..." } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 98b59f2..d4ebabe 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -305,5 +305,23 @@ "trend_tooltip_up": "More tickets received than resolved in the period — backlog is growing.", "trend_tooltip_down": "More tickets resolved than received in the period — backlog is shrinking.", "Download file": "Download file", - "Trend based on last {days} days": "Trend based on last {days} days" + "Trend based on last {days} days": "Trend based on last {days} days", + "Trend": "Trend", + "Risk radar": "Risk radar", + "Risk score": "Risk score", + "High risk": "High risk", + "Medium risk": "Medium risk", + "Low risk": "Low risk", + "SLA overdue": "SLA overdue", + "Net flow": "Net flow", + "Open pressure": "Open pressure", + "Inactivity": "Inactivity", + "No risk data available.": "No risk data available.", + "Could not load risk radar.": "Could not load risk radar.", + "Data may be incomplete. Not all tickets could be loaded.": "Data may be incomplete. Not all tickets could be loaded.", + "Why at risk": "Why at risk", + "open": "open", + "overdue": "overdue", + "net": "net", + "Search customers...": "Search customers..." } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php index e84d600..ec1c466 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskAuthorizationPolicy.php @@ -11,10 +11,12 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public const ABILITY_ACCESS = 'helpdesk.access'; public const ABILITY_SETTINGS_MANAGE = 'helpdesk.settings.manage'; public const ABILITY_TEAM_WORKLOAD = 'helpdesk.team-workload.view'; + public const ABILITY_RISK_RADAR = 'helpdesk.risk-radar.view'; public const PERMISSION_ACCESS = 'helpdesk.access'; public const PERMISSION_SETTINGS_MANAGE = 'helpdesk.settings.manage'; public const PERMISSION_TEAM_WORKLOAD = 'helpdesk.team-workload.view'; + public const PERMISSION_RISK_RADAR = 'helpdesk.risk-radar.view'; public function __construct( private readonly PermissionService $permissionService @@ -23,7 +25,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface public function supports(string $ability): bool { - return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD], true); + return in_array($ability, [self::ABILITY_ACCESS, self::ABILITY_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD, self::ABILITY_RISK_RADAR], true); } public function authorize(string $ability, array $context = []): AuthorizationDecision @@ -37,6 +39,7 @@ final class HelpdeskAuthorizationPolicy implements AuthorizationPolicyInterface self::ABILITY_ACCESS => self::PERMISSION_ACCESS, self::ABILITY_SETTINGS_MANAGE => self::PERMISSION_SETTINGS_MANAGE, self::ABILITY_TEAM_WORKLOAD => self::PERMISSION_TEAM_WORKLOAD, + self::ABILITY_RISK_RADAR => self::PERMISSION_RISK_RADAR, default => null, }; diff --git a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php index a3637b7..54a4572 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Providers/HelpdeskLayoutProvider.php @@ -20,14 +20,17 @@ final class HelpdeskLayoutProvider implements LayoutContextProvider $actorContext = ['actor_user_id' => $userId]; $canManageSettings = $authorizationService->authorize('helpdesk.settings.manage', $actorContext)->isAllowed(); $canViewTeam = $authorizationService->authorize('helpdesk.team-workload.view', $actorContext)->isAllowed(); + $canViewRiskRadar = $authorizationService->authorize('helpdesk.risk-radar.view', $actorContext)->isAllowed(); } catch (\Throwable) { $canManageSettings = false; $canViewTeam = false; + $canViewRiskRadar = false; } return ['helpdesk.nav' => [ 'can_manage_settings' => $canManageSettings, 'can_view_team' => $canViewTeam, + 'can_view_risk_radar' => $canViewRiskRadar, ]]; } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index b36abe0..02c2ddf 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -611,6 +611,56 @@ class BcODataGateway return $this->extractODataValues($response); } + /** + * Get tickets across all customers for risk radar aggregation. + * + * Uses PBI_LV_Tickets with client-driven paging ($skip/$top). + * Hardcap: 5000 rows (10 pages × 500 per page). + * + * @return array{tickets: array>, truncated: bool} + */ + public function getTicketsForRiskRadar(): array + { + $pageSize = 500; + $maxPages = 10; + $select = 'No,Customer_No,Cust_Name,Escalation_Code,Created_On,Last_Activity_Date,Process_Stage,Ticket_State,Support_User_Name,Category_1_Code,Process_Code'; + $allTickets = []; + $truncated = false; + + for ($page = 0; $page < $maxPages; $page++) { + $skip = $page * $pageSize; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) + . '?$top=' . $pageSize + . '&$skip=' . $skip + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('Created_On desc'); + + $response = $this->request('GET', $url); + if ($response === null) { + break; + } + + $rows = $this->extractODataValues($response); + if ($rows === []) { + break; + } + + foreach ($rows as $row) { + $allTickets[] = $row; + } + + if (count($rows) < $pageSize) { + break; + } + + if ($page === $maxPages - 1) { + $truncated = true; + } + } + + return ['tickets' => $allTickets, 'truncated' => $truncated]; + } + /** * Get contracts for a customer by customer number. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php index d5c3f8f..f185206 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php @@ -87,6 +87,11 @@ final class DebitorCacheControl return 'module.helpdesk.team.v1.workload.' . $tenantScope; } + public static function riskRadarKey(string $tenantScope, int $periodDays): string + { + return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays; + } + /** * @return array */ diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php new file mode 100644 index 0000000..3fda438 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/RiskRadarService.php @@ -0,0 +1,417 @@ +> $tickets Raw OData tickets + * @param array> $escalationDefs Escalation definitions with Code + Target_Time + * @param int $periodDays One of 30, 90, 180, 365 + * @param bool $truncated Whether the ticket set was capped + * + * @return array{summary: array, customers: array>, meta: array} + */ + public static function buildRiskRadar( + array $tickets, + array $escalationDefs, + int $periodDays, + bool $truncated = false + ): array { + $nowTs = time(); + $periodStart = $nowTs - ($periodDays * 86400); + $prevPeriodStart = $periodStart - ($periodDays * 86400); + + // Build escalation target map: Code → seconds + $slaTargets = self::buildSlaTargetMap($escalationDefs); + + // Aggregate per customer + $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'] ?? '')), + 'open' => 0, + 'critical' => 0, + 'created_current' => 0, + 'closed_current' => 0, + 'created_prev' => 0, + 'closed_prev' => 0, + 'sla_overdue_count' => 0, + 'resolution_hours' => [], + 'oldest_open_age_hours' => 0, + 'open_tickets' => [], + ]; + } + + $state = trim((string) ($ticket['Ticket_State'] ?? '')); + $isClosed = in_array($state, self::CLOSED_STATES, true) + || (isset($ticket['Process_Stage']) && (int) $ticket['Process_Stage'] === 40); + + $createdTs = self::parseTimestamp((string) ($ticket['Created_On'] ?? '')); + $activityTs = self::resolveActivityTimestamp($ticket); + + if ($isClosed) { + if ($activityTs !== null && $activityTs >= $periodStart) { + $customers[$customerNo]['closed_current']++; + if ($createdTs !== null && $activityTs > $createdTs) { + $customers[$customerNo]['resolution_hours'][] = intdiv($activityTs - $createdTs, 3600); + } + } + if ($activityTs !== null && $activityTs >= $prevPeriodStart && $activityTs < $periodStart) { + $customers[$customerNo]['closed_prev']++; + } + if ($createdTs !== null && $createdTs >= $periodStart) { + $customers[$customerNo]['created_current']++; + } + if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) { + $customers[$customerNo]['created_prev']++; + } + } else { + $customers[$customerNo]['open']++; + + if ($createdTs !== null && $createdTs >= $periodStart) { + $customers[$customerNo]['created_current']++; + } + if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) { + $customers[$customerNo]['created_prev']++; + } + + // Critical: open > 48h + if ($activityTs !== null) { + $ageHours = intdiv(max(0, $nowTs - $activityTs), 3600); + if ($ageHours > 48) { + $customers[$customerNo]['critical']++; + } + if ($ageHours > $customers[$customerNo]['oldest_open_age_hours']) { + $customers[$customerNo]['oldest_open_age_hours'] = $ageHours; + } + } + + // SLA overdue check + $escalationCode = trim((string) ($ticket['Escalation_Code'] ?? '')); + if ($escalationCode !== '' && $createdTs !== null && isset($slaTargets[$escalationCode])) { + $elapsed = $nowTs - $createdTs; + if ($elapsed > $slaTargets[$escalationCode]) { + $customers[$customerNo]['sla_overdue_count']++; + } + } + + // Collect open ticket info for detail dialog + $customers[$customerNo]['open_tickets'][] = [ + 'no' => trim((string) ($ticket['No'] ?? '')), + 'state' => $state, + 'age_hours' => $activityTs !== null ? intdiv(max(0, $nowTs - $activityTs), 3600) : 0, + 'escalation_code' => $escalationCode, + 'category' => trim((string) ($ticket['Category_1_Code'] ?? '')), + ]; + } + } + + // Score each customer + $scoredCustomers = []; + $summaryHigh = 0; + $summaryMedium = 0; + $summaryLow = 0; + + foreach ($customers as $customerNo => $c) { + $hasActivity = $c['open'] > 0 || $c['created_current'] > 0 || $c['closed_current'] > 0; + if (!$hasActivity) { + continue; + } + + $dimensions = self::scoreDimensions($c); + $totalScore = self::computeTotalScore($dimensions); + $level = $totalScore >= self::LEVEL_HIGH ? 'high' + : ($totalScore >= self::LEVEL_MEDIUM ? 'medium' : 'low'); + + if ($level === 'high') { + $summaryHigh++; + } elseif ($level === 'medium') { + $summaryMedium++; + } else { + $summaryLow++; + } + + // Top-3 drivers sorted by weighted contribution + $drivers = $dimensions; + usort($drivers, static fn (array $a, array $b): int => $b['weighted_points'] <=> $a['weighted_points']); + $topDrivers = array_slice($drivers, 0, 3); + + // Sort open tickets: critical first, then by age + $openTickets = $c['open_tickets']; + usort($openTickets, static function (array $a, array $b): int { + $aCrit = $a['age_hours'] > 48 ? 1 : 0; + $bCrit = $b['age_hours'] > 48 ? 1 : 0; + if ($aCrit !== $bCrit) { + return $bCrit <=> $aCrit; + } + + return $b['age_hours'] <=> $a['age_hours']; + }); + + $netFlow = $c['created_current'] - $c['closed_current']; + $resolutionMedian = self::median($c['resolution_hours']); + + $scoredCustomers[] = [ + 'customer_no' => $c['customer_no'], + 'customer_name' => $c['customer_name'], + 'risk_score' => $totalScore, + 'risk_level' => $level, + 'kpis' => [ + 'open' => $c['open'], + 'critical' => $c['critical'], + 'sla_overdue' => $c['sla_overdue_count'], + 'net_flow' => $netFlow, + 'resolution_median_hours' => $resolutionMedian, + 'oldest_open_hours' => $c['oldest_open_age_hours'], + ], + 'drivers' => $topDrivers, + 'dimensions' => $dimensions, + 'open_tickets' => array_slice($openTickets, 0, 10), + ]; + } + + // Sort: score desc, sla_overdue desc, open desc, customer_no asc + usort($scoredCustomers, static function (array $a, array $b): int { + if ($a['risk_score'] !== $b['risk_score']) { + return $b['risk_score'] <=> $a['risk_score']; + } + if ($a['kpis']['sla_overdue'] !== $b['kpis']['sla_overdue']) { + return $b['kpis']['sla_overdue'] <=> $a['kpis']['sla_overdue']; + } + if ($a['kpis']['open'] !== $b['kpis']['open']) { + return $b['kpis']['open'] <=> $a['kpis']['open']; + } + + return strcmp($a['customer_no'], $b['customer_no']); + }); + + return [ + 'summary' => [ + 'total' => count($scoredCustomers), + 'high' => $summaryHigh, + 'medium' => $summaryMedium, + 'low' => $summaryLow, + ], + 'customers' => $scoredCustomers, + 'meta' => [ + 'period_days' => $periodDays, + 'tickets_total' => count($tickets), + 'truncated' => $truncated, + 'confidence' => $truncated ? 'medium' : 'high', + ], + ]; + } + + /** + * Score all five dimensions for a single customer. + * + * @param array $c Customer aggregate data + * @return array + */ + private static function scoreDimensions(array $c): array + { + $open = (int) $c['open']; + $critical = (int) $c['critical']; + $netFlow = (int) $c['created_current'] - (int) $c['closed_current']; + $slaOverdue = (int) $c['sla_overdue_count']; + $resolutionMedian = self::median($c['resolution_hours']); + $oldestAge = (int) $c['oldest_open_age_hours']; + + // 1. Open Pressure: min(100, open*8 + critical*18) + $openScore = min(100, $open * 8 + $critical * 18); + + // 2. Trend (net flow): <=0:0, 1-2:35, 3-5:70, >=6:100 + $trendScore = match (true) { + $netFlow <= 0 => 0, + $netFlow <= 2 => 35, + $netFlow <= 5 => 70, + default => 100, + }; + + // 3. SLA Overdue: 0:0, 1:50, 2:75, >=3:100 + $slaScore = match (true) { + $slaOverdue === 0 => 0, + $slaOverdue === 1 => 50, + $slaOverdue === 2 => 75, + default => 100, + }; + + // 4. Resolution (median hours): null→neutral, <=24:0, 25-48:35, 49-96:70, >96:100 + $resolutionScore = null; + if ($resolutionMedian !== null) { + $resolutionScore = match (true) { + $resolutionMedian <= 24 => 0, + $resolutionMedian <= 48 => 35, + $resolutionMedian <= 96 => 70, + default => 100, + }; + } + + // 5. Inactivity (oldest open): <=24:0, 25-72:35, 73-168:70, >168:100 + $inactivityScore = match (true) { + $oldestAge <= 24 => 0, + $oldestAge <= 72 => 35, + $oldestAge <= 168 => 70, + default => 100, + }; + + $dimensions = [ + ['id' => 'open_pressure', 'label' => 'Open pressure', 'raw_value' => $open, 'dimension_score' => $openScore, 'weight' => self::W_OPEN_PRESSURE], + ['id' => 'trend', 'label' => 'Trend', 'raw_value' => $netFlow, 'dimension_score' => $trendScore, 'weight' => self::W_TREND], + ['id' => 'sla_overdue', 'label' => 'SLA overdue', 'raw_value' => $slaOverdue, 'dimension_score' => $slaScore, 'weight' => self::W_SLA_OVERDUE], + ['id' => 'resolution', 'label' => 'Resolution time', 'raw_value' => $resolutionMedian, 'dimension_score' => $resolutionScore, 'weight' => self::W_RESOLUTION], + ['id' => 'inactivity', 'label' => 'Inactivity', 'raw_value' => $oldestAge, 'dimension_score' => $inactivityScore, 'weight' => self::W_INACTIVITY], + ]; + + // Compute weighted points; redistribute resolution weight when null + $effectiveWeightSum = 0; + foreach ($dimensions as $d) { + if ($d['dimension_score'] !== null) { + $effectiveWeightSum += $d['weight']; + } + } + + foreach ($dimensions as $i => $d) { + if ($d['dimension_score'] === null) { + $dimensions[$i]['weighted_points'] = 0.0; + } else { + $adjustedWeight = $effectiveWeightSum > 0 + ? ($d['weight'] / $effectiveWeightSum) * 100 + : (float) $d['weight']; + $dimensions[$i]['weighted_points'] = round(($d['dimension_score'] / 100) * $adjustedWeight, 1); + } + } + + return $dimensions; + } + + /** + * Compute total score from dimensions (sum of weighted points, capped at 100). + * + * @param array> $dimensions + */ + private static function computeTotalScore(array $dimensions): int + { + $total = 0.0; + foreach ($dimensions as $d) { + $total += (float) ($d['weighted_points'] ?? 0); + } + + return min(100, (int) round($total)); + } + + /** + * @param array> $escalationDefs + * @return array Code → target seconds + */ + private static function buildSlaTargetMap(array $escalationDefs): array + { + $map = []; + foreach ($escalationDefs as $def) { + $code = trim((string) ($def['Code'] ?? '')); + $targetTime = trim((string) ($def['Target_Time'] ?? '')); + if ($code === '' || $targetTime === '') { + continue; + } + + $seconds = self::parseIsoDuration($targetTime); + if ($seconds !== null && $seconds > 0) { + $map[$code] = $seconds; + } + } + + return $map; + } + + private static function parseIsoDuration(string $value): ?int + { + $value = strtoupper(trim($value)); + if ($value === '' || !str_starts_with($value, 'P')) { + return null; + } + + if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/', $value, $m)) { + return null; + } + + $days = isset($m[1]) && $m[1] !== '' ? (int) $m[1] : 0; + $hours = isset($m[2]) && $m[2] !== '' ? (int) $m[2] : 0; + $minutes = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : 0; + $seconds = isset($m[4]) && $m[4] !== '' ? (int) floor((float) $m[4]) : 0; + + return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds; + } + + /** + * @param array $values + */ + private static function median(array $values): ?float + { + if ($values === []) { + return null; + } + + sort($values); + $count = count($values); + $mid = intdiv($count, 2); + + return $count % 2 === 0 + ? round(($values[$mid - 1] + $values[$mid]) / 2, 1) + : (float) $values[$mid]; + } + + 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; + } + + /** + * @param array $ticket + */ + private static function resolveActivityTimestamp(array $ticket): ?int + { + $lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? '')); + if ($lastActivity !== '' && !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/module.php b/modules/helpdesk/module.php index baea7e3..304625d 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -34,6 +34,8 @@ return [ ['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'], ['path' => 'helpdesk/team', 'target' => 'helpdesk/team'], ['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'], ], 'public_paths' => [], @@ -80,6 +82,12 @@ return [ 'active' => true, 'is_system' => true, ], + [ + 'key' => 'helpdesk.risk-radar.view', + 'description' => 'View helpdesk risk radar dashboard', + 'active' => true, + 'is_system' => true, + ], ], 'search_resources' => [], diff --git a/modules/helpdesk/pages/helpdesk/risk-radar-data().php b/modules/helpdesk/pages/helpdesk/risk-radar-data().php new file mode 100644 index 0000000..5b2b01e --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/risk-radar-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; +} + +$sessionStore = app(SessionStoreInterface::class); +$session = $sessionStore->all(); +$tenantScope = DebitorCacheControl::resolveTenantScope($session); + +$cacheKey = DebitorCacheControl::riskRadarKey($tenantScope, $periodDays); +$cacheTtl = DebitorCacheControl::TTL_STANDARD_SECONDS; +$cached = $sessionStore->get($cacheKey); +$cacheUsed = false; + +if (!$refreshRequested && is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < $cacheTtl) { + $radar = is_array($cached['radar'] ?? null) ? $cached['radar'] : null; + if ($radar !== null) { + $radar['meta']['cache_used'] = true; + Router::json(array_merge(['ok' => true], $radar)); + + return; + } +} + +if ($refreshRequested) { + $sessionStore->remove($cacheKey); +} + +$gateway = app(BcODataGateway::class); + +try { + $ticketData = $gateway->getTicketsForRiskRadar(); +} catch (\Throwable) { + Router::json(['ok' => false, 'error' => 'Failed to load ticket data']); + + return; +} + +$tickets = $ticketData['tickets'] ?? []; +$truncated = (bool) ($ticketData['truncated'] ?? false); + +// Load escalation definitions (cached separately with 30min TTL) +$escalationDefs = []; +try { + $escalationDefs = $gateway->getEscalationDefinitions(); +} catch (\Throwable) { + // Continue without — SLA dimension will score 0 +} + +$radar = RiskRadarService::buildRiskRadar($tickets, $escalationDefs, $periodDays, $truncated); + +$sessionStore->set($cacheKey, [ + 'radar' => $radar, + 'fetched_at' => time(), +]); + +Router::json(array_merge(['ok' => true], $radar, [ + 'meta' => array_merge($radar['meta'], [ + 'cache_used' => false, + 'cache_bypassed' => $refreshRequested, + ]), +])); diff --git a/modules/helpdesk/pages/helpdesk/risk-radar/index().php b/modules/helpdesk/pages/helpdesk/risk-radar/index().php new file mode 100644 index 0000000..fed5d00 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/risk-radar/index().php @@ -0,0 +1,16 @@ + t('Home'), 'path' => 'admin'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], + ['label' => t('Risk radar')], +]; diff --git a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml new file mode 100644 index 0000000..cb79081 --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml @@ -0,0 +1,128 @@ + t('30 days'), + 90 => t('90 days'), + 180 => t('6 months'), + 365 => t('1 year'), +]; +?> +
+
+ t('Risk radar'), + 'actions' => [ + [ + 'label' => t('Refresh data'), + 'type' => 'button', + 'name' => 'radar-refresh', + 'class' => 'secondary outline', + ], + ], + ]; + require templatePath('partials/app-details-titlebar.phtml'); + require templatePath('partials/app-flash.phtml'); + ?> + +
+
+
+ + checked> + + +
+ +
+ +
+
+ +
+
+
+
+ +
+
+ +
+ +
+
+ + + + + + + + + + +
+

+ +
+
+
+
+
+
+ + diff --git a/modules/helpdesk/templates/aside-helpdesk-panel.phtml b/modules/helpdesk/templates/aside-helpdesk-panel.phtml index 45d98f9..f5257dc 100644 --- a/modules/helpdesk/templates/aside-helpdesk-panel.phtml +++ b/modules/helpdesk/templates/aside-helpdesk-panel.phtml @@ -4,11 +4,13 @@ $layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; $helpdeskNav = is_array($layoutNav['helpdesk.nav'] ?? null) ? $layoutNav['helpdesk.nav'] : []; $canManageSettings = !empty($helpdeskNav['can_manage_settings']); $canViewTeam = !empty($helpdeskNav['can_view_team']); +$canViewRiskRadar = !empty($helpdeskNav['can_view_risk_radar']); $settingsActive = navActive('helpdesk/settings', true); $teamActive = navActive('helpdesk/team', true); +$riskRadarActive = navActive('helpdesk/risk-radar', true); $customersActive = navActive('helpdesk', true); -if ($settingsActive['isActive'] || $teamActive['isActive']) { +if ($settingsActive['isActive'] || $teamActive['isActive'] || $riskRadarActive['isActive']) { $customersActive = ['class' => '', 'aria' => '', 'isActive' => false]; } @@ -25,6 +27,12 @@ $helpdeskNavItems = [ 'active' => $teamActive, 'visible' => $canViewTeam, ], + [ + 'label' => t('Risk radar'), + 'path' => 'helpdesk/risk-radar', + 'active' => $riskRadarActive, + 'visible' => $canViewRiskRadar, + ], [ 'label' => t('Settings'), 'path' => 'helpdesk/settings', diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/RiskRadarServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/RiskRadarServiceTest.php new file mode 100644 index 0000000..451dba6 --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/RiskRadarServiceTest.php @@ -0,0 +1,224 @@ + $no, + 'Customer_No' => $customerNo, + 'Cust_Name' => $customerName, + 'Escalation_Code' => $escalationCode, + 'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs), + 'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs), + 'Process_Stage' => $closed ? 40 : 10, + 'Ticket_State' => $closed ? 'Geschlossen' : 'Offen', + 'Support_User_Name' => 'NKS', + 'Category_1_Code' => 'FEHLER', + 'Process_Code' => 'NORMAL', + ]; + } + + public function testEmptyTicketsReturnsEmptyResult(): void + { + $result = RiskRadarService::buildRiskRadar([], [], 90); + + $this->assertSame(0, $result['summary']['total']); + $this->assertSame([], $result['customers']); + } + + public function testSkipsTicketsWithoutCustomerNo(): void + { + $tickets = [ + self::ticket('T1', '', 'No Customer', 10), + self::ticket('T2', '10610', 'Valid Customer', 10), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $this->assertSame(1, $result['summary']['total']); + $this->assertSame('10610', $result['customers'][0]['customer_no']); + } + + public function testAggregatesPerCustomerNo(): void + { + $tickets = [ + self::ticket('T1', '10610', 'Kunde A', 10), + self::ticket('T2', '10610', 'Kunde A', 20), + self::ticket('T3', '10620', 'Kunde B', 5), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $this->assertSame(2, $result['summary']['total']); + $customerNos = array_column($result['customers'], 'customer_no'); + $this->assertContains('10610', $customerNos); + $this->assertContains('10620', $customerNos); + + $kundeA = $result['customers'][array_search('10610', $customerNos)]; + $this->assertSame(2, $kundeA['kpis']['open']); + } + + public function testHighRiskLevel(): void + { + // Many open + critical tickets → high score + $tickets = []; + for ($i = 0; $i < 10; $i++) { + $tickets[] = self::ticket('T' . $i, '10610', 'Stressed', 100); // all >48h → critical + } + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $this->assertSame('high', $result['customers'][0]['risk_level']); + $this->assertGreaterThanOrEqual(70, $result['customers'][0]['risk_score']); + } + + public function testLowRiskLevel(): void + { + // One recently closed ticket, one just opened + $tickets = [ + self::ticket('T1', '10610', 'Calm', 2), + self::ticket('T2', '10610', 'Calm', 10, true, '', 2), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $this->assertSame('low', $result['customers'][0]['risk_level']); + $this->assertLessThan(40, $result['customers'][0]['risk_score']); + } + + public function testSlaOverdueDetection(): void + { + $escalationDefs = [ + ['Code' => 'MAX', 'Target_Time' => 'PT2H', 'No_of_Escalation_Level' => 1], + ]; + + // Ticket created 10h ago with MAX escalation, target 2h → overdue + $tickets = [ + self::ticket('T1', '10610', 'SLA Breach', 10, false, 'MAX'), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, $escalationDefs, 90); + + $this->assertSame(1, $result['customers'][0]['kpis']['sla_overdue']); + } + + public function testSlaNotOverdueWhenWithinTarget(): void + { + $escalationDefs = [ + ['Code' => 'NORMAL', 'Target_Time' => 'P30D', 'No_of_Escalation_Level' => 1], + ]; + + $tickets = [ + self::ticket('T1', '10610', 'Within SLA', 10, false, 'NORMAL'), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, $escalationDefs, 90); + + $this->assertSame(0, $result['customers'][0]['kpis']['sla_overdue']); + } + + public function testResolutionMedian(): void + { + $tickets = [ + self::ticket('T1', '10610', 'Fast', 100, true, '', 10), + self::ticket('T2', '10610', 'Fast', 100, true, '', 20), + self::ticket('T3', '10610', 'Fast', 100, true, '', 30), + self::ticket('T4', '10610', 'Fast', 5), // one open + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $this->assertSame(20.0, $result['customers'][0]['kpis']['resolution_median_hours']); + } + + public function testResolutionNullRedistributesWeight(): void + { + // Only open tickets, no closed → resolution is null + $tickets = [ + self::ticket('T1', '10610', 'Open Only', 10), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $customer = $result['customers'][0]; + $resolutionDim = null; + foreach ($customer['dimensions'] as $d) { + if ($d['id'] === 'resolution') { + $resolutionDim = $d; + } + } + + $this->assertNotNull($resolutionDim); + $this->assertNull($resolutionDim['dimension_score']); + $this->assertSame(0.0, $resolutionDim['weighted_points']); + } + + public function testTruncatedFlagPassthrough(): void + { + $tickets = [self::ticket('T1', '10610', 'Any', 5)]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90, true); + + $this->assertTrue($result['meta']['truncated']); + $this->assertSame('medium', $result['meta']['confidence']); + } + + public function testSortsByScoreDescending(): void + { + $tickets = [ + self::ticket('T1', '10610', 'Low Risk', 2), + self::ticket('T2', '10620', 'High Risk', 100), + self::ticket('T3', '10620', 'High Risk', 100), + self::ticket('T4', '10620', 'High Risk', 100), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $this->assertSame('10620', $result['customers'][0]['customer_no']); + $this->assertSame('10610', $result['customers'][1]['customer_no']); + } + + public function testTopDriversSortedByWeightedPoints(): void + { + $tickets = []; + for ($i = 0; $i < 8; $i++) { + $tickets[] = self::ticket('T' . $i, '10610', 'Heavy', 200); + } + + $result = RiskRadarService::buildRiskRadar($tickets, [], 90); + + $drivers = $result['customers'][0]['drivers']; + $this->assertCount(3, $drivers); + $this->assertGreaterThanOrEqual($drivers[1]['weighted_points'], $drivers[0]['weighted_points']); + } + + public function testExcludesInactiveCustomers(): void + { + // Ticket created and closed outside the period window + $tickets = [ + self::ticket('T1', '10610', 'Old', 3000, true, '', 10), + ]; + + $result = RiskRadarService::buildRiskRadar($tickets, [], 30); + + // Customer had no activity in the 30-day window and no open tickets + $this->assertSame(0, $result['summary']['total']); + } +} diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 9658c1b..f371059 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -520,6 +520,219 @@ font-weight: 600; } + /* --- Risk Radar --- */ + .helpdesk-risk-radar-toolbar { + display: flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.75); + flex-wrap: wrap; + margin-bottom: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-risk-radar-search { + flex: 1; + min-width: 12rem; + max-width: 20rem; + } + + .helpdesk-risk-radar-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: calc(var(--app-spacing) * 0.6); + margin-top: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-risk-card { + border: 1px solid var(--app-muted-border-color); + border-radius: var(--app-radius, 0.375rem); + padding: calc(var(--app-spacing) * 0.6) calc(var(--app-spacing) * 0.75); + transition: border-color 0.15s ease, box-shadow 0.15s ease; + cursor: pointer; + } + + .helpdesk-risk-card:hover { + border-color: var(--app-primary, #0d6efd); + box-shadow: 0 1px 4px color-mix(in srgb, var(--app-primary, #0d6efd) 15%, transparent); + } + + .helpdesk-risk-card.helpdesk-risk-level-high { + border-left: 3px solid var(--app-danger, #dc3545); + } + + .helpdesk-risk-card.helpdesk-risk-level-medium { + border-left: 3px solid var(--app-warning, #f59e0b); + } + + .helpdesk-risk-card.helpdesk-risk-level-low { + border-left: 3px solid var(--app-success, #198754); + } + + .helpdesk-risk-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.5); + margin-bottom: calc(var(--app-spacing) * 0.3); + } + + .helpdesk-risk-card-name { + font-weight: 600; + font-size: var(--text-sm, 0.875rem); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .helpdesk-risk-card-score { + font-variant-numeric: tabular-nums; + font-weight: 700; + font-size: 0.8rem; + min-width: 2.2rem; + height: 2.2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + flex-shrink: 0; + color: #fff; + } + + .helpdesk-risk-card-score.helpdesk-risk-level-high { + background: var(--app-danger, #dc3545); + } + + .helpdesk-risk-card-score.helpdesk-risk-level-medium { + background: var(--app-warning, #f59e0b); + } + + .helpdesk-risk-card-score.helpdesk-risk-level-low { + background: var(--app-success, #198754); + } + + .helpdesk-risk-card-metrics { + display: flex; + flex-wrap: wrap; + gap: calc(var(--app-spacing) * 0.3); + margin-bottom: calc(var(--app-spacing) * 0.35); + font-size: var(--text-xs, 0.75rem); + } + + .helpdesk-risk-card-metric { + padding: 0.1em 0.45em; + border-radius: var(--app-border-radius, 0.25rem); + background: color-mix(in srgb, var(--app-muted-border-color) 40%, transparent); + color: var(--app-muted-color); + } + + .helpdesk-risk-card-metric-warn { + background: color-mix(in srgb, var(--app-warning, #f59e0b) 15%, transparent); + color: var(--app-warning, #f59e0b); + font-weight: 600; + } + + .helpdesk-risk-card-drivers { + display: grid; + gap: calc(var(--app-spacing) * 0.2); + } + + .helpdesk-risk-card-driver { + display: flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.35); + font-size: var(--text-xs, 0.75rem); + } + + .helpdesk-risk-card-driver-label { + min-width: 7ch; + color: var(--app-muted-color); + flex-shrink: 0; + } + + .helpdesk-risk-card-driver-bar { + flex: 1; + height: 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--app-muted-border-color) 50%, transparent); + } + + .helpdesk-risk-card-driver-fill { + height: 100%; + border-radius: 999px; + background: var(--app-primary, #0d6efd); + transition: width 0.3s ease; + } + + .helpdesk-risk-detail-dimensions { + display: grid; + gap: calc(var(--app-spacing) * 0.4); + margin-bottom: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-risk-detail-dim { + display: flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.5); + } + + .helpdesk-risk-detail-dim-label { + min-width: 10ch; + font-size: var(--text-sm, 0.875rem); + font-weight: 500; + } + + .helpdesk-risk-detail-dim-value { + font-variant-numeric: tabular-nums; + font-size: var(--text-sm, 0.875rem); + color: var(--app-muted-color); + min-width: 5ch; + text-align: right; + } + + .helpdesk-risk-detail-tickets { + width: 100%; + font-size: var(--text-sm, 0.875rem); + border: 1px solid var(--app-muted-border-color); + border-radius: var(--app-radius, 0.375rem); + border-spacing: 0; + overflow: hidden; + margin-bottom: calc(var(--app-spacing) * 0.75); + } + + .helpdesk-risk-detail-tickets th { + text-align: left; + font-weight: 500; + color: var(--app-muted-color); + padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5); + border-bottom: 1px solid var(--app-muted-border-color); + background: color-mix(in srgb, var(--app-muted-border-color) 20%, transparent); + } + + .helpdesk-risk-detail-tickets td { + padding: calc(var(--app-spacing) * 0.3) calc(var(--app-spacing) * 0.5); + border-bottom: 1px solid color-mix(in srgb, var(--app-muted-border-color) 40%, transparent); + } + + .helpdesk-risk-detail-tickets tbody tr:last-child td { + border-bottom: none; + } + + .helpdesk-risk-detail-ticket-critical td:first-child { + color: var(--app-warning, #f59e0b); + font-weight: 600; + } + + .helpdesk-risk-detail-link { + display: inline-block; + font-size: var(--text-sm, 0.875rem); + color: var(--app-primary, #0d6efd); + text-decoration: none; + } + + .helpdesk-risk-detail-link:hover { + text-decoration: underline; + } + /* Clickable rows — shared between search results and ticket list */ .app-clickable-row { cursor: pointer; diff --git a/modules/helpdesk/web/js/helpdesk-risk-radar.js b/modules/helpdesk/web/js/helpdesk-risk-radar.js new file mode 100644 index 0000000..56734ff --- /dev/null +++ b/modules/helpdesk/web/js/helpdesk-risk-radar.js @@ -0,0 +1,276 @@ +/** + * Risk Radar — customer portfolio risk view. + */ + +const container = document.querySelector('.app-details-container[data-risk-radar-url]'); +if (container) { + const dataUrl = container.dataset.riskRadarUrl; + const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; + const d = (key, fallback) => container.dataset[key] || fallback; + + const elLoading = document.getElementById('radar-loading'); + const elError = document.getElementById('radar-error'); + const elEmpty = document.getElementById('radar-empty'); + const elTruncated = document.getElementById('radar-truncated'); + const elContent = document.getElementById('radar-content'); + const elCards = document.getElementById('radar-cards'); + const elRefresh = container.querySelector('button[name="radar-refresh"]'); + const periodSelector = document.getElementById('radar-period-selector'); + const searchInput = document.getElementById('radar-search'); + const dialog = document.getElementById('radar-detail-dialog'); + const dialogTitle = document.getElementById('radar-detail-title'); + const dialogBody = document.getElementById('radar-detail-body'); + const dialogClose = dialog ? dialog.querySelector('.app-dialog-close') : null; + + let currentPeriod = 90; + let lastData = null; + + function showState(state) { + if (elLoading) elLoading.hidden = state !== 'loading'; + if (elError) elError.hidden = state !== 'error'; + if (elEmpty) elEmpty.hidden = state !== 'empty'; + if (elContent) elContent.hidden = state !== 'success'; + } + + function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; + } + + function h(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; + } + + function levelClass(level) { + return 'helpdesk-risk-level-' + level; + } + + function formatAge(hours) { + if (hours < 24) return hours + 'h'; + return Math.floor(hours / 24) + 'd'; + } + + /** + * Dimension label lookup from data attributes. + */ + function dimLabel(id) { + const map = { + 'open_pressure': d('labelOpenPressure', 'Open pressure'), + 'trend': d('labelTrend', 'Trend'), + 'sla_overdue': d('labelSla', 'SLA overdue'), + 'resolution': d('labelResolution', 'Resolution time'), + 'inactivity': d('labelInactivity', 'Inactivity'), + }; + return map[id] || id; + } + + /** + * Build a single customer risk card. + */ + function buildCard(customer) { + const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level)); + card.setAttribute('aria-label', customer.customer_name || customer.customer_no); + + // Header: name + score badge + const header = h('div', 'helpdesk-risk-card-header'); + const nameEl = h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no); + const scoreBadge = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score)); + scoreBadge.setAttribute('aria-label', d('labelScore', 'Risk score') + ': ' + customer.risk_score); + header.appendChild(nameEl); + header.appendChild(scoreBadge); + card.appendChild(header); + + // Core metrics row + const metrics = h('div', 'helpdesk-risk-card-metrics'); + const kpis = customer.kpis || {}; + + if (kpis.sla_overdue > 0) { + const m = h('span', 'helpdesk-risk-card-metric helpdesk-risk-card-metric-warn'); + m.textContent = kpis.sla_overdue + ' ' + d('labelOverdue', 'overdue'); + metrics.appendChild(m); + } + if (kpis.critical > 0) { + const m = h('span', 'helpdesk-risk-card-metric helpdesk-risk-card-metric-warn'); + m.textContent = kpis.critical + ' critical'; + metrics.appendChild(m); + } + const openM = h('span', 'helpdesk-risk-card-metric'); + openM.textContent = kpis.open + ' ' + d('labelOpen', 'open'); + metrics.appendChild(openM); + + if (kpis.net_flow !== 0) { + const flowM = h('span', 'helpdesk-risk-card-metric'); + flowM.textContent = (kpis.net_flow > 0 ? '+' : '') + kpis.net_flow + ' ' + d('labelNet', 'net'); + if (kpis.net_flow > 0) flowM.classList.add('helpdesk-risk-card-metric-warn'); + metrics.appendChild(flowM); + } + + card.appendChild(metrics); + + // Top drivers + const drivers = customer.drivers || []; + if (drivers.length > 0) { + const driverSection = h('div', 'helpdesk-risk-card-drivers'); + for (const dr of drivers) { + if (dr.dimension_score === null || dr.weighted_points <= 0) continue; + const row = h('div', 'helpdesk-risk-card-driver'); + row.appendChild(h('span', 'helpdesk-risk-card-driver-label', dimLabel(dr.id))); + const bar = h('div', 'helpdesk-risk-card-driver-bar'); + const fill = h('div', 'helpdesk-risk-card-driver-fill'); + fill.style.width = Math.min(100, dr.dimension_score) + '%'; + bar.appendChild(fill); + row.appendChild(bar); + driverSection.appendChild(row); + } + card.appendChild(driverSection); + } + + // Click to open detail + card.style.cursor = 'pointer'; + card.addEventListener('click', () => openDetail(customer)); + + return card; + } + + /** + * Open detail dialog for a customer. + */ + function openDetail(customer) { + if (!dialog || !dialogTitle || !dialogBody) return; + + dialogTitle.textContent = (customer.customer_name || customer.customer_no) + ' — ' + customer.risk_score + '/100'; + dialogBody.replaceChildren(); + + // All 5 dimensions + const dims = customer.dimensions || []; + const dimSection = h('div', 'helpdesk-risk-detail-dimensions'); + for (const dim of dims) { + const row = h('div', 'helpdesk-risk-detail-dim'); + row.appendChild(h('span', 'helpdesk-risk-detail-dim-label', dimLabel(dim.id))); + const bar = h('div', 'helpdesk-risk-card-driver-bar'); + const fill = h('div', 'helpdesk-risk-card-driver-fill'); + fill.style.width = (dim.dimension_score !== null ? Math.min(100, dim.dimension_score) : 0) + '%'; + bar.appendChild(fill); + row.appendChild(bar); + const val = h('span', 'helpdesk-risk-detail-dim-value'); + val.textContent = dim.dimension_score !== null ? dim.dimension_score + '/100' : '—'; + row.appendChild(val); + dimSection.appendChild(row); + } + dialogBody.appendChild(dimSection); + + // Open tickets list + const tickets = customer.open_tickets || []; + if (tickets.length > 0) { + dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', d('labelTickets', 'Tickets') + ' (' + tickets.length + ')')); + const table = h('table', 'helpdesk-risk-detail-tickets'); + const thead = h('thead'); + const headRow = h('tr'); + headRow.appendChild(h('th', '', 'Ticket')); + headRow.appendChild(h('th', '', d('labelSla', 'SLA'))); + headRow.appendChild(h('th', '', 'Status')); + headRow.appendChild(h('th', '', d('labelInactivity', 'Age'))); + thead.appendChild(headRow); + table.appendChild(thead); + const tbody = h('tbody'); + for (const t of tickets) { + const tr = h('tr', t.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : ''); + tr.appendChild(h('td', '', t.no)); + tr.appendChild(h('td', '', t.escalation_code || '—')); + tr.appendChild(h('td', '', t.state)); + tr.appendChild(h('td', '', formatAge(t.age_hours))); + tbody.appendChild(tr); + } + table.appendChild(tbody); + dialogBody.appendChild(table); + } + + // Link to debitor + if (debitorBaseUrl && customer.customer_no) { + const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no); + link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no); + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + dialogBody.appendChild(link); + } + + dialog.showModal(); + } + + // Dialog close handlers + if (dialogClose) dialogClose.addEventListener('click', () => dialog.close()); + if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); + + function renderKpis(summary) { + setText('radar-kpi-total', summary.total ?? 0); + setText('radar-kpi-high', summary.high ?? 0); + setText('radar-kpi-medium', summary.medium ?? 0); + setText('radar-kpi-low', summary.low ?? 0); + } + + function renderCards(customers) { + if (!elCards) return; + elCards.replaceChildren(); + for (const c of customers) elCards.appendChild(buildCard(c)); + } + + function filterCards(query) { + if (!lastData) return; + const q = query.toLowerCase().trim(); + const filtered = q === '' + ? lastData.customers + : lastData.customers.filter(c => (c.customer_name || '').toLowerCase().includes(q) || c.customer_no.toLowerCase().includes(q)); + renderCards(filtered); + } + + async function fetchData(refresh = false) { + showState('loading'); + if (elTruncated) elTruncated.hidden = true; + + const params = new URLSearchParams({ periodDays: String(currentPeriod) }); + if (refresh) params.set('refresh', '1'); + + try { + const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); + const json = await res.json(); + + if (!json.ok) { showState('error'); return; } + if (!json.customers || json.customers.length === 0) { showState('empty'); return; } + + lastData = json; + renderKpis(json.summary); + renderCards(json.customers); + showState('success'); + + if (json.meta && json.meta.truncated && elTruncated) { + elTruncated.hidden = false; + } + } catch { + showState('error'); + } + } + + if (periodSelector) { + periodSelector.addEventListener('change', (e) => { + const radio = e.target.closest('input[name="radar-period"]'); + if (!radio) return; + const period = parseInt(radio.value, 10); + if (!period || period === currentPeriod) return; + currentPeriod = period; + fetchData(); + }); + } + + if (searchInput) { + searchInput.addEventListener('input', () => filterCards(searchInput.value)); + } + + if (elRefresh) { + elRefresh.addEventListener('click', () => fetchData(true)); + } + + fetchData(); +}