import { useState, useRef, useEffect, useMemo } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare, faPhoneArrowRight, faRecordVinyl, faClipboardQuestion, } from '@fortawesome/pro-duotone-svg-icons'; import { useData } from '@/providers/data-provider'; import { Button } from '@/components/base/buttons/button'; import { Badge } from '@/components/base/badges/badges'; import { useSetAtom } from 'jotai'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state'; import { setOutboundPending } from '@/state/sip-manager'; import { useSip } from '@/providers/sip-provider'; import { DispositionModal } from './disposition-modal'; import type { CallAction } from './disposition-modal'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; import { AppointmentForm } from './appointment-form'; import { TransferDialog } from './transfer-dialog'; import { EnquiryForm } from './enquiry-form'; import { formatPhone, formatShortDate } from '@/lib/format'; import { apiClient } from '@/lib/api-client'; import { useAuth } from '@/providers/auth-provider'; import { useAgentState } from '@/hooks/use-agent-state'; import { cx } from '@/utils/cx'; import { notify } from '@/lib/toast'; import type { Lead, CallDisposition } from '@/types/entities'; interface ActiveCallCardProps { lead: Lead | null; callerPhone: string; missedCallId?: string | null; onCallComplete?: () => void; } const formatDuration = (seconds: number): string => { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, '0')}`; }; export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => { const { user } = useAuth(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip(); const setCallState = useSetAtom(sipCallStateAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallUcid = useSetAtom(sipCallUcidAtom); const [appointmentOpen, setAppointmentOpen] = useState(false); // Which existing appointment is being edited (null = creating a new one). // The Book Appt drawer shows pills: [+ New] + one per upcoming appointment. // Clicking Edit on a pill sets this; clicking + New clears it. const [editingApptId, setEditingApptId] = useState(null); const [transferOpen, setTransferOpen] = useState(false); const [recordingPaused, setRecordingPaused] = useState(false); const [enquiryOpen, setEnquiryOpen] = useState(false); const [dispositionOpen, setDispositionOpen] = useState(false); const [callerDisconnected, setCallerDisconnected] = useState(false); // Actions actually recorded during this call. Drives the disposition // modal's priority-lock: if the agent booked an appointment and logged // an enquiry, both badges render and the primary disposition is // locked to APPOINTMENT_BOOKED. const [actionsTaken, setActionsTaken] = useState([]); const addActions = (...newActions: CallAction[]) => { setActionsTaken((prev) => { const next = new Set(prev); for (const a of newActions) next.add(a); return Array.from(next); }); }; // Upcoming appointments for this caller (if returning patient) — drives // the pill row above AppointmentForm so the agent can edit existing // bookings in addition to creating new ones. const { appointments } = useData(); const leadAppointments = useMemo(() => { const patientId = (lead as any)?.patientId; if (!patientId) return []; const now = Date.now(); return appointments .filter((a) => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW' && a.appointmentStatus !== 'COMPLETED' // Only future appointments make sense as reschedule targets. // Past ones can't be edited — they already happened. && a.scheduledAt && new Date(a.scheduledAt).getTime() >= now, ) .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()); }, [appointments, lead]); const editingAppt = useMemo( () => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null), [leadAppointments, editingApptId], ); // Pending pill click awaiting the reschedule-confirm modal. When the // agent clicks a pill, we store the appointment id here + open the modal. // Yes → promote to editingApptId in edit mode. No → promote in view mode. const [pendingApptId, setPendingApptId] = useState(null); const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit'); const agentConfig = localStorage.getItem('helix_agent_config'); const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const { supervisorPresence } = useAgentState(agentIdForState); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const wasAnsweredRef = useRef(callState === 'active'); useEffect(() => { console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Sync UCID to localStorage so sendBeacon can fire auto-dispose on page refresh. // Cleared on disposition submit (handleDisposition below) or when call resets to idle. useEffect(() => { if (callUcid) { localStorage.setItem('helix_active_ucid', callUcid); } return () => { // Don't clear on unmount if disposition hasn't fired — the // beforeunload handler in SipProvider needs it }; }, [callUcid]); // Detect caller disconnect: call was active and ended without agent pressing End useEffect(() => { if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { setCallerDisconnected(true); setDispositionOpen(true); } }, [callState, dispositionOpen]); const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); const phone = lead?.contactPhone?.[0]; const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown'; const handleDisposition = async (disposition: CallDisposition, notes: string) => { // Hangup if still connected if (callState === 'active' || callState === 'ringing-out' || callState === 'ringing-in') { hangup(); } // Submit disposition to sidecar if (callUcid) { const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}'); const disposePayload = { ucid: callUcid, disposition, agentId: agentCfg.ozonetelAgentId, callerPhone, direction: callDirectionRef.current, durationSec: callDuration, leadId: lead?.id ?? null, leadName: fullName || null, notes, missedCallId: missedCallId ?? undefined, }; console.log('[DISPOSE] Sending disposition:', JSON.stringify(disposePayload)); apiClient.post('/api/ozonetel/dispose', disposePayload) .then((res) => console.log('[DISPOSE] Response:', JSON.stringify(res))) .catch((err) => console.error('[DISPOSE] Failed:', err)); } else { console.warn('[DISPOSE] No callUcid — skipping disposition'); } // Follow-ups are created by the enquiry form (where the agent picks // the date + context). No second creation here — that was causing // duplicate entries on every FOLLOW_UP_SCHEDULED call. // Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback localStorage.removeItem('helix_active_ucid'); notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); handleReset(); }; const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => { setAppointmentOpen(false); if (outcome === 'RESCHEDULED') { addActions('RESCHEDULE'); notify.success('Appointment Rescheduled'); } else if (outcome === 'CANCELLED') { addActions('CANCEL'); notify.success('Appointment Cancelled'); } else { addActions('APPOINTMENT'); notify.success('Appointment Booked', 'Payment link will be sent to the patient'); } }; const handleReset = () => { setDispositionOpen(false); setCallerDisconnected(false); setActionsTaken([]); setCallState('idle'); setCallerNumber(null); setCallUcid(null); setOutboundPending(false); onCallComplete?.(); }; // Outbound ringing if (callState === 'ringing-out') { return (

