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

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 && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
{t('certification.add')}
</Button>
)}
</Group>
<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>
)
}
/>
{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,73 +58,143 @@ 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
tableName="Awaiting claim"
columns={dashboardQueueColumns}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
onRowClick={() => navigate('/licence-review')}
refresh={queue.refetch}
emptyText="Nothing waiting to be claimed."
{/* 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')}
/>
</Card>
</Container>
<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}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
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>
}
/>
</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,26 +436,25 @@ 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 && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
</RequirePermission>
)}
</Group>
<PageHeader
title={t("exam.title")}
subtitle={t("exam.subtitle")}
noMargin
action={
!showForm && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
size="sm"
>
{t("exam.add")}
</Button>
</RequirePermission>
)
}
/>
{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,21 +139,19 @@ 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>
<TextInput
placeholder="Certificate № or company"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
<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} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
}
/>
<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,16 +501,11 @@ 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>
<Group gap="xs">
<PageHeader
title={queueTitle}
subtitle={typeCode ? t(`nav.type${typeCode}`, { defaultValue: typeCode }) : undefined}
action={
<Group gap="xs">
<SegmentedControl
size="xs"
value={density}
@@ -534,8 +529,9 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
</Group>
</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,9 +94,11 @@ export function LocationPage() {
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>{t('location.title')}</Title>
<Group gap="sm">
<PageHeader
title={t('location.title')}
noMargin
action={
<Group gap="sm">
{locationTypes.length > 0 && (
<Button
variant="light"
@@ -135,8 +123,9 @@ export function LocationPage() {
<IconSettings size={20} />
</ActionIcon>
</Tooltip>
</Group>
</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,22 +356,24 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
</Title>
<Text size="sm" c="dimmed" mb="md">
{isMedical
? t(
'recordVerification.medicalSubtitle',
'Submitted medical certificates awaiting a ruling. Verified certificates are frozen; rejections return to the seafarer with your remark.',
)
: 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>
<PageHeader
title={
isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: 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.',
)
: 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.',
)
}
/>
{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(
'paymentConfig.subtitle',
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
)}
</Text>
</div>
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
</Group>
<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.',
)}
noMargin
action={
<ThemeIcon size="xl" radius="md" variant="light">
<IconCreditCard size={22} />
</ThemeIcon>
}
/>
<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 && (
<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>
<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>
)
}
/>
{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,12 +242,8 @@ export function QuestionPage() {
style={{ width: 280 }}
clearable
/>
</Group>
<AdvancedTable
columns={columns}
data={page.rows}
tableName={t('question.title')}
itemCount={page.itemCount}
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
@@ -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,12 +264,12 @@ 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>
<Group gap="sm">
<PageHeader
title={t('result.title')}
subtitle={t('result.subtitle')}
noMargin
action={
<Group gap="sm">
<RequirePermission anyOf={[LICENSE_PERMISSIONS.PUBLISH_EXAM_RESULT]} hideOnly>
<Button
variant="light"
@@ -317,8 +286,9 @@ export function ResultPage() {
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
{t('result.record')}
</Button>
</Group>
</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,23 +321,17 @@ 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}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
</Card>
</>
}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('result.noItems')}
/>
<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,40 +117,39 @@ 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">
<TextInput
placeholder="Search number, name or seafarer №…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerDocumentStatus | null);
setPage(0);
}}
clearable
w={220}
/>
</Group>
<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} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={260}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerDocumentStatus | null);
setPage(0);
}}
clearable
w={200}
/>
</>
}
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,9 +123,10 @@ export function SeafarerDocumentReviewPage() {
{document.documentNumber}
</Badge>
)}
</Group>
</div>
<Group gap="xs">
</>
}
action={
<Group gap="xs">
{(document.status === 'PAYMENT_PENDING' || document.status === 'PAID') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button loading={confirming} onClick={() => run(() => confirmPayment(id).unwrap(), 'Payment confirmed')}>
@@ -174,8 +158,9 @@ export function SeafarerDocumentReviewPage() {
</Button>
</RequirePermission>
)}
</Group>
</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,40 +105,39 @@ 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">
<TextInput
placeholder="Search number, name or ID…"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={280}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerRegistrationStatus | null);
setPage(0);
}}
clearable
w={220}
/>
</Group>
<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} />}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(0);
}}
w={260}
/>
<Select
placeholder="All statuses"
data={STATUS_FILTERS}
value={status}
onChange={(v) => {
setStatus(v as SeafarerRegistrationStatus | null);
setPage(0);
}}
clearable
w={200}
/>
</>
}
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,24 +93,26 @@ 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>
<Group gap="xs">
</>
}
action={
<Group gap="xs">
{canDecide && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
@@ -146,8 +132,9 @@ export function SeafarerRegistrationReviewPage() {
</RequirePermission>
</>
)}
</Group>
</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,26 +221,28 @@ 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>
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
</Group>
</>
}
action={
<TextInput
placeholder="Name, registration № or IMO"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={280}
/>
}
/>
<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
? `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>
<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.'
}
/>
<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);
}