pediatric-ai-scribe-v3/client/src/pages/Catchup.tsx
Daniel efd3e32574 feat: port Vaccine Schedule + Catch-Up Schedule (real tables, not stubs)
Instead of shipping placeholder pages, added a new backend endpoint
so the React client can render the real AAP/CDC tables without
duplicating the 2000-line pediatricScheduleData module in the client
bundle:

  GET /api/schedule-data  (authed)
    Returns { visitAges, periodicity, catchUpSchedule, vaccineFullNames }
    — everything the vaccine table and catch-up views need from the
    server-side pediatricScheduleData require(). VACCINE_FULL_NAMES
    (which was inlined in public/js/wellVisit.js) now lives in the
    route file so it's single-sourced.

client/src/pages/VaxSchedule.tsx
  useQuery → /api/schedule-data. Renders the full vaccine × visit-age
  grid with sticky header + sticky first column for long scrolling.
  Each filled cell shows the dose number or bullet, with the original
  note text on hover.

client/src/pages/Catchup.tsx
  Card-per-vaccine layout with min-age / min-interval tables and
  catch-up notes. Matches the vanilla layout.

Layout: vaccine + catch-up links now available in the sidebar.

Typecheck green both sides. Vite build 382 kB / 112 kB gzipped.
2026-04-23 22:25:41 +02:00

88 lines
3.5 KiB
TypeScript

// ============================================================
// CATCH-UP SCHEDULE — CDC catch-up immunization tables from
// GET /api/schedule-data.
// ============================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface CatchUpSeries { dose: number | string; minimumAge?: string; minimumIntervalToPrev?: string; notes?: string }
interface CatchUpEntry {
minimumAgeForDose1?: string;
series?: CatchUpSeries[];
catchUpNotes?: string | string[];
}
interface ScheduleData {
catchUpSchedule: Record<string, CatchUpEntry>;
vaccineFullNames: Record<string, string>;
}
export default function Catchup() {
const { data, isLoading, error } = useQuery<ScheduleData>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
});
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Catch-Up Schedule</h1>
<p className="text-sm text-muted-foreground">
CDC 2025 catch-up immunization schedule minimum ages and intervals per vaccine.
</p>
</header>
{Object.entries(data.catchUpSchedule).map(([key, v]) => {
const fullName = data.vaccineFullNames[key] || key;
const notes = v.catchUpNotes
? (Array.isArray(v.catchUpNotes) ? v.catchUpNotes : [v.catchUpNotes])
: [];
return (
<section key={key} className="rounded-lg border border-border bg-card overflow-hidden">
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
<h2 className="text-sm font-semibold">{fullName}</h2>
{v.minimumAgeForDose1 && (
<span className="text-xs text-muted-foreground">
Min age dose 1: <strong>{v.minimumAgeForDose1}</strong>
</span>
)}
</header>
{v.series && v.series.length > 0 && (
<table className="w-full text-xs">
<thead className="bg-muted/20">
<tr>
<th className="text-left px-3 py-2">Dose</th>
<th className="text-left px-3 py-2">Min age</th>
<th className="text-left px-3 py-2">Min interval from prev</th>
<th className="text-left px-3 py-2">Notes</th>
</tr>
</thead>
<tbody>
{v.series.map((s) => (
<tr key={String(s.dose)} className="border-t border-border">
<td className="px-3 py-2 font-semibold">Dose {s.dose}</td>
<td className="px-3 py-2">{s.minimumAge || '—'}</td>
<td className="px-3 py-2">{s.minimumIntervalToPrev || '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{s.notes || ''}</td>
</tr>
))}
</tbody>
</table>
)}
{notes.length > 0 && (
<ul className="list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1">
{notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
)}
</section>
);
})}
</div>
);
}