mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
New reusable PageHeader component (src/components/layout/page-header.tsx) with consistent layout: title + badge + subtitle on left, controls on right, optional tabs below with no extra borders. Refactored pages: - All Leads: inline header → PageHeader - Contacts: inline header → PageHeader - Appointments v2: inline header → PageHeader with tabs - Call History: removed p-7 wrapper + TableCard.Root → flat table - Patients: removed p-7 wrapper + TableCard.Root → flat table - Missed Calls: removed TopBar → PageHeader with tabs All pages now share identical header spacing, font sizing, and control alignment. No more double borders from tab + container. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
16 KiB
TypeScript
304 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
// useNavigate removed — row click opens profile panel
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faUser, faMagnifyingGlass, faCommentDots, faMessageDots, faEllipsisVertical, 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 { Input } from '@/components/base/input/input';
|
|
import { Table } from '@/components/application/table/table';
|
|
import { PageHeader } from '@/components/layout/page-header';
|
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
|
|
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
|
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 ?? '';
|
|
};
|
|
|
|
const HamburgerMenu = ({ phone }: { phone: string }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
|
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="More actions"
|
|
>
|
|
<FontAwesomeIcon icon={faEllipsisVertical} className="size-4" />
|
|
</button>
|
|
{open && (
|
|
<div className="absolute top-full right-0 mt-1 w-40 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden py-1">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); window.open(`sms:+91${phone}`, '_self'); setOpen(false); }}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
|
>
|
|
<FontAwesomeIcon icon={faCommentDots} className="size-3.5 text-fg-quaternary" />
|
|
Send SMS
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); window.open(`https://wa.me/91${phone}`, '_blank'); setOpen(false); }}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
|
>
|
|
<FontAwesomeIcon icon={faMessageDots} className="size-3.5 text-fg-quaternary" />
|
|
WhatsApp
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const PatientsPage = () => {
|
|
const { patients, loading } = useData();
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const PAGE_SIZE = 15;
|
|
|
|
const filteredPatients = useMemo(() => {
|
|
return patients.filter((patient) => {
|
|
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;
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
}, [patients, searchQuery]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filteredPatients.length / PAGE_SIZE));
|
|
const pagedPatients = filteredPatients.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
|
const handleSearch = (val: string) => { setSearchQuery(val); setCurrentPage(1); };
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<PageHeader
|
|
title="All Patients"
|
|
badge={filteredPatients.length}
|
|
subtitle="Manage and view patient records"
|
|
controls={
|
|
<>
|
|
<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>
|
|
|
|
<div className="w-56">
|
|
<Input
|
|
placeholder="Search by name or phone..."
|
|
icon={SearchLg}
|
|
size="sm"
|
|
value={searchQuery}
|
|
onChange={handleSearch}
|
|
aria-label="Search patients"
|
|
/>
|
|
</div>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
|
{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="PHONE" />
|
|
<Table.Head label="EMAIL" />
|
|
<Table.Head label="GENDER" />
|
|
<Table.Head label="AGE" />
|
|
<Table.Head label="" className="w-12" />
|
|
</Table.Header>
|
|
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
|
{(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>
|
|
|
|
{/* Phone — clickable to dial */}
|
|
<Table.Cell>
|
|
{phone ? (
|
|
<PhoneActionCell phoneNumber={phone} displayNumber={phone} />
|
|
) : (
|
|
<span className="text-sm text-placeholder">No phone</span>
|
|
)}
|
|
</Table.Cell>
|
|
|
|
{/* Email */}
|
|
<Table.Cell>
|
|
{email ? (
|
|
<span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
|
|
) : (
|
|
<span className="text-sm text-quaternary">—</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>
|
|
|
|
{/* Hamburger — SMS + WhatsApp */}
|
|
<Table.Cell>
|
|
{phone ? (
|
|
<HamburgerMenu phone={phone} />
|
|
) : null}
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
);
|
|
}}
|
|
</Table.Body>
|
|
</Table>
|
|
)}
|
|
|
|
{totalPages > 1 && (
|
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
|
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|