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 = { PENDING_CALLBACK: 'Pending', CALLBACK_ATTEMPTED: 'Attempted', CALLBACK_COMPLETED: 'Completed', WRONG_NUMBER: 'Wrong Number', INVALID: 'Invalid', }; const STATUS_COLORS: Record = { 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 && }` 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 ? : Unknown; case 'dateTime': return call.startedAt ? (
{formatDateOrdinal(call.startedAt)} {formatTimeOnly(call.startedAt)}
) : ; case 'branch': return {call.callSourceNumber || '—'}; case 'agent': return {call.agentName || '—'}; case 'count': return call.missedCallCount && call.missedCallCount > 1 ? {call.missedCallCount}x : 1; case 'status': return {STATUS_LABELS[status] ?? status}; case 'sla': if (call.sla == null) return ; const slaStatus = computeSlaStatus(call.sla); return ( {call.sla}% ); case 'callback': return call.callbackAttemptedAt ? (
{formatDateOrdinal(call.callbackAttemptedAt)} {formatTimeOnly(call.callbackAttemptedAt)}
) : ; default: return null; } }; const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: { calls: MissedCallRecord[]; columns: ColDef[]; columnKey: string; sortDescriptor: SortDescriptor; onSortChange: (desc: SortDescriptor) => void; }) => (
{(col) => ( )} {(call) => ( {(col) => ( {renderCell(call, col.id)} )} )}
); export const MissedCallsPage = () => { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [sortDescriptor, setSortDescriptor] = useState({ 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 = {}; 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 (
} tabs={
{tabItems.map((item) => ( ))}
} /> {/* Table */}
{loading ? (

Loading missed calls...

) : filtered.length === 0 ? (

{search ? 'No matching calls' : 'No missed calls'}

) : ( visibleColumns.has(c.id))} columnKey={Array.from(visibleColumns).sort().join(',')} sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} /> )}
{/* Pagination */} {totalPages > 1 && (
)}
); };