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,7 +1,15 @@
import { useMemo, useState } from 'react';
import type { FC, HTMLAttributes } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneXmark, faBell, faUsers } from '@fortawesome/pro-duotone-svg-icons';
import {
faPhoneArrowDown,
faPhoneArrowUp,
} 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 { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { ClickToCallButton } from './click-to-call-button';
import { formatPhone } from '@/lib/format';
import { cx } from '@/utils/cx';
@@ -20,6 +28,7 @@ type WorklistLead = {
type WorklistFollowUp = {
id: string;
createdAt: string | null;
followUpType: string | null;
followUpStatus: string | null;
scheduledAt: string | null;
@@ -29,6 +38,7 @@ type WorklistFollowUp = {
type MissedCall = {
id: string;
createdAt: string;
callDirection: string | null;
callerNumber: { number: string; callingCode: string }[] | null;
startedAt: string | null;
leadId: string | null;
@@ -43,54 +53,184 @@ interface WorklistPanelProps {
selectedLeadId: string | null;
}
const IconMissed: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneXmark} className={className} />
);
const IconFollowUp: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faBell} className={className} />
);
const IconLeads: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faUsers} className={className} />
);
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups';
const formatAge = (dateStr: string): string => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
// Unified row type for the table
type WorklistRow = {
id: string;
type: 'missed' | 'callback' | 'follow-up' | 'lead';
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
name: string;
phone: string;
phoneRaw: string;
direction: 'inbound' | 'outbound' | null;
typeLabel: string;
reason: string;
createdAt: string;
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
leadId: string | null;
originalLead: WorklistLead | null;
};
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
URGENT: { color: 'error', label: 'Urgent', sort: 0 },
HIGH: { color: 'warning', label: 'High', sort: 1 },
NORMAL: { color: 'brand', label: 'Normal', sort: 2 },
LOW: { color: 'gray', label: 'Low', sort: 3 },
};
const followUpLabel: Record<string, string> = {
CALLBACK: 'Callback',
APPOINTMENT_REMINDER: 'Appointment Reminder',
POST_VISIT: 'Post-visit Follow-up',
APPOINTMENT_REMINDER: 'Appt Reminder',
POST_VISIT: 'Post-visit',
MARKETING: 'Marketing',
REVIEW_REQUEST: 'Review Request',
REVIEW_REQUEST: 'Review',
};
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string }> = {
URGENT: { color: 'error', label: 'Urgent' },
HIGH: { color: 'warning', label: 'High' },
NORMAL: { color: 'brand', label: 'Normal' },
LOW: { color: 'gray', label: 'Low' },
// Compute SLA: minutes since created, color-coded
const computeSla = (createdAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(createdAt).getTime()) / 60000));
if (minutes < 1) return { label: '<1m', color: 'success' };
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
if (minutes < 60) return { label: `${minutes}m`, color: 'error' };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' };
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
};
const SectionHeader = ({ icon: Icon, title, count, color }: {
icon: FC<HTMLAttributes<HTMLOrSVGElement>>;
title: string;
count: number;
color: 'error' | 'blue' | 'brand';
}) => (
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
<Icon className="size-4 text-fg-quaternary" />
<span className="text-xs font-bold text-tertiary uppercase tracking-wider">{title}</span>
{count > 0 && <Badge size="sm" color={color} type="pill-color">{count}</Badge>}
</div>
const IconInbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneArrowDown} className={className} />
);
const IconOutbound: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneArrowUp} className={className} />
);
const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], leads: WorklistLead[]): WorklistRow[] => {
const rows: WorklistRow[] = [];
for (const call of missedCalls) {
const phone = call.callerNumber?.[0];
rows.push({
id: `mc-${call.id}`,
type: 'missed',
priority: 'HIGH',
name: phone ? formatPhone(phone) : 'Unknown',
phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
typeLabel: 'Missed Call',
reason: call.startedAt
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}`
: 'Missed call',
createdAt: call.createdAt,
taskState: 'PENDING',
leadId: call.leadId,
originalLead: null,
});
}
for (const fu of followUps) {
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
rows.push({
id: `fu-${fu.id}`,
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
name: label,
phone: '',
phoneRaw: '',
direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
reason: fu.scheduledAt
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
: '',
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
leadId: null,
originalLead: null,
});
}
for (const lead of leads) {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
rows.push({
id: `lead-${lead.id}`,
type: 'lead',
priority: 'NORMAL',
name: fullName,
phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '',
direction: null,
typeLabel: 'Lead',
reason: lead.interestedService ?? lead.aiSuggestedAction ?? '',
createdAt: lead.createdAt,
taskState: 'PENDING',
leadId: lead.id,
originalLead: lead,
});
}
// Sort by priority (urgent first), then by creation time (oldest first)
rows.sort((a, b) => {
const pa = priorityConfig[a.priority]?.sort ?? 2;
const pb = priorityConfig[b.priority]?.sort ?? 2;
if (pa !== pb) return pa - pb;
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
return rows;
};
const typeConfig: Record<WorklistRow['type'], { color: 'error' | 'brand' | 'blue-light' | 'gray' }> = {
missed: { color: 'error' },
callback: { color: 'brand' },
'follow-up': { color: 'blue-light' },
lead: { color: 'gray' },
};
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
const allRows = useMemo(
() => buildRows(missedCalls, followUps, leads),
[missedCalls, followUps, leads],
);
const filteredRows = useMemo(() => {
let rows = allRows;
// Tab filter
if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed');
else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
// Search filter
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(
(r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q),
);
}
return rows;
}, [allRows, tab, search]);
const missedCount = allRows.filter((r) => r.type === 'missed').length;
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
const tabItems = [
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
{ id: 'callbacks' as const, label: 'Callbacks', badge: callbackCount > 0 ? String(callbackCount) : undefined },
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
];
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -111,112 +251,124 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
}
return (
<div className="divide-y divide-secondary">
{/* Missed calls */}
{missedCalls.length > 0 && (
<div>
<SectionHeader icon={IconMissed} title="Missed Calls" count={missedCalls.length} color="error" />
<div className="px-3 pb-3">
{missedCalls.map((call) => {
const phone = call.callerNumber?.[0];
const phoneDisplay = phone ? formatPhone(phone) : 'Unknown number';
const phoneNumber = phone?.number ?? '';
return (
<div key={call.id} className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{phoneDisplay}</span>
<Badge size="sm" color="error" type="pill-color">
{call.createdAt ? formatAge(call.createdAt) : 'Unknown'}
</Badge>
</div>
{call.startedAt && (
<p className="text-xs text-tertiary mt-0.5">
{new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}
</p>
)}
</div>
<ClickToCallButton phoneNumber={phoneNumber} leadId={call.leadId ?? undefined} label="Call Back" size="sm" />
</div>
);
})}
<TableCard.Root size="sm">
<TableCard.Header
title="Worklist"
badge={String(allRows.length)}
contentTrailing={
<div className="flex items-center gap-2">
<div className="w-48">
<Input
placeholder="Search..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search worklist"
/>
</div>
</div>
</div>
)}
}
/>
{/* Follow-ups */}
{followUps.length > 0 && (
<div>
<SectionHeader icon={IconFollowUp} title="Follow-ups" count={followUps.length} color="blue" />
<div className="px-3 pb-3 space-y-1">
{followUps.map((fu) => {
const isOverdue = fu.followUpStatus === 'OVERDUE' ||
(fu.scheduledAt && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
const priority = priorityConfig[fu.priority ?? 'NORMAL'] ?? priorityConfig.NORMAL;
{/* Filter tabs */}
<div className="border-b border-secondary px-4">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as TabKey)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
</div>
{filteredRows.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-quaternary">
{search ? 'No matching items' : 'No items in this category'}
</p>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" />
<Table.Head label="PATIENT" />
<Table.Head label="PHONE" />
<Table.Head label="TYPE" />
<Table.Head label="SLA" className="w-20" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header>
<Table.Body items={filteredRows}>
{(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.createdAt);
const typeCfg = typeConfig[row.type];
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
return (
<div key={fu.id} className={cx(
"rounded-lg px-3 py-2.5 transition duration-100 ease-linear",
isOverdue ? "bg-error-primary" : "hover:bg-primary_hover",
)}>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{label}</span>
{isOverdue && <Badge size="sm" color="error" type="pill-color">Overdue</Badge>}
<Badge size="sm" color={priority.color} type="pill-color">{priority.label}</Badge>
</div>
{fu.scheduledAt && (
<p className="text-xs text-tertiary mt-0.5">
{new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Assigned leads */}
{leads.length > 0 && (
<div>
<SectionHeader icon={IconLeads} title="Assigned Leads" count={leads.length} color="brand" />
<div className="px-3 pb-3 space-y-1">
{leads.map((lead) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
const phoneDisplay = phone ? formatPhone(phone) : '';
const phoneNumber = phone?.number ?? '';
const isSelected = lead.id === selectedLeadId;
return (
<div
key={lead.id}
onClick={() => onSelectLead(lead)}
<Table.Row
id={row.id}
className={cx(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition duration-100 ease-linear",
isSelected ? "bg-brand-primary ring-1 ring-brand" : "hover:bg-primary_hover",
'cursor-pointer',
isSelected && 'bg-brand-primary',
)}
onAction={() => {
if (row.originalLead) {
onSelectLead(row.originalLead);
}
}}
>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-sm font-semibold text-primary">{fullName}</span>
{phoneDisplay && <span className="text-xs text-tertiary">{phoneDisplay}</span>}
<Table.Cell>
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-2">
{row.direction === 'inbound' && (
<IconInbound className="size-3.5 text-fg-success-secondary" />
)}
{row.direction === 'outbound' && (
<IconOutbound className="size-3.5 text-fg-brand-secondary" />
)}
<span className="text-sm font-medium text-primary truncate max-w-[140px]">
{row.name}
</span>
</div>
{lead.interestedService && (
<p className="text-xs text-quaternary mt-0.5">{lead.interestedService}</p>
)}
</div>
<ClickToCallButton phoneNumber={phoneNumber} leadId={lead.id} size="sm" />
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap">
{row.phone || '\u2014'}
</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={typeCfg.color} type="pill-color">
{row.typeLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color">
{sla.label}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
{row.phoneRaw ? (
<ClickToCallButton
phoneNumber={row.phoneRaw}
leadId={row.leadId ?? undefined}
size="sm"
/>
) : (
<span className="text-xs text-quaternary">No phone</span>
)}
</div>
</Table.Cell>
</Table.Row>
);
})}
</div>
</div>
}}
</Table.Body>
</Table>
)}
</div>
</TableCard.Root>
);
};