mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import { useCallback, useMemo, useState } from 'react';
|
|
import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
|
|
import { useDisclosure } from '@mantine/hooks';
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { BrandMark, logout, useIdleTimer } from '@ema-platform/auth';
|
|
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
|
import type { NavItem, NavSection } from '@ema-platform/ui';
|
|
import { notify } from '@ema-platform/ui';
|
|
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
|
import { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui';
|
|
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
|
import { usePermissions } from '@ema-platform/auth';
|
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
|
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
|
import { NAV_SECTIONS } from './nav-config';
|
|
import { CommandPalette } from './CommandPalette';
|
|
|
|
/**
|
|
* How often the pending-work badges refresh.
|
|
*
|
|
* Polled on a timer rather than refetched per navigation: the counts sit in
|
|
* the chrome and are visible on every screen, so tying them to route changes
|
|
* would fire a request each time an officer clicked anything.
|
|
*/
|
|
const BADGE_POLL_MS = 60_000;
|
|
|
|
const HEADER_HEIGHT = 116;
|
|
|
|
/**
|
|
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
|
|
* inside this, so the nav strip below needs the sum to line up with the
|
|
* controls above it — it used to start 20px to their left.
|
|
*/
|
|
const CHROME_PAD_X = 32;
|
|
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
|
|
|
|
/**
|
|
* A desk left unlocked with a license-review or medical-record screen open is
|
|
* the actual threat model here, not a slow token. 15 minutes of no mouse,
|
|
* key, scroll, or touch activity signs the officer out automatically.
|
|
*/
|
|
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
|
|
export function BackofficeLayout() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const dispatch = useAppDispatch();
|
|
const [opened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const user = useAppSelector((state) => state.auth.user);
|
|
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
|
const { permissions: granted, known } = usePermissions();
|
|
|
|
// Badges reflect real pending work. One grouped request on a timer, shared
|
|
// by the sidebar and the top bar via the RTK cache.
|
|
const { data: counts } = useGetQueueCountsQuery(undefined, {
|
|
pollingInterval: BADGE_POLL_MS,
|
|
refetchOnMountOrArgChange: false,
|
|
});
|
|
|
|
const sections = useMemo<NavSection[]>(() => {
|
|
const withBadges = NAV_SECTIONS.map((section) => ({
|
|
...section,
|
|
items: section.items.map((item) =>
|
|
item.to === '/licence-review' && counts?.unassigned
|
|
? { ...item, badge: counts.unassigned }
|
|
: item,
|
|
),
|
|
}));
|
|
// Unfiltered until the grant list has loaded, matching PortalLayout and
|
|
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
|
return known ? filterByPermissions(withBadges, granted) : withBadges;
|
|
}, [counts?.unassigned, granted, known]);
|
|
|
|
/** Flat list used for breadcrumbs and active-route lookup. */
|
|
const navItems = useMemo<NavItem[]>(
|
|
() =>
|
|
sections.flatMap((section) =>
|
|
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
|
|
),
|
|
[sections],
|
|
);
|
|
|
|
const displayName = user?.name?.en || user?.username || '';
|
|
const initials = displayName
|
|
? displayName
|
|
.split(/\s+/)
|
|
.map((s) => s[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
: "?";
|
|
|
|
const handleLogout = useCallback(() => {
|
|
dispatch(logout());
|
|
dispatch(baseApi.util.resetApiState());
|
|
navigate("/");
|
|
}, [dispatch, navigate]);
|
|
|
|
useIdleTimer(IDLE_TIMEOUT_MS, () => {
|
|
notify.info(
|
|
t(
|
|
'common.idleLogoutMessage',
|
|
'You were signed out after 15 minutes of inactivity.',
|
|
),
|
|
t('common.idleLogoutTitle', 'Session ended'),
|
|
);
|
|
handleLogout();
|
|
});
|
|
|
|
const segments = location.pathname.split('/').filter(Boolean);
|
|
// Label each crumb from the nav item it corresponds to, falling back to a
|
|
// readable form of the path segment. Every crumb was previously labelled
|
|
// "Dashboard", which made the trail useless.
|
|
const crumbs = [
|
|
{ label: t("nav.dashboard"), path: "/dashboard" },
|
|
...segments
|
|
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
|
.filter((path) => path !== '/dashboard')
|
|
.filter((path) => !path.startsWith('/um'))
|
|
.map((path) => {
|
|
const match = navItems.find((item) => item.to === path);
|
|
if (match) return { label: t(match.label), path };
|
|
const segment = path.split('/').pop() ?? '';
|
|
// Ids get a generic label rather than a raw uuid in the trail.
|
|
const isId = /^[0-9a-f-]{8,}$/i.test(segment) || /^\d+$/.test(segment);
|
|
return {
|
|
label: isId
|
|
? t('nav.details', 'Details')
|
|
: segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
path,
|
|
};
|
|
}),
|
|
];
|
|
|
|
const go = (item: NavItem) => {
|
|
if (item.soon) {
|
|
notify.info(`${t(item.label)} — coming soon.`);
|
|
return;
|
|
}
|
|
if (item.to) {
|
|
navigate(item.to);
|
|
}
|
|
};
|
|
|
|
const handleToggleCollapse = useCallback(() => {
|
|
setCollapsed((prev) => !prev);
|
|
}, []);
|
|
|
|
const isSidebar = layoutMode === "sidebar";
|
|
|
|
return (
|
|
<>
|
|
{/* First focusable element on the page, so a keyboard user can bypass
|
|
the 20-plus nav items instead of tabbing through them every time. */}
|
|
<SkipLink />
|
|
<AppShell
|
|
// The top layout drops its nav strip on small screens — the drawer is
|
|
// the nav there — so the header shrinks back to a single row with it.
|
|
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
|
|
navbar={
|
|
isSidebar
|
|
? {
|
|
width: collapsed ? 72 : 264,
|
|
breakpoint: "sm",
|
|
// Mobile has its own Drawer below — AppShell's built-in mobile
|
|
// navbar takes over the full viewport width, which felt like
|
|
// it swallowed the page. Always collapsed here on mobile.
|
|
collapsed: { mobile: true },
|
|
}
|
|
: undefined
|
|
}
|
|
padding="lg"
|
|
>
|
|
<AppShell.Header
|
|
style={{
|
|
background: "var(--mantine-color-body)",
|
|
borderBottom: "1px solid var(--mantine-color-default-border)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<div
|
|
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
|
|
>
|
|
<AppHeader
|
|
brand={
|
|
isSidebar ? undefined : (
|
|
<Group gap="xs" wrap="nowrap">
|
|
<BrandMark size={28} />
|
|
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
|
|
{t('app.name')}
|
|
</Text>
|
|
</Group>
|
|
)
|
|
}
|
|
// Nothing to toggle on a desktop top bar; on mobile it opens the
|
|
// drawer below.
|
|
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
|
|
onToggleNav={toggleNav}
|
|
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
|
navOpened={opened}
|
|
breadcrumbs={crumbs}
|
|
onNavigate={navigate}
|
|
onLogout={handleLogout}
|
|
userName={displayName || t("app.name")}
|
|
userInitials={initials}
|
|
supportedLanguages={SUPPORTED_LANGUAGES}
|
|
/>
|
|
</div>
|
|
|
|
{!isSidebar && (
|
|
<Box
|
|
visibleFrom="sm"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
padding: `0 ${NAV_STRIP_PAD_X}px`,
|
|
height: 42,
|
|
borderTop: '1px solid var(--mantine-color-default-border)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{/* Grouped dropdowns. Previously every destination rendered as a
|
|
sibling button in one horizontally scrolling row. */}
|
|
<AppTopNav
|
|
navItems={sections}
|
|
activePath={location.pathname}
|
|
onNavigate={go}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</AppShell.Header>
|
|
|
|
{isSidebar && (
|
|
<AppShell.Navbar
|
|
p={0}
|
|
style={{
|
|
overflow: "hidden",
|
|
transition: "width 200ms ease",
|
|
background: "var(--mantine-color-body)",
|
|
borderRight: "1px solid var(--mantine-color-default-border)",
|
|
}}
|
|
>
|
|
<AppSidebar
|
|
navItems={sections}
|
|
collapsed={collapsed}
|
|
activePath={location.pathname}
|
|
onToggleCollapse={handleToggleCollapse}
|
|
onNavigate={go}
|
|
brandName={t('app.name')}
|
|
brandSubtitle={t('app.authority')}
|
|
brandLogo={<BrandMark size={32} />}
|
|
/>
|
|
</AppShell.Navbar>
|
|
)}
|
|
|
|
<AppShell.Main id={MAIN_CONTENT_ID}>
|
|
<div key={location.pathname} className="ema-page-enter">
|
|
<Outlet />
|
|
</div>
|
|
</AppShell.Main>
|
|
|
|
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
|
|
<CommandPalette sections={sections} />
|
|
|
|
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
|
|
click) instead of AppShell's full-width mobile navbar. Mirrors the
|
|
landing page's mobile menu. */}
|
|
<Drawer
|
|
opened={opened}
|
|
onClose={closeNav}
|
|
hiddenFrom="sm"
|
|
size="75%"
|
|
padding={0}
|
|
withCloseButton={false}
|
|
>
|
|
<AppSidebar
|
|
navItems={sections}
|
|
collapsed={false}
|
|
activePath={location.pathname}
|
|
onToggleCollapse={handleToggleCollapse}
|
|
onNavigate={(item) => {
|
|
go(item);
|
|
closeNav();
|
|
}}
|
|
brandName={t('app.name')}
|
|
brandSubtitle={t('app.authority')}
|
|
brandLogo={<BrandMark size={32} />}
|
|
/>
|
|
</Drawer>
|
|
</AppShell>
|
|
</>
|
|
);
|
|
}
|