Initial End to End functionality

This commit is contained in:
Mulu Mehari
2026-08-02 22:44:08 +03:00
parent c9d885356c
commit c62ec59655
53 changed files with 6791 additions and 1208 deletions

View File

@@ -3,8 +3,12 @@ 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';

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

@@ -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

@@ -2,6 +2,7 @@ import {
AppShell,
Badge,
NavLink,
Popover,
ScrollArea,
Stack,
Text,
@@ -14,13 +15,32 @@ 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;
}
@@ -32,15 +52,174 @@ export interface NavSection {
items: NavItem[];
}
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
export type { NavEntries } from './nav-utils';
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[] }];
/** 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 {
@@ -51,6 +230,12 @@ interface AppSidebarProps {
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({
@@ -61,13 +246,10 @@ export function AppSidebar({
onNavigate,
brandName,
brandSubtitle,
brandLogo,
}: AppSidebarProps) {
const { t } = useTranslation();
const activeNavItem = (item: NavItem) =>
!!item.to &&
(activePath === item.to || activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
@@ -83,7 +265,7 @@ export function AppSidebar({
flexShrink: 0,
}}
>
<BrandMark size={32} />
{brandLogo}
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
@@ -141,61 +323,15 @@ export function AppSidebar({
}}
/>
)}
{section.items.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip
{section.items.map((item) => (
<SidebarItem
key={item.label}
label={item.soon ? `${t(item.label)}${t('nav.soon', 'Soon')}` : 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),
opacity: item.soon ? 0.55 : 1,
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 (
<NavLink
key={item.label}
active={active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
// Tells a reviewer at a glance which screens are wired up.
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : undefined
}
onClick={() => onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
/>
);
})}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
/>
))}
</Stack>
))}
</Stack>

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;
}