feat: send disposition to sidecar with UCID for Ozonetel ACW release

- Store UCID from outbound dial API response in sipCallUcidAtom
- Replace direct createCall GraphQL mutation with sidecar /api/ozonetel/dispose
- Remove agent-ready call from handleReset (no longer needed)
- Clear UCID on dial error and call reset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 18:38:19 +05:30
parent d6ef2b70d8
commit 3a5bbc3f2a
2 changed files with 36 additions and 24 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
@@ -7,7 +7,8 @@ import {
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom } from '@/state/sip-state'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { DispositionForm } from './disposition-form'; import { DispositionForm } from './disposition-form';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
@@ -30,12 +31,15 @@ const formatDuration = (seconds: number): string => {
}; };
export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const { callState, callDuration, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null); const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null); const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
const [appointmentOpen, setAppointmentOpen] = useState(false); const [appointmentOpen, setAppointmentOpen] = useState(false);
// Capture direction at mount — survives through disposition stage
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const firstName = lead?.contactName?.firstName ?? ''; const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -43,25 +47,20 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
const phone = lead?.contactPhone?.[0]; const phone = lead?.contactPhone?.[0];
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown'; const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
const handleDisposition = async (disposition: CallDisposition, _notes: string) => { const handleDisposition = async (disposition: CallDisposition, notes: string) => {
setSavedDisposition(disposition); setSavedDisposition(disposition);
// Create call record in platform // Submit disposition to sidecar — handles Ozonetel ACW release
try { if (callUcid) {
await apiClient.graphql(`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`, { apiClient.post('/api/ozonetel/dispose', {
data: { ucid: callUcid,
name: `${fullName || phoneDisplay}${disposition}`,
direction: 'INBOUND',
callStatus: 'COMPLETED',
agentName: null,
startedAt: new Date().toISOString(),
durationSec: callDuration,
disposition, disposition,
callerPhone,
direction: callDirectionRef.current,
durationSec: callDuration,
leadId: lead?.id ?? null, leadId: lead?.id ?? null,
}, notes,
}, { silent: true }); }).catch((err) => console.warn('Disposition failed:', err));
} catch {
// non-blocking
} }
if (disposition === 'APPOINTMENT_BOOKED') { if (disposition === 'APPOINTMENT_BOOKED') {
@@ -103,6 +102,8 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
setSavedDisposition(null); setSavedDisposition(null);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
setCallUcid(null);
setOutboundPending(false);
}; };
// Outbound ringing — agent initiated the call // Outbound ringing — agent initiated the call

View File

@@ -3,7 +3,9 @@ import { Phone01 } from '@untitledui/icons';
import { useSetAtom } from 'jotai'; 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 { 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 { notify } from '@/lib/toast';
interface ClickToCallButtonProps { interface ClickToCallButtonProps {
@@ -14,10 +16,11 @@ interface ClickToCallButtonProps {
} }
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => { export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
const { isRegistered, isInCall, makeCall } = useSip(); const { isRegistered, isInCall } = useSip();
const [dialing, setDialing] = useState(false); const [dialing, setDialing] = useState(false);
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const handleDial = async () => { const handleDial = async () => {
setDialing(true); setDialing(true);
@@ -25,13 +28,21 @@ export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCa
// Show call UI immediately // Show call UI immediately
setCallState('ringing-out'); setCallState('ringing-out');
setCallerNumber(phoneNumber); setCallerNumber(phoneNumber);
setOutboundPending(true);
// Safety: reset flag if SIP INVITE doesn't arrive within 30s
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
try { try {
// Direct SIP call from browser const result = await apiClient.post<{ ucid?: string; status?: string }>('/api/ozonetel/dial', { phoneNumber });
makeCall(phoneNumber); if (result?.ucid) {
setCallUcid(result.ucid);
}
} catch { } catch {
clearTimeout(safetyTimer);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error('Dial Failed', 'Could not place the call'); notify.error('Dial Failed', 'Could not place the call');
} finally { } finally {
setDialing(false); setDialing(false);