feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix

Contacts page:
 - New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
 - Leads page now excludes those sources (campaign-sourced only)
 - Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
 - Same LeadTable + pagination + CSV export pattern as All Leads

P360 context panel for all worklist tabs (#6-10):
 - WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
 - call-desk: handleSelectItem builds ContextPanelSubject for any row type
 - ContextPanelSubject type replaces (lead as any).patientId casts
 - Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id

Dynamic column toggle (blank-screen fix):
 - missed-calls + call-recordings refactored to React Aria dynamic
   collections API (Table.Header columns={} + Table.Row columns={})
 - Fixes "Cell count must match column count" crash on column hide
 - Row-header column metadata in columnDefs instead of hardcoded JSX

Slot flickering fix (#2):
 - Removed clinic + timeSlot from slot-fetch useEffect deps (circular
   loop: effect sets clinic → clinic in deps → re-fires)
 - Memoized timeSlotSelectItems

Other:
 - GlobalSearch hidden (stale appointment state on navigation)
 - Branch column: shows campaign name from relation, falls back to DID
 - formatSource maps PHONE → "Phone"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 16:54:30 +05:30
parent c22d82f8c5
commit ca482e731e
12 changed files with 524 additions and 237 deletions

View File

@@ -76,13 +76,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
};
const columnDefs = [
{ id: 'agent', label: 'Agent', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: 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: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
{ id: 'recording', label: 'Recording', defaultVisible: true },
];
@@ -96,6 +96,85 @@ export const CallRecordingsPage = () => {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
// Dynamic columns for React Aria — filter by visibility, pass as prop
const activeColumns = useMemo(
() => columnDefs.filter(c => visibleColumns.has(c.id)),
[visibleColumns],
);
// Cell renderer — lives inside the component so it can access setSlideoutCallId
const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
switch (colId) {
case 'agent':
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
case 'caller':
return phone
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
: <span className="text-xs text-quaternary"></span>;
case 'ai':
return (
<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>
);
case 'type':
return <Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>;
case 'sla': {
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>
);
}
case 'dateTime':
return 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>;
case 'duration':
return <span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>;
case 'disposition':
return 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>;
case 'recording':
return call.recording?.primaryLinkUrl
? <RecordingPlayer url={call.recording.primaryLinkUrl} />
: null;
default:
return null;
}
}, []);
const fetchRecordings = useCallback(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => {
@@ -186,119 +265,27 @@ export const CallRecordingsPage = () => {
) : (
<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 columns={activeColumns}>
{(col) => (
<Table.Head
key={col.id}
id={col.id}
label={col.label}
isRowHeader={col.isRowHeader}
allowsSorting={col.allowsSorting}
/>
)}
</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}>
{visibleColumns.has('agent') && (
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
)}
{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>
);
}}
{(call) => (
<Table.Row id={call.id} columns={activeColumns}>
{(col) => (
<Table.Cell key={col.id}>
{renderRecordingCell(call, col.id)}
</Table.Cell>
)}
</Table.Row>
)}
</Table.Body>
</Table>
</div>