mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Bug 558: Appointment edit view persisted in Patient 360 after Back to Worklist. Closed as not-a-bug — the edit flow now lives inside the unified Book Appt drawer, so the same button opens either path. Rename makes the intent explicit: - 'New Appt' when the caller has no upcoming appointments - 'New / Reschedule Appt' when upcoming appointments exist (pills inside the drawer let the agent pick which one to reschedule)
557 lines
31 KiB
TypeScript
557 lines
31 KiB
TypeScript
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<string | null>(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<CallAction[]>([]);
|
|
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<string | null>(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 (
|
|
<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">{fullName || '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>
|
|
<div className="flex items-center gap-2">
|
|
{supervisorPresence === 'whisper' && (
|
|
<Badge size="sm" color="blue" type="pill-color">Supervisor coaching</Badge>
|
|
)}
|
|
{supervisorPresence === 'barge' && (
|
|
<Badge size="sm" color="brand" type="pill-color">Supervisor on call</Badge>
|
|
)}
|
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
|
</div>
|
|
</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} />}
|
|
isDisabled={!wasAnsweredRef.current}
|
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
|
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
|
</Button>
|
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
|
isDisabled={!wasAnsweredRef.current}
|
|
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} />}
|
|
isDisabled={!wasAnsweredRef.current}
|
|
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);
|
|
// 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 && (
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingApptId(null)}
|
|
className={cx(
|
|
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
|
|
!editingApptId
|
|
? 'border-brand bg-brand-primary text-brand-secondary'
|
|
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
|
|
)}
|
|
>
|
|
<FontAwesomeIcon icon={faPlus} className="size-3" />
|
|
New
|
|
</button>
|
|
{leadAppointments.map((appt) => (
|
|
<div
|
|
key={appt.id}
|
|
className={cx(
|
|
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
|
|
editingApptId === appt.id
|
|
? 'border-brand bg-brand-primary'
|
|
: 'border-secondary bg-primary',
|
|
)}
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="font-semibold text-primary">
|
|
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
|
|
</span>
|
|
<span className="text-[11px] text-tertiary">
|
|
{appt.doctorName ?? 'Doctor'}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingApptId(appt.id)}
|
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
|
|
>
|
|
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
|
|
Edit
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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. */}
|
|
<AppointmentForm
|
|
key={`${editingApptId ?? 'new'}-${apptMode}`}
|
|
isOpen={appointmentOpen}
|
|
onOpenChange={(open) => {
|
|
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);
|
|
}}
|
|
/>
|
|
|
|
<EnquiryForm
|
|
isOpen={enquiryOpen}
|
|
onOpenChange={setEnquiryOpen}
|
|
callerPhone={callerPhone}
|
|
leadName={fullName || null}
|
|
leadId={lead?.id ?? null}
|
|
patientId={(lead as any)?.patientId ?? null}
|
|
agentName={user.name}
|
|
onSaved={(actions) => {
|
|
setEnquiryOpen(false);
|
|
addActions(...actions);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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). */}
|
|
<ModalOverlay
|
|
isOpen={pendingApptId !== null}
|
|
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
|
|
isDismissable
|
|
>
|
|
<Modal className="sm:max-w-md">
|
|
<Dialog>
|
|
{() => (
|
|
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
|
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
|
<p className="text-sm text-tertiary">
|
|
Choose "Yes, reschedule" to change the date, time, or doctor.
|
|
Choose "No, just view" to see the details without changing anything.
|
|
</p>
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<Button
|
|
size="sm"
|
|
color="secondary"
|
|
onClick={() => {
|
|
if (pendingApptId) {
|
|
setEditingApptId(pendingApptId);
|
|
setApptMode('view');
|
|
setPendingApptId(null);
|
|
}
|
|
}}
|
|
>
|
|
No, just view
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
color="primary"
|
|
onClick={() => {
|
|
if (pendingApptId) {
|
|
setEditingApptId(pendingApptId);
|
|
setApptMode('edit');
|
|
setPendingApptId(null);
|
|
}
|
|
}}
|
|
>
|
|
Yes, reschedule
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Dialog>
|
|
</Modal>
|
|
</ModalOverlay>
|
|
|
|
{/* Disposition Modal — the ONLY path to end a call */}
|
|
<DispositionModal
|
|
isOpen={dispositionOpen}
|
|
callerName={fullName || phoneDisplay}
|
|
callerDisconnected={callerDisconnected}
|
|
// wasAnsweredRef only flips true once callState reaches
|
|
// 'active'. Outbound callbacks that never connect keep
|
|
// this false, which narrows the disposition options to
|
|
// no-answer outcomes and prevents SLA-gaming dispositions
|
|
// like Info Provided on a call the customer never took.
|
|
callAnswered={wasAnsweredRef.current}
|
|
actionsTaken={actionsTaken}
|
|
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;
|
|
};
|