feat: supervisor fixes — settings disabled cards, column toggle fix, hold SSE, campaign edit disabled

- SectionCard: added disabled prop (muted, non-clickable, no arrow)
- Settings hub: Clinics, Doctors, Team, Telephony, AI, Widget cards disabled
- Campaigns: edit button disabled
- Missed calls + Call recordings: column toggle blank page fixed (key-based
  Table remount forces clean React Aria collection on column change)
- Live monitor: replaced 5s polling with SSE stream for real-time active
  call updates (new/hold/unhold/disconnect reflected instantly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 05:45:04 +05:30
parent b03d0f62cf
commit a9d19af1d3
6 changed files with 75 additions and 30 deletions

View File

@@ -17,6 +17,7 @@ type SectionCardProps = {
href?: string;
onClick?: () => void;
status?: SectionStatus;
disabled?: boolean;
};
// Settings hub card. Each card represents one setup-able section (Branding,
@@ -30,26 +31,32 @@ export const SectionCard = ({
href,
onClick,
status = 'unknown',
disabled = false,
}: SectionCardProps) => {
const className = cx(
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
'group block w-full text-left rounded-xl border border-secondary p-5 shadow-xs transition',
disabled
? 'cursor-not-allowed opacity-50 bg-disabled_subtle'
: 'bg-primary hover:border-brand hover:shadow-md',
);
const body = (
<>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} />
<FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-primary">{title}</h3>
<h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
<p className="mt-1 text-xs text-tertiary">{description}</p>
</div>
</div>
<FontAwesomeIcon
icon={faArrowRight}
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
{!disabled && (
<FontAwesomeIcon
icon={faArrowRight}
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
)}
</div>
{status !== 'unknown' && (
@@ -70,6 +77,13 @@ export const SectionCard = ({
</>
);
if (disabled) {
return (
<div className={className}>
{body}
</div>
);
}
if (onClick) {
return (
<button type="button" onClick={onClick} className={className}>

View File

@@ -264,7 +264,7 @@ export const CallRecordingsPage = () => {
</div>
) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header columns={activeColumns}>
{(col) => (
<Table.Head
@@ -278,7 +278,7 @@ export const CallRecordingsPage = () => {
</Table.Header>
<Table.Body items={pagedRows}>
{(call) => (
<Table.Row id={call.id} columns={activeColumns}>
<Table.Row id={call.id} columns={activeColumns} className="group/row">
{(col) => (
<Table.Cell key={col.id}>
{renderRecordingCell(call, col.id)}

View File

@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
<Button
size="sm"
color="secondary"
isDisabled
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPenToSquare} className={className} />
)}

View File

@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
const [contextLoading, setContextLoading] = useState(false);
const { leads } = useData();
// Poll active calls every 5 seconds
// Initial load + SSE stream for real-time active call updates
useEffect(() => {
const fetchCalls = () => {
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(calls => {
setActiveCalls(calls);
// If selected call ended, clear selection
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
})
.catch(() => {})
.finally(() => setLoading(false));
};
// Initial snapshot
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(setActiveCalls)
.catch(() => {})
.finally(() => setLoading(false));
fetchCalls();
const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval);
}, [selectedCall?.ucid]);
// SSE stream — receives update/remove events in real-time
const apiUrl = import.meta.env.VITE_API_URL ?? '';
const es = new EventSource(`${apiUrl}/api/supervisor/active-calls/stream`);
es.onmessage = (msg) => {
try {
const event = JSON.parse(msg.data) as { type: 'update' | 'remove'; call?: ActiveCall; ucid: string };
setActiveCalls(prev => {
if (event.type === 'remove') {
return prev.filter(c => c.ucid !== event.ucid);
}
if (event.type === 'update' && event.call) {
const exists = prev.find(c => c.ucid === event.ucid);
if (exists) {
return prev.map(c => c.ucid === event.ucid ? event.call! : c);
}
return [...prev, event.call];
}
return prev;
});
} catch {}
};
es.onerror = () => {
// SSE reconnects automatically; no-op
};
return () => es.close();
}, []);
// Clear selection if the selected call ended
useEffect(() => {
if (selectedCall && !activeCalls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
}, [activeCalls, selectedCall]);
// Tick every second for duration display
useEffect(() => {

View File

@@ -125,14 +125,15 @@ const renderCell = (call: MissedCallRecord, colId: string) => {
}
};
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[];
columns: ColDef[];
columnKey: string;
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 key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
<Table.Header columns={columns}>
{(col) => (
<Table.Head
@@ -146,7 +147,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
</Table.Header>
<Table.Body items={calls}>
{(call) => (
<Table.Row id={call.id} columns={columns}>
<Table.Row id={call.id} columns={columns} className="group/row">
{(col) => (
<Table.Cell key={col.id}>
{renderCell(call, col.id)}
@@ -274,6 +275,7 @@ export const MissedCallsPage = () => {
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
columnKey={Array.from(visibleColumns).sort().join(',')}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
/>

View File

@@ -73,6 +73,7 @@ export const SettingsPage = () => {
icon={faBuilding}
href="/settings/clinics"
status={STEP_TO_STATUS(state, 'clinics')}
disabled
/>
<SectionCard
title={SETUP_STEP_LABELS.doctors.title}
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
icon={faStethoscope}
href="/settings/doctors"
status={STEP_TO_STATUS(state, 'doctors')}
disabled
/>
<SectionCard
title={SETUP_STEP_LABELS.team.title}
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
icon={faUserTie}
href="/settings/team"
status={STEP_TO_STATUS(state, 'team')}
disabled
/>
</SectionGroup>
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
icon={faPhone}
href="/settings/telephony"
status={STEP_TO_STATUS(state, 'telephony')}
disabled
/>
<SectionCard
title={SETUP_STEP_LABELS.ai.title}
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
icon={faRobot}
href="/settings/ai"
status={STEP_TO_STATUS(state, 'ai')}
disabled
/>
<SectionCard
title="Website widget"
description="Embed the chat + booking widget on your hospital website."
icon={faGlobe}
href="/settings/widget"
disabled
/>
<SectionCard
title="Routing rules"