import { useState, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPhoneHangup, faCalendarCheck, faCalendarXmark, faCalendarArrowDown, faClipboardCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { Badge } from '@/components/base/badges/badges'; import { TextArea } from '@/components/base/textarea/textarea'; import type { FC } from 'react'; import type { CallDisposition } from '@/types/entities'; import { cx } from '@/utils/cx'; export type CallAction = 'APPOINTMENT' | 'RESCHEDULE' | 'CANCEL' | 'FOLLOWUP' | 'ENQUIRY'; // Maps a recorded action to the disposition it implies. The first action in // the priority list (highest-ranked entry in actionsTaken) becomes the // primary disposition. When any action is present, all other dispositions // are locked out — an agent can't mark a call as "Not Interested" after // they've already booked an appointment. const ACTION_TO_DISPOSITION: Record = { APPOINTMENT: 'APPOINTMENT_BOOKED', RESCHEDULE: 'APPOINTMENT_RESCHEDULED', CANCEL: 'APPOINTMENT_CANCELLED', FOLLOWUP: 'FOLLOW_UP_SCHEDULED', ENQUIRY: 'INFO_PROVIDED', }; const ACTION_META: Record = { APPOINTMENT: { label: 'Appointment booked', icon: faCalendarCheck, color: 'success' }, RESCHEDULE: { label: 'Appointment rescheduled', icon: faCalendarArrowDown, color: 'warning' }, CANCEL: { label: 'Appointment cancelled', icon: faCalendarXmark, color: 'error' }, FOLLOWUP: { label: 'Follow-up scheduled', icon: faClockRotateLeft, color: 'brand' }, ENQUIRY: { label: 'Enquiry logged', icon: faClipboardCheck, color: 'blue-light' }, }; // Priority order — highest-rank action wins when multiple are taken. Booked // > Rescheduled > Cancelled > Follow-up > Enquiry. A cancel inherently means // no booking, so it ranks below booking/rescheduling; but above a follow-up // because cancellation is a definitive outcome on this call. const ACTION_PRIORITY: CallAction[] = ['APPOINTMENT', 'RESCHEDULE', 'CANCEL', 'FOLLOWUP', 'ENQUIRY']; const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => ( ); const dispositionOptions: Array<{ value: CallDisposition; label: string; activeClass: string; defaultClass: string; }> = [ { value: 'APPOINTMENT_BOOKED', label: 'Appointment Booked', activeClass: 'bg-success-solid text-white border-transparent', defaultClass: 'bg-success-primary text-success-primary border-success', }, { value: 'APPOINTMENT_RESCHEDULED', label: 'Appt Rescheduled', activeClass: 'bg-warning-solid text-white border-transparent', defaultClass: 'bg-warning-primary text-warning-primary border-warning', }, { value: 'APPOINTMENT_CANCELLED', label: 'Appt Cancelled', activeClass: 'bg-error-solid text-white border-transparent', defaultClass: 'bg-error-primary text-error-primary border-error', }, { value: 'FOLLOW_UP_SCHEDULED', label: 'Follow-up Needed', activeClass: 'bg-brand-solid text-white border-transparent', defaultClass: 'bg-brand-primary text-brand-secondary border-brand', }, { value: 'INFO_PROVIDED', label: 'Info Provided', activeClass: 'bg-utility-blue-light-600 text-white border-transparent', defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200', }, { value: 'NO_ANSWER', label: 'No Answer', activeClass: 'bg-warning-solid text-white border-transparent', defaultClass: 'bg-warning-primary text-warning-primary border-warning', }, { value: 'WRONG_NUMBER', label: 'Wrong Number', activeClass: 'bg-secondary-solid text-white border-transparent', defaultClass: 'bg-secondary text-secondary border-secondary', }, { value: 'NOT_INTERESTED', label: 'Not Interested', activeClass: 'bg-error-solid text-white border-transparent', defaultClass: 'bg-error-primary text-error-primary border-error', }, { value: 'CALLBACK_REQUESTED', label: 'Callback Requested', activeClass: 'bg-utility-blue-600 text-white border-transparent', defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200', }, { value: 'CALL_DROPPED', label: 'Call Dropped', activeClass: 'bg-secondary-solid text-white border-transparent', defaultClass: 'bg-secondary text-secondary border-secondary', }, ]; type DispositionModalProps = { isOpen: boolean; callerName: string; callerDisconnected: boolean; // True once the call reached the active (answered) state. When false, // the customer never picked up — only no-answer dispositions are // valid; conversation-implying ones (Info Provided, Appointment // Booked, Follow-up, Not Interested) are disabled. Defaults to // true so existing callers don't accidentally lock everything out. callAnswered?: boolean; // Actions actually performed during the call (appointment booked, enquiry // logged, follow-up scheduled). Drives the priority-based disposition // lock — when any action is present, the primary disposition is forced // and the other options are disabled. actionsTaken?: CallAction[]; onSubmit: (disposition: CallDisposition, notes: string) => void; onDismiss?: () => void; }; // Dispositions that only make sense when the customer actually connected. // Selecting these on an unanswered call would misrepresent SLA and // conversation metrics. const ANSWERED_ONLY_DISPOSITIONS: ReadonlySet = new Set([ 'INFO_PROVIDED', 'APPOINTMENT_BOOKED', 'APPOINTMENT_RESCHEDULED', 'APPOINTMENT_CANCELLED', 'FOLLOW_UP_SCHEDULED', 'NOT_INTERESTED', ]); export const DispositionModal = ({ isOpen, callerName, callerDisconnected, callAnswered = true, actionsTaken, onSubmit, onDismiss }: DispositionModalProps) => { const [selected, setSelected] = useState(null); const [notes, setNotes] = useState(''); const appliedLockRef = useRef(undefined); // Rank actionsTaken to pick the primary (highest-priority) action. When // any action is present, that action's disposition becomes locked — // the agent cannot override it to a contradictory outcome. const primaryAction = actionsTaken && actionsTaken.length > 0 ? ACTION_PRIORITY.find((a) => actionsTaken.includes(a)) ?? null : null; const lockedDisposition = primaryAction ? ACTION_TO_DISPOSITION[primaryAction] : null; // Apply the lock once per open — agent can still re-select the same // option, but switching to another value is prevented in the click handler. if (isOpen && lockedDisposition && appliedLockRef.current !== lockedDisposition) { appliedLockRef.current = lockedDisposition; setSelected(lockedDisposition); } const handleSubmit = () => { if (selected === null) return; onSubmit(selected, notes); setSelected(null); setNotes(''); appliedLockRef.current = undefined; }; return ( { if (!open && onDismiss) onDismiss(); }} > {() => (
{/* Header */}

{callerDisconnected ? 'Call Disconnected' : 'End Call'}

{callerDisconnected ? `${callerName} disconnected. What was the outcome?` : `Select a reason to end the call with ${callerName}.` }

{/* Disposition options */}
{actionsTaken && actionsTaken.length > 0 && (
Actions taken on this call
{ACTION_PRIORITY.filter((a) => actionsTaken.includes(a)).map((action) => { const meta = ACTION_META[action]; return ( {meta.label} ); })}
)}
{dispositionOptions.map((option) => { const isSelected = selected === option.value; // Two reasons an option can be disabled: // (1) action lock — the agent already booked / scheduled // something, so only the matching disposition is valid. // (2) unanswered call — dispositions that imply the customer // actually spoke with the agent (Info Provided, etc.) // are disabled to prevent SLA-gaming. const isLockedOut = lockedDisposition !== null && option.value !== lockedDisposition; const isAnsweredOnlyBlocked = !callAnswered && ANSWERED_ONLY_DISPOSITIONS.has(option.value); const isDisabled = isLockedOut || isAnsweredOnlyBlocked; return ( ); })}