Calling...

{fullName || phoneDisplay}

{fullName &&

{phoneDisplay}

}
); } // Inbound ringing if (callState === 'ringing-in') { return (

Incoming Call

{fullName || phoneDisplay}

{fullName &&

{phoneDisplay}

}
); } // Unanswered call (ringing → ended without ever reaching active) if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { return (

{fullName || 'Missed Call'}

{phoneDisplay} — not answered

); } // Active call if (callState === 'active' || dispositionOpen) { wasAnsweredRef.current = true; return ( <>
{/* Pinned: caller info + controls */}

{fullName || phoneDisplay}

{fullName &&

{phoneDisplay}

}
{supervisorPresence === 'whisper' && ( Supervisor coaching )} {supervisorPresence === 'barge' && ( Supervisor on call )} {formatDuration(callDuration)}
{/* Call controls */}
{/* Scrollable: expanded forms + transfer */} {(appointmentOpen || enquiryOpen || transferOpen) && (
{transferOpen && callUcid && ( setTransferOpen(false)} onTransferred={() => { setTransferOpen(false); // A transfer implies the original agent handed the call // off — treat that as a follow-up action so the // disposition pre-locks to FOLLOW_UP_SCHEDULED. addActions('FOLLOWUP'); setDispositionOpen(true); }} /> )} {appointmentOpen && leadAppointments.length > 0 && (
{leadAppointments.map((appt) => (
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'} {appt.doctorName ?? 'Doctor'}
))}
)} {/* Key forces a full remount when switching between pills (or between edit/view modes) so the form's internal state re-initializes from the new existingAppointment prop instead of staying stuck on the first-mounted values. */} { setAppointmentOpen(open); if (!open) { setEditingApptId(null); setApptMode('edit'); } }} callerNumber={callerPhone} leadName={fullName || null} leadId={lead?.id ?? null} patientId={(lead as any)?.patientId ?? null} readOnly={apptMode === 'view'} existingAppointment={editingAppt ? { id: editingAppt.id, scheduledAt: editingAppt.scheduledAt ?? '', doctorName: editingAppt.doctorName ?? '', doctorId: editingAppt.doctorId ?? undefined, department: editingAppt.department ?? '', clinicId: editingAppt.clinicId ?? undefined, reasonForVisit: editingAppt.reasonForVisit ?? undefined, status: editingAppt.appointmentStatus ?? 'SCHEDULED', } : null} onSaved={(outcome) => { setEditingApptId(null); setApptMode('edit'); handleAppointmentSaved(outcome); }} /> { setEnquiryOpen(false); addActions(...actions); }} />
)}
{/* Reschedule confirm modal — fires when the agent clicks Edit on an upcoming-appointment pill. Yes → open the form in edit mode (fields editable, Save button). No → open in view-only mode (fields disabled, Close button). */} { if (!open) setPendingApptId(null); }} isDismissable > {() => (

Reschedule this appointment?

Choose "Yes, reschedule" to change the date, time, or doctor. Choose "No, just view" to see the details without changing anything.

)}
{/* Disposition Modal — the ONLY path to end a call */} { // Agent wants to continue the call — close modal, call stays active if (!callerDisconnected) { setDispositionOpen(false); } else { // Caller already disconnected — dismiss goes to worklist handleReset(); } }} /> ); } return null; };