feat: appointments page, data refresh on login, multi-agent spec + plan

- Appointment Master page with status tabs, search, PhoneActionCell
- Login calls DataProvider.refresh() to load data after auth
- Sidebar: appointments nav for CC agents + executives
- Multi-agent SIP + lockout spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:08:23 +05:30
parent 5816cc0b5c
commit b9b7ee275f
7 changed files with 1064 additions and 1 deletions

235
src/pages/appointments.tsx Normal file
View File

@@ -0,0 +1,235 @@
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 { 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 } 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 { clinic { clinicName } }
} } } }`;
const formatDate = (iso: string): string => {
const d = new Date(iso);
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
};
const formatTime = (iso: string): string => {
const d = new Date(iso);
return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true });
};
export const AppointmentsPage = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
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 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 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-1 overflow-y-auto 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={filtered}>
{(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>
</div>
</>
);
};

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { Button } from '@/components/base/buttons/button';
import { SocialButton } from '@/components/base/buttons/social-button';
import { Checkbox } from '@/components/base/checkbox/checkbox';
@@ -10,6 +11,7 @@ import { Input } from '@/components/base/input/input';
export const LoginPage = () => {
const { loginWithUser } = useAuth();
const { refresh } = useData();
const navigate = useNavigate();
const saved = localStorage.getItem('helix_remember');
@@ -59,6 +61,7 @@ export const LoginPage = () => {
platformRoles: u?.platformRoles,
});
refresh();
navigate('/');
} catch (err: any) {
setError(err.message);