From 727a0728eebb56c50fab33c1f0ed4897555f3a4b Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 11:52:33 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20QA=20fixes=20=E2=80=94=20Patient=20360?= =?UTF-8?q?=20rewrite,=20token=20refresh,=20call=20flow,=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Patient 360 page queries Patient entity with appointments, calls, leads - Patients added to CC agent sidebar navigation - Auto token refresh on 401 (deduplicated concurrent refreshes) - Call desk: callDismissed flag prevents SIP race on worklist return - Missed calls skip disposition when never answered - Callbacks tab renamed to Leads tab - Branch column header on missed calls tab - F0rty2.ai link on login footer Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 17 + src/components/call-desk/worklist-panel.tsx | 10 +- src/components/layout/sidebar.tsx | 1 + src/lib/api-client.ts | 76 ++++- src/pages/call-desk.tsx | 10 +- src/pages/login.tsx | 2 +- src/pages/patient-360.tsx | 296 +++++++++++++----- 7 files changed, 312 insertions(+), 100 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index e7e3fcd..fde377d 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -50,6 +50,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const [enquiryOpen, setEnquiryOpen] = useState(false); // Capture direction at mount — survives through disposition stage const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); + // Track if the call was ever answered (reached 'active' state) + const wasAnsweredRef = useRef(callState === 'active'); const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; @@ -174,6 +176,20 @@ 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')) { + return ( +
+ +

Missed Call

+

{phoneDisplay} — not answered

