diff --git a/src/components/dashboard/supervisor-rollup.tsx b/src/components/dashboard/supervisor-rollup.tsx new file mode 100644 index 0000000..a2d04cd --- /dev/null +++ b/src/components/dashboard/supervisor-rollup.tsx @@ -0,0 +1,401 @@ +import { useEffect, useMemo, useState } from 'react'; +import ReactECharts from 'echarts-for-react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons'; +import { Badge } from '@/components/base/badges/badges'; +import { Table } from '@/components/application/table/table'; +import { apiClient } from '@/lib/api-client'; +import { cx } from '@/utils/cx'; + +// Shared rollup surfaces for the supervisor dashboard: agent performance +// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown, +// NPS gauge + conversion metrics, and performance alerts. Kept in one file +// so both the Team Dashboard and the legacy Team Performance page render +// identically from a single data fetch. + +type DateRange = 'today' | 'week' | 'month' | 'year'; + +type AgentPerf = { + name: string; + ozonetelAgentId: string; + npsScore: number | null; + maxIdleMinutes: number | null; + minNpsThreshold: number | null; + minConversionPercent: number | null; + calls: number; + inbound: number; + missed: number; + followUps: number; + leads: number; + appointments: number; + convPercent: number; + idleMinutes: number; + activeMinutes: number; + wrapMinutes: number; + breakMinutes: number; +}; + +const getDateRange = (range: DateRange): { gte: string; lte: string } => { + const now = new Date(); + const lte = now.toISOString(); + const start = new Date(now); + if (range === 'today') start.setHours(0, 0, 0, 0); + else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); } + else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); } + else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); } + return { gte: start.toISOString(), lte }; +}; + +const parseTime = (timeStr: string): number => { + if (!timeStr) return 0; + const parts = timeStr.split(':').map(Number); + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return 0; +}; + +export const useSupervisorRollup = (range: DateRange) => { + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const load = async () => { + setLoading(true); + const { gte, lte } = getDateRange(range); + const dateStr = new Date().toISOString().split('T')[0]; + + try { + const [callsData, leadsData, followUpsData, teamData] = await Promise.all([ + apiClient.graphql(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }), + apiClient.graphql(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }), + apiClient.graphql(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }), + apiClient.get(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })), + ]); + + const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? []; + const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? []; + const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? []; + const teamAgents = teamData?.agents ?? []; + + let agentPerfs: AgentPerf[]; + + if (teamAgents.length > 0) { + agentPerfs = teamAgents.map((agent: any) => { + const agentCalls = calls.filter((c: any) => { + if (c.agentId && c.agentId === agent.id) return true; + if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true; + return false; + }); + const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name); + const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name); + const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; + const totalCalls = agentCalls.length; + const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length; + const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length; + + const tb = agent.timeBreakdown; + const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0; + const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0; + const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0; + const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0; + + return { + name: agent.name ?? agent.ozonetelAgentId, + ozonetelAgentId: agent.ozonetelAgentId, + npsScore: agent.npsScore, + maxIdleMinutes: agent.maxIdleMinutes, + minNpsThreshold: agent.minNpsThreshold, + minConversionPercent: agent.minConversionPercent, + calls: totalCalls, + inbound, + missed, + followUps: agentFollowUps.length, + leads: agentLeads.length, + appointments: agentAppts, + convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0, + idleMinutes: Math.round(idleSec / 60), + activeMinutes: Math.round(activeSec / 60), + wrapMinutes: Math.round(wrapSec / 60), + breakMinutes: Math.round(breakSec / 60), + }; + }); + } else { + const byKey = new Map(); + for (const c of calls) { + if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId }); + else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName }); + } + agentPerfs = Array.from(byKey.values()).map(({ key, name }) => { + const agentCalls = calls.filter((c: any) => { + if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id; + return c.agent?.id === key; + }); + const agentLeads = leads.filter((l: any) => l.assignedAgent === name); + const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name); + const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; + const totalCalls = agentCalls.length; + + return { + name, + ozonetelAgentId: name, + npsScore: null, + maxIdleMinutes: null, + minNpsThreshold: null, + minConversionPercent: null, + calls: totalCalls, + inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length, + missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length, + followUps: agentFollowUps.length, + leads: agentLeads.length, + appointments: completed, + convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0, + idleMinutes: 0, + activeMinutes: 0, + wrapMinutes: 0, + breakMinutes: 0, + }; + }); + } + + setAgents(agentPerfs); + } catch (err) { + console.error('Failed to load supervisor rollup:', err); + } finally { + setLoading(false); + } + }; + load(); + }, [range]); + + return { agents, loading }; +}; + +export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => ( +
+

Agent Performance

