feat: disposition modal, persistent top bar, pagination, QA fixes

- DispositionModal: single modal for all call endings. Dismissable (agent can resume call).
  Agent clicks End → modal → select reason → hangup + dispose. Caller disconnects → same modal.
- One call screen: CallWidget stripped to ringing notification + auto-redirect to Call Desk.
- Persistent top bar in AppShell: agent status toggle + network indicator on all pages.
- Network indicator always visible (Connected/Unstable/No connection).
- Pagination: Untitled UI PaginationCardDefault on Call History + Appointments (20/page).
- Pinned table headers/footers: sticky column headers, scrollable body, pinned pagination.
  Applied to Call Desk worklist, Call History, Appointments, Call Recordings, Missed Calls.
- "Patient" → "Caller" column label in Call History.
- Offline → Ready toggle enabled.
- Profile status dot reflects Ozonetel state.
- NavAccountCard: popover placement top, View Profile + Account Settings restored.
- WIP pages for /profile and /account-settings.
- Enquiry form PHONE_INQUIRY → PHONE enum fix.
- Force Ready / View Profile / Account Settings removed then restored properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:29:54 +05:30
parent daa2fbb0c2
commit e6b2208077
21 changed files with 645 additions and 816 deletions

View File

@@ -11,7 +11,7 @@ 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 { DispositionForm } from './disposition-form';
import { DispositionModal } from './disposition-modal';
import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form';
@@ -21,8 +21,6 @@ import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities';
type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
interface ActiveCallCardProps {
lead: Lead | null;
callerPhone: string;
@@ -41,22 +39,28 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
const [appointmentOpen, setAppointmentOpen] = useState(false);
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false);
const [enquiryOpen, setEnquiryOpen] = useState(false);
// Capture direction at mount — survives through disposition stage
const [dispositionOpen, setDispositionOpen] = useState(false);
const [callerDisconnected, setCallerDisconnected] = useState(false);
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
// Track if the call was ever answered (reached 'active' state)
const wasAnsweredRef = useRef(callState === 'active');
// Log mount so we can tell which component handled the call
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();
@@ -64,8 +68,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
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 — handles Ozonetel ACW release
// Submit disposition to sidecar
if (callUcid) {
const disposePayload = {
ucid: callUcid,
@@ -85,7 +93,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
console.warn('[DISPOSE] No callUcid — skipping disposition');
}
// Side effects per disposition type
// Side effects
if (disposition === 'FOLLOW_UP_SCHEDULED') {
try {
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
@@ -104,7 +112,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
}
// Disposition is the last step — return to worklist immediately
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
handleReset();
};
@@ -112,13 +119,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleAppointmentSaved = () => {
setAppointmentOpen(false);
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
if (callState === 'active') {
setAppointmentBookedDuringCall(true);
}
};
const handleReset = () => {
setPostCallStage(null);
setDispositionOpen(false);
setCallerDisconnected(false);
setCallState('idle');
setCallerNumber(null);
setCallUcid(null);
@@ -126,7 +131,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onCallComplete?.();
};
// Outbound ringing — agent initiated the call
// Outbound ringing
if (callState === 'ringing-out') {
return (
<div className="rounded-xl bg-brand-primary p-4">
@@ -145,7 +150,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
End Call
Cancel
</Button>
</div>
</div>
@@ -177,8 +182,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
);
}
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) {
// 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" />
@@ -191,149 +196,133 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
);
}
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
// Disposition form + enquiry access
// Active call
if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true;
return (
<>
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
<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-semibold text-primary">Call Ended {fullName || phoneDisplay}</p>
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
{/* 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); }}>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); }}>Enquiry</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => setTransferOpen(!transferOpen)}>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>
{/* Transfer dialog */}
{transferOpen && callUcid && (
<TransferDialog
ucid={callUcid}
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
setDispositionOpen(true);
}}
/>
)}
{/* Appointment form */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved}
/>
{/* Enquiry form */}
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success('Enquiry Logged');
}}
/>
</div>
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success('Enquiry Logged');
{/* Disposition Modal — the ONLY path to end a call */}
<DispositionModal
isOpen={dispositionOpen}
callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected}
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();
}
}}
/>
</>
);
}
// Active call
if (callState === 'active') {
wasAnsweredRef.current = true;
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 items-center gap-1.5">
{/* Icon-only toggles */}
<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" />
{/* Text+Icon primary actions */}
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(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); }}>Enquiry</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
</div>
{/* Transfer dialog */}
{transferOpen && callUcid && (
<TransferDialog
ucid={callUcid}
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
hangup();
setPostCallStage('disposition');
}}
/>
)}
{/* Appointment form accessible during call */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved}
/>
{/* Enquiry form */}
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success('Enquiry Logged');
}}
/>
</div>
);
}
return null;
};

