mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
#533: Remove redundant Call History top header (duplicate TopBar) #531: Block logout during active call (confirm dialog + UCID check) #529: Block outbound calls when agent is on Break/Training #527: Remove updatePatient during appointment creation (was mutating shared Patient entity, affecting all past appointments) #547: SLA rules seeded via API (config issue, not code) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { useEffect, useCallback, useRef, type PropsWithChildren } from 'react';
|
|
import { useAtom, useSetAtom } from 'jotai';
|
|
import {
|
|
sipConnectionStatusAtom,
|
|
sipCallStateAtom,
|
|
sipCallerNumberAtom,
|
|
sipIsMutedAtom,
|
|
sipIsOnHoldAtom,
|
|
sipCallDurationAtom,
|
|
sipCallStartTimeAtom,
|
|
sipCallUcidAtom,
|
|
} from '@/state/sip-state';
|
|
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import type { SIPConfig } from '@/types/sip';
|
|
|
|
// SIP config comes exclusively from the Agent entity (stored on login).
|
|
// No env var fallback — users without an Agent entity don't connect SIP.
|
|
const getSipConfig = (): SIPConfig | null => {
|
|
try {
|
|
const stored = localStorage.getItem('helix_agent_config');
|
|
if (stored) {
|
|
const config = JSON.parse(stored);
|
|
if (config.sipUri && config.sipWsServer) {
|
|
return {
|
|
displayName: 'Helix Agent',
|
|
uri: config.sipUri,
|
|
password: config.sipPassword,
|
|
wsServer: config.sipWsServer,
|
|
stunServers: 'stun:stun.l.google.com:19302',
|
|
};
|
|
}
|
|
}
|
|
} catch {}
|
|
return null;
|
|
};
|
|
|
|
export const SipProvider = ({ children }: PropsWithChildren) => {
|
|
const [, setConnectionStatus] = useAtom(sipConnectionStatusAtom);
|
|
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
|
const setCallDuration = useSetAtom(sipCallDurationAtom);
|
|
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
|
|
|
|
// Register Jotai setters so the singleton SIP manager can update atoms
|
|
useEffect(() => {
|
|
registerSipStateUpdater({
|
|
setConnectionStatus,
|
|
setCallState,
|
|
setCallerNumber,
|
|
setCallUcid,
|
|
});
|
|
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
|
|
|
// Auto-connect SIP on mount — only if Agent entity has SIP config
|
|
useEffect(() => {
|
|
const config = getSipConfig();
|
|
if (config) {
|
|
connectSip(config);
|
|
} else {
|
|
console.log('[SIP] No agent SIP config — skipping connection');
|
|
}
|
|
}, []);
|
|
|
|
// Call duration timer
|
|
useEffect(() => {
|
|
if (callState === 'active') {
|
|
const start = new Date();
|
|
setCallStartTime(start);
|
|
const interval = window.setInterval(() => {
|
|
setCallDuration(Math.floor((Date.now() - start.getTime()) / 1000));
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}
|
|
setCallDuration(0);
|
|
setCallStartTime(null);
|
|
}, [callState, setCallDuration, setCallStartTime]);
|
|
|
|
// Ringtone on incoming call
|
|
useEffect(() => {
|
|
if (callState === 'ringing-in') {
|
|
import('@/lib/ringtone').then(({ startRingtone }) => startRingtone());
|
|
} else {
|
|
import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone());
|
|
}
|
|
}, [callState]);
|
|
|
|
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
|
// and resets to idle via the "Back to Worklist" button
|
|
|
|
// Layer 1: Warn on page refresh/close during active call
|
|
// Layer 2: Fire sendBeacon to auto-dispose if user confirms leave
|
|
// These two layers protect against the "agent refreshes mid-call → stuck in ACW" bug.
|
|
// Layer 3 (server-side ACW timeout) lives in supervisor.service.ts.
|
|
//
|
|
// IMPORTANT: beforeunload reads callState via a ref (not the dep array)
|
|
// because adding callState to deps causes the cleanup to fire on every
|
|
// state transition → disconnectSip() → kills the call mid-flight.
|
|
const callStateRef = useRef(callState);
|
|
callStateRef.current = callState;
|
|
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
const ucid = localStorage.getItem('helix_active_ucid');
|
|
const state = callStateRef.current;
|
|
|
|
// Layer 1: Show browser "Leave page?" dialog during active calls
|
|
if (state === 'active' || state === 'ringing-in' || state === 'ringing-out') {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
|
|
// Layer 2: Fire disposition beacon if there's an active UCID
|
|
if (ucid) {
|
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
|
const payload = JSON.stringify({
|
|
ucid,
|
|
disposition: 'CALLBACK_REQUESTED',
|
|
agentId: agentCfg.ozonetelAgentId,
|
|
autoDisposed: true,
|
|
});
|
|
navigator.sendBeacon('/api/ozonetel/dispose', new Blob([payload], { type: 'application/json' }));
|
|
localStorage.removeItem('helix_active_ucid');
|
|
}
|
|
};
|
|
|
|
const handleUnload = () => disconnectSip(true);
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
window.addEventListener('unload', handleUnload);
|
|
return () => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
window.removeEventListener('unload', handleUnload);
|
|
disconnectSip(true); // force — component is unmounting
|
|
};
|
|
}, []); // empty deps — runs once on mount, cleanup only on unmount
|
|
|
|
return <>{children}</>;
|
|
};
|
|
|
|
// Hook for components to access SIP actions + state
|
|
export const useSip = () => {
|
|
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
|
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
|
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
|
const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom);
|
|
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
|
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
|
|
const [callDuration] = useAtom(sipCallDurationAtom);
|
|
|
|
const makeCall = useCallback((phoneNumber: string) => {
|
|
getSipClient()?.call(phoneNumber);
|
|
setCallerNumber(phoneNumber);
|
|
}, [setCallerNumber]);
|
|
|
|
// Ozonetel outbound dial — single path for all outbound calls
|
|
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
|
// Block outbound calls when agent is on Break or Training
|
|
const agentCfg = localStorage.getItem('helix_agent_config');
|
|
if (agentCfg) {
|
|
const { useAgentState: _ } = await import('@/hooks/use-agent-state');
|
|
// Read state from the SSE endpoint directly (can't use hook here)
|
|
const agentId = JSON.parse(agentCfg).ozonetelAgentId;
|
|
try {
|
|
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
|
|
const stateData = await stateRes.json();
|
|
if (stateData.state === 'break' || stateData.state === 'training') {
|
|
const { notify } = await import('@/lib/toast');
|
|
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
|
|
return;
|
|
}
|
|
} catch {}
|
|
}
|
|
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
|
|
setCallState('ringing-out');
|
|
setCallerNumber(phoneNumber);
|
|
setOutboundPending(true);
|
|
const safetyTimeout = setTimeout(() => {
|
|
console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending');
|
|
setOutboundPending(false);
|
|
}, 30000);
|
|
|
|
try {
|
|
// Send agent config so the sidecar dials with the correct agent ID + campaign
|
|
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
|
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', {
|
|
phoneNumber,
|
|
agentId: agentConfig.ozonetelAgentId,
|
|
campaignName: agentConfig.campaignName,
|
|
});
|
|
console.log('[DIAL] Dial API response:', result);
|
|
clearTimeout(safetyTimeout);
|
|
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound
|
|
if (result?.ucid) {
|
|
console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`);
|
|
setCallUcid(result.ucid);
|
|
}
|
|
} catch (err) {
|
|
console.error('[DIAL] Dial API failed:', err);
|
|
clearTimeout(safetyTimeout);
|
|
setOutboundPending(false);
|
|
setCallState('idle');
|
|
setCallerNumber(null);
|
|
throw new Error('Dial failed');
|
|
}
|
|
}, [setCallState, setCallerNumber, setCallUcid]);
|
|
|
|
const answer = useCallback(() => getSipClient()?.answer(), []);
|
|
const reject = useCallback(() => getSipClient()?.reject(), []);
|
|
const hangup = useCallback(() => getSipClient()?.hangup(), []);
|
|
|
|
const toggleMute = useCallback(() => {
|
|
if (isMuted) {
|
|
getSipClient()?.unmute();
|
|
} else {
|
|
getSipClient()?.mute();
|
|
}
|
|
setIsMuted(!isMuted);
|
|
}, [isMuted, setIsMuted]);
|
|
|
|
const toggleHold = useCallback(() => {
|
|
if (isOnHold) {
|
|
getSipClient()?.unhold();
|
|
} else {
|
|
getSipClient()?.hold();
|
|
}
|
|
setIsOnHold(!isOnHold);
|
|
}, [isOnHold, setIsOnHold]);
|
|
|
|
return {
|
|
connectionStatus,
|
|
callState,
|
|
callerNumber,
|
|
callUcid,
|
|
isMuted,
|
|
isOnHold,
|
|
callDuration,
|
|
isRegistered: connectionStatus === 'registered',
|
|
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
|
|
ozonetelStatus: 'logged-in' as const,
|
|
ozonetelError: null as string | null,
|
|
connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
|
|
disconnect: disconnectSip,
|
|
makeCall,
|
|
dialOutbound,
|
|
answer,
|
|
reject,
|
|
hangup,
|
|
toggleMute,
|
|
toggleHold,
|
|
};
|
|
};
|