diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index b0f0a31..81c48e9 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -106,13 +106,36 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const agentConfig = localStorage.getItem('helix_agent_config'); const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; 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 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 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(() => { 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]); - // 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(() => { - 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); setDispositionOpen(true); } - }, [callState, dispositionOpen]); + }, [callState, dispositionOpen, isOutbound, customerAnswered]); const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; @@ -214,6 +243,29 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, 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 if (callState === 'ringing-out') { return ( @@ -263,8 +315,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete ); } - // Unanswered call (ringing → ended without ever reaching active) - if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { + if (!trulyAnsweredAtEnd && (callState === 'ended' || callState === 'failed')) { + console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: trulyAnswered=${trulyAnsweredAtEnd} isOutbound=${isOutbound} customerAnswered=${customerAnswered} wasAnswered=${wasAnsweredRef.current}`); return (