mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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:
@@ -57,16 +57,108 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
|
||||
};
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||
{ id: 'count', label: 'Count', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
||||
];
|
||||
|
||||
// Dynamic columns table — React Aria requires the column count to match
|
||||
// between Header and Row. Conditional `{visible && <Cell>}` crashes the
|
||||
// table (#8127). Using the dynamic collections API (columns prop +
|
||||
// render function) lets React Aria rebuild its collection cleanly when
|
||||
// the visible set changes.
|
||||
type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean };
|
||||
|
||||
const renderCell = (call: MissedCallRecord, colId: string) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
switch (colId) {
|
||||
case 'caller':
|
||||
return phone
|
||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
: <span className="text-xs text-quaternary">Unknown</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 'branch':
|
||||
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
|
||||
case 'agent':
|
||||
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||
case 'count':
|
||||
return call.missedCallCount && call.missedCallCount > 1
|
||||
? <Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
||||
: <span className="text-xs text-quaternary">1</span>;
|
||||
case 'status':
|
||||
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
|
||||
case 'sla':
|
||||
if (call.sla == null) return <span className="text-xs text-quaternary">—</span>;
|
||||
const slaStatus = computeSlaStatus(call.sla);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx('size-2 rounded-full',
|
||||
slaStatus === 'low' && 'bg-success-solid',
|
||||
slaStatus === 'medium' && 'bg-warning-solid',
|
||||
slaStatus === 'high' && 'bg-error-solid',
|
||||
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{call.sla}%</span>
|
||||
</span>
|
||||
);
|
||||
case 'callback':
|
||||
return 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>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
|
||||
calls: MissedCallRecord[];
|
||||
columns: ColDef[];
|
||||
sortDescriptor: SortDescriptor;
|
||||
onSortChange: (desc: SortDescriptor) => void;
|
||||
}) => (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table.Header columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
key={col.id}
|
||||
id={col.id}
|
||||
label={col.label}
|
||||
isRowHeader={col.isRowHeader}
|
||||
allowsSorting={col.allowsSorting}
|
||||
/>
|
||||
)}
|
||||
</Table.Header>
|
||||
<Table.Body items={calls}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderCell(call, col.id)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MissedCallsPage = () => {
|
||||
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -175,101 +267,12 @@ export const MissedCallsPage = () => {
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
|
||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
|
||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
|
||||
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
|
||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
{visibleColumns.has('caller') && (
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">Unknown</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('branch') && (
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('agent') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('count') && (
|
||||
<Table.Cell>
|
||||
{call.missedCallCount && call.missedCallCount > 1 ? (
|
||||
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
||||
) : <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>
|
||||
<DynamicMissedCallTable
|
||||
calls={pagedRows}
|
||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user