diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 6f6df65..dc1aa55 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -12,6 +12,7 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/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 { AppointmentForm } from './appointment-form'; import { TransferDialog } from './transfer-dialog'; import { EnquiryForm } from './enquiry-form'; @@ -48,7 +49,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const [enquiryOpen, setEnquiryOpen] = useState(false); const [dispositionOpen, setDispositionOpen] = useState(false); const [callerDisconnected, setCallerDisconnected] = useState(false); - const [suggestedDisposition, setSuggestedDisposition] = useState(null); + // 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); + }); + }; const agentConfig = localStorage.getItem('helix_agent_config'); const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; @@ -104,6 +116,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete direction: callDirectionRef.current, durationSec: callDuration, leadId: lead?.id ?? null, + leadName: fullName || null, notes, missedCallId: missedCallId ?? undefined, }; @@ -115,24 +128,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete 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'); - } - } + // 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'); @@ -141,15 +139,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete handleReset(); }; - const handleAppointmentSaved = () => { + const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => { setAppointmentOpen(false); - setSuggestedDisposition('APPOINTMENT_BOOKED'); - notify.success('Appointment Booked', 'Payment link will be sent to the patient'); + 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); @@ -213,7 +220,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete return (
-

Missed Call

+

{fullName || 'Missed Call'}

{phoneDisplay} — not answered

