mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log - Disposition modal: auto-lock based on actions taken, not-interested split - Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format) - Worklist-panel: pagination awareness, filter chips - Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish - SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner - Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts - Types: entities.ts extended Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
290 lines
17 KiB
TypeScript
290 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState, useCallback } from 'react';
|
|
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
|
import type { SortDescriptor } from 'react-aria-components';
|
|
import { faIcon } from '@/lib/icon-wrapper';
|
|
|
|
const SearchLg = faIcon(faMagnifyingGlass);
|
|
import { Badge } from '@/components/base/badges/badges';
|
|
import { Input } from '@/components/base/input/input';
|
|
import { Table } from '@/components/application/table/table';
|
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
|
import { TopBar } from '@/components/layout/top-bar';
|
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format';
|
|
import { computeSlaStatus } from '@/lib/scoring';
|
|
import { cx } from '@/utils/cx';
|
|
|
|
type MissedCallRecord = {
|
|
id: string;
|
|
callerNumber: { primaryPhoneNumber: string } | null;
|
|
agentName: string | null;
|
|
startedAt: string | null;
|
|
callSourceNumber: string | null;
|
|
callbackStatus: string | null;
|
|
missedCallCount: number | null;
|
|
callbackAttemptedAt: string | null;
|
|
sla: number | null;
|
|
};
|
|
|
|
type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED';
|
|
|
|
const QUERY = `{ calls(first: 200, filter: {
|
|
callStatus: { eq: MISSED }
|
|
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
|
id callerNumber { primaryPhoneNumber } agentName
|
|
startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
|
|
} } } }`;
|
|
|
|
const PAGE_SIZE = 15;
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
PENDING_CALLBACK: 'Pending',
|
|
CALLBACK_ATTEMPTED: 'Attempted',
|
|
CALLBACK_COMPLETED: 'Completed',
|
|
WRONG_NUMBER: 'Wrong Number',
|
|
INVALID: 'Invalid',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' | 'gray'> = {
|
|
PENDING_CALLBACK: 'warning',
|
|
CALLBACK_ATTEMPTED: 'brand',
|
|
CALLBACK_COMPLETED: 'success',
|
|
WRONG_NUMBER: 'error',
|
|
INVALID: 'gray',
|
|
};
|
|
|
|
const columnDefs = [
|
|
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
|
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
|
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
|
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
|
{ id: 'count', label: 'Count', defaultVisible: true },
|
|
{ id: 'status', label: 'Status', defaultVisible: true },
|
|
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
|
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
|
];
|
|
|
|
export const MissedCallsPage = () => {
|
|
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [tab, setTab] = useState<StatusTab>('all');
|
|
const [search, setSearch] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
|
|
|
const fetchCalls = useCallback(() => {
|
|
apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
|
|
.then(data => setCalls(data.calls.edges.map(e => e.node)))
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchCalls();
|
|
const interval = setInterval(fetchCalls, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchCalls]);
|
|
|
|
const statusCounts = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const c of calls) {
|
|
const s = c.callbackStatus ?? 'PENDING_CALLBACK';
|
|
counts[s] = (counts[s] ?? 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [calls]);
|
|
|
|
const filtered = useMemo(() => {
|
|
let rows = calls;
|
|
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
|
|
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED');
|
|
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
|
|
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase();
|
|
rows = rows.filter(c =>
|
|
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
|
(c.agentName ?? '').toLowerCase().includes(q) ||
|
|
(c.callSourceNumber ?? '').toLowerCase().includes(q),
|
|
);
|
|
}
|
|
|
|
if (sortDescriptor.column) {
|
|
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
|
rows = [...rows].sort((a, b) => {
|
|
switch (sortDescriptor.column) {
|
|
case 'dateTime': {
|
|
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
|
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
|
return (ta - tb) * dir;
|
|
}
|
|
case 'count': return ((a.missedCallCount ?? 1) - (b.missedCallCount ?? 1)) * dir;
|
|
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
|
|
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
|
default: return 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}, [calls, tab, search, sortDescriptor]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
|
const pagedRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
|
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
|
const handleTab = (key: StatusTab) => { setTab(key); setCurrentPage(1); };
|
|
|
|
const tabItems = [
|
|
{ id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined },
|
|
{ id: 'PENDING_CALLBACK' as const, label: 'Pending', badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined },
|
|
{ id: 'CALLBACK_ATTEMPTED' as const, label: 'Attempted', badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined },
|
|
{ id: 'CALLBACK_COMPLETED' as const, label: 'Completed', badge: (statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER) ? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0)) : undefined },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<TopBar title="Missed Calls" />
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{/* Tabs + toolbar */}
|
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
|
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
|
|
<TabList items={tabItems} type="underline" size="sm">
|
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
|
</TabList>
|
|
</Tabs>
|
|
<div className="flex items-center gap-3 pb-1">
|
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
|
<div className="w-56">
|
|
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
|
<Table.Header>
|
|
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
|
|
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
|
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
|
|
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
|
|
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
|
|
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
|
|
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
|
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
|
|
</Table.Header>
|
|
<Table.Body items={pagedRows}>
|
|
{(call) => {
|
|
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
|
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
|
|
|
return (
|
|
<Table.Row id={call.id}>
|
|
{visibleColumns.has('caller') && (
|
|
<Table.Cell>
|
|
{phone ? (
|
|
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
|
) : <span className="text-xs text-quaternary">Unknown</span>}
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('dateTime') && (
|
|
<Table.Cell>
|
|
{call.startedAt ? (
|
|
<div>
|
|
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
|
</div>
|
|
) : <span className="text-xs text-quaternary">—</span>}
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('branch') && (
|
|
<Table.Cell>
|
|
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('agent') && (
|
|
<Table.Cell>
|
|
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('count') && (
|
|
<Table.Cell>
|
|
{call.missedCallCount && call.missedCallCount > 1 ? (
|
|
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
|
) : <span className="text-xs text-quaternary">1</span>}
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('status') && (
|
|
<Table.Cell>
|
|
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
|
|
{STATUS_LABELS[status] ?? status}
|
|
</Badge>
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('sla') && (
|
|
<Table.Cell>
|
|
{call.sla != null ? (() => {
|
|
const status = computeSlaStatus(call.sla);
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
|
<span className={cx(
|
|
'size-2 rounded-full',
|
|
status === 'low' && 'bg-success-solid',
|
|
status === 'medium' && 'bg-warning-solid',
|
|
status === 'high' && 'bg-error-solid',
|
|
status === 'critical' && 'bg-error-solid animate-pulse',
|
|
)} />
|
|
<span className="text-secondary">{call.sla}%</span>
|
|
</span>
|
|
);
|
|
})() : <span className="text-xs text-quaternary">—</span>}
|
|
</Table.Cell>
|
|
)}
|
|
{visibleColumns.has('callback') && (
|
|
<Table.Cell>
|
|
{call.callbackAttemptedAt ? (
|
|
<div>
|
|
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
|
</div>
|
|
) : <span className="text-xs text-quaternary">—</span>}
|
|
</Table.Cell>
|
|
)}
|
|
</Table.Row>
|
|
);
|
|
}}
|
|
</Table.Body>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
|
<PaginationPageDefault
|
|
page={currentPage}
|
|
total={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|