Files
helix-engage/src/pages/appointments.tsx
saridsa2 3296977a6a fix: branch column shows clinic name instead of department
Appointments page was using department for the Branch column. Now fetches
doctor.clinic.clinicName from the GraphQL query and displays that. Search
filter also updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:16:31 +05:30

246 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table';
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { TopBar } from '@/components/layout/top-bar';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
type AppointmentRecord = {
id: string;
scheduledAt: string | null;
durationMin: number | null;
appointmentType: string | null;
status: string | null;
doctorName: string | null;
department: string | null;
reasonForVisit: string | null;
patient: {
id: string;
fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null;
} | null;
doctor: {
clinic: { clinicName: string } | null;
} | null;
};
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
const STATUS_COLORS: Record<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
SCHEDULED: 'brand',
CONFIRMED: 'brand',
COMPLETED: 'success',
CANCELLED: 'error',
NO_SHOW: 'warning',
RESCHEDULED: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Booked',
CONFIRMED: 'Confirmed',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
NO_SHOW: 'No Show',
RESCHEDULED: 'Rescheduled',
FOLLOW_UP: 'Follow-up',
CONSULTATION: 'Consultation',
};
const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id clinic { clinicName } }
} } } }`;
const formatDate = (iso: string): string => formatDateOnly(iso);
const formatTime = (iso: string): string => formatTimeOnly(iso);
export const AppointmentsPage = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const PAGE_SIZE = 20;
useEffect(() => {
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of appointments) {
const s = a.status ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [appointments]);
const filtered = useMemo(() => {
let rows = appointments;
if (tab !== 'all') {
rows = rows.filter(a => a.status === tab);
}
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(a => {
const patientName = `${a.patient?.fullName?.firstName ?? ''} ${a.patient?.fullName?.lastName ?? ''}`.toLowerCase();
const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
const doctor = (a.doctorName ?? '').toLowerCase();
const dept = (a.department ?? '').toLowerCase();
const branch = (a.doctor?.clinic?.clinicName ?? '').toLowerCase();
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
});
}
return rows;
}, [appointments, tab, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// Reset page on filter/search change
useEffect(() => { setPage(1); }, [tab, search]);
const tabItems = [
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
];
return (
<>
<TopBar title="Appointment Master" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + search */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-56 shrink-0 pb-1">
<Input
placeholder="Search patient, doctor, branch..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search appointments"
/>
</div>
</div>
{/* Table */}
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading appointments...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Patient" isRowHeader />
<Table.Head label="Date" className="w-28" />
<Table.Head label="Time" className="w-24" />
<Table.Head label="Doctor" />
<Table.Head label="Department" className="w-28" />
<Table.Head label="Branch" className="w-36" />
<Table.Head label="Status" className="w-28" />
<Table.Head label="Chief Complaint" />
</Table.Header>
<Table.Body items={pagedRows}>
{(appt) => {
const patientName = appt.patient
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
: 'Unknown';
const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
const branch = appt.doctor?.clinic?.clinicName ?? '—';
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
return (
<Table.Row id={appt.id}>
<Table.Cell>
<div className="min-w-0">
<span className="text-sm font-medium text-primary block truncate max-w-[180px]">
{patientName}
</span>
{phone && (
<PhoneActionCell
phoneNumber={phone}
displayNumber={formatPhone({ number: phone, callingCode: '+91' })}
/>
)}
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">
{appt.scheduledAt ? formatDate(appt.scheduledAt) : '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">
{appt.scheduledAt ? formatTime(appt.scheduledAt) : '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{appt.doctorName ?? '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">
{statusLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[200px]">
{appt.reasonForVisit ?? '—'}
</span>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</div>
</>
);
};