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;
|
href?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
status?: SectionStatus;
|
status?: SectionStatus;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||||
@@ -30,26 +31,32 @@ export const SectionCard = ({
|
|||||||
href,
|
href,
|
||||||
onClick,
|
onClick,
|
||||||
status = 'unknown',
|
status = 'unknown',
|
||||||
|
disabled = false,
|
||||||
}: SectionCardProps) => {
|
}: SectionCardProps) => {
|
||||||
const className = cx(
|
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 = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
<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>
|
||||||
<div className="min-w-0">
|
<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>
|
<p className="mt-1 text-xs text-tertiary">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!disabled && (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faArrowRight}
|
icon={faArrowRight}
|
||||||
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status !== 'unknown' && (
|
{status !== 'unknown' && (
|
||||||
@@ -70,6 +77,13 @@ export const SectionCard = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} className={className}>
|
<button type="button" onClick={onClick} className={className}>
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export const CallRecordingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
<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}>
|
<Table.Header columns={activeColumns}>
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Head
|
<Table.Head
|
||||||
@@ -278,7 +278,7 @@ export const CallRecordingsPage = () => {
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(call) => (
|
{(call) => (
|
||||||
<Table.Row id={call.id} columns={activeColumns}>
|
<Table.Row id={call.id} columns={activeColumns} className="group/row">
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Cell key={col.id}>
|
<Table.Cell key={col.id}>
|
||||||
{renderRecordingCell(call, col.id)}
|
{renderRecordingCell(call, col.id)}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
isDisabled
|
||||||
iconLeading={({ className }: { className?: string }) => (
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
|
|||||||
const [contextLoading, setContextLoading] = useState(false);
|
const [contextLoading, setContextLoading] = useState(false);
|
||||||
const { leads } = useData();
|
const { leads } = useData();
|
||||||
|
|
||||||
// Poll active calls every 5 seconds
|
// Initial load + SSE stream for real-time active call updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCalls = () => {
|
// Initial snapshot
|
||||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||||
.then(calls => {
|
.then(setActiveCalls)
|
||||||
setActiveCalls(calls);
|
.catch(() => {})
|
||||||
// If selected call ended, clear selection
|
.finally(() => setLoading(false));
|
||||||
if (selectedCall && !calls.find(c => c.ucid === 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);
|
setSelectedCall(null);
|
||||||
setCallerContext(null);
|
setCallerContext(null);
|
||||||
}
|
}
|
||||||
})
|
}, [activeCalls, selectedCall]);
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCalls();
|
|
||||||
const interval = setInterval(fetchCalls, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [selectedCall?.ucid]);
|
|
||||||
|
|
||||||
// Tick every second for duration display
|
// Tick every second for duration display
|
||||||
useEffect(() => {
|
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[];
|
calls: MissedCallRecord[];
|
||||||
columns: ColDef[];
|
columns: ColDef[];
|
||||||
|
columnKey: string;
|
||||||
sortDescriptor: SortDescriptor;
|
sortDescriptor: SortDescriptor;
|
||||||
onSortChange: (desc: SortDescriptor) => void;
|
onSortChange: (desc: SortDescriptor) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
<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}>
|
<Table.Header columns={columns}>
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Head
|
<Table.Head
|
||||||
@@ -146,7 +147,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={calls}>
|
<Table.Body items={calls}>
|
||||||
{(call) => (
|
{(call) => (
|
||||||
<Table.Row id={call.id} columns={columns}>
|
<Table.Row id={call.id} columns={columns} className="group/row">
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Cell key={col.id}>
|
<Table.Cell key={col.id}>
|
||||||
{renderCell(call, col.id)}
|
{renderCell(call, col.id)}
|
||||||
@@ -274,6 +275,7 @@ export const MissedCallsPage = () => {
|
|||||||
<DynamicMissedCallTable
|
<DynamicMissedCallTable
|
||||||
calls={pagedRows}
|
calls={pagedRows}
|
||||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||||
|
columnKey={Array.from(visibleColumns).sort().join(',')}
|
||||||
sortDescriptor={sortDescriptor}
|
sortDescriptor={sortDescriptor}
|
||||||
onSortChange={setSortDescriptor}
|
onSortChange={setSortDescriptor}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faBuilding}
|
icon={faBuilding}
|
||||||
href="/settings/clinics"
|
href="/settings/clinics"
|
||||||
status={STEP_TO_STATUS(state, 'clinics')}
|
status={STEP_TO_STATUS(state, 'clinics')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={SETUP_STEP_LABELS.doctors.title}
|
title={SETUP_STEP_LABELS.doctors.title}
|
||||||
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faStethoscope}
|
icon={faStethoscope}
|
||||||
href="/settings/doctors"
|
href="/settings/doctors"
|
||||||
status={STEP_TO_STATUS(state, 'doctors')}
|
status={STEP_TO_STATUS(state, 'doctors')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={SETUP_STEP_LABELS.team.title}
|
title={SETUP_STEP_LABELS.team.title}
|
||||||
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faUserTie}
|
icon={faUserTie}
|
||||||
href="/settings/team"
|
href="/settings/team"
|
||||||
status={STEP_TO_STATUS(state, 'team')}
|
status={STEP_TO_STATUS(state, 'team')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</SectionGroup>
|
</SectionGroup>
|
||||||
|
|
||||||
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faPhone}
|
icon={faPhone}
|
||||||
href="/settings/telephony"
|
href="/settings/telephony"
|
||||||
status={STEP_TO_STATUS(state, 'telephony')}
|
status={STEP_TO_STATUS(state, 'telephony')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={SETUP_STEP_LABELS.ai.title}
|
title={SETUP_STEP_LABELS.ai.title}
|
||||||
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
|
|||||||
icon={faRobot}
|
icon={faRobot}
|
||||||
href="/settings/ai"
|
href="/settings/ai"
|
||||||
status={STEP_TO_STATUS(state, 'ai')}
|
status={STEP_TO_STATUS(state, 'ai')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Website widget"
|
title="Website widget"
|
||||||
description="Embed the chat + booking widget on your hospital website."
|
description="Embed the chat + booking widget on your hospital website."
|
||||||
icon={faGlobe}
|
icon={faGlobe}
|
||||||
href="/settings/widget"
|
href="/settings/widget"
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Routing rules"
|
title="Routing rules"
|
||||||
|
|||||||
Reference in New Issue
Block a user