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 }) => (
);
export const TeamPerformancePage = () => {
const [range, setRange] = useState('week');
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
let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) {
// Real Ozonetel data available
agentPerfs = 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;
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,
};
});
} else {
// Fallback: build agent list from call records
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[];
agentPerfs = agentNames.map((name) => {
const agentCalls = calls.filter((c: any) => c.agentName === name);
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,
timeBreakdown: null,
};
});
}
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, textStyle: { fontSize: 11 } },
grid: { top: 10, right: 10, bottom: 50, 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 */}
{/* Section 2: Call Breakdown Trends */}
{/* 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 === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
Time utilisation data unavailable — requires Ozonetel agent session data.
)}
{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.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}%
))}
{/* Section 6: Performance Alerts */}
{alerts.length > 0 && (
Performance Alerts ({alerts.length})
{alerts.map((alert, i) => (
{alert.agent}
— {alert.type}
{alert.value}
))}
)}
>
);
};