View File

@@ -74,7 +74,7 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
}
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training';
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline';
return (
<div className="relative">

View File

@@ -1,174 +1,41 @@
import { useState, useEffect, useRef } from 'react';
import {
faPhone,
faPhoneArrowDown,
faPhoneArrowUp,
faPhoneHangup,
faPhoneXmark,
faMicrophoneSlash,
faMicrophone,
faPause,
faCircleCheck,
faFloppyDisk,
faCalendarPlus,
} from '@fortawesome/pro-duotone-svg-icons';
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const Phone01 = faIcon(faPhone);
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
const PhoneOutgoing01 = faIcon(faPhoneArrowUp);
const PhoneHangUp = faIcon(faPhoneHangup);
const PhoneX = faIcon(faPhoneXmark);
const MicrophoneOff01 = faIcon(faMicrophoneSlash);
const Microphone01 = faIcon(faMicrophone);
const PauseCircle = faIcon(faPause);
const CheckCircle = faIcon(faCircleCheck);
const Save01 = faIcon(faFloppyDisk);
const CalendarPlus02 = faIcon(faCalendarPlus);
import { Button } from '@/components/base/buttons/button';
import { TextArea } from '@/components/base/textarea/textarea';
import { AppointmentForm } from '@/components/call-desk/appointment-form';
import { useSetAtom } from 'jotai';
import { sipCallStateAtom } from '@/state/sip-state';
import { useSip } from '@/providers/sip-provider';
import { useAuth } from '@/providers/auth-provider';
import { cx } from '@/utils/cx';
import type { CallDisposition } from '@/types/entities';
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, '0');
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
const dispositionOptions: Array<{
value: CallDisposition;
label: string;
activeClass: string;
defaultClass: string;
}> = [
{
value: 'APPOINTMENT_BOOKED',
label: 'Appt Booked',
activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up',
activeClass: 'bg-brand-solid text-white ring-transparent',
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
},
{
value: 'INFO_PROVIDED',
label: 'Info Given',
activeClass: 'bg-utility-blue-light-600 text-white ring-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 ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'WRONG_NUMBER',
label: 'Wrong #',
activeClass: 'bg-secondary-solid text-white ring-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
},
{
value: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
];
// CallWidget is a lightweight floating notification for calls outside the Call Desk.
// It only handles: ringing (answer/decline) + auto-redirect to Call Desk.
// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard.
export const CallWidget = () => {
const {
callState,
callerNumber,
isMuted,
isOnHold,
callDuration,
answer,
reject,
hangup,
toggleMute,
toggleHold,
} = useSip();
const { user } = useAuth();
const { callState, callerNumber, callDuration, answer, reject } = useSip();
const setCallState = useSetAtom(sipCallStateAtom);
const navigate = useNavigate();
const { pathname } = useLocation();
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const [lastDuration, setLastDuration] = useState(0);
const [matchedLead, setMatchedLead] = useState<any>(null);
const [leadActivities, setLeadActivities] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
const callStartTimeRef = useRef<string | null>(null);
// Capture duration right before call ends
// Auto-navigate to Call Desk when a call becomes active or outbound ringing starts
useEffect(() => {
if (callState === 'active' && callDuration > 0) {
setLastDuration(callDuration);
if (pathname === '/call-desk') return;
if (callState === 'active' || callState === 'ringing-out') {
console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`);
navigate('/call-desk');
}
}, [callState, callDuration]);
// Track call start time
useEffect(() => {
if (callState === 'active' && !callStartTimeRef.current) {
callStartTimeRef.current = new Date().toISOString();
}
if (callState === 'idle') {
callStartTimeRef.current = null;
}
}, [callState]);
// Look up caller when call becomes active
useEffect(() => {
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
const lookup = async () => {
try {
const { apiClient } = await import('@/lib/api-client');
const token = apiClient.getStoredToken();
if (!token) return;
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const res = await fetch(`${API_URL}/api/call/lookup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ phoneNumber: callerNumber }),
});
const data = await res.json();
if (data.matched && data.lead) {
setMatchedLead(data.lead);
setLeadActivities(data.activities ?? []);
}
} catch (err) {
console.warn('Lead lookup failed:', err);
}
};
lookup();
}
}, [callState, callerNumber]);
// Reset state when returning to idle
useEffect(() => {
if (callState === 'idle') {
setDisposition(null);
setNotes('');
setMatchedLead(null);
setLeadActivities([]);
}
}, [callState]);
}, [callState, pathname, navigate]);
// Auto-dismiss ended/failed state after 3 seconds
useEffect(() => {
@@ -181,127 +48,35 @@ export const CallWidget = () => {
}
}, [callState, setCallState]);
const handleSaveAndClose = async () => {
if (!disposition) return;
console.log(`[CALL-WIDGET] Save & Close: disposition=${disposition} lead=${matchedLead?.id ?? 'none'}`);
setIsSaving(true);
try {
const { apiClient } = await import('@/lib/api-client');
// 1. Create Call record on platform
await apiClient.graphql(
`mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id }
}`,
{
data: {
callDirection: 'INBOUND',
callStatus: 'COMPLETED',
agentName: user.name,
startedAt: callStartTimeRef.current,
endedAt: new Date().toISOString(),
durationSeconds: callDuration,
disposition,
callNotes: notes || null,
leadId: matchedLead?.id ?? null,
},
},
).catch(err => console.warn('Failed to create call record:', err));
// 2. Update lead status if matched
if (matchedLead?.id) {
const statusMap: Partial<Record<string, string>> = {
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
FOLLOW_UP_SCHEDULED: 'CONTACTED',
INFO_PROVIDED: 'CONTACTED',
NO_ANSWER: 'CONTACTED',
WRONG_NUMBER: 'LOST',
CALLBACK_REQUESTED: 'CONTACTED',
NOT_INTERESTED: 'LOST',
};
const newStatus = statusMap[disposition];
if (newStatus) {
await apiClient.graphql(
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
id: matchedLead.id,
data: {
leadStatus: newStatus,
lastContactedAt: new Date().toISOString(),
},
},
).catch(err => console.warn('Failed to update lead:', err));
}
// 3. Create lead activity
await apiClient.graphql(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id }
}`,
{
data: {
activityType: 'CALL_RECEIVED',
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
occurredAt: new Date().toISOString(),
performedBy: user.name,
channel: 'PHONE',
durationSeconds: callDuration,
leadId: matchedLead.id,
},
},
).catch(err => console.warn('Failed to create activity:', err));
}
} catch (err) {
console.error('Save failed:', err);
}
setIsSaving(false);
hangup();
setDisposition(null);
setNotes('');
};
// Log state changes for observability
// Log state changes
useEffect(() => {
if (callState !== 'idle') {
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
}
}, [callState, callerNumber]);
// Idle: nothing to show — call desk has its own status toggle
if (callState === 'idle') {
return null;
}
if (callState === 'idle') return null;
// Ringing inbound
// Ringing inbound — answer redirects to Call Desk
if (callState === 'ringing-in') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
)}
>
<div className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
)}>
<div className="relative">
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
<div className="relative animate-bounce">
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
</div>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
Incoming Call
</span>
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
</div>
<div className="flex items-center gap-3">
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
<Button size="md" color="primary" iconLeading={Phone01} onClick={() => { answer(); navigate('/call-desk'); }}>
Answer
</Button>
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
@@ -312,208 +87,25 @@ export const CallWidget = () => {
);
}
// Ringing outbound
if (callState === 'ringing-out') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
)}
>
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
Calling...
</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
</div>
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
Cancel
</Button>
</div>
);
}
// Active call (full widget)
if (callState === 'active') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
)}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Phone01 className="size-4 text-fg-success-primary" />
<span className="text-sm font-semibold text-primary">Active Call</span>
</div>
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
{formatDuration(callDuration)}
</span>
</div>
{/* Caller info */}
<div>
<span className="text-lg font-bold text-primary">
{matchedLead?.contactName
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
: callerNumber ?? 'Unknown'}
</span>
{matchedLead && (
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
)}
</div>
{/* AI Summary */}
{matchedLead?.aiSummary && (
<div className="rounded-xl bg-brand-primary p-3">
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
{matchedLead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
{matchedLead.aiSuggestedAction}
</span>
)}
</div>
)}
{/* Recent activity */}
{leadActivities.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
{leadActivities.slice(0, 3).map((a: any, i: number) => (
<div key={i} className="text-xs text-quaternary">
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
</div>
))}
</div>
)}
{/* Call controls */}
<div className="flex items-center gap-2">
<Button
size="sm"
color={isMuted ? 'primary' : 'secondary'}
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
onClick={toggleMute}
>
{isMuted ? 'Unmute' : 'Mute'}
</Button>
<Button
size="sm"
color={isOnHold ? 'primary' : 'secondary'}
iconLeading={PauseCircle}
onClick={toggleHold}
>
{isOnHold ? 'Resume' : 'Hold'}
</Button>
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
End
</Button>
</div>
{/* Book Appointment */}
<Button
size="sm"
color="primary"
iconLeading={CalendarPlus02}
onClick={() => setIsAppointmentOpen(true)}
className="w-full"
>
Book Appointment
</Button>
<AppointmentForm
isOpen={isAppointmentOpen}
onOpenChange={setIsAppointmentOpen}
callerNumber={callerNumber}
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
leadId={matchedLead?.id}
patientId={matchedLead?.patientId}
onSaved={() => {
setIsAppointmentOpen(false);
setDisposition('APPOINTMENT_BOOKED');
}}
/>
{/* Divider */}
<div className="border-t border-secondary" />
{/* Disposition */}
<div className="flex flex-col gap-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
<div className="grid grid-cols-2 gap-1.5">
{dispositionOptions.map((opt) => {
const isSelected = disposition === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setDisposition(opt.value)}
className={cx(
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
)}
>
{opt.label}
</button>
);
})}
</div>
<TextArea
placeholder="Add notes..."
value={notes}
onChange={(value) => setNotes(value)}
rows={2}
textAreaClassName="text-xs"
/>
<Button
size="sm"
color="primary"
iconLeading={Save01}
isDisabled={disposition === null || isSaving}
isLoading={isSaving}
onClick={handleSaveAndClose}
className="w-full"
>
{isSaving ? 'Saving...' : 'Save & Close'}
</Button>
</div>
</div>
);
}
// Ended / Failed
// Ended / Failed — brief notification
if (callState === 'ended' || callState === 'failed') {
const isEnded = callState === 'ended';
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
)}
>
<CheckCircle
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
/>
<div className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
)}>
<CheckCircle className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')} />
<span className="text-sm font-semibold text-primary">
{isEnded ? 'Call Ended' : 'Call Failed'}
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
{callDuration > 0 && ` \u00B7 ${formatDuration(callDuration)}`}
</span>
<span className="text-xs text-tertiary">auto-closing...</span>
</div>
);
}
// Any other state (active, ringing-out) on a non-call-desk page — redirect handled by useEffect above
return null;
};

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneHangup } 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 { TextArea } from '@/components/base/textarea/textarea';
import type { FC } from 'react';
import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx';
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneHangup} className={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: '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: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
];
type DispositionModalProps = {
isOpen: boolean;
callerName: string;
callerDisconnected: boolean;
onSubmit: (disposition: CallDisposition, notes: string) => void;
onDismiss?: () => void;
};
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, onSubmit, onDismiss }: DispositionModalProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const handleSubmit = () => {
if (selected === null) return;
onSubmit(selected, notes);
setSelected(null);
setNotes('');
};
return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="flex flex-col items-center gap-3 px-6 pt-6 pb-4">
<FeaturedIcon icon={PhoneHangUpIcon} color={callerDisconnected ? 'warning' : 'error'} theme="light" size="md" />
<div className="text-center">
<h2 className="text-lg font-semibold text-primary">
{callerDisconnected ? 'Call Disconnected' : 'End Call'}
</h2>
<p className="mt-1 text-sm text-tertiary">
{callerDisconnected
? `${callerName} disconnected. What was the outcome?`
: `Select a reason to end the call with ${callerName}.`
}
</p>
</div>
</div>
{/* Disposition options */}
<div className="px-6 pb-4">
<div className="grid grid-cols-2 gap-2">
{dispositionOptions.map((option) => {
const isSelected = selected === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setSelected(option.value)}
className={cx(
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
isSelected
? cx(option.activeClass, 'ring-2 ring-brand')
: option.defaultClass,
)}
>
{option.label}
</button>
);
})}
</div>
<div className="mt-3">
<TextArea
label="Notes (optional)"
placeholder="Add any notes about this call..."
value={notes}
onChange={(value) => setNotes(value)}
rows={2}
textAreaClassName="text-sm"
/>
</div>
</div>
{/* Footer */}
<div className="border-t border-secondary px-6 py-4">
<button
type="button"
onClick={handleSubmit}
disabled={selected === null}
className={cx(
'w-full rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
selected !== null
? 'cursor-pointer bg-error-solid text-white hover:bg-error-solid_hover'
: 'cursor-not-allowed bg-disabled text-disabled',
)}
>
{callerDisconnected
? (selected ? 'Submit & Close' : 'Select a reason')
: (selected ? 'End Call & Submit' : 'Select a reason to end call')
}
</button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -81,7 +81,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE_INQUIRY',
source: 'PHONE',
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
interestedService: queryAsked.substring(0, 100),
},

View File

@@ -320,9 +320,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
}
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */}
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
@@ -342,7 +342,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
{/* Missed call status sub-tabs */}
{tab === 'missed' && (
<div className="flex gap-1 px-5 py-2 border-b border-secondary">
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button
key={sub}
@@ -372,7 +372,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</p>
</div>
) : (
<div className="px-2 pt-3">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
<Table size="sm">
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
@@ -457,7 +457,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
</span>