mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
feat: telephony overhaul + appointment availability + Force Ready
Telephony: - Track UCID from SIP headers and ManualDial response - Submit disposition to Ozonetel via Set Disposition API (ends ACW) - Fix outboundPending flag lifecycle to prevent inbound poisoning - Fix render order: post-call UI takes priority over active state - Pre-select disposition when appointment booked during call Appointment form: - Convert from slideout to inline collapsible below call card - Fetch real doctors from platform, filter by department - Show time slot availability grid (booked slots greyed + strikethrough) - Double-check availability before booking - Support edit and cancel existing appointments UI: - Add Force Ready button to profile menu (logout+login to clear ACW) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
||||
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
|
||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
||||
// Capture direction at mount — survives through disposition stage
|
||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||
|
||||
@@ -94,7 +95,12 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
const handleAppointmentSaved = () => {
|
||||
setAppointmentOpen(false);
|
||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||
setPostCallStage('done');
|
||||
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
|
||||
if (callState === 'active') {
|
||||
setAppointmentBookedDuringCall(true);
|
||||
} else {
|
||||
setPostCallStage('done');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -157,52 +163,8 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Active call
|
||||
if (callState === 'active') {
|
||||
return (
|
||||
<div className="rounded-xl border border-brand bg-primary 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>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
|
||||
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
|
||||
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
|
||||
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
|
||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
||||
</div>
|
||||
|
||||
{/* Appointment form accessible during call */}
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Call ended — show disposition
|
||||
if (callState === 'ended' || callState === 'failed' || postCallStage !== null) {
|
||||
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
|
||||
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
|
||||
// Done state
|
||||
if (postCallStage === 'done') {
|
||||
return (
|
||||
@@ -255,7 +217,51 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
|
||||
</div>
|
||||
</div>
|
||||
<DispositionForm onSubmit={handleDisposition} />
|
||||
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active call
|
||||
if (callState === 'active') {
|
||||
return (
|
||||
<div className="rounded-xl border border-brand bg-primary 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>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
|
||||
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
|
||||
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
|
||||
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
|
||||
<Button size="sm" color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
|
||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
||||
</div>
|
||||
|
||||
{/* Appointment form accessible during call */}
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user