Files
helix-engage/src/pages/missed-calls.tsx
saridsa2 5f3b455edc feat: notification bell in PageHeader + remove wasted top bar row for supervisors
- PageHeader: renders NotificationBell when isAdmin — bell now appears
  on every page that uses PageHeader (leads, contacts, appointments,
  patients, call history, missed calls, call recordings, live monitor,
  team performance, settings)
- app-shell: top bar row only renders for agents (network indicator +
  status toggle). Supervisors no longer see a wasted empty row.
- Call Recordings: TopBar → PageHeader with badge + info icon
- Live Monitor: TopBar → PageHeader with badge + info icon
- Team Performance: TopBar → PageHeader with info icon
- Settings: TopBar → PageHeader with info icon
- Missed Calls: underline tabs → custom pills (consistent with all pages)
- Desktop overlay app-shell synced with same changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:51:16 +05:30

308 lines
14 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 { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { PageHeader } from '@/components/layout/page-header';
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, allowsSorting: false, isRowHeader: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'branch', label: 'Branch', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
{ id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
{ id: 'status', label: 'Status', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'callback', label: 'Callback At', defaultVisible: false },
];
// Dynamic columns table — React Aria requires the column count to match
// between Header and Row. Conditional `{visible && <Cell>}` crashes the
// table (#8127). Using the dynamic collections API (columns prop +
// render function) lets React Aria rebuild its collection cleanly when
// the visible set changes.
type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean };
const renderCell = (call: MissedCallRecord, colId: string) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
switch (colId) {
case 'caller':
return phone
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
: <span className="text-xs text-quaternary">Unknown</span>;
case 'dateTime':
return 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>;
case 'branch':
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
case 'agent':
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
case 'count':
return 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>;
case 'status':
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
case 'sla':
if (call.sla == null) return <span className="text-xs text-quaternary"></span>;
const slaStatus = computeSlaStatus(call.sla);
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx('size-2 rounded-full',
slaStatus === 'low' && 'bg-success-solid',
slaStatus === 'medium' && 'bg-warning-solid',
slaStatus === 'high' && 'bg-error-solid',
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{call.sla}%</span>
</span>
);
case 'callback':
return 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>;
default:
return null;
}
};
const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[];
columns: ColDef[];
columnKey: string;
sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void;
}) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
<Table.Header columns={columns}>
{(col) => (
<Table.Head
key={col.id}
id={col.id}
label={col.label}
isRowHeader={col.isRowHeader}
allowsSorting={col.allowsSorting}
/>
)}
</Table.Header>
<Table.Body items={calls}>
{(call) => (
<Table.Row id={call.id} columns={columns} className="group/row">
{(col) => (
<Table.Cell key={col.id}>
{renderCell(call, col.id)}
</Table.Cell>
)}
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
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 (
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="Missed Calls"
badge={calls.length}
infoText="Inbound calls that were not answered. Agents can call back from here."
controls={
<>
<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>
</>
}
tabs={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => handleTab(item.id)}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
tab === item.id
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{item.label}{item.badge ? ` ${item.badge}` : ''}
</button>
))}
</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>
) : (
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
columnKey={Array.from(visibleColumns).sort().join(',')}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
/>
)}
</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>
);
};