mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
fix: outbound call — debounce customer-answered, auto-dispose on no-answer (#568)
Ozonetel sends 'in-call' even for voicemail (~4s before ACW), which briefly enabled action buttons and poisoned wasAnsweredRef. Three fixes: 1. Debounce customerAnswered for outbound: require 'in-call' to hold 5s before enabling buttons (filters voicemail/IVR pickup) 2. Use live customerAnswered (not stale latch) for outbound call-end routing — unanswered calls go to Back to Worklist, not disposition 3. Auto-dispose with NO_ANSWER on unanswered outbound to release agent from ACW immediately (was waiting 30s for server safety net) Also: hide AI FAB for CC agents in app-shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,13 +106,36 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
|
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
|
||||||
// For outbound calls, SIP goes 'active' when the agent's bridge connects
|
|
||||||
// (before customer answers). Ozonetel state stays 'calling' until customer
|
|
||||||
// picks up, then transitions to 'in-call'. Use this to gate action buttons.
|
|
||||||
const customerAnswered = callState === 'active' && ozonetelState !== 'calling';
|
|
||||||
|
|
||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
|
const isOutbound = callDirectionRef.current === 'OUTBOUND';
|
||||||
|
|
||||||
|
// For outbound: Ozonetel sends 'in-call' even for voicemail (~4s before ACW).
|
||||||
|
// Debounce: only treat as answered if 'in-call' holds for 5+ seconds.
|
||||||
|
// For inbound: customer is already there — active means answered, no delay.
|
||||||
|
const rawAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
|
||||||
|
const [customerAnswered, setCustomerAnswered] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOutbound) {
|
||||||
|
setCustomerAnswered(rawAnswered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rawAnswered) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
|
||||||
|
setCustomerAnswered(true);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setCustomerAnswered(false);
|
||||||
|
}, [rawAnswered, isOutbound]);
|
||||||
|
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
const wasAnsweredRef = useRef(callState === 'active');
|
||||||
|
const unansweredDisposeFired = useRef(false);
|
||||||
|
|
||||||
|
// ── DEBUG: trace every state change ──
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} rawAnswered=${rawAnswered} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`);
|
||||||
|
}, [callState, ozonetelState, isOutbound, rawAnswered, customerAnswered]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
@@ -130,13 +153,19 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
};
|
};
|
||||||
}, [callUcid]);
|
}, [callUcid]);
|
||||||
|
|
||||||
// Detect caller disconnect: call was active and ended without agent pressing End
|
// Detect caller disconnect: call was active and ended without agent pressing End.
|
||||||
|
// For outbound: use live customerAnswered (not the latch) — Ozonetel may briefly
|
||||||
|
// fire 'in-call' then 'acw' even when no one answered (voicemail, timeout).
|
||||||
|
// If customerAnswered is false at call end, route to Back to Worklist.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
|
||||||
|
const trulyAnswered = isOutbound ? customerAnswered : wasAnsweredRef.current;
|
||||||
|
console.log(`[CALL-DBG] ▶ CALL ENDED: trulyAnswered=${trulyAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current} callState=${callState}`);
|
||||||
|
if (trulyAnswered) {
|
||||||
setCallerDisconnected(true);
|
setCallerDisconnected(true);
|
||||||
setDispositionOpen(true);
|
setDispositionOpen(true);
|
||||||
}
|
}
|
||||||
}, [callState, dispositionOpen]);
|
}, [callState, dispositionOpen, isOutbound, customerAnswered]);
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? '';
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
@@ -214,6 +243,29 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onCallComplete?.();
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Unanswered call — for outbound use live flag, for inbound use latch
|
||||||
|
const trulyAnsweredAtEnd = isOutbound ? customerAnswered : wasAnsweredRef.current;
|
||||||
|
|
||||||
|
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trulyAnsweredAtEnd && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
|
||||||
|
unansweredDisposeFired.current = true;
|
||||||
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||||
|
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
|
||||||
|
apiClient.post('/api/ozonetel/dispose', {
|
||||||
|
ucid: callUcid,
|
||||||
|
disposition: 'NO_ANSWER',
|
||||||
|
agentId: agentCfg.ozonetelAgentId,
|
||||||
|
callerPhone,
|
||||||
|
direction: 'OUTBOUND',
|
||||||
|
durationSec: 0,
|
||||||
|
leadId: lead?.id ?? null,
|
||||||
|
leadName: fullName || null,
|
||||||
|
notes: 'Auto-disposed — customer did not answer',
|
||||||
|
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
|
||||||
|
}
|
||||||
|
}, [trulyAnsweredAtEnd, isOutbound, callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Outbound ringing
|
// Outbound ringing
|
||||||
if (callState === 'ringing-out') {
|
if (callState === 'ringing-out') {
|
||||||
return (
|
return (
|
||||||
@@ -263,8 +315,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unanswered call (ringing → ended without ever reaching active)
|
if (!trulyAnsweredAtEnd && (callState === 'ended' || callState === 'failed')) {
|
||||||
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: trulyAnswered=${trulyAnsweredAtEnd} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
@@ -279,6 +331,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === 'active' || dispositionOpen) {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
|
if (customerAnswered && !wasAnsweredRef.current) {
|
||||||
|
console.log(`[CALL-DBG] ▶ wasAnsweredRef SET TRUE: ozonetel=${ozonetelState} callState=${callState}`);
|
||||||
|
}
|
||||||
if (customerAnswered) wasAnsweredRef.current = true;
|
if (customerAnswered) wasAnsweredRef.current = true;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
{isAdmin && <AiFloatingButton />}
|
{isAdmin && !isCCAgent && <AiFloatingButton />}
|
||||||
</div>
|
</div>
|
||||||
<MaintOtpModal
|
<MaintOtpModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user