mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log - Disposition modal: auto-lock based on actions taken, not-interested split - Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format) - Worklist-panel: pagination awareness, filter chips - Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish - SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner - Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts - Types: entities.ts extended Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
138 lines
7.2 KiB
TypeScript
138 lines
7.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { useAgentState } from '@/hooks/use-agent-state';
|
|
import type { OzonetelState } from '@/hooks/use-agent-state';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { notify } from '@/lib/toast';
|
|
import { cx } from '@/utils/cx';
|
|
|
|
type ToggleableStatus = 'ready' | 'break' | 'training';
|
|
|
|
const displayConfig: Record<OzonetelState, { label: string; color: string; dotColor: string }> = {
|
|
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
|
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
|
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
|
calling: { label: 'Calling', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
|
'in-call': { label: 'In Call', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
|
acw: { label: 'Wrapping up', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
|
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
|
|
};
|
|
|
|
const toggleOptions: Array<{ key: ToggleableStatus; label: string; color: string; dotColor: string }> = [
|
|
{ key: 'ready', label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
|
{ key: 'break', label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
|
{ key: 'training', label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
|
];
|
|
|
|
type AgentStatusToggleProps = {
|
|
isRegistered: boolean;
|
|
connectionStatus: string;
|
|
};
|
|
|
|
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
|
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
|
const { state: ozonetelState } = useAgentState(agentId);
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [changing, setChanging] = useState(false);
|
|
|
|
const handleChange = async (newStatus: ToggleableStatus) => {
|
|
setMenuOpen(false);
|
|
if (newStatus === ozonetelState) return;
|
|
setChanging(true);
|
|
|
|
try {
|
|
if (newStatus === 'ready') {
|
|
console.log('[AGENT-STATE] Changing to Ready');
|
|
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
|
|
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
|
} else {
|
|
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
|
// Ozonetel rejects Pause→Pause (Break↔Training) — the agent must
|
|
// transit through Ready. Insert a Ready hop whenever we're
|
|
// moving between two paused sub-states.
|
|
const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training';
|
|
if (isPauseToPause) {
|
|
console.log(`[AGENT-STATE] ${ozonetelState}→${newStatus}: sending Ready first, then Pause(${pauseReason})`);
|
|
await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
|
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
}
|
|
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
|
|
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
|
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
|
}
|
|
// Don't setStatus — SSE will push the real state
|
|
} catch (err) {
|
|
console.error('[AGENT-STATE] Status change failed:', err);
|
|
notify.error('Status Change Failed', 'Could not update agent status');
|
|
} finally {
|
|
setChanging(false);
|
|
}
|
|
};
|
|
|
|
// If SIP isn't connected, show connection status with user-friendly message
|
|
if (!isRegistered) {
|
|
const statusMessages: Record<string, string> = {
|
|
disconnected: 'Telephony unavailable',
|
|
connecting: 'Connecting to telephony...',
|
|
connected: 'Registering...',
|
|
error: 'Telephony error — check VPN',
|
|
};
|
|
return (
|
|
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
|
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
|
|
<span className="text-xs font-medium text-tertiary">{statusMessages[connectionStatus] ?? connectionStatus}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
|
|
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline';
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => canToggle && setMenuOpen(!menuOpen)}
|
|
disabled={changing || !canToggle}
|
|
className={cx(
|
|
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
|
canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
|
)}
|
|
>
|
|
{changing ? (
|
|
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
|
|
) : (
|
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
|
)}
|
|
<span className={cx('text-xs font-medium', changing ? 'text-brand-secondary' : current.color)}>
|
|
{changing ? 'Changing…' : current.label}
|
|
</span>
|
|
{canToggle && !changing && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
|
|
</button>
|
|
|
|
{menuOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
|
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
|
{toggleOptions.map((opt) => (
|
|
<button
|
|
key={opt.key}
|
|
onClick={() => handleChange(opt.key)}
|
|
className={cx(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
|
|
opt.key === ozonetelState ? 'bg-active' : 'hover:bg-primary_hover',
|
|
)}
|
|
>
|
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', opt.dotColor)} />
|
|
<span className={opt.color}>{opt.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|