mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
1. notification-bell: drop the DEMO_ALERTS fallback (Riya Mehta etc.).
Empty state ("No active alerts") shows when the live computation
returns nothing — which is the truthful state until thresholds are
set on Agent records.
2. use-performance-alerts: bucket calls by c.agentId === agent.id when
the relation is set; fall back to legacy agentName matching only for
un-enriched rows. Fixes conversion% calc going to 0 after backfill.
3. agent-table: Link target uses agent.id (UUID or "legacy:NAME") so
the URL is a stable identifier instead of a display string.
4. agent-detail: parse the route param into UUID vs legacy:NAME, filter
calls by c.agentId or c.agentName accordingly, and resolve display
name via the platform Agents list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
123 lines
6.0 KiB
TypeScript
123 lines
6.0 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Link } from 'react-router';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { Avatar } from '@/components/base/avatar/avatar';
|
|
import { Badge } from '@/components/base/badges/badges';
|
|
import { Table, TableCard } from '@/components/application/table/table';
|
|
import { getInitials } from '@/lib/format';
|
|
import type { Call } from '@/types/entities';
|
|
|
|
const formatDuration = (seconds: number): string => {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
};
|
|
|
|
const formatPercent = (value: number): string => {
|
|
if (isNaN(value) || !isFinite(value)) return '0%';
|
|
return `${Math.round(value)}%`;
|
|
};
|
|
|
|
interface AgentTableProps {
|
|
calls: Call[];
|
|
}
|
|
|
|
export const AgentTable = ({ calls }: AgentTableProps) => {
|
|
const agents = useMemo(() => {
|
|
// Bucket by authoritative agent.id when present (from CDR enrichment);
|
|
// fall back to raw agentName for legacy rows that haven't been
|
|
// enriched yet. Skips rows with no agent info at all.
|
|
const agentMap = new Map<string, { displayName: string; calls: Call[] }>();
|
|
for (const call of calls) {
|
|
let key: string;
|
|
let displayName: string;
|
|
if (call.agent?.id) {
|
|
key = call.agent.id;
|
|
displayName = call.agent.name ?? call.agent.ozonetelAgentId ?? 'Unknown';
|
|
} else if (call.agentName) {
|
|
key = `legacy:${call.agentName}`;
|
|
displayName = call.agentName;
|
|
} else {
|
|
continue;
|
|
}
|
|
if (!agentMap.has(key)) agentMap.set(key, { displayName, calls: [] });
|
|
agentMap.get(key)!.calls.push(call);
|
|
}
|
|
|
|
return Array.from(agentMap.entries()).map(([key, { displayName, calls: agentCalls }]) => {
|
|
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
|
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
|
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
|
const total = agentCalls.length;
|
|
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
|
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
|
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
|
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
|
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
|
const nameParts = displayName.split(' ');
|
|
|
|
return {
|
|
id: key,
|
|
name: displayName,
|
|
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
|
inbound, outbound, missed, total, avgHandle, conversion,
|
|
};
|
|
}).sort((a, b) => b.total - a.total);
|
|
}, [calls]);
|
|
|
|
if (agents.length === 0) {
|
|
return (
|
|
<TableCard.Root size="sm">
|
|
<TableCard.Header title="Agent Performance" description="Call metrics by agent" />
|
|
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
|
<FontAwesomeIcon icon={faUserHeadset} className="size-8 text-fg-quaternary" />
|
|
<p className="text-sm text-tertiary">No agent data available</p>
|
|
</div>
|
|
</TableCard.Root>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TableCard.Root size="sm">
|
|
<TableCard.Header title="Agent Performance" badge={agents.length} description="Call metrics by agent" />
|
|
<Table>
|
|
<Table.Header>
|
|
<Table.Head label="AGENT" isRowHeader />
|
|
<Table.Head label="IN" />
|
|
<Table.Head label="OUT" />
|
|
<Table.Head label="MISSED" />
|
|
<Table.Head label="AVG HANDLE" />
|
|
<Table.Head label="CONVERSION" />
|
|
</Table.Header>
|
|
<Table.Body items={agents}>
|
|
{(agent) => (
|
|
<Table.Row id={agent.id}>
|
|
<Table.Cell>
|
|
<Link to={`/agent/${encodeURIComponent(agent.id)}`} className="no-underline">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar size="xs" initials={agent.initials} />
|
|
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
|
</div>
|
|
</Link>
|
|
</Table.Cell>
|
|
<Table.Cell><span className="text-sm text-success-primary">{agent.inbound}</span></Table.Cell>
|
|
<Table.Cell><span className="text-sm text-brand-secondary">{agent.outbound}</span></Table.Cell>
|
|
<Table.Cell>
|
|
{agent.missed > 0 ? <Badge size="sm" color="error">{agent.missed}</Badge> : <span className="text-sm text-tertiary">0</span>}
|
|
</Table.Cell>
|
|
<Table.Cell><span className="text-sm text-secondary">{formatDuration(agent.avgHandle)}</span></Table.Cell>
|
|
<Table.Cell>
|
|
<Badge size="sm" color={agent.conversion >= 30 ? 'success' : agent.conversion >= 15 ? 'warning' : 'gray'}>
|
|
{formatPercent(agent.conversion)}
|
|
</Badge>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
)}
|
|
</Table.Body>
|
|
</Table>
|
|
</TableCard.Root>
|
|
);
|
|
};
|