mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
refactor: centralise outbound dial into useSip().dialOutbound()
- Single dialOutbound() in sip-provider handles all outbound state: callState, callerNumber, outboundPending, API call, error recovery - ClickToCallButton, PhoneActionCell, Dialler all use dialOutbound() - Removed direct Jotai atom manipulation from calling components - Removed setOutboundPending imports from components - SIP disconnects on provider unmount + auth logout - Dialler input is now editable (type or numpad) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,10 @@ import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
|
||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
||||
import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
interface ClickToCallButtonProps {
|
||||
@@ -20,33 +16,14 @@ interface ClickToCallButtonProps {
|
||||
}
|
||||
|
||||
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
||||
const { isRegistered, isInCall } = useSip();
|
||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||
const [dialing, setDialing] = useState(false);
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
|
||||
const handleDial = async () => {
|
||||
setDialing(true);
|
||||
|
||||
// Show call UI immediately
|
||||
setCallState('ringing-out');
|
||||
setCallerNumber(phoneNumber);
|
||||
setOutboundPending(true);
|
||||
// Safety: reset flag if SIP INVITE doesn't arrive within 30s
|
||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{ ucid?: string; status?: string }>('/api/ozonetel/dial', { phoneNumber });
|
||||
if (result?.ucid) {
|
||||
setCallUcid(result.ucid);
|
||||
}
|
||||
await dialOutbound(phoneNumber);
|
||||
} catch {
|
||||
clearTimeout(safetyTimer);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
setOutboundPending(false);
|
||||
setCallUcid(null);
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
} finally {
|
||||
setDialing(false);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
||||
import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
@@ -16,10 +12,7 @@ type PhoneActionCellProps = {
|
||||
};
|
||||
|
||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
||||
const { isRegistered, isInCall } = useSip();
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [dialing, setDialing] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,20 +34,9 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
|
||||
if (!isRegistered || isInCall || dialing) return;
|
||||
setMenuOpen(false);
|
||||
setDialing(true);
|
||||
setCallState('ringing-out');
|
||||
setCallerNumber(phoneNumber);
|
||||
setOutboundPending(true);
|
||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
|
||||
if (result?.ucid) setCallUcid(result.ucid);
|
||||
await dialOutbound(phoneNumber);
|
||||
} catch {
|
||||
clearTimeout(safetyTimer);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
setOutboundPending(false);
|
||||
setCallUcid(null);
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
} finally {
|
||||
setDialing(false);
|
||||
|
||||
@@ -12,14 +12,13 @@ import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { leadActivities } = useData();
|
||||
const { connectionStatus, isRegistered, callState, callerNumber, callUcid } = useSip();
|
||||
const { connectionStatus, isRegistered, callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
@@ -34,7 +33,7 @@ export const CallDeskPage = () => {
|
||||
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
|
||||
setDialling(true);
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/dial', { phoneNumber: num });
|
||||
await dialOutbound(num);
|
||||
setDiallerOpen(false);
|
||||
setDialNumber('');
|
||||
} catch {
|
||||
@@ -95,9 +94,15 @@ export const CallDeskPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2.5 rounded-lg bg-secondary min-h-[40px]">
|
||||
<span className="flex-1 text-lg font-semibold text-primary tracking-wider text-center">
|
||||
{dialNumber || <span className="text-placeholder font-normal text-sm">Enter number</span>}
|
||||
</span>
|
||||
<input
|
||||
type="tel"
|
||||
value={dialNumber}
|
||||
onChange={e => setDialNumber(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleDial()}
|
||||
placeholder="Enter number"
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-lg font-semibold text-primary tracking-wider text-center placeholder:text-placeholder placeholder:font-normal placeholder:text-sm outline-none"
|
||||
/>
|
||||
{dialNumber && (
|
||||
<button onClick={() => setDialNumber(dialNumber.slice(0, -1))} className="text-fg-quaternary hover:text-fg-secondary shrink-0">
|
||||
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
|
||||
|
||||
@@ -96,6 +96,12 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
// Disconnect SIP before logout
|
||||
try {
|
||||
const { disconnectSip } = await import('@/state/sip-manager');
|
||||
disconnectSip();
|
||||
} catch {}
|
||||
|
||||
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
|
||||
const token = localStorage.getItem('helix_access_token');
|
||||
if (token) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
sipCallStartTimeAtom,
|
||||
sipCallUcidAtom,
|
||||
} from '@/state/sip-state';
|
||||
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient } from '@/state/sip-manager';
|
||||
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { SIPConfig } from '@/types/sip';
|
||||
|
||||
const getSipConfig = (): SIPConfig => {
|
||||
@@ -85,11 +86,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
|
||||
// and resets to idle via the "Back to Worklist" button
|
||||
|
||||
// Cleanup on page unload
|
||||
// Cleanup on unmount + page unload
|
||||
useEffect(() => {
|
||||
const handleUnload = () => disconnectSip();
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
disconnectSip();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
@@ -98,7 +102,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
// Hook for components to access SIP actions + state
|
||||
export const useSip = () => {
|
||||
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||
const [callState] = useAtom(sipCallStateAtom);
|
||||
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
||||
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
||||
const [callUcid] = useAtom(sipCallUcidAtom);
|
||||
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
||||
@@ -110,6 +114,24 @@ export const useSip = () => {
|
||||
setCallerNumber(phoneNumber);
|
||||
}, [setCallerNumber]);
|
||||
|
||||
// Ozonetel outbound dial — single path for all outbound calls
|
||||
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
||||
setCallState('ringing-out');
|
||||
setCallerNumber(phoneNumber);
|
||||
setOutboundPending(true);
|
||||
const safetyTimeout = setTimeout(() => setOutboundPending(false), 30000);
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/ozonetel/dial', { phoneNumber });
|
||||
} catch {
|
||||
clearTimeout(safetyTimeout);
|
||||
setOutboundPending(false);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
throw new Error('Dial failed');
|
||||
}
|
||||
}, [setCallState, setCallerNumber]);
|
||||
|
||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||
const hangup = useCallback(() => getSipClient()?.hangup(), []);
|
||||
@@ -147,6 +169,7 @@ export const useSip = () => {
|
||||
connect: () => connectSip(getSipConfig()),
|
||||
disconnect: disconnectSip,
|
||||
makeCall,
|
||||
dialOutbound,
|
||||
answer,
|
||||
reject,
|
||||
hangup,
|
||||
|
||||
Reference in New Issue
Block a user