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,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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
faCommentDots,
|
||||
faGear,
|
||||
faGrid2,
|
||||
faHospitalUser,
|
||||
faPhone,
|
||||
faPlug,
|
||||
faUsers,
|
||||
@@ -58,6 +59,9 @@ const IconClockRewind: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) =>
|
||||
const IconUsers: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faUsers} className={className} />
|
||||
);
|
||||
const IconHospitalUser: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faHospitalUser} className={className} />
|
||||
);
|
||||
|
||||
type NavSection = {
|
||||
label: string;
|
||||
@@ -70,7 +74,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
|
||||
{ label: 'Management', items: [
|
||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
||||
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
|
||||
]},
|
||||
{ label: 'Admin', items: [
|
||||
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
|
||||
@@ -83,6 +87,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
return [
|
||||
{ label: 'Call Center', items: [
|
||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
|
||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||
]},
|
||||
@@ -93,11 +98,12 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: 'Main', items: [
|
||||
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
||||
]},
|
||||
{ label: 'Insights', items: [
|
||||
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
||||
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
|
||||
]},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Transform platform GraphQL responses → frontend entity types
|
||||
// Platform remaps some field names during sync
|
||||
|
||||
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call } from '@/types/entities';
|
||||
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient } from '@/types/entities';
|
||||
|
||||
type PlatformNode = Record<string, any>;
|
||||
|
||||
@@ -150,3 +150,16 @@ export function transformCalls(data: any): Call[] {
|
||||
leadId: n.leadId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformPatients(data: any): Patient[] {
|
||||
return extractEdges(data, 'patients').map((n) => ({
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
fullName: n.fullName ?? null,
|
||||
phones: n.phones ?? null,
|
||||
emails: n.emails ?? null,
|
||||
dateOfBirth: n.dateOfBirth ?? null,
|
||||
gender: n.gender ?? null,
|
||||
patientType: n.patientType ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { FollowUpsPage } from "@/pages/follow-ups-page";
|
||||
import { LoginPage } from "@/pages/login";
|
||||
import { OutreachPage } from "@/pages/outreach";
|
||||
import { Patient360Page } from "@/pages/patient-360";
|
||||
import { ReportsPage } from "@/pages/reports";
|
||||
import { PatientsPage } from "@/pages/patients";
|
||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { DataProvider } from "@/providers/data-provider";
|
||||
@@ -47,7 +49,9 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
||||
@@ -75,16 +75,14 @@ export const CallDeskPage = () => {
|
||||
|
||||
{/* Worklist (visible when idle) */}
|
||||
{!isInCall && (
|
||||
<div className="rounded-xl border border-secondary bg-primary">
|
||||
<WorklistPanel
|
||||
missedCalls={missedCalls}
|
||||
followUps={followUps}
|
||||
leads={marketingLeads}
|
||||
loading={loading}
|
||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||
selectedLeadId={selectedLead?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
<WorklistPanel
|
||||
missedCalls={missedCalls}
|
||||
followUps={followUps}
|
||||
leads={marketingLeads}
|
||||
loading={loading}
|
||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||
selectedLeadId={selectedLead?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Today's calls — always visible */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
253
src/pages/patients.tsx
Normal file
253
src/pages/patients.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { SearchLg } from '@untitledui/icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { getInitials } from '@/lib/format';
|
||||
import type { Patient } from '@/types/entities';
|
||||
|
||||
const computeAge = (dateOfBirth: string | null): number | null => {
|
||||
if (!dateOfBirth) return null;
|
||||
const dob = new Date(dateOfBirth);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dob.getFullYear();
|
||||
const monthDiff = today.getMonth() - dob.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
};
|
||||
|
||||
const formatGender = (gender: string | null): string => {
|
||||
if (!gender) return '';
|
||||
switch (gender) {
|
||||
case 'MALE': return 'M';
|
||||
case 'FEMALE': return 'F';
|
||||
case 'OTHER': return 'O';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getPatientDisplayName = (patient: Patient): string => {
|
||||
if (patient.fullName) {
|
||||
return `${patient.fullName.firstName} ${patient.fullName.lastName}`.trim();
|
||||
}
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const getPatientPhone = (patient: Patient): string => {
|
||||
return patient.phones?.primaryPhoneNumber ?? '';
|
||||
};
|
||||
|
||||
const getPatientEmail = (patient: Patient): string => {
|
||||
return patient.emails?.primaryEmail ?? '';
|
||||
};
|
||||
|
||||
export const PatientsPage = () => {
|
||||
const { patients, loading } = useData();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
|
||||
const filteredPatients = useMemo(() => {
|
||||
return patients.filter((patient) => {
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const name = getPatientDisplayName(patient).toLowerCase();
|
||||
const phone = getPatientPhone(patient).toLowerCase();
|
||||
const email = getPatientEmail(patient).toLowerCase();
|
||||
if (!name.includes(query) && !phone.includes(query) && !email.includes(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter — treat all patients as active for now since we don't have a status field
|
||||
if (statusFilter === 'inactive') return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [patients, searchQuery, statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||
<TableCard.Root size="sm">
|
||||
<TableCard.Header
|
||||
title="All Patients"
|
||||
badge={filteredPatients.length}
|
||||
description="Manage and view patient records"
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status filter buttons */}
|
||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||
{(['all', 'active', 'inactive'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
|
||||
statusFilter === status
|
||||
? 'bg-active text-brand-secondary'
|
||||
: 'bg-primary text-tertiary hover:bg-primary_hover'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search by name or phone..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={(value) => setSearchQuery(value)}
|
||||
aria-label="Search patients"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-tertiary">Loading patients...</p>
|
||||
</div>
|
||||
) : filteredPatients.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-2">
|
||||
<FontAwesomeIcon icon={faUser} className="size-8 text-fg-quaternary" />
|
||||
<h3 className="text-sm font-semibold text-primary">No patients found</h3>
|
||||
<p className="text-sm text-tertiary">
|
||||
{searchQuery ? 'Try adjusting your search.' : 'No patient records available yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Head label="PATIENT" />
|
||||
<Table.Head label="CONTACT" />
|
||||
<Table.Head label="TYPE" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
<Table.Head label="STATUS" />
|
||||
<Table.Head label="ACTIONS" />
|
||||
</Table.Header>
|
||||
<Table.Body items={filteredPatients}>
|
||||
{(patient) => {
|
||||
const displayName = getPatientDisplayName(patient);
|
||||
const age = computeAge(patient.dateOfBirth);
|
||||
const gender = formatGender(patient.gender);
|
||||
const phone = getPatientPhone(patient);
|
||||
const email = getPatientEmail(patient);
|
||||
const initials = patient.fullName
|
||||
? getInitials(patient.fullName.firstName, patient.fullName.lastName)
|
||||
: '?';
|
||||
|
||||
return (
|
||||
<Table.Row id={patient.id}>
|
||||
{/* Patient name + avatar */}
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="sm" initials={initials} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{displayName}
|
||||
</span>
|
||||
{(age !== null || gender) && (
|
||||
<span className="text-xs text-tertiary">
|
||||
{[
|
||||
age !== null ? `${age}y` : null,
|
||||
gender || null,
|
||||
].filter(Boolean).join(' / ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Contact */}
|
||||
<Table.Cell>
|
||||
<div className="flex flex-col">
|
||||
{phone ? (
|
||||
<span className="text-sm text-secondary">{phone}</span>
|
||||
) : (
|
||||
<span className="text-sm text-placeholder">No phone</span>
|
||||
)}
|
||||
{email ? (
|
||||
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Type */}
|
||||
<Table.Cell>
|
||||
{patient.patientType ? (
|
||||
<Badge size="sm" color="gray">
|
||||
{patient.patientType}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-placeholder">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Gender */}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Age */}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{age !== null ? `${age} yrs` : '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Status */}
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color="success" type="pill-color">
|
||||
Active
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Actions */}
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-2">
|
||||
{phone && (
|
||||
<ClickToCallButton
|
||||
phoneNumber={phone}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="link-color"
|
||||
onClick={() => navigate(`/patient/${patient.id}`)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
347
src/pages/reports.tsx
Normal file
347
src/pages/reports.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faArrowTrendUp,
|
||||
faArrowTrendDown,
|
||||
faPhoneVolume,
|
||||
faPhoneArrowDownLeft,
|
||||
faPhoneArrowUpRight,
|
||||
faPercent,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { BadgeWithIcon } from '@/components/base/badges/badges';
|
||||
import { ArrowUp, ArrowDown } from '@untitledui/icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import type { Call } from '@/types/entities';
|
||||
|
||||
// Chart color palette — hardcoded from CSS tokens so ECharts can use them
|
||||
const COLORS = {
|
||||
brand600: 'rgb(21, 112, 239)',
|
||||
brand500: 'rgb(59, 130, 246)',
|
||||
gray300: 'rgb(213, 215, 218)',
|
||||
gray400: 'rgb(164, 167, 174)',
|
||||
success500: 'rgb(23, 178, 106)',
|
||||
warning500: 'rgb(247, 144, 9)',
|
||||
error500: 'rgb(240, 68, 56)',
|
||||
purple500: 'rgb(158, 119, 237)',
|
||||
};
|
||||
|
||||
// Helpers
|
||||
|
||||
const getLast7Days = (): { label: string; dateKey: string }[] => {
|
||||
const days: { label: string; dateKey: string }[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(now);
|
||||
date.setDate(now.getDate() - i);
|
||||
const label = date.toLocaleDateString('en-IN', { weekday: 'short', day: 'numeric' });
|
||||
const dateKey = date.toISOString().slice(0, 10);
|
||||
days.push({ label, dateKey });
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const groupCallsByDate = (calls: Call[]): Record<string, { inbound: number; outbound: number }> => {
|
||||
const grouped: Record<string, { inbound: number; outbound: number }> = {};
|
||||
|
||||
for (const call of calls) {
|
||||
const dateStr = call.startedAt ?? call.createdAt;
|
||||
if (!dateStr) continue;
|
||||
const dateKey = new Date(dateStr).toISOString().slice(0, 10);
|
||||
|
||||
if (!grouped[dateKey]) {
|
||||
grouped[dateKey] = { inbound: 0, outbound: 0 };
|
||||
}
|
||||
|
||||
if (call.callDirection === 'INBOUND') {
|
||||
grouped[dateKey].inbound++;
|
||||
} else if (call.callDirection === 'OUTBOUND') {
|
||||
grouped[dateKey].outbound++;
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const computeTrendPercent = (current: number, previous: number): number => {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
};
|
||||
|
||||
// Components
|
||||
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend: number;
|
||||
icon: typeof faPhoneVolume;
|
||||
iconColor: string;
|
||||
};
|
||||
|
||||
const KpiCard = ({ label, value, trend, icon, iconColor }: KpiCardProps) => {
|
||||
const isPositive = trend >= 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-secondary bg-primary p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-tertiary">{label}</span>
|
||||
<FontAwesomeIcon icon={icon} className="size-5" style={{ color: iconColor }} />
|
||||
</div>
|
||||
<div className="flex items-end gap-3">
|
||||
<span className="text-display-sm font-bold text-primary">{value}</span>
|
||||
<BadgeWithIcon
|
||||
size="sm"
|
||||
color={isPositive ? 'success' : 'error'}
|
||||
iconLeading={isPositive ? ArrowUp : ArrowDown}
|
||||
>
|
||||
{Math.abs(trend)}%
|
||||
</BadgeWithIcon>
|
||||
</div>
|
||||
<span className="text-xs text-quaternary">vs previous 7 days</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReportsPage = () => {
|
||||
const { calls, loading } = useData();
|
||||
|
||||
// Split current 7 days vs previous 7 days
|
||||
const { currentCalls, previousCalls } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now);
|
||||
sevenDaysAgo.setDate(now.getDate() - 7);
|
||||
const fourteenDaysAgo = new Date(now);
|
||||
fourteenDaysAgo.setDate(now.getDate() - 14);
|
||||
|
||||
const current: Call[] = [];
|
||||
const previous: Call[] = [];
|
||||
|
||||
for (const call of calls) {
|
||||
const dateStr = call.startedAt ?? call.createdAt;
|
||||
if (!dateStr) continue;
|
||||
const date = new Date(dateStr);
|
||||
|
||||
if (date >= sevenDaysAgo) {
|
||||
current.push(call);
|
||||
} else if (date >= fourteenDaysAgo) {
|
||||
previous.push(call);
|
||||
}
|
||||
}
|
||||
|
||||
return { currentCalls: current, previousCalls: previous };
|
||||
}, [calls]);
|
||||
|
||||
// KPI values
|
||||
const kpis = useMemo(() => {
|
||||
const totalCurrent = currentCalls.length;
|
||||
const totalPrevious = previousCalls.length;
|
||||
|
||||
const inboundCurrent = currentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const inboundPrevious = previousCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
|
||||
const outboundCurrent = currentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const outboundPrevious = previousCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
|
||||
const bookedCurrent = currentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const bookedPrevious = previousCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const conversionCurrent = totalCurrent > 0 ? Math.round((bookedCurrent / totalCurrent) * 100) : 0;
|
||||
const conversionPrevious = totalPrevious > 0 ? Math.round((bookedPrevious / totalPrevious) * 100) : 0;
|
||||
|
||||
return {
|
||||
total: { value: totalCurrent, trend: computeTrendPercent(totalCurrent, totalPrevious) },
|
||||
inbound: { value: inboundCurrent, trend: computeTrendPercent(inboundCurrent, inboundPrevious) },
|
||||
outbound: { value: outboundCurrent, trend: computeTrendPercent(outboundCurrent, outboundPrevious) },
|
||||
conversion: { value: conversionCurrent, trend: computeTrendPercent(conversionCurrent, conversionPrevious) },
|
||||
};
|
||||
}, [currentCalls, previousCalls]);
|
||||
|
||||
// Bar chart data — last 7 days
|
||||
const barChartOption = useMemo(() => {
|
||||
const days = getLast7Days();
|
||||
const grouped = groupCallsByDate(calls);
|
||||
|
||||
const inboundData = days.map((d) => grouped[d.dateKey]?.inbound ?? 0);
|
||||
const outboundData = days.map((d) => grouped[d.dateKey]?.outbound ?? 0);
|
||||
const labels = days.map((d) => d.label);
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
backgroundColor: '#fff',
|
||||
borderColor: '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
textStyle: { color: '#344054', fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
textStyle: { color: '#667085', fontSize: 12 },
|
||||
data: ['Inbound', 'Outbound'],
|
||||
},
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: labels,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#667085', fontSize: 12 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
splitLine: { lineStyle: { color: '#f2f4f7' } },
|
||||
axisLabel: { color: '#667085', fontSize: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Inbound',
|
||||
type: 'bar' as const,
|
||||
data: inboundData,
|
||||
barGap: '10%',
|
||||
itemStyle: { color: COLORS.gray400, borderRadius: [4, 4, 0, 0] },
|
||||
},
|
||||
{
|
||||
name: 'Outbound',
|
||||
type: 'bar' as const,
|
||||
data: outboundData,
|
||||
itemStyle: { color: COLORS.brand600, borderRadius: [4, 4, 0, 0] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [calls]);
|
||||
|
||||
// Donut chart data — call outcomes
|
||||
const { donutOption, donutTotal } = useMemo(() => {
|
||||
const booked = currentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const followUp = currentCalls.filter((c) => c.disposition === 'FOLLOW_UP_SCHEDULED').length;
|
||||
const infoOnly = currentCalls.filter((c) => c.disposition === 'INFO_PROVIDED').length;
|
||||
const missed = currentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
const other = currentCalls.length - booked - followUp - infoOnly - missed;
|
||||
const total = currentCalls.length;
|
||||
|
||||
const data = [
|
||||
{ value: booked, name: 'Booked', itemStyle: { color: COLORS.success500 } },
|
||||
{ value: followUp, name: 'Follow-up', itemStyle: { color: COLORS.brand600 } },
|
||||
{ value: infoOnly, name: 'Info Only', itemStyle: { color: COLORS.purple500 } },
|
||||
{ value: missed, name: 'Missed', itemStyle: { color: COLORS.error500 } },
|
||||
...(other > 0 ? [{ value: other, name: 'Other', itemStyle: { color: COLORS.gray300 } }] : []),
|
||||
].filter((d) => d.value > 0);
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item' as const,
|
||||
backgroundColor: '#fff',
|
||||
borderColor: '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
textStyle: { color: '#344054', fontSize: 12 },
|
||||
formatter: (params: { name: string; value: number; percent: number }) =>
|
||||
`${params.name}: ${params.value} (${params.percent}%)`,
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
textStyle: { color: '#667085', fontSize: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie' as const,
|
||||
radius: ['55%', '80%'],
|
||||
center: ['50%', '45%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'center' as const,
|
||||
formatter: () => `${total}`,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: '#101828',
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 28, fontWeight: 700 },
|
||||
},
|
||||
labelLine: { show: false },
|
||||
data,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return { donutOption: option, donutTotal: total };
|
||||
}, [currentCalls]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Reports" subtitle="Call analytics and insights" />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-tertiary">Loading data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Reports" subtitle="Call analytics and insights" />
|
||||
<div className="flex-1 overflow-y-auto p-7 space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Total Calls"
|
||||
value={kpis.total.value}
|
||||
trend={kpis.total.trend}
|
||||
icon={faPhoneVolume}
|
||||
iconColor={COLORS.brand600}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Inbound"
|
||||
value={kpis.inbound.value}
|
||||
trend={kpis.inbound.trend}
|
||||
icon={faPhoneArrowDownLeft}
|
||||
iconColor={COLORS.gray400}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Outbound"
|
||||
value={kpis.outbound.value}
|
||||
trend={kpis.outbound.trend}
|
||||
icon={faPhoneArrowUpRight}
|
||||
iconColor={COLORS.success500}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Conversion %"
|
||||
value={`${kpis.conversion.value}%`}
|
||||
trend={kpis.conversion.trend}
|
||||
icon={faPercent}
|
||||
iconColor={COLORS.warning500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
{/* Call Volume Trend — 2/3 width */}
|
||||
<div className="col-span-1 xl:col-span-2 rounded-xl border border-secondary bg-primary p-5">
|
||||
<h2 className="text-md font-semibold text-primary mb-1">Call Volume Trend</h2>
|
||||
<p className="text-sm text-tertiary mb-4">Inbound vs outbound calls — last 7 days</p>
|
||||
<ReactECharts option={barChartOption} style={{ height: 340 }} />
|
||||
</div>
|
||||
|
||||
{/* Call Outcomes Donut — 1/3 width */}
|
||||
<div className="col-span-1 rounded-xl border border-secondary bg-primary p-5">
|
||||
<h2 className="text-md font-semibold text-primary mb-1">Call Outcomes</h2>
|
||||
<p className="text-sm text-tertiary mb-4">Disposition breakdown — last 7 days</p>
|
||||
{donutTotal === 0 ? (
|
||||
<div className="flex h-[340px] items-center justify-center">
|
||||
<p className="text-sm text-tertiary">No call data in this period</p>
|
||||
</div>
|
||||
) : (
|
||||
<ReactECharts option={donutOption} style={{ height: 340 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +1,456 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone,
|
||||
faPhoneArrowDownLeft,
|
||||
faPhoneArrowUpRight,
|
||||
faPhoneMissed,
|
||||
faClock,
|
||||
faCalendarCheck,
|
||||
faUserHeadset,
|
||||
faChartMixed,
|
||||
} 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 { Table, TableCard } from '@/components/application/table/table';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { TeamScoreboard } from '@/components/admin/team-scoreboard';
|
||||
import { CampaignRoiCards } from '@/components/admin/campaign-roi-cards';
|
||||
import { LeadFunnel } from '@/components/admin/lead-funnel';
|
||||
import { SlaMetrics } from '@/components/admin/sla-metrics';
|
||||
import { IntegrationHealth } from '@/components/admin/integration-health';
|
||||
import { useLeads } from '@/hooks/use-leads';
|
||||
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { getInitials, formatShortDate } from '@/lib/format';
|
||||
import type { Call } from '@/types/entities';
|
||||
|
||||
// KPI Card component
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: IconDefinition;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
const KpiCard = ({ label, value, icon, iconColor, iconBg, subtitle }: KpiCardProps) => (
|
||||
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
||||
<div className={`flex size-12 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
||||
<FontAwesomeIcon icon={icon} className={`size-5 ${iconColor}`} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
<span className="text-display-xs font-bold text-primary">{value}</span>
|
||||
{subtitle && <span className="text-xs text-tertiary">{subtitle}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Metric card for performance row
|
||||
type MetricCardProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const MetricCard = ({ label, value, description }: MetricCardProps) => (
|
||||
<div className="flex flex-1 flex-col gap-1 rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
<span className="text-lg font-bold text-primary">{value}</span>
|
||||
<span className="text-xs text-tertiary">{description}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month';
|
||||
|
||||
const getDateRangeStart = (range: DateRange): Date => {
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case 'today': {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return start;
|
||||
}
|
||||
case 'week': {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 7);
|
||||
return start;
|
||||
}
|
||||
case 'month': {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 30);
|
||||
return start;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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)}%`;
|
||||
};
|
||||
|
||||
type AgentPerformance = {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
inboundCalls: number;
|
||||
outboundCalls: number;
|
||||
missedCalls: number;
|
||||
totalCalls: number;
|
||||
avgHandleTime: number;
|
||||
appointmentsBooked: number;
|
||||
conversionRate: number;
|
||||
};
|
||||
|
||||
export const TeamDashboardPage = () => {
|
||||
const { leads } = useLeads();
|
||||
const { campaigns } = useCampaigns();
|
||||
const { calls, agents, ingestionSources } = useData();
|
||||
const { calls, leads, followUps, loading } = useData();
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||
|
||||
// Filter calls by date range
|
||||
const filteredCalls = useMemo(() => {
|
||||
const rangeStart = getDateRangeStart(dateRange);
|
||||
return calls.filter((call) => {
|
||||
if (!call.startedAt) return false;
|
||||
return new Date(call.startedAt) >= rangeStart;
|
||||
});
|
||||
}, [calls, dateRange]);
|
||||
|
||||
// KPI computations
|
||||
const totalCalls = filteredCalls.length;
|
||||
const inboundCalls = filteredCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const outboundCalls = filteredCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const missedCalls = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
// Performance metrics
|
||||
const avgResponseTime = useMemo(() => {
|
||||
const leadsWithResponse = leads.filter((l) => l.createdAt && l.firstContactedAt);
|
||||
if (leadsWithResponse.length === 0) return null;
|
||||
const totalMinutes = leadsWithResponse.reduce((sum, l) => {
|
||||
const created = new Date(l.createdAt!).getTime();
|
||||
const contacted = new Date(l.firstContactedAt!).getTime();
|
||||
return sum + (contacted - created) / 60000;
|
||||
}, 0);
|
||||
return Math.round(totalMinutes / leadsWithResponse.length);
|
||||
}, [leads]);
|
||||
|
||||
const missedCallbackTime = useMemo(() => {
|
||||
const missedCallsList = filteredCalls.filter((c) => c.callStatus === 'MISSED' && c.startedAt);
|
||||
if (missedCallsList.length === 0) return null;
|
||||
const now = Date.now();
|
||||
const totalMinutes = missedCallsList.reduce((sum, c) => {
|
||||
return sum + (now - new Date(c.startedAt!).getTime()) / 60000;
|
||||
}, 0);
|
||||
return Math.round(totalMinutes / missedCallsList.length);
|
||||
}, [filteredCalls]);
|
||||
|
||||
const callToAppointmentRate = useMemo(() => {
|
||||
if (totalCalls === 0) return 0;
|
||||
const booked = filteredCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
return (booked / totalCalls) * 100;
|
||||
}, [filteredCalls, totalCalls]);
|
||||
|
||||
const leadToAppointmentRate = useMemo(() => {
|
||||
if (leads.length === 0) return 0;
|
||||
const converted = leads.filter(
|
||||
(l) => l.leadStatus === 'APPOINTMENT_SET' || l.leadStatus === 'CONVERTED',
|
||||
).length;
|
||||
return (converted / leads.length) * 100;
|
||||
}, [leads]);
|
||||
|
||||
// Agent performance table data
|
||||
const agentPerformance = useMemo((): AgentPerformance[] => {
|
||||
const agentMap = new Map<string, Call[]>();
|
||||
for (const call of filteredCalls) {
|
||||
const agent = call.agentName ?? 'Unknown';
|
||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||
agentMap.get(agent)!.push(call);
|
||||
}
|
||||
|
||||
return Array.from(agentMap.entries()).map(([name, 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 totalDuration = agentCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||
const completedCalls = agentCalls.filter((c) => (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 = name.split(' ');
|
||||
const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? '');
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
initials,
|
||||
inboundCalls: inbound,
|
||||
outboundCalls: outbound,
|
||||
missedCalls: missed,
|
||||
totalCalls: total,
|
||||
avgHandleTime: avgHandle,
|
||||
appointmentsBooked: booked,
|
||||
conversionRate: conversion,
|
||||
};
|
||||
}).sort((a, b) => b.totalCalls - a.totalCalls);
|
||||
}, [filteredCalls]);
|
||||
|
||||
// Missed call queue (recent missed calls)
|
||||
const missedCallQueue = useMemo(() => {
|
||||
return filteredCalls
|
||||
.filter((c) => c.callStatus === 'MISSED')
|
||||
.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;
|
||||
})
|
||||
.slice(0, 10);
|
||||
}, [filteredCalls]);
|
||||
|
||||
const formatCallerPhone = (call: Call): string => {
|
||||
if (!call.callerNumber || call.callerNumber.length === 0) return 'Unknown';
|
||||
const first = call.callerNumber[0];
|
||||
return `${first.callingCode} ${first.number}`;
|
||||
};
|
||||
|
||||
const getTimeSince = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '—';
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
// Supervisor AI quick prompts
|
||||
const supervisorQuickPrompts = [
|
||||
{ label: 'Top conversions', template: 'Which agents have the highest conversion rates this week?' },
|
||||
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
|
||||
{ label: 'Missed callback risks', template: 'Which missed calls have been waiting the longest without a callback?' },
|
||||
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance.' },
|
||||
];
|
||||
|
||||
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Team Dashboard" subtitle="Global Hospital \u00b7 This Week" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Team Dashboard" subtitle={`Global Hospital \u00b7 ${dateRangeLabel}`} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-7 space-y-6">
|
||||
<TeamScoreboard leads={leads} calls={calls} agents={agents} />
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<LeadFunnel leads={leads} />
|
||||
<SlaMetrics leads={leads} />
|
||||
{/* Date range filter */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-md font-semibold text-primary">Overview</h2>
|
||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||
{(['today', 'week', 'month'] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setDateRange(range)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
|
||||
dateRange === range
|
||||
? 'bg-active text-brand-secondary'
|
||||
: 'bg-primary text-tertiary hover:bg-primary_hover'
|
||||
}`}
|
||||
>
|
||||
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<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"
|
||||
subtitle={totalCalls > 0 ? `${formatPercent((missedCalls / totalCalls) * 100)} of total` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
label="Avg Lead Response Time"
|
||||
value={avgResponseTime !== null ? `${avgResponseTime} min` : '—'}
|
||||
description="Time from lead creation to first contact"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Avg Missed Callback Time"
|
||||
value={missedCallbackTime !== null ? `${missedCallbackTime} min` : '—'}
|
||||
description="Avg wait time for missed call callbacks"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Call to Appointment %"
|
||||
value={formatPercent(callToAppointmentRate)}
|
||||
description="Calls resulting in appointments"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Lead to Appointment %"
|
||||
value={formatPercent(leadToAppointmentRate)}
|
||||
description="Leads converted to appointments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent Performance Table + Missed Call Queue */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
{/* Agent Performance Table */}
|
||||
<div className="xl:col-span-2">
|
||||
<TableCard.Root size="sm">
|
||||
<TableCard.Header
|
||||
title="Agent Performance"
|
||||
badge={agentPerformance.length}
|
||||
description="Call metrics by agent"
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading...</p>
|
||||
</div>
|
||||
) : agentPerformance.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Head label="AGENT" />
|
||||
<Table.Head label="INBOUND" />
|
||||
<Table.Head label="OUTBOUND" />
|
||||
<Table.Head label="MISSED" />
|
||||
<Table.Head label="AVG HANDLE TIME" />
|
||||
<Table.Head label="CONVERSION %" />
|
||||
</Table.Header>
|
||||
<Table.Body items={agentPerformance}>
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.id}>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="sm" initials={agent.initials} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span className="text-xs text-tertiary">
|
||||
{agent.totalCalls} total calls
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm font-medium text-success-primary">{agent.inboundCalls}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm font-medium text-brand-secondary">{agent.outboundCalls}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{agent.missedCalls > 0 ? (
|
||||
<Badge size="sm" color="error">{agent.missedCalls}</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-tertiary">0</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{formatDuration(agent.avgHandleTime)}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={agent.conversionRate >= 30 ? 'success' : agent.conversionRate >= 15 ? 'warning' : 'gray'}
|
||||
>
|
||||
{formatPercent(agent.conversionRate)}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
|
||||
{/* Missed Call Queue */}
|
||||
<div className="xl:col-span-1">
|
||||
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faPhoneMissed} className="size-4 text-fg-error-primary" />
|
||||
<h3 className="text-md font-semibold text-primary">Missed Call Queue</h3>
|
||||
</div>
|
||||
{missedCalls > 0 && (
|
||||
<Badge size="sm" color="error">{missedCalls}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{missedCallQueue.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
||||
<FontAwesomeIcon icon={faPhoneMissed} className="size-6 text-fg-quaternary" />
|
||||
<p className="text-sm text-tertiary">No missed calls</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-secondary">
|
||||
{missedCallQueue.map((call) => (
|
||||
<li key={call.id} className="flex items-center justify-between px-5 py-3 hover:bg-primary_hover transition duration-100 ease-linear">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{formatCallerPhone(call)}
|
||||
</span>
|
||||
{call.leadName && (
|
||||
<span className="text-xs text-tertiary">{call.leadName}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-tertiary whitespace-nowrap">
|
||||
{getTimeSince(call.startedAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Assistant Section */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FontAwesomeIcon icon={faChartMixed} className="size-4 text-fg-brand-primary" />
|
||||
<h3 className="text-md font-semibold text-primary">Supervisor AI Assistant</h3>
|
||||
</div>
|
||||
<div className="h-[350px]">
|
||||
<AiChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
<CampaignRoiCards campaigns={campaigns} />
|
||||
<IntegrationHealth sources={ingestionSources} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FOLLOW_UPS_QUERY,
|
||||
LEAD_ACTIVITIES_QUERY,
|
||||
CALLS_QUERY,
|
||||
PATIENTS_QUERY,
|
||||
} from '@/lib/queries';
|
||||
import {
|
||||
transformLeads,
|
||||
@@ -16,9 +17,10 @@ import {
|
||||
transformFollowUps,
|
||||
transformLeadActivities,
|
||||
transformCalls,
|
||||
transformPatients,
|
||||
} from '@/lib/transforms';
|
||||
|
||||
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource } from '@/types/entities';
|
||||
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient } from '@/types/entities';
|
||||
|
||||
type DataContextType = {
|
||||
leads: Lead[];
|
||||
@@ -29,6 +31,7 @@ type DataContextType = {
|
||||
templates: WhatsAppTemplate[];
|
||||
agents: Agent[];
|
||||
calls: Call[];
|
||||
patients: Patient[];
|
||||
ingestionSources: LeadIngestionSource[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -60,6 +63,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
||||
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
||||
const [calls, setCalls] = useState<Call[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -80,13 +84,14 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
try {
|
||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
|
||||
gql<any>(LEADS_QUERY),
|
||||
gql<any>(CAMPAIGNS_QUERY),
|
||||
gql<any>(ADS_QUERY),
|
||||
gql<any>(FOLLOW_UPS_QUERY),
|
||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||
gql<any>(CALLS_QUERY),
|
||||
gql<any>(PATIENTS_QUERY),
|
||||
]);
|
||||
|
||||
if (leadsData) setLeads(transformLeads(leadsData));
|
||||
@@ -95,6 +100,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
||||
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
||||
if (callsData) setCalls(transformCalls(callsData));
|
||||
if (patientsData) setPatients(transformPatients(patientsData));
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load data');
|
||||
} finally {
|
||||
@@ -116,7 +122,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{
|
||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, ingestionSources,
|
||||
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, patients, ingestionSources,
|
||||
loading, error,
|
||||
updateLead, addCall, refresh: fetchData,
|
||||
}}>
|
||||
|
||||
@@ -278,6 +278,22 @@ export type Call = {
|
||||
leadService?: string;
|
||||
};
|
||||
|
||||
// Patient domain
|
||||
export type PatientStatus = 'ACTIVE' | 'INACTIVE';
|
||||
export type PatientGender = 'MALE' | 'FEMALE' | 'OTHER';
|
||||
export type PatientType = 'OPD' | 'IPD' | 'EMERGENCY' | 'REGULAR';
|
||||
|
||||
export type Patient = {
|
||||
id: string;
|
||||
createdAt: string | null;
|
||||
fullName: { firstName: string; lastName: string } | null;
|
||||
phones: { primaryPhoneNumber: string } | null;
|
||||
emails: { primaryEmail: string } | null;
|
||||
dateOfBirth: string | null;
|
||||
gender: PatientGender | null;
|
||||
patientType: PatientType | null;
|
||||
};
|
||||
|
||||
// Lead Ingestion Source domain
|
||||
export type IntegrationStatus = 'ACTIVE' | 'WARNING' | 'ERROR' | 'DISABLED';
|
||||
export type AuthStatus = 'VALID' | 'EXPIRING_SOON' | 'EXPIRED' | 'NOT_CONFIGURED';
|
||||
|
||||
Reference in New Issue
Block a user