mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: build all data pages — worklist table, call history, patients, dashboard, reports
Worklist (call-desk): - Upgrade to Untitled UI Table with columns: Priority, Patient, Phone, Type, SLA, Actions - Filter tabs: All Tasks / Missed Calls / Callbacks / Follow-ups with counts - Search by name or phone - SLA timer color-coded: green <15m, amber <30m, red >30m Call History: - Full table: Type (direction icon), Patient (matched from leads), Phone, Duration, Outcome, Agent, Recording (play/pause), Time - Search + All/Inbound/Outbound/Missed filter - Recording playback via native <audio> Patients: - New page with table: Patient (avatar+name+age), Contact, Type, Gender, Status, Actions - Search + status filter - Call + View Details actions - Added patients to DataProvider + transforms + queries - Route /patients added, sidebar nav updated for cc-agent + executive Supervisor Dashboard: - KPI cards: Total Calls, Inbound, Outbound, Missed - Performance metrics: Avg Response Time, Callback Time, Conversion % - Agent performance table with per-agent stats - Missed Call Queue - AI Assistant section - Day/Week/Month filter Reports: - ECharts bar chart: Call Volume Trend (7-day, Inbound vs Outbound) - ECharts donut chart: Call Outcomes (Booked, Follow-up, Info, Missed) - KPI cards with trend indicators (+/-%) - Route /reports, sidebar Analytics → /reports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
253
src/pages/patients.tsx
Normal file
253
src/pages/patients.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { SearchLg } from '@untitledui/icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
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 { useData } from '@/providers/data-provider';
|
||||
import { getInitials } from '@/lib/format';
|
||||
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 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">
|
||||
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
|
||||
|
||||
<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">
|
||||
{/* 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" />
|
||||
<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}>
|
||||
{/* 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-2">
|
||||
{phone && (
|
||||
<ClickToCallButton
|
||||
phoneNumber={phone}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="link-color"
|
||||
onClick={() => navigate(`/patient/${patient.id}`)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user