mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Layout (P1-adjacent):
- context-panel switches to an edit-only layout when editingAppointment
is set. Previously AppointmentForm rendered inline BELOW the AI panel,
crushing the AI area into a ~2-line strip that made the returning-
patient summary + quick actions unusable. Edit view gets full height
with a "Back to context" button.
P2s:
- Remove Attempted sub-tab from Missed Calls worklist (Pending only).
- Add CALL_DROPPED disposition option + propagate through every
per-disposition Record<CallDisposition,...> map (incoming-call-card,
call-log, call-history, agent-detail, patient-360).
- Block SLA-gaming on unanswered calls: Book Appt / Enquiry / Transfer
buttons on active-call-card are disabled until the call reaches the
answered state (wasAnsweredRef). The disposition filter was already
in place; this closes the upstream entry.
- Data labels on performance charts: my-performance bar chart shows
value on top of each bar; donut shows {d}% slice labels; team-
performance day trend line shows per-point values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
295 lines
14 KiB
TypeScript
295 lines
14 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Link, useParams } from 'react-router';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import {
|
|
faArrowLeft,
|
|
faPhone,
|
|
faPhoneArrowDownLeft,
|
|
faPhoneArrowUpRight,
|
|
faPhoneMissed,
|
|
faClock,
|
|
faPercent,
|
|
faPhoneArrowDown,
|
|
faPhoneArrowUp,
|
|
faPhoneXmark,
|
|
faUserHeadset,
|
|
} from '@fortawesome/pro-duotone-svg-icons';
|
|
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
|
import { Avatar } from '@/components/base/avatar/avatar';
|
|
import { Badge } from '@/components/base/badges/badges';
|
|
import { Button } from '@/components/base/buttons/button';
|
|
import { Table, TableCard } from '@/components/application/table/table';
|
|
import { TopBar } from '@/components/layout/top-bar';
|
|
import { formatShortDate, formatPhone, getInitials } from '@/lib/format';
|
|
import { useData } from '@/providers/data-provider';
|
|
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
|
|
|
type KpiCardProps = {
|
|
label: string;
|
|
value: number | string;
|
|
icon: IconDefinition;
|
|
iconColor: string;
|
|
iconBg: string;
|
|
};
|
|
|
|
const KpiCard = ({ label, value, icon, iconColor, iconBg }: KpiCardProps) => (
|
|
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
|
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
|
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-medium text-tertiary">{label}</span>
|
|
<span className="text-lg font-bold text-primary">{value}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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)}%`;
|
|
};
|
|
|
|
const formatPhoneDisplay = (call: Call): string => {
|
|
if (call.callerNumber && call.callerNumber.length > 0) {
|
|
return formatPhone(call.callerNumber[0]);
|
|
}
|
|
return '\u2014';
|
|
};
|
|
|
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
|
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
|
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
|
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
|
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
|
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
|
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
|
};
|
|
|
|
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
|
|
if (status === 'MISSED') {
|
|
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
|
}
|
|
if (direction === 'OUTBOUND') {
|
|
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
|
|
}
|
|
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
|
};
|
|
|
|
export const AgentDetailPage = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { calls, leads, agents, loading } = useData();
|
|
|
|
// Route param is either a platform Agent UUID (new bucketing) or
|
|
// "legacy:<rawAgentName>" for calls that haven't been enriched yet.
|
|
// Older bookmarks may still pass the raw display name — handle that too.
|
|
const rawId = id ? decodeURIComponent(id) : '';
|
|
const isLegacy = rawId.startsWith('legacy:');
|
|
const agentUuid = !isLegacy ? rawId : null;
|
|
const legacyName = isLegacy ? rawId.slice('legacy:'.length) : null;
|
|
|
|
// Resolve display name: prefer Agent entity name, else the legacy string.
|
|
const agentName = useMemo(() => {
|
|
if (agentUuid) {
|
|
const a = agents.find((x: any) => x.id === agentUuid);
|
|
return a?.name ?? rawId;
|
|
}
|
|
return legacyName ?? '';
|
|
}, [agentUuid, legacyName, agents, rawId]);
|
|
|
|
const agentCalls = useMemo(
|
|
() =>
|
|
calls
|
|
.filter((c) => {
|
|
if (agentUuid) return c.agentId === agentUuid;
|
|
if (legacyName) return !c.agentId && c.agentName === legacyName;
|
|
return false;
|
|
})
|
|
.sort((a, b) => {
|
|
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
return dateB - dateA;
|
|
}),
|
|
[calls, agentUuid, legacyName],
|
|
);
|
|
|
|
// Build lead name map for enrichment
|
|
const leadNameMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const lead of leads) {
|
|
if (lead.id && lead.contactName) {
|
|
const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim();
|
|
if (name) map.set(lead.id, name);
|
|
}
|
|
}
|
|
return map;
|
|
}, [leads]);
|
|
|
|
// KPI calculations
|
|
const totalCalls = agentCalls.length;
|
|
const inboundCalls = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
|
const outboundCalls = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
|
const missedCalls = agentCalls.filter((c) => c.callStatus === 'MISSED').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 = totalCalls > 0 ? (booked / totalCalls) * 100 : 0;
|
|
|
|
const nameParts = agentName.split(' ');
|
|
const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? '');
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<TopBar title="Agent Detail" />
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<p className="text-sm text-tertiary">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (totalCalls === 0 && !loading) {
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<TopBar title="Agent Detail" />
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
|
|
<FontAwesomeIcon icon={faUserHeadset} className="size-10 text-fg-quaternary" />
|
|
<p className="text-md font-semibold text-primary">No data found for "{agentName}"</p>
|
|
<p className="text-sm text-tertiary">This agent has no call records.</p>
|
|
<Link to="/team-dashboard">
|
|
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
|
|
<FontAwesomeIcon icon={faArrowLeft} className={className} />
|
|
)}>
|
|
Back to Team Dashboard
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<TopBar title="Agent Detail" subtitle={`${totalCalls} total calls`} />
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
|
{/* Agent header + back button */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link to="/team-dashboard">
|
|
<Button size="sm" color="tertiary" iconLeading={({ className }: { className?: string }) => (
|
|
<FontAwesomeIcon icon={faArrowLeft} className={className} />
|
|
)}>
|
|
Back
|
|
</Button>
|
|
</Link>
|
|
<div className="h-8 w-px bg-secondary" />
|
|
<Avatar size="lg" initials={initials} />
|
|
<div className="flex flex-col">
|
|
<h2 className="text-lg font-bold text-primary">{agentName}</h2>
|
|
<p className="text-sm text-tertiary">Agent</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI row */}
|
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-6">
|
|
<KpiCard label="Total Calls" value={totalCalls} icon={faPhone} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" />
|
|
<KpiCard label="Inbound" value={inboundCalls} icon={faPhoneArrowDownLeft} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" />
|
|
<KpiCard label="Outbound" value={outboundCalls} icon={faPhoneArrowUpRight} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" />
|
|
<KpiCard label="Missed" value={missedCalls} icon={faPhoneMissed} iconColor="text-fg-error-primary" iconBg="bg-error-secondary" />
|
|
<KpiCard label="Avg Handle" value={formatDuration(avgHandle)} icon={faClock} iconColor="text-fg-warning-primary" iconBg="bg-warning-secondary" />
|
|
<KpiCard label="Conversion" value={formatPercent(conversion)} icon={faPercent} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" />
|
|
</div>
|
|
|
|
{/* Call log table */}
|
|
<TableCard.Root size="sm">
|
|
<TableCard.Header
|
|
title="Call Log"
|
|
badge={agentCalls.length}
|
|
description={`Calls handled by ${agentName}`}
|
|
/>
|
|
|
|
{agentCalls.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16">
|
|
<p className="text-sm text-tertiary">No calls found.</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<Table.Header>
|
|
<Table.Head label="TYPE" className="w-14" isRowHeader />
|
|
<Table.Head label="PATIENT" />
|
|
<Table.Head label="PHONE" />
|
|
<Table.Head label="DURATION" className="w-24" />
|
|
<Table.Head label="OUTCOME" />
|
|
<Table.Head label="TIME" />
|
|
</Table.Header>
|
|
<Table.Body items={agentCalls}>
|
|
{(call) => {
|
|
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
|
const phoneDisplay = formatPhoneDisplay(call);
|
|
const durationStr = call.durationSeconds !== null && call.durationSeconds > 0
|
|
? formatDuration(call.durationSeconds)
|
|
: '\u2014';
|
|
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
|
|
|
return (
|
|
<Table.Row id={call.id}>
|
|
<Table.Cell>
|
|
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
|
|
{patientName}
|
|
</span>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
|
{phoneDisplay}
|
|
</span>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<span className="text-sm text-secondary whitespace-nowrap">
|
|
{durationStr}
|
|
</span>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
{dispositionCfg ? (
|
|
<Badge size="sm" color={dispositionCfg.color} type="pill-color">
|
|
{dispositionCfg.label}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
|
)}
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
|
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
|
</span>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
);
|
|
}}
|
|
</Table.Body>
|
|
</Table>
|
|
)}
|
|
</TableCard.Root>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|