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(() => { 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( () => 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. */}
{t('app.name')} ) } // 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} />
{!isSidebar && ( {/* Grouped dropdowns. Previously every destination rendered as a sibling button in one horizontally scrolling row. */} )}
{isSidebar && ( } /> )}
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */} {/* 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. */} { go(item); closeNav(); }} brandName={t('app.name')} brandSubtitle={t('app.authority')} brandLogo={} />
); }