mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
- Appointment/enquiry forms reverted to inline rendering (not modals) - Forms: flat scrollable section with pinned footer, no card wrapper - Appointment form: DatePicker component, date prefilled, removed Returning Patient checkbox - Enquiry form: removed disposition dropdown, lead status defaults to CONTACTED - Transfer dialog: agent picker with live status, doctor list with department, select-then-connect flow - Transfer: removed external number input, moved Cancel/Connect to pinned header row - Button mutual exclusivity: Book Appt / Enquiry / Transfer close each other - Patient name write-back: appointment + enquiry forms update patient fullName after save - Caller cache invalidation: POST /api/caller/invalidate after name update - Follow-up fix (#513): assignedAgent, patientId, date validation in createFollowUp - Patients page: removed status filters + column, added pagination (15/page) - Pending badge removed from call desk header - Table resize handles visible (bg-tertiary pill) - Sim call button: dev-only (import.meta.env.DEV) - CallControlStrip component (reusable, not currently mounted) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
345 lines
18 KiB
TypeScript
345 lines
18 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import {
|
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
|
faPause, faPlay, faCalendarPlus,
|
|
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
|
} from '@fortawesome/pro-duotone-svg-icons';
|
|
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 { AppointmentForm } from './appointment-form';
|
|
import { TransferDialog } from './transfer-dialog';
|
|
import { EnquiryForm } from './enquiry-form';
|
|
import { formatPhone } from '@/lib/format';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { useAuth } from '@/providers/auth-provider';
|
|
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);
|
|
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);
|
|
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
|
|
|
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
|
|
|
|
// 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 disposePayload = {
|
|
ucid: callUcid,
|
|
disposition,
|
|
callerPhone,
|
|
direction: callDirectionRef.current,
|
|
durationSec: callDuration,
|
|
leadId: lead?.id ?? 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');
|
|
}
|
|
|
|
// Side effects
|
|
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
|
try {
|
|
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
|
|
data: {
|
|
name: `Follow-up — ${fullName || phoneDisplay}`,
|
|
typeCustom: 'CALLBACK',
|
|
status: 'PENDING',
|
|
assignedAgent: null,
|
|
priority: 'NORMAL',
|
|
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
},
|
|
}, { silent: true });
|
|
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
|
|
} catch {
|
|
notify.info('Follow-up', 'Could not auto-create follow-up');
|
|
}
|
|
}
|
|
|
|
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
|
handleReset();
|
|
};
|
|
|
|
const handleAppointmentSaved = () => {
|
|
setAppointmentOpen(false);
|
|
setSuggestedDisposition('APPOINTMENT_BOOKED');
|
|
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setDispositionOpen(false);
|
|
setCallerDisconnected(false);
|
|
setCallState('idle');
|
|
setCallerNumber(null);
|
|
setCallUcid(null);
|
|
setOutboundPending(false);
|
|
onCallComplete?.();
|
|
};
|
|
|
|
// Outbound ringing
|
|
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') {
|
|
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-ping 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 animate-bounce" />
|
|
</div>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</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" onClick={answer}>Answer</Button>
|
|
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Unanswered call (ringing → ended without ever reaching active)
|
|
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
|
return (
|
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
|
<p className="text-sm font-semibold text-primary">Missed Call</p>
|
|
<p className="text-xs text-tertiary mt-1">{phoneDisplay} — not answered</p>
|
|
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
|
Back to Worklist
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Active call
|
|
if (callState === 'active' || dispositionOpen) {
|
|
wasAnsweredRef.current = true;
|
|
return (
|
|
<>
|
|
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
|
{/* Pinned: caller info + controls */}
|
|
<div className="shrink-0 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
|
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
|
|
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
|
</div>
|
|
</div>
|
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
|
</div>
|
|
|
|
{/* Call controls */}
|
|
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
|
<button
|
|
onClick={toggleMute}
|
|
title={isMuted ? 'Unmute' : 'Mute'}
|
|
className={cx(
|
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
|
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
|
)}
|
|
>
|
|
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={toggleHold}
|
|
title={isOnHold ? 'Resume' : 'Hold'}
|
|
className={cx(
|
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
|
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
|
)}
|
|
>
|
|
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const action = recordingPaused ? 'unPause' : 'pause';
|
|
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
|
setRecordingPaused(!recordingPaused);
|
|
}}
|
|
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
|
|
className={cx(
|
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
|
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
|
)}
|
|
>
|
|
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-secondary mx-0.5" />
|
|
|
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
|
|
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
|
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Scrollable: expanded forms + transfer */}
|
|
{(appointmentOpen || enquiryOpen || transferOpen) && (
|
|
<div className="flex flex-col min-h-0 flex-1 border-t border-secondary px-4 pb-4 pt-4">
|
|
{transferOpen && callUcid && (
|
|
<TransferDialog
|
|
ucid={callUcid}
|
|
currentAgentId={JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}').ozonetelAgentId}
|
|
onClose={() => setTransferOpen(false)}
|
|
onTransferred={() => {
|
|
setTransferOpen(false);
|
|
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
|
|
setDispositionOpen(true);
|
|
}}
|
|
/>
|
|
)}
|
|
<AppointmentForm
|
|
isOpen={appointmentOpen}
|
|
onOpenChange={setAppointmentOpen}
|
|
callerNumber={callerPhone}
|
|
leadName={fullName || null}
|
|
leadId={lead?.id ?? null}
|
|
patientId={(lead as any)?.patientId ?? null}
|
|
onSaved={handleAppointmentSaved}
|
|
/>
|
|
|
|
<EnquiryForm
|
|
isOpen={enquiryOpen}
|
|
onOpenChange={setEnquiryOpen}
|
|
callerPhone={callerPhone}
|
|
leadId={lead?.id ?? null}
|
|
patientId={(lead as any)?.patientId ?? null}
|
|
agentName={user.name}
|
|
onSaved={() => {
|
|
setEnquiryOpen(false);
|
|
setSuggestedDisposition('INFO_PROVIDED');
|
|
notify.success('Enquiry Logged');
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Disposition Modal — the ONLY path to end a call */}
|
|
<DispositionModal
|
|
isOpen={dispositionOpen}
|
|
callerName={fullName || phoneDisplay}
|
|
callerDisconnected={callerDisconnected}
|
|
defaultDisposition={suggestedDisposition}
|
|
onSubmit={handleDisposition}
|
|
onDismiss={() => {
|
|
// 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;
|
|
};
|