Skip to content
97 changes: 51 additions & 46 deletions apps/ehr/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ import { ProtectedRoute } from './components/routing/ProtectedRoute';
import { TestErrorPage } from './components/TestErrorPage';
import { FEATURE_FLAGS } from './constants/feature-flags';
import { CustomThemeProvider } from './CustomThemeProvider';
import { DEFAULT_BILLING_PATH } from './features/admin/adminNav';
import {
BILLING_URL,
CHARGE_MASTERS_URL,
FEE_SCHEDULES_URL,
GLOBAL_TEMPLATES_URL,
INSURANCES_URL,
OUTREACH_URL,
PAYMENT_LOCATIONS_URL,
VIRTUAL_LOCATIONS_URL,
} from './features/admin/adminRoutes';
import { AdminLayout } from './features/admin/AdminSidebar';
import { UnsolicitedResultsInbox } from './features/external-labs/pages/UnsolicitedResultsInbox';
import { UnsolicitedResultsMatch } from './features/external-labs/pages/UnsolicitedResultsMatch';
import { UnsolicitedResultsReview } from './features/external-labs/pages/UnsolicitedResultsReview';
Expand Down Expand Up @@ -82,16 +94,6 @@ setupSentry({

const InPersonRoutingLazy = lazy(() => import('./features/visits/in-person/routing/InPersonRouting'));

export const INSURANCES_URL = '/admin/insurances';
export const FEE_SCHEDULES_URL = '/admin/fee-schedule';
export const CHARGE_MASTERS_URL = '/admin/charge-masters';
export const VIRTUAL_LOCATIONS_URL = '/admin/virtual-locations';
export const BILLING_URL = '/admin/billing';
export const BILLING_INSURANCE_URL = '/admin/billing/insurance';
export const PAYMENT_LOCATIONS_URL = '/admin/billing/payments/locations';
export const OUTREACH_URL = '/admin/outreach';
export const GLOBAL_TEMPLATES_URL = '/admin/global-templates';

const MUI_X_LICENSE_KEY = import.meta.env.VITE_APP_MUI_X_LICENSE_KEY;
if (MUI_X_LICENSE_KEY != null) {
LicenseInfo.setLicenseKey(MUI_X_LICENSE_KEY);
Expand Down Expand Up @@ -241,42 +243,45 @@ function App(): ReactElement {
{FEATURE_FLAGS.LEGACY_PATIENT_FOLLOWUPS_ENABLED && (
<Route path="/patient/:id/followup/:encounterId" element={<PatientFollowup />} />
)}
<Route path="/admin" element={<AdminPage />} />
<Route path={`${BILLING_URL}/:billingTab`} element={<AdminPage />} />
<Route path={`${BILLING_URL}/:billingTab/:insuranceTab`} element={<AdminPage />} />
<Route path={`${OUTREACH_URL}/:outreachSubTab`} element={<AdminPage />} />
<Route path={`${OUTREACH_URL}/:outreachSubTab/:outreachDetailTab`} element={<AdminPage />} />
<Route path="/admin/:adminTab" element={<AdminPage />} />
<Route path="/admin/:adminTab/:subTab" element={<AdminPage />} />
<Route path="/admin/quick-picks/procedure/:quickPickId" element={<ProcedureQuickPickDetailPage />} />
<Route path="/admin/quick-picks/radiology/:quickPickId" element={<RadiologyQuickPickDetailPage />} />
<Route
path="/admin/quick-picks/immunization/:quickPickId"
element={<ImmunizationQuickPickDetailPage />}
/>
<Route
path="/admin/quick-picks/in-house-medication/:quickPickId"
element={<InHouseMedicationQuickPickDetailPage />}
/>
<Route path="/admin/employees/add" element={<AddEmployeePage />} />
<Route path="/admin/employee/:id" element={<EditEmployeePage />} />
<Route path="/admin/schedule/:schedule-type/add" element={<AddSchedulePage />} />
<Route path="/admin/group/id/:group-id" element={<GroupPage />} />
<Route path="/admin/schedule/id/:schedule-id" element={<SchedulePage />} />
<Route path="/admin/schedule/new/:schedule-type/:owner-id" element={<SchedulePage />} />
<Route path="/admin/medications/add" element={<AddMedicationPage />} />
<Route path="/admin/medication/:medication-id" element={<UpdateMedicationPage />} />
<Route path={`${VIRTUAL_LOCATIONS_URL}/:id`} element={<EditVirtualLocationPage />} />
<Route path={`${INSURANCES_URL}/:insuranceTab/:insurance`} element={<EditInsurance />} />
<Route path={`${BILLING_URL}/:billingTab/:insuranceTab/:insurance`} element={<EditInsurance />} />
<Route path={`${FEE_SCHEDULES_URL}/:id`} element={<EditChargeItem />} />
<Route path={`${CHARGE_MASTERS_URL}/:id`} element={<EditChargeItem mode="charge-master" />} />
<Route path={`${PAYMENT_LOCATIONS_URL}/:id`} element={<PaymentLocationDetailPage />} />
<Route path={`${GLOBAL_TEMPLATES_URL}/:templateId`} element={<GlobalTemplateDetailPage />} />
<Route path="/admin/in-house-labs/add" element={<AdminAddInHouseLab />} />
<Route path="/admin/in-house-labs/:activityDefinitionId" element={<AdminInHouseLabDetails />} />
<Route path="/admin/lab-sets/add" element={<AdminAddLabSet />} />
<Route path="/admin/lab-sets/:listId" element={<AdminLabSetDetails />} />
<Route element={<AdminLayout />}>
<Route path="/admin" element={<AdminPage />} />
<Route path={BILLING_URL} element={<Navigate to={DEFAULT_BILLING_PATH} replace />} />
<Route path={`${BILLING_URL}/:billingTab`} element={<AdminPage />} />
<Route path={`${BILLING_URL}/:billingTab/:insuranceTab`} element={<AdminPage />} />
<Route path={`${OUTREACH_URL}/:outreachSubTab`} element={<AdminPage />} />
<Route path={`${OUTREACH_URL}/:outreachSubTab/:outreachDetailTab`} element={<AdminPage />} />
<Route path="/admin/:adminTab" element={<AdminPage />} />
<Route path="/admin/:adminTab/:subTab" element={<AdminPage />} />
Comment thread
RomanGolchuk marked this conversation as resolved.
<Route path="/admin/quick-picks/procedure/:quickPickId" element={<ProcedureQuickPickDetailPage />} />
<Route path="/admin/quick-picks/radiology/:quickPickId" element={<RadiologyQuickPickDetailPage />} />
<Route
path="/admin/quick-picks/immunization/:quickPickId"
element={<ImmunizationQuickPickDetailPage />}
/>
<Route
path="/admin/quick-picks/in-house-medication/:quickPickId"
element={<InHouseMedicationQuickPickDetailPage />}
/>
<Route path="/admin/employees/add" element={<AddEmployeePage />} />
<Route path="/admin/employee/:id" element={<EditEmployeePage />} />
<Route path="/admin/schedule/:schedule-type/add" element={<AddSchedulePage />} />
<Route path="/admin/group/id/:group-id" element={<GroupPage />} />
<Route path="/admin/schedule/id/:schedule-id" element={<SchedulePage />} />
<Route path="/admin/schedule/new/:schedule-type/:owner-id" element={<SchedulePage />} />
<Route path="/admin/medications/add" element={<AddMedicationPage />} />
<Route path="/admin/medication/:medication-id" element={<UpdateMedicationPage />} />
<Route path={`${VIRTUAL_LOCATIONS_URL}/:id`} element={<EditVirtualLocationPage />} />
<Route path={`${INSURANCES_URL}/:insuranceTab/:insurance`} element={<EditInsurance />} />
<Route path={`${BILLING_URL}/:billingTab/:insuranceTab/:insurance`} element={<EditInsurance />} />
<Route path={`${FEE_SCHEDULES_URL}/:id`} element={<EditChargeItem />} />
<Route path={`${CHARGE_MASTERS_URL}/:id`} element={<EditChargeItem mode="charge-master" />} />
<Route path={`${PAYMENT_LOCATIONS_URL}/:id`} element={<PaymentLocationDetailPage />} />
<Route path={`${GLOBAL_TEMPLATES_URL}/:templateId`} element={<GlobalTemplateDetailPage />} />
<Route path="/admin/in-house-labs/add" element={<AdminAddInHouseLab />} />
<Route path="/admin/in-house-labs/:activityDefinitionId" element={<AdminInHouseLabDetails />} />
<Route path="/admin/lab-sets/add" element={<AdminAddLabSet />} />
<Route path="/admin/lab-sets/:listId" element={<AdminLabSetDetails />} />
</Route>
{FEATURE_FLAGS.LEGACY_DATA_ENABLED && <Route path="/legacy-data" element={<LegacyDataPage />} />}
<Route path="/tasks" element={<Tasks />} />
<Route path="*" element={<Navigate to={'/'} />} />
Expand Down
23 changes: 23 additions & 0 deletions apps/ehr/src/features/admin/AdminPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createContext, FC, PropsWithChildren, useContext } from 'react';
import { createPortal } from 'react-dom';

/**
* Holds the DOM node of the shared admin page header's action area. Admin pages render their
* primary CTA into it via {@link AdminHeaderActionSlot}, so every page shares one title+action row.
*/
const AdminHeaderSlotContext = createContext<HTMLElement | null>(null);

export const AdminHeaderSlotProvider = AdminHeaderSlotContext.Provider;

/**
* Renders its children into the shared admin page header, on the same row as the page title and
* aligned to the right. Place it anywhere inside an admin page to lift that page's primary action
* (e.g. an "Add"/"New" button) into the common header while keeping its handler and state local.
*/
export const AdminHeaderActionSlot: FC<PropsWithChildren> = ({ children }) => {
const container = useContext(AdminHeaderSlotContext);
if (!container) {
return null;
}
return createPortal(children, container);
};
208 changes: 208 additions & 0 deletions apps/ehr/src/features/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { otherColors } from '@ehrTheme/colors';
import ArrowDropDownCircleOutlinedIcon from '@mui/icons-material/ArrowDropDownCircleOutlined';
import {
Box,
Collapse,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Tooltip,
Typography,
useTheme,
} from '@mui/material';
import { FC, Fragment, ReactElement, ReactNode, useState } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { adjustTopForBannerHeight } from 'src/helpers/misc.helper';
import { ArrowIcon } from '../visits/shared/components/Sidebar';
import { adminNavGroups } from './adminNav';
import { isItemActive } from './adminRoutes';

const CLOSED_DRAWER_WIDTH = 56;
const OPEN_DRAWER_WIDTH = 250;
// The sticky Navbar occupies this height; the sidebar starts just below it. In non-production the
// environment banner sits above the Navbar, so we offset by the banner height too (matching Navbar's
// own `top`) — otherwise the fixed sidebar slides up underneath the banner and Navbar.
const NAVBAR_OFFSET = adjustTopForBannerHeight(81);

interface AdminSidebarProps {
children: ReactNode;
}

export const AdminSidebar: FC<AdminSidebarProps> = ({ children }) => {
const theme = useTheme();
const { pathname } = useLocation();
const [open, setOpen] = useState(true);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());

const drawerWidth = open ? OPEN_DRAWER_WIDTH : CLOSED_DRAWER_WIDTH;
const sidebarToggleLabel = `${open ? 'Collapse' : 'Expand'} sidebar`;

const toggleGroup = (groupLabel: string): void => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupLabel)) {
next.delete(groupLabel);
} else {
next.add(groupLabel);
}
return next;
});
};

