mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: build all data pages — worklist table, call history, patients, dashboard, reports
Worklist (call-desk): - Upgrade to Untitled UI Table with columns: Priority, Patient, Phone, Type, SLA, Actions - Filter tabs: All Tasks / Missed Calls / Callbacks / Follow-ups with counts - Search by name or phone - SLA timer color-coded: green <15m, amber <30m, red >30m Call History: - Full table: Type (direction icon), Patient (matched from leads), Phone, Duration, Outcome, Agent, Recording (play/pause), Time - Search + All/Inbound/Outbound/Missed filter - Recording playback via native <audio> Patients: - New page with table: Patient (avatar+name+age), Contact, Type, Gender, Status, Actions - Search + status filter - Call + View Details actions - Added patients to DataProvider + transforms + queries - Route /patients added, sidebar nav updated for cc-agent + executive Supervisor Dashboard: - KPI cards: Total Calls, Inbound, Outbound, Missed - Performance metrics: Avg Response Time, Callback Time, Conversion % - Agent performance table with per-agent stats - Missed Call Queue - AI Assistant section - Day/Week/Month filter Reports: - ECharts bar chart: Call Volume Trend (7-day, Inbound vs Outbound) - ECharts donut chart: Call Outcomes (Booked, Follow-up, Info, Missed) - KPI cards with trend indicators (+/-%) - Route /reports, sidebar Analytics → /reports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,124 +1,292 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhoneArrowDown,
|
||||
faPhoneArrowUp,
|
||||
faPhoneXmark,
|
||||
faPlay,
|
||||
faPause,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { SearchLg } from '@untitledui/icons';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import type { CallDisposition } from '@/types/entities';
|
||||
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||
|
||||
const dispositionColor = (disposition: CallDisposition | null): 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' => {
|
||||
switch (disposition) {
|
||||
case 'APPOINTMENT_BOOKED':
|
||||
return 'success';
|
||||
case 'FOLLOW_UP_SCHEDULED':
|
||||
return 'brand';
|
||||
case 'INFO_PROVIDED':
|
||||
return 'blue-light';
|
||||
case 'NO_ANSWER':
|
||||
return 'warning';
|
||||
case 'WRONG_NUMBER':
|
||||
return 'gray';
|
||||
case 'CALLBACK_REQUESTED':
|
||||
return 'brand';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||
|
||||
const formatDispositionLabel = (disposition: CallDisposition | null): string => {
|
||||
if (!disposition) return '—';
|
||||
return disposition
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const filterItems = [
|
||||
{ id: 'all' as const, label: 'All Calls' },
|
||||
{ id: 'inbound' as const, label: 'Inbound' },
|
||||
{ id: 'outbound' as const, label: 'Outbound' },
|
||||
{ id: 'missed' as const, label: 'Missed' },
|
||||
];
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||
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' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null): string => {
|
||||
if (seconds === null) return '—';
|
||||
const mins = Math.round(seconds / 60);
|
||||
return mins === 0 ? '<1 min' : `${mins} min`;
|
||||
if (seconds === null || seconds === 0) return '\u2014';
|
||||
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 formatCallerNumber = (callerNumber: { number: string; callingCode: string }[] | null): string => {
|
||||
if (!callerNumber || callerNumber.length === 0) return '—';
|
||||
const first = callerNumber[0];
|
||||
return `${first.callingCode} ${first.number}`;
|
||||
const formatPhoneDisplay = (call: Call): string => {
|
||||
if (call.callerNumber && call.callerNumber.length > 0) {
|
||||
return formatPhone(call.callerNumber[0]);
|
||||
}
|
||||
return '\u2014';
|
||||
};
|
||||
|
||||
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
|
||||
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" />;
|
||||
};
|
||||
|
||||
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().catch(() => setIsPlaying(false));
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<audio ref={audioRef} src={url} preload="none" onEnded={handleEnded} />
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary"
|
||||
iconLeading={
|
||||
<FontAwesomeIcon
|
||||
icon={isPlaying ? faPause : faPlay}
|
||||
data-icon
|
||||
className="size-3.5"
|
||||
/>
|
||||
}
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? 'Pause recording' : 'Play recording'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CallHistoryPage = () => {
|
||||
const { calls } = useData();
|
||||
const { user } = useAuth();
|
||||
const { calls, leads } = useData();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
|
||||
const agentCalls = calls
|
||||
.filter((call) => call.agentName === user.name)
|
||||
.sort((a, b) => {
|
||||
// Build a map of lead names by ID 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]);
|
||||
|
||||
// Sort by time (newest first) and apply filters
|
||||
const filteredCalls = useMemo(() => {
|
||||
let result = [...calls].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;
|
||||
});
|
||||
|
||||
// Direction / status filter
|
||||
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
|
||||
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
||||
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||
|
||||
// Search filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
|
||||
const phone = c.callerNumber?.[0]?.number ?? '';
|
||||
const agent = c.agentName ?? '';
|
||||
return (
|
||||
name.toLowerCase().includes(q) ||
|
||||
phone.includes(q) ||
|
||||
agent.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [calls, filter, search, leadNameMap]);
|
||||
|
||||
const inboundCount = calls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const outboundCount = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const missedCount = calls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Call History" subtitle="All inbound calls" />
|
||||
<TopBar title="Call History" subtitle={`${calls.length} total calls`} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-7">
|
||||
{agentCalls.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center py-20">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<TableCard.Root size="md">
|
||||
<TableCard.Header
|
||||
title="Call History"
|
||||
badge={String(filteredCalls.length)}
|
||||
description={`${inboundCount} inbound \u00B7 ${outboundCount} outbound \u00B7 ${missedCount} missed`}
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-44">
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="All Calls"
|
||||
selectedKey={filter}
|
||||
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||
items={filterItems}
|
||||
aria-label="Filter calls"
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item id={item.id} label={item.label}>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search calls..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={(value) => setSearch(value)}
|
||||
aria-label="Search calls"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{filteredCalls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
||||
<p className="text-sm text-tertiary">No call history available for your account yet.</p>
|
||||
<p className="text-sm text-tertiary mt-1">
|
||||
{search ? 'Try a different search term' : 'No call history available yet.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-secondary bg-primary overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-secondary">
|
||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
||||
Date / Time
|
||||
</th>
|
||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
||||
Caller
|
||||
</th>
|
||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
||||
Lead Name
|
||||
</th>
|
||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
||||
Duration
|
||||
</th>
|
||||
<th className="text-xs uppercase tracking-wider text-quaternary font-semibold px-4 py-3 text-left">
|
||||
Disposition
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agentCalls.map((call) => (
|
||||
<tr key={call.id} className="border-b border-tertiary hover:bg-primary_hover transition duration-100 ease-linear">
|
||||
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
|
||||
{call.startedAt ? formatShortDate(call.startedAt) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
|
||||
{formatCallerNumber(call.callerNumber)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-primary">
|
||||
{call.leadName ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-secondary whitespace-nowrap">
|
||||
{formatDuration(call.durationSeconds)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{call.disposition ? (
|
||||
<Badge size="sm" color={dispositionColor(call.disposition)}>
|
||||
{formatDispositionLabel(call.disposition)}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-tertiary">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Head label="TYPE" className="w-14" />
|
||||
<Table.Head label="PATIENT" />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="DURATION" className="w-24" />
|
||||
<Table.Head label="OUTCOME" />
|
||||
<Table.Head label="AGENT" />
|
||||
<Table.Head label="RECORDING" className="w-24" />
|
||||
<Table.Head label="TIME" />
|
||||
<Table.Head label="ACTIONS" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={filteredCalls}>
|
||||
{(call) => {
|
||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
||||
const phoneDisplay = formatPhoneDisplay(call);
|
||||
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
||||
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">
|
||||
{formatDuration(call.durationSeconds)}
|
||||
</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-secondary">
|
||||
{call.agentName ?? '\u2014'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{call.recordingUrl ? (
|
||||
<RecordingPlayer url={call.recordingUrl} />
|
||||
) : (
|
||||
<span className="text-xs 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.Cell>
|
||||
{phoneRaw ? (
|
||||
<ClickToCallButton
|
||||
phoneNumber={phoneRaw}
|
||||
leadId={call.leadId ?? undefined}
|
||||
label="Call"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user