mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
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:
@@ -10,6 +10,7 @@ import {
|
||||
faGear,
|
||||
faGrid2,
|
||||
faHospitalUser,
|
||||
faCalendarCheck,
|
||||
faPhone,
|
||||
faPlug,
|
||||
faUsers,
|
||||
@@ -44,6 +45,7 @@ const IconPhone = faIcon(faPhone);
|
||||
const IconClockRewind = faIcon(faClockRotateLeft);
|
||||
const IconUsers = faIcon(faUsers);
|
||||
const IconHospitalUser = faIcon(faHospitalUser);
|
||||
const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
|
||||
type NavSection = {
|
||||
label: string;
|
||||
@@ -71,6 +73,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||
]},
|
||||
];
|
||||
@@ -81,6 +84,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
||||
]},
|
||||
|
||||
@@ -102,7 +102,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||
id: a.id,
|
||||
type: 'appointment',
|
||||
title: a.doctorName ?? 'Appointment',
|
||||
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(' · '),
|
||||
subtitle: [a.department, date, a.status].filter(Boolean).join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { MyPerformancePage } from "@/pages/my-performance";
|
||||
import { AppointmentsPage } from "@/pages/appointments";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { DataProvider } from "@/providers/data-provider";
|
||||
import { RouteProvider } from "@/providers/router-provider";
|
||||
@@ -55,6 +56,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
<Route path="/appointments" element={<AppointmentsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
|
||||
235
src/pages/appointments.tsx
Normal file
235
src/pages/appointments.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user