mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
@@ -50,6 +50,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||||
// Capture direction at mount — survives through disposition stage
|
// Capture direction at mount — survives through disposition stage
|
||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
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 firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
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 (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
|
<p className="text-sm font-semibold text-primary">Missed Call</p>
|
||||||
|
<p className="text-xs text-tertiary mt-1">{phoneDisplay} — not answered</p>
|
||||||
|
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||||
|
Back to Worklist
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
|
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
|
||||||
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
|
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
|
||||||
// Done state
|
// Done state
|
||||||
@@ -235,6 +251,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === 'active') {
|
if (callState === 'active') {
|
||||||
|
wasAnsweredRef.current = true;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ interface WorklistPanelProps {
|
|||||||
selectedLeadId: string | null;
|
selectedLeadId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups';
|
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||||||
|
|
||||||
type WorklistRow = {
|
type WorklistRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -258,7 +258,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
let rows = allRows;
|
let rows = allRows;
|
||||||
if (tab === 'missed') rows = missedSubTabRows;
|
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');
|
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
@@ -272,7 +272,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
}, [allRows, tab, search]);
|
}, [allRows, tab, search]);
|
||||||
|
|
||||||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
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;
|
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||||||
|
|
||||||
// Notification for new missed calls
|
// Notification for new missed calls
|
||||||
@@ -296,7 +296,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
{ 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: '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 },
|
{ 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
|
|||||||
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
||||||
<Table.Head label="PATIENT" />
|
<Table.Head label="PATIENT" />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label="SOURCE" className="w-28" />
|
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
||||||
<Table.Head label="SLA" className="w-24" />
|
<Table.Head label="SLA" className="w-24" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Call Center', items: [
|
{ label: 'Call Center', items: [
|
||||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||||
]},
|
]},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -31,8 +31,55 @@ const authHeaders = (): Record<string, string> => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared response handler — extracts error message, handles 401, toasts on failure
|
// Token refresh — attempts to get a new access token using the refresh token
|
||||||
const handleResponse = async <T>(response: Response, silent = false): Promise<T> => {
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
const tryRefreshToken = async (): Promise<boolean> => {
|
||||||
|
// 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 <T>(response: Response, silent = false, retryFn?: () => Promise<Response>): Promise<T> => {
|
||||||
|
if (response.status === 401 && retryFn) {
|
||||||
|
const refreshed = await tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
const retryResponse = await retryFn();
|
||||||
|
return handleResponse<T>(retryResponse, silent);
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
if (!silent) notify.error('Session expired. Please log in again.');
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
if (!silent) notify.error('Session expired. Please log in again.');
|
if (!silent) notify.error('Session expired. Please log in again.');
|
||||||
@@ -86,17 +133,24 @@ export const apiClient = {
|
|||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
if (!token) throw new AuthError();
|
if (!token) throw new AuthError();
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/graphql`, {
|
const doFetch = () => fetch(`${API_URL}/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let response = await doFetch();
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
const refreshed = await tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
response = await doFetch();
|
||||||
|
} else {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
||||||
throw new AuthError();
|
throw new AuthError();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (json.errors) {
|
if (json.errors) {
|
||||||
@@ -110,20 +164,22 @@ export const apiClient = {
|
|||||||
|
|
||||||
// REST — all sidecar API calls go through these
|
// REST — all sidecar API calls go through these
|
||||||
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const doFetch = () => fetch(`${API_URL}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
return handleResponse<T>(response, options?.silent);
|
const response = await doFetch();
|
||||||
|
return handleResponse<T>(response, options?.silent, doFetch);
|
||||||
},
|
},
|
||||||
|
|
||||||
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
|
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const doFetch = () => fetch(`${API_URL}${path}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
return handleResponse<T>(response, options?.silent);
|
const response = await doFetch();
|
||||||
|
return handleResponse<T>(response, options?.silent, doFetch);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Health check — silent, no toasts
|
// Health check — silent, no toasts
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ export const CallDeskPage = () => {
|
|||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
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
|
const callerLead = callerNumber
|
||||||
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
||||||
@@ -63,7 +69,7 @@ export const CallDeskPage = () => {
|
|||||||
{/* Active call */}
|
{/* Active call */}
|
||||||
{isInCall && (
|
{isInCall && (
|
||||||
<div className="p-5">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export const LoginPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
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';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
const Phone01 = faIcon(faPhone);
|
const Phone01 = faIcon(faPhone);
|
||||||
@@ -8,16 +8,15 @@ const Mail01 = faIcon(faEnvelope);
|
|||||||
const Calendar = faIcon(faCalendar);
|
const Calendar = faIcon(faCalendar);
|
||||||
const MessageTextSquare01 = faIcon(faCommentDots);
|
const MessageTextSquare01 = faIcon(faCommentDots);
|
||||||
const Plus = faIcon(faPlus);
|
const Plus = faIcon(faPlus);
|
||||||
|
const CalendarCheck = faIcon(faCalendarCheck);
|
||||||
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
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 { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone, formatShortDate, getInitials } from '@/lib/format';
|
import { formatShortDate, getInitials } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
|
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
@@ -58,11 +57,84 @@ const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' |
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'timeline', label: 'Timeline' },
|
{ id: 'appointments', label: 'Appointments' },
|
||||||
{ id: 'calls', label: 'Calls' },
|
{ id: 'calls', label: 'Calls' },
|
||||||
|
{ id: 'timeline', label: 'Timeline' },
|
||||||
{ id: 'notes', label: 'Notes' },
|
{ 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 => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
if (seconds === null || seconds === 0) return '--';
|
if (seconds === null || seconds === 0) return '--';
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
@@ -192,62 +264,97 @@ const EmptyState = ({ icon, title, subtitle }: { icon: string; title: string; su
|
|||||||
|
|
||||||
export const Patient360Page = () => {
|
export const Patient360Page = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { leads, leadActivities, calls } = useData();
|
const [activeTab, setActiveTab] = useState<string>('appointments');
|
||||||
const [activeTab, setActiveTab] = useState<string>('timeline');
|
|
||||||
const [noteText, setNoteText] = useState('');
|
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
|
apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
|
||||||
const activities = useMemo(
|
PATIENT_QUERY,
|
||||||
() =>
|
{ id },
|
||||||
leadActivities
|
{ silent: true },
|
||||||
.filter((a) => a.leadId === id)
|
).then(data => {
|
||||||
.sort((a, b) => {
|
const p = data.patients.edges[0]?.node ?? null;
|
||||||
if (!a.occurredAt) return 1;
|
setPatient(p);
|
||||||
if (!b.occurredAt) return -1;
|
|
||||||
return new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime();
|
// Fetch activities from linked leads
|
||||||
}),
|
const leadIds = p?.leads?.edges?.map((e: any) => e.node.id) ?? [];
|
||||||
[leadActivities, 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 appointments = useMemo(
|
||||||
const leadCalls = useMemo(
|
() => patient?.appointments?.edges?.map(e => e.node) ?? [],
|
||||||
() =>
|
[patient],
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notes are activities of type NOTE_ADDED
|
|
||||||
const notes = useMemo(
|
const notes = useMemo(
|
||||||
() => activities.filter((a) => a.activityType === 'NOTE_ADDED'),
|
() => activities.filter((a) => a.activityType === 'NOTE_ADDED'),
|
||||||
[activities],
|
[activities],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!lead) {
|
const leadInfo = patient?.leads?.edges?.[0]?.node;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar title="Patient 360" />
|
<TopBar title="Patient 360" />
|
||||||
<div className="flex flex-1 items-center justify-center p-8">
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
if (!patient) {
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
return (
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
|
<>
|
||||||
|
<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 initials = getInitials(firstName || '?', lastName || '?');
|
||||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
|
const phoneRaw = patient.phones?.primaryPhoneNumber ?? '';
|
||||||
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
|
const email = patient.emails?.primaryEmail ?? null;
|
||||||
const email = lead.contactEmail?.[0]?.address ?? 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -263,8 +370,19 @@ export const Patient360Page = () => {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-display-xs font-bold text-primary">{fullName}</h2>
|
<h2 className="text-display-xs font-bold text-primary">{fullName}</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{lead.leadStatus && <LeadStatusBadge status={lead.leadStatus} />}
|
{patient.patientType && (
|
||||||
{lead.leadSource && <SourceTag source={lead.leadSource} />}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,10 +390,10 @@ export const Patient360Page = () => {
|
|||||||
{/* Contact details */}
|
{/* Contact details */}
|
||||||
<div className="flex flex-1 flex-col gap-2 lg:ml-auto lg:items-end">
|
<div className="flex flex-1 flex-col gap-2 lg:ml-auto lg:items-end">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{phone && (
|
{phoneRaw && (
|
||||||
<span className="flex items-center gap-1.5 text-sm text-secondary">
|
<span className="flex items-center gap-1.5 text-sm text-secondary">
|
||||||
<Phone01 className="size-4 text-fg-quaternary" />
|
<Phone01 className="size-4 text-fg-quaternary" />
|
||||||
{phone}
|
{phoneRaw}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{email && (
|
{email && (
|
||||||
@@ -285,25 +403,18 @@ export const Patient360Page = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lead.interestedService && (
|
{leadInfo?.interestedService && (
|
||||||
<span className="text-xs text-tertiary">
|
<span className="text-xs text-tertiary">
|
||||||
Interested in: {lead.interestedService}
|
Interested in: {leadInfo.interestedService}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI summary */}
|
{/* AI summary from linked lead */}
|
||||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
{leadInfo?.aiSummary && (
|
||||||
<div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3">
|
<div className="mt-4 rounded-lg border border-secondary bg-secondary_alt p-3">
|
||||||
{lead.aiSummary && (
|
<p className="text-sm text-secondary">{leadInfo.aiSummary}</p>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -335,10 +446,12 @@ export const Patient360Page = () => {
|
|||||||
id={item.id}
|
id={item.id}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
badge={
|
badge={
|
||||||
item.id === 'timeline'
|
item.id === 'appointments'
|
||||||
? activities.length
|
? appointments.length
|
||||||
: item.id === 'calls'
|
: item.id === 'calls'
|
||||||
? leadCalls.length
|
? patientCalls.length
|
||||||
|
: item.id === 'timeline'
|
||||||
|
? activities.length
|
||||||
: item.id === 'notes'
|
: item.id === 'notes'
|
||||||
? notes.length
|
? notes.length
|
||||||
: undefined
|
: undefined
|
||||||
@@ -347,6 +460,44 @@ export const Patient360Page = () => {
|
|||||||
)}
|
)}
|
||||||
</TabList>
|
</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 */}
|
{/* Timeline tab */}
|
||||||
<TabPanel id="timeline">
|
<TabPanel id="timeline">
|
||||||
<div className="mt-5 pb-7">
|
<div className="mt-5 pb-7">
|
||||||
@@ -370,25 +521,6 @@ export const Patient360Page = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</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 */}
|
{/* Notes tab */}
|
||||||
<TabPanel id="notes">
|
<TabPanel id="notes">
|
||||||
<div className="mt-5 pb-7">
|
<div className="mt-5 pb-7">
|
||||||
|
|||||||
Reference in New Issue
Block a user