const renderItem = (itemPath: string, label: string, icon: ReactNode): ReactNode => {
const active = isItemActive(pathname, itemPath);
return (
<ListItem key={itemPath} disablePadding sx={{ display: 'block' }}>
<Link to={itemPath} style={{ textDecoration: 'none', color: 'inherit' }}>
<Tooltip title={open ? '' : label} placement="right">
<ListItemButton
selected={active}
sx={{
minHeight: 44,
justifyContent: open ? 'initial' : 'center',
px: 2,
'&.Mui-selected': {
backgroundColor: theme.palette.action.hover,
borderRight: `2px solid ${theme.palette.primary.main}`,
},
}}
>
<ListItemIcon
sx={{
minWidth: 0,
mr: open ? 2 : 'auto',
justifyContent: 'center',
color: active ? theme.palette.primary.main : theme.palette.text.secondary,
}}
>
{icon}
</ListItemIcon>
<ListItemText
primary={label}
sx={{ opacity: open ? 1 : 0, m: 0 }}
primaryTypographyProps={{
fontSize: 14,
fontWeight: active ? 700 : 500,
color: active ? theme.palette.primary.main : theme.palette.text.primary,
noWrap: true,
}}
/>
</ListItemButton>
</Tooltip>
</Link>
</ListItem>
);
};