+ + + + + + + + + + + + + + {(agent) => ( + + {agent.name} + {agent.calls} + {agent.inbound} + {agent.missed} + {agent.followUps} + {agent.leads} + + = 25 ? 'text-success-primary' : 'text-error-primary')}> + {agent.convPercent}% + + + + = 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}> + {agent.npsScore ?? '—'} + + + + agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}> + {agent.idleMinutes}m + + + + )} + +
+
+); + +export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => { + const teamAvg = useMemo(() => { + if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 }; + return { + active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length), + wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length), + idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length), + break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length), + }; + }, [agents]); + + // QA flagged the earlier stacked-bar rendering as misleading — per-agent + // totals varied wildly, making the visual width comparison meaningless. + // Rendered as a table so the numbers speak for themselves; team-average + // row sits at the top as the reference point. + return ( +
+

Time Breakdown

+ {teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && ( +

Time utilisation data unavailable — requires Ozonetel agent session data.

+ )} + + + + + + + + + + ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })), + ]} + > + {(item) => { + const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes; + const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes; + const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes; + const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes; + const total = active + wrap + idle + breakM; + const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0); + return ( + + + + {item.name} + + + {active}m + {wrap}m + + + {idle}m + + + {breakM}m + {total}m + + ); + }} + +
+
+ ); +}; + +export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => { + const avgNps = useMemo(() => { + const withNps = agents.filter(a => a.npsScore != null); + if (withNps.length === 0) return 0; + return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length); + }, [agents]); + + const npsOption = useMemo(() => ({ + tooltip: { trigger: 'item' }, + series: [{ + type: 'gauge', startAngle: 180, endAngle: 0, + min: 0, max: 100, + pointer: { show: false }, + progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } }, + axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } }, + axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, + detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' }, + data: [{ value: avgNps }], + }], + }), [avgNps]); + + return ( +
+
+

Overall NPS

+ {agents.every(a => a.npsScore == null) ? ( +
+

NPS data unavailable — configure NPS scores on agent profiles.

+
+ ) : ( + <> + +
+ {agents.filter(a => a.npsScore != null).map(a => ( +
+ {a.name} +
+
= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} /> +
+ {a.npsScore} +
+ ))} +
+ + )} +
+
+

Conversion Metrics

+
+
+

{convRate}%

+

Call → Appointment

+
+
+

+ {agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}% +

+

Lead → Contact

+
+
+
+ {agents.map(a => ( +
+ {a.name} + = 25 ? 'success' : 'error'}>{a.convPercent}% +
+ ))} +
+
+
+ ); +}; + +export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => { + const alerts = useMemo(() => { + const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = []; + for (const a of agents) { + if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) { + list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' }); + } + if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) { + list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' }); + } + if (a.minConversionPercent && a.convPercent < a.minConversionPercent) { + list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' }); + } + } + return list; + }, [agents]); + + if (alerts.length === 0) return null; + + return ( +
+

+ + Performance Alerts ({alerts.length}) +

+
+ {alerts.map((alert, i) => ( +
+
+ + {alert.agent} + — {alert.type} +
+ {alert.value} +
+ ))} +
+
+ ); +}; diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 1c6352c..92aabd8 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -15,7 +15,6 @@ import { faUsers, faArrowRightFromBracket, faTowerBroadcast, - faChartLine, faFileAudio, faPhoneMissed, } from "@fortawesome/pro-duotone-svg-icons"; @@ -30,6 +29,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon import type { NavItemType } from "@/components/application/app-navigation/config"; import { Avatar } from "@/components/base/avatar/avatar"; import { useAuth } from "@/providers/auth-provider"; +import { useUiFlags } from "@/hooks/use-ui-flags"; import { useAgentState } from "@/hooks/use-agent-state"; import { useThemeTokens } from "@/providers/theme-token-provider"; import { sidebarCollapsedAtom } from "@/state/sidebar-state"; @@ -49,7 +49,6 @@ const IconUsers = faIcon(faUsers); const IconHospitalUser = faIcon(faHospitalUser); const IconCalendarCheck = faIcon(faCalendarCheck); const IconTowerBroadcast = faIcon(faTowerBroadcast); -const IconChartLine = faIcon(faChartLine); const IconFileAudio = faIcon(faFileAudio); const IconPhoneMissed = faIcon(faPhoneMissed); @@ -62,8 +61,11 @@ const getNavSections = (role: string): NavSection[] => { if (role === 'admin') { return [ { label: 'Supervisor', items: [ + // Team Performance retired as a nav entry — its surfaces + // (time breakdown, NPS/conversion, alerts, richer agent + // table) are now rolled into the Dashboard. The route is + // kept alive for reference but not linked in the sidebar. { label: 'Dashboard', href: '/', icon: IconGrid2 }, - { label: 'Team Performance', href: '/team-performance', icon: IconChartLine }, { label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast }, ]}, { label: 'Data & Reports', items: [ @@ -149,7 +151,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => { navigate('/login'); }; - const navSections = getNavSections(user.role); + const uiFlags = useUiFlags(); + const navSections = getNavSections(user.role).map((section) => ({ + ...section, + items: uiFlags.setupManaged + // When setup is managed by the product team (per-tenant flag), + // hide the Settings entry from the nav. The route is also + // blocked in router-provider so a stray bookmark doesn't work. + ? section.items.filter((item) => item.href !== '/settings') + : section.items, + })).filter((section) => section.items.length > 0); const content = (