Files
helix-engage/src/pages/patients.tsx
2026-03-25 16:14:15 +05:30

319 lines
18 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
// Button removed — actions are icon-only now
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 => {
if (!dateOfBirth) return null;
const dob = new Date(dateOfBirth);
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const monthDiff = today.getMonth() - dob.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
age--;
}
return age;
};
const formatGender = (gender: string | null): string => {
if (!gender) return '';
switch (gender) {
case 'MALE': return 'M';
case 'FEMALE': return 'F';
case 'OTHER': return 'O';
default: return '';
}
};
const getPatientDisplayName = (patient: Patient): string => {
if (patient.fullName) {
return `${patient.fullName.firstName} ${patient.fullName.lastName}`.trim();
}
return 'Unknown';
};
const getPatientPhone = (patient: Patient): string => {
return patient.phones?.primaryPhoneNumber ?? '';
};
const getPatientEmail = (patient: Patient): string => {
return patient.emails?.primaryEmail ?? '';
};
export const PatientsPage = () => {
const { patients, loading } = useData();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const filteredPatients = useMemo(() => {
return patients.filter((patient) => {
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
const name = getPatientDisplayName(patient).toLowerCase();
const phone = getPatientPhone(patient).toLowerCase();
const email = getPatientEmail(patient).toLowerCase();
if (!name.includes(query) && !phone.includes(query) && !email.includes(query)) {
return false;
}
}
// Status filter — treat all patients as active for now since we don't have a status field
if (statusFilter === 'inactive') return false;
return true;
});
}, [patients, searchQuery, statusFilter]);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7">
<TableCard.Root size="sm">
<TableCard.Header
title="All Patients"
badge={filteredPatients.length}
description="Manage and view patient records"
contentTrailing={
<div className="flex items-center gap-2">
<button
onClick={() => setPanelOpen(!panelOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
{/* Status filter buttons */}
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['all', 'active', 'inactive'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
statusFilter === status
? 'bg-active text-brand-secondary'
: 'bg-primary text-tertiary hover:bg-primary_hover'
}`}
>
{status}
</button>
))}
</div>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={(value) => setSearchQuery(value)}
aria-label="Search patients"
/>
</div>
</div>
}
/>
{loading ? (
<div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading patients...</p>
</div>
) : filteredPatients.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-2">
<FontAwesomeIcon icon={faUser} className="size-8 text-fg-quaternary" />
<h3 className="text-sm font-semibold text-primary">No patients found</h3>
<p className="text-sm text-tertiary">
{searchQuery ? 'Try adjusting your search.' : 'No patient records available yet.'}
</p>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" />
<Table.Head label="TYPE" />
<Table.Head label="GENDER" />
<Table.Head label="AGE" />
<Table.Head label="STATUS" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={filteredPatients}>
{(patient) => {
const displayName = getPatientDisplayName(patient);
const age = computeAge(patient.dateOfBirth);
const gender = formatGender(patient.gender);
const phone = getPatientPhone(patient);
const email = getPatientEmail(patient);
const initials = patient.fullName
? getInitials(patient.fullName.firstName, patient.fullName.lastName)
: '?';
return (
<Table.Row
id={patient.id}
className={cx(
'cursor-pointer',
selectedPatient?.id === patient.id && 'bg-brand-primary'
)}
onAction={() => {
setSelectedPatient(patient);
setPanelOpen(true);
}}
>
{/* Patient name + avatar */}
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar size="sm" initials={initials} />
<div className="flex flex-col">
<span className="text-sm font-medium text-primary">
{displayName}
</span>
{(age !== null || gender) && (
<span className="text-xs text-tertiary">
{[
age !== null ? `${age}y` : null,
gender || null,
].filter(Boolean).join(' / ')}
</span>
)}
</div>
</div>
</Table.Cell>
{/* Contact */}
<Table.Cell>
<div className="flex flex-col">
{phone ? (
<span className="text-sm text-secondary">{phone}</span>
) : (
<span className="text-sm text-placeholder">No phone</span>
)}
{email ? (
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
) : null}
</div>
</Table.Cell>
{/* Type */}
<Table.Cell>
{patient.patientType ? (
<Badge size="sm" color="gray">
{patient.patientType}
</Badge>
) : (
<span className="text-sm text-placeholder"></span>
)}
</Table.Cell>
{/* Gender */}
<Table.Cell>
<span className="text-sm text-secondary">
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
</span>
</Table.Cell>
{/* Age */}
<Table.Cell>
<span className="text-sm text-secondary">
{age !== null ? `${age} yrs` : '—'}
</span>
</Table.Cell>
{/* Status */}
<Table.Cell>
<Badge size="sm" color="success" type="pill-color">
Active
</Badge>
</Table.Cell>
{/* Actions */}
<Table.Cell>
<div className="flex items-center gap-1">
{phone && (
<>
<ClickToCallButton
phoneNumber={phone}
size="sm"
label=""
/>
<button
onClick={() => window.open(`sms:+91${phone}`, '_self')}
title="SMS"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
</button>
<button
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
title="WhatsApp"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
</button>
</>
)}
<button
onClick={() => navigate(`/patient/${patient.id}`)}
title="View patient"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faEye} className="size-4" />
</button>
</div>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</TableCard.Root>
</div>
{/* Patient Profile Panel - collapsible with smooth transition */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
panelOpen ? "w-[450px]" : "w-0 border-l-0",
)}>
{panelOpen && (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
<h3 className="text-sm font-semibold text-primary">Patient Profile</h3>
<button
onClick={() => setPanelOpen(false)}
className="flex size-6 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faSidebarFlip} className="size-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<PatientProfilePanel patient={selectedPatient} />
</div>
</div>
)}
</div>
</div>
</div>
);
};