feat: supervisor module — team performance, live monitor, master data pages

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 13:52:53 +05:30
parent ad58888514
commit d21841ddd5
6 changed files with 1011 additions and 7 deletions

View File

@@ -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<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
if (!audioRef.current) return;
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
setPlaying(!playing);
};
return (
<div className="flex items-center gap-2">
<button onClick={toggle} className="flex size-7 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear">
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3" />
</button>
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
</div>
);
};
export const CallRecordingsPage = () => {
const [calls, setCalls] = useState<RecordingRecord[]>([]);
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 (
<>
<TopBar title="Call Recordings" />
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-secondary px-6 py-3">
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
<div className="w-56">
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading recordings...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching recordings' : 'No call recordings found'}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Caller" />
<Table.Head label="Type" className="w-20" />
<Table.Head label="Date" className="w-28" />
<Table.Head label="Duration" className="w-20" />
<Table.Head label="Disposition" className="w-32" />
<Table.Head label="Recording" className="w-24" />
</Table.Header>
<Table.Body items={filtered}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
return (
<Table.Row id={call.id}>
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
</Table.Cell>
<Table.Cell>
{call.disposition ? (
<Badge size="sm" color="gray" type="pill-color">
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
<Table.Cell>
{call.recording?.primaryLinkUrl && (
<RecordingPlayer url={call.recording.primaryLinkUrl} />
)}
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
</div>
</>
);
};

178
src/pages/live-monitor.tsx Normal file
View File

@@ -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 }) => (
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
<p className="text-3xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary mt-1">{label}</p>
</div>
);
export const LiveMonitorPage = () => {
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0);
const { leads } = useData();
// Poll active calls every 5 seconds
useEffect(() => {
const fetchCalls = () => {
apiClient.get<ActiveCall[]>('/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 (
<>
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI Cards */}
<div className="px-6 pt-5">
<div className="flex gap-4">
<KpiCard value={activeCalls.length} label="Active Calls" />
<KpiCard value={onHold} label="On Hold" />
<KpiCard value={avgDuration} label="Avg Duration" />
</div>
</div>
{/* Active Calls Table */}
<div className="px-6 pt-6">
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p>
</div>
) : activeCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center rounded-xl border border-secondary bg-primary">
<FontAwesomeIcon icon={faHeadset} className="size-12 text-fg-quaternary mb-4" />
<p className="text-sm font-medium text-secondary">No active calls</p>
<p className="text-xs text-tertiary mt-1">Active calls will appear here in real-time</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Caller" />
<Table.Head label="Type" className="w-16" />
<Table.Head label="Duration" className="w-20" />
<Table.Head label="Status" className="w-24" />
<Table.Head label="Actions" className="w-48" />
</Table.Header>
<Table.Body items={activeCalls}>
{(call) => {
const callerName = resolveCallerName(call.callerNumber);
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
return (
<Table.Row id={call.ucid}>
<Table.Cell>
<span className="text-sm font-medium text-primary">{call.agentId}</span>
</Table.Cell>
<Table.Cell>
<div>
{callerName && <span className="text-sm font-medium text-primary block">{callerName}</span>}
<span className="text-xs text-tertiary">{call.callerNumber}</span>
</div>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={typeColor} type="pill-color">{typeLabel}</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-sm font-mono text-primary">{formatDuration(call.startTime)}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={call.status === 'on-hold' ? 'warning' : 'success'} type="pill-color">
{call.status}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1.5">
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Whisper</Button>
<Button size="sm" color="primary-destructive" isDisabled title="Coming soon — requires supervisor SIP extension">Barge</Button>
</div>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
{/* Monitoring hint */}
{activeCalls.length > 0 && (
<div className="px-6 pt-6 pb-8">
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center">
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" />
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p>
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p>
</div>
</div>
)}
</div>
</>
);
};

189
src/pages/missed-calls.tsx Normal file
View File

@@ -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<string, string> = {
PENDING_CALLBACK: 'Pending',
CALLBACK_ATTEMPTED: 'Attempted',
CALLBACK_COMPLETED: 'Completed',
WRONG_NUMBER: 'Wrong Number',
INVALID: 'Invalid',
};
const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' | 'gray'> = {
PENDING_CALLBACK: 'warning',
CALLBACK_ATTEMPTED: 'brand',
CALLBACK_COMPLETED: 'success',
WRONG_NUMBER: 'error',
INVALID: 'gray',
};
export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('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<string, number> = {};
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 (
<>
<TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-56 shrink-0 pb-1">
<Input placeholder="Search phone or agent..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Caller" isRowHeader />
<Table.Head label="Date / Time" className="w-36" />
<Table.Head label="Branch" className="w-32" />
<Table.Head label="Agent" className="w-28" />
<Table.Head label="Count" className="w-16" />
<Table.Head label="Status" className="w-28" />
<Table.Head label="SLA" className="w-24" />
</Table.Header>
<Table.Body items={filtered}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
const sla = call.startedAt ? computeSla(call.startedAt) : null;
return (
<Table.Row id={call.id}>
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary">Unknown</span>}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
<Table.Cell>
{call.missedcallcount && call.missedcallcount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
) : <span className="text-xs text-quaternary">1</span>}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
{STATUS_LABELS[status] ?? status}
</Badge>
</Table.Cell>
<Table.Cell>
{sla && <Badge size="sm" color={sla.color} type="pill-color">{sla.label}</Badge>}
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
</div>
</>
);
};

View File

@@ -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 }) => (
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month', 'year'] as DateRange[]).map(r => (
<button key={r} onClick={() => onChange(r)} className={cx(
'px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear',
value === r ? 'bg-brand-solid text-white' : 'bg-primary text-secondary hover:bg-secondary',
)}>{r}</button>
))}
</div>
);
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
<div className="flex flex-1 items-center gap-3 rounded-xl border border-secondary bg-primary p-4">
<div className={cx('flex size-10 items-center justify-center rounded-lg', color ?? 'bg-brand-secondary')}>
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
</div>
<div>
<p className="text-xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary">{label}</p>
</div>
</div>
);
export const TeamPerformancePage = () => {
const [range, setRange] = useState<DateRange>('today');
const [agents, setAgents] = useState<AgentPerf[]>([]);
const [allCalls, setAllCalls] = useState<any[]>([]);
const [allAppointments, setAllAppointments] = useState<any[]>([]);
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<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
apiClient.get<any>(`/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<string, { inbound: number; outbound: number }> = {};
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 (
<>
<TopBar title="Team Performance" />
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading team performance...</p>
</div>
</>
);
}
return (
<>
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Section 1: Key Metrics */}
<div className="px-6 pt-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
<DateFilter value={range} onChange={setRange} />
</div>
<div className="flex gap-3">
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
<KpiCard icon={faPhoneMissed} value={totalMissed} label="Missed Calls" color="bg-error-solid" />
<KpiCard icon={faPercent} value={`${convRate}%`} label="Conversion Rate" color="bg-warning-solid" />
</div>
</div>
{/* Section 2: Call Breakdown Trends */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Call Breakdown Trends</h3>
<div className="flex gap-4">
<div className="flex-1">
<p className="text-xs text-tertiary mb-2">Inbound vs Outbound</p>
<ReactECharts option={callTrendOption} style={{ height: 200 }} />
</div>
</div>
</div>
</div>
{/* Section 3: Agent Performance Table */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Calls" className="w-16" />
<Table.Head label="Inbound" className="w-20" />
<Table.Head label="Missed" className="w-16" />
<Table.Head label="Follow-ups" className="w-24" />
<Table.Head label="Leads" className="w-16" />
<Table.Head label="Conv%" className="w-16" />
<Table.Head label="NPS" className="w-16" />
<Table.Head label="Idle" className="w-16" />
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<Table.Row id={agent.ozonetelagentid || agent.name}>
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
{agent.convPercent}%
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-bold', (agent.npsscore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsscore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
{agent.npsscore ?? '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm', agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m
</span>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</div>
</div>
{/* Section 4: Time Breakdown */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
<div className="flex gap-6 mb-4 px-2">
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-success-solid" />
<span className="text-xs text-secondary">{teamAvg.active}m Active</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-brand-solid" />
<span className="text-xs text-secondary">{teamAvg.wrap}m Wrap</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-warning-solid" />
<span className="text-xs text-secondary">{teamAvg.idle}m Idle</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-tertiary" />
<span className="text-xs text-secondary">{teamAvg.break_}m Break</span>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{agents.map(agent => {
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
return (
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
<div className="flex h-3 rounded-full overflow-hidden">
<div className="bg-success-solid" style={{ width: `${(agent.activeMinutes / total) * 100}%` }} />
<div className="bg-brand-solid" style={{ width: `${(agent.wrapMinutes / total) * 100}%` }} />
<div className="bg-warning-solid" style={{ width: `${(agent.idleMinutes / total) * 100}%` }} />
<div className="bg-tertiary" style={{ width: `${(agent.breakMinutes / total) * 100}%` }} />
</div>
<div className="flex gap-2 mt-1.5 text-[10px] text-quaternary">
<span>Active {agent.activeMinutes}m</span>
<span>Wrap {agent.wrapMinutes}m</span>
<span>Idle {agent.idleMinutes}m</span>
<span>Break {agent.breakMinutes}m</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Section 5: NPS + Conversion */}
<div className="px-6 pt-6">
<div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
<ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2">
{agents.filter(a => a.npsscore != null).map(a => (
<div key={a.name} className="flex items-center gap-2">
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
<div className={cx('h-full rounded-full', (a.npsscore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsscore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsscore ?? 0}%` }} />
</div>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsscore}</span>
</div>
))}
</div>
</div>
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
<div className="flex gap-3 mb-4">
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
<p className="text-xs text-tertiary">Call Appointment</p>
</div>
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
</p>
<p className="text-xs text-tertiary">Lead Contact</p>
</div>
</div>
<div className="space-y-1">
{agents.map(a => (
<div key={a.name} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-28 truncate">{a.name}</span>
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
</div>
))}
</div>
</div>
</div>
</div>
{/* Section 6: Performance Alerts */}
{alerts.length > 0 && (
<div className="px-6 pt-6 pb-8">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-error-primary mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
Performance Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.map((alert, i) => (
<div key={i} className={cx(
'flex items-center justify-between rounded-lg px-4 py-3',
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
)}>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
<span className="text-sm font-medium text-primary">{alert.agent}</span>
<span className="text-sm text-secondary"> {alert.type}</span>
</div>
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
</div>
))}
</div>
</div>
</div>
)}
</div>
</>
);
};