Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain

# Conflicts:
#	apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx
#	apps/portal/src/app/features/exams/pages/ExamsPage/index.tsx
#	libs/api/src/lib/features/licensing/licensing.helpers.ts
#	libs/auth/src/lib/components/AuthBootstrap.tsx
This commit is contained in:
mihretue
2026-08-20 11:41:06 +00:00
189 changed files with 36273 additions and 4832 deletions

View File

@@ -6,9 +6,122 @@
<link rel="icon" type="image/png" href="/ema-logo.png" />
<link rel="apple-touch-icon" href="/ema-logo.png" />
<title>EMA Backoffice</title>
<script>
// Apply the saved Mantine color scheme before paint to avoid a flash.
try {
var s = localStorage.getItem('mantine-color-scheme-value') || 'light';
document.documentElement.setAttribute('data-mantine-color-scheme', s);
} catch (e) {}
</script>
<style>
/* Boot splash — shown until React mounts into #root. Colors are
hardcoded (Mantine's default light/dark-7 body background) so the
splash never depends on the app's own stylesheet finishing its load. */
#ema-boot-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f172a;
color: #38bdf8;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html[data-mantine-color-scheme='light'] #ema-boot-splash {
background: #f8fafc;
color: #0f2c59;
}
#ema-boot-splash .ema-card {
padding: 2.5rem 3.5rem;
border-radius: 1.5rem;
background: rgba(15, 23, 42, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
html[data-mantine-color-scheme='light'] #ema-boot-splash .ema-card {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(15, 44, 89, 0.1);
box-shadow: 0 25px 50px -12px rgba(11, 25, 44, 0.12);
}
#ema-boot-splash svg {
width: 140px;
height: auto;
}
.ema-boot-compass {
transform-box: fill-box;
transform-origin: center;
animation: ema-boot-spin 20s linear infinite;
}
.ema-boot-helm {
transform-box: fill-box;
transform-origin: center;
animation: ema-boot-spin-rev 14s linear infinite;
}
.ema-boot-title {
margin-top: 1rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
background: linear-gradient(135deg, #078930, #fcd116, #2563eb);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.ema-boot-sub {
margin-top: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
opacity: 0.85;
}
@keyframes ema-boot-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes ema-boot-spin-rev {
from { transform: rotate(360deg); }
to { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.ema-boot-compass, .ema-boot-helm { animation: none; }
}
/* Hide splash once React mounts */
#root:not(:empty) ~ #ema-boot-splash {
display: none;
}
</style>
</head>
<body>
<div id="root"></div>
<div id="ema-boot-splash" role="status" aria-live="polite" aria-label="Loading Ethiopian Maritime Backoffice">
<div class="ema-card">
<div style="position: relative; width: 120px; height: 120px; display: flex; align-items: center; justify-content: center;">
<svg viewBox="0 0 120 120" style="position: absolute; inset: 0; width: 100%; height: 100%;">
<defs>
<linearGradient id="bo-ring-1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0284C7" />
<stop offset="100%" stop-color="#078930" />
</linearGradient>
<linearGradient id="bo-ring-2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F59E0B" />
<stop offset="100%" stop-color="#FCD116" />
</linearGradient>
</defs>
<circle cx="60" cy="60" r="54" fill="none" stroke="url(#bo-ring-1)" stroke-width="1.8" stroke-dasharray="8 6 2 6" opacity="0.85" class="ema-boot-compass" />
<circle cx="60" cy="60" r="39" fill="none" stroke="url(#bo-ring-2)" stroke-width="2" stroke-dasharray="28 14" class="ema-boot-helm" />
</svg>
<img src="/ema-logo.png" alt="EMA" style="width: 58px; height: 58px; object-fit: contain; position: relative; z-index: 2;" />
</div>
<div class="ema-boot-title">ETHIOPIAN MARITIME AUTHORITY</div>
<div style="font-size: 0.7rem; opacity: 0.6; margin-top: 2px;">የኢትዮጵያ ማሪታይም ባለስልጣን</div>
<div class="ema-boot-sub">Loading Maritime Backoffice…</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api';
@@ -13,12 +13,14 @@ interface PreviewArgs {
/**
* Renders the editor's current contents, not the saved row, so unsaved edits
* are what you see. Opened as a blob so it never leaves a file behind.
* are what you see. Opened as a blob into `PdfPreviewModal` rather than a new
* tab, so the designer never loses their place.
*/
export function useTemplatePreview() {
const { t } = useTranslation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return useCallback(
const open = useCallback(
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query
@@ -38,10 +40,7 @@ export function useTemplatePreview() {
}),
});
if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob());
window.open(url, '_blank', 'noopener');
// Give the new tab time to read it before revoking.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
setPreviewUrl(URL.createObjectURL(await response.blob()));
} catch (err) {
notifications.show({
color: 'red',
@@ -52,4 +51,13 @@ export function useTemplatePreview() {
},
[t],
);
const close = useCallback(() => {
setPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current);
return null;
});
}, []);
return { previewUrl, open, close };
}

View File

@@ -31,7 +31,7 @@ import {
useUpdateLicenseValidityMutation,
useUpdateLicenseTemplateMutation,
} from '@ema-platform/api';
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
import { EmptyState, ErrorState, PageHeader, PdfPreviewModal } from '@ema-platform/ui';
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar';
@@ -86,7 +86,7 @@ export function CertificateDesignerPage() {
const draft = useTemplateDraft(templates);
const run = useDesignerActions();
const openPreview = useTemplatePreview();
const { previewUrl, open: openPreview, close: closePreview } = useTemplatePreview();
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
@@ -377,6 +377,13 @@ export function CertificateDesignerPage() {
}, t('designer.created', 'Draft created'))
}
/>
<PdfPreviewModal
opened={Boolean(previewUrl)}
onClose={closePreview}
url={previewUrl ?? ''}
title={t('designer.preview', 'Preview')}
/>
</Container>
);
}

View File

@@ -5,6 +5,8 @@ import type {
ListResponse,
CreateProfessionPayload,
UpdateProfessionPayload,
NumberFormatConfig,
NumberFormatPayload,
} from "../types/configuration";
const configurationApi = baseApi.injectEndpoints({
@@ -36,6 +38,51 @@ const configurationApi = baseApi.injectEndpoints({
query: (id) => ({ url: `/professions/${id}`, method: "DELETE" }),
invalidatesTags: ["Api"],
}),
// Number formats — the shape of generated seafarer, seaman book and BTC
// identifiers. The counter behind each stays server-side; only the
// rendering is configurable here.
getNumberFormats: builder.query<NumberFormatConfig[], void>({
query: () => "/number-format-configs",
providesTags: ["Api", "NumberFormatApi"],
}),
createNumberFormat: builder.mutation<
NumberFormatConfig,
NumberFormatPayload
>({
query: (body) => ({
url: "/number-format-configs",
method: "POST",
body,
}),
invalidatesTags: ["Api", "NumberFormatApi"],
}),
updateNumberFormat: builder.mutation<
NumberFormatConfig,
{ id: string } & Partial<NumberFormatPayload>
>({
query: ({ id, ...body }) => ({
url: `/number-format-configs/${id}`,
method: "PATCH",
body,
}),
invalidatesTags: ["Api", "NumberFormatApi"],
}),
/**
* Server-rendered sample. Asked of the server rather than formatted in the
* browser so the preview cannot drift from what approval will actually
* generate — the two would be the same rule written twice.
*/
previewNumberFormat: builder.mutation<
{ sample: string },
NumberFormatPayload
>({
query: (body) => ({
url: "/number-format-configs/preview",
method: "POST",
body,
}),
}),
}),
overrideExisting: true,
});
@@ -46,4 +93,8 @@ export const {
useCreateProfessionMutation,
useUpdateProfessionMutation,
useDeleteProfessionMutation,
useGetNumberFormatsQuery,
useCreateNumberFormatMutation,
useUpdateNumberFormatMutation,
usePreviewNumberFormatMutation,
} = configurationApi;

View File

