Last tab on the revamp roadmap. Ships /app/admin as a shell that
renders either an access-denied card or a legacy-viewer link
depending on me.user.role, plus a new Admin nav group in the
sidebar that is hidden entirely for non-admin users.
client/src/components/Layout.tsx
NavItem gains an optional adminOnly flag. Layout now runs its own
useQuery<MeOk>(['auth-me']) — the query key is already shared with
Settings so the cache is reused across both. Nav groups whose
items all filter out (in practice: the Admin group for non-admins)
don't render their group header either, so the sidebar stays clean
for regular users. 5-minute staleTime so the header doesn't hammer
/me on every route change.
client/src/pages/Admin.tsx
Same /me query + role check. Non-admins land on the
admin-access-denied card; admins see the admin-shell with a link
to the legacy admin viewer. Sub-sections (users, feature flags,
OIDC, SMTP, AI prompts, model management, TTS/STT, email
templates, announcement banner, site-wide saved encounters) stay
in the vanilla admin page for now — each touches production state
immediately on save, so each port needs its own deliberate commit
with dedicated tests before shipping.
e2e/tests/admin-react.spec.js — one smoke test
The seeded e2e user is non-admin, so the expected outcome is the
access-denied card. Guard is written to accept either state so the
test still passes if the seed ever flips to an admin.
With this commit the React sidebar covers the full legacy nav:
• Encounters: Encounter HPI, Dictation HPI
• Notes: Hospital Course, Chart Review, SOAP, Well Visit, Sick Visit
• Clinical Tools: Vax Schedule, Catch-Up, PE Guide, Bedside,
Calculators, Pagers & Extensions, Learning Hub
• Account: Settings, FAQ
• Admin: Admin Panel (role-gated)
Client tsc -b + vite build clean. Bundle 462.48 kB / 131.98 kB gz.
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
// ============================================================
|
|
// LAYOUT — sidebar + main content shell shared across every page.
|
|
// Structure mirrors the vanilla app so a user moving between the two
|
|
// trees during migration sees consistent navigation.
|
|
// ============================================================
|
|
|
|
import { NavLink, Outlet } from 'react-router-dom';
|
|
import type { ReactNode } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import type { MeOk } from '@/shared/types';
|
|
|
|
interface NavItem {
|
|
to: string;
|
|
label: string;
|
|
available?: boolean; // false = rendered as "coming soon" stub
|
|
adminOnly?: boolean; // renders only when me.user.role === 'admin'
|
|
}
|
|
|
|
interface NavGroup {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
const NAV: NavGroup[] = [
|
|
{
|
|
label: 'Encounters',
|
|
items: [
|
|
{ to: '/encounter', label: 'Encounter HPI', available: true },
|
|
{ to: '/dictation', label: 'Dictation HPI', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Notes',
|
|
items: [
|
|
{ to: '/hospital', label: 'Hospital Course', available: true },
|
|
{ to: '/chart', label: 'Chart Review', available: true },
|
|
{ to: '/soap', label: 'SOAP Note', available: true },
|
|
{ to: '/wellvisit', label: 'Well Visit', available: true },
|
|
{ to: '/sickvisit', label: 'Sick Visit', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Clinical Tools',
|
|
items: [
|
|
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
|
|
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
|
|
{ to: '/peguide', label: 'Physical Exam Guide', available: true },
|
|
{ to: '/bedside', label: 'Bedside', available: true },
|
|
{ to: '/calculators', label: 'Calculators', available: true },
|
|
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
|
|
{ to: '/learning', label: 'Learning Hub', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Account',
|
|
items: [
|
|
{ to: '/settings', label: 'Settings', available: true },
|
|
{ to: '/faq', label: 'FAQ', available: true },
|
|
],
|
|
},
|
|
{
|
|
label: 'Admin',
|
|
items: [
|
|
{ to: '/admin', label: 'Admin Panel', available: true, adminOnly: true },
|
|
],
|
|
},
|
|
];
|
|
|
|
function SidebarLink({ item }: { item: NavItem }) {
|
|
if (!item.available) {
|
|
return (
|
|
<div
|
|
className="px-3 py-2 text-sm rounded-md text-muted-foreground italic cursor-not-allowed opacity-60"
|
|
title="Not yet ported to React — still available in the vanilla app at /"
|
|
>
|
|
{item.label} <span className="text-[10px]">· pending</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<NavLink
|
|
to={item.to}
|
|
className={({ isActive }) =>
|
|
'block px-3 py-2 text-sm rounded-md transition-colors ' +
|
|
(isActive
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-muted text-foreground')
|
|
}
|
|
>
|
|
{item.label}
|
|
</NavLink>
|
|
);
|
|
}
|
|
|
|
export default function Layout({ children }: { children?: ReactNode }) {
|
|
// One-shot /me fetch shared across the app via React Query cache.
|
|
// Settings already uses this queryKey, so the Layout gets it for free
|
|
// after the first Settings visit — and vice versa.
|
|
const { data: me } = useQuery<MeOk>({
|
|
queryKey: ['auth-me'],
|
|
queryFn: () => api.get<MeOk>('/api/auth/me'),
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
const isAdmin = me?.user.role === 'admin';
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-foreground flex">
|
|
{/* Sidebar */}
|
|
<aside className="w-64 border-r border-border bg-muted/30 flex-shrink-0 p-3 space-y-4 sticky top-0 h-screen overflow-y-auto">
|
|
<div className="px-2 py-1 border-b border-border pb-3">
|
|
<div className="font-semibold">Pediatric AI Scribe</div>
|
|
<a href="/" className="text-[11px] text-muted-foreground underline">
|
|
← back to legacy app
|
|
</a>
|
|
</div>
|
|
{NAV.map((group) => {
|
|
const items = group.items.filter((i) => !i.adminOnly || isAdmin);
|
|
if (items.length === 0) return null;
|
|
return (
|
|
<div key={group.label} className="space-y-1">
|
|
<div className="px-3 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{group.label}
|
|
</div>
|
|
{items.map((item) => (
|
|
<SidebarLink key={item.to} item={item} />
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<main className="flex-1 min-w-0">
|
|
{children ?? <Outlet />}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|