import { useCallback, useMemo, useState } from 'react'; import { AppShell } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { BrandMark, logout } 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 { 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; export function BackofficeLayout() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const dispatch = useAppDispatch(); const [opened, { toggle: toggleNav }] = 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(); // TEMPORARY diagnostic — remove once the sidebar is confirmed working. // eslint-disable-next-line no-console console.log( '[NAV] known=', known, 'granted=', granted.length, '| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')), '| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12), ); // 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("/login"); }, [dispatch, navigate]); 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 (
{!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. */}
); }