Merge branch 'ui_ux' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-21 17:36:09 +00:00
92 changed files with 3024 additions and 2446 deletions

4
.gitignore vendored
View File

@@ -30,3 +30,7 @@ apps/backoffice/public/_um/
apps/backoffice/public/tinymce/
local-packages/iamui-extracted/
# Playwright visual-regression artifacts (baselines under apps/e2e/visual are tracked)
test-results/
dist/visual-report/

View File

@@ -44,6 +44,7 @@ export function CertificateRequirementsPage() {
'certReq.subtitle',
'Configure the form fields and document uploads applicants must complete for a licence type, including conditions on when a requirement applies.',
)}
noMargin
/>
{isError ? (

View File

@@ -1,20 +1,9 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Button,
Modal,
Text,
TextInput,
Textarea,
Card,
Alert,
} from '@mantine/core';
import {Stack, Button, Modal, Text, TextInput, Textarea, Card} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import {IconPlus} from '@tabler/icons-react';
import { AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import {
useGetCertificationsQuery,
useCreateCertificationMutation,
@@ -120,7 +109,8 @@ export function CertificationPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
if (isError)
return <ErrorState title={t('certification.loadError')} onRetry={refetch} />;
const columns = [
...certificationColumns(t, locale),
@@ -134,17 +124,18 @@ export function CertificationPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('certification.title')}</Title>
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
</div>
{!showForm && (
<PageHeader
title={t('certification.title')}
subtitle={t('certification.subtitle')}
noMargin
action={
!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('certification.add')}
</Button>
)}
</Group>
)
}
/>
{showForm && (
<CertificationForm

View File

@@ -46,6 +46,7 @@ import {
import type { Profession } from "../../types/configuration";
import { professionColumns } from "./columns";
import { professionActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
interface ProfFormValues {
nameEn: string;
@@ -378,7 +379,7 @@ export function ConfigurationPage() {
return (
<Stack gap="lg">
<Title order={2}>{t("configuration.title")}</Title>
<PageHeader title={t("configuration.title")} noMargin />
<Tabs defaultValue="professions">
<Tabs.List>

View File

@@ -1,25 +1,42 @@
import { useNavigate } from 'react-router-dom';
import { Anchor, Grid, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import {
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Text,
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
IconAlertTriangle,
IconCreditCard,
IconFileText,
IconInbox,
IconUserCheck,
} from '@tabler/icons-react';
import {
useGetAssignedToMeQuery,
useGetQueueQuery,
useListSeafarerDocumentsQuery,
useListSeafarerRegistrationsQuery,
type LicenseApplication,
} from '@ema-platform/api';
import {
AdvancedTable,
PageHeader,
PageLoader,
StatTile,
WaitingFor,
useServerTable,
} from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns';
/**
* Backoffice home.
*
* Shows the licence pipeline, which is the part of the platform that has real
* data behind it. The previous version charted invented registration volumes
* and a fictional breakdown of staff roles.
* Every figure here is counted from a queue the officer can open, and each
* tile navigates to the list it counted — a dashboard that cannot be drilled
* into is a poster. Nothing is charted: the platform exposes queues, not time
* series, and an earlier version of this page invented both a registration
* trend and a staff-role breakdown rather than admit that.
*
* The seafarer counts are fetched with `take: 1`, for `total` alone. Both
* queues are permission-gated and an officer without them simply gets no
* count — never a broken page — so the tiles read `—` rather than `0`, which
* would be a lie.
*/
export function DashboardPage() {
const navigate = useNavigate();
@@ -27,6 +44,13 @@ export function DashboardPage() {
const mine = useGetAssignedToMeQuery();
const table = useServerTable();
const registrations = useListSeafarerRegistrationsQuery({ status: 'SUBMITTED', take: 1 });
const seamanBooks = useListSeafarerDocumentsQuery({
kind: 'SEAMAN_BOOK',
status: 'PAYMENT_PENDING',
take: 1,
});
if (queue.isLoading || mine.isLoading) {
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
}
@@ -34,60 +58,88 @@ export function DashboardPage() {
const unclaimed = queue.data?.items ?? [];
const inProgress = mine.data?.items ?? [];
const all = [...unclaimed, ...inProgress];
const paged = table.paginate(unclaimed.slice(0, 8));
const stats = [
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
{ label: 'Assigned to me', value: inProgress.length, color: 'indigo' },
{
label: 'Needs applicant action',
value: all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length,
color: 'orange',
},
{
label: 'Awaiting payment',
value: all.filter((a) => a.status === 'PAYMENT_PENDING').length,
color: 'yellow',
},
];
const needsApplicant = all.filter((a) => a.status === 'RESUBMIT_REQUIRED').length;
const awaitingPayment = all.filter((a) => a.status === 'PAYMENT_PENDING').length;
/** Oldest first: a queue is worked by age, so the dashboard previews it that way. */
const byAge = [...unclaimed].sort((a, b) =>
(a.submittedAt ?? a.createdAt).localeCompare(b.submittedAt ?? b.createdAt),
);
const paged = table.paginate(byAge.slice(0, 8));
/** `undefined` while loading or forbidden — rendered as "—", never as 0. */
const countOf = (q: { data?: { total: number }; isError: boolean }) =>
q.isError ? undefined : q.data?.total;
const show = (n: number | undefined) => (n === undefined ? '—' : n);
return (
<Container size="xl" py="md">
<Title order={3} mb="xs">
Dashboard
</Title>
<Text size="sm" c="dimmed" mb="lg">
Licence applications currently in the system.
</Text>
<Stack gap="lg">
<PageHeader
title="Dashboard"
subtitle="Work waiting across the Authority's review queues."
noMargin
/>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => (
<Card withBorder key={stat.label} padding="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{stat.label}
</Text>
<Text fz={32} fw={700} c={stat.color} lh={1.2}>
{stat.value}
</Text>
</Card>
))}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile
label="Awaiting claim"
value={unclaimed.length}
hint="Licence applications nobody has picked up"
icon={IconInbox}
tone="info"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Assigned to me"
value={inProgress.length}
hint="Your open licence reviews"
icon={IconFileText}
tone="neutral"
onClick={() => navigate('/licence-review')}
/>
<StatTile
label="Needs applicant action"
value={needsApplicant}
hint="Returned for corrections"
icon={IconAlertTriangle}
tone="pending"
/>
<StatTile
label="Awaiting payment"
value={awaitingPayment}
hint="Approved, fee not yet settled"
icon={IconCreditCard}
tone="warning"
/>
</SimpleGrid>
<Card withBorder padding={0} radius="md">
<Group justify="space-between" p="md" pb="xs">
<Text fw={600} size="sm">
Awaiting claim
</Text>
<Text
size="xs"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/licence-review')}
>
Open queue <IconChevronRight size={11} style={{ verticalAlign: -1 }} />
</Text>
</Group>
<AdvancedTable
{/* Same 4-column track as the row above, so a two-tile row lines up with
it instead of stretching each tile to half the page. */}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile
label="Seafarer registrations"
value={show(countOf(registrations))}
hint="Submitted, awaiting review"
icon={IconUserCheck}
tone="info"
onClick={() => navigate('/seafarer-registrations')}
/>
<StatTile
label="Seaman books"
value={show(countOf(seamanBooks))}
hint="Released, awaiting payment"
icon={IconCreditCard}
tone="pending"
onClick={() => navigate('/seaman-book-queue')}
/>
</SimpleGrid>
<Grid gutter="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<AdvancedTable<LicenseApplication>
title="Awaiting claim — oldest first"
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={paged.rows}
@@ -95,12 +147,54 @@ export function DashboardPage() {
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={() => navigate('/licence-review')}
onRowClick={(row) => navigate(`/licence-review/${row.id}`)}
refresh={queue.refetch}
isLoading={queue.isFetching}
emptyText="Nothing waiting to be claimed."
toolbar={
<Anchor size="sm" onClick={() => navigate('/licence-review')}>
Open queue
</Anchor>
}
/>
</Card>
</Container>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Paper withBorder radius="lg" p="lg" h="100%">
<Text fw={600} size="sm" mb="xs">
Longest waiting
</Text>
<Text size="xs" c="dimmed" mb="md">
Unclaimed applications, by how long they have sat.
</Text>
<Stack gap="sm">
{byAge.slice(0, 5).map((app) => (
<Group key={app.id} justify="space-between" wrap="nowrap" gap="sm">
<Anchor
size="sm"
lineClamp={1}
onClick={() => navigate(`/licence-review/${app.id}`)}
>
{app.applicationNumber}
</Anchor>
<WaitingFor
since={app.submittedAt ?? app.createdAt}
slaDays={
app.licenseType?.slaHours ? app.licenseType.slaHours / 24 : undefined
}
/>
</Group>
))}
{byAge.length === 0 && (
<Text size="sm" c="dimmed">
Nothing waiting.
</Text>
)}
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -1,15 +1,17 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Button, Text } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
OPEN: 'red',
UNDER_REVIEW: 'yellow',
RESOLVED: 'teal',
DISMISSED: 'gray',
const STATUS_TONE: Record<ExamIncidentStatus, StatusTone> = {
OPEN: 'danger',
UNDER_REVIEW: 'warning',
RESOLVED: 'success',
DISMISSED: 'neutral',
};
export function examIncidentColumns(
@@ -56,13 +58,12 @@ export function examIncidentColumns(
{
header: t('exam.incidents.status'),
cell: ({ row }) => (
<Badge
<StatusBadge
tone={STATUS_TONE[row.original.status] ?? 'neutral'}
label={t(`exam.incidentStatus.${row.original.status}`)}
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status] ?? 'gray'}
>
{t(`exam.incidentStatus.${row.original.status}`)}
</Badge>
/>
),
},
{

View File

@@ -1,3 +1,4 @@
import { type StatusTone } from '@ema-platform/shared';
import { useState, useEffect, useRef, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
@@ -39,7 +40,7 @@ import {
IconCheck,
IconX,
} from '@tabler/icons-react';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { StatusBadge, ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import {
@@ -57,13 +58,13 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
import { PageLoader } from '@ema-platform/ui';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
const STATUS_TONE: Record<string, StatusTone> = {
PENDING: 'neutral',
ACTIVE: 'info',
COMPLETED: 'success',
CANCELLED: 'danger',
POSTPONED: 'pending',
PUBLISHED: 'success',
};
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
@@ -290,7 +291,7 @@ export function ExamDetailPage() {
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>{exam.title[locale]}</Title>
<Title order={2}>{exam.title[locale]}</Title>
</div>
</Group>
<Group gap="sm">
@@ -313,14 +314,13 @@ export function ExamDetailPage() {
</Group>
{/* Status badge */}
<Badge
<StatusBadge
tone={STATUS_TONE[exam.status]}
label={t(`exam.status.${exam.status}`)}
size="lg"
variant="light"
color={STATUS_COLOR[exam.status]}
style={{ width: "fit-content" }}
>
{t(`exam.status.${exam.status}`)}
</Badge>
/>
{/* Exam Info */}
<Paper withBorder radius="lg" p="lg">

View File

@@ -1,15 +1,17 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Text } from "@mantine/core";
import type { TFunction } from "i18next";
import type { AdvancedColumn } from "@ema-platform/ui";
import { StatusBadge } from '@ema-platform/ui';
import type { Exam } from "../../types/exam";
const STATUS_COLOR: Record<string, string> = {
PENDING: "gray",
ACTIVE: "blue",
COMPLETED: "teal",
CANCELLED: "red",
POSTPONED: "orange",
PUBLISHED: "green",
const STATUS_TONE: Record<string, StatusTone> = {
PENDING: 'neutral',
ACTIVE: 'info',
COMPLETED: 'success',
CANCELLED: 'danger',
POSTPONED: 'pending',
PUBLISHED: 'success',
};
export function examColumns(
@@ -72,9 +74,12 @@ export function examColumns(
{
header: t("exam.columns.status"),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`exam.status.${row.original.status}`)}
</Badge>
<StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`exam.status.${row.original.status}`)}
size="sm"
variant="light"
/>
),
},
];

View File

@@ -2,7 +2,6 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Stack,
Title,
Group,
Button,
Modal,
@@ -10,7 +9,6 @@ import {
TextInput,
Textarea,
Card,
Alert,
Select,
NumberInput,
Tabs,
@@ -35,6 +33,7 @@ import {
import type { Exam } from "../../types/exam";
import { examColumns } from "./columns";
import { examActionsColumn } from "./actions";
import { ErrorState, PageHeader } from '@ema-platform/ui';
function ExamForm({
editing,
@@ -416,13 +415,7 @@ export function ExamPage() {
};
if (isError)
return (
<Alert
icon={<IconInfoCircle size={16} />}
color="red"
title={t("exam.loadError")}
/>
);
return <ErrorState title={t("exam.loadError")} onRetry={refetch} />;
const columns = [
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
@@ -443,14 +436,12 @@ export function ExamPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t("exam.title")}</Title>
<Text fz="sm" c="dimmed">
{t("exam.subtitle")}
</Text>
</div>
{!showForm && (
<PageHeader
title={t("exam.title")}
subtitle={t("exam.subtitle")}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
@@ -461,8 +452,9 @@ export function ExamPage() {
{t("exam.add")}
</Button>
</RequirePermission>
)}
</Group>
)
}
/>
{showForm && (
<ExamForm

View File

@@ -1,11 +1,12 @@
import { Badge } from '@mantine/core';
import { type StatusTone } from '@ema-platform/shared';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Item } from '../../api/item-api';
const STATUS_COLORS: Record<Item['status'], string> = {
DRAFT: 'gray',
ACTIVE: 'green',
ARCHIVED: 'orange',
const STATUS_TONES: Record<Item['status'], StatusTone> = {
DRAFT: 'neutral',
ACTIVE: 'success',
ARCHIVED: 'pending',
};
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
@@ -14,7 +15,7 @@ export function itemColumns(showDate: (date: string) => string): AdvancedColumn<
{
header: 'Status',
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
<StatusBadge tone={STATUS_TONES[row.original.status]} label={row.original.status} />
),
},
{

View File

@@ -1,10 +1,11 @@
import { Stack, Title, Paper } from '@mantine/core';
import {Stack, Paper} from '@mantine/core';
import { ItemTable } from '../components/ItemTable';
import { PageHeader } from '@ema-platform/ui';
export function ItemPage() {
return (
<Stack gap="lg">
<Title order={2}>Items</Title>
<PageHeader title="Items" noMargin />
<Paper p="md" shadow="sm" radius="md" withBorder>
<ItemTable />
</Paper>

View File

@@ -1,15 +1,17 @@
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { type StatusTone } from '@ema-platform/shared';
import { Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const LICENSE_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'green',
EXPIRED: 'yellow',
SUSPENDED: 'orange',
CANCELLED: 'red',
SUPERSEDED: 'gray',
const LICENSE_STATUS_TONES: Record<string, StatusTone> = {
ACTIVE: 'success',
EXPIRED: 'warning',
SUSPENDED: 'pending',
CANCELLED: 'danger',
SUPERSEDED: 'neutral',
};
export type LifecycleAction = 'suspend' | 'revoke' | 'reinstate';
@@ -70,13 +72,12 @@ export function licenseRegisterColumns(
{
header: 'Status',
cell: ({ row }) => (
<Badge
<StatusBadge
tone={LICENSE_STATUS_TONES[row.original.status] ?? 'neutral'}
label={row.original.status}
size="sm"
variant="light"
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
>
{row.original.status}
</Badge>
/>
),
},
{

View File

@@ -1,19 +1,7 @@
import { useState } from 'react';
import {
Button,
Card,
Container,
Group,
Modal,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Textarea} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -151,13 +139,10 @@ export function LicenseRegisterPage() {
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Licence register</Title>
<Text size="sm" c="dimmed">
{data?.total ?? 0} issued licence{(data?.total ?? 0) === 1 ? '' : 's'}
</Text>
</div>
<PageHeader
title="Licence register"
subtitle={`${data?.total ?? 0} issued licence${(data?.total ?? 0) === 1 ? '' : 's'}`}
action={
<TextInput
placeholder="Certificate № or company"
leftSection={<IconSearch size={14} />}
@@ -165,7 +150,8 @@ export function LicenseRegisterPage() {
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
}
/>
<Card withBorder padding={0}>
<AdvancedTable<IssuedLicense>

View File

@@ -17,7 +17,6 @@ import {
Tabs,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
@@ -72,6 +71,7 @@ import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
import { licenseQueueColumns } from "./columns";
import { licenseQueueActionsColumn } from "./actions";
import { PageHeader } from '@ema-platform/ui';
const PAGE_SIZE = 10;
const SEARCH_DEBOUNCE_MS = 300;
@@ -501,15 +501,10 @@ export function LicenseQueuePage() {
return (
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{queueTitle}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
</Text>
)}
</div>
<PageHeader
title={queueTitle}
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
action={
<Group gap="xs">
<SegmentedControl
size="xs"
@@ -535,7 +530,8 @@ export function LicenseQueuePage() {
{t("queue.export", "Export CSV")}
</Button>
</Group>
</Group>
}
/>
{/* Saved views, counted. */}
<Tabs

View File

@@ -724,7 +724,7 @@ export function LicenseReviewPage() {
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{headerName}</Title>
<Title order={2}>{headerName}</Title>
<Group gap="xs">
<Text size="sm" c="dimmed">
{app.applicationNumber}

View File

@@ -1,23 +1,9 @@
import { useState, useCallback } from 'react';
import {
Stack,
Title,
Group,
Button,
Paper,
Text,
Grid,
Modal,
ActionIcon,
Tooltip,
Loader,
Center,
Alert,
} from '@mantine/core';
import {Stack, Group, Button, Paper, Text, Grid, Modal, ActionIcon, Tooltip, Loader, Center, Alert} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
import { ModalFooter, notify, PageHeader, PageLoader, useErrorHandler } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
@@ -108,8 +94,10 @@ export function LocationPage() {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>{t('location.title')}</Title>
<PageHeader
title={t('location.title')}
noMargin
action={
<Group gap="sm">
{locationTypes.length > 0 && (
<Button
@@ -136,7 +124,8 @@ export function LocationPage() {
</ActionIcon>
</Tooltip>
</Group>
</Group>
}
/>
{locationTypes.length === 0 && (
<Alert

View File

@@ -1,19 +1,7 @@
import { useNavigate } from 'react-router-dom';
import {
Badge,
Card,
Center,
Container,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
import {Badge, Card, Center, Container, Grid, Group, Loader, SimpleGrid, Stack, Text} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
import { AdvancedTable, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -74,12 +62,10 @@ export function LogisticsHeadDashboardPage() {
return (
<Container size="xl" py="md">
<Title order={3} mb="xs">
Logistics overview
</Title>
<Text size="sm" c="dimmed" mb="lg">
Licence applications currently in the department.
</Text>
<PageHeader
title="Logistics overview"
subtitle="Licence applications currently in the department."
/>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} mb="xl">
{stats.map((stat) => (

View File

@@ -1,6 +1,8 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import {
seaServiceDays,
type MedicalCertificate,
@@ -18,10 +20,10 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
);
}
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'yellow',
VERIFIED: 'teal',
REJECTED: 'red',
const STATUS_TONE: Record<SeafarerRecordStatus, StatusTone> = {
SUBMITTED: 'warning',
VERIFIED: 'success',
REJECTED: 'danger',
};
/** Only meaningful now the queue can show ruled records too. */
@@ -33,9 +35,12 @@ function statusColumn<T extends { status: SeafarerRecordStatus }>(
label: t('recordVerification.columns.status', 'Status'),
accessorKey: 'status',
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{t(`recordVerification.status.${row.original.status}`, row.original.status)}
</Badge>
<StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`recordVerification.status.${row.original.status}`, row.original.status)}
size="sm"
variant="light"
/>
),
};
}

View File

@@ -1,27 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Center,
Container,
Group,
Loader,
Modal,
Paper,
SegmentedControl,
Stack,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Badge, Button, Center, Container, Group, Loader, Modal, Paper, SegmentedControl, Stack, Text, Textarea} from '@mantine/core';
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
import {
AdvancedTable,
notify,
PdfPreviewModal,
type AdvancedColumn,
} from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, PdfPreviewModal, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -375,13 +356,14 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{isMedical
<PageHeader
title={
isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
</Title>
<Text size="sm" c="dimmed" mb="md">
{isMedical
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')
}
subtitle={
isMedical
? t(
'recordVerification.medicalSubtitle',
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
@@ -389,8 +371,9 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
: t(
'recordVerification.seaServiceSubtitle',
'Submitted sea-service records awaiting a ruling. Verified records are frozen and count toward sea time; rejections return to the seafarer with your remark.',
)}
</Text>
)
}
/>
{statusFilter}

View File

@@ -1,37 +1,13 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Center,
Group,
Loader,
Modal,
NumberInput,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core';
import {Alert, Badge, Button, Center, Group, Loader, Modal, NumberInput, Paper, Stack, Switch, Text, TextInput, ThemeIcon, Tooltip} from '@mantine/core';
import {
IconAlertTriangle,
IconCreditCard,
IconInfoCircle,
IconLock,
} from '@tabler/icons-react';
import {
notify,
ModalFooter,
AdvancedTable,
useServerTable,
type AdvancedColumn,
PageLoader,
} from '@ema-platform/ui';
import { AdvancedTable, ModalFooter, notify, PageHeader, PageLoader, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import {
extractErrorMessage,
useLocalized,
@@ -92,20 +68,19 @@ export function PaymentConfigPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
<Text size="sm" c="dimmed" mt={4}>
{t(
<PageHeader
title={t('paymentConfig.title', 'Payment configuration')}
subtitle={t(
'paymentConfig.subtitle',
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
)}
</Text>
</div>
noMargin
action={
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
</Group>
}
/>
<Alert
variant="light"

View File

@@ -249,7 +249,7 @@ export function ProfilePage() {
return (
<Stack gap="lg" maw={900}>
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} />
<PageHeader title={t('profile.title')} subtitle={t('profile.subtitle')} noMargin />
{/* Profile summary */}
<Paper p="lg" shadow="sm" radius="lg" withBorder>

View File

@@ -1,23 +1,9 @@
import { useState } from 'react';
import {
Stack,
Title,
Group,
Button,
Badge,
Modal,
Text,
TextInput,
Card,
Alert,
Select,
NumberInput,
Textarea,
} from '@mantine/core';
import {Stack, Group, Button, Badge, Modal, Text, TextInput, Select, NumberInput, Textarea} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import {IconPlus} from '@tabler/icons-react';
import { AdvancedColumn, AdvancedTable, ErrorState, ModalFooter, notify, PageHeader, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
@@ -202,7 +188,7 @@ export function QuestionPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
if (isError) return <ErrorState title={t('question.loadError')} onRetry={refetch} />;
const columns: AdvancedColumn<Question>[] = [
...questionColumns(t, { locale, getCertName }),
@@ -217,16 +203,19 @@ export function QuestionPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<Title order={2}>{t('question.title')}</Title>
{!showForm && (
<PageHeader
title={t('question.title')}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.AUTHOR_QUESTION]} hideOnly>
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('question.addQuestion')}
</Button>
</RequirePermission>
)}
</Group>
)
}
/>
{showForm && (
<QuestionForm
@@ -238,9 +227,12 @@ export function QuestionPage() {
/>
)}
<Card withBorder padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('question.pool')}</Text>
<AdvancedTable
columns={columns}
data={page.rows}
title={t('question.pool')}
tableName={t('question.title')}
toolbar={
<Select
placeholder={t('question.filterByCertification')}
data={[{ value: '', label: 'All' }, ...certOptions]}
@@ -250,11 +242,7 @@ export function QuestionPage() {
style={{ width: 280 }}
clearable
/>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('question.title')}
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -264,7 +252,6 @@ export function QuestionPage() {
isLoading={isFetching}
emptyText={t('question.noQuestions')}
/>
</Card>
<Modal
opened={Boolean(reviewTarget)}

View File

@@ -1,21 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Center,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Button, Center, Group, Loader, Modal, Paper, Select, Stack, Text, Textarea} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, PageLoader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
@@ -75,12 +62,11 @@ export function ExamAppealsPage() {
return (
<Stack gap="lg">
<div>
<Title order={2}>{t('result.appeals.title')}</Title>
<Text fz="sm" c="dimmed">
{t('result.appeals.subtitle')}
</Text>
</div>
<PageHeader
title={t('result.appeals.title')}
subtitle={t('result.appeals.subtitle')}
noMargin
/>
<Paper withBorder radius="md">
<AdvancedTable<ExamAppeal>

View File

@@ -1,11 +1,13 @@
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
import { Badge, Box, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Result, ResultReviewStatus } from '../../types/result';
export const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
FAILED: 'red',
export const STATUS_TONE: Record<string, StatusTone> = {
PASSED: 'success',
FAILED: 'danger',
};
/** Where a mark sits in quality control (US-EXAM-011 → 014). */
@@ -46,20 +48,22 @@ export function resultColumns(
{
header: t('result.columns.status'),
cell: ({ row }) => (
<Badge
<StatusBadge
tone={STATUS_TONE[row.original.status]}
label={t(`result.status.${row.original.status}`)}
size="sm"
variant="light"
color={STATUS_COLOR[row.original.status]}
leftSection={
<Box
w={6}
h={6}
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
style={{
borderRadius: 999,
background: `var(--mantine-color-${STATUS_TONE_COLOR[STATUS_TONE[row.original.status]]}-6)`,
}}
/>
}
>
{t(`result.status.${row.original.status}`)}
</Badge>
/>
),
},
{

View File

@@ -1,40 +1,9 @@
import { useState, useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Title,
Group,
Table,
Badge,
Modal,
Text,
Paper,
Card,
Loader,
Center,
Alert,
Select,
SimpleGrid,
Divider,
Button,
ThemeIcon,
TextInput,
} from '@mantine/core';
import {Stack, Group, Table, Badge, Modal, Text, Paper, Loader, Center, Select, SimpleGrid, Divider, Button, ThemeIcon, TextInput} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconInfoCircle,
IconUser,
IconCertificate,
IconDeviceFloppy,
IconPlus,
IconClipboardList,
IconCircleCheck,
IconCircleX,
IconChartBar,
IconSearch,
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
import {IconUser, IconCertificate, IconDeviceFloppy, IconPlus, IconClipboardList, IconCircleCheck, IconCircleX, IconChartBar, IconSearch, IconSend} from '@tabler/icons-react';
import { AdvancedTable, BilingualInput, ErrorState, ModalFooter, notify, PageHeader, StatusBadge, useErrorHandler, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
@@ -53,7 +22,7 @@ import { useGetExamsQuery } from '../../../exam/api/exam-api';
import { RecordResultModal } from '../../components/RecordResultModal';
import type { Result, ResultBreakdown } from '../../types/result';
import type { Exam } from '../../../exam/types/exam';
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
import { resultColumns, STATUS_TONE, REVIEW_COLOR } from './columns';
import { resultActionsColumn, type QcAction } from './actions';
function ResultStat({
@@ -280,7 +249,7 @@ export function ResultPage() {
}
};
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
if (isError) return <ErrorState title={t('result.loadError')} onRetry={refetch} />;
const columns = [
...resultColumns(t, locale, showDate, getExamTitle),
@@ -295,11 +264,11 @@ export function ResultPage() {
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>{t('result.title')}</Title>
<Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div>
<PageHeader
title={t('result.title')}
subtitle={t('result.subtitle')}
noMargin
action={
<Group gap="sm">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button
@@ -318,7 +287,8 @@ export function ResultPage() {
{t('result.record')}
</Button>
</Group>
</Group>
}
/>
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
<ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
@@ -327,10 +297,13 @@ export function ResultPage() {
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid>
<Card withBorder padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>{t('result.section')}</Text>
<Group gap="sm" wrap="wrap">
<AdvancedTable
columns={columns}
data={page.rows}
title={t('result.section')}
tableName={t('result.title')}
toolbar={
<>
<TextInput
placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />}
@@ -348,13 +321,8 @@ export function ResultPage() {
style={{ width: 280 }}
clearable
/>
</Group>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('result.title')}
</>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
@@ -364,7 +332,6 @@ export function ResultPage() {
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Card>
<Modal
opened={detailOpened}
@@ -421,9 +388,10 @@ export function ResultPage() {
{t('result.review.derivedStatus')}
</Text>
<Group gap="xs" mt={4}>
<Badge variant="light" color={STATUS_COLOR[detailResult.status]}>
{t(`result.status.${detailResult.status}`)}
</Badge>
<StatusBadge
tone={STATUS_TONE[detailResult.status]}
label={t(`result.status.${detailResult.status}`)}
/>
<Badge variant="light" color={REVIEW_COLOR[detailResult.reviewStatus] ?? 'gray'}>
{t(`result.review.${detailResult.reviewStatus}`)}
</Badge>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import {Badge, Container, Select, Text, TextInput} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
@@ -12,7 +12,7 @@ import {
type SeafarerDocumentRow,
type SeafarerDocumentStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, PageHeader, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
@@ -91,6 +91,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
header: 'Waiting',
accessorKey: 'submittedAt',
size: 90,
cell: ({ row }) => (
<WaitingFor
since={row.original.submittedAt}
done={row.original.status === 'ISSUED' || row.original.status === 'REJECTED'}
/>
),
},
{
header: 'Status',
accessorKey: 'status',
@@ -106,14 +117,16 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue
</Title>
<Text size="sm" c="dimmed" mb="md">
Requests released by an approved seafarer registration: confirm payment, schedule the
collection date, then issue.
</Text>
<Group mb="md" gap="sm">
<PageHeader
title={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} Queue`}
subtitle="Requests released by an approved seafarer registration: confirm payment, schedule the collection date, then issue."
/>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
toolbar={
<>
<TextInput
placeholder="Search number, name or seafarer №…"
leftSection={<IconSearch size={14} />}
@@ -122,7 +135,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
w={260}
/>
<Select
placeholder="All statuses"
@@ -133,13 +146,10 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
setPage(0);
}}
clearable
w={220}
w={200}
/>
</Group>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
</>
}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}

View File

@@ -1,21 +1,6 @@
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Center,
Container,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Badge, Button, Center, Container, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
@@ -29,7 +14,7 @@ import {
useRejectSeafarerDocumentMutation,
useScheduleSeafarerDocumentMutation,
} from '@ema-platform/api';
import { AmharicDatePicker, notify } from '@ema-platform/ui';
import { AmharicDatePicker, notify, PageHeader } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
@@ -123,12 +108,10 @@ export function SeafarerDocumentReviewPage() {
>
Back to queue
</Button>
<Group justify="space-between" align="flex-start" mb="md">
<div>
<Title order={3}>
{kindLabel} {applicant?.name ?? '—'}
</Title>
<Group gap="xs" mt={4}>
<PageHeader
title={`${kindLabel}${applicant?.name ?? '—'}`}
meta={
<>
<Text size="sm" c="dimmed" ff="monospace">
{document.requestNumber}
</Text>
@@ -140,8 +123,9 @@ export function SeafarerDocumentReviewPage() {
{document.documentNumber}
</Badge>
)}
</Group>
</div>
</>
}
action={
<Group gap="xs">
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
@@ -175,7 +159,8 @@ export function SeafarerDocumentReviewPage() {
</RequirePermission>
)}
</Group>
</Group>
}
/>
{document.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
@@ -189,7 +174,7 @@ export function SeafarerDocumentReviewPage() {
<Text fw={600} size="sm" mb={4}>
Seafarer
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
<Row label="Name" value={applicant?.name} />
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
@@ -212,7 +197,7 @@ export function SeafarerDocumentReviewPage() {
<Text fw={600} size="sm" mb={4}>
Payment
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
<Row label="Fee" value={document.feeAmount !== null ? `${document.feeAmount} ${document.feeCurrency}` : null} />
<Row label="Released to payment" value={document.submittedAt ? showDate(document.submittedAt) : null} />
@@ -226,7 +211,7 @@ export function SeafarerDocumentReviewPage() {
<Text fw={600} size="sm" mb={4}>
Issuance
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
<Row label="Document №" value={document.documentNumber} />

View File

@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import {Container, Select, Text, TextInput} from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer,
useListSeafarerRegistrationsQuery,
type SeafarerRegistration,
type SeafarerRegistrationStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { AdvancedTable, PageHeader, StatusBadge, WaitingFor, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
@@ -78,13 +78,25 @@ export function SeafarerRegistrationQueuePage() {
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
header: 'Waiting',
accessorKey: 'submittedAt',
size: 90,
cell: ({ row }) => (
<WaitingFor
since={row.original.submittedAt}
done={row.original.status === 'APPROVED' || row.original.status === 'REJECTED'}
/>
),
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => (
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[row.original.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[row.original.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[row.original.status]}
/>
),
},
],
@@ -93,14 +105,16 @@ export function SeafarerRegistrationQueuePage() {
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
Seafarer Registration Queue
</Title>
<Text size="sm" c="dimmed" mb="md">
Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and
BTC applications.
</Text>
<Group mb="md" gap="sm">
<PageHeader
title="Seafarer Registration Queue"
subtitle="Registrations awaiting review. Approval numbers the seafarer and opens their Seaman Book and BTC applications."
/>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName="Seafarer registrations"
toolbar={
<>
<TextInput
placeholder="Search number, name or ID…"
leftSection={<IconSearch size={14} />}
@@ -109,7 +123,7 @@ export function SeafarerRegistrationQueuePage() {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
w={260}
/>
<Select
placeholder="All statuses"
@@ -120,13 +134,10 @@ export function SeafarerRegistrationQueuePage() {
setPage(0);
}}
clearable
w={220}
w={200}
/>
</Group>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName="Seafarer registrations"
</>
}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}

View File

@@ -1,28 +1,12 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Center,
Container,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Table,
Text,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Badge, Button, Center, Container, Divider, Group, Loader, Modal, Paper, Stack, Table, Text, Textarea} from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft, IconCheck } from '@tabler/icons-react';
import {
SEAFARER_REGISTRATION_DOCUMENTS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer,
extractErrorMessage,
@@ -31,7 +15,7 @@ import {
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, PageHeader, StatusBadge } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { applicantName } from './SeafarerRegistrationQueuePage';
@@ -109,23 +93,25 @@ export function SeafarerRegistrationReviewPage() {
<Button variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/seafarer-registrations')} mb="xs">
Back to queue
</Button>
<Group justify="space-between" align="flex-start" mb="md">
<div>
<Title order={3}>{applicantName(registration)}</Title>
<Group gap="xs" mt={4}>
<PageHeader
title={applicantName(registration)}
meta={
<>
<Text size="sm" c="dimmed" ff="monospace">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
{registration.seafarerNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{registration.seafarerNumber}
</Badge>
)}
</Group>
</div>
</>
}
action={
<Group gap="xs">
{canDecide && (
<>
@@ -147,7 +133,8 @@ export function SeafarerRegistrationReviewPage() {
</>
)}
</Group>
</Group>
}
/>
{registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
@@ -167,7 +154,7 @@ export function SeafarerRegistrationReviewPage() {
<Text fw={600} size="sm" mb={4}>
{section.title}
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
{section.fields
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
@@ -192,7 +179,7 @@ export function SeafarerRegistrationReviewPage() {
<Text fw={600} size="sm" mb={4}>
Documents
</Text>
<Table withTableBorder withColumnBorders>
<Table variant="vertical">
<Table.Tbody>
{slots.map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];

View File

@@ -1,25 +1,8 @@
import { PageHeader, StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared';
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
ActionIcon,
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
rem,
} from '@mantine/core';
import {ActionIcon, Badge, Button, Card, Collapse, Divider, Group, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, TextInput, ThemeIcon, rem} from '@mantine/core';
import {
IconBook2,
IconCertificate,
@@ -59,7 +42,7 @@ const STATUS_OPTIONS = ['All', 'Active', 'Inactive', 'Suspended'];
const MEDICAL_OPTIONS = ['All', 'Valid', 'Expiring', 'Expired'];
const MEDICAL_COLOR: Record<string, string> = { Valid: 'teal', Expiring: 'orange', Expired: 'red' };
const STATUS_COLOR: Record<string, string> = { Active: 'teal', Inactive: 'gray', Suspended: 'red' };
const STATUS_TONE: Record<string, StatusTone> = { Active: 'success', Inactive: 'neutral', Suspended: 'danger' };
// ---------------------------------------------------------------------------
// Detail modal
@@ -83,7 +66,7 @@ function DetailModal({ sf, opened, onClose }: { sf: Seafarer | null; opened: boo
<Group justify="space-between"><Text fz="xs" c="dimmed">Nationality</Text><Text fz="xs">{sf.nationality}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Date of Birth</Text><Text fz="xs">{sf.dob}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Rank</Text><Text fz="xs" fw={600}>{sf.rank}</Text></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge></Group>
<Group justify="space-between"><Text fz="xs" c="dimmed">Status</Text><StatusBadge tone={STATUS_TONE[sf.status]} label={sf.status} variant="light" size="xs" /></Group>
</Stack>
</Paper>
<Paper withBorder radius="md" p="md">
@@ -169,10 +152,11 @@ export function SeafarerRegistryPage() {
return (
<Stack gap="md">
<div>
<Title order={3}>Seafarer Registry</Title>
<Text fz="sm" c="dimmed">Search and view all registered seafarers, their documents, and certificate status</Text>
</div>
<PageHeader
title="Seafarer Registry"
subtitle="Search and view all registered seafarers, their documents, and certificate status"
noMargin
/>
{/* KPIs */}
<SimpleGrid cols={{ base: 3, sm: 6 }} spacing="sm">
@@ -264,7 +248,12 @@ export function SeafarerRegistryPage() {
: <Text fz="xs" c="dimmed"></Text>}
</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[sf.status]} variant="light" size="xs">{sf.status}</Badge>
<StatusBadge
tone={STATUS_TONE[sf.status]}
label={sf.status}
variant="light"
size="xs"
/>
</Table.Td>
<Table.Td>
<ActionIcon variant="light" color="blue" size="sm" onClick={() => setSelected(sf)}>

View File

@@ -336,7 +336,7 @@ export function VesselRegistrationFormBuilderPage() {
<IconSettings size={24} />
</ThemeIcon>
<div>
<Title order={3}>Vessel Registration Form Builder</Title>
<Title order={2}>Vessel Registration Form Builder</Title>
<Text fz="sm" c="dimmed">Add, edit, reorder, or disable fields on the vessel registration form</Text>
</div>
</Group>

View File

@@ -1,13 +1,15 @@
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { type StatusTone } from '@ema-platform/shared';
import { Button, Text, Tooltip } from '@mantine/core';
import { IconShieldCog } from '@tabler/icons-react';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import type { Vessel } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'success',
SUSPENDED: 'pending',
DEREGISTERED: 'neutral',
};
export const CATEGORY_LABELS: Record<string, string> = {
@@ -63,13 +65,12 @@ export function vesselRegistrationQueueColumns(
{
header: 'Status',
cell: ({ row }) => (
<Badge
<StatusBadge
tone={VESSEL_STATUS_TONES[row.original.status]}
label={row.original.status}
size="sm"
variant="light"
color={VESSEL_STATUS_COLORS[row.original.status]}
>
{row.original.status}
</Badge>
/>
),
},
{

View File

@@ -1,29 +1,12 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Badge,
Button,
Card,
Container,
Drawer,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Text,
TextInput,
Textarea,
Title,
} from '@mantine/core';
import {Alert, Badge, Button, Card, Container, Drawer, Group, Loader, Modal, Select, Stack, Table, Text, TextInput, Textarea} from '@mantine/core';
import {
IconAlertTriangle,
IconInfoCircle,
IconSearch,
} from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, notify, PageHeader, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -238,18 +221,19 @@ export function VesselRegistrationQueuePage() {
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md">
<div>
<Title order={3}>Vessel register</Title>
<Text size="sm" c="dimmed">
<PageHeader
title="Vessel register"
subtitle={
<>
{data?.total ?? 0} vessel{(data?.total ?? 0) === 1 ? '' : 's'}
pending registrations are reviewed in the{' '}
<Text component={Link} to="/licence-review" inherit c="blue">
licence queue
</Text>
.
</Text>
</div>
</>
}
action={
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
@@ -257,7 +241,8 @@ export function VesselRegistrationQueuePage() {
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
}
/>
<AdvancedTable
tableName="Vessel register"

View File

@@ -1,13 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Alert, Container, Group, Text, Title } from '@mantine/core';
import {Alert, Container, Group, Text} from '@mantine/core';
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
import {
ApiErrorAlert,
EmptyState,
PageLoader,
notify,
} from '@ema-platform/ui';
import { ApiErrorAlert, EmptyState, notify, PageHeader, PageLoader } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
downloadAuthedFile,
@@ -85,16 +80,14 @@ export function VesselRegistrationReportPage() {
return (
<Container size="xl" py="md">
<Group justify="space-between" mb="md" align="flex-start">
<div>
<Title order={3}>Vessel registration report</Title>
<Text size="sm" c="dimmed">
{report
<PageHeader
title="Vessel registration report"
subtitle={
report
? `Register-wide totals with a ${showDate(report.filters.from)} ${showDate(report.filters.to)} window on the trends.`
: 'The national vessel register at a glance.'}
</Text>
</div>
</Group>
: 'The national vessel register at a glance.'
}
/>
<ReportFilters
query={query}

View File

@@ -11,6 +11,10 @@ export const am: Translations = {
tagline: "የቁጥጥር ማዕከል",
},
a11y: {
skipToContent: "ወደ ዋናው ይዘት ዝለል",
},
msg: {
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",

View File

@@ -10,6 +10,11 @@ export const en = {
tagline: 'Control Center',
},
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: {
genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.',

View File

@@ -8,6 +8,7 @@ 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 { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui';
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
import { usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -151,6 +152,10 @@ export function BackofficeLayout() {
const isSidebar = layoutMode === "sidebar";
return (
<>
{/* First focusable element on the page, so a keyboard user can bypass
the 20-plus nav items instead of tabbing through them every time. */}
<SkipLink />
<AppShell
// The top layout drops its nav strip on small screens — the drawer is
// the nav there — so the header shrinks back to a single row with it.
@@ -252,7 +257,7 @@ export function BackofficeLayout() {
</AppShell.Navbar>
)}
<AppShell.Main>
<AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
@@ -287,5 +292,6 @@ export function BackofficeLayout() {
/>
</Drawer>
</AppShell>
</>
);
}

View File

@@ -12,6 +12,7 @@ import {
RequirePermission,
LICENSE_PERMISSIONS as P,
} from '@ema-platform/auth';
import { ThemeGallery } from '@ema-platform/ui';
import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
@@ -68,6 +69,9 @@ const router = createBrowserRouter([
],
},
{ path: '/um/*', element: <UserManagementPage /> },
// Theme visual-regression surface. Unauthenticated by design — it renders
// only static primitives, so it needs no API and cannot flake.
{ path: '/__gallery', element: <ThemeGallery /> },
{ path: '/', element: <LandingRoute /> },
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
{

View File

@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/spotlight/styles.css';
// After Mantine's CSS (it defines the variables these tokens resolve to),
// before the app's own, which may override them. Relative because the
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
import '../../../libs/shared/src/lib/theme/semantic.css';
import './styles.css';
import './app/i18n/config';
import { App } from './app/app';

View File

@@ -1,7 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
Without it every Amharic string in the app renders in whatever the OS
happens to substitute — different on Windows, macOS and Android. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; }

View File

@@ -347,7 +347,7 @@ test.describe('seafarer registration', () => {
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
await page.getByRole('button', { name: /^continue$/i }).click();
// Step 2 — Applicant Details.
// Step 2 — Details: address and physical characteristics.
await page.getByLabel('Place of Birth').fill('Addis Ababa');
await pick(page, 'Department', /deck/i);
await pick(page, 'City', /addis ababa/i);
@@ -356,15 +356,15 @@ test.describe('seafarer registration', () => {
await pick(page, 'Eye Colour', /brown/i);
await page.getByLabel('Height (cm)').fill('172');
await page.getByLabel('Weight (kg)').fill('68');
await page.getByLabel('Certificate Number').fill('MED-2026-001');
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
await pickDate(page, 'Issue Date', '2026-01-15');
await page.getByRole('button', { name: /^continue$/i }).click();
// Step 3 — Emergency Contact.
// Step 3 — Contact & Medical.
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
await page.getByLabel('Relationship').fill('Sister');
await page.getByLabel('Phone Number').fill('+251911222333');
await page.getByLabel('Certificate Number').fill('MED-2026-001');
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
await pickDate(page, 'Issue Date', '2026-01-15');
await page.getByRole('button', { name: /^continue$/i }).click();
// Step 4 — Documents: all four required slots show as uploaded.

65
apps/e2e/visual.config.ts Normal file
View File

@@ -0,0 +1,65 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Visual-regression suite for theme work.
*
* Deliberately separate from `playwright.config.ts`. That suite drives real
* cross-app workflows and therefore needs the API, a database and migrations;
* this one only needs to know what the theme renders. Loading the same
* dependencies here would make a screenshot diff fail for reasons that have
* nothing to do with the theme — a migration, a seeded row, an expired token.
*
* So: static routes only, `vite preview` over an already-built bundle, no
* backend. Run `vite build` for both apps first.
*/
const PORTAL_PORT = Number(process.env.VISUAL_PORTAL_PORT ?? 4312);
const BACKOFFICE_PORT = Number(process.env.VISUAL_BACKOFFICE_PORT ?? 4313);
export const VISUAL = {
portalUrl: `http://localhost:${PORTAL_PORT}`,
backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`,
};
export default defineConfig({
testDir: './visual',
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
// A visual diff that passes on a retry is a flake, and a flake here would
// mask exactly the regressions this suite exists to catch.
retries: 0,
timeout: 60_000,
expect: {
// Anti-aliasing differs slightly between runs; a handful of pixels is not
// a regression. Anything the theme actually changed is far larger.
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' },
},
reporter: [['list'], ['html', { outputFolder: '../../dist/visual-report', open: 'never' }]],
use: {
trace: 'retain-on-failure',
actionTimeout: 15_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: [
{
name: 'portal',
command: `npx vite preview --config apps/portal/vite.config.mts --port ${PORTAL_PORT} --strictPort`,
cwd: '../..',
url: VISUAL.portalUrl,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
{
name: 'backoffice',
command: `npx vite preview --config apps/backoffice/vite.config.mts --port ${BACKOFFICE_PORT} --strictPort`,
cwd: '../..',
url: VISUAL.backofficeUrl,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
],
});

View File

@@ -0,0 +1,139 @@
import { test, expect, type Page } from '@playwright/test';
import { VISUAL } from '../visual.config';
/**
* Theme baselines.
*
* These exist so a change to the shared theme can be reviewed as a diff rather
* than trusted. The gallery route renders every primitive the theme controls,
* so one screenshot per app per scheme per width covers the whole surface.
*
* Update baselines deliberately, never reflexively:
* npx playwright test -c apps/e2e/visual.config.ts --update-snapshots
* A diff you did not intend is the entire point of the suite.
*/
const WIDTHS = [
{ name: 'desktop', width: 1440, height: 1200 },
{ name: 'tablet', width: 768, height: 1200 },
] as const;
const SCHEMES = ['light', 'dark'] as const;
const APPS = [
{ name: 'backoffice', url: VISUAL.backofficeUrl },
{ name: 'portal', url: VISUAL.portalUrl },
] as const;
/**
* Set the scheme the way the app itself does — the pre-paint script in
* index.html reads this key. Setting it before navigation means the very first
* paint is already correct, so no screenshot catches a flash of the wrong one.
*/
async function gotoGallery(page: Page, baseUrl: string, scheme: string) {
await page.addInitScript((value) => {
window.localStorage.setItem('mantine-color-scheme-value', value);
}, scheme);
await page.goto(`${baseUrl}/__gallery`, { waitUntil: 'networkidle' });
// The gallery is static, but web fonts are not: screenshotting before they
// settle bakes a fallback-font baseline that every later run then fails
// against.
await page.evaluate(() => document.fonts.ready);
await expect(page.getByRole('heading', { name: 'Theme Gallery' })).toBeVisible();
}
for (const app of APPS) {
for (const scheme of SCHEMES) {
for (const size of WIDTHS) {
test(`${app.name} gallery — ${scheme}${size.name}`, async ({ page }) => {
await page.setViewportSize({ width: size.width, height: size.height });
await gotoGallery(page, app.url, scheme);
await expect(page).toHaveScreenshot(
`${app.name}-gallery-${scheme}-${size.name}.png`,
{ fullPage: true },
);
});
}
}
/**
* The focus ring, captured while actually focused.
*
* The full-page shots above can't show this: nothing is focused in them, so
* a regression that removed the ring entirely would leave them all green.
* Keyboard focus specifically, because `:focus-visible` deliberately does
* not match a mouse click.
*/
test(`${app.name} — focus ring is visible`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoGallery(page, app.url, 'light');
const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last();
await section.scrollIntoViewIfNeeded();
const button = page.getByRole('button', { name: 'Button', exact: true });
await button.focus();
await expect(button).toBeFocused();
// Assert the ring in computed styles as well as pixels. A screenshot alone
// would still pass if the outline came from somewhere unintended, and a
// token that failed to resolve leaves an empty string rather than an error.
const ring = await button.evaluate((el) => {
const s = getComputedStyle(el);
return {
width: s.outlineWidth,
style: s.outlineStyle,
token: getComputedStyle(document.documentElement)
.getPropertyValue('--ema-focus-ring')
.trim(),
};
});
expect(ring.style).toBe('solid');
expect(ring.width).toBe('2px');
expect(ring.token).not.toBe('');
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`);
});
}
/**
* The skip link, on a real app shell.
*
* Not on the gallery route: the point of a skip link is bypassing the nav, and
* the gallery has none. The login page is the shell-less public route both apps
* share, so this uses the landing route instead — it carries the chrome without
* needing a session.
*
* A skip link is invisible until focused, which means a broken one and a
* working one look identical in every screenshot. Only a focus test separates
* them.
*/
test.describe('skip link', () => {
for (const app of APPS) {
test(`${app.name} — reveals on focus and targets main`, async ({ page }) => {
await page.goto(`${app.url}/`, { waitUntil: 'networkidle' });
const link = page.locator('.ema-skip-link');
if ((await link.count()) === 0) {
// The public landing route does not mount the app shell in every app;
// skipping is honest here, where asserting absence would be wrong.
test.skip(true, 'landing route does not mount the app shell');
return;
}
// Off-screen until focused...
await expect(link).not.toBeInViewport();
await page.keyboard.press('Tab');
await expect(link).toBeFocused();
await expect(link).toBeInViewport();
// ...and it must point at something that exists.
const href = await link.getAttribute('href');
expect(href).toBe('#ema-main-content');
});
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -1,3 +1,4 @@
import { type StatusTone, STATUS_TONE_COLOR } from '@ema-platform/shared';
import { useRef, useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import {
@@ -30,7 +31,7 @@ import {
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
// ---------------------------------------------------------------------------
@@ -45,11 +46,11 @@ interface BSTRecord {
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending Verification';
}
const STATUS_COLOR: Record<string, string> = {
Valid: 'teal',
Expiring: 'orange',
Expired: 'red',
'Pending Verification': 'yellow',
const STATUS_TONE: Record<string, StatusTone> = {
Valid: 'success',
Expiring: 'pending',
Expired: 'danger',
'Pending Verification': 'warning',
};
/** Progress across the five STCW A-VI/1 modules, as `/bst/my` reports it. */
@@ -289,14 +290,13 @@ export function BasicSafetyTrainingPage() {
</Text>
</div>
{record && (
<Badge
<StatusBadge
tone={STATUS_TONE[record.status]}
label={record.status}
size="lg"
variant="light"
color={STATUS_COLOR[record.status]}
leftSection={<IconShieldCheck size={14} />}
>
{record.status}
</Badge>
/>
)}
</Group>
@@ -321,7 +321,7 @@ export function BasicSafetyTrainingPage() {
<Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Group gap="sm">
<ThemeIcon size={48} radius="md" color={STATUS_COLOR[record.status]} variant="light">
<ThemeIcon size={48} radius="md" color={STATUS_TONE_COLOR[STATUS_TONE[record.status]]} variant="light">
<IconShieldCheck size={24} />
</ThemeIcon>
<div>
@@ -329,9 +329,7 @@ export function BasicSafetyTrainingPage() {
<Text fz="xs" c="dimmed">Combined certificate all 5 STCW components</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[record.status]} variant="light">
{record.status}
</Badge>
<StatusBadge tone={STATUS_TONE[record.status]} label={record.status} variant="light" />
</Group>
<Stack gap="xs" mb="md">

View File

@@ -72,7 +72,7 @@ export function IdentityDetailsStep(
);
}
/** Step 2 — Identity, Address, Physical Characteristics, Medical Certificate. */
/** Step 2 — Identity, Address and Physical Characteristics. */
export function ApplicantDetailsStep(p: StepProps) {
return (
<Stack gap="lg">
@@ -138,6 +138,29 @@ export function ApplicantDetailsStep(p: StepProps) {
/>
</Grid>
</Stack>
);
}
/**
* Step 4 — Emergency Contact and Medical Certificate.
*
* The medical certificate used to sit at the bottom of Applicant Details,
* which made that step 17 fields across four sections while this one held
* three. Both are short, unrelated-to-identity, and copied off a document in
* hand rather than recalled — so they pair here and the wizard's longest step
* drops by a third.
*/
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
<Divider />
<SectionTitle
title="Medical Certificate"
@@ -157,17 +180,3 @@ export function ApplicantDetailsStep(p: StepProps) {
</Stack>
);
}
/** Step 3 — Emergency Contact. */
export function EmergencyContactStep(p: StepProps) {
return (
<Stack gap="lg">
<SectionTitle title="Emergency Contact" description="The person EMA contacts in an emergency." />
<Grid>
<TextField {...p} name="emergencyContactName" label="Full Name" maxLength={255} />
<TextField {...p} name="emergencyContactRelationship" label="Relationship" maxLength={64} />
<TextField {...p} name="emergencyContactPhone" label="Phone Number" maxLength={32} />
</Grid>
</Stack>
);
}

View File

@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Button,
Center,
Container,
@@ -20,7 +19,7 @@ import { notifications } from '@mantine/notifications';
import {
PHYSICAL_BOUNDS,
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_TONES,
SEAFARER_REGISTRATION_STATUS_LABELS,
extractErrorMessage,
extractValidationIssues,
@@ -33,7 +32,7 @@ import {
type SeafarerRegistration,
type ValidationIssue,
} from '@ema-platform/api';
import { splitPersonName } from '@ema-platform/ui';
import { splitPersonName, StatusBadge } from '@ema-platform/ui';
import { useCurrentProfile, type CurrentProfile } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
import { CheckboxField, type AnswerKey } from '../components/fields';
@@ -41,16 +40,19 @@ import { ApplicantDetailsStep, EmergencyContactStep, IdentityDetailsStep } from
import { RegistrationDocuments, documentSlots } from '../components/RegistrationDocuments';
import { RegistrationSummary } from '../components/RegistrationSummary';
const STEPS = ['Identity Details', 'Applicant Details', 'Emergency Contact', 'Documents', 'Review'];
const STEPS = [
{ label: 'Identity', description: 'Who you are' },
{ label: 'Details', description: 'Address & physical' },
{ label: 'Contact & Medical', description: 'Emergency & fitness' },
{ label: 'Documents', description: 'Upload evidence' },
{ label: 'Review', description: 'Check & submit' },
];
/** Which answers each step must have before "Continue" — mirrors the API's submission check. */
const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality', 'nationalIdNumber'],
[
'placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg',
'medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate',
],
[],
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
[],
['declarationAccepted'],
];
@@ -123,7 +125,7 @@ export function SeafarerRegistrationPage() {
const registration = data?.registration ?? null;
const [start] = useStartSeafarerRegistrationMutation();
const [save] = useSaveSeafarerRegistrationMutation();
const [save, { isLoading: saving }] = useSaveSeafarerRegistrationMutation();
const [submit, { isLoading: submitting }] = useSubmitSeafarerRegistrationMutation();
const [startError, setStartError] = useState<string | null>(null);
const started = useRef(false);
@@ -215,12 +217,16 @@ export function SeafarerRegistrationPage() {
}
}
setErrors(found);
const count = Object.keys(found).length;
if (count) {
const missingKeys = Object.keys(found) as AnswerKey[];
if (missingKeys.length) {
// Name the fields rather than counting them. "Complete 3 required fields"
// sends the applicant hunting up a step they have already scrolled past;
// the labels are what let them go straight to it.
const names = missingKeys.map((k) => SEAFARER_REGISTRATION_FIELD_LABELS[k]);
notifications.show({
color: 'red',
title: 'Incomplete',
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
title: missingKeys.length > 1 ? 'Some details are missing' : 'One detail is missing',
message: `${names.join(', ')}.`,
});
return false;
}
@@ -307,9 +313,10 @@ export function SeafarerRegistrationPage() {
<Text size="sm" c="dimmed">
{registration.registrationNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_REGISTRATION_STATUS_COLORS[registration.status]}>
{SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
</Badge>
<StatusBadge
tone={SEAFARER_REGISTRATION_STATUS_TONES[registration.status]}
label={SEAFARER_REGISTRATION_STATUS_LABELS[registration.status]}
/>
</Group>
</div>
{showSummary && !readOnly && (
@@ -362,8 +369,8 @@ export function SeafarerRegistrationPage() {
{!showSummary && (
<Paper withBorder p="lg" radius="md">
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
{STEPS.map((label) => (
<Stepper.Step key={label} label={label} />
{STEPS.map((step) => (
<Stepper.Step key={step.label} label={step.label} description={step.description} />
))}
</Stepper>
@@ -408,9 +415,11 @@ export function SeafarerRegistrationPage() {
Back
</Button>
{active < STEPS.length - 1 ? (
<Button onClick={() => goToStep(active + 1)}>Continue</Button>
<Button loading={saving} onClick={() => goToStep(active + 1)}>
Continue
</Button>
) : (
<Button color="teal" loading={submitting} disabled={readOnly} onClick={handleSubmit}>
<Button color="teal" loading={saving || submitting} disabled={readOnly} onClick={handleSubmit}>
{isAdjusting ? 'Resubmit corrections' : 'Submit registration'}
</Button>
)}

View File

@@ -1,12 +1,14 @@
import { type StatusTone } from '@ema-platform/shared';
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import { StatusBadge } from '@ema-platform/ui';
import { seaServiceDays, type MedicalCertificate, type SeaServiceRecord } from '@ema-platform/api';
const RECORD_STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'blue',
VERIFIED: 'green',
REJECTED: 'red',
const RECORD_STATUS_TONES: Record<string, StatusTone> = {
SUBMITTED: 'info',
VERIFIED: 'success',
REJECTED: 'danger',
};
export function fitnessOptions(t: TFunction) {
@@ -78,11 +80,12 @@ export function seaServiceColumns(
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
<StatusBadge
tone={RECORD_STATUS_TONES[row.original.status]}
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
/>
</Tooltip>
),
},
@@ -140,11 +143,12 @@ export function medicalColumns(
label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark}
>
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{t(`seaRecords.columns.recordStatus.${row.original.status}`, {
<StatusBadge
tone={RECORD_STATUS_TONES[row.original.status]}
label={t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge>
/>
</Tooltip>
),
},

View File

@@ -1,3 +1,4 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useState } from 'react';
import {
ActionIcon,
@@ -45,7 +46,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate, useParams } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
import type { Seafarer } from './SeafarerRegistryPage';
// ---------------------------------------------------------------------------
@@ -170,14 +171,14 @@ async function updateSeafarerStatus(_id: string, _status: string): Promise<void>
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal', Pending: 'yellow', Suspended: 'red',
Approved: 'teal', Expired: 'red', Valid: 'teal',
Fit: 'teal', Unfit: 'red', Conditional: 'orange',
const STATUS_TONE: Record<string, StatusTone> = {
Active: 'success', Pending: 'warning', Suspended: 'danger',
Approved: 'success', Expired: 'danger', Valid: 'success',
Fit: 'success', Unfit: 'danger', Conditional: 'pending',
};
function Chip({ value }: { value: string }) {
return <Badge color={STATUS_COLOR[value] ?? 'gray'} variant="light" radius="sm" size="sm">{value}</Badge>;
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} variant="light" radius="sm" size="sm" />;
}
function InfoField({ label, value }: { label: string; value: string }) {
@@ -639,7 +640,12 @@ export function SeafarerProfilePage() {
</div>
</Group>
<Stack gap="xs" align="flex-end" style={{ flexShrink: 0 }}>
<Badge color={STATUS_COLOR[profile.status] ?? 'gray'} variant="filled" radius="sm">{profile.status}</Badge>
<StatusBadge
tone={STATUS_TONE[profile.status] ?? 'neutral'}
label={profile.status}
variant="filled"
radius="sm"
/>
<Button size="xs" variant="default" leftSection={<IconEdit size={13} />} onClick={() => notify.info('Edit profile — coming soon.')}>
Edit Profile
</Button>

View File

@@ -1,7 +1,7 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -35,7 +35,7 @@ import {
IconX,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { StatusBadge, notify } from '@ema-platform/ui';
// ---------------------------------------------------------------------------
// Types
@@ -163,26 +163,17 @@ function StatCard({
// ---------------------------------------------------------------------------
// Status badges
// ---------------------------------------------------------------------------
const STATUS_COLOR: Record<string, string> = {
Active: 'teal',
Pending: 'yellow',
Suspended: 'red',
Expired: 'orange',
Fit: 'teal',
Unfit: 'red',
const STATUS_TONE: Record<string, StatusTone> = {
Active: 'success',
Pending: 'warning',
Suspended: 'danger',
Expired: 'pending',
Fit: 'success',
Unfit: 'danger',
};
function StatusBadge({ value }: { value: string }) {
return (
<Badge
color={STATUS_COLOR[value] ?? 'gray'}
variant="light"
radius="sm"
size="sm"
>
{value}
</Badge>
);
function RegistryStatus({ value }: { value: string }) {
return <StatusBadge tone={STATUS_TONE[value] ?? 'neutral'} label={value} />;
}
// ---------------------------------------------------------------------------
@@ -239,9 +230,9 @@ export function SeafarerRegistryPage() {
<Table.Td><Text fz="sm">{s.mobile}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.region}</Text></Table.Td>
<Table.Td><Text fz="sm">{s.registeredAt}</Text></Table.Td>
<Table.Td><StatusBadge value={s.medicalStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.bookStatus} /></Table.Td>
<Table.Td><StatusBadge value={s.status} /></Table.Td>
<Table.Td><RegistryStatus value={s.medicalStatus} /></Table.Td>
<Table.Td><RegistryStatus value={s.bookStatus} /></Table.Td>
<Table.Td><RegistryStatus value={s.status} /></Table.Td>
<Table.Td>
<Menu position="bottom-end" shadow="sm" width={160} withinPortal>
<Menu.Target>

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import {
Alert,
Badge,
Box,
Button,
Card,
@@ -31,7 +31,7 @@ import {
IconTransferIn,
IconUser,
} from '@tabler/icons-react';
import { notify, PhoneInput } from '@ema-platform/ui';
import { StatusBadge, notify, PhoneInput } from '@ema-platform/ui';
import { isValidPhoneNumber } from 'libphonenumber-js';
// Minimal vessel type for the approved vessel list
@@ -117,11 +117,11 @@ const TRANSFER_REASONS = [
'Other',
];
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'neutral',
'Under Review': 'warning',
Approved: 'success',
Rejected: 'danger',
};
// ---------------------------------------------------------------------------
@@ -140,7 +140,11 @@ function TransferCard({ req }: { req: OwnershipTransferRequest }) {
<Text fz="xs" c="dimmed">{req.id} · Transfer to {req.newOwnerName}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[req.status] ?? 'gray'} variant="light">{req.status}</Badge>
<StatusBadge
tone={STATUS_TONE[req.status] ?? 'neutral'}
label={req.status}
variant="light"
/>
</Group>
<Divider my="xs" />
<SimpleGrid cols={2} spacing="xs">

View File

@@ -1,9 +1,9 @@
import { type StatusTone } from '@ema-platform/shared';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Alert,
Badge,
Button,
Card,
Divider,
@@ -27,7 +27,7 @@ import {
IconClockHour4,
IconTransferIn,
} from '@tabler/icons-react';
import { AdvancedTable } from '@ema-platform/ui';
import { StatusBadge, AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns';
import {
TERMINAL_STATUSES,
@@ -68,12 +68,12 @@ interface VesselRegistration {
expiryDate: string | null;
}
const STATUS_COLOR: Record<string, string> = {
Pending: 'gray',
'Under Review': 'yellow',
Approved: 'teal',
Rejected: 'red',
'Correction Required': 'orange',
const STATUS_TONE: Record<string, StatusTone> = {
Pending: 'neutral',
'Under Review': 'warning',
Approved: 'success',
Rejected: 'danger',
'Correction Required': 'pending',
};
// Inland vessel certificates (1)
@@ -281,9 +281,12 @@ export function VesselRegistrationPage() {
<Text fz="xs" c="dimmed">{registration.id}</Text>
</div>
</Group>
<Badge color={STATUS_COLOR[registration.status] ?? 'gray'} size="lg" variant="light">
{registration.status}
</Badge>
<StatusBadge
tone={STATUS_TONE[registration.status] ?? 'neutral'}
label={registration.status}
size="lg"
variant="light"
/>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">

View File

@@ -1,3 +1,5 @@
import { StatusBadge } from '@ema-platform/ui';
import { type StatusTone } from '@ema-platform/shared';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -30,10 +32,10 @@ import {
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green',
SUSPENDED: 'orange',
DEREGISTERED: 'gray',
const VESSEL_STATUS_TONES: Record<string, StatusTone> = {
REGISTERED: 'success',
SUSPENDED: 'pending',
DEREGISTERED: 'neutral',
};
/**
@@ -194,12 +196,11 @@ export function VesselTransferPage() {
{categoryLabels[vessel.category] ?? vessel.category}
</Table.Td>
<Table.Td>
<Badge
<StatusBadge
tone={VESSEL_STATUS_TONES[vessel.status]}
label={vessel.status}
size="sm"
color={VESSEL_STATUS_COLORS[vessel.status]}
>
{vessel.status}
</Badge>
/>
</Table.Td>
<Table.Td>
<Group justify="flex-end">

View File

@@ -10,6 +10,10 @@ export const am: Translations = {
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
},
a11y: {
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
},
msg: {
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',

View File

@@ -9,6 +9,11 @@ export const en = {
tagline: 'Maritime licensing & certification services',
},
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: {
genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.',

View File

@@ -26,6 +26,8 @@ import {
AppHeader,
AppSidebar,
filterByPermissions,
SkipLink,
MAIN_CONTENT_ID,
} from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui";
import {
@@ -281,6 +283,10 @@ export function PortalLayout() {
: "?";
return (
<>
{/* First focusable element on the page, so a keyboard user can bypass
the nav instead of tabbing through it on every navigation. */}
<SkipLink />
<AppShell
header={{ height: 74 }}
navbar={{
@@ -335,7 +341,7 @@ export function PortalLayout() {
/>
</AppShell.Navbar>
<AppShell.Main>
<AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter">
<Outlet />
</div>
@@ -364,5 +370,6 @@ export function PortalLayout() {
/>
</Drawer>
</AppShell>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { ThemeGallery } from "@ema-platform/ui";
import { PortalLayout } from "./layouts/PortalLayout";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LandingRoute } from "./components/LandingRoute";
@@ -57,6 +58,10 @@ export const router = createBrowserRouter([
// Public landing page — institutional overview + role-based entry points.
{ path: "/", element: <LandingRoute /> },
// Theme visual-regression surface. Unauthenticated by design — it renders
// only static primitives, so it needs no API and cannot flake.
{ path: "/__gallery", element: <ThemeGallery /> },
// Public auth pages
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },

View File

@@ -1,12 +1,57 @@
/* Portal global styles — loaded after Mantine's CSS, no Tailwind preflight so
it never fights Mantine's base styles. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
/* Inter carries no Ge'ez glyphs, so Noto Sans Ethiopic is loaded alongside it.
Without it every Amharic string in the app renders in whatever the OS
happens to substitute — different on Windows, macOS and Android. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+Ethiopic:wght@400;500;600;700&display=swap');
:root {
--ema-surface-light: #f5f8fc;
--ema-surface-dark: #0e1521;
}
/* ---------------------------------------------------------------------------
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
come from Mantine's dark palette so they track the active color scheme
instead of a fixed gray.
These lived in `apps/portal/src/styles.css`, which nothing ever imported —
so the portal has been running with unthemed scrollbars while the backoffice
had these. Moved here, where they load.
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-5) transparent;
}
[data-mantine-color-scheme='dark'] * {
scrollbar-color: var(--mantine-color-dark-3) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-5);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-6);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-dark-2);
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;

View File

@@ -1,116 +1,13 @@
import {
createTheme,
rem,
type MantineColorsTuple,
} from '@mantine/core';
// ---- Coastal Modern palette ----------------------------------------------
// Portal-only theme. Lives here (not in @ema-platform/shared) so the backoffice
// is unaffected.
const emaPrimary: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
// Teal accent — the "coastal" half of the palette.
const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
// Cool neutral grays (slightly blue-tinted) for surfaces & text.
const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
export const portalTheme = createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary,
emaTeal,
gray: emaGray,
},
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
headings: {
fontFamily: 'Inter, sans-serif',
fontWeight: '700',
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.25' },
h2: { fontSize: rem(25), lineHeight: '1.3' },
h3: { fontSize: rem(21), lineHeight: '1.35' },
h4: { fontSize: rem(17), lineHeight: '1.4' },
h5: { fontSize: rem(15), lineHeight: '1.45' },
},
},
defaultRadius: 'md',
radius: {
xs: rem(6),
sm: rem(8),
md: rem(12),
lg: rem(16),
xl: rem(22),
},
shadows: {
xs: '0 1px 2px rgba(15,23,42,0.06)',
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
md: '0 8px 24px rgba(15,23,42,0.08)',
lg: '0 16px 40px rgba(15,23,42,0.12)',
xl: '0 24px 64px rgba(15,23,42,0.16)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
cursorType: 'pointer',
components: {
Paper: {
defaultProps: { radius: 'lg' },
},
Card: {
defaultProps: { radius: 'lg' },
},
Button: {
defaultProps: { radius: 'md' },
styles: { root: { fontWeight: 600 } },
},
Badge: {
defaultProps: { radius: 'sm' },
},
ThemeIcon: {
defaultProps: { radius: 'md' },
},
NavLink: {
styles: { root: { borderRadius: rem(10), fontWeight: 500 } },
},
TextInput: { defaultProps: { radius: 'md' } },
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});
/**
* The portal theme now lives in `@ema-platform/shared`, alongside the
* backoffice theme and the base they share.
*
* It moved because the two themes had diverged into unrelated definitions —
* this one carried a full type scale, radius scale and component defaults that
* the backoffice simply lacked. Sharing the structure fixes the backoffice
* without changing the portal.
*
* This re-export is kept so the portal's MantineThemeProvider import stays
* valid. Prefer importing from `@ema-platform/shared` directly in new code.
*/
export { portalTheme } from '@ema-platform/shared';

View File

@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
// After Mantine's CSS (it defines the variables these tokens resolve to),
// before the app's own, which may override them. Relative because the
// @ema-platform aliases are tsconfig paths, which do not carry subpaths.
import '../../../libs/shared/src/lib/theme/semantic.css';
import './app/theme/portal.css';
import './app/i18n/config';

View File

@@ -1,42 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ---------------------------------------------------------------------------
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
come from Mantine's dark palette so they track the active color scheme
instead of a fixed gray.
--------------------------------------------------------------------------- */
* {
scrollbar-width: thin;
scrollbar-color: var(--mantine-color-gray-5) transparent;
}
[data-mantine-color-scheme='dark'] * {
scrollbar-color: var(--mantine-color-dark-3) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-5);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-6);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-dark-3);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-dark-2);
}

View File

@@ -107,12 +107,26 @@ export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationSta
REJECTED: 'Rejected',
};
export const SEAFARER_REGISTRATION_STATUS_COLORS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
RESUBMIT_REQUIRED: 'orange',
APPROVED: 'teal',
REJECTED: 'red',
/**
* Registration status → platform tone.
*
* Tones, not colours. `StatusTone` is the platform's status vocabulary and the
* one place a tone becomes a colour (`STATUS_TONE_COLOR` in
* @ema-platform/shared), so this map cannot drift the way `APPROVED: 'teal'`
* had already drifted from every other feature's green success.
*
* The union is repeated rather than imported because @ema-platform/api does not
* depend on the theme layer, and should not start to for five string literals.
*/
export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
SeafarerRegistrationStatus,
'success' | 'warning' | 'danger' | 'info' | 'pending' | 'neutral'
> = {
DRAFT: 'neutral',
SUBMITTED: 'info',
RESUBMIT_REQUIRED: 'pending',
APPROVED: 'success',
REJECTED: 'danger',
};
/** Human label for each answer — the review table and the summary both use it. */

View File

@@ -1,4 +1,8 @@
export * from './lib/theme/palettes';
export * from './lib/theme/base-theme';
export * from './lib/theme/ema-theme';
export * from './lib/theme/portal-theme';
export * from './lib/theme/status-tone';
export * from './lib/date/date-displayer';
export * from './lib/date/use-date-displayer';
export * from './lib/date/ethiopic';

View File

@@ -0,0 +1,156 @@
import type { CSSProperties } from 'react';
import { createTheme, rem } from '@mantine/core';
/**
* Everything both apps agree on: scale, shape, elevation, and component
* defaults. No colours — those are the one thing the backoffice and the portal
* deliberately differ on, so each theme layers its own ramps over this.
*
* This began as the portal's theme. The backoffice had no heading scale, no
* radius scale and no component defaults at all, which is why its features
* drifted: with nothing to inherit, every page invented its own spacing and
* sizing. Promoting the portal's structure here fixes 23 features by editing
* one file, and costs the portal nothing — the values are unchanged.
*/
/**
* The type stack.
*
* Noto Sans Ethiopic sits directly after Inter rather than being swapped in by
* a `[lang='am']` rule. Browsers fall back per *glyph*, not per element, so one
* stack renders Latin in Inter and Ge'ez in Noto automatically — including
* inside a single string. That matters here: a registry is full of mixed-script
* lines like an Amharic name beside a Latin IMO number, and a language-scoped
* swap would render one half of those in the wrong face.
*
* Inter carries no Ge'ez glyphs at all, so before this the Amharic half of a
* bilingual system rendered in whatever the OS happened to substitute.
*/
export const EMA_FONT_STACK =
'Inter, "Noto Sans Ethiopic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
export const baseTheme = createTheme({
fontFamily: EMA_FONT_STACK,
headings: {
fontFamily: EMA_FONT_STACK,
fontWeight: '700',
// A little looser than the portal's original values. Ge'ez has taller
// ascenders and deeper descenders than Latin, so a heading set to Inter's
// natural leading clips its Amharic rendering — which only became visible
// once Ethiopic was actually being rendered rather than substituted.
sizes: {
h1: { fontSize: rem(32), lineHeight: '1.3' },
h2: { fontSize: rem(25), lineHeight: '1.35' },
h3: { fontSize: rem(21), lineHeight: '1.4' },
h4: { fontSize: rem(17), lineHeight: '1.45' },
h5: { fontSize: rem(15), lineHeight: '1.5' },
},
},
defaultRadius: 'md',
radius: {
xs: rem(6),
sm: rem(8),
md: rem(12),
lg: rem(16),
xl: rem(22),
},
shadows: {
xs: '0 1px 2px rgba(15,23,42,0.06)',
sm: '0 2px 8px rgba(15,23,42,0.06), 0 1px 2px rgba(15,23,42,0.04)',
md: '0 8px 24px rgba(15,23,42,0.08)',
lg: '0 16px 40px rgba(15,23,42,0.12)',
xl: '0 24px 64px rgba(15,23,42,0.16)',
},
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
cursorType: 'pointer',
// Draw a focus ring for keyboard users only. The codebase had no
// `:focus-visible` handling anywhere, which is the single largest WCAG gap.
focusRing: 'auto',
// Shade 6 in light. The dark counterpart is deliberately left unset until
// dark mode is verified end to end — changing it moves every filled control.
primaryShade: { light: 6 },
components: {
Paper: { defaultProps: { radius: 'lg' } },
Card: { defaultProps: { radius: 'lg' } },
Button: {
defaultProps: { radius: 'md' },
styles: { root: { fontWeight: 600 } },
},
Badge: { defaultProps: { radius: 'sm' } },
ThemeIcon: { defaultProps: { radius: 'md' } },
NavLink: { styles: { root: { borderRadius: rem(10), fontWeight: 500 } } },
TextInput: { defaultProps: { radius: 'md' } },
Textarea: { defaultProps: { radius: 'md' } },
Select: { defaultProps: { radius: 'md' } },
PasswordInput: { defaultProps: { radius: 'md' } },
// The page sits on a tinted surface and cards float on white. Without this
// the main area is the same white as every Paper on it, and the card
// borders are the only thing separating content from chrome.
AppShell: {
styles: { main: { background: 'var(--ema-surface-page)' } },
},
// Tables — the registry look: quiet uppercase headers, hairline row
// borders, a tint on hover, no zebra striping and no column rules. Set
// once here so the 14 pages rendering a raw <Table> match the 28 that go
// through AdvancedTable instead of each picking their own density.
//
// Header text is `text-secondary` rather than the lighter dimmed gray the
// mockup used: at 11px uppercase, gray-5 on white fails 4.5:1.
Table: {
defaultProps: { highlightOnHover: true, verticalSpacing: 'sm', horizontalSpacing: 'md' },
styles: {
table: {
'--table-border-color': 'var(--ema-border-subtle)',
'--table-hover-color': 'var(--ema-surface-page)',
'--table-striped-color': 'var(--ema-surface-sunken)',
} as CSSProperties,
th: {
fontSize: rem(11),
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--ema-text-secondary)',
whiteSpace: 'nowrap',
},
td: { fontSize: rem(13) },
},
},
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});

View File

@@ -1,55 +1,32 @@
import { createTheme, type MantineColorsTuple } from '@mantine/core';
import { createTheme, mergeThemeOverrides } from '@mantine/core';
import { baseTheme } from './base-theme';
import { emaBlue, emaSecondary } from './palettes';
const emaPrimary: MantineColorsTuple = [
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
];
const emaSecondary: MantineColorsTuple = [
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
];
export const emaTheme = createTheme({
/**
* Backoffice theme.
*
* Structure, scale and component defaults come from `baseTheme`; this file
* contributes only the brand. The backoffice keeps its own blue rather than
* adopting the portal's: shade 8 (#1e40af) is the higher-contrast choice for a
* tool staff read all day, and a distinct accent tells an officer at a glance
* which of the two systems they are looking at — worth having when both share
* a domain vocabulary but not the same authority.
*
* The export name is load-bearing: `libs/shared/src/index.ts` and the
* backoffice's MantineThemeProvider both import `emaTheme` by name.
*
* Note this theme does NOT override `colors.gray`. The portal's blue-tinted
* neutrals shift every dimmed label, neutral badge and table border, so that
* change is being made one app at a time rather than as a side effect of
* sharing a base.
*/
export const emaTheme = mergeThemeOverrides(
baseTheme,
createTheme({
primaryColor: 'emaPrimary',
colors: {
emaPrimary,
emaPrimary: emaBlue,
emaSecondary,
},
fontFamily: 'Inter, sans-serif',
defaultRadius: 'md',
breakpoints: {
xs: '36em',
sm: '48em',
md: '62em',
lg: '75em',
xl: '88em',
},
shadows: {
xs: '0 1px 3px rgba(0,0,0,0.05)',
sm: '0 1px 5px rgba(0,0,0,0.07)',
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
components: {
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});
}),
);

View File

@@ -0,0 +1,66 @@
import type { MantineColorsTuple } from '@mantine/core';
/**
* Every colour ramp the platform uses, in one place.
*
* Themes compose these; they do not define colours inline. Keeping the ramps
* separate from the themes is what lets the backoffice and the portal share a
* structure while keeping distinct brands — and it gives the hardcoded hexes
* scattered through feature code somewhere legitimate to be migrated to.
*/
/**
* Backoffice brand. Shade 8 (#1e40af) is the accessible-government blue; the
* ramp is deliberately more saturated than the portal's because staff tools
* are read all day under worse conditions than a citizen portal.
*/
export const emaBlue: MantineColorsTuple = [
'#eff6ff', '#dbeafe', '#bfdbfe', '#93c5fd', '#60a5fa',
'#3b82f6', '#2563eb', '#1d4ed8', '#1e40af', '#1e3a8a',
];
/** Backoffice secondary — a warm brown, used sparingly for accents. */
export const emaSecondary: MantineColorsTuple = [
'#fdf8f6', '#f2e8e5', '#eaddd7', '#e0cec7', '#d2bab0',
'#bfa094', '#a18072', '#977669', '#65524d', '#2c1f1a',
];
/** Portal brand — the "Coastal Modern" blue. Softer than the backoffice ramp. */
export const emaCoastalBlue: MantineColorsTuple = [
'#eef4ff', '#dce7fb', '#b6cdf4', '#8db0ee', '#6c97e9',
'#5887e6', '#4b7fe5', '#3b6ccc', '#3160b7', '#2453a2',
];
/** Portal accent — the "coastal" half of the palette. */
export const emaTeal: MantineColorsTuple = [
'#e1fbf6', '#cdf3eb', '#9ee6d7', '#6bd9c1', '#46cdaf',
'#30c7a5', '#1fc29d', '#0aab89', '#009879', '#008368',
];
/**
* Cool, slightly blue-tinted neutrals for surfaces and text.
*
* Overriding Mantine's stock `gray` with this shifts every `c="dimmed"`, every
* neutral badge and every table border in whichever app adopts it — so it is
* applied per-theme rather than in the base, and promoted one app at a time.
*/
export const emaGray: MantineColorsTuple = [
'#f6f8fb', '#eceff4', '#dde2eb', '#c8d0dd', '#aab5c7',
'#8d9bb3', '#73839e', '#5c6b85', '#46546b', '#333f52',
];
/**
* Ethiopian flag colours.
*
* These are intentional brand, not drift — they appear in the boot splashes,
* the maritime loader and the landing page. They live here so those usages can
* reference a name instead of repeating a hex, but they are deliberately NOT
* semantic tokens: `emaFlag.green` means "the flag's green", never "success".
*/
export const emaFlag = {
blue: '#0284C7',
yellow: '#FCD116',
green: '#078930',
gold: '#D4AF37',
sky: '#38BDF8',
} as const;

View File

@@ -0,0 +1,29 @@
import { createTheme, mergeThemeOverrides } from '@mantine/core';
import { baseTheme } from './base-theme';
import { emaCoastalBlue, emaGray, emaTeal } from './palettes';
/**
* Portal theme — "Coastal Modern".
*
* Structure comes from `baseTheme` (which this theme's own structure was the
* source of, so nothing here changes visually). What remains is the brand: a
* softer blue than the backoffice, a teal accent, and cool blue-tinted
* neutrals in place of Mantine's stock gray.
*
* The gray override stays portal-only for now. It is the widest-reaching
* single line in either theme — it retints every dimmed label, neutral badge
* and table border — so the backoffice adopts it as its own reviewed change,
* not as a side effect of sharing a base.
*/
export const portalTheme = mergeThemeOverrides(
baseTheme,
createTheme({
primaryColor: 'emaPrimary',
primaryShade: { light: 6, dark: 5 },
colors: {
emaPrimary: emaCoastalBlue,
emaTeal,
gray: emaGray,
},
}),
);

View File

@@ -0,0 +1,178 @@
/* ============================================================================
Semantic tokens.
These name a *role* — "the page background", "a subtle border", "danger" —
rather than a colour. Feature code should reach for these instead of a hex,
because a hex cannot follow the colour scheme and a Mantine shade index
(`gray.5`) says nothing about why that shade was chosen.
Every token resolves to a Mantine variable rather than a literal. That is
deliberate: Mantine already recomputes its own variables under
[data-mantine-color-scheme], so tokens defined in terms of them switch for
free and can never drift from the theme. A parallel palette of raw hexes
would recreate exactly the problem this layer exists to fix.
Loaded once per app, after Mantine's CSS.
============================================================================ */
:root {
/* --- Surfaces ---------------------------------------------------------- */
/* The page itself, a raised card, and a recessed well. */
--ema-surface-page: var(--mantine-color-gray-0);
--ema-surface-raised: var(--mantine-color-white);
--ema-surface-sunken: var(--mantine-color-gray-1);
/* --- Borders ----------------------------------------------------------- */
/* Subtle separates rows; strong outlines an input or a focused container. */
--ema-border-subtle: var(--mantine-color-gray-2);
--ema-border-strong: var(--mantine-color-gray-4);
/* --- Text -------------------------------------------------------------- */
/* Secondary must stay a *text* colour: it has to clear 4.5:1, not 3:1, so
it deliberately sits darker than the gray-5 that reads as "dimmed". */
--ema-text-primary: var(--mantine-color-gray-9);
--ema-text-secondary: var(--mantine-color-gray-7);
--ema-text-disabled: var(--mantine-color-gray-5);
/* --- Status ------------------------------------------------------------
Six tones, which is the entire vocabulary a status needs. Domain statuses
map onto these rather than each picking their own colour.
`-fg` is text on the app background; `-bg` is a tint to sit that text on.
Both are needed because a badge and a label have different contrast
requirements against the same surface. */
--ema-status-success-fg: var(--mantine-color-green-8);
--ema-status-success-bg: var(--mantine-color-green-0);
--ema-status-warning-fg: var(--mantine-color-yellow-8);
--ema-status-warning-bg: var(--mantine-color-yellow-0);
--ema-status-danger-fg: var(--mantine-color-red-8);
--ema-status-danger-bg: var(--mantine-color-red-0);
--ema-status-info-fg: var(--mantine-color-blue-8);
--ema-status-info-bg: var(--mantine-color-blue-0);
--ema-status-pending-fg: var(--mantine-color-orange-8);
--ema-status-pending-bg: var(--mantine-color-orange-0);
--ema-status-neutral-fg: var(--mantine-color-gray-7);
--ema-status-neutral-bg: var(--mantine-color-gray-1);
/* --- Focus -------------------------------------------------------------
One ring for the whole platform. Sized to stay visible against both a
white card and a tinted surface. */
--ema-focus-ring: var(--mantine-primary-color-filled);
--ema-focus-ring-width: 2px;
--ema-focus-ring-offset: 2px;
}
[data-mantine-color-scheme='dark'] {
/* Dark is not light inverted. Surfaces lift with elevation rather than
dropping, and text steps down from white rather than up from black. */
--ema-surface-page: var(--mantine-color-dark-8);
--ema-surface-raised: var(--mantine-color-dark-7);
--ema-surface-sunken: var(--mantine-color-dark-9);
--ema-border-subtle: var(--mantine-color-dark-4);
--ema-border-strong: var(--mantine-color-dark-3);
--ema-text-primary: var(--mantine-color-gray-0);
--ema-text-secondary: var(--mantine-color-gray-4);
--ema-text-disabled: var(--mantine-color-dark-2);
/* Saturated mid-shades go muddy on a dark ground; these step lighter so the
foreground still clears 4.5:1 and the tint stays distinguishable. */
--ema-status-success-fg: var(--mantine-color-green-4);
--ema-status-success-bg: var(--mantine-color-green-9);
--ema-status-warning-fg: var(--mantine-color-yellow-4);
--ema-status-warning-bg: var(--mantine-color-yellow-9);
--ema-status-danger-fg: var(--mantine-color-red-4);
--ema-status-danger-bg: var(--mantine-color-red-9);
--ema-status-info-fg: var(--mantine-color-blue-4);
--ema-status-info-bg: var(--mantine-color-blue-9);
--ema-status-pending-fg: var(--mantine-color-orange-4);
--ema-status-pending-bg: var(--mantine-color-orange-9);
--ema-status-neutral-fg: var(--mantine-color-gray-4);
--ema-status-neutral-bg: var(--mantine-color-dark-5);
}
/* ============================================================================
Focus.
The codebase had no :focus-visible rule anywhere, which is the single
largest accessibility gap in it. :focus-visible rather than :focus so a
mouse click does not leave a ring behind — that is the behaviour that gets
focus rings deleted from designs in the first place.
============================================================================ */
/* Mantine already rings its own controls (`.mantine-focus-auto:focus-visible`
resolves to the same 2px solid primary). This rule is the safety net for
everything it does not own: plain anchors, custom elements, and the
UnstyledButtons this codebase uses for its own controls.
Note there is deliberately no `outline: none` opt-out for the Mantine
classes. An earlier attempt at one suppressed Mantine's working ring and
left portal buttons with no focus indicator at all — which of the two rules
won came down to stylesheet order, and that differs between the apps.
Matching values mean overlap is invisible, so overlap is the safe default. */
:focus-visible {
outline: var(--ema-focus-ring-width) solid var(--ema-focus-ring);
outline-offset: var(--ema-focus-ring-offset);
}
/* ============================================================================
Screen-reader-only utility.
No equivalent existed anywhere in the codebase, so anything needing a text
alternative had nowhere to put it.
============================================================================ */
.ema-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
/* clip-path rather than the legacy clip: it does not force a layer and is
not deprecated. */
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A skip link is sr-only until focused, then must be plainly visible. */
.ema-skip-link {
position: absolute;
top: 0;
left: 0;
z-index: 9999;
padding: 0.75rem 1.25rem;
background: var(--ema-surface-raised);
color: var(--ema-text-primary);
border: 1px solid var(--ema-border-strong);
border-radius: 0 0 var(--mantine-radius-md) 0;
font-weight: 600;
text-decoration: none;
/* Off-screen rather than display:none, so it stays focusable. */
transform: translateY(-150%);
}
.ema-skip-link:focus-visible {
transform: translateY(0);
}
/* ============================================================================
Reduced motion.
Honour the OS setting globally. Animation is not removed outright — a
near-instant transition still conveys that something changed, without the
movement that triggers vestibular symptoms.
============================================================================ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,51 @@
/**
* The platform's status vocabulary.
*
* There are 48 separate status→colour maps across the codebase, each deciding
* independently what "pending" looks like. They disagree. The fix is not one
* bigger map — domain statuses genuinely differ per feature — but one small set
* of *tones* that every domain maps onto, so the colour decision is made six
* times instead of forty-eight.
*
* `semantic.css` carries the CSS-variable form of these for stylesheet use.
* This module is for the many places that need a Mantine `color` prop instead.
*/
export type StatusTone =
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'pending'
| 'neutral';
/**
* Tone → Mantine colour name.
*
* Deliberately the only place a tone becomes a colour. Changing the platform's
* idea of "warning" is an edit here, not a sweep through 48 files.
*/
export const STATUS_TONE_COLOR: Record<StatusTone, string> = {
success: 'green',
warning: 'yellow',
danger: 'red',
info: 'blue',
pending: 'orange',
neutral: 'gray',
};
/**
* Tone → CSS custom properties, for inline styles and stylesheets.
*
* Returns variable references rather than resolved colours so the values keep
* following the active colour scheme.
*/
export function statusToneVars(tone: StatusTone): {
color: string;
background: string;
} {
return {
color: `var(--ema-status-${tone}-fg)`,
background: `var(--ema-status-${tone}-bg)`,
};
}

View File

@@ -9,6 +9,7 @@ export * from "./lib/feedback/FeatureUnavailable";
export * from "./lib/feedback/EmptyState";
export * from "./lib/feedback/ErrorState";
export * from "./lib/feedback/PageLoader";
export * from "./lib/feedback/StatusBadge";
export * from "./lib/components/MaritimeLoader";
export * from "./lib/theme/maritime-loader-theme";
export * from "./lib/layout/AppHeader";
@@ -19,13 +20,17 @@ export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";
export * from "./lib/data/StatTile";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";
export * from "./lib/landing/landing-copy";
export * from "./lib/utils/person-name";
export * from "./lib/dev/ThemeGallery";

View File

@@ -51,6 +51,10 @@ interface AdvancedTableProps<T> {
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
/** Makes rows clickable (adds pointer cursor). */
onRowClick?: (row: T) => void;
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
title?: ReactNode;
/** Search box, filters, export — rendered top-right before Refresh/View. */
toolbar?: ReactNode;
}
function getByPath(obj: unknown, path?: string): unknown {
@@ -82,6 +86,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
verticalSpacing = "sm",
rowStyle,
onRowClick,
title,
toolbar,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -94,13 +100,22 @@ export function AdvancedTable<T extends { id?: string | number }>({
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
const heading = title ?? tableName;
const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1;
const to = Math.min(itemCount, pageIndex * pageSize + data.length);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Paper withBorder radius="lg" p={0}>
<Group justify="space-between" px="md" py="sm" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={600}>{""}</Text>
{heading && (
<Text fw={600} size="sm">
{heading}
</Text>
)}
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
{toolbar}
{refresh && (
<Button
variant="default"
@@ -162,13 +177,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing={verticalSpacing}
>
<Table verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
@@ -230,8 +239,21 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table>
</Table.ScrollContainer>
{(itemCount > pageSize || onPageSizeChange) && (
<Group justify="flex-end" mt="md">
<Group
justify="space-between"
px="md"
py="sm"
style={{ borderTop: "1px solid var(--ema-border-subtle)" }}
>
<Text size="xs" c="dimmed">
{t("common.showingRange", {
from,
to,
total: itemCount,
defaultValue: "Showing {{from}}{{to}} of {{total}}",
})}
</Text>
<Group gap="sm">
{onPageSizeChange && (
<Select
size="sm"
@@ -253,7 +275,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
/>
)}
</Group>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { Group, Paper, Text, ThemeIcon, UnstyledButton } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
import './stat-tile.css';
export interface StatTileProps {
label: string;
/** The number itself. A string so callers can pass "—" while loading. */
value: ReactNode;
/** One line under the value: what the number means, or how it is trending. */
hint?: ReactNode;
icon?: Icon;
/**
* Which tone the icon carries. Tone rather than colour so a tile counting
* overdue work is the same red as an overdue badge.
*/
tone?: StatusTone;
/** Makes the whole tile a button — use when the number has somewhere to go. */
onClick?: () => void;
}
/**
* One figure on a dashboard.
*
* The four stat cards on the backoffice home were `<Card>` + two `<Text>`,
* re-declared inline on every dashboard that wanted them — so the logistics
* overview and the backoffice home showed the same kind of number at different
* sizes. The number leads, the label sits above it small and quiet, and the
* icon is decoration that carries the tone.
*
* A tile with `onClick` becomes a real button: dashboards exist to be a
* jumping-off point, and a count you cannot click is a dead end.
*/
export function StatTile({ label, value, hint, icon: TileIcon, tone, onClick }: StatTileProps) {
const body = (
<>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lh={1.4}>
{label}
</Text>
{TileIcon && (
<ThemeIcon
size={38}
radius="md"
variant="light"
color={tone ? STATUS_TONE_COLOR[tone] : undefined}
>
<TileIcon size={20} stroke={1.7} />
</ThemeIcon>
)}
</Group>
<Text fz={30} fw={800} lh={1.15} mt="xs">
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed" mt={2}>
{hint}
</Text>
)}
</>
);
if (!onClick) {
return (
<Paper withBorder radius="lg" p="lg">
{body}
</Paper>
);
}
return (
<UnstyledButton
onClick={onClick}
className="ema-stat-tile-clickable"
style={{ display: 'block', width: '100%', height: '100%' }}
>
<Paper withBorder radius="lg" p="lg" h="100%">
{body}
</Paper>
</UnstyledButton>
);
}

View File

@@ -0,0 +1,83 @@
import { Text, Tooltip } from '@mantine/core';
import { statusToneVars } from '@ema-platform/shared';
export interface WaitingForProps {
/** When the clock started — normally the moment the applicant submitted. */
since: string | null | undefined;
/**
* Days after which the wait reads as overdue. Amber at half of it, red past
* it. Defaults to 14, which is the shortest SLA any configured licence type
* currently uses; pass the real one where a queue knows it.
*/
slaDays?: number;
/** Set once the item is decided — a closed item is not waiting for anyone. */
done?: boolean;
}
/**
* Fraction of the target elapsed before the wait reads as at-risk. Matches the
* licence queue's own `WARNING_RATIO`, so a queue using this component and one
* using `computeSla` turn amber at the same point rather than disagreeing.
*/
const WARNING_RATIO = 0.7;
/** Whole days between `since` and now, floored. Negative clock skew reads as 0. */
function daysSince(since: string): number {
const ms = Date.now() - new Date(since).getTime();
return Math.max(0, Math.floor(ms / 86_400_000));
}
/**
* How long an item has been sitting in a queue.
*
* No review queue showed this. An officer opening a list of thirty
* registrations could see what each one *was*, but not which had been waiting
* three days and which had been waiting three weeks — so the queue was worked
* top-down by whatever the sort happened to be rather than by urgency.
*
* Colour comes from the platform's status tones rather than its own scale, so
* "overdue" here is the same red as "rejected" everywhere else.
*/
export function WaitingFor({ since, slaDays = 14, done }: WaitingForProps) {
if (!since) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const days = daysSince(since);
// A decided item keeps its elapsed time visible — useful when reviewing how
// long something took — but never coloured, because nothing is pending.
const tone = done
? 'neutral'
: days >= slaDays
? 'danger'
: days >= slaDays * WARNING_RATIO
? 'pending'
: 'neutral';
const label = days === 0 ? 'today' : `${days}d`;
return (
<Tooltip
label={
done
? `Took ${days} day${days === 1 ? '' : 's'}`
: `Waiting ${days} day${days === 1 ? '' : 's'} · ${slaDays}-day target`
}
withArrow
>
<Text
size="sm"
fw={tone === 'neutral' ? 400 : 600}
c={tone === 'neutral' ? 'dimmed' : undefined}
style={tone === 'neutral' ? undefined : { color: statusToneVars(tone).color }}
>
{label}
</Text>
</Tooltip>
);
}

View File

@@ -0,0 +1,16 @@
/*
* A clickable StatTile. The hover cue lives here rather than in inline style
* handlers so it can use a real `:hover` — a JS mouseenter/leave pair misses
* keyboard focus, and `:focus-visible` needs the app's focus ring anyway.
*/
.ema-stat-tile-clickable > * {
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.ema-stat-tile-clickable:hover > * {
border-color: var(--mantine-primary-color-filled);
}
.ema-stat-tile-clickable:focus-visible > * {
border-color: var(--mantine-primary-color-filled);
}

View File

@@ -0,0 +1,362 @@
import {
SimpleGrid,
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Paper,
Radio,
Select,
Stack,
Switch,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
useMantineTheme,
} from '@mantine/core';
import { IconAlertTriangle, IconInbox } from '@tabler/icons-react';
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
import { StatusBadge } from '../feedback/StatusBadge';
import { WaitingFor } from '../data/WaitingFor';
import { StatTile } from '../data/StatTile';
/**
* Fixed clock. The gallery is screenshotted by the visual suite, so a tile
* reading "3d" must read "3d" tomorrow too — `Date.now()` would rewrite the
* baseline every day.
*/
const GALLERY_NOW = '2026-08-21T00:00:00.000Z';
const daysAgo = (n: number) =>
new Date(Date.parse(GALLERY_NOW) - n * 86_400_000).toISOString();
/**
* Every primitive the theme controls, on one page.
*
* This is the visual-regression surface for theme work. A real feature page
* renders a handful of primitives and only their happy states; a theme change
* that breaks `Button disabled` or `TextInput error` would sail past it. This
* renders all of them, including the states nothing else shows, so a screenshot
* diff says precisely what a theme edit did.
*
* Deliberately free of data, auth and network so it cannot flake: mounted at
* `/__gallery` outside the protected routes, it needs no API and no database.
*
* Not part of the product. Excluded from the nav on purpose.
*/
const SWATCH_SHADES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Stack gap="sm">
<Title order={3}>{title}</Title>
<Divider />
{children}
</Stack>
);
}
/** A full 09 ramp, so a palette swap is visible shade by shade. */
function ColorRamp({ name }: { name: string }) {
return (
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{name}
</Text>
<Group gap={0} wrap="nowrap">
{SWATCH_SHADES.map((shade) => (
<Box
key={shade}
style={{
background: `var(--mantine-color-${name}-${shade})`,
width: 48,
height: 40,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
fontSize: 10,
// Shades 0-4 are light enough to need dark text; 5+ need light.
color: shade < 5 ? 'var(--mantine-color-black)' : 'var(--mantine-color-white)',
}}
>
{shade}
</Box>
))}
</Group>
</Stack>
);
}
export function ThemeGallery() {
const theme = useMantineTheme();
const paletteNames = Object.keys(theme.colors).filter((c) =>
// The stock Mantine ramps are noise here; show the ones this app defines,
// plus gray because overriding it is the riskiest single theme change.
c.startsWith('ema') || c === 'gray',
);
return (
<>
{/* Mirrors the real app shells, so the skip-link contract is testable
without a session. */}
<SkipLink />
<Box id={MAIN_CONTENT_ID} p="xl" style={{ maxWidth: 1100, margin: '0 auto' }}>
<Stack gap="xl">
<Stack gap={4}>
<Title order={1}>Theme Gallery</Title>
<Text c="dimmed">
Visual-regression surface for theme changes. Not a product page.
</Text>
</Stack>
<Section title="Typography">
<Stack gap="xs">
<Title order={1}>Heading 1 Maritime licensing</Title>
<Title order={2}>Heading 2 Seafarer registry</Title>
<Title order={3}>Heading 3 Vessel registration</Title>
<Title order={4}>Heading 4 Certificate of competency</Title>
<Title order={5}>Heading 5 Sea service record</Title>
<Text>
Body text. The quick brown fox jumps over the lazy dog.
</Text>
<Text size="sm" c="dimmed">
Small dimmed text, used for subtitles and helper copy.
</Text>
<Text size="xs" c="dimmed">
Extra-small text, used for metadata.
</Text>
{/* Mixed-script line: the case a [lang] font swap would break. */}
<Text>Amharic: አበበ SF-2024-0001 </Text>
{/* Ge'ez at heading size, where its taller ascenders clip first. */}
<Title order={2}> </Title>
<Title order={4}> Seafarer Registry</Title>
<Text ff="monospace">Monospace: ET-IMO-0231 · 28,450 GT</Text>
</Stack>
</Section>
<Section title="Color ramps">
<Stack gap="md">
{paletteNames.map((name) => (
<ColorRamp key={name} name={name} />
))}
</Stack>
</Section>
<Section title="Buttons">
<Stack gap="sm">
<Group>
<Button>Filled</Button>
<Button variant="light">Light</Button>
<Button variant="outline">Outline</Button>
<Button variant="subtle">Subtle</Button>
<Button variant="default">Default</Button>
<Button variant="white">White</Button>
</Group>
<Group>
<Button size="xs">xs</Button>
<Button size="sm">sm</Button>
<Button size="md">md</Button>
<Button size="lg">lg</Button>
</Group>
<Group>
<Button color="red">Destructive</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
<Button fullWidth>Full width</Button>
</Group>
</Stack>
</Section>
<Section title="Surfaces">
<Group align="stretch" grow>
<Card withBorder>
<Text fw={600}>Card withBorder</Text>
<Text size="sm" c="dimmed">
Radius and shadow come from the theme.
</Text>
</Card>
<Paper p="md" withBorder>
<Text fw={600}>Paper withBorder</Text>
<Text size="sm" c="dimmed">
Default radius applies here too.
</Text>
</Paper>
<Paper p="md" shadow="md">
<Text fw={600}>Paper shadow=md</Text>
<Text size="sm" c="dimmed">
Shadow ramp check.
</Text>
</Paper>
</Group>
</Section>
<Section title="Badges">
<Group>
<Badge>Default</Badge>
<Badge color="green">Active</Badge>
<Badge color="yellow">Pending</Badge>
<Badge color="red">Suspended</Badge>
<Badge color="orange">Expiring</Badge>
<Badge color="gray">Draft</Badge>
<Badge variant="filled">Filled</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="dot">Dot</Badge>
</Group>
</Section>
<Section title="Form controls">
<Stack gap="md" style={{ maxWidth: 520 }}>
<TextInput label="Text input" placeholder="you@example.com" />
<TextInput
label="With description"
description="Helper text that persists, unlike a placeholder."
placeholder="ET-000000"
/>
<TextInput
label="Error state"
error="This field is required."
placeholder="Required"
/>
<TextInput label="Disabled" disabled value="Cannot edit" />
<TextInput label="Read-only" readOnly value="Read-only value" />
<Select
label="Select"
data={['Ethiopian', 'Djiboutian', 'Kenyan']}
placeholder="Pick one"
/>
<Textarea label="Textarea" placeholder="Full permanent address" />
<Group>
<Checkbox label="Checkbox" defaultChecked />
<Radio label="Radio" defaultChecked />
<Switch label="Switch" defaultChecked />
</Group>
</Stack>
</Section>
<Section title="Feedback">
<Stack gap="sm">
<Alert color="blue" title="Information">
A unique Seafarer ID is generated on approval.
</Alert>
<Alert color="yellow" title="Warning">
Upload clear copies of all required documents.
</Alert>
<Alert color="red" title="Error">
The certificate number could not be verified.
</Alert>
<Group>
<ThemeIcon size="lg">Ic</ThemeIcon>
<ThemeIcon size="lg" variant="light">
Lt
</ThemeIcon>
<ThemeIcon size="lg" color="green" variant="light">
Ok
</ThemeIcon>
</Group>
</Stack>
</Section>
<Section title="Tabs">
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="training">Training</Tabs.Tab>
<Tabs.Tab value="medical">Medical</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Text size="sm">Overview panel content.</Text>
</Tabs.Panel>
</Tabs>
</Section>
<Section title="Status badges">
<Group>
<StatusBadge tone="success" label="Approved" />
<StatusBadge tone="warning" label="Expiring" />
<StatusBadge tone="danger" label="Rejected" />
<StatusBadge tone="info" label="Submitted" />
<StatusBadge tone="pending" label="Corrections requested" />
<StatusBadge tone="neutral" label="Draft" />
</Group>
</Section>
<Section title="Waiting for">
<Group>
<WaitingFor since={GALLERY_NOW} />
<WaitingFor since={daysAgo(9)} />
<WaitingFor since={daysAgo(16)} />
<WaitingFor since={daysAgo(30)} done />
<WaitingFor since={null} />
</Group>
</Section>
<Section title="Stat tiles">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile label="Awaiting claim" value={12} hint="Nobody has picked these up" icon={IconInbox} tone="info" />
<StatTile label="Needs applicant action" value={3} hint="Returned for corrections" icon={IconAlertTriangle} tone="pending" />
<StatTile label="Overdue" value={2} hint="Past the turnaround target" icon={IconAlertTriangle} tone="danger" />
<StatTile label="Not permitted" value="—" hint="No access to this queue" icon={IconInbox} tone="neutral" />
</SimpleGrid>
</Section>
<Section title="Table">
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Seafarer ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Region</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Anchor size="sm">SF-2024-0001</Anchor>
</Table.Td>
<Table.Td>Abebe Girma</Table.Td>
<Table.Td>Addis Ababa</Table.Td>
<Table.Td>
<Badge color="green">Active</Badge>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Anchor size="sm">SF-2024-0002</Anchor>
</Table.Td>
<Table.Td> </Table.Td>
<Table.Td>Dire Dawa</Table.Td>
<Table.Td>
<Badge color="yellow">Pending</Badge>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Section>
<Section title="Focus states">
<Text size="sm" c="dimmed">
Tab through these to check the focus ring. Every interactive element
must show a visible indicator.
</Text>
<Group mt="sm">
<Button>Button</Button>
<Anchor href="#gallery-focus">Link</Anchor>
<TextInput placeholder="Input" />
<Checkbox label="Checkbox" />
</Group>
</Section>
</Stack>
</Box>
</>
);
}

View File

@@ -80,7 +80,10 @@ export function PageLoader({
<Text
fw={600}
size="md"
c={isDark ? 'gray.1' : 'navy.9'}
// Was `navy.9`, which is defined in neither theme — Mantine
// silently drops unresolved keys, so this label had been
// rendering an inherited colour rather than the intended one.
c={isDark ? 'gray.1' : 'gray.9'}
style={{ lineHeight: 1.3 }}
>
{resolvedLabel}
@@ -108,6 +111,7 @@ export function PageLoader({
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '40%',
background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)',
borderRadius: '2px',
@@ -115,9 +119,13 @@ export function PageLoader({
}}
/>
<style>{`
/* Translating rather than animating \`left\`: the latter runs
layout on every frame of an animation that plays during page
loads, which is exactly when the main thread is busiest.
Reduced motion is handled globally in semantic.css. */
@keyframes ema-shimmer {
0% { left: -40%; }
100% { left: 100%; }
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
`}</style>
</Box>

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { Badge, type BadgeProps } from '@mantine/core';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'children'> {
/** The platform tone this status maps onto. */
tone: StatusTone;
/** Human label. Already translated by the caller. */
label: ReactNode;
}
/**
* One badge for every status in the platform.
*
* There were 29 files rendering `<Badge variant="light" color={MAP[status]}>`,
* each re-deciding size, variant and radius alongside the colour — which is why
* a "pending" badge in one queue did not match "pending" in the next. Callers
* now supply a tone and a label; how a status *looks* is decided once, here.
*
* Tone rather than colour on purpose: `STATUS_TONE_COLOR` is the single place a
* tone becomes a Mantine colour, so restyling the platform's idea of "danger"
* stays one edit rather than a sweep.
*/
export function StatusBadge({ tone, label, ...props }: StatusBadgeProps) {
return (
<Badge variant="light" size="sm" radius="sm" {...props} color={STATUS_TONE_COLOR[tone]}>
{label}
</Badge>
);
}

View File

@@ -157,8 +157,8 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
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,
color: branchActive ? 'var(--mantine-primary-color-filled)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-primary-color-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
@@ -222,7 +222,10 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
opened={hasChildren ? opened : undefined}
onChange={hasChildren ? onToggle : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
// A leaf that is the current page is filled in the app's primary; a
// branch whose child is current stays a tint, so the filled item is
// always exactly the page you are on.
variant={hasChildren ? 'light' : 'filled'}
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },

View File

@@ -2,17 +2,54 @@ import { Group, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
/**
* The page's name. A `ReactNode` rather than a string because detail pages
* title themselves with the record they are showing, which is often a
* localised or composed value rather than a literal.
*/
title: ReactNode;
subtitle?: ReactNode;
/**
* Identifiers that belong beside the title rather than under it — a
* reference number, a status badge. Rendered inline, small and dimmed.
*/
meta?: ReactNode;
/** Right-aligned actions (e.g. a primary button). */
action?: ReactNode;
/**
* Set when the header sits directly inside a gapped `<Stack>`, which already
* spaces it from what follows — the built-in margin would double that gap.
*/
noMargin?: boolean;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
/**
* The heading every backoffice page starts with.
*
* It exists because 25 pages each rolled their own: eight at `order={2}`,
* seventeen at `order={3}`, with `mb="xs"`, `mb={4}`, `mb="lg"` and nothing at
* all between them. Clicking between two screens moved the title, which is the
* single loudest signal that a product was assembled rather than designed.
*
* `order={2}` is deliberate — it is the page's only `h2`, sitting under the
* app-level `h1`, so the heading outline reads correctly for a screen reader.
* Section headings inside cards stay at `order={4}`/`{5}` and are not this
* component's business.
*/
export function PageHeader({ title, subtitle, meta, action, noMargin }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Group
justify="space-between"
align="flex-end"
wrap="wrap"
gap="sm"
mb={noMargin ? undefined : 'lg'}
>
<Stack gap={2}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2}>{title}</Title>
{meta}
</Group>
{subtitle && (
<Text c="dimmed" size="sm">
{subtitle}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next';
/** The id the skip link targets. Exported so the main region cannot drift. */
export const MAIN_CONTENT_ID = 'ema-main-content';
/**
* "Skip to main content" — the first thing a keyboard user should reach.
*
* Both apps put a sidebar of 20-plus navigation items before the page body, so
* without this, reaching the actual content means tabbing through every one of
* them on every navigation. WCAG 2.4.1 asks for a bypass; there was none.
*
* Hidden until focused, which is why it is positioned off-screen rather than
* `display: none` — the latter would make it unfocusable and defeat the point.
* Styling lives in `semantic.css` as `.ema-skip-link`.
*
* Render it as the first child of the shell, before the header.
*/
export function SkipLink() {
const { t } = useTranslation();
return (
<a className="ema-skip-link" href={`#${MAIN_CONTENT_ID}`}>
{t('a11y.skipToContent', 'Skip to main content')}
</a>
);
}

View File

@@ -46,7 +46,6 @@
"react-router": "^7.12.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -71,7 +70,6 @@
"nx": "^22.5.4",
"postcss": "^8.5.1",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.3",
"typescript": "~5.9.2",
"vite": "^7.0.0",
"vitest": "^4.0.0"

2014
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./apps/**/*.{js,ts,jsx,tsx}',
'./libs/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
secondary: {
50: '#fdf8f6',
100: '#f2e8e5',
200: '#eaddd7',
300: '#e0cec7',
400: '#d2bab0',
500: '#bfa094',
600: '#a18072',
700: '#977669',
800: '#65524d',
900: '#2c1f1a',
},
},
boxShadow: {
card: '0 4px 20px rgba(15, 23, 42, 0.08)',
},
},
},
plugins: [],
};