+ +
+ ); + } + // Post-call flow takes priority over active state (handles race between hangup + SIP ended event) if (postCallStage !== null || callState === 'ended' || callState === 'failed') { // Done state @@ -235,6 +251,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete // Active call if (callState === 'active') { + wasAnsweredRef.current = true; return (
diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index ca08fa4..5bacbd3 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -62,7 +62,7 @@ interface WorklistPanelProps { selectedLeadId: string | null; } -type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups'; +type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; type WorklistRow = { id: string; @@ -258,7 +258,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect const filteredRows = useMemo(() => { let rows = allRows; if (tab === 'missed') rows = missedSubTabRows; - else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback'); + else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead'); else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up'); if (search.trim()) { @@ -272,7 +272,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect }, [allRows, tab, search]); const missedCount = allRows.filter((r) => r.type === 'missed').length; - const callbackCount = allRows.filter((r) => r.type === 'callback').length; + const leadCount = allRows.filter((r) => r.type === 'lead').length; const followUpCount = allRows.filter((r) => r.type === 'follow-up').length; // Notification for new missed calls @@ -296,7 +296,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect const tabItems = [ { id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined }, { id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined }, - { id: 'callbacks' as const, label: 'Callbacks', badge: callbackCount > 0 ? String(callbackCount) : undefined }, + { id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined }, { id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined }, ]; @@ -378,7 +378,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect - + diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 10feaf4..5ce7b5e 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -70,6 +70,7 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Call Center', items: [ { label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call History', href: '/call-history', icon: IconClockRewind }, + { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, ]}, ]; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index cf471a9..3d44bee 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -31,8 +31,55 @@ const authHeaders = (): Record => { }; }; -// Shared response handler — extracts error message, handles 401, toasts on failure -const handleResponse = async (response: Response, silent = false): Promise => { +// Token refresh — attempts to get a new access token using the refresh token +let refreshPromise: Promise | null = null; + +const tryRefreshToken = async (): Promise => { + // Deduplicate concurrent refresh attempts + if (refreshPromise) return refreshPromise; + + refreshPromise = (async () => { + const refreshToken = getRefreshToken(); + if (!refreshToken) return false; + + try { + const response = await fetch(`${API_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) return false; + + const data = await response.json(); + if (data.accessToken && data.refreshToken) { + storeTokens(data.accessToken, data.refreshToken); + return true; + } + return false; + } catch { + return false; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +// Shared response handler — extracts error message, handles 401 with auto-refresh, toasts on failure +const handleResponse = async (response: Response, silent = false, retryFn?: () => Promise): Promise => { + if (response.status === 401 && retryFn) { + const refreshed = await tryRefreshToken(); + if (refreshed) { + const retryResponse = await retryFn(); + return handleResponse(retryResponse, silent); + } + clearTokens(); + if (!silent) notify.error('Session expired. Please log in again.'); + throw new AuthError(); + } + if (response.status === 401) { clearTokens(); if (!silent) notify.error('Session expired. Please log in again.'); @@ -86,16 +133,23 @@ export const apiClient = { const token = getStoredToken(); if (!token) throw new AuthError(); - const response = await fetch(`${API_URL}/graphql`, { + const doFetch = () => fetch(`${API_URL}/graphql`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ query, variables }), }); + let response = await doFetch(); + if (response.status === 401) { - clearTokens(); - if (!options?.silent) notify.error('Session expired', 'Please log in again.'); - throw new AuthError(); + const refreshed = await tryRefreshToken(); + if (refreshed) { + response = await doFetch(); + } else { + clearTokens(); + if (!options?.silent) notify.error('Session expired', 'Please log in again.'); + throw new AuthError(); + } } const json = await response.json(); @@ -110,20 +164,22 @@ export const apiClient = { // REST — all sidecar API calls go through these async post(path: string, body?: Record, options?: { silent?: boolean }): Promise { - const response = await fetch(`${API_URL}${path}`, { + const doFetch = () => fetch(`${API_URL}${path}`, { method: 'POST', headers: authHeaders(), body: body ? JSON.stringify(body) : undefined, }); - return handleResponse(response, options?.silent); + const response = await doFetch(); + return handleResponse(response, options?.silent, doFetch); }, async get(path: string, options?: { silent?: boolean }): Promise { - const response = await fetch(`${API_URL}${path}`, { + const doFetch = () => fetch(`${API_URL}${path}`, { method: 'GET', headers: authHeaders(), }); - return handleResponse(response, options?.silent); + const response = await doFetch(); + return handleResponse(response, options?.silent, doFetch); }, // Health check — silent, no toasts diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index f9fac0f..5f9b2cc 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -22,8 +22,14 @@ export const CallDeskPage = () => { const [selectedLead, setSelectedLead] = useState(null); const [contextOpen, setContextOpen] = useState(true); const [activeMissedCallId, setActiveMissedCallId] = useState(null); + const [callDismissed, setCallDismissed] = useState(false); - const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed'; + // Reset callDismissed when a new call starts (ringing in or out) + if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) { + setCallDismissed(false); + } + + 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 ?? '---')) @@ -63,7 +69,7 @@ export const CallDeskPage = () => { {/* Active call */} {isInCall && (
- setActiveMissedCallId(null)} /> + { setActiveMissedCallId(null); setCallDismissed(true); }} />
)} diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 0310525..c4f0abf 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -165,7 +165,7 @@ export const LoginPage = () => {
{/* Footer */} -

Powered by FortyTwo

+ Powered by F0rty2.ai
); }; diff --git a/src/pages/patient-360.tsx b/src/pages/patient-360.tsx index 2d71b95..62642cf 100644 --- a/src/pages/patient-360.tsx +++ b/src/pages/patient-360.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router'; -import { faPhone, faEnvelope, faCalendar, faCommentDots, faPlus } from '@fortawesome/pro-duotone-svg-icons'; +import { faPhone, faEnvelope, faCalendar, faCommentDots, faPlus, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons'; import { faIcon } from '@/lib/icon-wrapper'; const Phone01 = faIcon(faPhone); @@ -8,16 +8,15 @@ const Mail01 = faIcon(faEnvelope); const Calendar = faIcon(faCalendar); const MessageTextSquare01 = faIcon(faCommentDots); const Plus = faIcon(faPlus); +const CalendarCheck = faIcon(faCalendarCheck); import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs'; import { TopBar } from '@/components/layout/top-bar'; import { Avatar } from '@/components/base/avatar/avatar'; import { Badge } from '@/components/base/badges/badges'; import { Button } from '@/components/base/buttons/button'; -import { LeadStatusBadge } from '@/components/shared/status-badge'; -import { SourceTag } from '@/components/shared/source-tag'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; -import { useData } from '@/providers/data-provider'; -import { formatPhone, formatShortDate, getInitials } from '@/lib/format'; +import { apiClient } from '@/lib/api-client'; +import { formatShortDate, getInitials } from '@/lib/format'; import { cx } from '@/utils/cx'; import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities'; @@ -58,11 +57,84 @@ const DISPOSITION_COLORS: Record }; + calls: { edges: Array<{ node: any }> }; + leads: { edges: Array<{ node: any }> }; +}; + +// Appointment row component +const AppointmentRow = ({ appt }: { appt: any }) => { + const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--'; + const statusColors: Record = { + COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand', + CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning', + }; + + return ( +
+
+ +
+
+
+ {scheduledAt} + {appt.appointmentType && ( + + {appt.appointmentType.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())} + + )} +
+

