mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: outbound call UI — immediate call card, auto-answer SIP bridge
- ClickToCallButton sets callState='ringing-out' immediately on click - ActiveCallCard shows "Calling..." state for outbound - SIP manager auto-answers incoming SIP when outbound is pending (Kookoo bridge) - CallPrepCard shows lead context while dialing - On error, resets state cleanly Flow: Click Call → UI shows call card → Kookoo dials customer → customer answers → SIP bridges → auto-answer → active call → disposition Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,33 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ringing state
|
// Outbound ringing — agent initiated the call
|
||||||
|
if (callState === 'ringing-out') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 animate-pulse rounded-full bg-brand-solid opacity-20" />
|
||||||
|
<div className="relative flex size-10 items-center justify-center rounded-full bg-brand-solid">
|
||||||
|
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Calling...</p>
|
||||||
|
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
|
||||||
|
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbound ringing
|
||||||
if (callState === 'ringing-in') {
|
if (callState === 'ringing-in') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Phone01 } from '@untitledui/icons';
|
import { Phone01 } from '@untitledui/icons';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { sipCallStateAtom, sipCallerNumberAtom } from '@/state/sip-state';
|
||||||
|
import { setOutboundPending } from '@/state/sip-manager';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
@@ -15,14 +18,25 @@ interface ClickToCallButtonProps {
|
|||||||
export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => {
|
export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => {
|
||||||
const { isRegistered, isInCall } = useSip();
|
const { isRegistered, isInCall } = useSip();
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
|
|
||||||
const handleDial = async () => {
|
const handleDial = async () => {
|
||||||
setDialing(true);
|
setDialing(true);
|
||||||
|
|
||||||
|
// Immediately show the call UI and mark outbound pending for auto-answer
|
||||||
|
setCallState('ringing-out');
|
||||||
|
setCallerNumber(phoneNumber);
|
||||||
|
setOutboundPending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ozonetel/dial', { phoneNumber, leadId });
|
await apiClient.post('/api/ozonetel/dial', { phoneNumber, leadId });
|
||||||
notify.success('Dialing', `Calling ${phoneNumber}...`);
|
|
||||||
} catch {
|
} catch {
|
||||||
// apiClient.post already toasts the error
|
// API error — reset call state
|
||||||
|
setCallState('idle');
|
||||||
|
setCallerNumber(null);
|
||||||
|
setOutboundPending(false);
|
||||||
|
notify.error('Dial Failed', 'Could not place the call');
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
}
|
}
|
||||||
@@ -37,7 +51,7 @@ export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: C
|
|||||||
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
||||||
isLoading={dialing}
|
isLoading={dialing}
|
||||||
>
|
>
|
||||||
{dialing ? 'Dialing...' : (label ?? 'Call')}
|
{label ?? 'Call'}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
|
|||||||
// Singleton SIP client — survives React StrictMode remounts
|
// Singleton SIP client — survives React StrictMode remounts
|
||||||
let sipClient: SIPClient | null = null;
|
let sipClient: SIPClient | null = null;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
let outboundPending = false;
|
||||||
|
|
||||||
type StateUpdater = {
|
type StateUpdater = {
|
||||||
setConnectionStatus: (status: ConnectionStatus) => void;
|
setConnectionStatus: (status: ConnectionStatus) => void;
|
||||||
@@ -17,6 +18,14 @@ export function registerSipStateUpdater(updater: StateUpdater) {
|
|||||||
stateUpdater = updater;
|
stateUpdater = updater;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setOutboundPending(pending: boolean) {
|
||||||
|
outboundPending = pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOutboundPending(): boolean {
|
||||||
|
return outboundPending;
|
||||||
|
}
|
||||||
|
|
||||||
export function connectSip(config: SIPConfig): void {
|
export function connectSip(config: SIPConfig): void {
|
||||||
if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) {
|
if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) {
|
||||||
return;
|
return;
|
||||||
@@ -38,6 +47,17 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
config,
|
config,
|
||||||
(status) => stateUpdater?.setConnectionStatus(status),
|
(status) => stateUpdater?.setConnectionStatus(status),
|
||||||
(state, number) => {
|
(state, number) => {
|
||||||
|
// Auto-answer SIP when it's a bridge from our outbound Kookoo call
|
||||||
|
if (state === 'ringing-in' && outboundPending) {
|
||||||
|
outboundPending = false;
|
||||||
|
// Auto-answer after a brief delay to let SIP negotiate
|
||||||
|
setTimeout(() => {
|
||||||
|
sipClient?.answer();
|
||||||
|
stateUpdater?.setCallState('active');
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
stateUpdater?.setCallState(state);
|
stateUpdater?.setCallState(state);
|
||||||
if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null);
|
if (number !== undefined) stateUpdater?.setCallerNumber(number ?? null);
|
||||||
},
|
},
|
||||||
@@ -50,6 +70,7 @@ export function disconnectSip(): void {
|
|||||||
sipClient?.disconnect();
|
sipClient?.disconnect();
|
||||||
sipClient = null;
|
sipClient = null;
|
||||||
connected = false;
|
connected = false;
|
||||||
|
outboundPending = false;
|
||||||
stateUpdater?.setConnectionStatus('disconnected');
|
stateUpdater?.setConnectionStatus('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user