@@ -355,7 +364,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete isOpen={dispositionOpen} callerName={fullName || phoneDisplay} callerDisconnected={callerDisconnected} - defaultDisposition={suggestedDisposition} + // 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 diff --git a/src/components/call-desk/agent-status-toggle.tsx b/src/components/call-desk/agent-status-toggle.tsx index 90ae72f..44150a3 100644 --- a/src/components/call-desk/agent-status-toggle.tsx +++ b/src/components/call-desk/agent-status-toggle.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons'; +import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons'; import { useAgentState } from '@/hooks/use-agent-state'; import type { OzonetelState } from '@/hooks/use-agent-state'; import { apiClient } from '@/lib/api-client'; @@ -50,6 +50,15 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu console.log('[AGENT-STATE] Ready response:', JSON.stringify(res)); } else { const pauseReason = newStatus === 'break' ? 'Break' : 'Training'; + // Ozonetel rejects Pause→Pause (Break↔Training) — the agent must + // transit through Ready. Insert a Ready hop whenever we're + // moving between two paused sub-states. + const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training'; + if (isPauseToPause) { + console.log(`[AGENT-STATE] ${ozonetelState}→${newStatus}: sending Ready first, then Pause(${pauseReason})`); + await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' }); + await new Promise(resolve => setTimeout(resolve, 400)); + } console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`); const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason }); console.log('[AGENT-STATE] Pause response:', JSON.stringify(res)); @@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu disabled={changing || !canToggle} className={cx( 'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear', - canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default', - changing && 'opacity-50', + canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default', )} > - - {current.label} - {canToggle && } + {changing ? ( + + ) : ( + + )} + + {changing ? 'Changing…' : current.label} + + {canToggle && !changing && } {menuOpen && ( diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index 1a9c887..485b055 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -29,7 +29,10 @@ type AppointmentFormProps = { leadName?: string | null; leadId?: string | null; patientId?: string | null; - onSaved?: () => void; + // Called after a successful save. Passes back what actually happened so + // the parent can pre-lock the disposition (BOOKED vs RESCHEDULED vs + // CANCELLED each map to distinct disposition outcomes). + onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void; existingAppointment?: ExistingAppointment | null; }; @@ -241,7 +244,9 @@ export const AppointmentForm = ({ const selectedDoctor = doctors.find(d => d.id === doctor); if (isEditMode && existingAppointment) { - // Update existing appointment + // Update existing appointment. Flip status to RESCHEDULED so + // the Appointments > Rescheduled tab reflects it and the + // patient timeline records the reschedule event. await apiClient.graphql( `mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } @@ -254,9 +259,32 @@ export const AppointmentForm = ({ department: selectedDoctor?.department ?? '', doctorId: doctor, reasonForVisit: chiefComplaint || null, + status: 'RESCHEDULED', }, }, ); + + // Propagate name change during reschedule. Same gate as the + // create branch — nameChanged implies isNameEditable=true, + // which means the agent went through EditPatientConfirmModal. + const trimmedName = patientName.trim(); + const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; + if (nameChanged) { + const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' }; + if (patientId) { + await apiClient.graphql( + `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, + { id: patientId, data: { fullName: nameParts } }, + ).catch((err: unknown) => console.warn('Failed to update patient name:', err)); + } + if (leadId) { + await apiClient.graphql( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, + { id: leadId, data: { contactName: nameParts } }, + ).catch((err: unknown) => console.warn('Failed to update lead name:', err)); + } + } + notify.success('Appointment Updated'); } else { // If no patient record exists yet (new caller), create one now @@ -271,9 +299,16 @@ export const AppointmentForm = ({ const phoneDigits = callerNumber.replace(/\D/g, '').slice(-10); const phoneE164 = `+91${phoneDigits}`; try { + const patientData: Record = { + fullName: nameParts, + phones: { primaryPhoneNumber: phoneE164 }, + patientType: 'NEW', + }; + if (age) patientData.dateOfBirth = new Date(Date.now() - parseInt(age) * 365.25 * 86400000).toISOString().split('T')[0]; + if (gender) patientData.gender = gender.toUpperCase(); const created = await apiClient.graphql<{ createPatient: { id: string } }>( `mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`, - { data: { fullName: nameParts, phones: { primaryPhoneNumber: phoneE164 }, patientType: 'NEW' } }, + { data: patientData }, ); resolvedPatientId = created.createPatient.id; } catch (err) { @@ -282,24 +317,26 @@ export const AppointmentForm = ({ } // Create appointment + const appointmentData: Record = { + scheduledAt, + durationMin: 30, + appointmentType: 'CONSULTATION', + status: 'SCHEDULED', + doctorName: selectedDoctor?.name ?? '', + department: selectedDoctor?.department ?? '', + doctorId: doctor, + reasonForVisit: chiefComplaint || null, + ...(resolvedPatientId ? { patientId: resolvedPatientId } : {}), + ...(clinic ? { clinicId: clinic } : {}), + ...(agentNotes ? { agentNotes } : {}), + ...(source ? { source } : {}), + }; + console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData)); await apiClient.graphql( `mutation CreateAppointment($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, - { - data: { - scheduledAt, - durationMin: 30, - appointmentType: 'CONSULTATION', - status: 'SCHEDULED', - doctorName: selectedDoctor?.name ?? '', - department: selectedDoctor?.department ?? '', - doctorId: doctor, - reasonForVisit: chiefComplaint || null, - ...(resolvedPatientId ? { patientId: resolvedPatientId } : {}), - ...(clinic ? { clinicId: clinic } : {}), - }, - }, + { data: appointmentData }, ); // Determine whether the agent actually renamed the patient. @@ -309,11 +346,13 @@ export const AppointmentForm = ({ const trimmedName = patientName.trim(); const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; - // Update patient name only when it was empty (new caller with no name). - // Don't overwrite an existing patient name — that would - // retroactively change the name on all past appointments. - // Bug #527: only set name on patients with no existing name. - if (nameChanged && patientId && initialLeadName.length === 0) { + // Update patient name when the agent explicitly renamed. + // `nameChanged` already requires isNameEditable=true (the + // agent went through EditPatientConfirmModal), so the + // rename intent is unambiguous. Bug #527's silent-overwrite + // case can no longer happen because the confirm modal + // gates the input. + if (nameChanged && patientId) { const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' }; apiClient.graphql( `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, @@ -346,21 +385,14 @@ export const AppointmentForm = ({ // If the agent actually renamed the patient, kick off the // side-effect chain: regenerate the AI summary against the - // corrected identity AND invalidate the Redis caller - // resolution cache so the next incoming call from this - // phone picks up fresh data. Both are fire-and-forget — - // the save toast fires immediately either way. + // corrected identity. Fire-and-forget; the save toast + // fires immediately regardless. if (nameChanged && leadId) { apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {}); - } else if (callerNumber) { - // No rename but still invalidate the cache so status + - // lastContacted updates propagate cleanly to the next - // lookup. - apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {}); } } - onSaved?.(); + onSaved?.(isEditMode ? 'RESCHEDULED' : 'BOOKED'); } catch (err) { console.error('Failed to save appointment:', err); setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.'); @@ -383,7 +415,7 @@ export const AppointmentForm = ({ }, ); notify.success('Appointment Cancelled'); - onSaved?.(); + onSaved?.('CANCELLED'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to cancel appointment'); } finally { diff --git a/src/components/call-desk/call-log.tsx b/src/components/call-desk/call-log.tsx index 54b8d28..c98e112 100644 --- a/src/components/call-desk/call-log.tsx +++ b/src/components/call-desk/call-log.tsx @@ -14,11 +14,14 @@ interface CallLogProps { const dispositionConfig: Record = { APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' }, + APPOINTMENT_RESCHEDULED: { label: 'Rescheduled', color: 'warning' }, + APPOINTMENT_CANCELLED: { label: 'Cancelled', color: 'error' }, FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, INFO_PROVIDED: { label: 'Info', color: 'blue-light' }, NO_ANSWER: { label: 'No Answer', color: 'warning' }, WRONG_NUMBER: { label: 'Wrong #', color: 'gray' }, - CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' }, + NOT_INTERESTED: { label: 'Not Interested', color: 'error' }, + CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' }, }; const formatDuration = (seconds: number | null): string => { diff --git a/src/components/call-desk/disposition-form.tsx b/src/components/call-desk/disposition-form.tsx index c0ddf66..e6e3a5f 100644 --- a/src/components/call-desk/disposition-form.tsx +++ b/src/components/call-desk/disposition-form.tsx @@ -20,6 +20,18 @@ const dispositionOptions: Array<{ activeClass: 'bg-success-solid text-white ring-transparent', defaultClass: 'bg-success-primary text-success-primary border-success', }, + { + value: 'APPOINTMENT_RESCHEDULED', + label: 'Appt Rescheduled', + activeClass: 'bg-warning-solid text-white ring-transparent', + defaultClass: 'bg-warning-primary text-warning-primary border-warning', + }, + { + value: 'APPOINTMENT_CANCELLED', + label: 'Appt Cancelled', + activeClass: 'bg-error-solid text-white ring-transparent', + defaultClass: 'bg-error-primary text-error-primary border-error', + }, { value: 'FOLLOW_UP_SCHEDULED', label: 'Follow-up Needed', @@ -45,11 +57,17 @@ const dispositionOptions: Array<{ defaultClass: 'bg-secondary text-secondary border-secondary', }, { - value: 'CALLBACK_REQUESTED', + value: 'NOT_INTERESTED', label: 'Not Interested', activeClass: 'bg-error-solid text-white ring-transparent', defaultClass: 'bg-error-primary text-error-primary border-error', }, + { + value: 'CALLBACK_REQUESTED', + label: 'Callback Requested', + activeClass: 'bg-utility-blue-600 text-white ring-transparent', + defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200', + }, ]; export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => { diff --git a/src/components/call-desk/disposition-modal.tsx b/src/components/call-desk/disposition-modal.tsx index 86925a7..a0d01e3 100644 --- a/src/components/call-desk/disposition-modal.tsx +++ b/src/components/call-desk/disposition-modal.tsx @@ -1,13 +1,43 @@ import { useState, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons'; +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 }) => ( ); @@ -24,6 +54,18 @@ const dispositionOptions: Array<{ 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', @@ -49,31 +91,68 @@ const dispositionOptions: Array<{ defaultClass: 'bg-secondary text-secondary border-secondary', }, { - value: 'CALLBACK_REQUESTED', + 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', + }, ]; type DispositionModalProps = { isOpen: boolean; callerName: string; callerDisconnected: boolean; - defaultDisposition?: CallDisposition | null; + // 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; }; -export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => { +// 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 appliedDefaultRef = useRef(undefined); + const appliedLockRef = useRef(undefined); - // Pre-select when modal opens with a suggestion - if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) { - appliedDefaultRef.current = defaultDisposition; - setSelected(defaultDisposition); + // 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 = () => { @@ -81,11 +160,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau onSubmit(selected, notes); setSelected(null); setNotes(''); - appliedDefaultRef.current = undefined; + appliedLockRef.current = undefined; }; return ( - { if (!open && onDismiss) onDismiss(); }}> + { if (!open && onDismiss) onDismiss(); }} + > {() => ( @@ -108,16 +196,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau {/* 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 (
- - - {followUpNeeded && ( - - )} +
+ + {followUpNeeded && ( +
+ +
+ )} +
{error && (
{error}
diff --git a/src/components/call-desk/incoming-call-card.tsx b/src/components/call-desk/incoming-call-card.tsx index 8181c82..36b6103 100644 --- a/src/components/call-desk/incoming-call-card.tsx +++ b/src/components/call-desk/incoming-call-card.tsx @@ -51,11 +51,14 @@ const ActivityIcon = ({ type }: { type: string }) => { const dispositionLabels: Record = { APPOINTMENT_BOOKED: 'Appointment Booked', + APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled', + APPOINTMENT_CANCELLED: 'Appointment Cancelled', FOLLOW_UP_SCHEDULED: 'Follow-up Needed', INFO_PROVIDED: 'Info Provided', NO_ANSWER: 'No Answer', WRONG_NUMBER: 'Wrong Number', - CALLBACK_REQUESTED: 'Not Interested', + NOT_INTERESTED: 'Not Interested', + CALLBACK_REQUESTED: 'Callback Requested', }; export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => { diff --git a/src/components/call-desk/transfer-dialog.tsx b/src/components/call-desk/transfer-dialog.tsx index 3af5116..79df1eb 100644 --- a/src/components/call-desk/transfer-dialog.tsx +++ b/src/components/call-desk/transfer-dialog.tsx @@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }: const fetchTargets = async () => { try { const [agentsRes, doctorsRes] = await Promise.all([ - apiClient.graphql(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`), + apiClient.graphql(`{ agents(first: 20) { edges { node { id name ozonetelAgentId sipExtension } } } }`), apiClient.graphql(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`), ]); const agents: TransferTarget[] = (agentsRes.agents?.edges ?? []) .map((e: any) => e.node) - .filter((a: any) => a.ozonetelagentid !== currentAgentId) + .filter((a: any) => a.ozonetelAgentId !== currentAgentId) .map((a: any) => ({ id: a.id, name: a.name, type: 'agent' as const, - phoneNumber: `0${a.sipextension}`, + phoneNumber: `0${a.sipExtension}`, status: 'offline' as const, })); diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index e12e4bf..69138ca 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -36,6 +36,8 @@ type WorklistFollowUp = { followUpStatus: string | null; scheduledAt: string | null; priority: string | null; + patientName?: string; + patientPhone?: string; }; type MissedCall = { @@ -45,11 +47,12 @@ type MissedCall = { callerNumber: { number: string; callingCode: string }[] | null; startedAt: string | null; leadId: string | null; + leadName: string | null; disposition: string | null; - callbackstatus: string | null; - callsourcenumber: string | null; - missedcallcount: number | null; - callbackattemptedat: string | null; + callbackStatus: string | null; + callSourceNumber: string | null; + missedCallCount: number | null; + callbackAttemptedAt: string | null; }; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; @@ -107,7 +110,9 @@ const followUpLabel: Record = { REVIEW_REQUEST: 'Review', }; -const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { +// SLA for reactive work — missed calls / unanswered leads. Measures time +// elapsed since the trigger: longer wait = worse SLA. +const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); if (minutes < 1) return { label: '<1m', color: 'success' }; if (minutes < 15) return { label: `${minutes}m`, color: 'success' }; @@ -118,6 +123,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni return { label: `${Math.floor(hours / 24)}d`, color: 'error' }; }; +// SLA for scheduled work — follow-ups / callbacks. Measures time remaining +// until the scheduled slot. Green when comfortably ahead, warning when +// due soon, error when overdue. +const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => { + const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000); + if (minutes < 0) { + const overdueMins = -minutes; + if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' }; + const overdueHrs = Math.floor(overdueMins / 60); + if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' }; + return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' }; + } + if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' }; + return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' }; +}; + +const computeSla = ( + row: Pick, +): { label: string; color: 'success' | 'warning' | 'error' } => { + if (row.type === 'follow-up' || row.type === 'callback') { + // scheduledAt was written into lastContactedAt during row construction. + return computeScheduledSla(row.lastContactedAt ?? row.createdAt); + } + return computeReactiveSla(row.lastContactedAt ?? row.createdAt); +}; + const formatTimeAgo = (dateStr: string): string => { const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000); if (minutes < 1) return 'Just now'; @@ -150,13 +183,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea for (const call of missedCalls) { const phone = call.callerNumber?.[0]; - const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : ''; - const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : ''; + const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : ''; + const sourceSuffix = call.callSourceNumber ? ` • ${call.callSourceNumber}` : ''; rows.push({ id: `mc-${call.id}`, type: 'missed', priority: 'HIGH', - name: (phone ? formatPhone(phone) : 'Unknown') + countBadge, + name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge, phone: phone ? formatPhone(phone) : '', phoneRaw: phone?.number ?? '', direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound', @@ -165,12 +198,12 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea ? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}` : 'Missed call', createdAt: call.createdAt, - taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', + taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', leadId: call.leadId, originalLead: null, - lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt, + lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt, contactAttempts: 0, - source: call.callsourcenumber ?? null, + source: call.callSourceNumber ?? null, lastDisposition: call.disposition ?? null, missedCallId: call.id, }); @@ -179,13 +212,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea for (const fu of followUps) { const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date()); const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up'; + // Sidecar enriches follow-ups with patient name/phone when a + // patientId is linked. Fall back to the generic type label when + // no patient is attached. + const displayName = fu.patientName?.trim() || label; + const phoneFormatted = fu.patientPhone + ? formatPhone({ number: fu.patientPhone, callingCode: '+91' }) + : ''; rows.push({ id: `fu-${fu.id}`, type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up', priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'), - name: label, - phone: '', - phoneRaw: '', + name: displayName, + phone: phoneFormatted, + phoneRaw: fu.patientPhone ?? '', direction: null, typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up', reason: fu.scheduledAt @@ -230,8 +270,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea }); } - // Remove rows without a phone number — agent can't act on them - const actionableRows = rows.filter(r => r.phoneRaw); + // Keep all rows — follow-ups may have no phone and still need to be visible. + // The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty. + const actionableRows = rows; // Sort by rules engine score if available, otherwise by priority + createdAt actionableRows.sort((a, b) => { @@ -249,13 +290,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); const [missedSubTab, setMissedSubTab] = useState('pending'); - const [sortDescriptor, setSortDescriptor] = useState({ column: 'sla', direction: 'descending' }); + // Default SLA sort is ascending — the bucket-sorted result puts the + // most-urgent rows at the top (overdue → oldest reactive → soonest due). + const [sortDescriptor, setSortDescriptor] = useState({ column: 'sla', direction: 'ascending' }); const missedByStatus = useMemo(() => ({ - pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus), - attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'), - completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'), - invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'), + pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus), + attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'), + completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'), + invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'), }), [missedCalls]); const allRows = useMemo( @@ -273,7 +316,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect let rows = allRows; if (tab === 'missed') rows = missedSubTabRows; else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead'); - else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up'); + else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback'); if (search.trim()) { const q = search.toLowerCase(); @@ -295,8 +338,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect case 'name': return a.name.localeCompare(b.name) * dir; case 'sla': { + // Mixed SLA sort: SLA means different things by row type + // (elapsed for reactive, remaining for scheduled). Bucket + // rows by urgency, then sort within bucket — Overdue + // first, then reactive (oldest-first), then scheduled + // (soonest-due first). `dir` flips the whole ordering + // so the user can still toggle ascending/descending. + const urgencyBucket = (row: WorklistRow): number => { + const isScheduled = row.type === 'follow-up' || row.type === 'callback'; + if (isScheduled) { + const t = new Date(row.lastContactedAt ?? row.createdAt).getTime(); + return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming + } + return 1; // reactive (missed / lead) + }; + const ba = urgencyBucket(a); + const bb = urgencyBucket(b); + if (ba !== bb) return (ba - bb) * dir; const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime(); const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime(); + // Within a bucket, ascending time = most urgent first + // (oldest overdue, oldest reactive, soonest upcoming). return (ta - tb) * dir; } default: @@ -310,7 +372,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect const missedCount = allRows.filter((r) => r.type === 'missed').length; const leadCount = allRows.filter((r) => r.type === 'lead').length; - const followUpCount = allRows.filter((r) => r.type === 'follow-up').length; + const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length; // Notification for new missed calls const prevMissedCount = useRef(missedCount); @@ -380,7 +442,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect {/* Missed call status sub-tabs */} {tab === 'missed' && (
- {(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => ( + {(['pending', 'attempted'] as MissedSubTab[]).map(sub => (