@@ -0,0 +1,288 @@
import { useEffect, useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Center,
Code,
Group,
Loader,
Modal,
NumberInput,
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
import {
useCreateNumberFormatMutation,
useGetNumberFormatsQuery,
usePreviewNumberFormatMutation,
useUpdateNumberFormatMutation,
} from '../api/configuration-api';
import type {
NumberFormatPayload,
NumberFormatScope,
} from '../types/configuration';
const SCOPES: { value: NumberFormatScope; label: string }[] = [
{ value: 'SEAFARER_NUMBER', label: 'Seafarer Number' },
{ value: 'SEAMAN_BOOK_NUMBER', label: 'Seaman Book Number' },
{ value: 'BTC_NUMBER', label: 'BTC Number' },
];
const scopeLabel = (scope: NumberFormatScope) =>
SCOPES.find((s) => s.value === scope)?.label ?? scope;
/**
* Backoffice control over the shape of generated identifiers.
*
* Authoring a format supersedes the one it replaces rather than editing it:
* numbers already issued were produced by a specific format, and the register
* has to stay explicable. Superseded rows therefore stay listed.
*/
export function NumberFormatTab() {
const { t } = useTranslation();
const { handleError } = useErrorHandler();
const [opened, { open, close }] = useDisclosure(false);
const [sample, setSample] = useState<string | null>(null);
const { data: formats = [], isLoading } = useGetNumberFormatsQuery();
const [createFormat, { isLoading: creating }] = useCreateNumberFormatMutation();
const [updateFormat] = useUpdateNumberFormatMutation();
const [previewFormat] = usePreviewNumberFormatMutation();
const form = useForm<NumberFormatPayload>({
initialValues: {
scope: 'SEAFARER_NUMBER',
prefix: 'SEA',
includeYear: true,
separator: '-',
sequenceLength: 6,
startingNumber: 1,
isActive: true,
},
validate: {
prefix: (value) => (value.trim() ? null : t('numberFormat.prefixRequired', 'Prefix is required')),
sequenceLength: (value) =>
value && value >= 1 && value <= 12
? null
: t('numberFormat.lengthRange', 'Length must be between 1 and 12'),
startingNumber: (value) =>
value && value >= 1
? null
: t('numberFormat.startPositive', 'Starting number must be at least 1'),
},
});
// The sample comes from the server so it cannot drift from what an approval
// will actually generate. Debounced because it follows every keystroke.
const { values } = form;
useEffect(() => {
if (!values.prefix?.trim()) {
setSample(null);
return;
}
const timer = setTimeout(() => {
previewFormat(values)
.unwrap()
.then((result) => setSample(result.sample))
// A preview that fails is not worth interrupting authoring for; the
// create call reports properly if the format is genuinely invalid.
.catch(() => setSample(null));
}, 300);
return () => clearTimeout(timer);
}, [values, previewFormat]);
const submit = form.onSubmit(async (payload) => {
try {
await createFormat(payload).unwrap();
notify.success(
t('numberFormat.created', 'Number format saved. It applies to numbers issued from now on.'),
);
close();
form.reset();
} catch (error) {
handleError(error);
}
});
const retire = async (id: string) => {
try {
await updateFormat({ id, isActive: false }).unwrap();
notify.success(t('numberFormat.retired', 'Format retired.'));
} catch (error) {
handleError(error);
}
};
if (isLoading) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
return (
<Stack gap="md">
<Group justify="space-between">
<div>
<Title order={4}>{t('numberFormat.title', 'Number Formats')}</Title>
<Text fz="sm" c="dimmed">
{t(
'numberFormat.subtitle',
'The shape of generated seafarer, seaman book and certificate numbers.',
)}
</Text>
</div>
<Button leftSection={<IconPlus size={16} />} onClick={open} size="sm">
{t('numberFormat.add', 'New Format')}
</Button>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t(
'numberFormat.notice',
'Changing a format never alters numbers already issued. A new format applies only to numbers generated after it becomes active.',
)}
</Alert>
{formats.length === 0 ? (
<Card withBorder radius="md" p="lg">
<Text fz="sm" c="dimmed" ta="center">
{t(
'numberFormat.empty',
'No formats configured. Numbers use the built-in default until one is added.',
)}
</Text>
</Card>
) : (
<Table.ScrollContainer minWidth={720}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('numberFormat.scope', 'Identifier')}</Table.Th>
<Table.Th>{t('numberFormat.example', 'Example')}</Table.Th>
<Table.Th>{t('numberFormat.sequence', 'Sequence')}</Table.Th>
<Table.Th>{t('numberFormat.status', 'Status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{formats.map((format) => {
const parts = [format.prefix];
if (format.includeYear) parts.push(String(new Date().getFullYear()));
parts.push(String(format.startingNumber).padStart(format.sequenceLength, '0'));
return (
<Table.Tr key={format.id}>
<Table.Td>{scopeLabel(format.scope)}</Table.Td>
<Table.Td>
<Code>{parts.join(format.separator)}</Code>
</Table.Td>
<Table.Td>{format.sequenceLength} digits</Table.Td>
<Table.Td>
<Badge color={format.isActive ? 'green' : 'gray'} variant="light">
{format.isActive
? t('numberFormat.active', 'Active')
: t('numberFormat.superseded', 'Superseded')}
</Badge>
</Table.Td>
<Table.Td>
{format.isActive && (
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={() => retire(format.id)}
>
{t('numberFormat.retire', 'Retire')}
</Button>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<Modal
opened={opened}
onClose={close}
title={t('numberFormat.add', 'New Format')}
size="md"
>
<form onSubmit={submit}>
<Stack gap="sm">
<Select
label={t('numberFormat.scope', 'Identifier')}
data={SCOPES}
allowDeselect={false}
{...form.getInputProps('scope')}
/>
<TextInput
label={t('numberFormat.prefix', 'Prefix')}
placeholder="SEA"
maxLength={12}
{...form.getInputProps('prefix')}
/>
<Switch
label={t('numberFormat.includeYear', 'Include the issuing year')}
{...form.getInputProps('includeYear', { type: 'checkbox' })}
/>
<TextInput
label={t('numberFormat.separator', 'Separator')}
maxLength={4}
{...form.getInputProps('separator')}
/>
<NumberInput
label={t('numberFormat.sequenceLength', 'Sequence length')}
description={t('numberFormat.sequenceHelp', 'Zero-padded width, e.g. 6 gives 000001.')}
min={1}
max={12}
{...form.getInputProps('sequenceLength')}
/>
<NumberInput
label={t('numberFormat.startingNumber', 'Starting number')}
description={t(
'numberFormat.startHelp',
'Where the counter begins. Raising it later does not renumber anything already issued.',
)}
min={1}
{...form.getInputProps('startingNumber')}
/>
{sample && (
<Alert color="gray" variant="light">
<Group gap="xs">
<Text fz="sm">{t('numberFormat.next', 'Next number will look like')}</Text>
<Code>{sample}</Code>
</Group>
</Alert>
)}
<ModalFooter>
<Button variant="default" onClick={close} size="sm">
{t('configuration.cancel', 'Cancel')}
</Button>
<Button type="submit" loading={creating} size="sm">
{t('numberFormat.save', 'Save Format')}
</Button>
</ModalFooter>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -21,6 +21,7 @@ import {
IconBriefcase,
IconMap,
IconCertificate,
IconHash,
IconInfoCircle,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -30,9 +31,11 @@ import {
AdvancedTable,
useServerTable,
ModalFooter,
PageLoader,
} from "@ema-platform/ui";
import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage";
import { NumberFormatTab } from "../../components/NumberFormatTab";
import {
useGetOrganizationsQuery,
useGetProfessionsQuery,
@@ -292,11 +295,7 @@ function ProfessionTab() {
];
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
return <PageLoader label="Loading Configuration…" height={400} />;
}
if (isError) {
@@ -398,6 +397,9 @@ export function ConfigurationPage() {
>
{t("certification.title")}
</Tabs.Tab>
<Tabs.Tab value="numberFormats" leftSection={<IconHash size={16} />}>
{t("numberFormat.title", "Number Formats")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="professions" pt="md">
@@ -411,6 +413,10 @@ export function ConfigurationPage() {
<Tabs.Panel value="certifications" pt="md">
<CertificationPage />
</Tabs.Panel>
<Tabs.Panel value="numberFormats" pt="md">
<NumberFormatTab />
</Tabs.Panel>
</Tabs>
</Stack>
);

View File

@@ -46,3 +46,41 @@ export interface UpdateProfessionPayload {
description?: NamePair;
isActive?: boolean;
}
/** Identifiers whose rendered shape is authored in the backoffice. */
export type NumberFormatScope =
| 'SEAFARER_NUMBER'
| 'SEAMAN_BOOK_NUMBER'
| 'BTC_NUMBER';
/**
* The shape of a generated identifier — prefix, optional year, separator and
* zero-padded counter, e.g. SEA-2026-000001.
*
* Only the shape. The counter itself is server-side and atomic, so nothing
* here can cause two people to be issued the same number.
*/
export interface NumberFormatConfig {
id: string;
scope: NumberFormatScope;
prefix: string;
includeYear: boolean;
separator: string;
sequenceLength: number;
startingNumber: number;
activeFrom: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface NumberFormatPayload {
scope: NumberFormatScope;
prefix: string;
includeYear?: boolean;
separator?: string;
sequenceLength?: number;
startingNumber?: number;
activeFrom?: string;
isActive?: boolean;
}

View File

@@ -11,7 +11,7 @@ import {
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
import { dashboardQueueColumns } from './columns';
/**
@@ -28,11 +28,7 @@ export function DashboardPage() {
const table = useServerTable();
if (queue.isLoading || mine.isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
return <PageLoader label="Loading Backoffice Dashboard…" height={400} />;
}
const unclaimed = queue.data?.items ?? [];

View File

@@ -54,6 +54,7 @@ import { QuestionAssigner } from '../components/QuestionAssigner';
import { RecordResultModal } from '../../result/components/RecordResultModal';
import { ExamCandidatesPanel } from '../components/ExamCandidatesPanel';
import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
import { PageLoader } from '@ema-platform/ui';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
@@ -129,11 +130,7 @@ export function ExamDetailPage() {
}, [allQuestions, exam?.certificationId, exam?.form]);
if (isLoading)
return (
<Center py="xl">
<Loader />
</Center>
);
return <PageLoader label="Loading Exam Details…" height={400} />;
if (isError || !exam) {
return (
<Stack gap="md">

View File

@@ -0,0 +1,108 @@
import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { ApplicationApplicant } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
interface ApplicantCardProps {
applicant: ApplicationApplicant;
}
/**
* Who the reviewer is deciding about.
*
* A company licence names itself in the page title (`companyName`); a seafarer
* registration has no company, so the officer's screen led with an application
* number and the human behind it was somewhere in the form answers. This puts
* the identity where it belongs on a person-centric review: name, national ID,
* contact, and — for a seafarer who already holds one — their number and
* standing, which is what says whether this is a first registration or a
* duplicate.
*
* Read-only and sourced from the profile, not the form: this is the record the
* registration will be written onto, so a reviewer comparing the two is exactly
* the intended use.
*/
export function ApplicantCard({ applicant }: ApplicantCardProps) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const fullName = [applicant.firstName, applicant.middleName, applicant.lastName]
.filter(Boolean)
.join(' ');
const initials = [applicant.firstName, applicant.lastName]
.filter(Boolean)
.map((part) => part?.[0]?.toUpperCase() ?? '')
.join('');
return (
<Paper withBorder p="md">
<Group gap="sm" wrap="nowrap" align="flex-start" mb="sm">
<Avatar radius="xl" color="blue" variant="light">
{initials || '—'}
</Avatar>
<div style={{ minWidth: 0 }}>
<Text fw={600} size="sm" style={{ wordBreak: 'break-word' }}>
{fullName || t('review.nameMissing', 'Name not on profile')}
</Text>
{applicant.seafarerNumber ? (
<Group gap={4} mt={2}>
<Text size="xs" c="dimmed">
{applicant.seafarerNumber}
</Text>
{applicant.seafarerStatus && (
<Badge
size="xs"
variant="light"
color={applicant.seafarerStatus === 'ACTIVE' ? 'teal' : 'orange'}
>
{applicant.seafarerStatus}
</Badge>
)}
</Group>
) : (
<Text size="xs" c="dimmed" mt={2}>
{t('review.notYetRegistered', 'Not yet registered')}
</Text>
)}
</div>
</Group>
<Stack gap={6}>
<Row label={t('review.applicantGender', 'Gender')} value={applicant.gender} />
<Row
label={t('review.applicantDob', 'Date of birth')}
value={applicant.dob ? showDate(applicant.dob) : null}
/>
<Row
label={t('review.applicantNationality', 'Nationality')}
value={applicant.nationality}
/>
<Row
// The id type is the label, so a Fayda number is not read as a passport.
label={applicant.idType ?? t('review.applicantId', 'National ID')}
value={applicant.idNumber}
/>
<Row
label={t('review.applicantPhone', 'Phone')}
value={applicant.primaryPhoneNumber}
/>
<Row label={t('review.applicantEmail', 'Email')} value={applicant.email} />
</Stack>
</Paper>
);
}
/** One label/value line, omitted entirely when there is nothing to show. */
function Row({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<Group justify="space-between" gap="xs" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{label}
</Text>
<Text size="xs" ta="right" style={{ wordBreak: 'break-word' }}>
{value}
</Text>
</Group>
);
}

View File

@@ -76,9 +76,13 @@ export function DecisionBar({
role="region"
aria-label={t('review.decisionBar', 'Decision bar')}
>
<Group justify="space-between" wrap="nowrap" gap="md">
{/* Wraps rather than overflows: at narrow widths the nowrap row pushed
the workflow buttons past the viewport edge, so Assign, Escalate and
Hold were simply not there. Wrapping drops them onto a second line
instead of off the screen. */}
<Group justify="space-between" wrap="wrap" gap="sm">
{/* Left: where the application stands, and who has it. */}
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Group gap="sm" wrap="wrap" style={{ minWidth: 0, flex: '1 1 auto' }}>
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
@@ -124,7 +128,7 @@ export function DecisionBar({
</Group>
{/* Right: the decision. */}
<Group gap="xs" wrap="nowrap">
<Group gap="xs" wrap="wrap" justify="flex-end" style={{ flex: '0 1 auto' }}>
{primary.map((action) => (
<ActionButton
key={action.id}
@@ -194,10 +198,17 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const button = (
<Button
size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'}
variant={
action.emphasis === 'filled'
? 'filled'
: action.emphasis === 'subtle'
? 'default'
: 'light'
}
color={action.color}
loading={busy}
disabled={!action.enabled}
style={{ flexShrink: 0 }}
onClick={() => onAction(action)}
>
{t(action.labelKey)}
@@ -207,7 +218,13 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
if (action.enabled) return button;
return (
<Tooltip label={action.disabledReason} withArrow position="top">
<Tooltip
label={action.disabledReason}
withArrow
position="top"
multiline
w={280}
>
<span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
</Tooltip>
);
@@ -234,7 +251,13 @@ function MenuAction({
);
if (action.enabled) return item;
return (
<Tooltip label={action.disabledReason} withArrow position="left">
<Tooltip
label={action.disabledReason}
withArrow
position="left"
multiline
w={280}
>
<div>{item}</div>
</Tooltip>
);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
import {
ActionIcon,
Alert,
@@ -13,7 +13,7 @@ import {
Text,
TextInput,
Tooltip,
} from '@mantine/core';
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
@@ -22,23 +22,27 @@ import {
IconFileText,
IconRotate,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
conditionHolds,
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useLocalized,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { notifications } from '@mantine/notifications';
} from "@ema-platform/api";
import { notifications } from "@mantine/notifications";
import { PdfPreviewModal } from "@ema-platform/ui";
interface DocumentsTabProps {
applicationId: string;
attachments: Attachment[];
/** From the licence type config, so completeness is measured against rules. */
requirements: DocumentRequirement[];
/** Applicant answers used to evaluate conditional document requirements. */
formData: Record<string, Record<string, unknown>>;
/** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
@@ -58,6 +62,7 @@ export function DocumentsTab({
applicationId,
attachments,
requirements,
formData,
flags,
onToggleFlag,
onFlagRemark,
@@ -80,16 +85,16 @@ export function DocumentsTab({
async function decide(
documentKey: string,
decision: 'ACCEPTED' | 'REJECTED',
decision: "ACCEPTED" | "REJECTED",
attachmentId?: string,
) {
const reason = rejecting[documentKey]?.trim();
if (decision === 'REJECTED' && !reason) {
if (decision === "REJECTED" && !reason) {
// The applicant is shown this verbatim, so refuse to send an empty one.
notifications.show({
color: 'red',
title: t('review.documents.reasonRequired', 'A reason is required'),
message: '',
color: "red",
title: t("review.documents.reasonRequired", "A reason is required"),
message: "",
});
return;
}
@@ -98,7 +103,7 @@ export function DocumentsTab({
id: applicationId,
documentKey,
decision,
reason: decision === 'REJECTED' ? reason : undefined,
reason: decision === "REJECTED" ? reason : undefined,
attachmentId,
}).unwrap();
setRejecting((prev) => {
@@ -108,24 +113,29 @@ export function DocumentsTab({
});
} catch {
notifications.show({
color: 'red',
title: t('review.documents.saveFailed', 'Could not save the verdict'),
message: '',
color: "red",
title: t("review.documents.saveFailed", "Could not save the verdict"),
message: "",
});
}
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const mandatory = requirements.filter(
(r) =>
r.mode === "ALWAYS" ||
(r.mode === "CONDITIONAL" &&
conditionHolds(r.conditionExpression, formData)),
);
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
: 100;
const previewFile = preview?.files?.[0];
const isImage = previewFile?.mimeType?.startsWith('image/');
const isPdf = previewFile?.mimeType === 'application/pdf';
const isImage = previewFile?.mimeType?.startsWith("image/");
const isPdf = previewFile?.mimeType === "application/pdf";
return (
<Stack gap="md">
@@ -133,25 +143,30 @@ export function DocumentsTab({
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
{t("review.documents.completeness", "Required documents")}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
<Text size="sm" c={missing.length ? "orange" : "teal"} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
color={missing.length ? "orange" : "teal"}
aria-label={t("review.documents.completenessLabel", {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
defaultValue: "{{value}}% of required documents uploaded",
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Alert
mt="sm"
color="orange"
icon={<IconAlertCircle size={16} />}
variant="light"
>
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => localized(r.name) || r.key).join(', ')}
{t("review.documents.missing", "Not yet uploaded")}:{" "}
{missing.map((r) => localized(r.name) || r.key).join(", ")}
</Text>
</Alert>
)}
@@ -174,45 +189,55 @@ export function DocumentsTab({
opaque badge painted over the bleeding text. Nested here
with its own wrap, the name truncates cleanly instead. */}
<Group gap={6} wrap="wrap" align="center">
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
<Text
size="sm"
fw={500}
truncate
style={{ maxWidth: "100%" }}
>
{localized(
requirementByKey.get(attachment.documentKey)?.name,
) || attachment.documentKey}
</Text>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
t("review.documents.reviewedBy", {
name: verdict.reviewedByName ?? "—",
defaultValue: "Reviewed by {{name}}",
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
color={
verdict.decision === "ACCEPTED" ? "teal" : "red"
}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
verdict.decision === "ACCEPTED" ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
{verdict.decision === "ACCEPTED"
? t("review.documents.accepted", "Accepted")
: t("review.documents.rejected", "Rejected")}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
{t("review.documents.flagged", "Correction requested")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
{file?.originalName ??
t("review.documents.noFile", "No file")}
</Text>
</div>
</Group>
@@ -221,8 +246,11 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
? t("review.documents.preview", "Preview")
: t(
"review.documents.noFileUploaded",
"Nothing uploaded yet",
)
}
>
<span>
@@ -233,15 +261,18 @@ export function DocumentsTab({
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
{t("review.documents.view", "View")}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
? t("review.documents.download", "Download")
: t(
"review.documents.noFileUploaded",
"Nothing uploaded yet",
)
}
>
<span>
@@ -253,7 +284,7 @@ export function DocumentsTab({
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
aria-label={t("review.documents.download", "Download")}
>
<IconDownload size={16} />
</ActionIcon>
@@ -266,19 +297,28 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
? t("review.documents.accept", "Accept")
: t(
"review.documents.nothingToJudge",
"Nothing uploaded to judge",
)
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
variant={
verdict?.decision === "ACCEPTED" ? "filled" : "light"
}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
aria-label={t("review.documents.accept", "Accept")}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
decide(
attachment.documentKey,
"ACCEPTED",
attachment.id,
)
}
>
<IconCheck size={16} />
@@ -288,20 +328,25 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
? t("review.documents.reject", "Reject")
: t(
"review.documents.nothingToJudge",
"Nothing uploaded to judge",
)
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
variant={
verdict?.decision === "REJECTED" ? "filled" : "light"
}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
aria-label={t("review.documents.reject", "Reject")}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
[attachment.documentKey]: verdict?.reason ?? "",
}))
}
>
@@ -310,11 +355,11 @@ export function DocumentsTab({
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<Tooltip label={t("review.documents.clear", "Clear verdict")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
aria-label={t("review.documents.clear", "Clear verdict")}
onClick={() =>
clearReview({
id: applicationId,
@@ -330,7 +375,7 @@ export function DocumentsTab({
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
label={t("review.documents.includeInAdjustment", "Send back")}
/>
</Group>
</Group>
@@ -342,8 +387,8 @@ export function DocumentsTab({
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
"review.documents.rejectReason",
"Why must this document be corrected?",
)}
value={rejecting[attachment.documentKey]}
onChange={(e) => {
@@ -363,10 +408,10 @@ export function DocumentsTab({
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
decide(attachment.documentKey, "REJECTED", attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
{t("review.documents.confirmReject", "Reject")}
</Button>
</Group>
)}
@@ -376,15 +421,20 @@ export function DocumentsTab({
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
"review.documents.adjustmentNote",
"What must the applicant correct?",
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
onChange={(e) =>
onFlagRemark(attachment.documentKey, e.currentTarget.value)
}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
: t(
"review.documents.reasonRequired",
"A reason is required",
)
}
/>
)}
@@ -392,15 +442,28 @@ export function DocumentsTab({
);
})}
<PdfPreviewModal
opened={Boolean(preview) && isPdf}
onClose={() => setPreview(null)}
url={previewFile?.url ?? ""}
title={
preview
? localized(requirementByKey.get(preview.documentKey)?.name) ||
preview.documentKey
: ""
}
/>
<Drawer
opened={Boolean(preview)}
opened={Boolean(preview) && !isPdf}
onClose={() => setPreview(null)}
position="right"
size="xl"
title={
preview
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
: ''
? localized(requirementByKey.get(preview.documentKey)?.name) ||
preview.documentKey
: ""
}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
@@ -408,25 +471,22 @@ export function DocumentsTab({
returnFocus
>
{previewFile?.url ? (
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ width: '100%', height: '80vh', border: 'none' }}
/>
) : isImage ? (
isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ maxWidth: '100%' }}
alt={
preview?.documentKey ??
t("review.documents.previewFallback", "document")
}
style={{ maxWidth: "100%" }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
"review.documents.noInlinePreview",
"This file type cannot be previewed in the browser.",
)}
</Text>
<Button
@@ -436,7 +496,7 @@ export function DocumentsTab({
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
{t("review.documents.downloadShort", "Download")}
</Button>
</Stack>
)

View File

@@ -0,0 +1,265 @@
import {
Badge,
Card,
Checkbox,
Divider,
Grid,
Group,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
conditionHolds,
displayFieldValue,
useLocalized,
type FormFieldConfig,
type FormSectionConfig,
} from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
/** A section as it will be rendered: config where there is some, key otherwise. */
interface ResolvedSection {
key: string;
title: string;
description?: string;
fields: { field: FormFieldConfig; value: unknown }[];
}
interface FormDetailsTabProps {
/** The application's answers, keyed by section. */
formData: Record<string, Record<string, unknown>>;
/** The licence type's form schema — the order and labels to render by. */
configSections: FormSectionConfig[];
currency?: string;
/** sectionKey -> remark. Owned by the review page. */
flags: Record<string, { remark: string }>;
onToggleFlag: (sectionKey: string) => void;
onFlagRemark: (sectionKey: string, remark: string) => void;
/** Resolves a location id to a readable path, when the tree is loaded. */
resolveLocation?: (locationId: string) => string | undefined;
}
/**
* What the applicant actually filled in, as the reviewing officer reads it.
*
* Replaces a set of bordered key/value tables built by walking `formData`.
* Three things were wrong with that, all of them worse on a person-centric
* registration than on a company licence:
*
* - Values were printed with `String(v)`, so a reviewer deciding on a seafarer
* read `O_POSITIVE`, `DECK` and `true` — database codes, not the answers
* anybody chose. Now resolved through the same field config that rendered
* the input, shared with the applicant's own summary (`displayFieldValue`).
* - Order came from jsonb key order, which is arbitrary: the declaration could
* appear above the emergency contact. Now the schema's `sortOrder` decides,
* which is the order the applicant filled them in.
* - A location answer is a uuid. Shown raw it told the reviewer nothing;
* resolved, it reads "Addis Ababa → Bole → Woreda 03".
*/
export function FormDetailsTab({
formData,
configSections,
currency,
flags,
onToggleFlag,
onFlagRemark,
resolveLocation,
}: FormDetailsTabProps) {
const { t, i18n } = useTranslation();
const localized = useLocalized();
const showDate = useDateDisplayer();
const sections = resolveSections();
/**
* Sections in schema order, each with its fields in schema order.
*
* Anything present in `formData` but absent from the schema is still shown,
* appended after the configured sections — a stale answer from a since-edited
* form is exactly the kind of thing a reviewer needs to see, not something to
* hide because the config moved on.
*/
function resolveSections(): ResolvedSection[] {
const configured = [...configSections]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((section) => {
const values = formData[section.key] ?? {};
const fields = [...(section.fields ?? [])]
.filter((f) => conditionHolds(f.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((field) => ({ field, value: values[field.key] }));
return {
key: section.key,
title: localized(section.title) || section.key,
description: localized(section.description) || undefined,
fields,
};
})
// A section the applicant never reached is noise on a review screen.
.filter((s) => s.fields.some((f) => hasValue(f.value)));
const configuredKeys = new Set(configSections.map((s) => s.key));
const orphans: ResolvedSection[] = Object.entries(formData)
.filter(([key, values]) => !configuredKeys.has(key) && values)
.map(([key, values]) => ({
key,
title: humanise(key),
fields: Object.entries(values).map(([fieldKey, value]) => ({
// No config to render by, so it is treated as free text under a
// humanised key rather than dropped.
field: { key: fieldKey, label: { en: humanise(fieldKey) }, type: 'TEXT' } as FormFieldConfig,
value,
})),
}));
return [...configured, ...orphans];
}
function display(field: FormFieldConfig, value: unknown): string {
// A location is stored as a tree id; the reviewer needs the place.
if (isLocationField(field) && typeof value === 'string' && value) {
return resolveLocation?.(value) ?? value;
}
return displayFieldValue(field, value, {
language: i18n.language,
showDate,
currency,
});
}
return (
<Grid>
{sections.map((section) => {
const flagged = Boolean(flags[section.key]);
const missing = section.fields.filter((f) => !hasValue(f.value)).length;
return (
<Grid.Col span={12} key={section.key}>
<Card withBorder padding="md" radius="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<div style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={600} size="sm">
{section.title}
</Text>
{missing > 0 && (
<Tooltip
label={t(
'review.missingAnswers',
'Left blank by the applicant',
)}
>
<Badge
size="xs"
color="gray"
variant="light"
leftSection={<IconAlertTriangle size={10} />}
>
{missing}
</Badge>
</Tooltip>
)}
</Group>
{section.description && (
<Text size="xs" c="dimmed" mt={2}>
{section.description}
</Text>
)}
</div>
<Checkbox
size="xs"
label={t('review.needsCorrection', 'Needs correction')}
checked={flagged}
onChange={() => onToggleFlag(section.key)}
style={{ flexShrink: 0 }}
/>
</Group>
<Divider my="sm" />
{/* Label above value, two per row — a reviewer scans a definition
list far faster than a bordered table of the same answers. */}
<Grid gutter="sm">
{section.fields.map(({ field, value }) => {
const text = display(field, value);
const answered = hasValue(value) && text !== '';
return (
<Grid.Col
span={{ base: 12, sm: field.type === 'TEXTAREA' ? 12 : 6 }}
key={field.key}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{localized(field.label) || field.key}
</Text>
<Group gap={4} wrap="nowrap" align="center" mt={2}>
{answered && isLocationField(field) && (
<IconMapPin size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
)}
<Text
size="sm"
c={answered ? undefined : 'dimmed'}
fs={answered ? undefined : 'italic'}
style={{ wordBreak: 'break-word' }}
>
{answered
? text
: t('review.notProvided', 'Not provided')}
</Text>
</Group>
</Grid.Col>
);
})}
</Grid>
{flagged && (
<TextInput
mt="sm"
size="xs"
withAsterisk
placeholder={t(
'review.correctionPlaceholder',
'What must the applicant correct?',
)}
// Flagging without saying why is what the applicant would
// receive: "fix this section", and nothing else.
error={
flags[section.key].remark.trim()
? null
: t('review.correctionRequired', 'Say what must be corrected')
}
value={flags[section.key].remark}
onChange={(e) => {
// Read here, not inside the updater: React nulls
// `currentTarget` when the handler returns, and the updater
// runs afterwards during the re-render.
onFlagRemark(section.key, e.currentTarget.value);
}}
/>
)}
</Card>
</Grid.Col>
);
})}
</Grid>
);
}
function hasValue(value: unknown): boolean {
return value !== null && value !== undefined && value !== '';
}
/** English-pinned, like the portal's own location override. */
function isLocationField(field: Pick<FormFieldConfig, 'key' | 'label'>): boolean {
return (
field.key === 'locationId' ||
(field.label?.en ?? '').trim().toLowerCase() === 'location'
);
}
function humanise(key: string): string {
const spaced = key.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
}

View File

@@ -1,5 +1,4 @@
import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api';
import { LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
/**
* Where an action is rendered. One tier per action, decided here rather than
@@ -31,10 +30,11 @@ export type ActionId =
| 'reject'
| 'schedule-exam'
| 'confirm-payment'
| 'schedule-issuance'
| 'issue-certificate'
| 'print'
| 'copy-link'
| 'download-documents'
| 'generate-certificate'
| 'audit-trail';
export interface ActionDefinition {
@@ -211,6 +211,28 @@ export const ACTIONS: ActionDefinition[] = [
emphasis: 'filled',
color: 'teal',
},
{
id: 'schedule-issuance',
tier: 'primary',
labelKey: 'review.actions.scheduleIssuance',
// Only reachable for a license type with `requiresIssuanceScheduling` —
// everything else cascades straight to CERTIFICATE_ISSUED and never
// shows PAYMENT_CONFIRMED with this action available (the server's
// `availableEvents` omits it there, same as the rest of this list).
from: ['PAYMENT_CONFIRMED'],
permissions: ['can:schedule:license-issuance'],
emphasis: 'filled',
color: 'cyan',
},
{
id: 'issue-certificate',
tier: 'primary',
labelKey: 'review.actions.issueCertificate',
from: ['SCHEDULED'],
permissions: ['can:issue:license-certificate'],
emphasis: 'filled',
color: 'teal',
},
// ------------------------------------------------------------ secondary
{ id: 'print', tier: 'secondary', labelKey: 'review.actions.print' },
@@ -220,13 +242,6 @@ export const ACTIONS: ActionDefinition[] = [
tier: 'secondary',
labelKey: 'review.actions.downloadDocuments',
},
{
id: 'generate-certificate',
tier: 'secondary',
labelKey: 'review.actions.generateCertificate',
from: ['CERTIFICATE_ISSUED'],
permissions: [PERMISSIONS.VIEW_APPLICATIONS],
},
{ id: 'audit-trail', tier: 'secondary', labelKey: 'review.actions.auditTrail' },
];
@@ -253,13 +268,44 @@ export interface ResolveContext {
needsFlags: string;
needsCapital: string;
needsInspection: string;
needsDocumentReviews: string;
};
/** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean;
/**
* False while any uploaded document is still unjudged or rejected. Approving
* is a statement that every document was checked, so the button stays dead
* until the officer has actually judged each one.
*/
allDocumentsAccepted: boolean;
}
/**
* Action ids that are workflow events, so `availableEvents` decides them.
*
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
* screens and side effects rather than transitions, and the server has no
* opinion on them — those keep using their own `from` list.
*/
const WORKFLOW_EVENT_IDS = new Set<ActionId>([
'claim',
'assign',
'escalate',
'hold',
'resume',
'complete-review',
'approve-documents',
'record-inspection',
'final-approve',
'request-adjustment',
'reject',
'confirm-payment',
'schedule-issuance',
'issue-certificate',
]);
/**
* Which actions to render, and for each, whether it can fire and why not.
*
@@ -267,16 +313,35 @@ export interface ResolveContext {
* are merely unavailable right now are kept and disabled with a reason, so the
* officer can see what the next step would be rather than wondering whether
* the screen is broken.
*
* For anything that is a workflow event, `detail.availableEvents` is the
* authority on what fires from here — it comes from the same transition table
* the server validates against, and it is workflow-profile aware. The local
* `from` lists describe the licence course only, so a registration (which skips
* evaluation and inspection, and approves straight out of UNDER_REVIEW) was
* offered Complete Review — rejected server-side with
* `event_not_available_for_service` — while Final Approve, the one action that
* would work, was hidden.
*/
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
const { detail, currentUserId, can, reasons } = ctx;
const app = detail.application;
const serverEvents = detail.availableEvents;
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
(action) => {
// Status-scoped actions vanish outside their stage rather than piling up
// as a column of permanently dead buttons.
if (action.from && !action.from.includes(app.status)) return [];
if (WORKFLOW_EVENT_IDS.has(action.id)) {
// Tolerate an older server that sends no list rather than rendering an
// empty action bar.
if (serverEvents?.length && !serverEvents.includes(action.id)) return [];
if (!serverEvents?.length && action.from && !action.from.includes(app.status)) {
return [];
}
} else if (action.from && !action.from.includes(app.status)) {
return [];
}
// Scheduling and recording are the same slot at the same status; which
// one applies depends on whether an inspection is already booked.
@@ -300,6 +365,13 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
return disabled(reasons.notAssigned);
}
if (
(action.id === 'approve-documents' || action.id === 'final-approve') &&
!ctx.allDocumentsAccepted
) {
return disabled(reasons.needsDocumentReviews);
}
if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
return disabled(reasons.needsFlags);
}

View File

@@ -87,6 +87,23 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
// Person-centric: no company entity, no capital threshold, no staff roles.
detailSections: ['overview', 'documents'],
},
// Opened automatically when a registration is approved, and reviewed like any
// other person-centric service. Listed explicitly because neither key matches
// the certificate prefixes below, so both fell through to the company-shaped
// default and offered an officer Company, Financials and Staff tabs for an
// application about one person.
SEAMAN_BOOK: {
key: 'SEAMAN_BOOK',
icon: IconId,
// Its own TRB inspection is a real stage, unlike the other personal
// services, so the inspection tab stays.
detailSections: ['overview', 'documents', 'inspection'],
},
BTC_BASIC_TRAINING: {
key: 'BTC_BASIC_TRAINING',
icon: IconShieldCheck,
detailSections: ['overview', 'documents'],
},
VESSEL_REGISTRATION: {
key: 'VESSEL_REGISTRATION',
icon: IconAnchor,

View File

@@ -10,6 +10,8 @@ export function licenseQueueActionsColumn(
claiming: boolean;
onClaim: (id: string) => void;
onOpen: (id: string) => void;
/** False for a non-logistics queue — there's no unclaimed pool to claim from. */
claimable?: boolean;
},
): AdvancedColumn<LicenseApplication> {
return {
@@ -18,6 +20,7 @@ export function licenseQueueActionsColumn(
align: "right",
size: 140,
cell: ({ row }) =>
handlers.claimable !== false &&
row.original.assignedOfficerId === null &&
row.original.status === "SUBMITTED" ? (
<RequirePermission

View File

@@ -2,7 +2,6 @@ import type { Dispatch, ReactNode, SetStateAction } from "react";
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
import type { TFunction } from "i18next";
import {
APPLICANT_NAME_TYPE_KEYS,
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
@@ -14,11 +13,36 @@ import type { AdvancedColumn } from "@ema-platform/ui";
import { dateDisplayer } from "@ema-platform/shared";
import { computeSla } from "../../sla";
/**
* Label for the Company/Applicant column, derived from the rows actually on
* screen rather than the route — a type-pinned queue (`/type/:typeCode`)
* happens to be one family, but nothing stops the mixed "All Applications"
* grid from holding both, and a static header can't be correct for both at
* once. Falls back to the combined label until the page has data to look at.
*/
function companyColumnHeader(
t: TFunction,
items: LicenseApplication[],
isLogistics: boolean | undefined,
): string {
// A type-pinned non-logistics queue (Seafarer Registration, Seaman Book,
// BTC, ...) is always "Applicant" — no need to guess from loaded rows.
if (isLogistics === false) return t("queue.applicant", "Applicant");
if (isLogistics === true) return t("queue.company", "Company");
if (items.length === 0) {
return t("queue.companyOrApplicant", "Applicant / Company");
}
const allLogistics = items.every((a) => a.familyKind === "LOGISTICS_LICENSE");
const allNonLogistics = items.every((a) => a.familyKind !== "LOGISTICS_LICENSE");
if (allLogistics) return t("queue.company", "Company");
if (allNonLogistics) return t("queue.applicant", "Applicant");
return t("queue.companyOrApplicant", "Applicant / Company");
}
export function licenseQueueColumns(
t: TFunction,
locale: string,
opts: {
typeCode: string | undefined;
items: LicenseApplication[];
selected: string[];
setSelected: Dispatch<SetStateAction<string[]>>;
@@ -27,11 +51,12 @@ export function licenseQueueColumns(
label: string,
field: NonNullable<QueueFilter["sortBy"]>,
) => ReactNode;
/** Set for a type-pinned queue; undefined for the mixed All/Mine grids. */
isLogistics?: boolean;
},
): AdvancedColumn<LicenseApplication>[] {
const { typeCode, items, selected, setSelected, allSelected, sortableHeader } =
opts;
return [
const { items, selected, setSelected, allSelected, sortableHeader, isLogistics } = opts;
const columns: AdvancedColumn<LicenseApplication>[] = [
{
header: (
<Checkbox
@@ -72,25 +97,18 @@ export function licenseQueueColumns(
),
},
{
header: sortableHeader(
typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode)
? t("queue.applicant", "Applicant")
: t("queue.company", "Company"),
"companyName",
),
// Header reflects what's actually on screen, not the route: a
// type-pinned queue (`typeCode` set) is always one family, but the
// mixed "All Applications" grid can hold logistics rows and
// certificate/document rows side by side, so no single static label is
// right for the whole column there — "Applicant / Company" covers
// both without claiming a row is one or the other.
header: sortableHeader(companyColumnHeader(t, items, isLogistics), "companyName"),
label: t("queue.company", "Company"),
cell: ({ row }) => (
<Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text>
),
},
{
header: t("queue.tin", "TIN"),
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? "—"}
</Text>
),
},
{
header: t("queue.typeCol", "Type"),
cell: ({ row }) => (
@@ -102,14 +120,19 @@ export function licenseQueueColumns(
{
header: sortableHeader(t("queue.statusCol", "Status"), "status"),
label: t("queue.statusCol", "Status"),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{t(
`queue.statusValues.${row.original.status}`,
STATUS_LABELS[row.original.status],
)}
</Badge>
),
cell: ({ row }) => {
const label = t(
`queue.statusValues.${row.original.status}`,
STATUS_LABELS[row.original.status],
);
return (
<Tooltip label={label} withArrow>
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{label}
</Badge>
</Tooltip>
);
},
},
{
header: sortableHeader(
@@ -139,4 +162,21 @@ export function licenseQueueColumns(
},
},
];
// A type-pinned non-logistics queue never has a TIN to show — a business
// registration number doesn't apply to a certificate/document filed by a
// person — so the column itself is dropped rather than left showing blanks.
if (isLogistics !== false) {
columns.splice(2, 0, {
header: t("queue.tin", "TIN"),
cell: ({ row }) =>
row.original.familyKind === "LOGISTICS_LICENSE" ? (
<Text size="sm" c="dimmed">
{row.original.tinNumber ?? "—"}
</Text>
) : null,
});
}
return columns;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Badge,
@@ -33,7 +33,9 @@ import { useTranslation } from "react-i18next";
import {
STATUS_LABELS,
extractErrorMessage,
familyLabels,
localized,
resolveFamilyKind,
useClaimApplicationMutation,
useGetAllApplicationsQuery,
useGetAssignedToMeQuery,
@@ -43,6 +45,7 @@ import {
useLazyExportApplicationsQuery,
type LicenseApplication,
type LicenseStatus,
type LicenseType,
type QueueFilter,
} from "@ema-platform/api";
import {
@@ -58,6 +61,7 @@ import {
SAVED_VIEWS,
filterFromSearchParams,
readLastView,
savedViewsForFamily,
searchParamsFromFilter,
writeLastView,
type SavedViewId,
@@ -72,6 +76,17 @@ import { licenseQueueActionsColumn } from "./actions";
const PAGE_SIZE = 10;
const SEARCH_DEBOUNCE_MS = 300;
/**
* Statuses only the STANDARD course can reach. A registration goes straight
* review → approval, so offering these in its facet would be offering filters
* that can only ever match nothing.
*/
const STANDARD_ONLY_STATUSES: LicenseStatus[] = [
"UNDER_EVALUATION",
"INSPECTION_PENDING",
"INSPECTION_COMPLETED",
];
const ALL_STATUSES: LicenseStatus[] = [
"SUBMITTED",
"UNDER_REVIEW",
@@ -89,6 +104,24 @@ const ALL_STATUSES: LicenseStatus[] = [
"REJECTED",
];
/** Statuses an application of this type can actually occupy. */
function statusesFor(type: LicenseType | undefined): LicenseStatus[] {
if (!type) return ALL_STATUSES;
return ALL_STATUSES.filter((status) => {
if (
type.workflowProfile === "REGISTRATION" &&
STANDARD_ONLY_STATUSES.includes(status)
) {
return false;
}
if (status === "INSPECTION_PENDING" || status === "INSPECTION_COMPLETED") {
return type.inspectionRequired;
}
if (status === "CERTIFICATE_ISSUED") return type.issuesCertificate;
return true;
});
}
/**
* The officer work pool.
*
@@ -105,8 +138,19 @@ export function LicenseQueuePage() {
const dispatch = useAppDispatch();
const density = useAppSelector((state) => state.preferences.density);
// Type-pinned queues resolve a family straight from the URL, no query
// needed — `resolveFamilyKind` falls back to LOGISTICS_LICENSE for unknown
// keys and undefined for the mixed All/Mine grids, which is the safe
// default (nothing hidden) in both cases.
const isLogistics = typeCode
? resolveFamilyKind(typeCode) === "LOGISTICS_LICENSE"
: undefined;
const visibleViews = savedViewsForFamily(isLogistics !== false);
const [view, setView] = useState<SavedViewId>(
() => (searchParams.get("view") as SavedViewId) || readLastView(),
() =>
(searchParams.get("view") as SavedViewId) ||
(isLogistics === false ? "all" : readLastView()),
);
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
@@ -116,6 +160,22 @@ export function LicenseQueuePage() {
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Non-logistics queues have no unassigned/unclaimed pool (see
// `savedViewsForFamily`), so a stale "unassigned" view — e.g. restored from
// `readLastView()` — must fall back to "all" rather than land on a tab that
// no longer exists. Auto-created BTC requests specifically start at
// PAYMENT_PENDING, outside "mine" too, so "all" is the one view guaranteed
// to show them.
useEffect(() => {
if (
isLogistics === false &&
!searchParams.has("view") &&
view === "unassigned"
) {
setView("all");
}
}, [isLogistics, searchParams, view]);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
[searchParams],
@@ -123,14 +183,37 @@ export function LicenseQueuePage() {
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
const { data: licenseTypes } = useGetLicenseTypesQuery();
const { data: counts } = useGetQueueCountsQuery();
// A `/licence-review/type/:typeCode` deep link pins the type facet.
const pinnedTypeId = useMemo(() => {
if (!typeCode) return undefined;
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
}, [typeCode, licenseTypes]);
// Counts endpoint keys off `key`, the facet off `id` — resolve whichever the
// route or the dropdown set, so the tab badges always count the same rows
// the grid is showing rather than system-wide totals.
const countsKey = useMemo(
() =>
typeCode ??
licenseTypes?.items?.find((type) => type.id === urlFilter.licenseTypeId)
?.key,
[typeCode, urlFilter.licenseTypeId, licenseTypes],
);
const { data: counts } = useGetQueueCountsQuery(countsKey);
const selectedType = useMemo(() => {
const typeId = pinnedTypeId ?? urlFilter.licenseTypeId;
if (!typeId) return undefined;
return licenseTypes?.items?.find((type) => type.id === typeId);
}, [pinnedTypeId, urlFilter.licenseTypeId, licenseTypes]);
// The facet offers what the chosen type can actually reach. With no type
// chosen the queue spans every course, so the full list is correct.
const statusOptions = useMemo(
() => statusesFor(selectedType),
[selectedType],
);
const filter: QueueFilter = useMemo(
() => ({
...activeView.filter,
@@ -234,6 +317,21 @@ export function LicenseQueuePage() {
updateUrl(next, view, 1);
};
/**
* Switching type drops any selected status the new type cannot reach —
* otherwise the facet keeps an invisible filter that matches nothing and the
* grid looks empty for no reason the officer can see.
*/
const changeType = (typeId: string | undefined) => {
const allowed = statusesFor(
licenseTypes?.items?.find((type) => type.id === typeId),
);
setFacet({
licenseTypeId: typeId,
status: urlFilter.status?.filter((s) => allowed.includes(s)),
});
};
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
const dir =
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
@@ -302,15 +400,28 @@ export function LicenseQueuePage() {
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
onClaim: () => {
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
// rather than an error the officer has to read.
if (cursorRow && cursorRow.assignedOfficerId === null)
// Only unclaimed rows on a logistics queue can be claimed; pressing c
// elsewhere is a no-op rather than an error the officer has to read.
if (isLogistics !== false && cursorRow && cursorRow.assignedOfficerId === null)
handleClaim(cursorRow.id);
},
onEscape: () => setSelected([]),
onHelp: () => setHelpOpen(true),
});
// Deep-linked by type (`/licence-review/type/:typeCode`), so the queue
// title/labels read "Certificate applications" for a CoC queue and
// "Document applications" for a Seaman Book queue rather than always
// "Licence applications" — the All/Mine views have no single type and stay
// on the licence-flavoured default, matching today's behaviour.
const queueLabels = familyLabels(resolveFamilyKind(typeCode));
const queueTitle = typeCode
? t("queue.titleByFamily", {
family: queueLabels.typeLabel,
defaultValue: `${queueLabels.typeLabel} applications`,
})
: t("queue.title", "Licence applications");
const allSelected = items.length > 0 && selected.length === items.length;
const sortIcon =
urlFilter.sortDir === "DESC" ? (
@@ -345,17 +456,20 @@ export function LicenseQueuePage() {
const columns: AdvancedColumn<LicenseApplication>[] = useMemo(
() => [
...licenseQueueColumns(t, i18n.language, {
typeCode,
items,
selected,
setSelected,
allSelected,
sortableHeader,
isLogistics,
}),
licenseQueueActionsColumn(t, {
claiming,
onClaim: handleClaim,
onOpen: (id) => navigate(`/licence-review/${id}`),
// Non-logistics applications aren't claimed off a shared queue (see
// `savedViewsForFamily`) — every row opens straight to Review.
claimable: isLogistics !== false,
}),
],
[
@@ -367,7 +481,7 @@ export function LicenseQueuePage() {
allSelected,
items,
claiming,
typeCode,
isLogistics,
],
);
@@ -375,7 +489,7 @@ export function LicenseQueuePage() {
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
<Group justify="space-between" mb="md">
<div>
<Title order={3}>{t("queue.title", "Licence applications")}</Title>
<Title order={3}>{queueTitle}</Title>
{typeCode && (
<Text size="sm" c="dimmed">
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
@@ -416,7 +530,7 @@ export function LicenseQueuePage() {
mb="sm"
>
<Tabs.List>
{SAVED_VIEWS.map((savedView) => (
{visibleViews.map((savedView) => (
<Tabs.Tab
key={savedView.id}
value={savedView.id}
@@ -448,7 +562,7 @@ export function LicenseQueuePage() {
<MultiSelect
label={t("queue.status", "Status")}
placeholder={t("queue.anyStatus", "Any")}
data={ALL_STATUSES.map((s) => ({
data={statusOptions.map((s) => ({
value: s,
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
}))}
@@ -459,14 +573,14 @@ export function LicenseQueuePage() {
/>
{!typeCode && (
<Select
label={t("queue.type", "Licence type")}
label={t("queue.type", "Type")}
placeholder={t("queue.anyType", "Any")}
data={(licenseTypes?.items ?? []).map((type) => ({
value: type.id,
label: localized(type.name, i18n.language) || type.key,
}))}
value={urlFilter.licenseTypeId ?? null}
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
onChange={(v) => changeType(v ?? undefined)}
clearable
w={220}
/>
@@ -562,7 +676,7 @@ export function LicenseQueuePage() {
<AdvancedTable
columns={columns}
data={items}
tableName={t("queue.title", "Licence applications")}
tableName={queueTitle}
itemCount={total}
pageIndex={page - 1}
onPageChange={(pageIndex) => {
@@ -639,17 +753,19 @@ export function LicenseQueuePage() {
>
{t("queue.export", "Export CSV")}
</Button>
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
{isLogistics !== false && (
<RequirePermission
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
hideOnly
>
<Button loading={claiming} onClick={handleBulkClaim}>
{t("queue.bulkClaim", {
count: selected.length,
defaultValue: "Claim {{count}}",
})}
</Button>
</RequirePermission>
)}
</Group>
</Group>
</Paper>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveFamilyKind } from "@ema-platform/api";
describe("resolveFamilyKind", () => {
it("treats person-centric seafarer applications as document queues", () => {
expect(resolveFamilyKind("SEAFARER_REGISTRATION")).toBe("DOCUMENT");
expect(resolveFamilyKind("SEAMAN_BOOK")).toBe("DOCUMENT");
expect(resolveFamilyKind("BTC_BASIC_TRAINING")).toBe("CERTIFICATE");
});
});

View File

@@ -77,6 +77,17 @@ export const SAVED_VIEWS: SavedView[] = [
export const DEFAULT_VIEW: SavedViewId = 'unassigned';
/**
* Non-logistics queues (Seafarer Registration, Seaman Book, BTC, CoC, ...)
* have no unclaimed pool to triage — those applications aren't claimed off a
* shared queue — so the tab that lists it doesn't apply there.
*/
export function savedViewsForFamily(isLogistics: boolean): SavedView[] {
return isLogistics
? SAVED_VIEWS
: SAVED_VIEWS.filter((v) => v.id !== 'unassigned');
}
const LAST_VIEW_KEY = 'ema-backoffice-queue-view';
export function readLastView(): SavedViewId {

View File

@@ -140,7 +140,10 @@ export function LocationForm({
placeholder={t('location.selectType')}
data={allAtLevel.map((lt) => ({
value: lt.id,
label: lt.names[locale],
// Not every locale is filled in on every row, and an option
// with no label is unpickable — fall back to English, then the
// code, which always exists.
label: lt.names[locale] || lt.names.en || lt.code,
}))}
{...form.getInputProps('locationTypeId')}
size="sm"

View File

@@ -11,7 +11,7 @@ import {
Box,
rem,
Center,
useMantineColorScheme,
useComputedColorScheme,
} from '@mantine/core';
import {
IconChevronRight,
@@ -23,6 +23,7 @@ import { useGetLocationsQuery } from '../api/location-api';
import type { Location } from '../types/location';
import { useTranslation } from 'react-i18next';
import { useLocalized } from '@ema-platform/api';
import { PageLoader } from '@ema-platform/ui';
interface LocationTreeProps {
selectedId: string | null;
@@ -57,7 +58,7 @@ function TreeNode({
const isSelected = selectedId === location.id;
const hasChildren =
Array.isArray(location.children) && location.children.length > 0;
const { colorScheme } = useMantineColorScheme();
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
const hoverBg = colorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)';
@@ -177,7 +178,7 @@ export function LocationTree({
if (!search) return tree;
const matches = (loc: Location): boolean => {
const nameMatch = loc.names.en
const nameMatch = (loc.names.en ?? '')
.toLowerCase()
.includes(search.toLowerCase());
const childMatch =
@@ -207,11 +208,7 @@ export function LocationTree({
);
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
return <PageLoader label="Loading Locations…" height={300} />;
}
return (

View File

@@ -26,7 +26,10 @@ export function locationTypeColumns(
},
{
header: t('location.name'),
cell: ({ row }) => row.original.names[locale],
// Falls back like the type Select: a row missing this locale shows its
// English name, then its code, rather than an empty cell.
cell: ({ row }) =>
row.original.names[locale] || row.original.names.en || row.original.code,
},
];
}

View File

@@ -18,6 +18,7 @@ import {
useDeleteLocationTypeMutation,
} from '../../api/location-api';
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import type { LocationType } from '../../types/location';
import { locationTypeColumns } from './columns';
import { locationTypeColumnActions } from './actions';
@@ -68,12 +69,14 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
setShowForm(false);
};
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => {
const handleEdit = (type: LocationType) => {
setEditingId(type.id);
form.setValues({
code: type.code,
namesEn: type.names.en,
namesAm: type.names.am,
// The form's inputs are controlled strings; a locale the row never had
// must edit as empty rather than reading back "undefined".
namesEn: type.names.en ?? '',
namesAm: type.names.am ?? '',
level: type.level,
});
setShowForm(true);

View File

@@ -17,7 +17,7 @@ import {
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
import { notify, useErrorHandler, ModalFooter, PageLoader } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
@@ -103,11 +103,7 @@ export function LocationPage() {
}, [selectedLocation, deleteLocation, closeDeleteModal, handleError]);
if (typesLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
return <PageLoader label="Loading Location Types…" height={400} />;
}
return (

View File

@@ -1,37 +1,24 @@
export interface NamePair {
en: string;
am: string;
}
/**
* Re-exported from the shared contract so both apps read one definition.
*
* See the portal's copy of this file: the two apps each maintained their own
* `Location`/`LocationType` and drifted. The payload types below stay here —
* only the backoffice writes locations.
*/
export type {
Location,
LocationType,
ListResponse,
} from '@ema-platform/api';
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
createdAt: string;
updatedAt: string;
}
import type { Bilingual } from '@ema-platform/api';
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
locationType?: LocationType;
children?: Location[];
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
export type NamePair = Bilingual;
export interface CreateLocationTypePayload {
code: string;
names: NamePair;
names: Bilingual;
level: number;
}
@@ -41,7 +28,7 @@ export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
export interface CreateLocationPayload {
code: string;
names: NamePair;
names: Bilingual;
locationTypeId: string;
parentId?: string | null;
}

View File

@@ -13,7 +13,7 @@ import {
Title,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
@@ -37,11 +37,7 @@ export function LogisticsHeadDashboardPage() {
const table = useServerTable();
if (queue.isLoading || mine.isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
return <PageLoader label="Loading Dashboard…" height={400} />;
}
const unclaimed = queue.data?.items ?? [];

View File

@@ -33,24 +33,28 @@ export function medicalActionsColumn(
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
hideOnly
>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
{row.original.status === 'SUBMITTED' && (
<>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</>
)}
</RequirePermission>
</Group>
),
@@ -85,24 +89,28 @@ export function seaServiceActionsColumn(
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
hideOnly
>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
{row.original.status === 'SUBMITTED' && (
<>
<Button
size="compact-xs"
color="teal"
leftSection={<IconCheck size={14} />}
loading={handlers.ruling}
onClick={() => handlers.onVerify(row.original)}
>
{t('recordVerification.verify', 'Verify')}
</Button>
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<IconX size={14} />}
onClick={() => handlers.onReject(row.original)}
>
{t('recordVerification.reject', 'Reject')}
</Button>
</>
)}
</RequirePermission>
</Group>
),

View File

@@ -1,10 +1,12 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type {
MedicalCertificate,
SeaServiceRecord,
SeafarerProfileSummary,
import {
seaServiceDays,
type MedicalCertificate,
type SeaServiceRecord,
type SeafarerProfileSummary,
type SeafarerRecordStatus,
} from '@ema-platform/api';
export function ownerName(profile?: SeafarerProfileSummary): string {
@@ -16,6 +18,28 @@ export function ownerName(profile?: SeafarerProfileSummary): string {
);
}
const STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'yellow',
VERIFIED: 'teal',
REJECTED: 'red',
};
/** Only meaningful now the queue can show ruled records too. */
function statusColumn<T extends { status: SeafarerRecordStatus }>(
t: TFunction,
): AdvancedColumn<T> {
return {
header: t('recordVerification.columns.status', 'Status'),
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>
),
};
}
export function medicalColumns(
t: TFunction,
showDate: (date: string) => string,
@@ -72,6 +96,7 @@ export function medicalColumns(
</Badge>
),
},
statusColumn<MedicalCertificate>(t),
];
}
@@ -109,6 +134,17 @@ export function seaServiceColumns(
IMO {row.original.imoNumber}
</Text>
)}
{(row.original.vesselType || row.original.flagState || row.original.grossTonnage) && (
<Text size="xs" c="dimmed">
{[
row.original.vesselType,
row.original.flagState,
row.original.grossTonnage ? `${row.original.grossTonnage} GT` : null,
]
.filter(Boolean)
.join(' · ')}
</Text>
)}
</div>
),
},
@@ -127,5 +163,16 @@ export function seaServiceColumns(
</Text>
),
},
{
header: t('recordVerification.columns.days', 'Days'),
label: t('recordVerification.columns.days', 'Days'),
align: 'right',
cell: ({ row }) => (
<Text size="sm" fw={600}>
{seaServiceDays(row.original.engagementDate, row.original.dischargeDate) ?? '—'}
</Text>
),
},
statusColumn<SeaServiceRecord>(t),
];
}

View File

@@ -9,20 +9,19 @@ import {
Loader,
Modal,
Paper,
SegmentedControl,
Stack,
Tabs,
Text,
Textarea,
Title,
} from '@mantine/core';
import { IconEye, IconInbox, IconPaperclip } from '@tabler/icons-react';
import {
IconAnchor,
IconEye,
IconInbox,
IconPaperclip,
IconStethoscope,
} from '@tabler/icons-react';
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
AdvancedTable,
notify,
PdfPreviewModal,
type AdvancedColumn,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
@@ -32,7 +31,11 @@ import {
useVerifyMedicalCertificateMutation,
useVerifySeaServiceRecordMutation,
} from '@ema-platform/api';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import type {
MedicalCertificate,
RecordQueueFilter,
SeaServiceRecord,
} from '@ema-platform/api';
import { medicalColumns, seaServiceColumns, ownerName } from './columns';
import { medicalActionsColumn, seaServiceActionsColumn } from './actions';
@@ -104,6 +107,9 @@ function AttachmentsModal({
{ ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId },
);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const files = useMemo(
() => (attachments ?? []).flatMap((a) => a.files ?? []),
@@ -151,10 +157,9 @@ function AttachmentsModal({
size="compact-xs"
variant="light"
leftSection={<IconEye size={14} />}
component="a"
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
setPreview({ url: file.url as string, title: file.originalName })
}
>
{t('recordVerification.view', 'View')}
</Button>
@@ -168,6 +173,12 @@ function AttachmentsModal({
))
)}
</Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Modal>
);
}
@@ -178,21 +189,29 @@ function AttachmentsModal({
* freezes a record — sea service starts counting toward sea time, a medical
* certificate starts satisfying the submission gate.
*/
export function MedicalVerificationPage() {
export type VerificationKind = 'medical' | 'sea-service';
/**
* One kind per page — the sidebar lists "Sea Service Verification" and
* "Medical Verification" separately, so an officer lands on the queue they
* came for rather than on a tab.
*/
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
const { t } = useTranslation();
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
const {
data: pendingMedical,
isLoading: loadingMedical,
isFetching: fetchingMedical,
refetch: refetchMedical,
} = useGetPendingMedicalQuery();
} = useGetPendingMedicalQuery(filter);
const {
data: pendingSeaService,
isLoading: loadingSeaService,
isFetching: fetchingSeaService,
refetch: refetchSeaService,
} = useGetPendingSeaServiceQuery();
} = useGetPendingSeaServiceQuery(filter);
const [verifyMedical, { isLoading: rulingMedical }] =
useVerifyMedicalCertificateMutation();
@@ -251,6 +270,12 @@ export function MedicalVerificationPage() {
return pendingSeaServiceList.slice(start, start + seaServicePageSize);
}, [pendingSeaServiceList, seaServicePage, seaServicePageSize]);
const changeFilter = useCallback((value: string) => {
setFilter(value as RecordQueueFilter);
setMedicalPage(0);
setSeaServicePage(0);
}, []);
const handleMedicalPageSizeChange = useCallback((size: number) => {
setMedicalPageSize(size);
setMedicalPage(0);
@@ -261,6 +286,34 @@ export function MedicalVerificationPage() {
setSeaServicePage(0);
}, []);
const statusFilter = (
<SegmentedControl
mb="md"
value={filter}
onChange={changeFilter}
data={[
{
value: 'SUBMITTED',
label: t('recordVerification.filter.pending', 'Pending'),
},
{
value: 'VERIFIED',
label: t('recordVerification.filter.verified', 'Accepted'),
},
{
value: 'REJECTED',
label: t('recordVerification.filter.rejected', 'Rejected'),
},
{ value: 'ALL', label: t('recordVerification.filter.all', 'All') },
]}
/>
);
const emptyText =
filter === 'SUBMITTED'
? t('recordVerification.emptyText', 'Nothing awaiting verification.')
: t('recordVerification.emptyTextFiltered', 'No records match this filter.');
const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
() => [
...medicalColumns(t, showDate),
@@ -319,72 +372,62 @@ export function MedicalVerificationPage() {
[rulingSeaService, rule, verifySeaService, showDate, t],
);
const isMedical = kind === 'medical';
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{t('recordVerification.title', 'Record verification')}
{isMedical
? t('recordVerification.medicalTitle', 'Medical Certificate Verification')
: t('recordVerification.seaServiceTitle', 'Sea Service Verification')}
</Title>
<Text size="sm" c="dimmed" mb="md">
{t(
'recordVerification.subtitle',
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
)}
{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>
<Tabs defaultValue="medical" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
{t('recordVerification.tabs.medical', {
count: pendingMedicalList.length,
defaultValue: 'Medical ({{count}})',
})}
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
{t('recordVerification.tabs.seaService', {
count: pendingSeaServiceList.length,
defaultValue: 'Sea Service ({{count}})',
})}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="medical" pt="md">
<AdvancedTable
columns={medicalTableColumns}
data={pagedMedical}
tableName={t('recordVerification.tabs.medical', {
count: pendingMedicalList.length,
defaultValue: 'Medical ({{count}})',
})}
itemCount={pendingMedicalList.length}
pageIndex={medicalPage}
onPageChange={setMedicalPage}
pageSize={medicalPageSize}
onPageSizeChange={handleMedicalPageSizeChange}
refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
/>
</Tabs.Panel>
<Tabs.Panel value="sea-service" pt="md">
<AdvancedTable
columns={seaServiceTableColumns}
data={pagedSeaService}
tableName={t('recordVerification.tabs.seaService', {
count: pendingSeaServiceList.length,
defaultValue: 'Sea Service ({{count}})',
})}
itemCount={pendingSeaServiceList.length}
pageIndex={seaServicePage}
onPageChange={setSeaServicePage}
pageSize={seaServicePageSize}
onPageSizeChange={handleSeaServicePageSizeChange}
refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
/>
</Tabs.Panel>
</Tabs>
{isMedical ? (
<AdvancedTable
columns={medicalTableColumns}
data={pagedMedical}
tableName={t('recordVerification.tabs.medical', {
count: pendingMedicalList.length,
defaultValue: 'Medical ({{count}})',
})}
itemCount={pendingMedicalList.length}
pageIndex={medicalPage}
onPageChange={setMedicalPage}
pageSize={medicalPageSize}
onPageSizeChange={handleMedicalPageSizeChange}
refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
/>
) : (
<AdvancedTable
columns={seaServiceTableColumns}
data={pagedSeaService}
tableName={t('recordVerification.tabs.seaService', {
count: pendingSeaServiceList.length,
defaultValue: 'Sea Service ({{count}})',
})}
itemCount={pendingSeaServiceList.length}
pageIndex={seaServicePage}
onPageChange={setSeaServicePage}
pageSize={seaServicePageSize}
onPageSizeChange={handleSeaServicePageSizeChange}
refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
/>
)}
<AttachmentsModal
opened={Boolean(attachmentModal)}
@@ -436,4 +479,6 @@ export function MedicalVerificationPage() {
);
}
export const SeaServiceVerificationPage = () => <MedicalVerificationPage kind="sea-service" />;
export default MedicalVerificationPage;

View File

@@ -30,6 +30,7 @@ import {
AdvancedTable,
useServerTable,
type AdvancedColumn,
PageLoader,
} from '@ema-platform/ui';
import {
extractErrorMessage,
@@ -43,9 +44,10 @@ import { paymentConfigColumns } from './columns';
import { paymentConfigActionsColumn } from './actions';
/**
* Licence fee configuration.
* Fee configuration — shared across logistics licences, seafarer
* certificates and seafarer/vessel documents alike.
*
* The amounts live on the licence type itself, which is what the workflow
* The amounts live on the license type itself, which is what the workflow
* reads when it raises a payment — so what is edited here is the same value
* the applicant is charged, not a parallel copy of it.
*
@@ -63,11 +65,7 @@ export function PaymentConfigPage() {
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
return <PageLoader label="Loading Payment Configuration…" height={400} />;
}
if (error) {
@@ -75,7 +73,7 @@ export function PaymentConfigPage() {
<Alert
color="red"
icon={<IconAlertTriangle size={18} />}
title={t('paymentConfig.loadError', 'Could not load licence types')}
title={t('paymentConfig.loadError', 'Could not load fee types')}
>
<Text size="sm">{extractErrorMessage(error)}</Text>
</Alert>

View File

@@ -35,6 +35,18 @@
box-shadow: var(--mantine-shadow-xs);
}
[data-mantine-color-scheme='dark'] .list {
background: var(--mantine-color-dark-6);
}
[data-mantine-color-scheme='dark'] .tab {
color: var(--mantine-color-dark-1);
}
[data-mantine-color-scheme='dark'] .tab:hover {
color: var(--mantine-color-white);
}
/* Selectable option card (language + appearance). */
.choice {
border: 1px solid var(--mantine-color-gray-3);
@@ -49,8 +61,21 @@
border-color: var(--mantine-color-gray-4);
}
[data-mantine-color-scheme='dark'] .choice {
border-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme='dark'] .choice:hover {
border-color: var(--mantine-color-dark-3);
}
.choiceActive,
.choiceActive:hover {
border-color: var(--mantine-color-emaPrimary-6);
background: var(--mantine-color-emaPrimary-0);
}
[data-mantine-color-scheme='dark'] .choiceActive,
[data-mantine-color-scheme='dark'] .choiceActive:hover {
background: var(--mantine-color-dark-6);
}

View File

@@ -44,7 +44,7 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { setUser } from '@ema-platform/auth';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -87,7 +87,16 @@ export function ProfilePage() {
const [isSavingPassword, setIsSavingPassword] = useState(false);
// UI-only preferences (no backend wiring yet).
// Two-step verification is wired but parked for the testing phase: turning it
// on makes every sign-in require an OTP. Swap this back for `useTwoFactor()`
// to re-enable it (the login/OTP side already handles `mfaRequired`).
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
// const {
// enabled: twoStepEnabled,
// isLoading: twoStepLoading,
// isSaving: twoStepSaving,
// setEnabled: setTwoStepEnabled,
// } = useTwoFactor();
const [emailNotifications, setEmailNotifications] = useState(true);
// Load the latest profile from the server on mount so the form always
@@ -395,8 +404,9 @@ export function ProfilePage() {
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="lg">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl">
<div>
<Title order={5}>{t('profile.security')}</Title>
@@ -449,7 +459,7 @@ export function ProfilePage() {
backgroundColor:
i <= score
? `var(--mantine-color-${strengthColors[score]}-6)`
: 'var(--mantine-color-gray-2)',
: 'var(--mantine-color-default-border)',
}}
/>
))}
@@ -471,6 +481,15 @@ export function ProfilePage() {
<Switch
checked={twoStepEnabled}
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
// disabled={twoStepLoading || twoStepSaving}
// onChange={async (e) => {
// try {
// await setTwoStepEnabled(e.currentTarget.checked);
// notify.success(t('profile.twoStep.saved'));
// } catch (err) {
// handleError(err);
// }
// }}
/>
</Group>
@@ -484,8 +503,11 @@ export function ProfilePage() {
</Button>
</Group>
</Stack>
</form>
</Paper>
</form>
</Paper>
<ActiveSessions />
</Stack>
</Tabs.Panel>
{/* ---- Preferences ---- */}
@@ -525,7 +547,7 @@ export function ProfilePage() {
) : (
<IconCircle
size={20}
color="var(--mantine-color-gray-4)"
color="var(--mantine-color-dimmed)"
/>
)}
</Group>
@@ -558,7 +580,7 @@ export function ProfilePage() {
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)'
: 'var(--mantine-color-dimmed)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
@@ -600,7 +622,7 @@ export function ProfilePage() {
color={
active
? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)'
: 'var(--mantine-color-dimmed)'
}
/>
<Text fw={600} size="sm" style={{ flex: 1 }}>
@@ -623,7 +645,7 @@ export function ProfilePage() {
<Group align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<IconBell size={20} color="var(--mantine-color-gray-6)" />
<IconBell size={20} color="var(--mantine-color-dimmed)" />
<div>
<Text fw={600}>{t('profile.notifications.title')}</Text>
<Text size="sm" c="dimmed">

View File

@@ -15,7 +15,7 @@ import {
Title,
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, notify, useServerTable, PageLoader } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { extractErrorMessage } from '@ema-platform/api';
import {
@@ -61,11 +61,7 @@ export function ExamAppealsPage() {
};
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
return <PageLoader label="Loading Exam Appeals…" height={400} />;
}
if (isError) {
return (

View File

@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
useListSeafarerDocumentsQuery,
type SeafarerDocumentKind,
type SeafarerDocumentRow,
type SeafarerDocumentStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
/** Statuses an officer filters by — held and withdrawn requests are not work. */
const STATUS_FILTERS = (
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
/**
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
* appear here once the seafarer registration that opened them is approved.
*/
export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind }) {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
kind,
status: status ?? undefined,
search: debouncedSearch || undefined,
take: pageSize,
skip: page * pageSize,
});
const columns: AdvancedColumn<SeafarerDocumentRow>[] = useMemo(
() => [
{
header: 'Request №',
accessorKey: 'requestNumber',
cell: ({ row }) => (
<div>
<Text size="sm" ff="monospace">
{row.original.requestNumber}
</Text>
{row.original.documentNumber && (
<Text size="xs" c="teal" ff="monospace">
{row.original.documentNumber}
</Text>
)}
</div>
),
},
{
header: 'Seafarer',
accessorKey: 'applicant.name',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{row.original.applicant?.name ?? '—'}
</Text>
<Text size="xs" c="dimmed" ff="monospace">
{row.original.applicant?.seafarerNumber ?? '—'}
</Text>
</div>
),
},
{
header: 'Fee',
accessorKey: 'feeAmount',
cell: ({ row }) => (
<Text size="sm">
{row.original.feeAmount !== null ? `${row.original.feeAmount} ${row.original.feeCurrency}` : '—'}
</Text>
),
},
{
header: 'Released',
accessorKey: 'submittedAt',
cell: ({ row }) => (
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => (
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[row.original.status]}>
{SEAFARER_DOCUMENT_STATUS_LABELS[row.original.status]}
</Badge>
),
},
],
[showDate],
);
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>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName={`${SEAFARER_DOCUMENT_KIND_LABELS[kind]} requests`}
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(0);
}}
refresh={refetch}
isLoading={isLoading || isFetching}
emptyText="No requests match."
onRowClick={(row) => navigate(`/seafarer-documents/${row.id}`)}
/>
</Container>
);
}
export const SeamanBookQueuePage = () => <SeafarerDocumentQueuePage kind="SEAMAN_BOOK" />;
export const BtcQueuePage = () => <SeafarerDocumentQueuePage kind="BTC_BASIC_TRAINING" />;

View File

@@ -0,0 +1,276 @@
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 { IconAlertTriangle, IconArrowLeft, IconCheck, IconDownload } from '@tabler/icons-react';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
useConfirmSeafarerDocumentPaymentMutation,
useGetSeafarerDocumentReviewQuery,
useIssueSeafarerDocumentMutation,
useLazyGetSeafarerDocumentReviewDownloadQuery,
useRejectSeafarerDocumentMutation,
useScheduleSeafarerDocumentMutation,
} from '@ema-platform/api';
import { AmharicDatePicker, notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const QUEUE_PATH = { SEAMAN_BOOK: '/seaman-book-queue', BTC_BASIC_TRAINING: '/btc-queue' } as const;
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Table.Tr>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{label}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" component="div">
{value ?? '—'}
</Text>
</Table.Td>
</Table.Tr>
);
}
/** One Seaman Book / BTC request: payment → collection date → issue, or reject. */
export function SeafarerDocumentReviewPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const showDate = useDateDisplayer();
const { data, isLoading, error } = useGetSeafarerDocumentReviewQuery(id, { skip: !id });
const [confirmPayment, { isLoading: confirming }] = useConfirmSeafarerDocumentPaymentMutation();
const [schedule, { isLoading: scheduling }] = useScheduleSeafarerDocumentMutation();
const [issue, { isLoading: issuing }] = useIssueSeafarerDocumentMutation();
const [reject, { isLoading: rejecting }] = useRejectSeafarerDocumentMutation();
const [getDownload, { isFetching: downloading }] = useLazyGetSeafarerDocumentReviewDownloadQuery();
const [scheduleOpen, setScheduleOpen] = useState(false);
const [pickupDate, setPickupDate] = useState('');
const [rejectOpen, setRejectOpen] = useState(false);
const [reason, setReason] = useState('');
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
if (error || !data) {
return (
<Container size="md" py="xl">
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{extractErrorMessage(error, 'Could not load this request.')}
</Alert>
</Container>
);
}
const { document, applicant, payment } = data;
const kindLabel = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const terminal = ['ISSUED', 'REJECTED', 'CANCELLED'].includes(document.status);
async function run(action: () => Promise<unknown>, done: string) {
try {
await action();
notify.success(done);
setScheduleOpen(false);
setRejectOpen(false);
setReason('');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not record the action'));
}
}
async function download() {
try {
const { url } = await getDownload(id).unwrap();
window.open(url, '_blank', 'noopener');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not fetch the PDF'));
}
}
return (
<Container size="lg" py="md">
<Button
variant="subtle"
size="xs"
leftSection={<IconArrowLeft size={14} />}
onClick={() => navigate(QUEUE_PATH[document.kind])}
mb="xs"
>
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}>
<Text size="sm" c="dimmed" ff="monospace">
{document.requestNumber}
</Text>
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
{document.documentNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{document.documentNumber}
</Badge>
)}
</Group>
</div>
<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')}>
Confirm payment
</Button>
</RequirePermission>
)}
{document.status === 'PAYMENT_CONFIRMED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button onClick={() => setScheduleOpen(true)}>Schedule pickup</Button>
</RequirePermission>
)}
{(document.status === 'SCHEDULED' || document.status === 'PAYMENT_CONFIRMED') && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button color="teal" loading={issuing} onClick={() => run(() => issue(id).unwrap(), `${kindLabel} issued`)}>
Issue
</Button>
</RequirePermission>
)}
{document.status === 'ISSUED' && (
<Button variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
)}
{!terminal && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REJECT_APPLICATION]} hideOnly>
<Button color="red" variant="light" onClick={() => setRejectOpen(true)}>
Reject
</Button>
</RequirePermission>
)}
</Group>
</Group>
{document.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
{document.rejectionReason}
</Alert>
)}
<Paper withBorder p="lg" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb={4}>
Seafarer
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Name" value={applicant?.name} />
<Row label="Seafarer №" value={applicant?.seafarerNumber} />
<Row
label="Registration"
value={
applicant?.registrationId ? (
<Link to={`/seafarer-registrations/${applicant.registrationId}`}>
{applicant.registrationNumber}
</Link>
) : (
applicant?.registrationNumber
)
}
/>
</Table.Tbody>
</Table>
</div>
<div>
<Text fw={600} size="sm" mb={4}>
Payment
</Text>
<Table withTableBorder withColumnBorders>
<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} />
<Row label="Paid" value={document.paidAt ? showDate(document.paidAt) : null} />
<Row label="Provider" value={payment?.provider} />
<Row label="Reference" value={document.paymentReference ?? payment?.providerRef} />
</Table.Tbody>
</Table>
</div>
<div>
<Text fw={600} size="sm" mb={4}>
Issuance
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
<Row label="Pickup date" value={document.scheduledIssuanceDate ? showDate(document.scheduledIssuanceDate) : null} />
<Row label="Document №" value={document.documentNumber} />
<Row label="Issued" value={document.issueDate ? showDate(document.issueDate) : null} />
<Row label="Valid until" value={document.expiryDate ? showDate(document.expiryDate) : null} />
</Table.Tbody>
</Table>
</div>
</Stack>
</Paper>
<Modal opened={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule pickup" centered>
<Stack>
<AmharicDatePicker label="Pickup date" dateFormat="date" value={pickupDate} onChange={setPickupDate} required />
<Group justify="flex-end">
<Button variant="default" onClick={() => setScheduleOpen(false)}>
Cancel
</Button>
<Button
disabled={!pickupDate}
loading={scheduling}
onClick={() => run(() => schedule({ id, scheduledDate: pickupDate }).unwrap(), 'Pickup scheduled')}
>
Schedule
</Button>
</Group>
</Stack>
</Modal>
<Modal opened={rejectOpen} onClose={() => setRejectOpen(false)} title={`Reject ${kindLabel}`} centered>
<Stack>
<Textarea label="Reason (shown to the seafarer)" required minRows={3} value={reason} onChange={(e) => setReason(e.currentTarget.value)} data-autofocus />
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button color="red" disabled={reason.trim().length < 3} loading={rejecting} onClick={() => run(() => reject({ id, reason: reason.trim() }).unwrap(), 'Request rejected')}>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default SeafarerDocumentReviewPage;

View File

@@ -0,0 +1,147 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Container, Group, Select, Text, TextInput, Title } from '@mantine/core';
import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_REGISTRATION_STATUS_COLORS,
SEAFARER_REGISTRATION_STATUS_LABELS,
displaySeafarerAnswer,
useListSeafarerRegistrationsQuery,
type SeafarerRegistration,
type SeafarerRegistrationStatus,
} from '@ema-platform/api';
import { AdvancedTable, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const PAGE_SIZE = 10;
const STATUS_FILTERS = (Object.keys(SEAFARER_REGISTRATION_STATUS_LABELS) as SeafarerRegistrationStatus[])
.filter((s) => s !== 'DRAFT')
.map((value) => ({ value, label: SEAFARER_REGISTRATION_STATUS_LABELS[value] }));
export function applicantName(r: Pick<SeafarerRegistration, 'firstName' | 'middleName' | 'lastName'>): string {
return [r.firstName, r.middleName, r.lastName].filter(Boolean).join(' ') || '—';
}
/** Submitted seafarer registrations, oldest first — click a row to review it. */
export function SeafarerRegistrationQueuePage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const [status, setStatus] = useState<SeafarerRegistrationStatus | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const { data, isLoading, isFetching, refetch } = useListSeafarerRegistrationsQuery({
status: status ?? undefined,
search: debouncedSearch || undefined,
take: pageSize,
skip: page * pageSize,
});
const columns: AdvancedColumn<SeafarerRegistration>[] = useMemo(
() => [
{
header: 'Registration №',
accessorKey: 'registrationNumber',
cell: ({ row }) => (
<Text size="sm" ff="monospace">
{row.original.registrationNumber}
</Text>
),
},
{
header: 'Applicant',
accessorKey: 'lastName',
cell: ({ row }) => (
<div>
<Text size="sm" fw={500}>
{applicantName(row.original)}
</Text>
<Text size="xs" c="dimmed">
{row.original.nationalIdNumber ?? '—'}
</Text>
</div>
),
},
{
header: 'Department',
accessorKey: 'department',
cell: ({ row }) => <Text size="sm">{displaySeafarerAnswer('department', row.original.department)}</Text>,
},
{
header: 'Submitted',
accessorKey: 'submittedAt',
cell: ({ row }) => (
<Text size="sm">{row.original.submittedAt ? showDate(row.original.submittedAt) : '—'}</Text>
),
},
{
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>
),
},
],
[showDate],
);
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>
<AdvancedTable
columns={columns}
data={data?.items ?? []}
tableName="Seafarer registrations"
itemCount={data?.total ?? 0}
pageIndex={page}
onPageChange={setPage}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(0);
}}
refresh={refetch}
isLoading={isLoading || isFetching}
emptyText="No registrations match."
onRowClick={(row) => navigate(`/seafarer-registrations/${row.id}`)}
/>
</Container>
);
}
export default SeafarerRegistrationQueuePage;

View File

@@ -0,0 +1,267 @@
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 { 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_LABELS,
displaySeafarerAnswer,
extractErrorMessage,
useApproveSeafarerRegistrationMutation,
useGetSeafarerRegistrationReviewQuery,
useRejectSeafarerRegistrationMutation,
useRequestSeafarerRegistrationChangesMutation,
} from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { applicantName } from './SeafarerRegistrationQueuePage';
type Decision = 'approve' | 'reject' | 'changes';
const DECISION_COPY: Record<Decision, { title: string; label: string; color: string; required: boolean }> = {
approve: { title: 'Approve registration', label: 'Remark (optional)', color: 'teal', required: false },
changes: { title: 'Request corrections', label: 'What must the applicant fix?', color: 'orange', required: true },
reject: { title: 'Reject registration', label: 'Reason (shown to the applicant)', color: 'red', required: true },
};
/** One registration: every answer, every upload, and the officer's actions. */
export function SeafarerRegistrationReviewPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const { data, isLoading, error } = useGetSeafarerRegistrationReviewQuery(id, { skip: !id });
const [approve, { isLoading: approving }] = useApproveSeafarerRegistrationMutation();
const [reject, { isLoading: rejecting }] = useRejectSeafarerRegistrationMutation();
const [requestChanges, { isLoading: requesting }] = useRequestSeafarerRegistrationChangesMutation();
const [decision, setDecision] = useState<Decision | null>(null);
const [text, setText] = useState('');
if (isLoading) {
return (
<Center h={300}>
<Loader />
</Center>
);
}
if (error || !data) {
return (
<Container size="md" py="xl">
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{extractErrorMessage(error, 'Could not load this registration.')}
</Alert>
</Container>
);
}
const { registration, attachments } = data;
// Decided straight off the queue — no claim step.
const canDecide = registration.status === 'SUBMITTED';
const busy = approving || rejecting || requesting;
async function run(action: () => Promise<unknown>, done: string) {
try {
await action();
notify.success(done);
setDecision(null);
setText('');
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not record the decision'));
}
}
function confirmDecision() {
const remark = text.trim();
if (decision === 'approve') {
run(() => approve({ id, remark: remark || undefined }).unwrap(), 'Registration approved — seafarer numbered.');
} else if (decision === 'changes') {
run(() => requestChanges({ id, remark }).unwrap(), 'Sent back for corrections.');
} else if (decision === 'reject') {
run(() => reject({ id, reason: remark }).unwrap(), 'Registration rejected.');
}
}
const slots = SEAFARER_REGISTRATION_DOCUMENTS.filter(
(d) => d.required !== 'passport' || registration.passportNumber,
);
return (
<Container size="lg" py="md">
<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}>
<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>
{registration.seafarerNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{registration.seafarerNumber}
</Badge>
)}
</Group>
</div>
<Group gap="xs">
{canDecide && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REQUEST_ADJUSTMENT]} hideOnly>
<Button variant="default" onClick={() => setDecision('changes')}>
Request corrections
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.REJECT_APPLICATION]} hideOnly>
<Button color="red" variant="light" onClick={() => setDecision('reject')}>
Reject
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.APPROVE_APPLICATION]} hideOnly>
<Button color="teal" onClick={() => setDecision('approve')}>
Approve
</Button>
</RequirePermission>
</>
)}
</Group>
</Group>
{registration.status === 'RESUBMIT_REQUIRED' && (
<Alert color="orange" icon={<IconAlertTriangle size={16} />} title="Awaiting the applicant's corrections" mb="md">
{registration.reviewRemark}
</Alert>
)}
{registration.status === 'REJECTED' && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Rejected" mb="md">
{registration.rejectionReason}
</Alert>
)}
<Paper withBorder p="lg" radius="md">
<Stack gap="md">
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
<div key={section.key}>
<Text fw={600} size="sm" mb={4}>
{section.title}
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{section.fields
.filter((f) => f !== 'passportExpiry' || registration.passportNumber)
.map((field) => (
<Table.Tr key={field}>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{SEAFARER_REGISTRATION_FIELD_LABELS[field]}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{displaySeafarerAnswer(field, registration[field])}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
))}
<Divider />
<Text fw={600} size="sm" mb={4}>
Documents
</Text>
<Table withTableBorder withColumnBorders>
<Table.Tbody>
{slots.map((slot) => {
const file = attachments.find((a) => a.documentKey === slot.key)?.files?.[0];
const required = slot.required === 'passport' ? true : slot.required;
return (
<Table.Tr key={slot.key}>
<Table.Td w="40%">
<Text size="xs" c="dimmed">
{slot.name}
</Text>
</Table.Td>
<Table.Td>
{file ? (
<Group gap="xs">
<Text size="sm">{file.originalName}</Text>
{file.url && (
<Button size="compact-xs" variant="light" component="a" href={file.url} target="_blank" rel="noopener noreferrer">
View
</Button>
)}
</Group>
) : (
<Text size="sm" c={required ? 'red' : 'dimmed'}>
{required ? 'Missing' : '—'}
</Text>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Stack>
</Paper>
<Modal
opened={decision !== null}
onClose={() => setDecision(null)}
title={decision ? DECISION_COPY[decision].title : ''}
centered
>
{decision && (
<Stack>
<Textarea
label={DECISION_COPY[decision].label}
required={DECISION_COPY[decision].required}
minRows={3}
value={text}
onChange={(e) => setText(e.currentTarget.value)}
data-autofocus
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDecision(null)}>
Cancel
</Button>
<Button
color={DECISION_COPY[decision].color}
loading={busy}
disabled={DECISION_COPY[decision].required && text.trim().length < 3}
onClick={confirmDecision}
>
Confirm
</Button>
</Group>
</Stack>
)}
</Modal>
</Container>
);
}
export default SeafarerRegistrationReviewPage;

View File

@@ -1,21 +0,0 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function SeamanBookQueuePage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Seaman Book queue"
description="Seaman Book applications are not connected to the backend yet."
/>
</Container>
);
}
export default SeamanBookQueuePage;

View File

@@ -1,21 +0,0 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationHeadDashboardPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration overview"
description="Vessel registration is not connected to the backend yet, so there are no figures to report."
/>
</Container>
);
}
export default VesselRegistrationHeadDashboardPage;

View File

@@ -109,7 +109,7 @@ function VesselDetailDrawer({
</Text>
</Group>
{loadingIncidents ? (
<Loader size="sm" />
<Loader size="sm" type="oval" />
) : (incidents ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No incidents recorded.

View File

@@ -1,21 +0,0 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
/**
* Placeholder until this feature has a backend.
*
* This page previously rendered hardcoded sample records, which were
* indistinguishable from real ones.
*/
export function VesselRegistrationReportPage() {
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Vessel registration report"
description="Vessel registration is not connected to the backend yet, so there is nothing to report on."
/>
</Container>
);
}
export default VesselRegistrationReportPage;

View File

@@ -0,0 +1,171 @@
import { Badge, Card, Group, SimpleGrid, Text, Tooltip } from '@mantine/core';
import {
IconAlarm,
IconAnchor,
IconCalendarStats,
IconCoin,
IconClockHour4,
IconScale,
IconShip,
IconThumbUp,
type Icon,
} from '@tabler/icons-react';
import type { VesselReport } from '@ema-platform/api';
import {
DASH,
deltaColor,
formatDelta,
formatMoney,
formatNumber,
formatPercent,
} from './report-format';
interface TileProps {
icon: Icon;
label: string;
value: string;
/** The second line: what the headline figure is made of. */
detail?: string;
/** Hover text for anything the headline alone would misrepresent. */
hint?: string;
delta?: { text: string; color: string };
color?: string;
}
function Tile({ icon: TileIcon, label, value, detail, hint, delta, color = 'blue' }: TileProps) {
const card = (
<Card withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.3}>
{label}
</Text>
<TileIcon size={18} stroke={1.6} color={`var(--mantine-color-${color}-6)`} />
</Group>
<Group gap="xs" align="baseline" wrap="nowrap">
<Text fz={26} fw={700} lh={1.1}>
{value}
</Text>
{delta && (
<Badge size="sm" variant="light" color={delta.color}>
{delta.text}
</Badge>
)}
</Group>
{detail && (
<Text size="xs" c="dimmed" mt={6} lh={1.4}>
{detail}
</Text>
)}
</Card>
);
return hint ? (
<Tooltip label={hint} multiline w={260} withArrow>
{card}
</Tooltip>
) : (
card
);
}
/**
* The headline figures.
*
* Two different scopes sit side by side here and the labels have to keep them
* apart: the register totals describe the whole book regardless of the date
* filter, while "new in period" and the pipeline figures answer to it. A tile
* reading "12 vessels" under a one-month filter would be taken for the size of
* the national fleet.
*/
export function KpiTiles({ report }: { report: VesselReport }) {
const { register, fleet, pipeline, certificates, revenue } = report.kpis;
return (
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
<Tile
icon={IconShip}
label="Vessels on the register"
value={formatNumber(register.total)}
detail={`${formatNumber(register.registered)} registered · ${formatNumber(register.suspended)} suspended · ${formatNumber(register.deregistered)} deregistered`}
hint="The whole register. Not affected by the date filter."
/>
<Tile
icon={IconCalendarStats}
color="teal"
label="New in period"
value={formatNumber(register.registeredInPeriod)}
detail={`${formatNumber(register.registeredInPreviousPeriod)} in the previous period`}
delta={{
text: formatDelta(register.changePct),
color: deltaColor(register.changePct),
}}
hint="Vessels entered on the register inside the selected window, against the equally long window before it."
/>
<Tile
icon={IconScale}
color="indigo"
label="Fleet tonnage"
value={formatNumber(fleet.totalGrossTonnage)}
// The coverage count is not decoration: an average over 2 of 300 hulls
// is a different claim from an average over all of them.
detail={`avg ${formatNumber(fleet.avgGrossTonnage, { decimals: 1 })} GT across ${formatNumber(fleet.grossTonnageKnownFor)} of ${formatNumber(register.total)} vessels`}
hint="Gross tonnage is optional on the register, so the average covers only the vessels that declared one."
/>
<Tile
icon={IconAnchor}
color="cyan"
label="Average age"
value={
fleet.avgAgeYears === null
? DASH
: formatNumber(fleet.avgAgeYears, { decimals: 1, suffix: ' yrs' })
}
detail={`${formatNumber(fleet.seaGoing)} sea-going · ${formatNumber(fleet.inlandWaterway)} inland · known for ${formatNumber(fleet.ageKnownFor)}`}
hint="Derived from the build year, which not every entry carries."
/>
<Tile
icon={IconThumbUp}
color="green"
label="Approval rate"
value={formatPercent(pipeline.approvalRatePct)}
detail={`${formatNumber(pipeline.approved)} approved · ${formatNumber(pipeline.rejected)} rejected · ${formatNumber(pipeline.inProgress)} in flight`}
hint="Approved as a share of decided applications. Drafts and applications still in the queue are excluded."
/>
<Tile
icon={IconClockHour4}
color="grape"
label="Processing time"
value={
pipeline.medianProcessingDays === null
? DASH
: formatNumber(pipeline.medianProcessingDays, {
decimals: 1,
suffix: ' d',
})
}
detail={`median · mean ${formatNumber(pipeline.avgProcessingDays, { decimals: 1, suffix: ' d' })} · ${formatNumber(pipeline.avgAdjustmentRounds, { decimals: 2 })} adjustment rounds`}
hint="Submission to decision. Only applications that have been decided are counted."
/>
<Tile
icon={IconAlarm}
color="orange"
label="Certificates expiring"
value={formatNumber(certificates.expiringIn30)}
detail={`within 30 days · ${formatNumber(certificates.expiringIn60)} within 60 · ${formatNumber(certificates.expiringIn90)} within 90`}
hint="Cumulative: a certificate due in a fortnight is counted in all three figures."
/>
<Tile
icon={IconCoin}
color="yellow"
label="Fees collected"
value={formatMoney(revenue.paid, revenue.currency)}
detail={`${formatMoney(revenue.pending, revenue.currency)} outstanding · ${formatNumber(revenue.failedCount)} failed`}
hint={
revenue.mixedCurrency
? 'The register holds payments in more than one currency; this total sums across them.'
: undefined
}
/>
</SimpleGrid>
);
}

View File

@@ -0,0 +1,369 @@
import type { ReactNode } from 'react';
import { Card, Group, SimpleGrid, Text } from '@mantine/core';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { BreakdownItem, ReportGranularity, VesselReport } from '@ema-platform/api';
import {
expiryBands,
formatBucket,
formatNumber,
officerLabel,
sliceColor,
} from './report-format';
// Recharts is unused elsewhere in this repo, so the shared setup lives here
// rather than being repeated per chart: one grid style, one tooltip style, one
// axis style, and a fixed height so the dashboard's rows line up.
const CHART_HEIGHT = 260;
const AXIS = { fontSize: 11, stroke: 'var(--mantine-color-dimmed)' } as const;
const GRID = 'var(--mantine-color-default-border)';
const TOOLTIP_STYLE = {
background: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 8,
fontSize: 12,
} as const;
function ChartCard({
title,
subtitle,
children,
empty,
}: {
title: string;
subtitle?: string;
children: ReactNode;
/** True when there is genuinely nothing to draw — say so, don't draw axes. */
empty?: boolean;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text fw={600} size="sm">
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</Group>
{empty ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
Nothing to show for this filter yet.
</Text>
) : (
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
{children as never}
</ResponsiveContainer>
)}
</Card>
);
}
/**
* A ranked breakdown as horizontal bars.
*
* Horizontal because the labels are flag states, ports and vessel types —
* words, which a vertical axis can show in full instead of rotating them.
*/
function BreakdownBars({
title,
subtitle,
items,
}: {
title: string;
subtitle?: string;
items: BreakdownItem[];
}) {
return (
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
<BarChart data={items} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...AXIS} />
<YAxis type="category" dataKey="label" width={130} {...AXIS} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, _name, entry) => [
countWithShare(value, entry),
'Vessels',
]}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{items.map((item, index) => (
<Cell key={item.key} fill={sliceColor(item, index)} />
))}
</Bar>
</BarChart>
</ChartCard>
);
}
function BreakdownDonut({
title,
subtitle,
items,
}: {
title: string;
subtitle?: string;
items: BreakdownItem[];
}) {
return (
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
<PieChart>
<Pie
data={items}
dataKey="count"
nameKey="label"
innerRadius="52%"
outerRadius="78%"
paddingAngle={2}
>
{items.map((item, index) => (
<Cell key={item.key} fill={sliceColor(item, index)} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name, entry) => [countWithShare(value, entry), name]}
/>
<Legend
verticalAlign="bottom"
height={36}
wrapperStyle={{ fontSize: 11 }}
/>
</PieChart>
</ChartCard>
);
}
/**
* "12 (7.5%)" for a breakdown tooltip.
*
* The share comes off the payload rather than being recomputed: the API's
* percentage is of the whole, including the slices folded into "Other", and
* dividing by what is on screen would quietly disagree with it.
*/
function countWithShare(value: unknown, entry: unknown): string {
const count = typeof value === 'number' ? value : Number(value ?? 0);
const payload = (entry as { payload?: BreakdownItem } | undefined)?.payload;
const share = payload?.percentage ?? 0;
return `${formatNumber(count)} (${formatNumber(share, { decimals: 1 })}%)`;
}
/** True when every bucket in a zero-filled series is empty. */
const allZero = (values: number[]): boolean =>
values.every((value) => value === 0);
export function ReportCharts({ report }: { report: VesselReport }) {
const { timeSeries, breakdowns, kpis } = report;
const granularity: ReportGranularity = report.filters.granularity;
const tick = (bucket: string) => formatBucket(bucket, granularity);
// Recharts types the tooltip label as a ReactNode; only a string is ever a
// bucket key, and anything else is passed through untouched.
const tickLabel = (label: unknown) =>
typeof label === 'string' ? tick(label) : String(label ?? '');
// Expiry counts arrive cumulative; drawn side by side they have to be
// disjoint or the three bars double-count each other.
const expiry = expiryBands(kpis.certificates);
const officers = breakdowns.byOfficer.map((item) => ({
...item,
label: officerLabel(item.key),
}));
return (
<>
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<ChartCard
title="Registrations over time"
subtitle="count and gross tonnage"
empty={allZero(timeSeries.registrations.map((b) => b.count))}
>
<AreaChart data={timeSeries.registrations}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis yAxisId="count" allowDecimals={false} {...AXIS} />
<YAxis yAxisId="tonnage" orientation="right" {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area
yAxisId="count"
type="monotone"
dataKey="count"
name="Vessels"
stroke="var(--mantine-color-blue-6)"
fill="var(--mantine-color-blue-2)"
/>
<Area
yAxisId="tonnage"
type="monotone"
dataKey="grossTonnage"
name="Gross tonnage"
stroke="var(--mantine-color-teal-6)"
fill="transparent"
/>
</AreaChart>
</ChartCard>
<ChartCard
title="Application throughput"
subtitle="decisions land in the month they were made"
empty={allZero(
timeSeries.applications.flatMap((b) => [
b.submitted,
b.approved,
b.rejected,
]),
)}
>
<BarChart data={timeSeries.applications}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar
dataKey="submitted"
name="Submitted"
fill="var(--mantine-color-blue-4)"
/>
{/* Approved and rejected stack: together they are the decisions
made in that bucket, which reads against intake beside it. */}
<Bar
dataKey="approved"
name="Approved"
stackId="decided"
fill="var(--mantine-color-teal-6)"
/>
<Bar
dataKey="rejected"
name="Rejected"
stackId="decided"
fill="var(--mantine-color-red-6)"
/>
</BarChart>
</ChartCard>
<ChartCard
title="Fees collected"
subtitle={kpis.revenue.currency}
empty={allZero(timeSeries.revenue.map((b) => b.amount))}
>
<LineChart data={timeSeries.revenue}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Line
type="monotone"
dataKey="amount"
name={`Paid (${kpis.revenue.currency})`}
stroke="var(--mantine-color-yellow-7)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartCard>
<ChartCard
title="Incidents over time"
empty={allZero(timeSeries.incidents.map((b) => b.count))}
>
<BarChart data={timeSeries.incidents}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
<YAxis allowDecimals={false} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
<Bar
dataKey="count"
name="Incidents"
fill="var(--mantine-color-orange-6)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartCard>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<BreakdownDonut title="Register status" items={breakdowns.byStatus} />
<BreakdownDonut title="Category" items={breakdowns.byCategory} />
<ChartCard
title="Certificate expiry"
subtitle="disjoint bands"
empty={allZero(expiry.map((band) => band.count))}
>
<BarChart data={expiry} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...AXIS} />
<YAxis type="category" dataKey="label" width={110} {...AXIS} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Bar
dataKey="count"
name="Certificates"
fill="var(--mantine-color-orange-6)"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ChartCard>
<BreakdownBars title="Tonnage bands" items={breakdowns.byTonnageBand} />
<BreakdownBars title="Age bands" items={breakdowns.byAgeBand} />
<BreakdownBars title="Length bands" items={breakdowns.byLengthBand} />
<BreakdownBars
title="Flag states"
subtitle="top slices, rest grouped"
items={breakdowns.byFlagState}
/>
<BreakdownBars
title="Ports of registry"
subtitle="top slices, rest grouped"
items={breakdowns.byPortOfRegistry}
/>
<BreakdownBars title="Vessel types" items={breakdowns.byVesselType} />
<BreakdownBars title="Hull material" items={breakdowns.byHullMaterial} />
<BreakdownBars title="Engine type" items={breakdowns.byEngineType} />
<BreakdownBars title="Build decade" items={breakdowns.byBuildDecade} />
<BreakdownBars
title="Application status"
items={breakdowns.byApplicationStatus}
/>
<BreakdownDonut
title="New vs renewal"
items={breakdowns.byApplicationKind}
/>
<BreakdownDonut
title="Incident severity"
subtitle="free text on the register"
items={breakdowns.byIncidentSeverity}
/>
<BreakdownBars
title="Officer workload"
subtitle="user id — names not resolved"
items={officers}
/>
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
Group,
MultiSelect,
SegmentedControl,
TextInput,
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconDownload, IconSearch, IconX } from '@tabler/icons-react';
import type {
ReportGranularity,
VesselCategory,
VesselReport,
VesselReportQuery,
VesselStatus,
} from '@ema-platform/api';
import { optionsFrom } from './report-format';
const CATEGORY_OPTIONS = [
{ value: 'SEA_GOING', label: 'Sea-going' },
{ value: 'INLAND_WATERWAY', label: 'Inland waterway' },
];
const STATUS_OPTIONS = [
{ value: 'REGISTERED', label: 'Registered' },
{ value: 'SUSPENDED', label: 'Suspended' },
{ value: 'DEREGISTERED', label: 'Deregistered' },
];
const GRANULARITY_OPTIONS = [
{ value: 'DAY', label: 'Day' },
{ value: 'WEEK', label: 'Week' },
{ value: 'MONTH', label: 'Month' },
];
interface ReportFiltersProps {
query: VesselReportQuery;
onChange: (next: VesselReportQuery) => void;
/**
* The last successful response. Flag states, ports and vessel types are free
* text on the register with no lookup endpoint behind them, so the only
* honest source for the options is what the register actually holds.
*/
report?: VesselReport;
onExport: () => void;
exporting: boolean;
}
export function ReportFilters({
query,
onChange,
report,
onExport,
exporting,
}: ReportFiltersProps) {
// The search box is local so typing does not refetch on every keystroke; it
// is pushed up on a debounce.
const [search, setSearch] = useState(query.search ?? '');
useEffect(() => {
setSearch(query.search ?? '');
}, [query.search]);
useEffect(() => {
const current = query.search ?? '';
if (search === current) return;
const timer = setTimeout(
() => onChange({ ...query, search: search.trim() || undefined }),
350,
);
return () => clearTimeout(timer);
}, [search, query, onChange]);
const set = <K extends keyof VesselReportQuery>(
key: K,
value: VesselReportQuery[K],
) => onChange({ ...query, [key]: value });
// Mantine 8 works in `YYYY-MM-DD` strings here, which is exactly what the
// API wants — no Date round trip, and no timezone to shift the day.
const range: [string | null, string | null] = [
query.from ?? null,
query.to ?? null,
];
const filtered =
Boolean(query.search) ||
Boolean(query.from) ||
Boolean(query.to) ||
[
query.category,
query.status,
query.flagState,
query.portOfRegistry,
query.vesselType,
].some((values) => (values ?? []).length > 0);
return (
<Card withBorder radius="md" p="md" mb="md">
<Group align="flex-end" gap="sm" wrap="wrap">
<DatePickerInput
type="range"
label="Period"
placeholder="Last 12 months"
value={range}
// Both ends before refetching: a half-set range would send `from`
// with no `to` and redraw the charts against a window the user is
// still in the middle of choosing.
onChange={([from, to]) => {
if (from && !to) return;
onChange({
...query,
from: from ?? undefined,
to: to ?? undefined,
});
}}
clearable
w={250}
/>
<SegmentedControl
size="sm"
data={GRANULARITY_OPTIONS}
value={query.granularity ?? 'MONTH'}
onChange={(value) => set('granularity', value as ReportGranularity)}
/>
<MultiSelect
label="Category"
placeholder="All"
data={CATEGORY_OPTIONS}
value={query.category ?? []}
onChange={(value) => set('category', value as VesselCategory[])}
clearable
w={190}
/>
<MultiSelect
label="Status"
placeholder="All"
data={STATUS_OPTIONS}
value={query.status ?? []}
onChange={(value) => set('status', value as VesselStatus[])}
clearable
w={190}
/>
<MultiSelect
label="Flag state"
placeholder="All"
data={optionsFrom(report?.breakdowns.byFlagState)}
value={query.flagState ?? []}
onChange={(value) => set('flagState', value)}
searchable
clearable
w={190}
/>
<MultiSelect
label="Port of registry"
placeholder="All"
data={optionsFrom(report?.breakdowns.byPortOfRegistry)}
value={query.portOfRegistry ?? []}
onChange={(value) => set('portOfRegistry', value)}
searchable
clearable
w={190}
/>
<MultiSelect
label="Vessel type"
placeholder="All"
data={optionsFrom(report?.breakdowns.byVesselType)}
value={query.vesselType ?? []}
onChange={(value) => set('vesselType', value)}
searchable
clearable
w={190}
/>
<TextInput
label="Search"
placeholder="Name, register №, IMO or owner"
leftSection={<IconSearch size={14} />}
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
w={250}
/>
<Group gap="xs" ml="auto">
{filtered && (
<Button
variant="subtle"
color="gray"
leftSection={<IconX size={14} />}
onClick={() => onChange({})}
>
Clear
</Button>
)}
<Button
variant="light"
leftSection={<IconDownload size={16} />}
loading={exporting}
onClick={onExport}
>
Export CSV
</Button>
</Group>
</Group>
</Card>
);
}

View File

@@ -0,0 +1,211 @@
import { Link } from 'react-router-dom';
import { Badge, Card, Group, SimpleGrid, Table, Text } from '@mantine/core';
import type { ReactNode } from 'react';
import type { VesselReport } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared';
import { expiryUrgency, formatNumber } from './report-format';
/**
* The worklists.
*
* Plain Mantine tables rather than `AdvancedTable`: every one of these is
* already capped server-side by `tableLimit`, so the pagination, search and
* column-picker that component brings would all be controls over a list that
* is only ever ten rows of a much longer one. Each card links out to the screen
* that does own the full list.
*/
function TableCard({
title,
subtitle,
to,
linkLabel,
empty,
head,
children,
}: {
title: string;
subtitle?: string;
to?: string;
linkLabel?: string;
empty: boolean;
head: string[];
children: ReactNode;
}) {
return (
<Card withBorder radius="md" p="md">
<Group justify="space-between" mb="xs" wrap="nowrap">
<div>
<Text fw={600} size="sm">
{title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
)}
</div>
{to && (
<Text component={Link} to={to} size="xs" c="blue">
{linkLabel ?? 'View all'}
</Text>
)}
</Group>
{empty ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
Nothing to show.
</Text>
) : (
<Table highlightOnHover verticalSpacing="xs" fz="sm">
<Table.Thead>
<Table.Tr>
{head.map((column) => (
<Table.Th key={column}>{column}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>{children}</Table.Tbody>
</Table>
)}
</Card>
);
}
export function ReportTables({ report }: { report: VesselReport }) {
const showDate = useDateDisplayer();
const { tables, filters } = report;
return (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="md">
<TableCard
title="Certificates expiring"
subtitle={`within ${filters.expiringWithinDays} days`}
to="/licence-register"
empty={tables.expiringCertificates.length === 0}
head={['Vessel', 'Certificate', 'Expires', 'Days']}
>
{tables.expiringCertificates.map((row) => (
<Table.Tr key={row.vesselId}>
<Table.Td>
<Text size="sm" fw={500}>
{row.name}
</Text>
<Text size="xs" c="dimmed">
{row.registrationNumber}
{row.ownerName ? ` · ${row.ownerName}` : ''}
</Text>
</Table.Td>
<Table.Td>{row.certificateNumber ?? '—'}</Table.Td>
<Table.Td>{showDate(row.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={expiryUrgency(row.daysToExpiry)}
>
{/* 0 is today, and a certificate is valid through its last day. */}
{row.daysToExpiry === 0
? 'Today'
: `${formatNumber(row.daysToExpiry)} d`}
</Badge>
</Table.Td>
</Table.Tr>
))}
</TableCard>
<TableCard
title="Recent registrations"
to="/vessel-registration-queue"
linkLabel="Open register"
empty={tables.recentRegistrations.length === 0}
head={['Vessel', 'Category', 'Flag', 'Registered']}
>
{tables.recentRegistrations.map((row) => (
<Table.Tr key={row.vesselId}>
<Table.Td>
<Text size="sm" fw={500}>
{row.name}
</Text>
<Text size="xs" c="dimmed">
{row.registrationNumber}
{row.vesselType ? ` · ${row.vesselType}` : ''}
</Text>
</Table.Td>
<Table.Td>
{row.category === 'SEA_GOING' ? 'Sea-going' : 'Inland'}
</Table.Td>
<Table.Td>{row.flagState ?? '—'}</Table.Td>
<Table.Td>{showDate(row.registeredAt)}</Table.Td>
</Table.Tr>
))}
</TableCard>
<TableCard
title="Applications in the queue"
subtitle="oldest first"
to="/licence-review/type/VESSEL_REGISTRATION"
linkLabel="Open queue"
empty={tables.pendingApplications.length === 0}
head={['Application', 'Status', 'Submitted', 'Open']}
>
{tables.pendingApplications.map((row) => (
<Table.Tr key={row.applicationNumber}>
<Table.Td>
<Text size="sm" fw={500}>
{row.applicationNumber}
</Text>
<Text size="xs" c="dimmed">
{row.kind === 'RENEWAL' ? 'Renewal' : 'New'}
{row.adjustmentRound > 0
? ` · ${row.adjustmentRound} adjustment round${row.adjustmentRound === 1 ? '' : 's'}`
: ''}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status.replaceAll('_', ' ')}
</Badge>
</Table.Td>
<Table.Td>
{row.submittedAt ? showDate(row.submittedAt) : '—'}
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={row.daysOpen > 30 ? 'red' : row.daysOpen > 14 ? 'orange' : 'gray'}
>
{formatNumber(row.daysOpen)} d
</Badge>
</Table.Td>
</Table.Tr>
))}
</TableCard>
<TableCard
title="Recent incidents"
empty={tables.recentIncidents.length === 0}
head={['Vessel', 'Occurred', 'Severity', 'Reported by']}
>
{tables.recentIncidents.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={500}>
{row.vesselName}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{row.description}
</Text>
</Table.Td>
<Table.Td>{showDate(row.occurredAt)}</Table.Td>
<Table.Td>{row.severity ?? '—'}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={row.reportedByOfficer ? 'blue' : 'gray'}>
{row.reportedByOfficer ? 'Officer' : 'Owner'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</TableCard>
</SimpleGrid>
);
}

View File

@@ -0,0 +1,149 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Alert, Container, Group, Text, Title } from '@mantine/core';
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
import {
ApiErrorAlert,
EmptyState,
PageLoader,
notify,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
downloadAuthedFile,
extractErrorMessage,
useGetVesselReportQuery,
} from '@ema-platform/api';
import type { VesselReportQuery } from '@ema-platform/api';
import { KpiTiles } from './KpiTiles';
import { ReportCharts } from './ReportCharts';
import { ReportFilters } from './ReportFilters';
import { ReportTables } from './ReportTables';
import { queryToSearchParams, searchParamsToQuery } from './report-format';
/**
* The vessel registration dashboard (module 11).
*
* One `GET /vessels/report` call fills the whole screen — KPIs, four time
* series, fifteen breakdowns and four worklists — so the filter bar drives a
* single refetch rather than a dozen independent ones.
*
* Filter state lives in the URL. A filtered dashboard is the thing an officer
* wants to send someone, and rebuilding six selects from a description is not
* how that conversation should go.
*/
export function VesselRegistrationReportPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [exporting, setExporting] = useState(false);
const showDate = useDateDisplayer();
const query: VesselReportQuery = useMemo(
() => searchParamsToQuery(searchParams),
[searchParams],
);
const setQuery = useCallback(
(next: VesselReportQuery) => {
// `replace` so a session of narrowing filters does not bury the page the
// officer arrived from under twenty history entries.
setSearchParams(queryToSearchParams(next), { replace: true });
},
[setSearchParams],
);
const { data: report, isLoading, isFetching, error } = useGetVesselReportQuery(
query,
);
const exportCsv = useCallback(async () => {
setExporting(true);
try {
const params = queryToSearchParams(query).toString();
const { rowCount, truncated } = await downloadAuthedFile(
`/vessels/report/export${params ? `?${params}` : ''}`,
'vessel-register.csv',
);
if (truncated) {
notify.error(
`Export cut off at ${rowCount ?? 'the row limit'} rows. Narrow the filter and export again.`,
);
} else {
notify.success(
`Exported ${rowCount ?? 'the filtered'} vessel${rowCount === 1 ? '' : 's'}.`,
);
}
} catch (err) {
notify.error(extractErrorMessage(err, 'Could not export the register'));
} finally {
setExporting(false);
}
}, [query]);
// Only the very first load blanks the page; a filter change keeps the last
// report on screen so the dashboard does not flash between every tweak.
if (isLoading) return <PageLoader />;
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>
<ReportFilters
query={query}
onChange={setQuery}
report={report}
onExport={exportCsv}
exporting={exporting}
/>
{error && <ApiErrorAlert error={error} title="Could not load the report" />}
{report && (
<>
{report.truncated && (
<Alert
color="yellow"
icon={<IconAlertTriangle size={16} />}
mb="md"
title="Partial figures"
>
The register is larger than this report can scan in one pass, so
every figure below covers only part of it. Narrow the filter for
an exact answer.
</Alert>
)}
{report.kpis.register.total === 0 ? (
<EmptyState
icon={IconShip}
title="No vessels match this filter"
description={
Object.keys(query).length > 0
? 'Nothing on the register matches the current filter. Clear it to see the whole book.'
: 'No vessels have been registered yet. Entries appear here once a registration certificate is issued.'
}
/>
) : (
<div style={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 120ms' }}>
<KpiTiles report={report} />
<div style={{ marginTop: 'var(--mantine-spacing-md)' }}>
<ReportCharts report={report} />
</div>
<ReportTables report={report} />
</div>
)}
</>
)}
</Container>
);
}
export default VesselRegistrationReportPage;

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import type { BreakdownItem, CertificateKpis } from '@ema-platform/api';
import {
DASH,
defaultRange,
deltaColor,
expiryBands,
expiryUrgency,
formatBucket,
formatDelta,
formatNumber,
formatPercent,
officerLabel,
optionsFrom,
queryToSearchParams,
searchParamsToQuery,
sliceColor,
} from './report-format';
const certificates = (partial: Partial<CertificateKpis>): CertificateKpis => ({
total: 0,
active: 0,
expired: 0,
suspended: 0,
expiringIn30: 0,
expiringIn60: 0,
expiringIn90: 0,
missingCertificate: 0,
...partial,
});
const item = (key: string, count = 1): BreakdownItem => ({
key,
label: key,
count,
percentage: 0,
});
describe('formatNumber', () => {
it('renders a dash for a figure the API had no answer for', () => {
expect(formatNumber(null)).toBe(DASH);
expect(formatNumber(undefined)).toBe(DASH);
expect(formatNumber(Number.NaN)).toBe(DASH);
});
it('keeps a real zero', () => {
expect(formatNumber(0)).toBe('0');
});
it('honours decimals and a suffix', () => {
expect(formatNumber(12.345, { decimals: 2 })).toBe('12.35');
expect(formatNumber(7, { suffix: ' GT' })).toBe('7 GT');
});
});
describe('formatPercent / formatDelta', () => {
it('distinguishes no answer from zero', () => {
expect(formatPercent(null)).toBe(DASH);
expect(formatPercent(0)).toBe('0.0%');
expect(formatDelta(null)).toBe(DASH);
});
it('signs a positive change', () => {
expect(formatDelta(12.5)).toBe('+12.5%');
expect(formatDelta(-4)).toBe('-4.0%');
});
it('colours a flat or absent change neutrally', () => {
expect(deltaColor(null)).toBe('gray');
expect(deltaColor(0)).toBe('gray');
expect(deltaColor(1)).toBe('teal');
expect(deltaColor(-1)).toBe('red');
});
});
describe('expiryBands', () => {
it('differences the API cumulative counts into disjoint bands', () => {
expect(
expiryBands(
certificates({ expiringIn30: 4, expiringIn60: 9, expiringIn90: 11 }),
),
).toEqual([
{ label: 'Within 30 days', count: 4 },
{ label: '3160 days', count: 5 },
{ label: '6190 days', count: 2 },
]);
});
it('never draws a negative bar if the counts are not monotonic', () => {
const bands = expiryBands(
certificates({ expiringIn30: 9, expiringIn60: 4, expiringIn90: 4 }),
);
expect(bands.every((band) => band.count >= 0)).toBe(true);
});
});
describe('expiryUrgency', () => {
it('escalates on the boundaries', () => {
expect(expiryUrgency(0)).toBe('red');
expect(expiryUrgency(7)).toBe('red');
expect(expiryUrgency(8)).toBe('orange');
expect(expiryUrgency(30)).toBe('orange');
expect(expiryUrgency(31)).toBe('gray');
});
});
describe('officerLabel', () => {
it('spells out the unassigned bucket and shortens a uuid', () => {
expect(officerLabel('UNASSIGNED')).toBe('Unassigned');
expect(officerLabel('c8d0a151-91e9-433e-b221-db331480b10f')).toBe(
'c8d0a151…',
);
expect(officerLabel('short')).toBe('short');
});
});
describe('sliceColor', () => {
it('mutes the bookkeeping slices and cycles the rest', () => {
const muted = sliceColor(item('OTHER'), 0);
expect(sliceColor(item('Unknown'), 3)).toBe(muted);
expect(sliceColor(item('SEA_GOING'), 0)).not.toBe(muted);
});
it('is stable for a given position', () => {
expect(sliceColor(item('A'), 2)).toBe(sliceColor(item('B'), 2));
});
});
describe('formatBucket', () => {
it('reads a month bucket as a month and a day bucket as a day', () => {
expect(formatBucket('2026-03-01', 'MONTH')).toMatch(/2026/);
expect(formatBucket('2026-03-04', 'DAY')).not.toMatch(/2026/);
});
it('passes an unparseable bucket through rather than printing NaN', () => {
expect(formatBucket('not-a-date', 'MONTH')).toBe('not-a-date');
});
});
describe('defaultRange', () => {
it('spans the twelve months the API defaults to', () => {
const [from, to] = defaultRange(new Date('2026-08-18T00:00:00Z'));
expect(from.toISOString().slice(0, 10)).toBe('2025-08-18');
expect(to.toISOString().slice(0, 10)).toBe('2026-08-18');
});
});
describe('url round trip', () => {
it('drops empty values so an untouched dashboard has a clean link', () => {
const params = queryToSearchParams({
search: '',
category: [],
topN: 15,
});
expect(params.toString()).toBe('topN=15');
});
it('restores the filter state a shared link carries', () => {
const query = {
from: '2026-01-01',
to: '2026-08-18',
granularity: 'WEEK' as const,
status: ['REGISTERED' as const, 'SUSPENDED' as const],
flagState: ['Ethiopia'],
search: 'abay',
topN: 20,
};
expect(searchParamsToQuery(queryToSearchParams(query))).toEqual(query);
});
it('ignores a hand-edited value the API would reject', () => {
const query = searchParamsToQuery(
new URLSearchParams('topN=abc&granularity=YEAR'),
);
expect(query.topN).toBeUndefined();
expect(query.granularity).toBeUndefined();
});
});
describe('optionsFrom', () => {
it('offers the register values but not the bookkeeping slices', () => {
expect(
optionsFrom([item('Ethiopia'), item('Unknown'), item('OTHER')]),
).toEqual(['Ethiopia']);
expect(optionsFrom(undefined)).toEqual([]);
});
});

View File

@@ -0,0 +1,221 @@
import type {
BreakdownItem,
CertificateKpis,
ReportGranularity,
VesselReportQuery,
} from '@ema-platform/api';
/** Nothing measurable is not zero — an em dash says so without lying. */
export const DASH = '—';
/**
* A figure the API may legitimately have no answer for.
*
* `avgGrossTonnage` is null on an empty register and `approvalRatePct` is null
* until something has been decided; rendering either as 0 would report a fleet
* that weighs nothing and a service that approves nobody.
*/
export function formatNumber(
value: number | null | undefined,
options: { decimals?: number; suffix?: string } = {},
): string {
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
const text = value.toLocaleString(undefined, {
minimumFractionDigits: options.decimals ?? 0,
maximumFractionDigits: options.decimals ?? 0,
});
return options.suffix ? `${text}${options.suffix}` : text;
}
export function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined
? DASH
: `${formatNumber(value, { decimals: 1 })}%`;
}
export function formatMoney(value: number, currency: string): string {
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
}
/** A signed delta for the change-vs-previous chip. */
export function formatDelta(value: number | null): string {
if (value === null) return DASH;
const sign = value > 0 ? '+' : '';
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
}
export function deltaColor(value: number | null): string {
if (value === null || value === 0) return 'gray';
return value > 0 ? 'teal' : 'red';
}
/**
* The API's expiry counts are cumulative — a certificate due in eleven days is
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
* them. Stacked side by side in a chart that reads as three separate groups,
* so they are differenced into disjoint bands first.
*/
export function expiryBands(
certificates: CertificateKpis,
): Array<{ label: string; count: number }> {
const { expiringIn30, expiringIn60, expiringIn90 } = certificates;
return [
{ label: 'Within 30 days', count: expiringIn30 },
// Math.max guards against a server that ever answers non-monotonically —
// a negative bar is worse than a zero one.
{ label: '3160 days', count: Math.max(0, expiringIn60 - expiringIn30) },
{ label: '6190 days', count: Math.max(0, expiringIn90 - expiringIn60) },
];
}
/** Red inside a week, orange inside a month, otherwise unremarkable. */
export function expiryUrgency(daysToExpiry: number): string {
if (daysToExpiry <= 7) return 'red';
if (daysToExpiry <= 30) return 'orange';
return 'gray';
}
/**
* Officer ids are IAM uuids, which make useless axis labels. Until the
* dashboard has a name lookup, shorten them and keep "UNASSIGNED" readable.
*/
export function officerLabel(key: string): string {
if (key === 'UNASSIGNED') return 'Unassigned';
return key.length > 8 ? `${key.slice(0, 8)}` : key;
}
/**
* Chart colours, assigned by position so a slice keeps its colour between
* renders. Mantine's palette rather than invented hex codes, so the charts
* follow the theme the rest of the app is built on.
*/
const PALETTE = [
'var(--mantine-color-blue-6)',
'var(--mantine-color-teal-6)',
'var(--mantine-color-orange-6)',
'var(--mantine-color-grape-6)',
'var(--mantine-color-cyan-6)',
'var(--mantine-color-lime-7)',
'var(--mantine-color-pink-6)',
'var(--mantine-color-indigo-6)',
];
const MUTED = 'var(--mantine-color-gray-5)';
/**
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
* always take the muted colour instead of competing with the real categories
* for one of the bright ones.
*/
export function sliceColor(item: BreakdownItem, index: number): string {
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
return PALETTE[index % PALETTE.length];
}
/** Bucket keys are ISO dates; the axis wants something a human reads. */
export function formatBucket(
bucket: string,
granularity: ReportGranularity,
): string {
const date = new Date(bucket);
if (Number.isNaN(date.getTime())) return bucket;
if (granularity === 'MONTH') {
return date.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
return date.toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
}
/** The default window the API applies when none is given: the last 12 months. */
export function defaultRange(now: Date): [Date, Date] {
const from = new Date(
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
);
return [from, now];
}
export const ISO_DAY_LENGTH = 10;
export const toIsoDay = (date: Date): string =>
date.toISOString().slice(0, ISO_DAY_LENGTH);
/**
* The filter state as URL search params, so a filtered dashboard is a
* shareable link rather than something the next person has to rebuild.
*
* Empty arrays and blank strings are dropped rather than serialised, which
* keeps an untouched dashboard's URL clean and lets the API apply its own
* defaults instead of being handed an empty filter to honour.
*/
export function queryToSearchParams(query: VesselReportQuery): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
params.set(key, value.join(','));
} else {
params.set(key, String(value));
}
}
return params;
}
const ARRAY_KEYS = [
'category',
'status',
'flagState',
'portOfRegistry',
'vesselType',
] as const;
const NUMBER_KEYS = ['expiringWithinDays', 'topN', 'tableLimit'] as const;
/** The inverse, for restoring state from a shared link. */
export function searchParamsToQuery(
params: URLSearchParams,
): VesselReportQuery {
const query: Record<string, unknown> = {};
for (const key of ARRAY_KEYS) {
const raw = params.get(key);
if (raw) query[key] = raw.split(',').filter(Boolean);
}
for (const key of NUMBER_KEYS) {
const raw = params.get(key);
// An unparseable number in a hand-edited URL is ignored rather than sent
// on to fail the API's validation pipe.
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
query[key] = Number(raw);
}
}
for (const key of ['from', 'to', 'search'] as const) {
const raw = params.get(key);
if (raw) query[key] = raw;
}
const granularity = params.get('granularity');
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
query.granularity = granularity;
}
return query as VesselReportQuery;
}
/**
* The multi-select options a filter offers, taken from the breakdown the last
* response carried — there is no lookup endpoint for flag states or ports, and
* the register is the only place that knows which ones are in use.
*
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
* filter the register down to.
*/
export function optionsFrom(items: BreakdownItem[] | undefined): string[] {
return (items ?? [])
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
.map((item) => item.key);
}

View File

@@ -55,12 +55,12 @@ export const am: Translations = {
groupSeafarer: "የመርከበኞች አገልግሎት",
groupVessels: "መርከቦች",
groupExaminations: "ፈተናዎች",
groupShared: "የጋራ አገልግሎቶች",
groupAdministration: "አስተዳደር",
groupAccount: "መለያ",
soon: "በቅርቡ",
details: "ዝርዝር",
licenceReview: "የፈቃድ ማመልከቻዎች",
vesselRegistrationHeadDashboard: "የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ",
vesselRegistrationApplicationQueue: "የመርከብ ምዝገባ ወረፋ",
vesselRegistrationQueue: "የመርከብ መዝገብ",
vesselFormBuilder: "የመርከብ ቅጽ መገንቢያ",
@@ -92,7 +92,8 @@ export const am: Translations = {
applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት",
analytics: "ትንታኔ",
medicalVerification: "የሕክምና እና የባህር አገልግሎት ማረጋገጫ",
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
medicalVerification: "የሕክምና ማረጋገጫ",
locations: "አካባቢዎች",
configuration: "ውቅረት",
profile: "መገለጫ",
@@ -106,6 +107,8 @@ export const am: Translations = {
common: {
logout: "ውጣ",
idleLogoutTitle: "ክፍለ ጊዜው አልቋል",
idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።",
profile: "መገለጫ",
settings: "ቅንብሮች",
export: "ላክ",
@@ -465,9 +468,41 @@ export const am: Translations = {
dark: "ሌሊት",
system: "ሲስተም",
},
sessions: {
title: 'ንቁ የመግቢያ ክፍለ ጊዜዎች',
hint: 'በአሁኑ ሰዓት ወደ መለያዎ የገቡ መሣሪያዎች። የማያውቁትን ይሰርዙ።',
columns: {
device: 'የአይ ፒ አድራሻ',
signedIn: 'የገባበት ጊዜ',
expires: 'የሚያበቃበት',
status: 'ሁኔታ',
actions: 'እርምጃዎች',
},
select: 'ይምረጡ',
selectAll: 'ሁሉንም ክፍለ ጊዜዎች ይምረጡ',
selectRow: 'ከ {{device}} የመጣውን ክፍለ ጊዜ ይምረጡ',
thisDevice: 'ይህ መሣሪያ',
revoke: 'ሰርዝ',
cannotRevokeCurrent: 'ይህ አሁን እየተጠቀሙበት ያለው ክፍለ ጊዜ ነው።',
revokeSelected_one: 'የተመረጠውን {{count}} ሰርዝ',
revokeSelected_other: 'የተመረጡትን {{count}} ሰርዝ',
signOutOthers: 'ከሌሎች ቦታዎች ሁሉ ውጣ',
empty: 'ንቁ ክፍለ ጊዜ የለም።',
confirm: {
title: 'ክፍለ ጊዜ ሰርዝ',
one: 'ከ {{device}} የመጣው ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
selected_one: '{{count}} ክፍለ ጊዜ ወዲያውኑ ይወጣል።',
selected_other: '{{count}} ክፍለ ጊዜዎች ወዲያውኑ ይወጣሉ።',
others: 'ሌሎቹ ክፍለ ጊዜዎች በሙሉ ወዲያውኑ ይወጣሉ።',
unknownDevice: 'ይህ አሁን እየተጠቀሙበት ያለውን መሣሪያ ሊያካትት ይችላል።',
},
revoked_one: '{{count}} ክፍለ ጊዜ ተሰርዟል',
revoked_other: '{{count}} ክፍለ ጊዜዎች ተሰርዘዋል',
},
twoStep: {
title: "ባለሁለት ደረጃ ማረጋገጫ",
desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",
saved: "ባለሁለት ደረጃ ማረጋገጫ ተዘምኗል",
},
layout: {
title: "አቀማመጥ",
@@ -812,11 +847,12 @@ export const am: Translations = {
queue: {
title: "የፈቃድ ማመልከቻዎች",
titleByFamily: "{{family}} ማመልከቻዎች",
search: "ፍለጋ",
searchPlaceholder: "ኩባንያ፣ ቲን ወይም ቁጥር",
status: "ሁኔታ",
anyStatus: "ማንኛውም",
type: "የፈቃድ ዓይነት",
type: "ዓይነት",
anyType: "ማንኛውም",
typeCol: "ዓይነት",
statusCol: "ሁኔታ",
@@ -851,6 +887,7 @@ export const am: Translations = {
number: "ማመልከቻ ቁ.",
company: "ኩባንያ",
applicant: "አመልካች",
companyOrApplicant: "አመልካች / ኩባንያ",
tin: "ቲን",
submitted: "የቀረበበት",
sla: "ዕድሜ / የጊዜ ገደብ",
@@ -979,6 +1016,9 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል",
needsDocumentReviews:
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
},
reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች",

View File

@@ -54,6 +54,7 @@ export const en = {
groupSeafarer: 'Seafarer Services',
groupVessels: 'Vessels',
groupExaminations: 'Examinations',
groupShared: 'Shared Services',
groupAdministration: 'Administration',
groupAccount: 'Account',
soon: 'Soon',
@@ -64,7 +65,6 @@ export const en = {
userManagement: 'User Management',
seamanBookQueue: 'Seaman Book Queue',
btcQueue: 'BTC Queue',
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
vesselRegistrationQueue: 'Vessel Register',
vesselFormBuilder: 'Vessel Form Builder',
@@ -90,7 +90,8 @@ export const en = {
applications: 'Applications',
paymentConfig: 'Payment Config',
analytics: 'Analytics',
medicalVerification: 'Medical and Sea Service Verification',
seaServiceVerification: 'Sea Service Verification',
medicalVerification: 'Medical Verification',
locations: 'Locations',
configuration: 'Configuration',
profile: 'Profile',
@@ -104,6 +105,8 @@ export const en = {
common: {
logout: 'Log out',
idleLogoutTitle: 'Session ended',
idleLogoutMessage: 'You were signed out after 15 minutes of inactivity.',
profile: 'Profile',
settings: 'Settings',
export: 'Export',
@@ -463,9 +466,41 @@ export const en = {
dark: 'Dark',
system: 'System',
},
sessions: {
title: 'Active sessions',
hint: 'Devices currently signed in to your account. Revoke any you do not recognise.',
columns: {
device: 'IP address',
signedIn: 'Signed in',
expires: 'Expires',
status: 'Status',
actions: 'Actions',
},
select: 'Select',
selectAll: 'Select all sessions',
selectRow: 'Select session from {{device}}',
thisDevice: 'This device',
revoke: 'Revoke',
cannotRevokeCurrent: 'This is the session you are using now.',
revokeSelected_one: 'Revoke {{count}} selected',
revokeSelected_other: 'Revoke {{count}} selected',
signOutOthers: 'Sign out everywhere else',
empty: 'No active sessions.',
confirm: {
title: 'Revoke session',
one: 'The session from {{device}} will be signed out immediately.',
selected_one: '{{count}} session will be signed out immediately.',
selected_other: '{{count}} sessions will be signed out immediately.',
others: 'Every other session will be signed out immediately.',
unknownDevice: 'This may include the device you are using now.',
},
revoked_one: '{{count}} session revoked',
revoked_other: '{{count}} sessions revoked',
},
twoStep: {
title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.',
saved: 'Two-step verification updated',
},
layout: {
title: 'Layout',
@@ -815,11 +850,15 @@ export const en = {
queue: {
title: 'Licence applications',
// {{family}} is "Certificate"/"Document"/"Licence" — used only on a
// type-scoped queue (`/licence-review/type/:typeCode`), where the whole
// list is one family; the mixed All/Mine views keep the plain title above.
titleByFamily: '{{family}} applications',
search: 'Search',
searchPlaceholder: 'Company, TIN or number',
status: 'Status',
anyStatus: 'Any',
type: 'Licence type',
type: 'Type',
anyType: 'Any',
typeCol: 'Type',
statusCol: 'Status',
@@ -853,6 +892,10 @@ export const en = {
number: 'App #',
company: 'Company',
applicant: 'Applicant',
// Column header when the grid holds both logistics-licence rows (which
// have a company) and certificate/document rows (which have an
// applicant instead) — the mixed "All Applications" queue.
companyOrApplicant: 'Applicant / Company',
tin: 'TIN',
submitted: 'Submitted',
sla: 'Age / SLA',
@@ -981,6 +1024,9 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result',
needsDocumentReviews:
'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
needsDocumentsUploaded: 'No documents uploaded to review yet',
},
reasons: {
incompleteDocuments: 'Incomplete documents',

View File

@@ -1,9 +1,9 @@
import { useCallback, useMemo, useState } from 'react';
import { AppShell } from '@mantine/core';
import { AppShell, Drawer } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { BrandMark, logout } from '@ema-platform/auth';
import { BrandMark, logout, useIdleTimer } from '@ema-platform/auth';
import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui';
@@ -26,26 +26,24 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116;
/**
* A desk left unlocked with a license-review or medical-record screen open is
* the actual threat model here, not a slow token. 15 minutes of no mouse,
* key, scroll, or touch activity signs the officer out automatically.
*/
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
export function BackofficeLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useAppDispatch();
const [opened, { toggle: toggleNav }] = useDisclosure();
const [opened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { permissions: granted, known } = usePermissions();
// TEMPORARY diagnostic — remove once the sidebar is confirmed working.
// eslint-disable-next-line no-console
console.log(
'[NAV] known=', known,
'granted=', granted.length,
'| cookies:', document.cookie.split('; ').map((c) => c.split('=')[0]).filter((n) => n.includes('token')),
'| token tail:', (document.cookie.match(/ema-backoffice-auth-token=([^;]+)/)?.[1] ?? 'NONE').slice(-12),
);
// Badges reflect real pending work. One grouped request on a timer, shared
// by the sidebar and the top bar via the RTK cache.
const { data: counts } = useGetQueueCountsQuery(undefined, {
@@ -89,9 +87,20 @@ export function BackofficeLayout() {
const handleLogout = useCallback(() => {
dispatch(logout());
dispatch(baseApi.util.resetApiState());
navigate("/login");
navigate("/");
}, [dispatch, navigate]);
useIdleTimer(IDLE_TIMEOUT_MS, () => {
notify.info(
t(
'common.idleLogoutMessage',
'You were signed out after 15 minutes of inactivity.',
),
t('common.idleLogoutTitle', 'Session ended'),
);
handleLogout();
});
const segments = location.pathname.split('/').filter(Boolean);
// Label each crumb from the nav item it corresponds to, falling back to a
// readable form of the path segment. Every crumb was previously labelled
@@ -141,7 +150,10 @@ export function BackofficeLayout() {
? {
width: collapsed ? 72 : 264,
breakpoint: "sm",
collapsed: { mobile: !opened },
// Mobile has its own Drawer below — AppShell's built-in mobile
// navbar takes over the full viewport width, which felt like
// it swallowed the page. Always collapsed here on mobile.
collapsed: { mobile: true },
}
: undefined
}
@@ -222,6 +234,34 @@ export function BackofficeLayout() {
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
<CommandPalette sections={sections} />
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */}
{isSidebar && (
<Drawer
opened={opened}
onClose={closeNav}
hiddenFrom="sm"
size="75%"
padding={0}
withCloseButton={false}
>
<AppSidebar
navItems={sections}
collapsed={false}
activePath={location.pathname}
onToggleCollapse={handleToggleCollapse}
onNavigate={(item) => {
go(item);
closeNav();
}}
brandName={t('app.name')}
brandSubtitle={t('app.authority')}
brandLogo={<BrandMark size={32} />}
/>
</Drawer>
)}
</AppShell>
);
}

View File

@@ -37,9 +37,15 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
/**
* The backoffice information architecture.
*
* Six top-level groups, none deeper than one level of nesting. `soon` marks
* Seven top-level groups, none deeper than one level of nesting. `soon` marks
* screens with no backend behind them, so a reviewer can tell at a glance what
* actually works.
*
* `groupLicensing` is scoped strictly to logistics-operator licences (the
* permission a company holds to trade) — Certificate Designer and Payment
* Config serve every family (logistics licences, seafarer certificates,
* seafarer/vessel documents alike), so they sit in `groupShared` instead of
* implying they're licensing-only.
*/
export const NAV_SECTIONS: NavSection[] = [
{
@@ -74,12 +80,6 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconListCheck,
permissions: [P.VIEW_LICENSES],
},
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{ to: '/licence-review/type/PRE_WAIVER', label: 'nav.preWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/POST_WAIVER', label: 'nav.postWaiverQueue', icon: IconShieldOff, permissions: APPLICATION_QUEUE },
{
@@ -89,37 +89,31 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconGauge,
permissions: APPLICATION_QUEUE,
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
],
},
{
label: 'nav.groupSeafarer',
items: [
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers, permissions: [P.VIEW_SEAFARER_REGISTRY] },
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
{ to: '/seafarer-registrations', label: 'nav.seafarerRegistrationQueue', icon: IconId, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/SEAMAN_BOOK', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/BTC_BASIC_TRAINING', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, permissions: APPLICATION_QUEUE },
{ to: '/btc-queue', label: 'nav.btcQueue', icon: IconShieldCheck, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp, permissions: APPLICATION_QUEUE },
{ to: '/sea-service-verification', label: 'nav.seaServiceVerification', icon: IconAnchor, permissions: [P.VERIFY_SEAFARER_RECORDS] },
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart, permissions: [P.VERIFY_SEAFARER_RECORDS] },
],
},
{
label: 'nav.groupVessels',
items: [
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor, permissions: APPLICATION_QUEUE },
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange, permissions: APPLICATION_QUEUE },
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true, permissions: [P.VIEW_VESSEL_REGISTRY] },
],
},
{
@@ -141,6 +135,23 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: '/exam-appeals', label: 'nav.examAppeals', icon: IconGavel, permissions: [P.DECIDE_EXAM_APPEAL] },
],
},
{
label: 'nav.groupShared',
items: [
{
to: '/certificate-designer',
label: 'nav.certificateDesigner',
icon: IconRosetteDiscountCheck,
permissions: [P.VIEW_TEMPLATES],
},
{
to: '/payment-config',
label: 'nav.paymentConfig',
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
],
},
{
label: 'nav.groupAdministration',
items: [

View File

@@ -1,11 +1,14 @@
import { MantineProvider } from '@mantine/core';
import { MantineProvider, mergeThemeOverrides } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { emaTheme } from '@ema-platform/shared';
import { maritimeLoaderTheme } from '@ema-platform/ui';
import type { ReactNode } from 'react';
const theme = mergeThemeOverrides(emaTheme, maritimeLoaderTheme);
export function MantineThemeProvider({ children }: { children: ReactNode }) {
return (
<MantineProvider theme={emaTheme} defaultColorScheme="light">
<MantineProvider theme={theme} defaultColorScheme="light">
<Notifications position="top-right" />
{children}
</MantineProvider>

View File

@@ -1,13 +1,16 @@
import Cookies from 'js-cookie';
import { Navigate } from 'react-router-dom';
import { LandingPage } from '@ema-platform/ui';
import { authStorage } from '@ema-platform/auth';
import { useAuthToken } from '@ema-platform/auth';
/**
* Public `/` — mounts the shared landing page. Backoffice has no /signup
* (enableSignup: false) and no /verify route, so those props are omitted.
* Public `/`. Signed-in visitors skip the landing page entirely and go
* straight to the dashboard. Backoffice has no /signup (enableSignup: false)
* and no /verify route, so those props are omitted.
*/
export function LandingRoute() {
const token = authStorage.getToken() ?? Cookies.get('auth-token');
const token = useAuthToken();
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} />;
if (token) return <Navigate to="/dashboard" replace />;
return <LandingPage primaryHref="/login" />;
}

View File

@@ -22,10 +22,16 @@ import { ProfilePage } from '../features/profile/pages/ProfilePage';
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
import {
MedicalVerificationPage,
SeaServiceVerificationPage,
} from '../features/medical-verification/pages/MedicalVerificationPage';
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
import { SeamanBookQueuePage } from '../features/seaman-book-queue/pages/SeamanBookQueuePage';
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
import { BtcQueuePage, SeamanBookQueuePage } from '../features/seafarer-document-review/pages/SeafarerDocumentQueuePage';
import { SeafarerDocumentReviewPage } from '../features/seafarer-document-review/pages/SeafarerDocumentReviewPage';
import { QuestionPage } from '../features/question/pages/QuestionPage';
import { ExamPage } from '../features/exam/pages/ExamPage';
import { ExamDetailPage } from '../features/exam/pages/ExamDetailPage';
@@ -35,7 +41,6 @@ import { VesselRegistrationQueuePage } from '../features/vessel-registration/pag
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
@@ -71,7 +76,6 @@ const router = createBrowserRouter([
element: <BackofficeLayout />,
children: [
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'vessel-registration-head-dashboard', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationHeadDashboardPage />) },
{ path: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], <ConfigurationPage />) },
@@ -84,9 +88,19 @@ const router = createBrowserRouter([
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
// Seafarer registration is not a licence: own queue, own review.
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
{ path: 'licence-review/type/SEAFARER_REGISTRATION', element: <Navigate to="/seafarer-registrations" replace /> },
// Seaman Book and BTC are not licences: own queues, own review.
{ path: 'seaman-book-queue', element: guard(APPLICATION_QUEUE, <SeamanBookQueuePage />) },
{ path: 'btc-queue', element: guard(APPLICATION_QUEUE, <BtcQueuePage />) },
{ path: 'seafarer-documents/:id', element: guard(APPLICATION_QUEUE, <SeafarerDocumentReviewPage />) },
{ path: 'licence-review/type/SEAMAN_BOOK', element: <Navigate to="/seaman-book-queue" replace /> },
{ path: 'licence-review/type/BTC_BASIC_TRAINING', element: <Navigate to="/btc-queue" replace /> },
{ path: 'questions', element: guard([P.APPROVE_QUESTION, P.AUTHOR_QUESTION], <QuestionPage />) },
{ path: 'exams', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamPage />) },
{ path: 'exams/:id', element: guard([P.MANAGE_EXAMS, P.RECORD_EXAM_ATTENDANCE, P.MANAGE_EXAM_INCIDENTS, P.PUBLISH_EXAM_RESULT], <ExamDetailPage />) },
@@ -95,7 +109,7 @@ const router = createBrowserRouter([
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) },
{ path: 'vessel-registration-queue/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type.

View File

@@ -51,7 +51,7 @@ configureTokenRefresh({
},
onAuthFailure: () => {
store.dispatch(logout());
window.location.href = '/login';
window.location.href = '/';
},
});

View File

@@ -13,6 +13,15 @@ export default defineConfig({
port: 4201,
host: 'localhost',
},
// server: {
// port: 4201,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths()],
resolve: {
@@ -33,4 +42,14 @@ export default defineConfig({
emptyOutDir: true,
reportCompressedSize: true,
},
// Unit tests for the pure helpers behind a screen (formatters, URL state).
// Component tests are deliberately not set up: nothing here renders React,
// so no jsdom environment or setup file is needed.
// test: {
// watch: false,
// globals: true,
// environment: 'node',
// include: ['src/**/*.spec.ts'],
// reporters: ['default'],
// },
});