mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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:
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user