+ {appt.doctorName ?? 'Unknown Doctor'} + {appt.department ? ` · ${appt.department}` : ''} + {appt.durationMin ? ` · ${appt.durationMin}min` : ''} +

+ {appt.reasonForVisit && ( +

{appt.reasonForVisit}

+ )} +
+ {appt.status && ( + + {appt.status.toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())} + + )} +
+ ); +}; + const formatDuration = (seconds: number | null): string => { if (seconds === null || seconds === 0) return '--'; const minutes = Math.floor(seconds / 60); @@ -192,62 +264,97 @@ const EmptyState = ({ icon, title, subtitle }: { icon: string; title: string; su export const Patient360Page = () => { const { id } = useParams<{ id: string }>(); - const { leads, leadActivities, calls } = useData(); - const [activeTab, setActiveTab] = useState('timeline'); + const [activeTab, setActiveTab] = useState('appointments'); const [noteText, setNoteText] = useState(''); + const [patient, setPatient] = useState(null); + const [loading, setLoading] = useState(true); + const [activities, setActivities] = useState([]); - const lead = leads.find((l) => l.id === id); + // Fetch patient with related data from platform + useEffect(() => { + if (!id) return; + setLoading(true); - // Filter activities for this lead - const activities = useMemo( - () => - leadActivities - .filter((a) => a.leadId === id) - .sort((a, b) => { - if (!a.occurredAt) return 1; - if (!b.occurredAt) return -1; - return new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(); - }), - [leadActivities, id], + apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>( + PATIENT_QUERY, + { id }, + { silent: true }, + ).then(data => { + const p = data.patients.edges[0]?.node ?? null; + setPatient(p); + + // Fetch activities from linked leads + const leadIds = p?.leads?.edges?.map((e: any) => e.node.id) ?? []; + if (leadIds.length > 0) { + const leadFilter = leadIds.map((lid: string) => `"${lid}"`).join(', '); + apiClient.graphql<{ leadActivities: { edges: Array<{ node: LeadActivity }> } }>( + `{ leadActivities(first: 50, filter: { leadId: { in: [${leadFilter}] } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { + id activityType summary occurredAt performedBy previousValue newValue leadId + } } } }`, + undefined, + { silent: true }, + ).then(actData => { + setActivities(actData.leadActivities.edges.map(e => e.node)); + }).catch(() => {}); + } + }).catch(() => setPatient(null)) + .finally(() => setLoading(false)); + }, [id]); + + const patientCalls = useMemo( + () => (patient?.calls?.edges?.map(e => e.node) ?? []).map((c: any) => ({ + ...c, + callDirection: c.direction, + durationSeconds: c.durationSec, + })), + [patient], ); - // Filter calls for this lead - const leadCalls = useMemo( - () => - calls - .filter((c) => c.leadId === id) - .sort((a, b) => { - if (!a.startedAt) return 1; - if (!b.startedAt) return -1; - return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); - }), - [calls, id], + const appointments = useMemo( + () => patient?.appointments?.edges?.map(e => e.node) ?? [], + [patient], ); - // Notes are activities of type NOTE_ADDED const notes = useMemo( () => activities.filter((a) => a.activityType === 'NOTE_ADDED'), [activities], ); - if (!lead) { + const leadInfo = patient?.leads?.edges?.[0]?.node; + + if (loading) { return ( <>
-

Lead not found.

+

Loading patient profile...

); } - const firstName = lead.contactName?.firstName ?? ''; - const lastName = lead.contactName?.lastName ?? ''; - const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead'; + if (!patient) { + return ( + <> + +
+

Patient not found.

+
+ + ); + } + + const firstName = patient.fullName?.firstName ?? ''; + const lastName = patient.fullName?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Patient'; const initials = getInitials(firstName || '?', lastName || '?'); - const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null; - const phoneRaw = lead.contactPhone?.[0]?.number ?? ''; - const email = lead.contactEmail?.[0]?.address ?? null; + const phoneRaw = patient.phones?.primaryPhoneNumber ?? ''; + const email = patient.emails?.primaryEmail ?? null; + + const age = patient.dateOfBirth + ? Math.floor((Date.now() - new Date(patient.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000)) + : null; + const genderLabel = patient.gender === 'MALE' ? 'Male' : patient.gender === 'FEMALE' ? 'Female' : patient.gender; return ( <> @@ -263,8 +370,19 @@ export const Patient360Page = () => {

