diff --git a/src/components/application/table/table.tsx b/src/components/application/table/table.tsx index 5177684..5a9a1e8 100644 --- a/src/components/application/table/table.tsx +++ b/src/components/application/table/table.tsx @@ -17,7 +17,9 @@ import { Cell as AriaCell, Collection as AriaCollection, Column as AriaColumn, + ColumnResizer as AriaColumnResizer, Group as AriaGroup, + ResizableTableContainer as AriaResizableTableContainer, Row as AriaRow, Table as AriaTable, TableBody as AriaTableBody, @@ -115,9 +117,9 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => { return ( -
+ cx("w-full", typeof className === "function" ? className(state) : className)} {...props} /> -
+
); }; @@ -168,9 +170,10 @@ TableHeader.displayName = "TableHeader"; interface TableHeadProps extends AriaColumnProps, Omit, "children" | "className" | "style" | "id"> { label?: string; tooltip?: string; + resizable?: boolean; } -const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadProps) => { +const TableHead = ({ className, tooltip, label, children, resizable = true, ...props }: TableHeadProps) => { const { selectionBehavior } = useTableOptions(); return ( @@ -186,8 +189,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP } > {(state) => ( - -
+ +
{label && {label}} {typeof children === "function" ? children(state) : children}
@@ -206,6 +209,12 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP ) : ( ))} + + {resizable && ( + + )}
)} diff --git a/src/hooks/use-maint-shortcuts.ts b/src/hooks/use-maint-shortcuts.ts index 93a15ed..6d5c903 100644 --- a/src/hooks/use-maint-shortcuts.ts +++ b/src/hooks/use-maint-shortcuts.ts @@ -35,6 +35,11 @@ const MAINT_ACTIONS: Record = { description: 'Delete all imported leads from a selected campaign. For testing only.', needsPreStep: true, }, + clearAnalysisCache: { + endpoint: 'clear-analysis-cache', + label: 'Regenerate AI Analysis', + description: 'Clear all cached recording analyses. Next AI click will re-transcribe and re-analyze.', + }, }; export const useMaintShortcuts = () => { @@ -51,6 +56,16 @@ export const useMaintShortcuts = () => { setActiveAction(null); }, []); + // Listen for programmatic triggers (e.g., long-press on AI button) + useEffect(() => { + const maintHandler = (e: CustomEvent) => { + const action = MAINT_ACTIONS[e.detail]; + if (action) openAction(action); + }; + window.addEventListener('maint:trigger', maintHandler as EventListener); + return () => window.removeEventListener('maint:trigger', maintHandler as EventListener); + }, [openAction]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.ctrlKey && e.shiftKey && e.key === 'R') { @@ -79,5 +94,5 @@ export const useMaintShortcuts = () => { return () => window.removeEventListener('keydown', handler); }, [openAction]); - return { isOpen, activeAction, close }; + return { isOpen, activeAction, close, openAction, actions: MAINT_ACTIONS }; }; diff --git a/src/lib/format.ts b/src/lib/format.ts index 4bf3f7a..2a927c3 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -53,6 +53,18 @@ export const formatWeekdayShort = (dateStr: string): string => export const formatTimeFull = (dateStr: string): string => new Intl.DateTimeFormat('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }).format(new Date(dateStr)); +// 1st April, 2nd March, etc. +const ordinalSuffix = (n: number): string => { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +}; + +export const formatDateOrdinal = (dateStr: string): string => { + const d = new Date(dateStr); + return `${ordinalSuffix(d.getDate())} ${d.toLocaleDateString('en-IN', { month: 'long' })}`; +}; + // Get initials from a name export const getInitials = (firstName: string, lastName: string): string => `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f5a2943..d7242ca 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -52,7 +52,7 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls id name createdAt direction callStatus callerNumber { primaryPhoneNumber } agentName startedAt endedAt durationSec - recording { primaryLinkUrl } disposition + recording { primaryLinkUrl } disposition sla patientId appointmentId leadId } } } }`; diff --git a/src/pages/call-history.tsx b/src/pages/call-history.tsx index dfa669a..f48a449 100644 --- a/src/pages/call-history.tsx +++ b/src/pages/call-history.tsx @@ -19,6 +19,8 @@ import { Select } from '@/components/base/select/select'; import { TopBar } from '@/components/layout/top-bar'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { formatShortDate, formatPhone } from '@/lib/format'; +import { computeSlaStatus } from '@/lib/scoring'; +import { cx } from '@/utils/cx'; import { useData } from '@/providers/data-provider'; import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import type { Call, CallDirection, CallDisposition } from '@/types/entities'; @@ -66,6 +68,12 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta return ; }; +const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => { + if (call.sla == null) return null; + const percent = Math.round(call.sla); + return { percent, status: computeSlaStatus(percent) }; +}; + const RecordingPlayer: FC<{ url: string }> = ({ url }) => { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); @@ -221,6 +229,7 @@ export const CallHistoryPage = () => { + @@ -262,6 +271,24 @@ export const CallHistoryPage = () => { {'\u2014'} )} + + {(() => { + const sla = getCallSla(call); + if (!sla) return ; + return ( + + + {sla.percent}% + + ); + })()} + {call.agentName ?? '\u2014'} diff --git a/src/pages/call-recordings.tsx b/src/pages/call-recordings.tsx index 7f5f3df..7b388b1 100644 --- a/src/pages/call-recordings.tsx +++ b/src/pages/call-recordings.tsx @@ -1,20 +1,27 @@ -import { useEffect, useMemo, useState, useRef } from 'react'; +import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { faMagnifyingGlass, faPlay, faPause, faSparkles } from '@fortawesome/pro-duotone-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faIcon } from '@/lib/icon-wrapper'; +import type { SortDescriptor } from 'react-aria-components'; 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 { 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 { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis'; import { apiClient } from '@/lib/api-client'; -import { formatPhone, formatDateOnly } from '@/lib/format'; +import { getSocket } from '@/lib/socket'; +import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format'; +import { computeSlaStatus } from '@/lib/scoring'; +import { cx } from '@/utils/cx'; type RecordingRecord = { id: string; + createdAt: string | null; direction: string | null; callStatus: string | null; callerNumber: { primaryPhoneNumber: string } | null; @@ -23,15 +30,17 @@ type RecordingRecord = { durationSec: number | null; disposition: string | null; recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null; + sla: number | null; }; const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { - id direction callStatus callerNumber { primaryPhoneNumber } - agentName startedAt durationSec disposition + id createdAt direction callStatus callerNumber { primaryPhoneNumber } + agentName startedAt durationSec disposition sla recording { primaryLinkUrl primaryLinkLabel } } } } }`; -const formatDate = (iso: string): string => formatDateOnly(iso); +const PAGE_SIZE = 15; + const formatDuration = (sec: number | null): string => { if (!sec) return '—'; @@ -40,6 +49,12 @@ const formatDuration = (sec: number | null): string => { return `${m}:${s.toString().padStart(2, '0')}`; }; +const getCallSla = (call: RecordingRecord): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => { + if (call.sla == null) return null; + const percent = Math.round(call.sla); + return { percent, status: computeSlaStatus(percent) }; +}; + const RecordingPlayer = ({ url }: { url: string }) => { const audioRef = useRef(null); const [playing, setPlaying] = useState(false); @@ -60,13 +75,28 @@ const RecordingPlayer = ({ url }: { url: string }) => { ); }; +const columnDefs = [ + { id: 'agent', label: 'Agent', defaultVisible: true }, + { id: 'caller', label: 'Caller', defaultVisible: true }, + { id: 'ai', label: 'AI', defaultVisible: true }, + { id: 'type', label: 'Type', defaultVisible: true }, + { id: 'sla', label: 'SLA', defaultVisible: true }, + { id: 'dateTime', label: 'Date & Time', defaultVisible: true }, + { id: 'duration', label: 'Duration', defaultVisible: true }, + { id: 'disposition', label: 'Disposition', defaultVisible: true }, + { id: 'recording', label: 'Recording', defaultVisible: true }, +]; + export const CallRecordingsPage = () => { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [currentPage, setCurrentPage] = useState(1); const [slideoutCallId, setSlideoutCallId] = useState(null); + const [sortDescriptor, setSortDescriptor] = useState({ column: 'dateTime', direction: 'descending' }); + const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs); - useEffect(() => { + const fetchRecordings = useCallback(() => { apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) .then(data => { const withRecordings = data.calls.edges @@ -78,27 +108,73 @@ export const CallRecordingsPage = () => { .finally(() => setLoading(false)); }, []); + useEffect(() => { + fetchRecordings(); + + // Listen for real-time call created events via WebSocket + const socket = getSocket(); + if (!socket.connected) socket.connect(); + socket.emit('supervisor:register'); + const handleCallCreated = () => fetchRecordings(); + socket.on('call:created', handleCallCreated); + + return () => { + socket.off('call:created', handleCallCreated); + }; + }, [fetchRecordings]); + const filtered = useMemo(() => { - if (!search.trim()) return calls; - const q = search.toLowerCase(); - return calls.filter(c => - (c.agentName ?? '').toLowerCase().includes(q) || - (c.callerNumber?.primaryPhoneNumber ?? '').includes(q), - ); - }, [calls, search]); + let result = calls; + if (search.trim()) { + const q = search.toLowerCase(); + result = result.filter(c => + (c.agentName ?? '').toLowerCase().includes(q) || + (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || + (c.disposition ?? '').toLowerCase().includes(q), + ); + } + // Sort + if (sortDescriptor.column) { + const dir = sortDescriptor.direction === 'ascending' ? 1 : -1; + result = [...result].sort((a, b) => { + switch (sortDescriptor.column) { + case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir; + 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 'duration': return ((a.durationSec ?? 0) - (b.durationSec ?? 0)) * dir; + case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir; + default: return 0; + } + }); + } + return result; + }, [calls, 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); }; return ( <>
+ {/* Toolbar */}
{filtered.length} recordings -
- +
+ +
+ +
-
+ {/* Table */} +
{loading ? (

Loading recordings...

@@ -108,78 +184,138 @@ export const CallRecordingsPage = () => {

{search ? 'No matching recordings' : 'No call recordings found'}

) : ( - - - - - - - - - - - - - {(call) => { - const phone = call.callerNumber?.primaryPhoneNumber ?? ''; - const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; - const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; +
+
+ + {visibleColumns.has('agent') && } + {visibleColumns.has('caller') && } + {visibleColumns.has('ai') && } + {visibleColumns.has('type') && } + {visibleColumns.has('sla') && } + {visibleColumns.has('dateTime') && } + {visibleColumns.has('duration') && } + {visibleColumns.has('disposition') && } + {visibleColumns.has('recording') && } + + + {(call) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; + const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; - return ( - - - {call.agentName || '—'} - - - {phone ? ( - - ) : } - - - { - e.stopPropagation(); - setSlideoutCallId(call.id); - }} - className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear" - title="AI Analysis" - > - - AI - - - - {dirLabel} - - - {call.startedAt ? formatDate(call.startedAt) : '—'} - - - {formatDuration(call.durationSec)} - - - {call.disposition ? ( - - {call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} - - ) : } - - - {call.recording?.primaryLinkUrl && ( - + return ( + + {visibleColumns.has('agent') && ( + + {call.agentName || '—'} + )} - - - ); - }} - -
+ {visibleColumns.has('caller') && ( + + {phone ? ( + + ) : } + + )} + {visibleColumns.has('ai') && ( + + { + e.stopPropagation(); + let longPressed = false; + const timer = setTimeout(() => { + longPressed = true; + window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' })); + }, 1000); + const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); }; + document.addEventListener('pointerup', up, { once: true }); + }} + className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear" + title="AI Analysis (long-press to regenerate)" + > + + AI + + + )} + {visibleColumns.has('type') && ( + + {dirLabel} + + )} + {visibleColumns.has('sla') && ( + + {(() => { + const sla = getCallSla(call); + if (!sla) return ; + return ( + + + {sla.percent}% + + ); + })()} + + )} + {visibleColumns.has('dateTime') && ( + + {call.startedAt ? ( +
+ {formatDateOrdinal(call.startedAt)} + {formatTimeOnly(call.startedAt)} +
+ ) : } +
+ )} + {visibleColumns.has('duration') && ( + + {formatDuration(call.durationSec)} + + )} + {visibleColumns.has('disposition') && ( + + {call.disposition ? ( + + {call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + + ) : } + + )} + {visibleColumns.has('recording') && ( + + {call.recording?.primaryLinkUrl && ( + + )} + + )} + + ); + }} + + +
)} -
+ {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} + {/* Analysis slideout */} {(() => { const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null; diff --git a/src/pages/missed-calls.tsx b/src/pages/missed-calls.tsx index b5e5b65..569d2bf 100644 --- a/src/pages/missed-calls.tsx +++ b/src/pages/missed-calls.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; +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); @@ -7,10 +8,14 @@ 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, formatDateTimeShort } from '@/lib/format'; +import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format'; +import { computeSlaStatus } from '@/lib/scoring'; +import { cx } from '@/utils/cx'; type MissedCallRecord = { id: string; @@ -21,6 +26,7 @@ type MissedCallRecord = { callbackstatus: string | null; missedcallcount: number | null; callbackattemptedat: string | null; + sla: number | null; }; type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED'; @@ -29,20 +35,10 @@ const QUERY = `{ calls(first: 200, filter: { callStatus: { eq: MISSED } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id callerNumber { primaryPhoneNumber } agentName - startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat + startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla } } } }`; -const formatDate = (iso: string): string => formatDateTimeShort(iso); - -const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { - const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); - 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 PAGE_SIZE = 15; const STATUS_LABELS: Record = { PENDING_CALLBACK: 'Pending', @@ -60,19 +56,39 @@ const STATUS_COLORS: Record { 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); - useEffect(() => { + 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) { @@ -92,11 +108,35 @@ export const MissedCallsPage = () => { const q = search.toLowerCase(); rows = rows.filter(c => (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || - (c.agentName ?? '').toLowerCase().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]); + }, [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 }, @@ -109,18 +149,23 @@ export const MissedCallsPage = () => { <>
+ {/* Tabs + toolbar */}
- setTab(key as StatusTab)}> + handleTab(key as StatusTab)}> {(item) => } -
- +
+ +
+ +
-
+ {/* Table */} +
{loading ? (

Loading missed calls...

@@ -130,58 +175,114 @@ export const MissedCallsPage = () => {

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

) : ( - - - - - - - - - - - - {(call) => { - const phone = call.callerNumber?.primaryPhoneNumber ?? ''; - const status = call.callbackstatus ?? 'PENDING_CALLBACK'; - const sla = call.startedAt ? computeSla(call.startedAt) : null; +
+
+ + {visibleColumns.has('caller') && } + {visibleColumns.has('dateTime') && } + {visibleColumns.has('branch') && } + {visibleColumns.has('agent') && } + {visibleColumns.has('count') && } + {visibleColumns.has('status') && } + {visibleColumns.has('sla') && } + {visibleColumns.has('callback') && } + + + {(call) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const status = call.callbackstatus ?? 'PENDING_CALLBACK'; - return ( - - - {phone ? ( - - ) : Unknown} - - - {call.startedAt ? formatDate(call.startedAt) : '—'} - - - {call.callsourcenumber || '—'} - - - {call.agentName || '—'} - - - {call.missedcallcount && call.missedcallcount > 1 ? ( - {call.missedcallcount}x - ) : 1} - - - - {STATUS_LABELS[status] ?? status} - - - - {sla && {sla.label}} - - - ); - }} - -
+ return ( + + {visibleColumns.has('caller') && ( + + {phone ? ( + + ) : Unknown} + + )} + {visibleColumns.has('dateTime') && ( + + {call.startedAt ? ( +
+ {formatDateOrdinal(call.startedAt)} + {formatTimeOnly(call.startedAt)} +
+ ) : } +
+ )} + {visibleColumns.has('branch') && ( + + {call.callsourcenumber || '—'} + + )} + {visibleColumns.has('agent') && ( + + {call.agentName || '—'} + + )} + {visibleColumns.has('count') && ( + + {call.missedcallcount && call.missedcallcount > 1 ? ( + {call.missedcallcount}x + ) : 1} + + )} + {visibleColumns.has('status') && ( + + + {STATUS_LABELS[status] ?? status} + + + )} + {visibleColumns.has('sla') && ( + + {call.sla != null ? (() => { + const status = computeSlaStatus(call.sla); + return ( + + + {call.sla}% + + ); + })() : } + + )} + {visibleColumns.has('callback') && ( + + {call.callbackattemptedat ? ( +
+ {formatDateOrdinal(call.callbackattemptedat)} + {formatTimeOnly(call.callbackattemptedat)} +
+ ) : } +
+ )} +
+ ); + }} + + +
)}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )}
); diff --git a/src/types/entities.ts b/src/types/entities.ts index a2a9ab6..611c6e7 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -272,6 +272,7 @@ export type Call = { patientId: string | null; appointmentId: string | null; leadId: string | null; + sla?: number | null; // Denormalized for display leadName?: string; leadPhone?: string;