diff --git a/src/components/shared/patient-profile-panel.tsx b/src/components/shared/patient-profile-panel.tsx new file mode 100644 index 0000000..1d74fbd --- /dev/null +++ b/src/components/shared/patient-profile-panel.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; +import { Badge } from '@/components/base/badges/badges'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone, formatShortDate } from '@/lib/format'; +import type { LeadActivity, Patient } from '@/types/entities'; + +const CalendarCheck = faIcon(faCalendarCheck); + +interface PatientProfilePanelProps { + patient: Patient | null; + activities?: LeadActivity[]; +} + +/** + * Reusable Patient/Lead 360 profile panel + * Shows comprehensive patient information including appointments, calls, and activity timeline + * Can be used with either Patient or Lead entities + */ +export const PatientProfilePanel = ({ patient, activities = [] }: PatientProfilePanelProps) => { + const [patientData, setPatientData] = useState(null); + const [loadingPatient, setLoadingPatient] = useState(false); + + // Fetch full patient data with appointments and calls + useEffect(() => { + if (!patient?.id) { + 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: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt status doctorName department reasonForVisit appointmentType + } } } + calls(first: 10, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id callStatus disposition direction startedAt durationSec agentName + } } } + } } } }`, + { id: patient.id }, + { silent: true }, + ).then(data => { + setPatientData(data.patients.edges[0]?.node ?? null); + }).catch(() => setPatientData(null)) + .finally(() => setLoadingPatient(false)); + }, [patient?.id]); + + if (!patient) { + return ( +
+ +

Select a patient to see their full profile.

+
+ ); + } + + const firstName = patient.fullName?.firstName ?? ''; + const lastName = patient.fullName?.lastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() || 'Unknown'; + const phone = patient.phones?.primaryPhoneNumber; + const email = patient.emails?.primaryEmail; + + 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; + + // Filter activities for this patient (if Lead activities are provided) + const patientActivities = activities + .filter((a) => a.leadId === patient.id) + .sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime()) + .slice(0, 10); + + return ( +
+ {/* Profile */} +
+

{fullName}

+ {phone &&

{formatPhone({ number: phone, callingCode: '+91' })}

} + {email &&

{email}

} +
+ {patientAge !== null && patientGender && ( + {patientAge}y · {patientGender} + )} + {patient.patientType && {patient.patientType}} + {patient.gender && ( + + {patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase()} + + )} +
+
+ + {/* Loading state */} + {loadingPatient && ( +

Loading patient details...

+ )} + + {/* Appointments */} + {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}` : ''} +

+
+
+ ); + })} +
+
+ )} + + {/* Recent calls */} + {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) : ''} +
+ ))} +
+
+ )} + + {/* Activity timeline (if activities provided) */} + {patientActivities.length > 0 && ( +
+

Activity

+
+ {patientActivities.map((a) => ( +
+
+
+

{a.summary}

+

+ {a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''} +

+
+
+ ))} +
+
+ )} + + {/* Empty state when no data */} + {!loadingPatient && appointments.length === 0 && patientCalls.length === 0 && patientActivities.length === 0 && ( +
+ +

No appointments or call history yet

+
+ )} +
+ ); +}; diff --git a/src/pages/patients.tsx b/src/pages/patients.tsx index aa9a688..a5338f4 100644 --- a/src/pages/patients.tsx +++ b/src/pages/patients.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots } from '@fortawesome/pro-duotone-svg-icons'; +import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faIcon } from '@/lib/icon-wrapper'; const SearchLg = faIcon(faMagnifyingGlass); @@ -12,8 +12,10 @@ import { Input } from '@/components/base/input/input'; import { Table, TableCard } from '@/components/application/table/table'; import { TopBar } from '@/components/layout/top-bar'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; +import { PatientProfilePanel } from '@/components/shared/patient-profile-panel'; import { useData } from '@/providers/data-provider'; import { getInitials } from '@/lib/format'; +import { cx } from '@/utils/cx'; import type { Patient } from '@/types/entities'; const computeAge = (dateOfBirth: string | null): number | null => { @@ -58,6 +60,8 @@ export const PatientsPage = () => { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [selectedPatient, setSelectedPatient] = useState(null); + const [panelOpen, setPanelOpen] = useState(false); const filteredPatients = useMemo(() => { return patients.filter((patient) => { @@ -80,10 +84,11 @@ export const PatientsPage = () => { }, [patients, searchQuery, statusFilter]); return ( -
+
-
+
+
{ description="Manage and view patient records" contentTrailing={
+ {/* Status filter buttons */}
{(['all', 'active', 'inactive'] as const).map((status) => ( @@ -157,7 +169,17 @@ export const PatientsPage = () => { : '?'; return ( - + { + setSelectedPatient(patient); + setPanelOpen(true); + }} + > {/* Patient name + avatar */}
@@ -266,6 +288,30 @@ export const PatientsPage = () => { )} +
+ + {/* Patient Profile Panel - collapsible with smooth transition */} +
+ {panelOpen && ( +
+
+

Patient Profile

+ +
+
+ +
+
+ )} +
);