From 5816cc0b5c2b8549fefe4c450609b736467d5945 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 14:41:31 +0530 Subject: [PATCH] fix: pinned header/chat input, numpad dialler, caller matching, appointment FK - AppShell: h-screen + overflow-hidden for pinned header - AI chat: input pinned to bottom, messages scroll independently - Dialler: numpad grid (1-9,*,0,#) replaces text input - Inbound calls: don't fall back to previously selected lead - Appointment: use lead.patientId instead of leadId for FK - Added .env.production for consistent builds Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.production | 5 + src/components/call-desk/active-call-card.tsx | 2 + src/components/call-desk/appointment-form.tsx | 20 +-- src/components/call-desk/context-panel.tsx | 148 ++++++++++++++---- src/components/layout/app-shell.tsx | 4 +- src/pages/call-desk.tsx | 91 ++++++++++- 6 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 .env.production diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..1dee9e7 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud +VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud +VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com +VITE_SIP_PASSWORD=523590 +VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index fde377d..6bd5ee6 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -226,6 +226,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete callerNumber={callerPhone} leadName={fullName || null} leadId={lead?.id ?? null} + patientId={(lead as any)?.patientId ?? null} onSaved={handleAppointmentSaved} /> @@ -340,6 +341,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete callerNumber={callerPhone} leadName={fullName || null} leadId={lead?.id ?? null} + patientId={(lead as any)?.patientId ?? null} onSaved={handleAppointmentSaved} /> diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index f312cdc..2263032 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -20,7 +20,7 @@ type ExistingAppointment = { doctorId?: string; department: string; reasonForVisit?: string; - appointmentStatus: string; + status: string; }; type AppointmentFormProps = { @@ -29,6 +29,7 @@ type AppointmentFormProps = { callerNumber?: string | null; leadName?: string | null; leadId?: string | null; + patientId?: string | null; onSaved?: () => void; existingAppointment?: ExistingAppointment | null; }; @@ -70,6 +71,7 @@ export const AppointmentForm = ({ callerNumber, leadName, leadId, + patientId, onSaved, existingAppointment, }: AppointmentFormProps) => { @@ -141,11 +143,11 @@ export const AppointmentForm = ({ `{ appointments(filter: { doctorId: { eq: "${doctor}" }, scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" } - }) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`, + }) { edges { node { id scheduledAt durationMin status } } } }`, ).then(data => { // Filter out cancelled/completed appointments client-side const activeAppointments = data.appointments.edges.filter(e => { - const status = e.node.appointmentStatus; + const status = e.node.status; return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW'; }); const slots = activeAppointments.map(e => { @@ -223,14 +225,14 @@ export const AppointmentForm = ({ notify.success('Appointment Updated'); } else { // Double-check slot availability before booking - const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>( + const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { status: string } }> } }>( `{ appointments(filter: { doctorId: { eq: "${doctor}" }, scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" } - }) { edges { node { appointmentStatus } } } }`, + }) { edges { node { status } } } }`, ); const activeBookings = checkResult.appointments.edges.filter(e => - e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW', + e.node.status !== 'CANCELLED' && e.node.status !== 'NO_SHOW', ); if (activeBookings.length > 0) { setError('This slot was just booked by someone else. Please select a different time.'); @@ -248,12 +250,12 @@ export const AppointmentForm = ({ scheduledAt, durationMin: 30, appointmentType: 'CONSULTATION', - appointmentStatus: 'SCHEDULED', + status: 'SCHEDULED', doctorName: selectedDoctor?.name ?? '', department: selectedDoctor?.department ?? '', doctorId: doctor, reasonForVisit: chiefComplaint || null, - ...(leadId ? { patientId: leadId } : {}), + ...(patientId ? { patientId } : {}), }, }, ); @@ -294,7 +296,7 @@ export const AppointmentForm = ({ }`, { id: existingAppointment.id, - data: { appointmentStatus: 'CANCELLED' }, + data: { status: 'CANCELLED' }, }, ); notify.success('Appointment Cancelled'); diff --git a/src/components/call-desk/context-panel.tsx b/src/components/call-desk/context-panel.tsx index 4123bbe..0b2ec26 100644 --- a/src/components/call-desk/context-panel.tsx +++ b/src/components/call-desk/context-panel.tsx @@ -1,14 +1,16 @@ import { useEffect, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons'; +import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; import { AiChatPanel } from './ai-chat-panel'; -import { LiveTranscript } from './live-transcript'; -import { useCallAssist } from '@/hooks/use-call-assist'; import { Badge } from '@/components/base/badges/badges'; +import { apiClient } from '@/lib/api-client'; import { formatPhone, formatShortDate } from '@/lib/format'; import { cx } from '@/utils/cx'; import type { Lead, LeadActivity } from '@/types/entities'; +const CalendarCheck = faIcon(faCalendarCheck); + type ContextTab = 'ai' | 'lead360'; interface ContextPanelProps { @@ -19,7 +21,7 @@ interface ContextPanelProps { callUcid?: string | null; } -export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => { +export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => { const [activeTab, setActiveTab] = useState('ai'); // Auto-switch to lead 360 when a lead is selected @@ -29,13 +31,6 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, } }, [selectedLead?.id]); - const { transcript, suggestions, connected: assistConnected } = useCallAssist( - isInCall ?? false, - callUcid ?? null, - selectedLead?.id ?? null, - callerPhone ?? null, - ); - const callerContext = selectedLead ? { callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone, leadId: selectedLead.id, @@ -68,30 +63,57 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, )} > - Lead 360 + {(selectedLead as any)?.patientId ? 'Patient 360' : 'Lead 360'} {/* Tab content */} -
- {activeTab === 'ai' && ( - isInCall ? ( - - ) : ( -
- -
- ) - )} - {activeTab === 'lead360' && ( + {activeTab === 'ai' && ( +
+ +
+ )} + {activeTab === 'lead360' && ( +
- )} -
+
+ )} ); }; const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => { + const [patientData, setPatientData] = useState(null); + const [loadingPatient, setLoadingPatient] = useState(false); + + // Fetch patient data when lead has a patientId (returning patient) + useEffect(() => { + const patientId = (lead as any)?.patientId; + if (!patientId) { + setPatientData(null); + return; + } + + 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 patientType + phones { primaryPhoneNumber } emails { primaryEmail } + appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt status doctorName department reasonForVisit appointmentType + } } } + 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)); + }, [(lead as any)?.patientId]); + if (!lead) { return (
@@ -112,6 +134,15 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA .sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime()) .slice(0, 10); + const isReturning = !!patientData; + const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? []; + const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? []; + + const patientAge = patientData?.dateOfBirth + ? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000)) + : null; + const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null; + return (
{/* Profile */} @@ -120,6 +151,12 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA {phone &&

{formatPhone(phone)}

} {email &&

{email}

}
+ {isReturning && ( + Returning Patient + )} + {patientAge !== null && patientGender && ( + {patientAge}y · {patientGender} + )} {lead.leadStatus && {lead.leadStatus}} {lead.leadSource && {lead.leadSource}} {lead.priority && lead.priority !== 'NORMAL' && ( @@ -129,11 +166,66 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA {lead.interestedService && (

Interested in: {lead.interestedService}

)} - {lead.leadScore !== null && lead.leadScore !== undefined && ( -

Lead score: {lead.leadScore}

- )}
+ {/* Returning patient: Appointments */} + {loadingPatient && ( +

Loading patient details...

+ )} + {isReturning && appointments.length > 0 && ( +
+

Appointments

+
+ {appointments.map((appt: any) => { + const statusColors: Record = { + COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand', + CANCELLED: 'error', NO_SHOW: 'warning', + }; + return ( +
+ +
+
+ + {appt.doctorName ?? 'Doctor'} · {appt.department ?? ''} + + {appt.status && ( + + {appt.status.toLowerCase()} + + )} +
+

+ {appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''} + {appt.reasonForVisit ? ` — ${appt.reasonForVisit}` : ''} +

+
+
+ ); + })} +
+
+ )} + + {/* Returning patient: Recent calls */} + {isReturning && patientCalls.length > 0 && ( +
+

Recent Calls

+
+ {patientCalls.map((call: any) => ( +
+
+ + {call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} + {call.disposition ? ` — ${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''} + + {call.startedAt ? formatShortDate(call.startedAt) : ''} +
+ ))} +
+
+ )} + {/* AI Insight */} {(lead.aiSummary || lead.aiSuggestedAction) && (
diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index e6437ed..fb2e532 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -15,9 +15,9 @@ export const AppShell = ({ children }: AppShellProps) => { return ( -
+
-
{children}
+
{children}
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && }
diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 5f9b2cc..04192bb 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; +import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft } from '@fortawesome/pro-duotone-svg-icons'; import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useWorklist } from '@/hooks/use-worklist'; @@ -12,6 +12,8 @@ import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { Badge } from '@/components/base/badges/badges'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; import { cx } from '@/utils/cx'; export const CallDeskPage = () => { @@ -23,6 +25,24 @@ export const CallDeskPage = () => { const [contextOpen, setContextOpen] = useState(true); const [activeMissedCallId, setActiveMissedCallId] = useState(null); const [callDismissed, setCallDismissed] = useState(false); + const [diallerOpen, setDiallerOpen] = useState(false); + const [dialNumber, setDialNumber] = useState(''); + const [dialling, setDialling] = useState(false); + + const handleDial = async () => { + const num = dialNumber.replace(/[^0-9]/g, ''); + if (num.length < 10) { notify.error('Enter a valid phone number'); return; } + setDialling(true); + try { + await apiClient.post('/api/ozonetel/dial', { phoneNumber: num }); + setDiallerOpen(false); + setDialNumber(''); + } catch { + notify.error('Dial failed'); + } finally { + setDialling(false); + } + }; // Reset callDismissed when a new call starts (ringing in or out) if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) { @@ -35,7 +55,11 @@ export const CallDeskPage = () => { ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) : null; - const activeLead = isInCall ? (callerLead ?? selectedLead) : selectedLead; + // 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 + const activeLead = isInCall + ? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null)) + : selectedLead; const activeLeadFull = activeLead as any; return ( @@ -102,6 +126,69 @@ export const CallDeskPage = () => { )}
+ + {/* Dialler FAB */} + {!isInCall && ( +
+ {diallerOpen && ( +
+
+ Dial + +
+ + {/* Number display */} +
+ + {dialNumber || Enter number} + + {dialNumber && ( + + )} +
+ + {/* Numpad */} +
+ {['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => ( + + ))} +
+ + {/* Call button */} + +
+ )} + +
+ )}
); };