feat: data table improvements — SLA column, pagination, column resize, ordinal dates

- Call Recordings: pagination (15/page), column toggle, sortable SLA/duration/date, ordinal dates, SSE refresh
- Missed Calls: full rewrite matching data table pattern (pagination, column toggle, sort, SLA from entity)
- Call History: SLA column from entity field
- Table component: ResizableTableContainer + ColumnResizer for all tables
- Date formatting: formatDateOrdinal utility (1st April, 2nd March, etc.)
- SLA reads from platform call.sla field (seeded for 200 records)
- AI button long-press triggers OTP-gated cache clear for re-analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 16:51:45 +05:30
parent b90740e009
commit 4f5370abdc
8 changed files with 461 additions and 160 deletions

View File

@@ -17,7 +17,9 @@ import {
Cell as AriaCell, Cell as AriaCell,
Collection as AriaCollection, Collection as AriaCollection,
Column as AriaColumn, Column as AriaColumn,
ColumnResizer as AriaColumnResizer,
Group as AriaGroup, Group as AriaGroup,
ResizableTableContainer as AriaResizableTableContainer,
Row as AriaRow, Row as AriaRow,
Table as AriaTable, Table as AriaTable,
TableBody as AriaTableBody, TableBody as AriaTableBody,
@@ -115,9 +117,9 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
return ( return (
<TableContext.Provider value={{ size: context?.size ?? size }}> <TableContext.Provider value={{ size: context?.size ?? size }}>
<div className="flex-1 overflow-auto min-h-0"> <AriaResizableTableContainer className="flex-1 overflow-auto min-h-0">
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} /> <AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
</div> </AriaResizableTableContainer>
</TableContext.Provider> </TableContext.Provider>
); );
}; };
@@ -168,9 +170,10 @@ TableHeader.displayName = "TableHeader";
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> { interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
label?: string; label?: string;
tooltip?: 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(); const { selectionBehavior } = useTableOptions();
return ( return (
@@ -186,8 +189,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
} }
> >
{(state) => ( {(state) => (
<AriaGroup className="flex items-center gap-1"> <AriaGroup className="flex items-center gap-1" role="presentation">
<div className="flex items-center gap-1"> <div className="flex flex-1 items-center gap-1 truncate">
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>} {label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
{typeof children === "function" ? children(state) : children} {typeof children === "function" ? children(state) : children}
</div> </div>
@@ -206,6 +209,12 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
) : ( ) : (
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" /> <FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
))} ))}
{resizable && (
<AriaColumnResizer
className="absolute right-0 top-0 bottom-0 w-px bg-transparent cursor-col-resize touch-none box-border px-[3px] bg-clip-content hover:bg-brand-solid focus-visible:bg-brand-solid"
/>
)}
</AriaGroup> </AriaGroup>
)} )}
</AriaColumn> </AriaColumn>

View File

@@ -35,6 +35,11 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
description: 'Delete all imported leads from a selected campaign. For testing only.', description: 'Delete all imported leads from a selected campaign. For testing only.',
needsPreStep: true, 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 = () => { export const useMaintShortcuts = () => {
@@ -51,6 +56,16 @@ export const useMaintShortcuts = () => {
setActiveAction(null); setActiveAction(null);
}, []); }, []);
// Listen for programmatic triggers (e.g., long-press on AI button)
useEffect(() => {
const maintHandler = (e: CustomEvent<string>) => {
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(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'R') { if (e.ctrlKey && e.shiftKey && e.key === 'R') {
@@ -79,5 +94,5 @@ export const useMaintShortcuts = () => {
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [openAction]); }, [openAction]);
return { isOpen, activeAction, close }; return { isOpen, activeAction, close, openAction, actions: MAINT_ACTIONS };
}; };

View File

@@ -53,6 +53,18 @@ export const formatWeekdayShort = (dateStr: string): string =>
export const formatTimeFull = (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)); 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 // Get initials from a name
export const getInitials = (firstName: string, lastName: string): string => export const getInitials = (firstName: string, lastName: string): string =>
`${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase(); `${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase();

View File

@@ -52,7 +52,7 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
id name createdAt id name createdAt
direction callStatus callerNumber { primaryPhoneNumber } agentName direction callStatus callerNumber { primaryPhoneNumber } agentName
startedAt endedAt durationSec startedAt endedAt durationSec
recording { primaryLinkUrl } disposition recording { primaryLinkUrl } disposition sla
patientId appointmentId leadId patientId appointmentId leadId
} } } }`; } } } }`;

