From c3c3f4b3d7d7a238c49b4ae86c78bfaf18980b0d Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 30 Mar 2026 14:45:52 +0530 Subject: [PATCH] feat: worklist sorting, contextual disposition, context panel redesign, notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worklist default sort descending (newest first), sortable column headers (PRIORITY, PATIENT, SLA) via React Aria - Contextual disposition: auto-selects based on in-call actions (appointment → APPOINTMENT_BOOKED, enquiry → INFO_PROVIDED, transfer → FOLLOW_UP_SCHEDULED) - Context panel redesign: collapsible AI Insight, Upcoming (appointments + follow-ups + linked patient), Recent (calls + activities) sections; auto-collapse on AI chat start - Appointments added to DataProvider with APPOINTMENTS_QUERY, Appointment type, transform - Notification bell for admin/supervisor: performance alerts (idle time, NPS, conversion thresholds) with toast on load + bell dropdown with dismiss; demo alerts as fallback - Slideout z-index fix: added z-50 to slideout ModalOverlay matching modal component Co-Authored-By: Claude Opus 4.6 (1M context) --- .../slideout-menus/slideout-menu.tsx | 4 +- src/components/call-desk/active-call-card.tsx | 201 +++++---- .../call-desk/agent-status-toggle.tsx | 10 +- src/components/call-desk/ai-chat-panel.tsx | 109 +---- src/components/call-desk/appointment-form.tsx | 6 + src/components/call-desk/context-panel.tsx | 421 ++++++++++++------ .../call-desk/disposition-modal.tsx | 13 +- src/components/call-desk/enquiry-form.tsx | 50 ++- .../call-desk/phone-action-cell.tsx | 4 +- src/components/call-desk/worklist-panel.tsx | 44 +- src/components/layout/app-shell.tsx | 40 +- src/components/layout/notification-bell.tsx | 142 ++++++ src/hooks/use-performance-alerts.ts | 102 +++++ src/lib/queries.ts | 8 + src/lib/transforms.ts | 21 +- src/pages/call-desk.tsx | 63 ++- src/providers/data-provider.tsx | 12 +- src/types/entities.ts | 21 + 18 files changed, 882 insertions(+), 389 deletions(-) create mode 100644 src/components/layout/notification-bell.tsx create mode 100644 src/hooks/use-performance-alerts.ts diff --git a/src/components/application/slideout-menus/slideout-menu.tsx b/src/components/application/slideout-menus/slideout-menu.tsx index c337257..4b6b279 100644 --- a/src/components/application/slideout-menus/slideout-menu.tsx +++ b/src/components/application/slideout-menus/slideout-menu.tsx @@ -16,7 +16,7 @@ export const ModalOverlay = (props: ModalOverlayProps) => { {...props} className={(state) => cx( - "fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10", + "fixed inset-0 z-50 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10", state.isEntering && "duration-300 animate-in fade-in", state.isExiting && "duration-500 animate-out fade-out", typeof props.className === "function" ? props.className(state) : props.className, @@ -81,7 +81,7 @@ const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => { Menu.displayName = "SlideoutMenu"; const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => { - return
; + return
; }; Content.displayName = "SlideoutContent"; diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 5d88df2..f87acc9 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -45,6 +45,7 @@ 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); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const wasAnsweredRef = useRef(callState === 'active'); @@ -118,6 +119,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const handleAppointmentSaved = () => { setAppointmentOpen(false); + setSuggestedDisposition('APPOINTMENT_BOOKED'); notify.success('Appointment Booked', 'Payment link will be sent to the patient'); }; @@ -201,107 +203,115 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete wasAnsweredRef.current = true; return ( <> -
-
-
-
- -
-
-

{fullName || phoneDisplay}

- {fullName &&

{phoneDisplay}

} +
+ {/* Pinned: caller info + controls */} +
+
+
+
+ +
+
+

{fullName || phoneDisplay}

+ {fullName &&

{phoneDisplay}

} +
+ {formatDuration(callDuration)}
- {formatDuration(callDuration)} + + {/* Call controls */} +
+ + + + +
+ + + + + + +
+ + {/* Transfer dialog */} + {transferOpen && callUcid && ( + setTransferOpen(false)} + onTransferred={() => { + setTransferOpen(false); + setSuggestedDisposition('FOLLOW_UP_SCHEDULED'); + setDispositionOpen(true); + }} + /> + )}
- {/* Call controls */} -
- - - + {/* Scrollable: expanded forms */} + {(appointmentOpen || enquiryOpen) && ( +
+ -
- - - - - - -
- - {/* Transfer dialog */} - {transferOpen && callUcid && ( - setTransferOpen(false)} - onTransferred={() => { - setTransferOpen(false); - setDispositionOpen(true); - }} - /> + { + setEnquiryOpen(false); + setSuggestedDisposition('INFO_PROVIDED'); + notify.success('Enquiry Logged'); + }} + /> +
)} - - {/* Appointment form */} - - - {/* Enquiry form */} - { - setEnquiryOpen(false); - notify.success('Enquiry Logged'); - }} - />
{/* Disposition Modal — the ONLY path to end a call */} @@ -309,6 +319,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete isOpen={dispositionOpen} callerName={fullName || phoneDisplay} callerDisconnected={callerDisconnected} + defaultDisposition={suggestedDisposition} 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 a18420a..d7c6b4e 100644 --- a/src/components/call-desk/agent-status-toggle.tsx +++ b/src/components/call-desk/agent-status-toggle.tsx @@ -63,12 +63,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu } }; - // If SIP isn't connected, show connection status + // If SIP isn't connected, show connection status with user-friendly message if (!isRegistered) { + const statusMessages: Record = { + disconnected: 'Telephony unavailable', + connecting: 'Connecting to telephony...', + connected: 'Registering...', + error: 'Telephony error — check VPN', + }; return (
- {connectionStatus} + {statusMessages[connectionStatus] ?? connectionStatus}
); } diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index e235eb9..8acc96e 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import { useRef, useEffect } from 'react'; import { useChat } from '@ai-sdk/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPaperPlaneTop, faSparkles, faUserHeadset, faUser, faCalendarCheck, faStethoscope } from '@fortawesome/pro-duotone-svg-icons'; +import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; @@ -14,6 +14,7 @@ type CallerContext = { interface AiChatPanelProps { callerContext?: CallerContext; + onChatStart?: () => void; } const QUICK_ACTIONS = [ @@ -23,13 +24,15 @@ const QUICK_ACTIONS = [ { label: 'Treatment packages', prompt: 'What treatment packages are available?' }, ]; -export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => { +export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { const messagesEndRef = useRef(null); + const chatStartedRef = useRef(false); const token = localStorage.getItem('helix_access_token') ?? ''; const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({ api: `${API_URL}/api/ai/stream`, + streamProtocol: 'text', headers: { 'Authorization': `Bearer ${token}`, }, @@ -43,7 +46,11 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => { if (el?.parentElement) { el.parentElement.scrollTop = el.parentElement.scrollHeight; } - }, [messages]); + if (messages.length > 0 && !chatStartedRef.current) { + chatStartedRef.current = true; + onChatStart?.(); + } + }, [messages, onChatStart]); const handleQuickAction = (prompt: string) => { append({ role: 'user', content: prompt }); @@ -92,10 +99,6 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
)} - - {msg.parts?.filter((p: any) => p.type === 'tool-invocation').map((part: any, i: number) => ( - - ))}
))} @@ -138,97 +141,7 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
); }; - -const ToolResultCard = ({ toolName, state, result }: { toolName: string; state: string; result: any }) => { - if (state !== 'result' || !result) return null; - - switch (toolName) { - case 'lookup_patient': - if (!result.found) return null; - return ( -
- {result.leads?.map((lead: any) => ( -
-
- - - {lead.contactName?.firstName} {lead.contactName?.lastName} - - {lead.status && ( - - {lead.status.replace(/_/g, ' ')} - - )} -
- {lead.contactPhone?.primaryPhoneNumber && ( -

{lead.contactPhone.primaryPhoneNumber}

- )} - {lead.aiSummary && ( -

{lead.aiSummary}

- )} -
- ))} -
- ); - - case 'lookup_appointments': - if (!result.appointments?.length) return null; - return ( -
- {result.appointments.map((appt: any) => ( -
- -
- - {appt.doctorName ?? 'Doctor'} . {appt.department ?? ''} - - - {appt.scheduledAt ? new Date(appt.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : ''} - -
- {appt.status && ( - - {appt.status.toLowerCase()} - - )} -
- ))} -
- ); - - case 'lookup_doctor': - if (!result.found) return null; - return ( -
- {result.doctors?.map((doc: any) => ( -
-
- - - Dr. {doc.fullName?.firstName} {doc.fullName?.lastName} - -
-

- {doc.department} . {doc.specialty} -

- {doc.visitingHours && ( -

Hours: {doc.visitingHours}

- )} - {doc.consultationFeeNew && ( -

- Fee: {'\u20B9'}{doc.consultationFeeNew.amountMicros / 1_000_000} - {doc.clinic?.clinicName ? ` . ${doc.clinic.clinicName}` : ''} -

- )} -
- ))} -
- ); - - default: - return null; - } -}; +// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol const parseLine = (text: string): ReactNode[] => { const parts: ReactNode[] = []; diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index af09eb6..6ccef13 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -198,6 +198,12 @@ export const AppointmentForm = ({ return; } + const today = new Date().toISOString().split('T')[0]; + if (!isEditMode && date < today) { + setError('Appointment date cannot be in the past.'); + return; + } + setIsSaving(true); setError(null); diff --git a/src/components/call-desk/context-panel.tsx b/src/components/call-desk/context-panel.tsx index ef09508..000d68c 100644 --- a/src/components/call-desk/context-panel.tsx +++ b/src/components/call-desk/context-panel.tsx @@ -1,68 +1,74 @@ -import { useEffect, useState } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSparkles, faCalendarCheck, faPhone, faUser } from '@fortawesome/pro-duotone-svg-icons'; -import { faIcon } from '@/lib/icon-wrapper'; +import { + faSparkles, faPhone, faChevronDown, faChevronUp, + faCalendarCheck, faClockRotateLeft, faPhoneMissed, + faPhoneArrowDown, faPhoneArrowUp, faListCheck, +} from '@fortawesome/pro-duotone-svg-icons'; import { AiChatPanel } from './ai-chat-panel'; import { Badge } from '@/components/base/badges/badges'; -import { apiClient } from '@/lib/api-client'; import { formatPhone, formatShortDate } from '@/lib/format'; -import type { Lead, LeadActivity } from '@/types/entities'; - -const CalendarCheck = faIcon(faCalendarCheck); +import { cx } from '@/utils/cx'; +import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities'; +import { AppointmentForm } from './appointment-form'; interface ContextPanelProps { selectedLead: Lead | null; activities: LeadActivity[]; + calls: Call[]; + followUps: FollowUp[]; + appointments: Appointment[]; + patients: Patient[]; callerPhone?: string; isInCall?: boolean; callUcid?: string | null; } -export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall }: ContextPanelProps) => { - const [patientData, setPatientData] = useState(null); - const [loadingPatient, setLoadingPatient] = useState(false); +const formatTimeAgo = (dateStr: string): string => { + const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000); + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; - // Fetch patient data when lead has a patientId - useEffect(() => { - const patientId = (selectedLead as any)?.patientId; - if (!patientId) { - setPatientData(null); - return; - } +const formatDuration = (sec: number): string => { + if (sec < 60) return `${sec}s`; + return `${Math.floor(sec / 60)}m ${sec % 60}s`; +}; - setLoadingPatient(true); - apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>( - `query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node { - id fullName { firstName lastName } dateOfBirth gender - phones { primaryPhoneNumber } emails { primaryEmail } - appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { - id scheduledAt status doctorName department reasonForVisit - } } } - calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { - id callStatus disposition direction startedAt durationSec agentName - } } } - } } } }`, - { id: patientId }, - { silent: true }, - ).then(data => { - setPatientData(data.patients.edges[0]?.node ?? null); - }).catch(() => setPatientData(null)) - .finally(() => setLoadingPatient(false)); - }, [(selectedLead as any)?.patientId]); +const SectionHeader = ({ icon, label, count, expanded, onToggle }: { + icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void; +}) => ( + +); + +export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => { + const [contextExpanded, setContextExpanded] = useState(true); + const [insightExpanded, setInsightExpanded] = useState(true); + const [actionsExpanded, setActionsExpanded] = useState(true); + const [recentExpanded, setRecentExpanded] = useState(true); + const [editingAppointment, setEditingAppointment] = useState(null); const lead = selectedLead; const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); const phone = lead?.contactPhone?.[0]; - const email = lead?.contactEmail?.[0]?.address; - - const leadActivities = activities - .filter((a) => lead && a.leadId === lead.id) - .sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime()) - .slice(0, 10); - - const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? []; const callerContext = lead ? { callerPhone: phone?.number ?? callerPhone, @@ -70,115 +76,250 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall } leadName: fullName, } : callerPhone ? { callerPhone } : undefined; + // Filter data for this lead + const leadCalls = useMemo(() => + calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone))) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 5), + [calls, lead, callerPhone], + ); + + const leadFollowUps = useMemo(() => + followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED') + .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()) + .slice(0, 3), + [followUps, lead], + ); + + const leadAppointments = useMemo(() => { + const patientId = (lead as any)?.patientId; + if (!patientId) return []; + return appointments + .filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW') + .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()) + .slice(0, 3); + }, [appointments, lead]); + + const leadActivities = useMemo(() => + activities.filter(a => a.leadId === lead?.id) + .sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime()) + .slice(0, 5), + [activities, lead], + ); + + // Linked patient + const linkedPatient = useMemo(() => + patients.find(p => p.id === (lead as any)?.patientId), + [patients, lead], + ); + + // Auto-collapse context sections when chat starts + const handleChatStart = useCallback(() => { + setContextExpanded(false); + }, []); + + const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length); + return (
- {/* Context header — shows caller/lead info when available */} + {/* Lead header — always visible */} {lead && ( -
- {/* Call status banner */} - {isInCall && ( -
- - - On call with {fullName || callerPhone || 'Unknown'} - -
- )} - - {/* Lead profile */} -
-

{fullName || 'Unknown'}

- {phone &&

{formatPhone(phone)}

} - {email &&

{email}

} -
- - {/* Status badges */} -
- {!!patientData && ( - Returning Patient +
+
+ {fullName || 'Unknown'} + {phone && ( + {formatPhone(phone)} + )} + {lead.leadStatus && ( + {lead.leadStatus.replace(/_/g, ' ')} + )} + + - {lead.interestedService && ( -

Interested in: {lead.interestedService}

- )} + {/* Expanded context sections */} + {contextExpanded && ( +
+ {/* AI Insight */} + {lead.aiSummary && ( +
+ setInsightExpanded(!insightExpanded)} /> + {insightExpanded && ( +
+

{lead.aiSummary}

+ {lead.aiSuggestedAction && ( +

{lead.aiSuggestedAction}

+ )} +
+ )} +
+ )} - {/* AI Insight — live from platform */} - {(lead.aiSummary || lead.aiSuggestedAction) && ( -
-
- - AI Insight -
- {lead.aiSummary &&

{lead.aiSummary}

} - {lead.aiSuggestedAction && ( -

{lead.aiSuggestedAction}

+ {/* Quick Actions — upcoming appointments + follow-ups + linked patient */} + {(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && ( +
+ setActionsExpanded(!actionsExpanded)} /> + {actionsExpanded && ( +
+ {leadAppointments.map(appt => ( +
+ +
+ + {appt.doctorName ?? 'Appointment'} + + + {appt.department} + + {appt.scheduledAt && ( + + — {formatShortDate(appt.scheduledAt)} + + )} +
+ + {appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'} + + +
+ ))} + {leadFollowUps.map(fu => ( +
+ +
+ + {fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'} + + {fu.scheduledAt && ( + + {formatShortDate(fu.scheduledAt)} + + )} +
+ + {fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'} + +
+ ))} + {linkedPatient && ( +
+ + + Patient: {linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName} + + {linkedPatient.patientType && ( + {linkedPatient.patientType} + )} +
+ )} +
+ )} +
+ )} + + {/* Recent calls + activities */} + {(leadCalls.length > 0 || leadActivities.length > 0) && ( +
+ setRecentExpanded(!recentExpanded)} + /> + {recentExpanded && ( +
+ {leadCalls.map(call => ( +
+ +
+ + {call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call + + {call.durationSeconds != null && call.durationSeconds > 0 && ( + — {formatDuration(call.durationSeconds)} + )} + {call.disposition && ( + , {call.disposition.replace(/_/g, ' ')} + )} +
+ + {formatTimeAgo(call.startedAt ?? call.createdAt)} + +
+ ))} + {leadActivities + .filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---'))) + .slice(0, 3) + .map(a => ( +
+ + {a.summary} + {a.occurredAt && ( + {formatTimeAgo(a.occurredAt)} + )} +
+ )) + } +
+ )} +
+ )} + + {/* No context available */} + {!hasContext && ( +

No history for this lead yet.

)}
)} - - {/* Upcoming appointments */} - {appointments.length > 0 && ( -
- Appointments -
- {appointments.slice(0, 3).map((appt: any) => ( -
- - - {appt.doctorName ?? 'Doctor'} · {appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''} - - {appt.status && ( - - {appt.status.toLowerCase()} - - )} -
- ))} -
-
- )} - - {loadingPatient &&

Loading patient details...

} - - {/* Recent activity */} - {leadActivities.length > 0 && ( -
- Recent Activity -
- {leadActivities.slice(0, 5).map((a) => ( -
-
-
-

{a.summary}

-

- {a.activityType?.replace(/_/g, ' ')}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''} -

-
-
- ))} -
-
- )}
)} - {/* No lead selected — empty state */} - {!lead && ( -
-
- -

Select a lead from the worklist to see context

-
-
- )} - - {/* AI Chat — always available at the bottom */} -
- + {/* AI Chat — fills remaining space */} +
+
+ + {/* Appointment edit form */} + {editingAppointment && ( + { if (!open) setEditingAppointment(null); }} + callerNumber={callerPhone} + leadName={fullName} + leadId={lead?.id} + patientId={editingAppointment.patientId} + existingAppointment={{ + id: editingAppointment.id, + scheduledAt: editingAppointment.scheduledAt ?? '', + doctorName: editingAppointment.doctorName ?? '', + doctorId: editingAppointment.doctorId ?? undefined, + department: editingAppointment.department ?? '', + reasonForVisit: editingAppointment.reasonForVisit ?? undefined, + status: editingAppointment.appointmentStatus ?? 'SCHEDULED', + }} + onSaved={() => setEditingAppointment(null)} + /> + )}
); }; diff --git a/src/components/call-desk/disposition-modal.tsx b/src/components/call-desk/disposition-modal.tsx index 4990b81..86925a7 100644 --- a/src/components/call-desk/disposition-modal.tsx +++ b/src/components/call-desk/disposition-modal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } 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'; @@ -60,19 +60,28 @@ type DispositionModalProps = { isOpen: boolean; callerName: string; callerDisconnected: boolean; + defaultDisposition?: CallDisposition | null; onSubmit: (disposition: CallDisposition, notes: string) => void; onDismiss?: () => void; }; -export const DispositionModal = ({ isOpen, callerName, callerDisconnected, onSubmit, onDismiss }: DispositionModalProps) => { +export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => { const [selected, setSelected] = useState(null); const [notes, setNotes] = useState(''); + const appliedDefaultRef = useRef(undefined); + + // Pre-select when modal opens with a suggestion + if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) { + appliedDefaultRef.current = defaultDisposition; + setSelected(defaultDisposition); + } const handleSubmit = () => { if (selected === null) return; onSubmit(selected, notes); setSelected(null); setNotes(''); + appliedDefaultRef.current = undefined; }; return ( diff --git a/src/components/call-desk/enquiry-form.tsx b/src/components/call-desk/enquiry-form.tsx index 2a4e9bf..8ca259f 100644 --- a/src/components/call-desk/enquiry-form.tsx +++ b/src/components/call-desk/enquiry-form.tsx @@ -73,20 +73,44 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu setError(null); try { - // Create a lead with source PHONE_INQUIRY - await apiClient.graphql( - `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, - { - data: { - name: `Enquiry — ${patientName}`, - contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, - contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined, - source: 'PHONE', - status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW', - interestedService: queryAsked.substring(0, 100), + // Resolve caller — ensures lead+patient pair exists, returns IDs + let leadId: string | null = null; + if (registeredPhone) { + const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true }); + leadId = resolved.leadId; + } + + if (leadId) { + // Update existing lead with enquiry details + await apiClient.graphql( + `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, + { + id: leadId, + data: { + name: `Enquiry — ${patientName}`, + contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, + source: 'PHONE', + status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW', + interestedService: queryAsked.substring(0, 100), + }, }, - }, - ); + ); + } else { + // No phone provided — create a new lead (rare edge case) + await apiClient.graphql( + `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, + { + data: { + name: `Enquiry — ${patientName}`, + contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, + contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined, + source: 'PHONE', + status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW', + interestedService: queryAsked.substring(0, 100), + }, + }, + ); + } // Create follow-up if needed if (followUpNeeded && followUpDate) { diff --git a/src/components/call-desk/phone-action-cell.tsx b/src/components/call-desk/phone-action-cell.tsx index 07ceb2e..5642e17 100644 --- a/src/components/call-desk/phone-action-cell.tsx +++ b/src/components/call-desk/phone-action-cell.tsx @@ -9,9 +9,10 @@ type PhoneActionCellProps = { phoneNumber: string; displayNumber: string; leadId?: string; + onDial?: () => void; }; -export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => { +export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, onDial }: PhoneActionCellProps) => { const { isRegistered, isInCall, dialOutbound } = useSip(); const [menuOpen, setMenuOpen] = useState(false); const [dialing, setDialing] = useState(false); @@ -35,6 +36,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: setMenuOpen(false); setDialing(true); try { + onDial?.(); await dialOutbound(phoneNumber); } catch { notify.error('Dial Failed', 'Could not place the call'); diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index f7640b7..1377e81 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; +import type { SortDescriptor } from 'react-aria-components'; import { faIcon } from '@/lib/icon-wrapper'; import { Table } from '@/components/application/table/table'; @@ -60,6 +61,7 @@ interface WorklistPanelProps { loading: boolean; onSelectLead: (lead: WorklistLead) => void; selectedLeadId: string | null; + onDialMissedCall?: (missedCallId: string) => void; } type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; @@ -82,6 +84,7 @@ type WorklistRow = { contactAttempts: number; source: string | null; lastDisposition: string | null; + missedCallId: string | null; }; const priorityConfig: Record = { @@ -164,6 +167,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea contactAttempts: 0, source: call.callsourcenumber ?? null, lastDisposition: call.disposition ?? null, + missedCallId: call.id, }); } @@ -190,6 +194,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea contactAttempts: 0, source: null, lastDisposition: null, + missedCallId: null, }); } @@ -216,6 +221,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea contactAttempts: lead.contactAttempts ?? 0, source: lead.leadSource ?? lead.utmCampaign ?? null, lastDisposition: null, + missedCallId: null, }); } @@ -226,16 +232,17 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea const pa = priorityConfig[a.priority]?.sort ?? 2; const pb = priorityConfig[b.priority]?.sort ?? 2; if (pa !== pb) return pa - pb; - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); return actionableRows; }; -export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => { +export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => { const [tab, setTab] = useState('all'); const [search, setSearch] = useState(''); const [missedSubTab, setMissedSubTab] = useState('pending'); + const [sortDescriptor, setSortDescriptor] = useState({ column: 'sla', direction: 'descending' }); const missedByStatus = useMemo(() => ({ pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus), @@ -268,8 +275,30 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect ); } + if (sortDescriptor.column) { + const dir = sortDescriptor.direction === 'ascending' ? 1 : -1; + rows = [...rows].sort((a, b) => { + switch (sortDescriptor.column) { + case 'priority': { + const pa = priorityConfig[a.priority]?.sort ?? 2; + const pb = priorityConfig[b.priority]?.sort ?? 2; + return (pa - pb) * dir; + } + case 'name': + return a.name.localeCompare(b.name) * dir; + case 'sla': { + const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime(); + const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime(); + return (ta - tb) * dir; + } + default: + return 0; + } + }); + } + return rows; - }, [allRows, tab, search]); + }, [allRows, tab, search, sortDescriptor, missedSubTabRows]); const missedCount = allRows.filter((r) => r.type === 'missed').length; const leadCount = allRows.filter((r) => r.type === 'lead').length; @@ -373,13 +402,13 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
) : (
- +
- - + + - + {(row) => { @@ -432,6 +461,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect phoneNumber={row.phoneRaw} displayNumber={row.phone} leadId={row.leadId ?? undefined} + onDial={row.missedCallId ? () => onDialMissedCall?.(row.missedCallId!) : undefined} /> ) : ( No phone diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 29b33ff..8a9931d 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -8,6 +8,7 @@ import { useSip } from '@/providers/sip-provider'; import { CallWidget } from '@/components/call-desk/call-widget'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; +import { NotificationBell } from './notification-bell'; import { useAuth } from '@/providers/auth-provider'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useNetworkStatus } from '@/hooks/use-network-status'; @@ -19,7 +20,7 @@ interface AppShellProps { export const AppShell = ({ children }: AppShellProps) => { const { pathname } = useLocation(); - const { isCCAgent } = useAuth(); + const { isCCAgent, isAdmin } = useAuth(); const { isOpen, activeAction, close } = useMaintShortcuts(); const { connectionStatus, isRegistered } = useSip(); const networkQuality = useNetworkStatus(); @@ -50,23 +51,28 @@ export const AppShell = ({ children }: AppShellProps) => {
{/* Persistent top bar — visible on all pages */} - {hasAgentConfig && ( + {(hasAgentConfig || isAdmin) && (
-
- - {networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'} -
- + {isAdmin && } + {hasAgentConfig && ( + <> +
+ + {networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'} +
+ + + )}
)}
{children}
diff --git a/src/components/layout/notification-bell.tsx b/src/components/layout/notification-bell.tsx new file mode 100644 index 0000000..2170da0 --- /dev/null +++ b/src/components/layout/notification-bell.tsx @@ -0,0 +1,142 @@ +import { useState, useRef, useEffect } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons'; +import { Badge } from '@/components/base/badges/badges'; +import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts'; +import { cx } from '@/utils/cx'; + +const DEMO_ALERTS: PerformanceAlert[] = [ + { id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false }, + { id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false }, + { id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false }, + { id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false }, + { id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false }, + { id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false }, + { id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false }, + { id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false }, +]; + +export const NotificationBell = () => { + const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts(); + const [demoAlerts, setDemoAlerts] = useState(DEMO_ALERTS); + const [open, setOpen] = useState(true); + const panelRef = useRef(null); + + // Use live alerts if available, otherwise demo + const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed); + const isDemo = liveAlerts.length === 0; + + const dismiss = (id: string) => { + if (isDemo) { + setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a)); + } else { + liveDismiss(id); + } + }; + + const dismissAll = () => { + if (isDemo) { + setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true }))); + } else { + liveDismissAll(); + } + }; + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + return ( +
+ + + {open && ( +
+ {/* Header */} +
+
+ Notifications + {alerts.length > 0 && ( + {alerts.length} + )} +
+ {alerts.length > 0 && ( + + )} +
+ + {/* Alert list */} +
+ {alerts.length === 0 ? ( +
+ +

No active alerts

+
+ ) : ( + alerts.map(alert => ( +
+ +
+

{alert.agent}

+

{alert.type}

+
+ {alert.value} + +
+ )) + )} +
+
+ )} +
+ ); +}; diff --git a/src/hooks/use-performance-alerts.ts b/src/hooks/use-performance-alerts.ts new file mode 100644 index 0000000..8372dc1 --- /dev/null +++ b/src/hooks/use-performance-alerts.ts @@ -0,0 +1,102 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useData } from '@/providers/data-provider'; +import { useAuth } from '@/providers/auth-provider'; +import { notify } from '@/lib/toast'; + +export type PerformanceAlert = { + id: string; + agent: string; + type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion'; + value: string; + severity: 'error' | 'warning'; + dismissed: boolean; +}; + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + +export const usePerformanceAlerts = () => { + const { isAdmin } = useAuth(); + const { calls, leads } = useData(); + const [alerts, setAlerts] = useState([]); + const [teamPerf, setTeamPerf] = useState(null); + const toastsFiredRef = useRef(false); + + // Fetch team performance data from sidecar (same as team-performance page) + useEffect(() => { + if (!isAdmin) return; + const today = new Date().toISOString().split('T')[0]; + const token = localStorage.getItem('helix_access_token') ?? ''; + fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then(r => r.ok ? r.json() : null) + .then(data => setTeamPerf(data)) + .catch(() => {}); + }, [isAdmin]); + + // Compute alerts from team performance + entity data + useMemo(() => { + if (!isAdmin || !teamPerf?.agents) return; + + const parseTime = (t: string): number => { + const parts = t.split(':').map(Number); + return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0); + }; + + const list: PerformanceAlert[] = []; + let idx = 0; + + for (const agent of teamPerf.agents) { + const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid); + const totalCalls = agentCalls.length; + const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length; + const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0; + + const tb = agent.timeBreakdown; + const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0; + + if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) { + list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false }); + } + if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) { + list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false }); + } + if (agent.minconversionpercent && convPercent < agent.minconversionpercent) { + list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false }); + } + } + + setAlerts(list); + }, [isAdmin, teamPerf, calls, leads]); + + // Fire toasts once when alerts first load + useEffect(() => { + if (toastsFiredRef.current || alerts.length === 0) return; + toastsFiredRef.current = true; + + const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length; + const npsCount = alerts.filter(a => a.type === 'Low NPS').length; + const convCount = alerts.filter(a => a.type === 'Low Conversion').length; + + const parts: string[] = []; + if (idleCount > 0) parts.push(`${idleCount} excessive idle`); + if (npsCount > 0) parts.push(`${npsCount} low NPS`); + if (convCount > 0) parts.push(`${convCount} low conversion`); + + if (parts.length > 0) { + notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`); + } + }, [alerts]); + + const dismiss = (id: string) => { + setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a)); + }; + + const dismissAll = () => { + setAlerts(prev => prev.map(a => ({ ...a, dismissed: true }))); + }; + + const activeAlerts = alerts.filter(a => !a.dismissed); + + return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll }; +}; diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 62847fc..f5a2943 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -66,6 +66,14 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { clinic { id name clinicName } } } } }`; +export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id name createdAt + scheduledAt durationMin appointmentType status + doctorName department reasonForVisit + patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } + doctor { id clinic { clinicName } } +} } } }`; + export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node { id name fullName { firstName lastName } phones { primaryPhoneNumber } diff --git a/src/lib/transforms.ts b/src/lib/transforms.ts index 2416bb5..150e413 100644 --- a/src/lib/transforms.ts +++ b/src/lib/transforms.ts @@ -1,7 +1,7 @@ // Transform platform GraphQL responses → frontend entity types // Platform remaps field names during sync — this layer normalizes them -import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient } from '@/types/entities'; +import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient, Appointment } from '@/types/entities'; type PlatformNode = Record; @@ -153,6 +153,25 @@ export function transformCalls(data: any): Call[] { })); } +export function transformAppointments(data: any): Appointment[] { + return extractEdges(data, 'appointments').map((n) => ({ + id: n.id, + createdAt: n.createdAt, + scheduledAt: n.scheduledAt, + durationMinutes: n.durationMin ?? 30, + appointmentType: n.appointmentType, + appointmentStatus: n.status, + doctorName: n.doctorName, + doctorId: n.doctor?.id ?? null, + department: n.department, + reasonForVisit: n.reasonForVisit, + patientId: n.patient?.id ?? null, + patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null, + patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null, + clinicName: n.doctor?.clinic?.clinicName ?? null, + })); +} + export function transformPatients(data: any): Patient[] { return extractEdges(data, 'patients').map((n) => ({ id: n.id, diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 4a05707..3ad3b3e 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft } from '@fortawesome/pro-duotone-svg-icons'; import { useAuth } from '@/providers/auth-provider'; @@ -11,12 +11,13 @@ import { ContextPanel } from '@/components/call-desk/context-panel'; import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { Badge } from '@/components/base/badges/badges'; +import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; import { cx } from '@/utils/cx'; export const CallDeskPage = () => { const { user } = useAuth(); - const { leadActivities } = useData(); + const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { callState, callerNumber, callUcid, dialOutbound } = useSip(); const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist(); const [selectedLead, setSelectedLead] = useState(null); @@ -49,12 +50,53 @@ export const CallDeskPage = () => { const isInCall = !callDismissed && (callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed'); - const callerLead = callerNumber - ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) - : null; + // Resolve caller identity via sidecar (lookup-or-create lead+patient pair) + const [resolvedCaller, setResolvedCaller] = useState<{ + leadId: string; patientId: string; firstName: string; lastName: string; phone: string; + } | null>(null); + const resolveAttemptedRef = useRef(null); - // For inbound calls, only use matched lead (don't fall back to previously selected worklist lead) - // For outbound (agent initiated from worklist), selectedLead is the intended target + useEffect(() => { + if (!callerNumber || !isInCall) return; + if (resolveAttemptedRef.current === callerNumber) return; // already resolving/resolved this number + resolveAttemptedRef.current = callerNumber; + + apiClient.post<{ + leadId: string; patientId: string; firstName: string; lastName: string; phone: string; isNew: boolean; + }>('/api/caller/resolve', { phone: callerNumber }, { silent: true }) + .then((result) => { + setResolvedCaller(result); + if (result.isNew) { + notify.info('New Caller', 'Lead and patient records created'); + } + }) + .catch((err) => { + console.warn('[RESOLVE] Caller resolution failed:', err); + resolveAttemptedRef.current = null; // allow retry + }); + }, [callerNumber, isInCall]); + + // Reset resolved caller when call ends + useEffect(() => { + if (!isInCall) { + setResolvedCaller(null); + resolveAttemptedRef.current = null; + } + }, [isInCall]); + + // Build activeLead from resolved caller or fallback to client-side match + const callerLead = resolvedCaller + ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? { + id: resolvedCaller.leadId, + contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName }, + contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }], + patientId: resolvedCaller.patientId, + } + : callerNumber + ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) + : null; + + // For inbound calls, use resolved/matched lead. For outbound, use selectedLead. const activeLead = isInCall ? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null)) : selectedLead; @@ -147,7 +189,7 @@ export const CallDeskPage = () => { {/* Main content */}
{/* Main panel */} -
+
{/* Active call */} {isInCall && (
@@ -164,6 +206,7 @@ export const CallDeskPage = () => { loading={loading} onSelectLead={(lead) => setSelectedLead(lead)} selectedLeadId={selectedLead?.id ?? null} + onDialMissedCall={(id) => setActiveMissedCallId(id)} /> )}
@@ -177,6 +220,10 @@ export const CallDeskPage = () => { { const [followUps, setFollowUps] = useState([]); const [leadActivities, setLeadActivities] = useState([]); const [calls, setCalls] = useState([]); + const [appointments, setAppointments] = useState([]); const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -84,13 +88,14 @@ export const DataProvider = ({ children }: DataProviderProps) => { try { const gql = (query: string) => apiClient.graphql(query, undefined, { silent: true }).catch(() => null); - const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([ + const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([ gql(LEADS_QUERY), gql(CAMPAIGNS_QUERY), gql(ADS_QUERY), gql(FOLLOW_UPS_QUERY), gql(LEAD_ACTIVITIES_QUERY), gql(CALLS_QUERY), + gql(APPOINTMENTS_QUERY), gql(PATIENTS_QUERY), ]); @@ -100,6 +105,7 @@ export const DataProvider = ({ children }: DataProviderProps) => { if (followUpsData) setFollowUps(transformFollowUps(followUpsData)); if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData)); if (callsData) setCalls(transformCalls(callsData)); + if (appointmentsData) setAppointments(transformAppointments(appointmentsData)); if (patientsData) setPatients(transformPatients(patientsData)); } catch (err: any) { setError(err.message ?? 'Failed to load data'); @@ -122,7 +128,7 @@ export const DataProvider = ({ children }: DataProviderProps) => { return ( diff --git a/src/types/entities.ts b/src/types/entities.ts index 3a42666..a2a9ab6 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -294,6 +294,27 @@ export type Patient = { patientType: PatientType | null; }; +// Appointment domain +export type AppointmentStatus = 'SCHEDULED' | 'CONFIRMED' | 'CHECKED_IN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW'; +export type AppointmentType = 'CONSULTATION' | 'FOLLOW_UP' | 'PROCEDURE' | 'EMERGENCY'; + +export type Appointment = { + id: string; + createdAt: string | null; + scheduledAt: string | null; + durationMinutes: number | null; + appointmentType: AppointmentType | null; + appointmentStatus: AppointmentStatus | null; + doctorName: string | null; + doctorId: string | null; + department: string | null; + reasonForVisit: string | null; + patientId: string | null; + patientName: string | null; + patientPhone: string | null; + clinicName: string | null; +}; + // Lead Ingestion Source domain export type IntegrationStatus = 'ACTIVE' | 'WARNING' | 'ERROR' | 'DISABLED'; export type AuthStatus = 'VALID' | 'EXPIRING_SOON' | 'EXPIRED' | 'NOT_CONFIGURED';