From d21841ddd5f87d1ffa0fe7a6e11ea359d22cc698 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 13:52:53 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20supervisor=20module=20=E2=80=94=20team?= =?UTF-8?q?=20performance,=20live=20monitor,=20master=20data=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin sidebar restructured: Supervisor + Data & Reports + Admin groups - Team Performance (PP-5): 6 sections — KPIs, call trends, agent table, time breakdown, NPS/conversion, performance alerts - Live Call Monitor (PP-6): polling active calls, KPI cards, action buttons - Call Recordings: filtered call table with inline audio player - Missed Calls: supervisor view with status tabs and SLA tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/sidebar.tsx | 27 +- src/main.tsx | 8 + src/pages/call-recordings.tsx | 167 +++++++++++ src/pages/live-monitor.tsx | 178 ++++++++++++ src/pages/missed-calls.tsx | 189 +++++++++++++ src/pages/team-performance.tsx | 449 ++++++++++++++++++++++++++++++ 6 files changed, 1011 insertions(+), 7 deletions(-) create mode 100644 src/pages/call-recordings.tsx create mode 100644 src/pages/live-monitor.tsx create mode 100644 src/pages/missed-calls.tsx create mode 100644 src/pages/team-performance.tsx diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 2705633..9a5eeb5 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -12,9 +12,12 @@ import { faHospitalUser, faCalendarCheck, faPhone, - faPlug, faUsers, faArrowRightFromBracket, + faTowerBroadcast, + faChartLine, + faFileAudio, + faPhoneMissed, } from "@fortawesome/pro-duotone-svg-icons"; import { faIcon } from "@/lib/icon-wrapper"; import { useAtom } from "jotai"; @@ -39,13 +42,16 @@ const IconGrid2 = faIcon(faGrid2); const IconBullhorn = faIcon(faBullhorn); const IconCommentDots = faIcon(faCommentDots); const IconChartMixed = faIcon(faChartMixed); -const IconPlug = faIcon(faPlug); const IconGear = faIcon(faGear); const IconPhone = faIcon(faPhone); const IconClockRewind = faIcon(faClockRotateLeft); 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); type NavSection = { label: string; @@ -55,13 +61,20 @@ type NavSection = { const getNavSections = (role: string): NavSection[] => { if (role === 'admin') { return [ - { label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] }, - { label: 'Management', items: [ - { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, - { label: 'Analytics', href: '/reports', icon: IconChartMixed }, + { label: 'Supervisor', items: [ + { 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: [ + { label: 'Lead Master', href: '/leads', icon: IconUsers }, + { label: 'Patient Master', href: '/patients', icon: IconHospitalUser }, + { label: 'Appointment Master', href: '/appointments', icon: IconCalendarCheck }, + { label: 'Call Log Master', href: '/call-history', icon: IconClockRewind }, + { label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio }, + { label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed }, ]}, { label: 'Admin', items: [ - { label: 'Integrations', href: '/integrations', icon: IconPlug }, { label: 'Settings', href: '/settings', icon: IconGear }, ]}, ]; diff --git a/src/main.tsx b/src/main.tsx index c1161b1..4739c2c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,10 @@ import { AgentDetailPage } from "@/pages/agent-detail"; import { SettingsPage } from "@/pages/settings"; import { MyPerformancePage } from "@/pages/my-performance"; import { AppointmentsPage } from "@/pages/appointments"; +import { TeamPerformancePage } from "@/pages/team-performance"; +import { LiveMonitorPage } from "@/pages/live-monitor"; +import { CallRecordingsPage } from "@/pages/call-recordings"; +import { MissedCallsPage } from "@/pages/missed-calls"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; import { RouteProvider } from "@/providers/router-provider"; @@ -57,6 +61,10 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/call-recordings.tsx b/src/pages/call-recordings.tsx new file mode 100644 index 0000000..c118bbe --- /dev/null +++ b/src/pages/call-recordings.tsx @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useState, useRef } from 'react'; +import { faMagnifyingGlass, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faIcon } from '@/lib/icon-wrapper'; + +const SearchLg = faIcon(faMagnifyingGlass); +import { Badge } from '@/components/base/badges/badges'; +import { Input } from '@/components/base/input/input'; +import { Table } from '@/components/application/table/table'; +import { TopBar } from '@/components/layout/top-bar'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone } from '@/lib/format'; + +type RecordingRecord = { + id: string; + direction: string | null; + callStatus: string | null; + callerNumber: { primaryPhoneNumber: string } | null; + agentName: string | null; + startedAt: string | null; + durationSec: number | null; + disposition: string | null; + recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null; +}; + +const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id direction callStatus callerNumber { primaryPhoneNumber } + agentName startedAt durationSec disposition + recording { primaryLinkUrl primaryLinkLabel } +} } } }`; + +const formatDate = (iso: string): string => + new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); + +const formatDuration = (sec: number | null): string => { + if (!sec) return '—'; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +const RecordingPlayer = ({ url }: { url: string }) => { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + + const toggle = () => { + if (!audioRef.current) return; + if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); } + setPlaying(!playing); + }; + + return ( +
+ +
+ ); +}; + +export const CallRecordingsPage = () => { + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + useEffect(() => { + apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) + .then(data => { + const withRecordings = data.calls.edges + .map(e => e.node) + .filter(c => c.recording?.primaryLinkUrl); + setCalls(withRecordings); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const filtered = useMemo(() => { + if (!search.trim()) return calls; + const q = search.toLowerCase(); + return calls.filter(c => + (c.agentName ?? '').toLowerCase().includes(q) || + (c.callerNumber?.primaryPhoneNumber ?? '').includes(q), + ); + }, [calls, search]); + + return ( + <> + +
+
+ {filtered.length} recordings +
+ +
+
+ +
+ {loading ? ( +
+

Loading recordings...

+
+ ) : filtered.length === 0 ? ( +
+

{search ? 'No matching recordings' : 'No call recordings found'}

+
+ ) : ( + + + + + + + + + + + + {(call) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; + const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; + + return ( + + + {call.agentName || '—'} + + + {phone ? ( + + ) : } + + + {dirLabel} + + + {call.startedAt ? formatDate(call.startedAt) : '—'} + + + {formatDuration(call.durationSec)} + + + {call.disposition ? ( + + {call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + + ) : } + + + {call.recording?.primaryLinkUrl && ( + + )} + + + ); + }} + +
+ )} +
+
+ + ); +}; diff --git a/src/pages/live-monitor.tsx b/src/pages/live-monitor.tsx new file mode 100644 index 0000000..59afc87 --- /dev/null +++ b/src/pages/live-monitor.tsx @@ -0,0 +1,178 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faHeadset } from '@fortawesome/pro-duotone-svg-icons'; +import { TopBar } from '@/components/layout/top-bar'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { Table } from '@/components/application/table/table'; +import { apiClient } from '@/lib/api-client'; +import { useData } from '@/providers/data-provider'; + +type ActiveCall = { + ucid: string; + agentId: string; + callerNumber: string; + callType: string; + startTime: string; + status: 'active' | 'on-hold'; +}; + +const formatDuration = (startTime: string): string => { + const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000)); + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +const KpiCard = ({ value, label }: { value: string | number; label: string }) => ( +
+

{value}

+

{label}

+
+); + +export const LiveMonitorPage = () => { + const [activeCalls, setActiveCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [tick, setTick] = useState(0); + const { leads } = useData(); + + // Poll active calls every 5 seconds + useEffect(() => { + const fetchCalls = () => { + apiClient.get('/api/supervisor/active-calls', { silent: true }) + .then(setActiveCalls) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + fetchCalls(); + const interval = setInterval(fetchCalls, 5000); + return () => clearInterval(interval); + }, []); + + // Tick every second to update duration counters + useEffect(() => { + const interval = setInterval(() => setTick(t => t + 1), 1000); + return () => clearInterval(interval); + }, []); + + const onHold = activeCalls.filter(c => c.status === 'on-hold').length; + const avgDuration = useMemo(() => { + if (activeCalls.length === 0) return '0:00'; + const totalSec = activeCalls.reduce((sum, c) => { + return sum + Math.max(0, Math.floor((Date.now() - new Date(c.startTime).getTime()) / 1000)); + }, 0); + const avg = Math.floor(totalSec / activeCalls.length); + return `${Math.floor(avg / 60)}:${(avg % 60).toString().padStart(2, '0')}`; + }, [activeCalls, tick]); + + // Match caller to lead + const resolveCallerName = (phone: string): string | null => { + if (!phone) return null; + const clean = phone.replace(/\D/g, ''); + const lead = leads.find(l => { + const lp = (l.contactPhone?.[0]?.number ?? '').replace(/\D/g, ''); + return lp && (lp.endsWith(clean) || clean.endsWith(lp)); + }); + if (lead) { + return `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim() || null; + } + return null; + }; + + return ( + <> + + +
+ {/* KPI Cards */} +
+
+ + + +
+
+ + {/* Active Calls Table */} +
+

Active Calls

+ + {loading ? ( +
+

Loading...

+
+ ) : activeCalls.length === 0 ? ( +
+ +

No active calls

+

Active calls will appear here in real-time

+
+ ) : ( + + + + + + + + + + + {(call) => { + const callerName = resolveCallerName(call.callerNumber); + const typeLabel = call.callType === 'InBound' ? 'In' : 'Out'; + const typeColor = call.callType === 'InBound' ? 'blue' : 'brand'; + + return ( + + + {call.agentId} + + +
+ {callerName && {callerName}} + {call.callerNumber} +
+
+ + {typeLabel} + + + {formatDuration(call.startTime)} + + + + {call.status} + + + +
+ + + +
+
+
+ ); + }} +
+
+ )} +
+ + {/* Monitoring hint */} + {activeCalls.length > 0 && ( +
+
+ +

Select "Listen" on any active call to start monitoring

+

Agent will not be notified during listen mode

+
+
+ )} +
+ + ); +}; diff --git a/src/pages/missed-calls.tsx b/src/pages/missed-calls.tsx new file mode 100644 index 0000000..5df1d24 --- /dev/null +++ b/src/pages/missed-calls.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState } from 'react'; +import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; + +const SearchLg = faIcon(faMagnifyingGlass); +import { Badge } from '@/components/base/badges/badges'; +import { Input } from '@/components/base/input/input'; +import { Table } from '@/components/application/table/table'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +import { TopBar } from '@/components/layout/top-bar'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone } from '@/lib/format'; + +type MissedCallRecord = { + id: string; + callerNumber: { primaryPhoneNumber: string } | null; + agentName: string | null; + startedAt: string | null; + callsourcenumber: string | null; + callbackstatus: string | null; + missedcallcount: number | null; + callbackattemptedat: string | null; +}; + +type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED'; + +const QUERY = `{ calls(first: 200, filter: { + callStatus: { eq: MISSED } +}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id callerNumber { primaryPhoneNumber } agentName + startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat +} } } }`; + +const formatDate = (iso: string): string => + new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }); + +const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { + const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); + if (minutes < 15) return { label: `${minutes}m`, color: 'success' }; + if (minutes < 30) return { label: `${minutes}m`, color: 'warning' }; + if (minutes < 60) return { label: `${minutes}m`, color: 'error' }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' }; + return { label: `${Math.floor(hours / 24)}d`, color: 'error' }; +}; + +const STATUS_LABELS: Record = { + PENDING_CALLBACK: 'Pending', + CALLBACK_ATTEMPTED: 'Attempted', + CALLBACK_COMPLETED: 'Completed', + WRONG_NUMBER: 'Wrong Number', + INVALID: 'Invalid', +}; + +const STATUS_COLORS: Record = { + PENDING_CALLBACK: 'warning', + CALLBACK_ATTEMPTED: 'brand', + CALLBACK_COMPLETED: 'success', + WRONG_NUMBER: 'error', + INVALID: 'gray', +}; + +export const MissedCallsPage = () => { + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('all'); + const [search, setSearch] = useState(''); + + useEffect(() => { + apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true }) + .then(data => setCalls(data.calls.edges.map(e => e.node))) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const statusCounts = useMemo(() => { + const counts: Record = {}; + for (const c of calls) { + const s = c.callbackstatus ?? 'PENDING_CALLBACK'; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; + }, [calls]); + + const filtered = useMemo(() => { + let rows = calls; + if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus); + else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'); + else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'); + + if (search.trim()) { + const q = search.toLowerCase(); + rows = rows.filter(c => + (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || + (c.agentName ?? '').toLowerCase().includes(q), + ); + } + return rows; + }, [calls, tab, search]); + + const tabItems = [ + { id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined }, + { id: 'PENDING_CALLBACK' as const, label: 'Pending', badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined }, + { id: 'CALLBACK_ATTEMPTED' as const, label: 'Attempted', badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined }, + { id: 'CALLBACK_COMPLETED' as const, label: 'Completed', badge: (statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER) ? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0)) : undefined }, + ]; + + return ( + <> + +
+
+ setTab(key as StatusTab)}> + + {(item) => } + + +
+ +
+
+ +
+ {loading ? ( +
+

Loading missed calls...

+
+ ) : filtered.length === 0 ? ( +
+

{search ? 'No matching calls' : 'No missed calls'}

+
+ ) : ( + + + + + + + + + + + + {(call) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const status = call.callbackstatus ?? 'PENDING_CALLBACK'; + const sla = call.startedAt ? computeSla(call.startedAt) : null; + + return ( + + + {phone ? ( + + ) : Unknown} + + + {call.startedAt ? formatDate(call.startedAt) : '—'} + + + {call.callsourcenumber || '—'} + + + {call.agentName || '—'} + + + {call.missedcallcount && call.missedcallcount > 1 ? ( + {call.missedcallcount}x + ) : 1} + + + + {STATUS_LABELS[status] ?? status} + + + + {sla && {sla.label}} + + + ); + }} + +
+ )} +
+
+ + ); +}; diff --git a/src/pages/team-performance.tsx b/src/pages/team-performance.tsx new file mode 100644 index 0000000..7543260 --- /dev/null +++ b/src/pages/team-performance.tsx @@ -0,0 +1,449 @@ +import { useEffect, useMemo, useState } from 'react'; +import ReactECharts from 'echarts-for-react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed, + faPercent, faTriangleExclamation, +} from '@fortawesome/pro-duotone-svg-icons'; +import { TopBar } from '@/components/layout/top-bar'; +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'; + +type DateRange = 'today' | 'week' | 'month' | 'year'; + +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; +}; + +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; + timeBreakdown: any; +}; + +const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateRange) => void }) => ( +
+ {(['today', 'week', 'month', 'year'] as DateRange[]).map(r => ( + + ))} +
+); + +const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => ( +
+
+ +
+
+

{value}

+

{label}

+
+
+); + +export const TeamPerformancePage = () => { + const [range, setRange] = useState('today'); + const [agents, setAgents] = useState([]); + const [allCalls, setAllCalls] = useState([]); + const [allAppointments, setAllAppointments] = 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, apptsData, leadsData, followUpsData, teamData] = await Promise.all([ + apiClient.graphql(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`, undefined, { silent: true }), + apiClient.graphql(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, 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 appts = apptsData?.appointments?.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 ?? []; + + setAllCalls(calls); + setAllAppointments(appts); + + // Build per-agent metrics + const agentPerfs: AgentPerf[] = teamAgents.map((agent: any) => { + const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid); + 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; // approximate + 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), + timeBreakdown: tb, + }; + }); + + setAgents(agentPerfs); + } catch (err) { + console.error('Failed to load team performance:', err); + } finally { + setLoading(false); + } + }; + load(); + }, [range]); + + // Aggregate KPIs + const totalCalls = allCalls.length; + const totalMissed = allCalls.filter(c => c.callStatus === 'MISSED').length; + const totalAppts = allAppointments.length; + const convRate = totalCalls > 0 ? Math.round((totalAppts / totalCalls) * 100) : 0; + const activeAgents = agents.length; + + // Call trend by day + const callTrendOption = useMemo(() => { + const dayMap: Record = {}; + for (const c of allCalls) { + if (!c.startedAt) continue; + const day = new Date(c.startedAt).toLocaleDateString('en-IN', { weekday: 'short' }); + if (!dayMap[day]) dayMap[day] = { inbound: 0, outbound: 0 }; + if (c.direction === 'INBOUND') dayMap[day].inbound++; + else dayMap[day].outbound++; + } + const days = Object.keys(dayMap); + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['Inbound', 'Outbound'], bottom: 0 }, + grid: { top: 10, right: 10, bottom: 30, left: 40 }, + xAxis: { type: 'category', data: days }, + yAxis: { type: 'value' }, + series: [ + { name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' }, + { name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' }, + ], + }; + }, [allCalls]); + + // NPS + 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]); + + // Performance alerts + 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]); + + // Team time averages + 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]); + + if (loading) { + return ( + <> + +
+

Loading team performance...

+
+ + ); + } + + return ( + <> + + +
+ {/* Section 1: Key Metrics */} +
+
+

Key Metrics

+ +
+
+ + + + + +
+
+ + {/* Section 2: Call Breakdown Trends */} +
+
+

Call Breakdown Trends

+
+
+

Inbound vs Outbound

+ +
+
+
+
+ + {/* Section 3: Agent Performance Table */} +
+
+

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 + + + + )} + +
+
+
+ + {/* Section 4: Time Breakdown */} +
+
+

Time Breakdown

+
+
+
+ {teamAvg.active}m Active +
+
+
+ {teamAvg.wrap}m Wrap +
+
+
+ {teamAvg.idle}m Idle +
+
+
+ {teamAvg.break_}m Break +
+
+
+ {agents.map(agent => { + const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1; + const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes; + return ( +
+

{agent.name}

+
+
+
+
+
+
+
+ Active {agent.activeMinutes}m + Wrap {agent.wrapMinutes}m + Idle {agent.idleMinutes}m + Break {agent.breakMinutes}m +
+
+ ); + })} +
+
+
+ + {/* Section 5: NPS + Conversion */} +
+
+
+

Overall NPS

+ +
+ {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}% +
+ ))} +
+
+
+
+ + {/* Section 6: Performance Alerts */} + {alerts.length > 0 && ( +
+
+

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

+
+ {alerts.map((alert, i) => ( +
+
+ + {alert.agent} + — {alert.type} +
+ {alert.value} +
+ ))} +
+
+
+ )} +
+ + ); +};