mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Initial End to End functionality
This commit is contained in:
@@ -1,99 +1,28 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AppShell, rem } from '@mantine/core';
|
||||
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 { logout } from '@ema-platform/auth';
|
||||
import { BrandMark, logout } from '@ema-platform/auth';
|
||||
import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import {
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconCreditCard,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconLayoutDashboard,
|
||||
IconShieldCheck,
|
||||
IconRubberStamp,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconUserShield,
|
||||
IconQuestionMark,
|
||||
IconClipboardList,
|
||||
IconReport,
|
||||
IconAnchor,
|
||||
IconFilePlus,
|
||||
IconGauge,
|
||||
IconShieldOff,
|
||||
IconShip,
|
||||
IconMapPin,
|
||||
IconStack2,
|
||||
IconTruck,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||
import { 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';
|
||||
|
||||
/**
|
||||
* Grouped so a reviewer can find things, and flagged so they can tell what
|
||||
* actually works: `soon` marks screens with no backend behind them yet.
|
||||
* 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 NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
items: [{ to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard }],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupLicensing',
|
||||
items: [
|
||||
{ to: '/licence-review', label: 'nav.licenceReview', icon: IconTruck },
|
||||
{ to: '/logistics-head-dashboard', label: 'nav.logisticsHeadDashboard', icon: IconGauge },
|
||||
{ to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
|
||||
{ to: '/waiver', label: 'nav.waiver', icon: IconShieldOff, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck, soon: true },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupExaminations',
|
||||
items: [
|
||||
{ to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
|
||||
{ to: '/exams', label: 'nav.exams', icon: IconClipboardList },
|
||||
{ to: '/exam-results', label: 'nav.examResults', icon: IconReport },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, soon: true },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupAdministration',
|
||||
items: [
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/configuration', label: 'nav.configuration', icon: IconSettings },
|
||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
||||
{ to: '/analytics', label: 'nav.analytics', icon: IconChartBar, soon: true },
|
||||
{ to: '/profile', label: 'nav.profile', icon: IconUser },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat list used for breadcrumbs and active-route lookup. */
|
||||
const NAV_ITEMS: NavItem[] = NAV_SECTIONS.flatMap((section) => section.items);
|
||||
const BADGE_POLL_MS = 60_000;
|
||||
|
||||
const HEADER_HEIGHT = 116;
|
||||
|
||||
@@ -106,6 +35,44 @@ export function BackofficeLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { can } = 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,
|
||||
),
|
||||
}));
|
||||
return filterByPermissions(
|
||||
withBadges,
|
||||
// `can` already fails open when the token carries no permission claim,
|
||||
// so this only ever removes items we are sure the user cannot use.
|
||||
withBadges
|
||||
.flatMap((section) => section.items)
|
||||
.flatMap((item) => [item, ...(item.children ?? [])])
|
||||
.flatMap((item) => item.permissions ?? [])
|
||||
.filter((permission) => can([permission])),
|
||||
);
|
||||
}, [counts?.unassigned, can]);
|
||||
|
||||
/** 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
|
||||
@@ -128,7 +95,7 @@ export function BackofficeLayout() {
|
||||
.filter((path) => path !== '/dashboard')
|
||||
.filter((path) => !path.startsWith('/um'))
|
||||
.map((path) => {
|
||||
const match = NAV_ITEMS.find((item) => item.to === 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.
|
||||
@@ -195,58 +162,19 @@ export function BackofficeLayout() {
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(2),
|
||||
padding: '0 32px',
|
||||
height: 42,
|
||||
borderTop: '1px solid var(--mantine-color-gray-1)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = !!item.to && (location.pathname === item.to || location.pathname.startsWith(`${item.to}/`));
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
onClick={() => go(item)}
|
||||
title={item.soon ? `${t(item.label)} — ${t('nav.soon', 'Soon')}` : undefined}
|
||||
style={{
|
||||
opacity: item.soon ? 0.55 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(6),
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderBottom: '2px solid',
|
||||
borderBottomColor: active
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'transparent',
|
||||
background: 'transparent',
|
||||
color: active
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'var(--mantine-color-gray-6)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: rem(14),
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 150ms ease',
|
||||
height: '100%',
|
||||
marginBottom: -1,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.color = 'var(--mantine-color-blue-6)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
}}
|
||||
>
|
||||
<ItemIcon size={18} stroke={1.6} />
|
||||
<span>{t(item.label)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 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>
|
||||
@@ -262,13 +190,14 @@ export function BackofficeLayout() {
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={NAV_SECTIONS}
|
||||
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>
|
||||
)}
|
||||
@@ -278,6 +207,9 @@ export function BackofficeLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
|
||||
<CommandPalette sections={sections} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user