{fullName}

- {lead.leadStatus && } - {lead.leadSource && } + {patient.patientType && ( + + {patient.patientType === 'RETURNING' ? 'Returning' : 'New'} + + )} + {age !== null && genderLabel && ( + {age}y · {genderLabel} + )} + {leadInfo?.source && ( + + {leadInfo.source.replace(/_/g, ' ')} + + )}
@@ -272,10 +390,10 @@ export const Patient360Page = () => { {/* Contact details */}
- {phone && ( + {phoneRaw && ( - {phone} + {phoneRaw} )} {email && ( @@ -285,25 +403,18 @@ export const Patient360Page = () => { )}
- {lead.interestedService && ( + {leadInfo?.interestedService && ( - Interested in: {lead.interestedService} + Interested in: {leadInfo.interestedService} )}
- {/* AI summary */} - {(lead.aiSummary || lead.aiSuggestedAction) && ( + {/* AI summary from linked lead */} + {leadInfo?.aiSummary && (
- {lead.aiSummary && ( -

{lead.aiSummary}

- )} - {lead.aiSuggestedAction && ( - - {lead.aiSuggestedAction} - - )} +

{leadInfo.aiSummary}

)} @@ -335,18 +446,58 @@ export const Patient360Page = () => { id={item.id} label={item.label} badge={ - item.id === 'timeline' - ? activities.length + item.id === 'appointments' + ? appointments.length : item.id === 'calls' - ? leadCalls.length - : item.id === 'notes' - ? notes.length - : undefined + ? patientCalls.length + : item.id === 'timeline' + ? activities.length + : item.id === 'notes' + ? notes.length + : undefined } /> )} + {/* Appointments tab */} + +
+ {appointments.length === 0 ? ( + + ) : ( +
+ {appointments.map((appt: any) => ( + + ))} +
+ )} +
+
+ + {/* Calls tab */} + +
+ {patientCalls.length === 0 ? ( + + ) : ( +
+ {patientCalls.map((call: any) => ( + + ))} +
+ )} +
+
+ {/* Timeline tab */}
@@ -370,25 +521,6 @@ export const Patient360Page = () => {
- {/* Calls tab */} - -
- {leadCalls.length === 0 ? ( - - ) : ( -
- {leadCalls.map((call) => ( - - ))} -
- )} -
-
- {/* Notes tab */}