feat: QA fixes — Patient 360 rewrite, token refresh, call flow, UI polish

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 11:52:33 +05:30
parent 88fc743928
commit 727a0728ee
7 changed files with 312 additions and 100 deletions

View File

@@ -22,8 +22,14 @@ export const CallDeskPage = () => {
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(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 && (
<div className="p-5">
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => setActiveMissedCallId(null)} />
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => { setActiveMissedCallId(null); setCallDismissed(true); }} />
</div>
)}

View File

@@ -165,7 +165,7 @@ export const LoginPage = () => {
</div>
{/* Footer */}
<p className="mt-6 text-xs text-primary_on-brand opacity-60">Powered by FortyTwo</p>
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
</div>
);
};

View File

@@ -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<CallDisposition, 'success' | 'brand' | 'blue' |
};
const TABS = [
{ id: 'timeline', label: 'Timeline' },
{ id: 'appointments', label: 'Appointments' },
{ id: 'calls', label: 'Calls' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'notes', label: 'Notes' },
];
const PATIENT_QUERY = `query GetPatient360($id: UUID!) {
patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender
phones { primaryPhoneNumber } emails { primaryEmail }
patientType
appointments(orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
} } }
calls(first: 20, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction agentName
startedAt durationSec callerNumber { primaryPhoneNumber }
} } }
leads { edges { node {
id source status interestedService aiSummary
} } }
} } }
}`;
type PatientData = {
id: string;
fullName: { firstName: string; lastName: string } | null;
dateOfBirth: string | null;
gender: string | null;
phones: { primaryPhoneNumber: string } | null;
emails: { primaryEmail: string } | null;
patientType: string | null;
appointments: { edges: Array<{ node: any }> };
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<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
};
return (
<div className="flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
<CalendarCheck className="size-4 text-fg-white" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{scheduledAt}</span>
{appt.appointmentType && (
<Badge size="sm" type="pill-color" color="brand">
{appt.appointmentType.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
</Badge>
)}
</div>
<p className="text-xs text-tertiary">
{appt.doctorName ?? 'Unknown Doctor'}
{appt.department ? ` · ${appt.department}` : ''}
{appt.durationMin ? ` · ${appt.durationMin}min` : ''}
</p>
{appt.reasonForVisit && (
<p className="text-xs text-quaternary">{appt.reasonForVisit}</p>
)}
</div>
{appt.status && (
<Badge size="sm" type="pill-color" color={statusColors[appt.status] ?? 'gray'}>
{appt.status.toLowerCase().replace(/\b\w/g, (c: string) => c.toUpperCase())}
</Badge>
)}
</div>
);
};
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<string>('timeline');
const [activeTab, setActiveTab] = useState<string>('appointments');
const [noteText, setNoteText] = useState('');
const [patient, setPatient] = useState<PatientData | null>(null);
const [loading, setLoading] = useState(true);
const [activities, setActivities] = useState<LeadActivity[]>([]);
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 (
<>
<TopBar title="Patient 360" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Lead not found.</p>
<p className="text-sm text-tertiary">Loading patient profile...</p>
</div>
</>
);
}
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
if (!patient) {
return (
<>
<TopBar title="Patient 360" />
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-tertiary">Patient not found.</p>
</div>
</>
);
}
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 = () => {
<div className="flex flex-col gap-1">
<h2 className="text-display-xs font-bold text-primary">{fullName}</h2>
<div className="flex flex-wrap items-center gap-2">
{lead.leadStatus && <LeadStatusBadge status={lead.leadStatus} />}
{lead.leadSource && <SourceTag source={lead.leadSource} />}
{patient.patientType && (
<Badge size="sm" type="pill-color" color={patient.patientType === 'RETURNING' ? 'brand' : 'gray'}>
{patient.patientType === 'RETURNING' ? 'Returning' : 'New'}
</Badge>
)}
{age !== null && genderLabel && (
<span className="text-xs text-tertiary">{age}y · {genderLabel}</span>
)}
{leadInfo?.source && (
<Badge size="sm" type="pill-color" color="gray">
{leadInfo.source.replace(/_/g, ' ')}
</Badge>
)}
</div>
</div>
</div>
@@ -272,10 +390,10 @@ export const Patient360Page = () => {
{/* Contact details */}
<div className="flex flex-1 flex-col gap-2 lg:ml-auto lg:items-end">
<div className="flex flex-wrap items-center gap-3">
{phone && (
{phoneRaw && (
<span className="flex items-center gap-1.5 text-sm text-secondary">
<Phone01 className="size-4 text-fg-quaternary" />
{phone}
{phoneRaw}
</span>
)}
{email && (
@@ -285,25 +403,18 @@ export const Patient360Page = () => {
</span>
)}
</div>
{lead.interestedService && (
{leadInfo?.interestedService && (
<span className="text-xs text-tertiary">
Interested in: {lead.interestedService}
Interested in: {leadInfo.interestedService}
</span>
)}
</div>
</div>
{/* AI summary */}
{(lead.aiSummary || lead.aiSuggestedAction) && (
{/* AI summary from linked lead */}
{leadInfo?.aiSummary && (
<div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3">
{lead.aiSummary && (
<p className="text-sm text-secondary">{lead.aiSummary}</p>
)}
{lead.aiSuggestedAction && (
<Badge size="sm" type="pill-color" color="brand" className="mt-2">
{lead.aiSuggestedAction}
</Badge>
)}
<p className="text-sm text-secondary">{leadInfo.aiSummary}</p>
</div>
)}
@@ -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
}
/>
)}
</TabList>
{/* Appointments tab */}
<TabPanel id="appointments">
<div className="mt-5 pb-7">
{appointments.length === 0 ? (
<EmptyState
icon="📅"
title="No appointments"
subtitle="Appointment history will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} />
))}
</div>
)}
</div>
</TabPanel>
{/* Calls tab */}
<TabPanel id="calls">
<div className="mt-5 pb-7">
{patientCalls.length === 0 ? (
<EmptyState
icon="📞"
title="No calls yet"
subtitle="Call history with this patient will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{patientCalls.map((call: any) => (
<CallRow key={call.id} call={call} />
))}
</div>
)}
</div>
</TabPanel>
{/* Timeline tab */}
<TabPanel id="timeline">
<div className="mt-5 pb-7">
@@ -370,25 +521,6 @@ export const Patient360Page = () => {
</div>
</TabPanel>
{/* Calls tab */}
<TabPanel id="calls">
<div className="mt-5 pb-7">
{leadCalls.length === 0 ? (
<EmptyState
icon="📞"
title="No calls yet"
subtitle="Call history with this lead will appear here."
/>
) : (
<div className="rounded-xl border border-secondary bg-primary">
{leadCalls.map((call) => (
<CallRow key={call.id} call={call} />
))}
</div>
)}
</div>
</TabPanel>
{/* Notes tab */}
<TabPanel id="notes">
<div className="mt-5 pb-7">