mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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:
@@ -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<HTMLAudioElement>(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<RecordingRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
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 })
|
||||
.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 (
|
||||
<>
|
||||
<TopBar title="Call Recordings" />
|
||||
<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">
|
||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
|
||||
<div className="flex items-center gap-3">
|
||||
<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 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 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Caller" />
|
||||
<Table.Head label="AI" className="w-14" />
|
||||
<Table.Head label="Type" className="w-20" />
|
||||
<Table.Head label="Date" className="w-28" />
|
||||
<Table.Head label="Duration" className="w-20" />
|
||||
<Table.Head label="Disposition" className="w-32" />
|
||||
<Table.Head label="Recording" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={filtered}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
|
||||
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
|
||||
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
|
||||
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
|
||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
|
||||
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
|
||||
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
<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} />
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
{visibleColumns.has('agent') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
{visibleColumns.has('caller') && (
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
{(() => {
|
||||
const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null;
|
||||
|
||||
Reference in New Issue
Block a user