mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
- Sidebar: removed "Master" from nav labels (Leads, Patients, Appointments, Call Log) - Appointment form: Dept + Doctor in 2-col row, Date below, disabled cascade - DatePicker: placement="bottom start" + shouldFlip fixes popover positioning - Team Performance: default to "Week", grid KPI cards, chart legend spacing - Rules Engine: manual save (removed auto-debounce), Reset to Defaults uses DEFAULT_PRIORITY_CONFIG (no template endpoint), removed dead saveTimerRef - Automation rules: 6 showcase cards with trigger/condition/action, replaced agent-specific rule with generic round-robin - Recording analysis: friendly error message with retry instead of raw Deepgram error - Sidebar active/hover: brand color reference for theming Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
497 lines
28 KiB
TypeScript
497 lines
28 KiB
TypeScript
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 items-center gap-3 rounded-xl border border-secondary bg-primary p-4 min-w-0">
|
|
<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>('week');
|
|
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
|
|
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<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, 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 (
|
|
<>
|
|
<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="grid grid-cols-5 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>
|
|
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
|
|
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable — requires Ozonetel agent session data.</p>
|
|
)}
|
|
<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>
|
|
{agents.every(a => a.npsscore == null) ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
};
|