Files
emaui/apps/backoffice/src/app/layouts/BackofficeLayout.tsx
fitse-yotor 0ac75669d4 feat: add ExamStageActions component for exam fee handling
- Implemented ExamStageActions component to manage actions related to exam booking and payment based on application status.
- Added mock-base-query for development, providing a partial mock backend for various API endpoints.
- Introduced mock-data for simulating responses in the mock-base-query, covering profiles, vessels, applications, licenses, exams, and notifications.
2026-08-15 11:51:14 +03:00

228 lines
7.6 KiB
TypeScript

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<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("/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 (
<AppShell
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
navbar={
isSidebar
? {
width: collapsed ? 72 : 264,
breakpoint: "sm",
collapsed: { mobile: !opened },
}
: undefined
}
padding="lg"
>
<AppShell.Header
style={{
background: "var(--mantine-color-body)",
borderBottom: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
<AppHeader
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 && (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '0 32px',
height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)',
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}
/>
</div>
)}
</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-gray-2)",
}}
>
<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>
<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} />
</AppShell>
);
}