mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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:
@@ -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}>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
isDisabled
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user