Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-03 09:26:04 +03:00
133 changed files with 12220 additions and 23161 deletions

View File

@@ -1,17 +1,15 @@
export * from "./lib/input/BilingualInput";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/layout/AppHeader";
export * from "./lib/layout/AppSidebar";
export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/useServerTable";
export * from "./lib/components/MaritimeLoader"
export * from './lib/input/BilingualInput';
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/feedback/FeatureUnavailable';
export * from './lib/feedback/EmptyState';
export * from './lib/feedback/ErrorState';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/AppTopNav';
export * from './lib/layout/nav-utils';
export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';
export * from './lib/layout/PageHeader';

View File

@@ -0,0 +1,47 @@
import { Button, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconInbox, type Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface EmptyStateProps {
title: string;
description?: string;
icon?: Icon;
action?: { label: string; onClick: () => void; icon?: ReactNode };
}
/**
* The "nothing here" state.
*
* Distinct from an error: an empty queue is a normal, often good, outcome. It
* says so plainly and offers the next useful action rather than leaving a bare
* grey panel that reads as a failure.
*/
export function EmptyState({
title,
description,
icon: StateIcon = IconInbox,
action,
}: EmptyStateProps) {
return (
<Paper p="xl" withBorder>
<Stack align="center" gap="sm" py="xl">
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
<StateIcon size={28} stroke={1.5} />
</ThemeIcon>
<Text fw={600} fz="lg" ta="center">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={440}>
{description}
</Text>
)}
{action && (
<Button mt="xs" variant="light" leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Button>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,60 @@
import { Button, Code, Paper, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconAlertTriangle, IconRefresh, type Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
title: string;
/** What actually failed, in the API's words where available. */
description?: string;
/**
* Request/trace id. Shown verbatim and selectable so a user can quote it in
* a support ticket — "it didn't work" is not something anyone can act on.
*/
correlationId?: string;
onRetry?: () => void;
icon?: Icon;
}
/** The error state every screen shows: what broke, how to retry, what to quote. */
export function ErrorState({
title,
description,
correlationId,
onRetry,
icon: CustomIcon = IconAlertTriangle,
}: ErrorStateProps) {
const { t } = useTranslation();
return (
<Paper p="xl" withBorder role="alert">
<Stack align="center" gap="sm" py="lg">
<ThemeIcon size={48} radius="xl" color="red" variant="light">
<CustomIcon size={26} />
</ThemeIcon>
<Text fw={600} fz="lg" ta="center">
{title}
</Text>
{description && (
<Text c="dimmed" size="sm" ta="center" maw={460}>
{description}
</Text>
)}
{correlationId && (
<Text size="xs" c="dimmed">
{t('error.reference', 'Reference')}: <Code>{correlationId}</Code>
</Text>
)}
{onRetry && (
<Button
mt="xs"
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={onRetry}
>
{t('error.retry', 'Try again')}
</Button>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,45 @@
import { Card, Center, Stack, Text, ThemeIcon, Title } from '@mantine/core';
import { IconTool } from '@tabler/icons-react';
import type { ReactNode } from 'react';
interface FeatureUnavailableProps {
/** What the screen will eventually do, e.g. "Seaman Book applications". */
title: string;
/** Optional extra context — what is missing, or what to use instead. */
description?: string;
action?: ReactNode;
}
/**
* Placeholder for a screen whose backend does not exist yet.
*
* These pages previously rendered hardcoded sample rows, which read as real
* records: reviewers saw queues of applications that had never been filed and
* dashboards counting things nobody had done. Showing nothing is the honest
* option — an empty screen cannot be mistaken for data.
*/
export function FeatureUnavailable({
title,
description,
action,
}: FeatureUnavailableProps) {
return (
<Center mih={320}>
<Card withBorder radius="md" padding="xl" maw={520} w="100%">
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
<IconTool size={24} stroke={1.5} />
</ThemeIcon>
<Title order={4} ta="center">
{title}
</Title>
<Text size="sm" c="dimmed" ta="center">
{description ??
'This feature is not connected to the backend yet, so there is nothing to show.'}
</Text>
{action}
</Stack>
</Card>
</Center>
);
}

View File

@@ -53,8 +53,10 @@ export function AppHeader({
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
{/* Hamburger — styled like user-management Top.tsx */}
{/* The Burger itself owns the click so the control is a real, keyboard
reachable <button>; the Box is chrome only. It previously wrapped a
no-op button, which no keyboard user could operate. */}
<Box
onClick={isMobile ? onToggleNav : onToggleSidebar}
style={{
display: 'flex',
alignItems: 'center',
@@ -84,7 +86,7 @@ export function AppHeader({
>
<Burger
opened={navOpened}
onClick={() => {}}
onClick={isMobile ? onToggleNav : onToggleSidebar}
size="sm"
aria-label="Toggle navigation"
styles={{

View File

@@ -1,7 +1,9 @@
import { Fragment } from 'react';
import {
AppShell,
Badge,
NavLink,
Popover,
ScrollArea,
Stack,
Text,
@@ -14,25 +16,227 @@ import {
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { BrandMark } from '@ema-platform/auth';
import {
badgeLabel,
isBranchActive,
isItemActive,
toSections,
type NavEntries,
} from './nav-utils';
export interface NavItem {
label: string;
icon: Icon;
to?: string;
/**
* One level only. A parent with children is a disclosure, not a destination,
* so give it either `to` or `children` — not both.
*/
children?: NavItem[];
/**
* Pending count, or `'dot'` for "something is waiting" without a number.
* Zero renders nothing: a badge reading 0 is worse than no badge.
*/
badge?: number | 'dot';
/** Hidden unless the user holds at least one of these. */
permissions?: string[];
/** Not yet connected to real data — surfaced as a "Soon" badge. */
soon?: boolean;
section?: string;
}
/** A labelled group of nav items. */
export interface NavSection {
/** i18n key or literal heading. Omit for an ungrouped leading block. */
label?: string;
items: NavItem[];
}
export type { NavEntries } from './nav-utils';
/** Shown when an item is disabled, so no control is ever dead without a reason. */
function itemTooltip(item: NavItem, soonLabel: string): string | undefined {
return item.soon ? soonLabel : undefined;
}
interface SidebarItemProps {
item: NavItem;
collapsed: boolean;
activePath: string;
onNavigate: (item: NavItem) => void;
}
/**
* One nav entry, at either level.
*
* A parent with children renders as a Mantine NavLink disclosure that starts
* open when the current route is inside it, so the user can always see where
* they are without hunting. Collapsed to the icon rail there is no room to
* nest, so a parent becomes a hover flyout instead of silently losing its
* children.
*/
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
const { t } = useTranslation();
const ItemIcon = item.icon;
const hasChildren = Boolean(item.children?.length);
const active = isItemActive(item, activePath);
const branchActive = isBranchActive(item, activePath);
const soonLabel = `${t(item.label)}${t('nav.soon', 'Soon')}`;
const badge = badgeLabel(item.badge);
const badgeNode = badge !== null && (
<Badge
size="xs"
circle={item.badge === 'dot'}
variant="filled"
color="red"
radius="sm"
aria-label={
item.badge === 'dot'
? t('nav.pending', 'Items pending')
: t('nav.pendingCount', { count: Number(item.badge), defaultValue: '{{count}} pending' })
}
>
{badge}
</Badge>
);
const rightSection = item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeNode || undefined
);
if (collapsed) {
const trigger = (
<UnstyledButton
onClick={() => !hasChildren && onNavigate(item)}
aria-label={t(item.label)}
aria-current={active ? 'page' : undefined}
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
{badge !== null && (
<Badge
size="xs"
circle
variant="filled"
color="red"
style={{ position: 'absolute', top: rem(6), right: rem(10) }}
/>
)}
</UnstyledButton>
);
// Children would be unreachable behind an icon, so open them in a flyout.
if (hasChildren) {
return (
<Popover position="right-start" withArrow shadow="md" trapFocus>
<Popover.Target>{trigger}</Popover.Target>
<Popover.Dropdown p={4}>
<Text size="xs" fw={700} c="dimmed" px="xs" py={4}>
{t(item.label)}
</Text>
{item.children?.map((child) => (
<NavLink
key={child.label}
active={isItemActive(child, activePath)}
label={t(child.label)}
leftSection={<child.icon size={17} stroke={1.6} />}
onClick={() => onNavigate(child)}
variant="light"
styles={{ root: { borderRadius: rem(8) } }}
/>
))}
</Popover.Dropdown>
</Popover>
);
}
return (
<Tooltip
label={itemTooltip(item, soonLabel) ?? t(item.label)}
position="right"
withArrow
>
{trigger}
</Tooltip>
);
}
return (
<NavLink
active={hasChildren ? branchActive && !active : active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
rightSection={rightSection}
// Auto-expands the group the user is currently inside.
defaultOpened={hasChildren ? branchActive : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
>
{hasChildren
? item.children?.map((child) => (
<NavLink
key={child.label}
active={isItemActive(child, activePath)}
label={t(child.label)}
leftSection={<child.icon size={17} stroke={1.6} />}
rightSection={
child.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeLabel(child.badge) !== null && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badgeLabel(child.badge)}
</Badge>
)
) || undefined
}
onClick={() => onNavigate(child)}
variant="light"
styles={{ root: { borderRadius: rem(8) }, label: { fontWeight: 500 } }}
/>
))
: null}
</NavLink>
);
}
interface AppSidebarProps {
navItems: NavItem[];
navItems: NavEntries;
collapsed: boolean;
activePath: string;
onToggleCollapse: () => void;
onNavigate: (item: NavItem) => void;
brandName: string;
brandSubtitle: string;
/**
* Rendered in the brand header. Passed in rather than imported so this
* library stays free of a dependency on `@ema-platform/auth`, which depends
* on it — the two formed an import cycle.
*/
brandLogo?: ReactNode;
}
export function AppSidebar({
@@ -43,18 +247,10 @@ export function AppSidebar({
onNavigate,
brandName,
brandSubtitle,
brandLogo,
}: AppSidebarProps) {
const { t } = useTranslation();
// Prefer an exact `to` match so sibling routes sharing a path prefix
// (e.g. /vessel-registration and /vessel-registration/transfer) don't
// both light up. Prefix matching is the fallback, for sub-pages that
// aren't themselves in the nav (e.g. /vessel-registration/apply).
const exactMatch = navItems.find((i) => i.to === activePath);
const activeNavItem = (item: NavItem) =>
!!item.to &&
(exactMatch ? item === exactMatch : activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
@@ -70,7 +266,7 @@ export function AppSidebar({
flexShrink: 0,
}}
>
<BrandMark size={32} />
{brandLogo}
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
@@ -103,63 +299,42 @@ export function AppSidebar({
{/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={4}>
{navItems.map((item, index) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
const showHeader =
!collapsed && !!item.section && item.section !== navItems[index - 1]?.section;
const header = showHeader ? (
<Text
size="xs"
fw={700}
c="dimmed"
mt={index === 0 ? 0 : 12}
mb={2}
px={8}
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
{item.section}
</Text>
) : null;
if (collapsed) {
return (
<Tooltip key={item.label} label={t(item.label)} position="right" withArrow>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
color: active ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
</UnstyledButton>
</Tooltip>
);
}
return (
<Fragment key={item.label}>
{header}
<NavLink
active={active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
onClick={() => onNavigate(item)}
variant="light"
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
<Stack gap={2}>
{toSections(navItems).map((section, sectionIndex) => (
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
{/* Headings are noise when only icons are visible. */}
{section.label && !collapsed && (
<Text
size="xs"
fw={700}
c="dimmed"
mt={sectionIndex === 0 ? 0 : rem(14)}
pl={rem(12)}
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{t(section.label)}
</Text>
)}
{section.label && collapsed && sectionIndex > 0 && (
<div
style={{
height: 1,
margin: `${rem(8)} ${rem(6)}`,
backgroundColor: 'var(--mantine-color-gray-2)',
}}
/>
</Fragment>
);
})}
)}
{section.items.map((item) => (
<SidebarItem
key={item.label}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
/>
))}
</Stack>
))}
</Stack>
</AppShell.Section>

View File

@@ -0,0 +1,187 @@
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { NavItem } from './AppSidebar';
import {
badgeLabel,
isBranchActive,
isItemActive,
toSections,
type NavEntries,
} from './nav-utils';
interface AppTopNavProps {
navItems: NavEntries;
activePath: string;
onNavigate: (item: NavItem) => void;
}
/**
* Horizontal navigation for the top-bar layout.
*
* Every destination used to render as a sibling button in one horizontally
* scrolling strip, so with twenty-odd of them most were off-screen and the
* grouping that the sidebar already had was thrown away. Here each section
* collapses to a single labelled dropdown, which fits and keeps the same
* information architecture as the sidebar.
*/
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
const { t } = useTranslation();
const sections = toSections(navItems);
return (
<Group
gap={rem(2)}
h="100%"
wrap="nowrap"
role="navigation"
aria-label={t('nav.primary', 'Primary')}
>
{sections.map((section, index) => {
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
if (!section.label) {
return section.items.map((item) => (
<TopNavButton
key={item.label}
label={t(item.label)}
active={isBranchActive(item, activePath)}
badge={badgeLabel(item.badge)}
soon={item.soon}
onClick={() => onNavigate(item)}
/>
));
}
const sectionActive = section.items.some((item) =>
isBranchActive(item, activePath),
);
const pending = section.items.reduce((sum, item) => {
const own = typeof item.badge === 'number' ? item.badge : 0;
const nested = (item.children ?? []).reduce(
(n, child) => n + (typeof child.badge === 'number' ? child.badge : 0),
0,
);
return sum + own + nested;
}, 0);
return (
<Menu
key={section.label ?? `section-${index}`}
trigger="click-hover"
openDelay={80}
closeDelay={140}
position="bottom-start"
withinPortal
shadow="md"
>
<Menu.Target>
<TopNavButton
label={t(section.label)}
active={sectionActive}
badge={badgeLabel(pending)}
withChevron
/>
</Menu.Target>
<Menu.Dropdown>
{section.items.map((item) =>
item.children?.length ? (
<Menu.Sub key={item.label} position="right-start">
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<item.icon size={16} stroke={1.6} />}>
{t(item.label)}
</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{item.children.map((child) => (
<Menu.Item
key={child.label}
leftSection={<child.icon size={16} stroke={1.6} />}
onClick={() => onNavigate(child)}
disabled={child.soon}
>
{t(child.label)}
</Menu.Item>
))}
</Menu.Sub.Dropdown>
</Menu.Sub>
) : (
<Menu.Item
key={item.label}
leftSection={<item.icon size={16} stroke={1.6} />}
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : (
badgeLabel(item.badge) !== null && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badgeLabel(item.badge)}
</Badge>
)
) || undefined
}
onClick={() => onNavigate(item)}
// `soon` screens have no backend; the Menu.Item's own
// disabled styling plus the badge explains why.
disabled={item.soon}
aria-current={isItemActive(item, activePath) ? 'page' : undefined}
>
{t(item.label)}
</Menu.Item>
),
)}
</Menu.Dropdown>
</Menu>
);
})}
</Group>
);
}
interface TopNavButtonProps {
label: string;
active: boolean;
badge?: string | null;
soon?: boolean;
withChevron?: boolean;
onClick?: () => void;
}
function TopNavButton({
label,
active,
badge,
soon,
withChevron,
onClick,
}: TopNavButtonProps) {
return (
<UnstyledButton
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
padding: `0 ${rem(14)}`,
height: '100%',
borderBottom: '2px solid',
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
fontWeight: active ? 600 : 500,
fontSize: rem(14),
whiteSpace: 'nowrap',
opacity: soon ? 0.55 : 1,
marginBottom: -1,
}}
>
<span>{label}</span>
{badge !== null && badge !== undefined && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badge}
</Badge>
)}
{withChevron && <IconChevronDown size={14} stroke={2} />}
</UnstyledButton>
);
}

View File

@@ -0,0 +1,79 @@
import type { NavItem, NavSection } from './AppSidebar';
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
export function toSections(entries: NavEntries): NavSection[] {
if (entries.length === 0) return [];
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
return isSectioned
? (entries as NavSection[])
: [{ items: entries as NavItem[] }];
}
/**
* Whether a nav entry points at the current route.
*
* Prefix-matches on a path boundary so `/licensing` lights up for
* `/licensing/FREIGHT_FORWARDER` but not for an unrelated `/licensing-report`.
*/
export function isItemActive(item: NavItem, activePath: string): boolean {
if (!item.to) return false;
return activePath === item.to || activePath.startsWith(`${item.to}/`);
}
/** True when the item, or anything nested under it, matches the route. */
export function isBranchActive(item: NavItem, activePath: string): boolean {
if (isItemActive(item, activePath)) return true;
return (item.children ?? []).some((child) => isItemActive(child, activePath));
}
/**
* Removes entries the user may not see.
*
* An item with no `permissions` is visible to everyone. A parent survives if
* it is itself permitted and at least one child is — a disclosure that opens
* onto nothing is worse than no disclosure at all. Sections left empty are
* dropped so their heading does not hang over a gap.
*/
export function filterByPermissions(
sections: NavSection[],
granted: readonly string[],
): NavSection[] {
const permitted = new Set(granted);
const allows = (item: NavItem) =>
!item.permissions?.length ||
item.permissions.some((permission) => permitted.has(permission));
return sections
.map((section) => ({
...section,
items: section.items
.filter(allows)
.map((item) =>
item.children
? { ...item, children: item.children.filter(allows) }
: item,
)
.filter((item) => !item.children || item.children.length > 0),
}))
.filter((section) => section.items.length > 0);
}
/** Flattens parents and children into one list, for search and breadcrumbs. */
export function flattenNav(sections: NavSection[]): NavItem[] {
return sections.flatMap((section) =>
section.items.flatMap((item) => [item, ...(item.children ?? [])]),
);
}
/** Badge text, or null when there is nothing worth showing. */
export function badgeLabel(badge: NavItem['badge']): string | null {
if (badge === 'dot') return '';
if (typeof badge === 'number' && badge > 0) {
// Three digits of pending work is already "a lot"; the exact number
// stops being actionable and starts breaking the layout.
return badge > 99 ? '99+' : String(badge);
}
return null;
}