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:
2026-03-19 12:26:13 +05:30
parent c3b1bfe18e
commit 4c6cae9d65
12 changed files with 1994 additions and 263 deletions

View File

@@ -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>
);