View File

@@ -19,6 +19,8 @@ import { Select } from '@/components/base/select/select';
import { TopBar } from '@/components/layout/top-bar'; import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { formatShortDate, formatPhone } from '@/lib/format'; import { formatShortDate, formatPhone } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring';
import { cx } from '@/utils/cx';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import type { Call, CallDirection, CallDisposition } from '@/types/entities'; import type { Call, CallDirection, CallDisposition } from '@/types/entities';
@@ -66,6 +68,12 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />; return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
}; };
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 RecordingPlayer: FC<{ url: string }> = ({ url }) => {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -221,6 +229,7 @@ export const CallHistoryPage = () => {
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" /> <Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" /> <Table.Head label="OUTCOME" />
<Table.Head label="SLA" className="w-24" />
<Table.Head label="AGENT" /> <Table.Head label="AGENT" />
<Table.Head label="RECORDING" className="w-24" /> <Table.Head label="RECORDING" className="w-24" />
<Table.Head label="TIME" /> <Table.Head label="TIME" />
@@ -262,6 +271,24 @@ export const CallHistoryPage = () => {
<span className="text-sm text-quaternary">{'\u2014'}</span> <span className="text-sm text-quaternary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell>
{(() => {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</span>
);
})()}
</Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary"> <span className="text-sm text-secondary">
{call.agentName ?? '\u2014'} {call.agentName ?? '\u2014'}

View File

@@ -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 { faMagnifyingGlass, faPlay, faPause, faSparkles } from '@fortawesome/pro-duotone-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
import type { SortDescriptor } from 'react-aria-components';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; 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 { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis'; import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
import { apiClient } from '@/lib/api-client'; 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 = { type RecordingRecord = {
id: string; id: string;
createdAt: string | null;
direction: string | null; direction: string | null;
callStatus: string | null; callStatus: string | null;
callerNumber: { primaryPhoneNumber: string } | null; callerNumber: { primaryPhoneNumber: string } | null;
@@ -23,15 +30,17 @@ type RecordingRecord = {
durationSec: number | null; durationSec: number | null;
disposition: string | null; disposition: string | null;
recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null; recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
sla: number | null;
}; };
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id direction callStatus callerNumber { primaryPhoneNumber } id createdAt direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition agentName startedAt durationSec disposition sla
recording { primaryLinkUrl primaryLinkLabel } recording { primaryLinkUrl primaryLinkLabel }
} } } }`; } } } }`;
const formatDate = (iso: string): string => formatDateOnly(iso); const PAGE_SIZE = 15;
const formatDuration = (sec: number | null): string => { const formatDuration = (sec: number | null): string => {
if (!sec) return '—'; if (!sec) return '—';
@@ -40,6 +49,12 @@ const formatDuration = (sec: number | null): string => {
return `${m}:${s.toString().padStart(2, '0')}`; 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 RecordingPlayer = ({ url }: { url: string }) => {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false); 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 = () => { export const CallRecordingsPage = () => {
const [calls, setCalls] = useState<RecordingRecord[]>([]); const [calls, setCalls] = useState<RecordingRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [slideoutCallId, setSlideoutCallId] = useState<string | null>(null); const [slideoutCallId, setSlideoutCallId] = useState<string | null>(null);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ 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 }) apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => { .then(data => {
const withRecordings = data.calls.edges const withRecordings = data.calls.edges
@@ -78,27 +108,73 @@ export const CallRecordingsPage = () => {
.finally(() => setLoading(false)); .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(() => { const filtered = useMemo(() => {
if (!search.trim()) return calls; let result = calls;
const q = search.toLowerCase(); if (search.trim()) {
return calls.filter(c => const q = search.toLowerCase();
(c.agentName ?? '').toLowerCase().includes(q) || result = result.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q), (c.agentName ?? '').toLowerCase().includes(q) ||
); (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
}, [calls, search]); (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 ( return (
<> <>
<TopBar title="Call Recordings" /> <TopBar title="Call Recordings" />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> <div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<span className="text-sm text-tertiary">{filtered.length} recordings</span> <span className="text-sm text-tertiary">{filtered.length} recordings</span>
<div className="w-56"> <div className="flex items-center gap-3">
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} /> <ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56">
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3"> {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading recordings...</p> <p className="text-sm text-tertiary">Loading recordings...</p>
@@ -108,78 +184,138 @@ export const CallRecordingsPage = () => {
<p className="text-sm text-quaternary">{search ? 'No matching recordings' : 'No call recordings found'}</p> <p className="text-sm text-quaternary">{search ? 'No matching recordings' : 'No call recordings found'}</p>
</div> </div>
) : ( ) : (
<Table size="sm"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table.Header> <Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Head label="Agent" isRowHeader /> <Table.Header>
<Table.Head label="Caller" /> {visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
<Table.Head label="AI" className="w-14" /> {visibleColumns.has('caller') && <Table.Head label="Caller" />}
<Table.Head label="Type" className="w-20" /> {visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
<Table.Head label="Date" className="w-28" /> {visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
<Table.Head label="Duration" className="w-20" /> {visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
<Table.Head label="Disposition" className="w-32" /> {visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
<Table.Head label="Recording" className="w-24" /> {visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
</Table.Header> {visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
<Table.Body items={filtered}> {visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
{(call) => { </Table.Header>
const phone = call.callerNumber?.primaryPhoneNumber ?? ''; <Table.Body items={pagedRows}>
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; {(call) => {
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
return ( return (
<Table.Row id={call.id}> <Table.Row id={call.id}>
<Table.Cell> {visibleColumns.has('agent') && (
<span className="text-sm text-primary">{call.agentName || '—'}</span> <Table.Cell>
</Table.Cell> <span className="text-sm text-primary">{call.agentName || '—'}</span>
<Table.Cell> </Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
<Table.Cell>
<span
role="button"
tabIndex={0}
onPointerDown={(e) => {
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"
>
<FontAwesomeIcon icon={faSparkles} className="size-3" />
AI
</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
</Table.Cell>
<Table.Cell>
{call.disposition ? (
<Badge size="sm" color="gray" type="pill-color">
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
<Table.Cell>
{call.recording?.primaryLinkUrl && (
<RecordingPlayer url={call.recording.primaryLinkUrl} />
)} )}
</Table.Cell> {visibleColumns.has('caller') && (
</Table.Row> <Table.Cell>
); {phone ? (
}} <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
</Table.Body> ) : <span className="text-xs text-quaternary"></span>}
</Table> </Table.Cell>
)}
{visibleColumns.has('ai') && (
<Table.Cell>
<span
role="button"
tabIndex={0}
onPointerDown={(e) => {
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)"
>
<FontAwesomeIcon icon={faSparkles} className="size-3" />
AI
</span>
</Table.Cell>
)}
{visibleColumns.has('type') && (
<Table.Cell>
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
</Table.Cell>
)}
{visibleColumns.has('sla') && (
<Table.Cell>
{(() => {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</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('duration') && (
<Table.Cell>
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
</Table.Cell>
)}
{visibleColumns.has('disposition') && (
<Table.Cell>
{call.disposition ? (
<Badge size="sm" color="gray" type="pill-color">
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('recording') && (
<Table.Cell>
{call.recording?.primaryLinkUrl && (
<RecordingPlayer url={call.recording.primaryLinkUrl} />
)}
</Table.Cell>
)}
</Table.Row>
);
}}
</Table.Body>
</Table>
</div>
)} )}
</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>
)}
{/* Analysis slideout */} {/* Analysis slideout */}
{(() => { {(() => {
const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null; const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null;

View File

@@ -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 { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
@@ -7,10 +8,14 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; 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 { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client'; 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 = { type MissedCallRecord = {
id: string; id: string;
@@ -21,6 +26,7 @@ type MissedCallRecord = {
callbackstatus: string | null; callbackstatus: string | null;
missedcallcount: number | null; missedcallcount: number | null;
callbackattemptedat: string | null; callbackattemptedat: string | null;
sla: number | null;
}; };
type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED'; type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED';
@@ -29,20 +35,10 @@ const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED } callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
} } } }`; } } } }`;
const formatDate = (iso: string): string => formatDateTimeShort(iso); const PAGE_SIZE = 15;
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 STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
PENDING_CALLBACK: 'Pending', PENDING_CALLBACK: 'Pending',
@@ -60,19 +56,39 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
INVALID: 'gray', 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 = () => { export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]); const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all'); const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ 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 }) apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setCalls(data.calls.edges.map(e => e.node))) .then(data => setCalls(data.calls.edges.map(e => e.node)))
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
fetchCalls();
const interval = setInterval(fetchCalls, 30000);
return () => clearInterval(interval);
}, [fetchCalls]);
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const c of calls) { for (const c of calls) {
@@ -92,11 +108,35 @@ export const MissedCallsPage = () => {
const q = search.toLowerCase(); const q = search.toLowerCase();
rows = rows.filter(c => rows = rows.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || (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; 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 = [ const tabItems = [
{ id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined }, { id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined },
@@ -109,18 +149,23 @@ export const MissedCallsPage = () => {
<> <>
<TopBar title="Missed Calls" /> <TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden"> <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"> <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) => setTab(key as StatusTab)}> <Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm"> <TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} {(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList> </TabList>
</Tabs> </Tabs>
<div className="w-56 shrink-0 pb-1"> <div className="flex items-center gap-3 pb-1">
<Input placeholder="Search phone or agent..." icon={SearchLg} size="sm" value={search} onChange={setSearch} /> <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>
</div> </div>
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3"> {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p> <p className="text-sm text-tertiary">Loading missed calls...</p>
@@ -130,58 +175,114 @@ export const MissedCallsPage = () => {
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p> <p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div> </div>
) : ( ) : (
<Table size="sm"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table.Header> <Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Head label="Caller" isRowHeader /> <Table.Header>
<Table.Head label="Date / Time" className="w-36" /> {visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
<Table.Head label="Branch" className="w-32" /> {visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
<Table.Head label="Agent" className="w-28" /> {visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
<Table.Head label="Count" className="w-16" /> {visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
<Table.Head label="Status" className="w-28" /> {visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
<Table.Head label="SLA" className="w-24" /> {visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
</Table.Header> {visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
<Table.Body items={filtered}> {visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
{(call) => { </Table.Header>
const phone = call.callerNumber?.primaryPhoneNumber ?? ''; <Table.Body items={pagedRows}>
const status = call.callbackstatus ?? 'PENDING_CALLBACK'; {(call) => {
const sla = call.startedAt ? computeSla(call.startedAt) : null; const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
return ( return (
<Table.Row id={call.id}> <Table.Row id={call.id}>
<Table.Cell> {visibleColumns.has('caller') && (
{phone ? ( <Table.Cell>
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} /> {phone ? (
) : <span className="text-xs text-quaternary">Unknown</span>} <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
</Table.Cell> ) : <span className="text-xs text-quaternary">Unknown</span>}
<Table.Cell> </Table.Cell>
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : '—'}</span> )}
</Table.Cell> {visibleColumns.has('dateTime') && (
<Table.Cell> <Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span> {call.startedAt ? (
</Table.Cell> <div>
<Table.Cell> <span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="text-sm text-primary">{call.agentName || '—'}</span> <span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</Table.Cell> </div>
<Table.Cell> ) : <span className="text-xs text-quaternary"></span>}
{call.missedcallcount && call.missedcallcount > 1 ? ( </Table.Cell>
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge> )}
) : <span className="text-xs text-quaternary">1</span>} {visibleColumns.has('branch') && (
</Table.Cell> <Table.Cell>
<Table.Cell> <span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color"> </Table.Cell>
{STATUS_LABELS[status] ?? status} )}
</Badge> {visibleColumns.has('agent') && (
</Table.Cell> <Table.Cell>
<Table.Cell> <span className="text-sm text-primary">{call.agentName || '—'}</span>
{sla && <Badge size="sm" color={sla.color} type="pill-color">{sla.label}</Badge>} </Table.Cell>
</Table.Cell> )}
</Table.Row> {visibleColumns.has('count') && (
); <Table.Cell>
}} {call.missedcallcount && call.missedcallcount > 1 ? (
</Table.Body> <Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
</Table> ) : <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> </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> </div>
</> </>
); );

View File

@@ -272,6 +272,7 @@ export type Call = {
patientId: string | null; patientId: string | null;
appointmentId: string | null; appointmentId: string | null;
leadId: string | null; leadId: string | null;
sla?: number | null;
// Denormalized for display // Denormalized for display
leadName?: string; leadName?: string;
leadPhone?: string; leadPhone?: string;