return (
<Box sx={{ display: 'flex' }}>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
'& .MuiDrawer-paper': {
position: 'fixed',
top: `${NAVBAR_OFFSET}px`,
height: `calc(100% - ${NAVBAR_OFFSET}px)`,
width: drawerWidth,
boxSizing: 'border-box',
overflowX: 'hidden',
borderRight: `1px solid ${theme.palette.divider}`,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: open ? theme.transitions.duration.enteringScreen : theme.transitions.duration.leavingScreen,
}),
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 48,
px: open ? '10px' : 0,
justifyContent: open ? 'flex-end' : 'center',
}}
>
<Tooltip title={sidebarToggleLabel} placement="right">
<IconButton
onClick={() => setOpen((prev) => !prev)}
aria-label={sidebarToggleLabel}
sx={{
width: 40,
height: 40,
padding: 0,
'&:hover': {
backgroundColor: otherColors.sidebarItemHover,
},
}}
>
<ArrowIcon direction={open ? 'left' : 'right'} />
</IconButton>
</Tooltip>
</Box>

<List sx={{ py: 0 }}>
{adminNavGroups.map((group) => {
const groupCollapsed = collapsedGroups.has(group.label);
return (
<Fragment key={group.label}>
<Tooltip title={open ? '' : group.label} placement="right">
<ListItemButton
onClick={() => toggleGroup(group.label)}
sx={{
padding: open ? '8px 16px 6px 16px' : '8px 0',
justifyContent: open ? 'space-between' : 'center',
}}
aria-label={`Toggle ${group.label} section`}
>
{open && (
<Typography
sx={{
flexGrow: 1,
fontSize: '14px',
fontWeight: 500,
letterSpacing: '0px',
textTransform: 'uppercase',
color: theme.palette.primary.dark,
}}
>
{group.label}
</Typography>
)}
<ArrowDropDownCircleOutlinedIcon
fontSize="small"
sx={{ color: theme.palette.primary.main, rotate: groupCollapsed ? '' : '180deg' }}
/>
</ListItemButton>
</Tooltip>
<Collapse in={!groupCollapsed} timeout="auto" unmountOnExit>
{group.items.map((item) => renderItem(item.path, item.label, item.icon))}
</Collapse>
</Fragment>
);
})}
</List>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, minWidth: 0 }}>
{children}
</Box>
</Box>
);
};

/**
* Route layout that keeps the admin sidebar mounted across every `/admin/*` route — including the
* detail/add pages (e.g. `/admin/employees/add`) that render their own components rather than going
* through {@link AdminPage}. Nest admin routes under a `<Route element={<AdminLayout />}>`.
*/
export const AdminLayout: FC = (): ReactElement => (
<AdminSidebar>
<Outlet />
</AdminSidebar>
);
Loading
Loading