mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat(dashboard): merge Team Performance surfaces into single scrollable view
QA flagged Team Dashboard vs Team Performance as repetitive. Retire Team Performance from the sidebar; move its unique surfaces (rich agent table, time breakdown, NPS/Conversion, Performance Alerts) into Team Dashboard below the existing KPI row. - supervisor-rollup: new shared module — useSupervisorRollup hook + RichAgentTable / TimeBreakdown / NpsConversion / PerformanceAlerts - Time Breakdown rendered as a table (Agent / Active / Wrap / Idle / Break / Total + Team-average header row) — QA flagged the old stacked-bar tiles as misleading because per-agent totals varied wildly and width comparison was meaningless - team-dashboard: tabs replaced with stacked sections; everything scroll-visible so supervisors don't hunt across surfaces - sidebar: remove 'Team Performance' entry (route kept for backup) and drop the now-unused IconChartLine wiring
This commit is contained in:
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
// Shared rollup surfaces for the supervisor dashboard: agent performance
|
||||
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
|
||||
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
|
||||
// so both the Team Dashboard and the legacy Team Performance page render
|
||||
// identically from a single data fetch.
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month' | 'year';
|
||||
|
||||
type AgentPerf = {
|
||||
name: string;
|
||||
ozonetelAgentId: string;
|
||||
npsScore: number | null;
|
||||
maxIdleMinutes: number | null;
|
||||
minNpsThreshold: number | null;
|
||||
minConversionPercent: number | null;
|
||||
calls: number;
|
||||
inbound: number;
|
||||
missed: number;
|
||||
followUps: number;
|
||||
leads: number;
|
||||
appointments: number;
|
||||
convPercent: number;
|
||||
idleMinutes: number;
|
||||
activeMinutes: number;
|
||||
wrapMinutes: number;
|
||||
breakMinutes: number;
|
||||
};
|
||||
|
||||
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
|
||||
const now = new Date();
|
||||
const lte = now.toISOString();
|
||||
const start = new Date(now);
|
||||
if (range === 'today') start.setHours(0, 0, 0, 0);
|
||||
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
|
||||
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
|
||||
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
|
||||
return { gte: start.toISOString(), lte };
|
||||
};
|
||||
|
||||
const parseTime = (timeStr: string): number => {
|
||||
if (!timeStr) return 0;
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const useSupervisorRollup = (range: DateRange) => {
|
||||
const [agents, setAgents] = useState<AgentPerf[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const { gte, lte } = getDateRange(range);
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const [callsData, leadsData, followUpsData, teamData] = await Promise.all([
|
||||
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<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 leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
|
||||
const teamAgents = teamData?.agents ?? [];
|
||||
|
||||
let agentPerfs: AgentPerf[];
|
||||
|
||||
if (teamAgents.length > 0) {
|
||||
agentPerfs = teamAgents.map((agent: any) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (c.agentId && c.agentId === agent.id) return true;
|
||||
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
|
||||
return false;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
const totalCalls = agentCalls.length;
|
||||
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||
|
||||
return {
|
||||
name: agent.name ?? agent.ozonetelAgentId,
|
||||
ozonetelAgentId: agent.ozonetelAgentId,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversionPercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
appointments: agentAppts,
|
||||
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
|
||||
idleMinutes: Math.round(idleSec / 60),
|
||||
activeMinutes: Math.round(activeSec / 60),
|
||||
wrapMinutes: Math.round(wrapSec / 60),
|
||||
breakMinutes: Math.round(breakSec / 60),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const byKey = new Map<string, { key: string; name: string }>();
|
||||
for (const c of calls) {
|
||||
if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId });
|
||||
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
|
||||
}
|
||||
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
|
||||
return c.agent?.id === key;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
const totalCalls = agentCalls.length;
|
||||
|
||||
return {
|
||||
name,
|
||||
ozonetelAgentId: name,
|
||||
npsScore: null,
|
||||
maxIdleMinutes: null,
|
||||
minNpsThreshold: null,
|
||||
minConversionPercent: null,
|
||||
calls: totalCalls,
|
||||
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
appointments: completed,
|
||||
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
|
||||
idleMinutes: 0,
|
||||
activeMinutes: 0,
|
||||
wrapMinutes: 0,
|
||||
breakMinutes: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setAgents(agentPerfs);
|
||||
} catch (err) {
|
||||
console.error('Failed to load supervisor rollup:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
return { agents, loading };
|
||||
};
|
||||
|
||||
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
|
||||
<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" />
|
||||
<Table.Head label="Inbound" />
|
||||
<Table.Head label="Missed" />
|
||||
<Table.Head label="Follow-ups" />
|
||||
<Table.Head label="Leads" />
|
||||
<Table.Head label="Conv%" />
|
||||
<Table.Head label="NPS" />
|
||||
<Table.Head label="Idle" />
|
||||
</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>
|
||||
);
|
||||
|
||||
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
|
||||
const teamAvg = useMemo(() => {
|
||||
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
|
||||
return {
|
||||
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
|
||||
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
|
||||
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
|
||||
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
|
||||
// totals varied wildly, making the visual width comparison meaningless.
|
||||
// Rendered as a table so the numbers speak for themselves; team-average
|
||||
// row sits at the top as the reference point.
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Active" />
|
||||
<Table.Head label="Wrap" />
|
||||
<Table.Head label="Idle" />
|
||||
<Table.Head label="Break" />
|
||||
<Table.Head label="Total" />
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
items={[
|
||||
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
|
||||
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
|
||||
]}
|
||||
>
|
||||
{(item) => {
|
||||
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
|
||||
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
|
||||
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
|
||||
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
|
||||
const total = active + wrap + idle + breakM;
|
||||
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
|
||||
return (
|
||||
<Table.Row id={item.id}>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
|
||||
{idle}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
|
||||
const avgNps = useMemo(() => {
|
||||
const withNps = agents.filter(a => a.npsScore != null);
|
||||
if (withNps.length === 0) return 0;
|
||||
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
|
||||
}, [agents]);
|
||||
|
||||
const npsOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'gauge', startAngle: 180, endAngle: 0,
|
||||
min: 0, max: 100,
|
||||
pointer: { show: false },
|
||||
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
|
||||
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
|
||||
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
|
||||
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
|
||||
data: [{ value: avgNps }],
|
||||
}],
|
||||
}), [avgNps]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
|
||||
const alerts = useMemo(() => {
|
||||
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||
for (const a of agents) {
|
||||
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
|
||||
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||
}
|
||||
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
|
||||
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
|
||||
}
|
||||
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
|
||||
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [agents]);
|
||||
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
faUsers,
|
||||
faArrowRightFromBracket,
|
||||
faTowerBroadcast,
|
||||
faChartLine,
|
||||
faFileAudio,
|
||||
faPhoneMissed,
|
||||
} from "@fortawesome/pro-duotone-svg-icons";
|
||||
@@ -30,6 +29,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon
|
||||
import type { NavItemType } from "@/components/application/app-navigation/config";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||
import { useAgentState } from "@/hooks/use-agent-state";
|
||||
import { useThemeTokens } from "@/providers/theme-token-provider";
|
||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||
@@ -49,7 +49,6 @@ const IconUsers = faIcon(faUsers);
|
||||
const IconHospitalUser = faIcon(faHospitalUser);
|
||||
const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||
const IconChartLine = faIcon(faChartLine);
|
||||
const IconFileAudio = faIcon(faFileAudio);
|
||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||
|
||||
@@ -62,8 +61,11 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
if (role === 'admin') {
|
||||
return [
|
||||
{ label: 'Supervisor', items: [
|
||||
// Team Performance retired as a nav entry — its surfaces
|
||||
// (time breakdown, NPS/conversion, alerts, richer agent
|
||||
// table) are now rolled into the Dashboard. The route is
|
||||
// kept alive for reference but not linked in the sidebar.
|
||||
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
|
||||
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
|
||||
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
|
||||
]},
|
||||
{ label: 'Data & Reports', items: [
|
||||
@@ -149,7 +151,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const navSections = getNavSections(user.role);
|
||||
const uiFlags = useUiFlags();
|
||||
const navSections = getNavSections(user.role).map((section) => ({
|
||||
...section,
|
||||
items: uiFlags.setupManaged
|
||||
// When setup is managed by the product team (per-tenant flag),
|
||||
// hide the Settings entry from the nav. The route is also
|
||||
// blocked in router-provider so a stray bookmark doesn't work.
|
||||
? section.items.filter((item) => item.href !== '/settings')
|
||||
: section.items,
|
||||
})).filter((section) => section.items.length > 0);
|
||||
|
||||
const content = (
|
||||
<aside
|
||||
|
||||
@@ -3,13 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
||||
import { AgentTable } from '@/components/dashboard/agent-table';
|
||||
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
||||
import {
|
||||
RichAgentTable,
|
||||
TimeBreakdown,
|
||||
NpsConversion,
|
||||
PerformanceAlerts,
|
||||
useSupervisorRollup,
|
||||
} from '@/components/dashboard/supervisor-rollup';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month';
|
||||
type DashboardTab = 'agents' | 'missed' | 'campaigns';
|
||||
|
||||
const getDateRangeStart = (range: DateRange): Date => {
|
||||
const now = new Date();
|
||||
@@ -23,9 +28,13 @@ const getDateRangeStart = (range: DateRange): Date => {
|
||||
export const TeamDashboardPage = () => {
|
||||
const { calls, leads, campaigns, loading } = useData();
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||
const [tab, setTab] = useState<DashboardTab>('agents');
|
||||
const [aiOpen, setAiOpen] = useState(true);
|
||||
|
||||
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
|
||||
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
|
||||
// date-range semantics — map them through directly.
|
||||
const { agents: rollupAgents } = useSupervisorRollup(dateRange);
|
||||
|
||||
const filteredCalls = useMemo(() => {
|
||||
const rangeStart = getDateRangeStart(dateRange);
|
||||
return calls.filter((call) => {
|
||||
@@ -36,11 +45,13 @@ export const TeamDashboardPage = () => {
|
||||
|
||||
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'agents' as const, label: 'Agent Performance' },
|
||||
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` },
|
||||
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` },
|
||||
];
|
||||
const convRate = useMemo(() => {
|
||||
if (filteredCalls.length === 0) return 0;
|
||||
const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
||||
return Math.round((completed / filteredCalls.length) * 100);
|
||||
}, [filteredCalls]);
|
||||
|
||||
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -76,72 +87,68 @@ export const TeamDashboardPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main content */}
|
||||
{/* Main content — scrollable column with KPIs pinned at the
|
||||
top, then stacked supervisor sections (Agent table, Time
|
||||
breakdown, NPS/Conv, Alerts, Missed Queue, Campaigns).
|
||||
No tabs: everything is scroll-visible so a supervisor
|
||||
doesn't have to hunt across surfaces for their metrics. */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* KPI cards — always visible */}
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<DashboardKpi calls={filteredCalls} leads={leads} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-secondary px-6">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cx(
|
||||
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
|
||||
tab === t.id
|
||||
? "border-brand text-brand-secondary"
|
||||
: "border-transparent text-tertiary hover:text-secondary",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 p-6">
|
||||
{loading && (
|
||||
<div className="flex-1 space-y-5 px-6 pb-8">
|
||||
{loading && rollupAgents.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<RichAgentTable agents={rollupAgents} />
|
||||
|
||||
{!loading && tab === 'agents' && (
|
||||
<AgentTable calls={filteredCalls} />
|
||||
)}
|
||||
<TimeBreakdown agents={rollupAgents} />
|
||||
|
||||
{!loading && tab === 'missed' && (
|
||||
<MissedQueue calls={filteredCalls} />
|
||||
)}
|
||||
<NpsConversion agents={rollupAgents} convRate={convRate} />
|
||||
|
||||
{!loading && tab === 'campaigns' && (
|
||||
<div className="space-y-3">
|
||||
{campaigns.length === 0 ? (
|
||||
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p>
|
||||
) : (
|
||||
campaigns.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||
<span>{c.campaignStatus}</span>
|
||||
<span>{c.platform}</span>
|
||||
<span>{c.leadCount} leads</span>
|
||||
<span>{c.convertedCount} converted</span>
|
||||
<PerformanceAlerts agents={rollupAgents} />
|
||||
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">
|
||||
Missed Queue ({missedQueueCount})
|
||||
</h3>
|
||||
<MissedQueue calls={filteredCalls} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">
|
||||
Campaigns ({campaigns.length})
|
||||
</h3>
|
||||
{campaigns.length === 0 ? (
|
||||
<p className="text-sm text-tertiary py-4 text-center">No campaigns</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{campaigns.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-lg border border-secondary bg-primary p-4">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||
<span>{c.campaignStatus}</span>
|
||||
<span>{c.platform}</span>
|
||||
<span>{c.leadCount} leads</span>
|
||||
<span>{c.convertedCount} converted</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.budget && (
|
||||
<span className="text-sm font-medium text-secondary">
|
||||
₹{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.budget && (
|
||||
<span className="text-sm font-medium text-secondary">
|
||||
₹{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user