mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,6 +753,7 @@ export function LicenseQueuePage() {
|
||||
>
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
{isLogistics !== false && (
|
||||
<RequirePermission
|
||||
anyOf={[LICENSE_PERMISSIONS.CLAIM_APPLICATION]}
|
||||
hideOnly
|
||||
@@ -650,6 +765,7 @@ export function LicenseQueuePage() {
|
||||
})}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -33,6 +33,8 @@ export function medicalActionsColumn(
|
||||
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
|
||||
hideOnly
|
||||
>
|
||||
{row.original.status === 'SUBMITTED' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
@@ -51,6 +53,8 @@ export function medicalActionsColumn(
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
),
|
||||
@@ -85,6 +89,8 @@ export function seaServiceActionsColumn(
|
||||
anyOf={[LICENSE_PERMISSIONS.VERIFY_SEAFARER_RECORDS]}
|
||||
hideOnly
|
||||
>
|
||||
{row.original.status === 'SUBMITTED' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
@@ -103,6 +109,8 @@ export function seaServiceActionsColumn(
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,35 +372,28 @@ 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">
|
||||
{isMedical ? (
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
@@ -364,9 +410,7 @@ export function MedicalVerificationPage() {
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={seaServiceTableColumns}
|
||||
data={pagedSeaService}
|
||||
@@ -383,8 +427,7 @@ export function MedicalVerificationPage() {
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<AttachmentsModal
|
||||
opened={Boolean(attachmentModal)}
|
||||
@@ -436,4 +479,6 @@ export function MedicalVerificationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export const SeaServiceVerificationPage = () => <MedicalVerificationPage kind="sea-service" />;
|
||||
|
||||
export default MedicalVerificationPage;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,6 +404,7 @@ export function ProfilePage() {
|
||||
|
||||
{/* ---- Security ---- */}
|
||||
<Tabs.Panel value="security" pt="md">
|
||||
<Stack gap="lg">
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
|
||||
<Stack gap="xl">
|
||||
@@ -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>
|
||||
|
||||
@@ -486,6 +505,9 @@ export function ProfilePage() {
|
||||
</Stack>
|
||||
</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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: '31–60 days', count: 5 },
|
||||
{ label: '61–90 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||
{ label: '61–90 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);
|
||||
}
|
||||
@@ -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: "ያልተሟሉ ሰነዶች",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,7 +51,7 @@ configureTokenRefresh({
|
||||
},
|
||||
onAuthFailure: () => {
|
||||
store.dispatch(logout());
|
||||
window.location.href = '/login';
|
||||
window.location.href = '/';
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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'],
|
||||
// },
|
||||
});
|
||||
|
||||
554
apps/e2e/src/seafarer-registration.spec.ts
Normal file
554
apps/e2e/src/seafarer-registration.spec.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
Applicant,
|
||||
newApplicant,
|
||||
signUp,
|
||||
verifyOtpIfPrompted,
|
||||
} from './support/applicant';
|
||||
import { deleteApplicant, sql, sqlValue } from './support/db';
|
||||
import {
|
||||
approveRegistration,
|
||||
runDocumentWorkflow,
|
||||
runRegistrationWorkflow,
|
||||
} from './support/workflow';
|
||||
import { act, logInAsOfficer, openInQueue } from './support/officer';
|
||||
|
||||
/**
|
||||
* Seafarer registration, applicant through to approval.
|
||||
*
|
||||
* Registration is its own table and its own endpoints — not a licence
|
||||
* application. Submitting one requests a Seaman Book and a BTC by default
|
||||
* (`seafarer_documents`); approval stamps a permanent number on the profile,
|
||||
* activates the seafarer record, records the medical certificate, and releases
|
||||
* those two requests to payment. Those effects only fire at approval, so
|
||||
* nothing short of driving a registration into an officer's hands exercises
|
||||
* them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gives the applicant the profile the registration form prefills from.
|
||||
*
|
||||
* Written directly rather than through `/profile`: these tests are about the
|
||||
* registration, and the profile form is a separate surface with its own
|
||||
* tests — driving its tabs here made every registration test fail whenever
|
||||
* that form changed. The rows are what the Address and Maritime tabs save.
|
||||
*/
|
||||
function completeProfile(applicant: Applicant): void {
|
||||
const email = applicant.email.replace(/'/g, "''");
|
||||
sql(`
|
||||
WITH addr AS (
|
||||
INSERT INTO addresses (id_type, id_number, nationality, primary_phone_number, email)
|
||||
VALUES ('NID', 'FYD1234567890', 'Ethiopian', '${applicant.phoneNumber}', '${email}')
|
||||
RETURNING id
|
||||
)
|
||||
UPDATE profiles p
|
||||
SET first_name = '${applicant.firstName}',
|
||||
middle_name = '${applicant.middleName}',
|
||||
last_name = '${applicant.lastName}',
|
||||
gender = 'MALE',
|
||||
dob = '1995-04-12',
|
||||
marital_status = 'SINGLE',
|
||||
address_id = (SELECT id FROM addr)
|
||||
WHERE p.user_id = (SELECT id FROM iam.users WHERE email = '${email}')
|
||||
`);
|
||||
}
|
||||
|
||||
function profileIdOf(email: string): string | null {
|
||||
return sqlValue(`
|
||||
SELECT p.id FROM profiles p JOIN iam.users u ON u.id = p.user_id
|
||||
WHERE u.email = '${email}'
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
await page.getByRole('textbox', { name: label }).click();
|
||||
// Matched on text, not accessible name: CountrySelect renders each option's
|
||||
// label inside a nested element, which leaves the option itself unnamed.
|
||||
await page.getByRole('option').filter({ hasText: option }).first().click();
|
||||
}
|
||||
|
||||
/** Drives the AmharicDatePicker's own UI — a native `value` write bypasses `onChange`. */
|
||||
async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
const [year, month, day] = iso.split('-').map(Number);
|
||||
await page.getByRole('textbox', { name: label }).click();
|
||||
const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
await calendar.locator('select').last().selectOption(String(year));
|
||||
await calendar.locator('select').first().selectOption({ index: month - 1 });
|
||||
const cell = calendar
|
||||
.getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
.first();
|
||||
await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
await cell.click();
|
||||
await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Signs up, declares seafarer operations, and fills the profile. */
|
||||
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
await page
|
||||
.getByRole('checkbox', { name: /seafarer registration/i })
|
||||
.first()
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
await expect(page).toHaveURL(/\/seafarer-registration/, { timeout: 30_000 });
|
||||
// The portal has provisioned the profile by now (the page read it); fill it.
|
||||
await expect.poll(() => profileIdOf(applicant.email), { timeout: 30_000 }).toBeTruthy();
|
||||
completeProfile(applicant);
|
||||
}
|
||||
|
||||
test.describe('seafarer registration', () => {
|
||||
let applicant: Applicant;
|
||||
|
||||
test.beforeEach(() => {
|
||||
applicant = newApplicant('seafarer');
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
deleteApplicant(applicant.email);
|
||||
});
|
||||
|
||||
test('selecting seafarer opens the registration form', async ({ page }) => {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
await page
|
||||
.getByRole('checkbox', { name: /seafarer registration/i })
|
||||
.first()
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
|
||||
// Straight to the form they came for — its own page, not the licence wizard.
|
||||
await expect(page).toHaveURL(/\/seafarer-registration$/, { timeout: 30_000 });
|
||||
await expect(page.getByRole('heading', { name: /seafarer registration/i })).toBeVisible();
|
||||
|
||||
// The old licence-wizard link lands in the same place.
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
await expect(page).toHaveURL(/\/seafarer-registration$/, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('opening the form creates the draft up front', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
|
||||
// The draft exists before anything is filled in, so uploads have an owner
|
||||
// and closing the browser mid-form loses nothing.
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
expect(number).toMatch(/^SFR/);
|
||||
expect(statusOf(number)).toBe('DRAFT');
|
||||
|
||||
// Prefilled from the profile the applicant just completed.
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(applicant.firstName);
|
||||
});
|
||||
|
||||
test('an incomplete registration is refused with what is missing', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const id = idOf(await waitForRegistration(applicant.email));
|
||||
|
||||
const [code] = await runRegistrationWorkflow(
|
||||
id,
|
||||
[{ path: 'submit', expectFailure: true }],
|
||||
applicant,
|
||||
);
|
||||
expect(code).toBe(400);
|
||||
});
|
||||
|
||||
test('an officer can return a registration for correction and take it back', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
expect(statusOf(number)).toBe('SUBMITTED');
|
||||
|
||||
await runRegistrationWorkflow(id, [
|
||||
{ path: 'request-changes', data: { remark: 'Medical certificate is illegible.' } },
|
||||
]);
|
||||
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
|
||||
|
||||
// Nothing can be decided while it is with the applicant.
|
||||
const [refused] = await runRegistrationWorkflow(id, [
|
||||
{ path: 'approve', expectFailure: true },
|
||||
]);
|
||||
expect(refused).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// A resubmission returns to the queue.
|
||||
await runRegistrationWorkflow(id, [{ path: 'submit' }], applicant);
|
||||
expect(statusOf(number)).toBe('SUBMITTED');
|
||||
});
|
||||
|
||||
test('an officer can reject a registration with a reason', async ({ page }) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await runRegistrationWorkflow(id, [
|
||||
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
|
||||
]);
|
||||
|
||||
expect(statusOf(number)).toBe('REJECTED');
|
||||
// A rejection is terminal: nothing is numbered, and the documents requested
|
||||
// with the registration are withdrawn rather than left waiting.
|
||||
expect(seafarerNumberOf(applicant.email)).toBeNull();
|
||||
expect(documentsOf(applicant.email).map((r) => r[1])).toEqual(['CANCELLED', 'CANCELLED']);
|
||||
});
|
||||
|
||||
test('approval numbers the profile and opens both child applications', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await approveRegistration(id);
|
||||
|
||||
expect(statusOf(number)).toBe('APPROVED');
|
||||
|
||||
const profile = sql(`
|
||||
SELECT p.seafarer_number, p.seafarer_status, p.seafarer_department
|
||||
FROM profiles p
|
||||
JOIN iam.users u ON u.id = p.user_id
|
||||
WHERE u.email = '${applicant.email}'
|
||||
`);
|
||||
expect(profile[0][0]).toBeTruthy();
|
||||
expect(profile[0][1]).toBe('ACTIVE');
|
||||
expect(profile[0][2]).toBe('DECK');
|
||||
|
||||
// The medical details become a verified certificate on the profile.
|
||||
expect(
|
||||
sqlValue(`
|
||||
SELECT m.status FROM medical_certificates m
|
||||
JOIN profiles p ON p.id = m.profile_id
|
||||
JOIN iam.users u ON u.id = p.user_id
|
||||
WHERE u.email = '${applicant.email}'
|
||||
`),
|
||||
).toBe('VERIFIED');
|
||||
|
||||
// The applicant is not made to apply twice more for the documents that
|
||||
// prove what they have just been told: both were requested at submission
|
||||
// and are now released to payment, each with its own fee.
|
||||
const documents = documentsOf(applicant.email);
|
||||
expect(documents.map((r) => [r[0], r[1], r[2]])).toEqual([
|
||||
['BTC_BASIC_TRAINING', 'PAYMENT_PENDING', '250.00'],
|
||||
['SEAMAN_BOOK', 'PAYMENT_PENDING', '400.00'],
|
||||
]);
|
||||
|
||||
// The portal now shows the outcome rather than a form.
|
||||
await page.goto('/seafarer-registration');
|
||||
await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('a re-fired approval renumbers nobody and opens no second pair', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
await approveRegistration(id);
|
||||
const first = seafarerNumberOf(applicant.email);
|
||||
|
||||
const [code] = await runRegistrationWorkflow(id, [
|
||||
{ path: 'approve', expectFailure: true },
|
||||
]);
|
||||
expect(code).toBeGreaterThanOrEqual(400);
|
||||
expect(seafarerNumberOf(applicant.email)).toBe(first);
|
||||
expect(documentsOf(applicant.email)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a released document is paid, scheduled and issued from its own queue', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id, applicant);
|
||||
// Requested with the submission, held until approval.
|
||||
expect(documentsOf(applicant.email).map((r) => r[1])).toEqual([
|
||||
'AWAITING_REGISTRATION',
|
||||
'AWAITING_REGISTRATION',
|
||||
]);
|
||||
await approveRegistration(id);
|
||||
|
||||
const btc = documentsOf(applicant.email).find((r) => r[0] === 'BTC_BASIC_TRAINING');
|
||||
if (!btc) throw new Error('No BTC request opened');
|
||||
const btcId = btc[3];
|
||||
|
||||
// Nothing can be issued before the fee is settled.
|
||||
const [refused] = await runDocumentWorkflow(btcId, [{ path: 'issue', expectFailure: true }]);
|
||||
expect(refused).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// The test bypass settles the fee as the applicant; PAYMENT_AUTO_CONFIRM in
|
||||
// the suite's environment confirms it without a finance officer.
|
||||
await runDocumentWorkflow(btcId, [{ path: 'payments/bypass' }], applicant);
|
||||
expect(documentStatus(btcId)).toBe('PAYMENT_CONFIRMED');
|
||||
|
||||
await runDocumentWorkflow(btcId, [
|
||||
{ path: 'schedule-issuance', data: { scheduledDate: '2026-09-01' } },
|
||||
]);
|
||||
expect(documentStatus(btcId)).toBe('SCHEDULED');
|
||||
|
||||
await runDocumentWorkflow(btcId, [{ path: 'issue' }]);
|
||||
const issued = sql(`
|
||||
SELECT status, document_number, expiry_date, verification_code
|
||||
FROM seafarer_documents WHERE id = '${btcId}'
|
||||
`)[0];
|
||||
expect(issued[0]).toBe('ISSUED');
|
||||
expect(issued[1]).toMatch(/^BTC/);
|
||||
expect(issued[2]).toBeTruthy();
|
||||
expect(issued[3]).toBeTruthy();
|
||||
|
||||
// The portal shows the number and offers the PDF.
|
||||
await page.goto('/basic-training-certificate');
|
||||
await expect(page.getByText(issued[1], { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole('button', { name: /download pdf/i })).toBeVisible();
|
||||
|
||||
// Issued once: a second issue is refused and the number stands.
|
||||
const [again] = await runDocumentWorkflow(btcId, [{ path: 'issue', expectFailure: true }]);
|
||||
expect(again).toBeGreaterThanOrEqual(400);
|
||||
expect(documentStatus(btcId)).toBe('ISSUED');
|
||||
});
|
||||
|
||||
test('the form can be completed in the browser and approved from the backoffice', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
// Uploads need object storage, which this suite does not stand up; the
|
||||
// evidence rows go in directly and the page is reopened so it sees them.
|
||||
insertDocuments(id);
|
||||
await page.reload();
|
||||
|
||||
// Step 1 — Identity Details: prefilled from the profile, nothing to type.
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(applicant.firstName);
|
||||
await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 2 — Applicant Details.
|
||||
await page.getByLabel('Place of Birth').fill('Addis Ababa');
|
||||
await pick(page, 'Department', /deck/i);
|
||||
await pick(page, 'City', /addis ababa/i);
|
||||
await pick(page, 'Sub-City', /arada/i);
|
||||
await pick(page, 'Hair Colour', /black/i);
|
||||
await pick(page, 'Eye Colour', /brown/i);
|
||||
await page.getByLabel('Height (cm)').fill('172');
|
||||
await page.getByLabel('Weight (kg)').fill('68');
|
||||
await page.getByLabel('Certificate Number').fill('MED-2026-001');
|
||||
await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic');
|
||||
await pickDate(page, 'Issue Date', '2026-01-15');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 3 — Emergency Contact.
|
||||
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
|
||||
await page.getByLabel('Relationship').fill('Sister');
|
||||
await page.getByLabel('Phone Number').fill('+251911222333');
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 4 — Documents: all four required slots show as uploaded.
|
||||
await expect(page.getByText('uploaded')).toHaveCount(4);
|
||||
await page.getByRole('button', { name: /^continue$/i }).click();
|
||||
|
||||
// Step 5 — Review: the answers typed above, then the declaration.
|
||||
await expect(page.getByText('Addis Marine Clinic')).toBeVisible();
|
||||
await page.getByRole('checkbox', { name: /i declare/i }).check();
|
||||
await page.getByRole('button', { name: /submit registration/i }).click();
|
||||
await expect(page.getByText(/with the authority for review/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(statusOf(number)).toBe('SUBMITTED');
|
||||
|
||||
// What was typed is what was stored — typed columns, no form blob.
|
||||
const stored = sql(`
|
||||
SELECT place_of_birth, department, hair_color, height_cm, medical_issue_date,
|
||||
emergency_contact_name
|
||||
FROM seafarer_registrations WHERE id = '${id}'
|
||||
`)[0];
|
||||
expect(stored).toEqual([
|
||||
'Addis Ababa', 'DECK', 'BLACK', '172.0', '2026-01-15', 'Almaz Tesfaye',
|
||||
]);
|
||||
|
||||
// The officer's side, through its own queue and review screen.
|
||||
await logInAsOfficer(page);
|
||||
await openInQueue(page, number);
|
||||
await expect(page.getByRole('heading', { name: applicant.name })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// No claim step: the decision is taken straight off the queue.
|
||||
await act(page, /^approve$/i, /^confirm$/i);
|
||||
await expect(page.getByText('Approved', { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(statusOf(number)).toBe('APPROVED');
|
||||
expect(seafarerNumberOf(applicant.email)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a registered seafarer cannot start a second registration', async ({
|
||||
page,
|
||||
}) => {
|
||||
await readyApplicant(page, applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
const number = await waitForRegistration(applicant.email);
|
||||
|
||||
await submit(idOf(number), applicant);
|
||||
await approveRegistration(idOf(number));
|
||||
|
||||
// "Start" returns the approved registration rather than opening another.
|
||||
await runRegistrationWorkflow(idOf(number), [], applicant);
|
||||
await page.goto('/seafarer-registration');
|
||||
await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(
|
||||
sqlValue(`
|
||||
SELECT count(*) FROM seafarer_registrations r
|
||||
JOIN iam.users u ON u.id = r.applicant_user_id
|
||||
WHERE u.email = '${applicant.email}'
|
||||
`),
|
||||
).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/** Waits for the draft the form creates on open, and returns its number. */
|
||||
async function waitForRegistration(email: string, timeoutMs = 30_000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const found = sqlValue(`
|
||||
SELECT r.registration_number
|
||||
FROM seafarer_registrations r
|
||||
JOIN iam.users u ON u.id = r.applicant_user_id
|
||||
WHERE u.email = '${email}'
|
||||
ORDER BY r.created_at DESC LIMIT 1
|
||||
`);
|
||||
if (found) return found;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`No seafarer registration appeared for ${email}`);
|
||||
}
|
||||
|
||||
function idOf(registrationNumber: string): string {
|
||||
const id = sqlValue(`
|
||||
SELECT id FROM seafarer_registrations
|
||||
WHERE registration_number = '${registrationNumber}'
|
||||
`);
|
||||
if (!id) throw new Error(`No registration ${registrationNumber}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function statusOf(registrationNumber: string): string | null {
|
||||
return sqlValue(`
|
||||
SELECT status FROM seafarer_registrations
|
||||
WHERE registration_number = '${registrationNumber}'
|
||||
`);
|
||||
}
|
||||
|
||||
function seafarerNumberOf(email: string): string | null {
|
||||
return sqlValue(`
|
||||
SELECT p.seafarer_number FROM profiles p
|
||||
JOIN iam.users u ON u.id = p.user_id
|
||||
WHERE u.email = '${email}'
|
||||
`);
|
||||
}
|
||||
|
||||
/** The Seaman Book / BTC requests opened for this applicant: kind, status, fee, id. */
|
||||
function documentsOf(email: string): string[][] {
|
||||
return sql(`
|
||||
SELECT d.kind, d.status, d.fee_amount, d.id
|
||||
FROM seafarer_documents d
|
||||
JOIN iam.users u ON u.id = d.applicant_user_id
|
||||
WHERE u.email = '${email}'
|
||||
ORDER BY d.kind::text
|
||||
`);
|
||||
}
|
||||
|
||||
function documentStatus(documentId: string): string | null {
|
||||
return sqlValue(`SELECT status FROM seafarer_documents WHERE id = '${documentId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the draft's answers and evidence directly, so it can be submitted.
|
||||
*
|
||||
* These tests are about the workflow and its approval effects, not the form's
|
||||
* fields. The answers go in as one UPDATE and the evidence as attachment rows
|
||||
* — a row with a storage key is exactly as complete as an upload to the
|
||||
* submission check, without requiring object storage to be reachable.
|
||||
*/
|
||||
function fillForSubmission(registrationId: string): void {
|
||||
fillAnswers(registrationId);
|
||||
insertDocuments(registrationId);
|
||||
}
|
||||
|
||||
function fillAnswers(registrationId: string): void {
|
||||
const locationId = sqlValue(`
|
||||
SELECT l.id FROM iam.locations l
|
||||
JOIN iam.location_types lt ON lt.id = l.location_type_id
|
||||
WHERE lt.code = 'SUBCITY' LIMIT 1
|
||||
`);
|
||||
if (!locationId) {
|
||||
throw new Error('No SUBCITY location seeded — run the location seed.');
|
||||
}
|
||||
|
||||
sql(`
|
||||
UPDATE seafarer_registrations SET
|
||||
first_name = 'Dawit', middle_name = 'Bekele', last_name = 'Tesfaye',
|
||||
gender = 'MALE', date_of_birth = '1995-04-12', marital_status = 'SINGLE',
|
||||
nationality = 'Ethiopian', national_id_number = 'FYD1234567890',
|
||||
place_of_birth = 'Addis Ababa', department = 'DECK',
|
||||
location_id = '${locationId}', permanent_address = 'Bole, Addis Ababa',
|
||||
emergency_contact_name = 'Almaz Tesfaye', emergency_contact_relationship = 'Sister',
|
||||
emergency_contact_phone = '+251911222333',
|
||||
hair_color = 'BLACK', eye_color = 'BROWN', height_cm = 172, weight_kg = 68,
|
||||
blood_type = 'O_POSITIVE',
|
||||
medical_certificate_number = 'MED-2026-001', medical_issuer_name = 'Addis Marine Clinic',
|
||||
medical_issue_date = '2026-01-15', declaration_accepted = true
|
||||
WHERE id = '${registrationId}';
|
||||
`);
|
||||
}
|
||||
|
||||
/** The four required evidence rows, as attachment rows with a storage key. */
|
||||
function insertDocuments(registrationId: string): void {
|
||||
const documentKeys = ['photo', 'nationalId', 'medical_certificate', 'basic_training_evidence'];
|
||||
sql(`
|
||||
WITH inserted AS (
|
||||
INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to)
|
||||
SELECT 'SEAFARER_REGISTRATION', '${registrationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
||||
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO attachment_files
|
||||
(attachment_id, original_name, mime_type, size_bytes, storage_key)
|
||||
SELECT id, 'evidence.pdf', 'application/pdf', 1024, 'e2e/' || id || '.pdf'
|
||||
FROM inserted;
|
||||
`);
|
||||
}
|
||||
|
||||
/** Fills what submission requires, then submits as the applicant. */
|
||||
async function submit(registrationId: string, applicant: Applicant): Promise<void> {
|
||||
fillForSubmission(registrationId);
|
||||
await runRegistrationWorkflow(registrationId, [{ path: 'submit' }], applicant);
|
||||
}
|
||||
@@ -17,7 +17,11 @@ import { E2E } from '../../playwright.config';
|
||||
|
||||
const OTP_PATTERN = /is (\d{4,8})\./g;
|
||||
|
||||
/** Byte offset to read from later. Zero when the log does not exist yet. */
|
||||
/**
|
||||
* Byte offset to read from later. Zero when the log does not exist yet.
|
||||
*
|
||||
* Bytes, and read back as bytes — see `otpSince`.
|
||||
*/
|
||||
export function logOffset(): number {
|
||||
try {
|
||||
return statSync(E2E.apiLog).size;
|
||||
@@ -48,7 +52,14 @@ export async function waitForOtp(
|
||||
function otpSince(offset: number): string | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
|
||||
// Sliced as a Buffer, then decoded — not `readFileSync(…, 'utf8').slice()`.
|
||||
// `logOffset()` is a byte count from `statSync`, while slicing a string
|
||||
// counts UTF-16 code units, and the API logs Amharic notification bodies:
|
||||
// every multi-byte character made the offset overshoot, so a code written
|
||||
// just after it was skipped and the wait timed out. The drift grows with
|
||||
// the log, which is why this failed intermittently and more often later in
|
||||
// a run.
|
||||
text = readFileSync(E2E.apiLog).subarray(offset).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,18 +14,35 @@ export interface Applicant {
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
/** The account name, as typed at signup. Always `${firstName} ${middleName} ${lastName}`. */
|
||||
name: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export function newApplicant(label: string): Applicant {
|
||||
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
// The profile's Maritime tab refuses to save unless first/middle/last join to
|
||||
// exactly the account name (`ProfilePage.onSaveProfile`) — and that refusal is
|
||||
// a silent early return, no request. So the parts are the source of truth here
|
||||
// and the account name is composed from them, rather than the two being
|
||||
// written independently and hoped to agree.
|
||||
//
|
||||
// Each part is at least three characters, which `profileSchema` requires.
|
||||
const firstName = 'Dawit';
|
||||
const middleName = 'Bekele';
|
||||
const lastName = `Tesfaye${stamp.slice(-4)}`;
|
||||
return {
|
||||
email: `e2e.${label}.${stamp}@example.test`,
|
||||
username: `e2e${label}${stamp}`.slice(0, 28),
|
||||
// Ethiopian mobile format; the last digits vary so two runs never collide.
|
||||
phoneNumber: `+2519${stamp.slice(-8)}`,
|
||||
password: 'E2ePassw0rd!',
|
||||
name: `E2E ${label} ${stamp.slice(-4)}`,
|
||||
name: `${firstName} ${middleName} ${lastName}`,
|
||||
firstName,
|
||||
middleName,
|
||||
lastName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +56,16 @@ export function newApplicant(label: string): Applicant {
|
||||
export async function signUp(page: Page, applicant: Applicant): Promise<number> {
|
||||
await page.goto('/signup');
|
||||
|
||||
await page.getByLabel('Name (English)').fill(applicant.name);
|
||||
// The form has shipped both as one full-name field and as first/middle/last
|
||||
// parts; `applicant.name` is what the parts join to, so either is filled.
|
||||
const fullName = page.getByLabel(/^(full )?name \(english\)$/i);
|
||||
if (await fullName.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await fullName.fill(applicant.name);
|
||||
} else {
|
||||
await page.getByLabel('First name').fill(applicant.firstName);
|
||||
await page.getByLabel('Middle name').fill(applicant.middleName);
|
||||
await page.getByLabel('Last name').fill(applicant.lastName);
|
||||
}
|
||||
await page.getByLabel('Email address').fill(applicant.email);
|
||||
await page.getByLabel('Username').fill(applicant.username);
|
||||
await page.getByLabel('Phone number').fill(applicant.phoneNumber);
|
||||
@@ -50,7 +76,21 @@ export async function signUp(page: Page, applicant: Applicant): Promise<number>
|
||||
await page.getByRole('checkbox').check();
|
||||
|
||||
const offset = logOffset();
|
||||
await page.getByRole('button', { name: /create account|sign up/i }).click();
|
||||
const submit = page.getByRole('button', { name: /create account|sign up/i });
|
||||
await submit.click();
|
||||
// The first request after the API boots occasionally fails in the browser
|
||||
// before it reaches the server ("Network error"); the form stays filled, so
|
||||
// resubmitting is exactly what a person would do.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const failed = page.getByText(/network error/i);
|
||||
const outcome = await Promise.race([
|
||||
page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 15_000 }).then(() => 'navigated'),
|
||||
failed.waitFor({ state: 'visible', timeout: 15_000 }).then(() => 'failed'),
|
||||
]).catch(() => 'timeout');
|
||||
if (outcome !== 'failed') break;
|
||||
await page.waitForTimeout(2_000);
|
||||
await submit.click();
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { E2E } from '../../playwright.config';
|
||||
|
||||
/**
|
||||
@@ -12,18 +13,74 @@ import { E2E } from '../../playwright.config';
|
||||
const PSQL_ENV = {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090',
|
||||
PGOPTIONS: `--search_path=${process.env.E2E_DB_SCHEMA ?? 'ema'},public`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where `psql` lives.
|
||||
*
|
||||
* A local client is used when there is one. When there is not — Postgres
|
||||
* running only as a container is the common setup — the same query goes
|
||||
* through `docker compose exec` instead, so the suite does not require a
|
||||
* developer to install a database client for the sake of a few assertions.
|
||||
*
|
||||
* Set `E2E_DB_CONTAINER` to the compose service name to force the container
|
||||
* path, or `E2E_PSQL=1` to insist on a local binary.
|
||||
*/
|
||||
const DB_CONTAINER = process.env.E2E_DB_CONTAINER ?? 'ema-postgres';
|
||||
const COMPOSE_DIR =
|
||||
process.env.E2E_COMPOSE_DIR ??
|
||||
// Anchored on the repo root rather than this file: Playwright may load the
|
||||
// suite as ESM, where `__dirname` does not exist. `process.cwd()` is the
|
||||
// emaui workspace when the config is run from there.
|
||||
resolve(process.cwd(), '../emaapi');
|
||||
|
||||
function hasLocalPsql(): boolean {
|
||||
if (process.env.E2E_PSQL === '1') return true;
|
||||
try {
|
||||
execFileSync('psql', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const USE_CONTAINER = !hasLocalPsql();
|
||||
|
||||
export function sql(query: string): string[][] {
|
||||
const out = execFileSync(
|
||||
// Queries name EMA tables unqualified (`license_applications`), which only
|
||||
// resolves with the app's own schema on the path. `iam.` stays explicit.
|
||||
//
|
||||
// Passed as a connection option rather than a leading `SET` statement: psql
|
||||
// prints a result row per command, and a `SET` would land in every caller's
|
||||
// rows as a phantom first entry.
|
||||
const schema = process.env.E2E_DB_SCHEMA ?? 'ema';
|
||||
const psqlArgs = [
|
||||
'-U', process.env.E2E_DB_USER ?? 'postgres',
|
||||
'-d', E2E.database,
|
||||
'-v', 'ON_ERROR_STOP=1',
|
||||
'-tAF', '\t',
|
||||
'-c', query,
|
||||
];
|
||||
|
||||
const out = USE_CONTAINER
|
||||
? execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'compose', 'exec', '-T',
|
||||
// `exec` does not forward the caller's environment, so the schema
|
||||
// search path has to be handed across explicitly.
|
||||
'-e', `PGOPTIONS=${PSQL_ENV.PGOPTIONS}`,
|
||||
DB_CONTAINER, 'psql', ...psqlArgs,
|
||||
],
|
||||
{ cwd: COMPOSE_DIR, env: PSQL_ENV, encoding: 'utf8' },
|
||||
)
|
||||
: execFileSync(
|
||||
'psql',
|
||||
[
|
||||
'-h', process.env.E2E_DB_HOST ?? 'localhost',
|
||||
'-p', process.env.E2E_DB_PORT ?? '5432',
|
||||
'-U', process.env.E2E_DB_USER ?? 'postgres',
|
||||
'-d', E2E.database,
|
||||
'-tAF', '\t',
|
||||
'-c', query,
|
||||
...psqlArgs,
|
||||
],
|
||||
{ env: PSQL_ENV, encoding: 'utf8' },
|
||||
);
|
||||
@@ -46,11 +103,23 @@ export function deleteApplicant(email: string): void {
|
||||
if (!userId) return;
|
||||
|
||||
sql(`
|
||||
DELETE FROM attachment_files WHERE attachment_id IN (
|
||||
SELECT id FROM attachments WHERE owner_type = 'SEAFARER_REGISTRATION'
|
||||
AND owner_id IN (SELECT id FROM seafarer_registrations WHERE applicant_user_id = '${userId}')
|
||||
);
|
||||
DELETE FROM attachments WHERE owner_type = 'SEAFARER_REGISTRATION'
|
||||
AND owner_id IN (SELECT id FROM seafarer_registrations WHERE applicant_user_id = '${userId}');
|
||||
DELETE FROM seafarer_registrations WHERE applicant_user_id = '${userId}';
|
||||
DELETE FROM application_payments WHERE document_id IN (SELECT id FROM seafarer_documents WHERE applicant_user_id = '${userId}');
|
||||
DELETE FROM seafarer_documents WHERE applicant_user_id = '${userId}';
|
||||
DELETE FROM licenses WHERE holder_user_id = '${userId}';
|
||||
DELETE FROM license_applications WHERE applicant_user_id = '${userId}';
|
||||
DELETE FROM profile_operator_types
|
||||
WHERE profile_id IN (SELECT id FROM profiles WHERE user_id = '${userId}');
|
||||
DELETE FROM profiles WHERE user_id = '${userId}';
|
||||
WITH gone AS (
|
||||
DELETE FROM profiles WHERE user_id = '${userId}' RETURNING address_id
|
||||
)
|
||||
DELETE FROM addresses WHERE id IN (SELECT address_id FROM gone WHERE address_id IS NOT NULL);
|
||||
DELETE FROM iam.notifications WHERE recipient_id = '${userId}';
|
||||
DELETE FROM iam.user_verifications WHERE user_id = '${userId}';
|
||||
DELETE FROM iam.user_credentials WHERE user_id = '${userId}';
|
||||
|
||||
70
apps/e2e/src/support/officer.ts
Normal file
70
apps/e2e/src/support/officer.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { E2E } from '../../playwright.config';
|
||||
|
||||
/**
|
||||
* The backoffice side of a flow.
|
||||
*
|
||||
* Every registration test needs an officer to act on what the applicant filed,
|
||||
* and the only account the IAM seed creates is the super admin — which holds
|
||||
* every permission, so it can claim, return, reject and approve without a
|
||||
* fixture inventing a position first.
|
||||
*
|
||||
* That breadth is also a limitation worth naming: these tests prove the actions
|
||||
* work, not that a *review officer* specifically may perform them. Per-role
|
||||
* authorisation needs its own accounts and belongs in its own spec.
|
||||
*/
|
||||
export const OFFICER = {
|
||||
email: process.env.E2E_OFFICER_EMAIL ?? 'superadmin@tria.com',
|
||||
password: process.env.E2E_OFFICER_PASSWORD ?? 'password@tria',
|
||||
};
|
||||
|
||||
/** Signs the officer into the backoffice, which is a separate origin. */
|
||||
export async function logInAsOfficer(page: Page): Promise<void> {
|
||||
await page.goto(`${E2E.backofficeUrl}/login`);
|
||||
await page.getByLabel(/email/i).fill(OFFICER.email);
|
||||
await page.getByLabel(/password/i).fill(OFFICER.password);
|
||||
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
|
||||
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one application's review screen by its application number.
|
||||
*
|
||||
* Goes through the seafarer registration queue rather than deep-linking by id:
|
||||
* the queue is what an officer actually uses, and a test that skips it would
|
||||
* not notice the application failing to appear there at all.
|
||||
*/
|
||||
export async function openInQueue(
|
||||
page: Page,
|
||||
applicationNumber: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${E2E.backofficeUrl}/seafarer-registrations`);
|
||||
// Oldest first and paged, so the newest registration is rarely on page one.
|
||||
await page.getByPlaceholder(/search/i).fill(applicationNumber);
|
||||
const row = page.getByRole('row', { name: new RegExp(applicationNumber, 'i') });
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a workflow action and waits for the status to settle.
|
||||
*
|
||||
* Actions live behind buttons whose labels the design owns, so each is matched
|
||||
* by a loose pattern rather than an exact string — a rename should not read as
|
||||
* a broken workflow.
|
||||
*/
|
||||
export async function act(
|
||||
page: Page,
|
||||
action: RegExp,
|
||||
confirm: RegExp = /confirm|submit|yes|save|approve|reject|send/i,
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: action }).first().click();
|
||||
|
||||
// Most actions raise a modal; some apply directly. Either is fine.
|
||||
const dialog = page.getByRole('dialog');
|
||||
if (await dialog.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
const button = dialog.getByRole('button', { name: confirm }).first();
|
||||
if (await button.isVisible().catch(() => false)) await button.click();
|
||||
}
|
||||
}
|
||||
247
apps/e2e/src/support/workflow.ts
Normal file
247
apps/e2e/src/support/workflow.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { APIRequestContext, request } from '@playwright/test';
|
||||
import { E2E } from '../../playwright.config';
|
||||
import { OFFICER } from './officer';
|
||||
|
||||
/**
|
||||
* The workflow endpoints, called as the officer.
|
||||
*
|
||||
* Used where a test's subject is what an approval *does* — the seafarer
|
||||
* number, the activated record, the two child applications — rather than which
|
||||
* buttons produce it. Driving six sections of wizard and a review screen to
|
||||
* reach a completion effect would make those tests about forms.
|
||||
*
|
||||
* Nothing here writes to the database directly. The completion effect runs
|
||||
* inside the API's approval transaction, so a test that faked the status would
|
||||
* assert against an approval that never happened.
|
||||
*/
|
||||
|
||||
/** Routes served by the applicant-facing controller rather than the review one. */
|
||||
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
|
||||
|
||||
/**
|
||||
* Resolves every open remark on an application, as the applicant.
|
||||
*
|
||||
* `resubmit` refuses while any remain (`unresolved_remarks`) — the applicant is
|
||||
* expected to tick off each correction as they make it, which the portal does
|
||||
* per section. A test that only wants the round-trip still has to do it.
|
||||
*/
|
||||
export async function resolveOpenRemarks(
|
||||
applicationId: string,
|
||||
remarkIds: string[],
|
||||
applicant: { email: string; password: string },
|
||||
): Promise<void> {
|
||||
await runWorkflow(
|
||||
applicationId,
|
||||
remarkIds.map((remarkId) => ({
|
||||
path: `remarks/${remarkId}/resolve`,
|
||||
method: 'patch' as const,
|
||||
})),
|
||||
applicant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An authenticated API context for one account.
|
||||
*
|
||||
* Paths built against it are relative on purpose. `E2E.apiUrl` carries the
|
||||
* `/api` prefix, and a leading slash resolves against the *origin* —
|
||||
* `/auth/login` against `http://host/api` requests `http://host/auth/login`,
|
||||
* which 404s. Every path in this file is therefore written without one.
|
||||
*/
|
||||
async function contextFor(
|
||||
who: string,
|
||||
credentials: { email: string; password: string },
|
||||
): Promise<APIRequestContext> {
|
||||
const context = await request.newContext({ baseURL: `${E2E.apiUrl}/` });
|
||||
const response = await context.post('auth/login', {
|
||||
data: { email: credentials.email, password: credentials.password },
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`${who} login failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
const body = await response.json();
|
||||
const token = body?.token ?? body?.accessToken ?? body?.access_token;
|
||||
if (!token) {
|
||||
throw new Error(`No access token in login response: ${JSON.stringify(body)}`);
|
||||
}
|
||||
|
||||
await context.dispose();
|
||||
return request.newContext({
|
||||
baseURL: `${E2E.apiUrl}/`,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
function officerContext(): Promise<APIRequestContext> {
|
||||
return contextFor('Officer', OFFICER);
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
/** Route under the review controller, e.g. `claim`, `final-approve`. */
|
||||
path: string;
|
||||
data?: Record<string, unknown>;
|
||||
/** Set when a step is expected to be refused — the refusal is the assertion. */
|
||||
expectFailure?: boolean;
|
||||
/** POST unless stated; the applicant's remark-resolve route is a PATCH. */
|
||||
method?: 'post' | 'patch';
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a sequence of workflow calls against one application.
|
||||
*
|
||||
* Returns each step's status code so a caller can assert on a refusal as
|
||||
* readily as on a success — "an officer may not evaluate a registration" is a
|
||||
* result worth checking, not an error to swallow.
|
||||
*/
|
||||
export async function runWorkflow(
|
||||
applicationId: string,
|
||||
steps: WorkflowStep[],
|
||||
/**
|
||||
* The owner, required only when a step is applicant-side. `submit` and
|
||||
* `resubmit` are guarded by ownership, not permission — the officer holds
|
||||
* every permission but is not the applicant, so running them on the officer's
|
||||
* token is refused with `not_application_owner`.
|
||||
*/
|
||||
applicant?: { email: string; password: string },
|
||||
): Promise<number[]> {
|
||||
const officer = await officerContext();
|
||||
const needsApplicant = steps.some(
|
||||
(step) => APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'),
|
||||
);
|
||||
if (needsApplicant && !applicant) {
|
||||
throw new Error(
|
||||
`Steps [${steps
|
||||
.filter((s) => APPLICANT_STEPS.has(s.path) || s.path.startsWith('remarks/'))
|
||||
.map((s) => s.path)
|
||||
.join(', ')}] act as the applicant — pass their credentials to runWorkflow.`,
|
||||
);
|
||||
}
|
||||
const owner = needsApplicant && applicant
|
||||
? await contextFor('Applicant', applicant)
|
||||
: null;
|
||||
const codes: number[] = [];
|
||||
|
||||
try {
|
||||
for (const step of steps) {
|
||||
// Applicant-side actions (`submit`, `resubmit`) live on the
|
||||
// applications controller; everything an officer does is on the review
|
||||
// controller. Routing by step keeps callers from having to know.
|
||||
const isApplicantStep =
|
||||
APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/');
|
||||
const base = isApplicantStep
|
||||
? 'license-applications'
|
||||
: 'license-application-review';
|
||||
const api = isApplicantStep && owner ? owner : officer;
|
||||
const url = `${base}/${applicationId}/${step.path}`;
|
||||
const response =
|
||||
step.method === 'patch'
|
||||
? await api.patch(url, { data: step.data ?? {} })
|
||||
: await api.post(url, { data: step.data ?? {} });
|
||||
codes.push(response.status());
|
||||
|
||||
if (!step.expectFailure && !response.ok()) {
|
||||
throw new Error(
|
||||
`Step "${step.path}" failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await officer.dispose();
|
||||
await owner?.dispose();
|
||||
}
|
||||
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standalone seafarer-registration endpoints — its own controller pair,
|
||||
* not the licence ones above. `submit` is the applicant's; everything else
|
||||
* is the officer's.
|
||||
*/
|
||||
export async function runRegistrationWorkflow(
|
||||
registrationId: string,
|
||||
steps: WorkflowStep[],
|
||||
applicant?: { email: string; password: string },
|
||||
): Promise<number[]> {
|
||||
const officer = await officerContext();
|
||||
const needsApplicant = steps.some((step) => step.path === 'submit');
|
||||
if (needsApplicant && !applicant) {
|
||||
throw new Error('`submit` acts as the applicant — pass their credentials.');
|
||||
}
|
||||
const owner = needsApplicant && applicant
|
||||
? await contextFor('Applicant', applicant)
|
||||
: null;
|
||||
const codes: number[] = [];
|
||||
try {
|
||||
for (const step of steps) {
|
||||
const isApplicantStep = step.path === 'submit';
|
||||
const base = isApplicantStep
|
||||
? 'seafarer-registrations'
|
||||
: 'seafarer-registration-review';
|
||||
const api = isApplicantStep && owner ? owner : officer;
|
||||
const response = await api.post(`${base}/${registrationId}/${step.path}`, {
|
||||
data: step.data ?? {},
|
||||
});
|
||||
codes.push(response.status());
|
||||
if (!step.expectFailure && !response.ok()) {
|
||||
throw new Error(
|
||||
`Step "${step.path}" failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await officer.dispose();
|
||||
await owner?.dispose();
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** Approve — the whole officer path for a registration; there is no claim. */
|
||||
export async function approveRegistration(registrationId: string): Promise<void> {
|
||||
await runRegistrationWorkflow(registrationId, [
|
||||
{ path: 'approve', data: { remark: 'E2E approval' } },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Seaman Book / BTC endpoints. `payments/bypass` is the applicant's;
|
||||
* confirm-payment, schedule-issuance, issue and reject are the officer's.
|
||||
*/
|
||||
export async function runDocumentWorkflow(
|
||||
documentId: string,
|
||||
steps: WorkflowStep[],
|
||||
applicant?: { email: string; password: string },
|
||||
): Promise<number[]> {
|
||||
const officer = await officerContext();
|
||||
const isApplicantStep = (path: string) => path.startsWith('payments/');
|
||||
const needsApplicant = steps.some((step) => isApplicantStep(step.path));
|
||||
if (needsApplicant && !applicant) {
|
||||
throw new Error('`payments/*` acts as the applicant — pass their credentials.');
|
||||
}
|
||||
const owner = needsApplicant && applicant
|
||||
? await contextFor('Applicant', applicant)
|
||||
: null;
|
||||
const codes: number[] = [];
|
||||
try {
|
||||
for (const step of steps) {
|
||||
const applicantStep = isApplicantStep(step.path);
|
||||
const base = applicantStep ? 'seafarer-documents' : 'seafarer-document-review';
|
||||
const api = applicantStep && owner ? owner : officer;
|
||||
const response = await api.post(`${base}/${documentId}/${step.path}`, {
|
||||
data: step.data ?? {},
|
||||
});
|
||||
codes.push(response.status());
|
||||
if (!step.expectFailure && !response.ok()) {
|
||||
throw new Error(
|
||||
`Step "${step.path}" failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await officer.dispose();
|
||||
await owner?.dispose();
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
@@ -13,9 +13,115 @@
|
||||
document.documentElement.setAttribute('data-mantine-color-scheme', s);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<style>
|
||||
/* Boot splash — shown until React mounts into #root. Colors are
|
||||
hardcoded (not CSS vars from portal.css) 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 Portal">
|
||||
<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="b-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="b-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(#b-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(#b-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 Portal…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component } from 'react';
|
||||
import type { ReactNode, ErrorInfo } from 'react';
|
||||
import { Center, Paper, Title, Text, Button } from '@mantine/core';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import type { WithTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -11,7 +13,7 @@ interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
class ErrorBoundaryBase extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
@@ -24,12 +26,13 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Paper p="xl" shadow="md" radius="md" w={400}>
|
||||
<Title order={3} mb="sm">Something went wrong</Title>
|
||||
<Title order={3} mb="sm">{t('errorBoundary.title')}</Title>
|
||||
<Text c="dimmed" size="sm" mb="lg">
|
||||
{this.state.error?.message || 'An unexpected error occurred.'}
|
||||
{this.state.error?.message || t('errorBoundary.message')}
|
||||
</Text>
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -38,7 +41,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
>
|
||||
Reload page
|
||||
{t('errorBoundary.reload')}
|
||||
</Button>
|
||||
</Paper>
|
||||
</Center>
|
||||
@@ -48,3 +51,5 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = withTranslation()(ErrorBoundaryBase);
|
||||
|
||||
@@ -1,15 +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 with portal-specific routes.
|
||||
* Auth state is read the same way ProtectedRoute does (token cookie or
|
||||
* storage fallback) so the header can show "Go to dashboard" instead of
|
||||
* Login/Sign Up without gating the route itself.
|
||||
* Public `/`. Signed-in visitors skip the landing page entirely and go
|
||||
* straight to the dashboard — the landing page is a front door for people
|
||||
* who aren't in yet, not a screen for people who already are.
|
||||
*/
|
||||
export function LandingRoute() {
|
||||
const token = authStorage.getToken() ?? Cookies.get('auth-token');
|
||||
const token = useAuthToken();
|
||||
|
||||
return <LandingPage primaryHref={token ? '/dashboard' : '/login'} signupHref="/signup" />;
|
||||
if (token) return <Navigate to="/dashboard" replace />;
|
||||
|
||||
return <LandingPage primaryHref="/login" signupHref="/signup" />;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -275,6 +276,8 @@ export function BasicSafetyTrainingPage() {
|
||||
const isExpiringSoon = days !== null && days <= 180 && days > 0;
|
||||
const isExpired = days !== null && days <= 0;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
useGetMySeaServiceRecordsQuery,
|
||||
useGetMyMedicalCertificatesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
@@ -353,21 +353,12 @@ export function CertificatesPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
<PdfPreviewModal
|
||||
opened={!!previewUrl}
|
||||
onClose={() => setPreviewUrl(null)}
|
||||
title={<Text fw={700} fz="sm">{previewTitle}</Text>}
|
||||
size="95vw"
|
||||
radius="lg"
|
||||
fullScreen
|
||||
>
|
||||
<iframe
|
||||
src={previewUrl ?? ''}
|
||||
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
|
||||
url={previewUrl ?? ''}
|
||||
title={previewTitle}
|
||||
/>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge, Progress, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
@@ -8,10 +9,12 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
|
||||
export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
|
||||
[
|
||||
export function dashboardApplicationColumns(
|
||||
t: TFunction,
|
||||
): AdvancedColumn<LicenseApplication>[] {
|
||||
return [
|
||||
{
|
||||
header: 'Application',
|
||||
header: t('dashboard.table.application'),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -24,13 +27,13 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Licence',
|
||||
header: t('applications.table.licence'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
header: t('common.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[row.original.status]}>
|
||||
{STATUS_LABELS[row.original.status]}
|
||||
@@ -38,7 +41,7 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Progress',
|
||||
header: t('applications.table.progress'),
|
||||
size: 180,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
@@ -50,3 +53,4 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
@@ -10,7 +12,6 @@ import {
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, PageLoader, useServerTable } from '@ema-platform/ui';
|
||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
|
||||
@@ -64,15 +65,20 @@ function daysUntil(date: string): number {
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
function formatMoney(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return 'No fee';
|
||||
function formatMoney(
|
||||
amount: string | number | null,
|
||||
currency: string,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (amount === null || amount === '') return t('dashboard.noFee');
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value)) return 'No fee';
|
||||
if (!Number.isFinite(value)) return t('dashboard.noFee');
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const displayName = useSelector(
|
||||
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
|
||||
state.auth.user?.name?.en || state.auth.user?.username || '',
|
||||
@@ -108,11 +114,7 @@ export function DashboardPage() {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label={t('dashboard.loading')} height={450} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -137,17 +139,15 @@ export function DashboardPage() {
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<IconClockHour4 size={18} />}
|
||||
title={
|
||||
expiringSoon.length === 1
|
||||
? 'A licence is expiring soon'
|
||||
: `${expiringSoon.length} licences are expiring soon`
|
||||
}
|
||||
title={t('applications.notice.expiringSoon', { count: expiringSoon.length })}
|
||||
>
|
||||
<Text size="sm">
|
||||
{expiringSoon
|
||||
.map(
|
||||
(l) =>
|
||||
`${l.certificateNumber} expires in ${daysUntil(l.expiryDate)} days`,
|
||||
.map((l) =>
|
||||
t('dashboard.expiringSoon.detail', {
|
||||
certificateNumber: l.certificateNumber,
|
||||
days: daysUntil(l.expiryDate),
|
||||
}),
|
||||
)
|
||||
.join(' · ')}
|
||||
</Text>
|
||||
@@ -165,9 +165,9 @@ export function DashboardPage() {
|
||||
<GetStartedPanel />
|
||||
) : (
|
||||
<>
|
||||
<Section title="My licences">
|
||||
<Section title={t('dashboard.sections.myLicences.title')}>
|
||||
{heldLicenses.length === 0 ? (
|
||||
<EmptyCard message="No licence has been issued to you yet. One appears here once an application is approved and paid." />
|
||||
<EmptyCard message={t('applications.licences.empty')} />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{heldLicenses.map((license) => (
|
||||
@@ -185,20 +185,20 @@ export function DashboardPage() {
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="My applications"
|
||||
title={t('dashboard.sections.myApplications.title')}
|
||||
action={
|
||||
items.length > 0 ? (
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => navigate('/licensing/applications')}
|
||||
>
|
||||
View all
|
||||
{t('common.viewAll')}
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<EmptyCard message="You have not filed any applications yet. Pick a licence below to get started." />
|
||||
<EmptyCard message={t('dashboard.sections.myApplications.empty')} />
|
||||
) : (
|
||||
<ApplicationTable
|
||||
applications={items.slice(0, 6)}
|
||||
@@ -211,8 +211,8 @@ export function DashboardPage() {
|
||||
)}
|
||||
|
||||
<Section
|
||||
title="Apply for a licence"
|
||||
description="Choose the licence that matches the service your company provides."
|
||||
title={t('dashboard.sections.apply.title')}
|
||||
description={t('dashboard.sections.apply.description')}
|
||||
>
|
||||
<LicenseCatalogue />
|
||||
</Section>
|
||||
@@ -232,10 +232,14 @@ function Hero({
|
||||
applicationCount: number;
|
||||
licenseCount: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const summary =
|
||||
applicationCount === 0 && licenseCount === 0
|
||||
? 'Apply for a maritime or logistics licence and track it through to issue.'
|
||||
: `You have ${applicationCount} application${applicationCount === 1 ? '' : 's'} and ${licenseCount} active licence${licenseCount === 1 ? '' : 's'}.`;
|
||||
? t('dashboard.hero.summaryEmpty')
|
||||
: t('dashboard.hero.summary', {
|
||||
applications: t('dashboard.hero.applicationsCount', { count: applicationCount }),
|
||||
licences: t('dashboard.hero.licencesCount', { count: licenseCount }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -250,10 +254,10 @@ function Hero({
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" style={{ opacity: 0.85 }}>
|
||||
Ethiopian Maritime Authority
|
||||
{t('app.authority')}
|
||||
</Text>
|
||||
<Title order={2} mt={4} c="white">
|
||||
{displayName ? `Welcome back, ${displayName}` : 'Welcome back'}
|
||||
{displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
|
||||
</Title>
|
||||
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
|
||||
{summary}
|
||||
@@ -280,6 +284,7 @@ function ActionRequired({
|
||||
applications: LicenseApplication[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
@@ -292,12 +297,12 @@ function ActionRequired({
|
||||
<IconAlertTriangle size={14} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Waiting on you
|
||||
{t('dashboard.waitingOnYou')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{applications.map((app) => {
|
||||
const detail = detailFor(app);
|
||||
const detail = detailFor(app, t);
|
||||
return (
|
||||
<Paper key={app.id} radius="sm" p="sm" withBorder>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
@@ -339,7 +344,10 @@ function ActionRequired({
|
||||
}
|
||||
|
||||
/** What the applicant has to do next, and where that happens. */
|
||||
function detailFor(app: LicenseApplication): {
|
||||
function detailFor(
|
||||
app: LicenseApplication,
|
||||
t: TFunction,
|
||||
): {
|
||||
message: string;
|
||||
cta: string;
|
||||
color: string;
|
||||
@@ -353,22 +361,24 @@ function detailFor(app: LicenseApplication): {
|
||||
switch (app.status) {
|
||||
case 'RESUBMIT_REQUIRED':
|
||||
return {
|
||||
message: 'A reviewer asked for corrections before this can proceed.',
|
||||
cta: 'Fix now',
|
||||
message: t('dashboard.actionRequired.messages.resubmit'),
|
||||
cta: t('dashboard.actionRequired.cta.fixNow'),
|
||||
color: 'orange',
|
||||
path: wizard,
|
||||
};
|
||||
case 'PAYMENT_PENDING':
|
||||
return {
|
||||
message: `Approved — ${formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB')} due before the certificate is issued.`,
|
||||
cta: 'Pay now',
|
||||
message: t('dashboard.actionRequired.messages.paymentPending', {
|
||||
amount: formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB', t),
|
||||
}),
|
||||
cta: t('dashboard.actionRequired.cta.payNow'),
|
||||
color: 'yellow',
|
||||
path: '/licensing/applications',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message: 'This application is still a draft and has not been filed.',
|
||||
cta: 'Continue',
|
||||
message: t('dashboard.actionRequired.messages.draft'),
|
||||
cta: t('common.continue'),
|
||||
color: 'blue',
|
||||
path: wizard,
|
||||
};
|
||||
@@ -386,11 +396,12 @@ function StatRow({
|
||||
activeLicenses: number;
|
||||
expiringSoon: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stats = [
|
||||
{ label: 'In progress', value: inProgress, icon: IconClockHour4, color: 'blue' },
|
||||
{ label: 'Waiting on you', value: needsMe, icon: IconAlertTriangle, color: 'orange' },
|
||||
{ label: 'Active licences', value: activeLicenses, icon: IconCertificate, color: 'teal' },
|
||||
{ label: 'Expiring soon', value: expiringSoon, icon: IconClockHour4, color: 'grape' },
|
||||
{ label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' },
|
||||
{ label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' },
|
||||
{ label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' },
|
||||
{ label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -456,12 +467,13 @@ function ApplicationTable({
|
||||
navigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const table = useServerTable();
|
||||
const paged = table.paginate(applications);
|
||||
return (
|
||||
<AdvancedTable
|
||||
tableName="My applications"
|
||||
columns={dashboardApplicationColumns}
|
||||
tableName={t('dashboard.sections.myApplications.title')}
|
||||
columns={dashboardApplicationColumns(t)}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
@@ -486,6 +498,7 @@ function ApplicationTable({
|
||||
* it, and a button that only scrolls the page is noise.
|
||||
*/
|
||||
function GetStartedPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Group gap="md" wrap="nowrap" align="flex-start">
|
||||
@@ -493,11 +506,9 @@ function GetStartedPanel() {
|
||||
<IconCertificate size={24} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Get started</Title>
|
||||
<Title order={4}>{t('dashboard.getStarted.title')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4} maw={620}>
|
||||
You have not filed an application yet. Choose the licence that
|
||||
matches what your company does — your applications and the licences
|
||||
issued to you will appear here as you go.
|
||||
{t('dashboard.getStarted.body')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
@@ -8,11 +9,13 @@ import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function DocumentVaultPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="My documents"
|
||||
description="A central document vault is not connected to the backend yet. Documents you upload with a licence application are stored with that application."
|
||||
title={t('featureUnavailable.documents.title')}
|
||||
description={t('featureUnavailable.documents.description')}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,476 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
FileInput,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconFileDescription,
|
||||
IconCircleX,
|
||||
IconInfoCircle,
|
||||
IconRubberStamp,
|
||||
IconShieldCheck,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { endorsementColumns } from './columns';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data — existing endorsement applications
|
||||
// ---------------------------------------------------------------------------
|
||||
/** What `/endorsements/my` returns. */
|
||||
interface EndorsementsOverview {
|
||||
issued: {
|
||||
id: string;
|
||||
endorsementNo: string;
|
||||
cocType: string;
|
||||
foreignCocNo: string;
|
||||
issuingCountry: string;
|
||||
issued: string;
|
||||
expiry: string;
|
||||
status: string;
|
||||
}[];
|
||||
applications: {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
cocType: string;
|
||||
foreignCocNo: string;
|
||||
issuingCountry: string;
|
||||
submitted: string;
|
||||
status: string;
|
||||
}[];
|
||||
}
|
||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
||||
|
||||
// Keyed by the workflow's own status values so an unmapped one falls back to
|
||||
// grey rather than rendering colourless.
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: 'gray',
|
||||
SUBMITTED: 'blue',
|
||||
UNDER_REVIEW: 'yellow',
|
||||
UNDER_EVALUATION: 'yellow',
|
||||
RESUBMIT_REQUIRED: 'orange',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
PAYMENT_PENDING: 'orange',
|
||||
PAYMENT_CONFIRMED: 'blue',
|
||||
CERTIFICATE_ISSUED: 'teal',
|
||||
COMPLETED: 'teal',
|
||||
ACTIVE: 'teal',
|
||||
EXPIRED: 'red',
|
||||
SUSPENDED: 'orange',
|
||||
};
|
||||
|
||||
function humanStatus(status: string): string {
|
||||
return status
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
// blank PDF
|
||||
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application wizard
|
||||
// ---------------------------------------------------------------------------
|
||||
interface Docs {
|
||||
foreignCoc: File | null;
|
||||
translation: File | null;
|
||||
medical: File | null;
|
||||
seamanBook: File | null;
|
||||
photo: File | null;
|
||||
}
|
||||
|
||||
function ApplicationWizard({ onDone }: { onDone: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [cocNo, setCocNo] = useState('');
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [country, setCountry] = useState('');
|
||||
const [cocType, setCocType] = useState('');
|
||||
const [issueDate, setIssueDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [docs, setDocs] = useState<Docs>({ foreignCoc: null, translation: null, medical: null, seamanBook: null, photo: null });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const step0Ok = !!cocNo && !!issuer && !!country && !!cocType && !!issueDate && !!expiryDate;
|
||||
const step1Ok = !!docs.foreignCoc && !!docs.medical && !!docs.seamanBook && !!docs.photo;
|
||||
|
||||
if (submitted) {
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<Stack gap="lg" align="center" py="xl">
|
||||
<ThemeIcon size={72} radius="xl" color="teal" variant="light"><IconCircleCheck size={40} /></ThemeIcon>
|
||||
<Title order={3} ta="center">Application Submitted</Title>
|
||||
<Text c="dimmed" ta="center" maw={400}>
|
||||
Your endorsement application has been submitted. EMA officers will verify your documents
|
||||
and notify you of the outcome. Reference: <strong>END-APP-2025-NEW</strong>
|
||||
</Text>
|
||||
<Button onClick={onDone}>Back to Endorsements</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stepper active={step} size="sm">
|
||||
<Stepper.Step label="Foreign CoC Details" description="Certificate information" />
|
||||
<Stepper.Step label="Upload Documents" description="Required documents" />
|
||||
<Stepper.Step label="Payment" description="Pay endorsement fee" />
|
||||
<Stepper.Step label="Review & Submit" description="Final check" />
|
||||
</Stepper>
|
||||
|
||||
{/* Step 0 — Foreign CoC details */}
|
||||
{step === 0 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg">
|
||||
<Text fz="sm">
|
||||
<strong>STCW Regulation I/10</strong> — EMA will endorse your foreign CoC so it is
|
||||
recognised for service on Ethiopian-flagged vessels. The endorsement is valid
|
||||
for the same period as your foreign CoC.
|
||||
</Text>
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput label="Foreign CoC Number" placeholder="e.g. PHL-COC-2022-0045" value={cocNo} onChange={(e) => setCocNo(e.currentTarget.value)} required />
|
||||
<TextInput label="Issuing Country" placeholder="e.g. Philippines" value={country} onChange={(e) => setCountry(e.currentTarget.value)} required />
|
||||
<TextInput label="Issuing Authority / Administration" placeholder="e.g. Maritime Industry Authority (MARINA)" value={issuer} onChange={(e) => setIssuer(e.currentTarget.value)} required />
|
||||
<TextInput label="Certificate Type" placeholder="e.g. Officer in Charge of a Navigational Watch" value={cocType} onChange={(e) => setCocType(e.currentTarget.value)} required />
|
||||
<TextInput label="Issue Date" type="date" value={issueDate} onChange={(e) => setIssueDate(e.currentTarget.value)} required />
|
||||
<TextInput label="Expiry Date" type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.currentTarget.value)} required />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 1 — Documents */}
|
||||
{step === 1 && (
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}>
|
||||
<Text fz="sm">
|
||||
A <strong>certified translation</strong> is required if your foreign CoC is not in English.
|
||||
All documents must be clear, legible, and complete.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Required Documents</Text>
|
||||
<Stack gap="md">
|
||||
{[
|
||||
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true },
|
||||
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false },
|
||||
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true },
|
||||
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true },
|
||||
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true },
|
||||
].map((slot) => (
|
||||
<FileInput
|
||||
key={slot.key}
|
||||
label={<Group gap={4}><Text fz="sm" fw={500}>{slot.label}</Text>{slot.required && <Badge size="xs" color="red" variant="light">Required</Badge>}</Group>}
|
||||
placeholder="Click to upload"
|
||||
leftSection={<IconUpload size={14} />}
|
||||
value={docs[slot.key]}
|
||||
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
clearable
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Upload checklist */}
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0">
|
||||
<Text fz="xs" fw={700} mb="sm" tt="uppercase" c="dimmed">Upload Checklist</Text>
|
||||
<Stack gap={4}>
|
||||
{[
|
||||
{ label: 'Foreign CoC', done: !!docs.foreignCoc },
|
||||
{ label: 'Medical Cert', done: !!docs.medical },
|
||||
{ label: 'Seaman Book', done: !!docs.seamanBook },
|
||||
{ label: 'Photo', done: !!docs.photo },
|
||||
].map((item) => (
|
||||
<Group key={item.label} gap="xs">
|
||||
<ThemeIcon size={18} radius="xl" color={item.done ? 'teal' : 'gray'} variant={item.done ? 'filled' : 'light'}>
|
||||
{item.done ? <IconCheck size={11} /> : <IconFileDescription size={11} />}
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon
|
||||
color={ok ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
>
|
||||
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
|
||||
</ThemeIcon>
|
||||
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Step 2 — Payment */}
|
||||
{step === 2 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Endorsement Fee</Text>
|
||||
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg">
|
||||
{[
|
||||
{ label: 'Application Processing Fee', amount: 300 },
|
||||
{ label: 'Document Verification Fee', amount: 200 },
|
||||
{ label: 'Endorsement Issuance Fee', amount: 500 },
|
||||
].map(({ label, amount }) => (
|
||||
<Group key={label} justify="space-between" mb="xs">
|
||||
<Text fz="sm">{label}</Text>
|
||||
<Text fz="sm" fw={600}>ETB {amount}</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Divider my="xs" />
|
||||
<Group justify="space-between">
|
||||
<Text fw={800}>Total</Text>
|
||||
<Text fw={800} fz="lg" c="blue">ETB 1,000</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />}>
|
||||
<Text fz="sm">
|
||||
Transfer the fee to <strong>CBE Account: 1000-XXXXX-EMA</strong> and upload the receipt below.
|
||||
</Text>
|
||||
</Alert>
|
||||
<FileInput label="Payment Receipt" placeholder="Upload bank transfer receipt" leftSection={<IconUpload size={14} />} mt="md" accept=".pdf,.jpg,.jpeg,.png" />
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3 — Review */}
|
||||
{step === 3 && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="lg">Review Your Application</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="lg">
|
||||
{[
|
||||
['CoC Number', cocNo],
|
||||
['Country', country],
|
||||
['Issuer', issuer],
|
||||
['CoC Type', cocType],
|
||||
['Issue Date', issueDate],
|
||||
['Expiry Date', expiryDate],
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Divider mb="md" />
|
||||
<Text fz="xs" fw={700} tt="uppercase" c="dimmed" mb="xs">Uploaded Documents</Text>
|
||||
<List spacing="xs" size="sm">
|
||||
{[
|
||||
{ label: 'Foreign CoC', file: docs.foreignCoc },
|
||||
{ label: 'Medical Certificate', file: docs.medical },
|
||||
{ label: 'Seaman Book', file: docs.seamanBook },
|
||||
{ label: 'Photo', file: docs.photo },
|
||||
{ label: 'Translation', file: docs.translation },
|
||||
].map(({ label, file }) => file && (
|
||||
<List.Item key={label} icon={<ThemeIcon size={18} radius="xl" color="teal" variant="filled"><IconCheck size={11} /></ThemeIcon>}>
|
||||
<Text fz="sm">{label}: <Text span c="blue.7">{file.name}</Text></Text>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={14} />} mt="lg">
|
||||
<Text fz="xs">
|
||||
By submitting you confirm that all information is accurate and the documents are genuine.
|
||||
Providing false information is an offence under the Maritime Code.
|
||||
</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Group justify="space-between" mt="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={14} />} onClick={() => setStep(s => s - 1)} disabled={step === 0}>
|
||||
Back
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
disabled={(step === 0 && !step0Ok) || (step === 1 && !step1Ok)}
|
||||
onClick={() => setStep(s => s + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="teal" leftSection={<IconCircleCheck size={14} />} onClick={() => setSubmitted(true)}>
|
||||
Submit Application
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
|
||||
* two application entry points (CoC / GOC), and the seafarer's endorsement
|
||||
* applications and issued endorsements. The wizard itself is the
|
||||
* config-driven licensing flow.
|
||||
*/
|
||||
export function EndorsementPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
const { profile, isLoading: loadingProfile } = useCurrentProfile();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
const {
|
||||
data: licenses,
|
||||
isFetching: fetchingLicenses,
|
||||
refetch: refetchLicenses,
|
||||
} = useGetMyLicensesQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const issuedTable = useServerTable();
|
||||
|
||||
const { data } = useApiQuery<EndorsementsOverview>({
|
||||
url: '/endorsements/my',
|
||||
method: 'GET',
|
||||
});
|
||||
const endorsementApps = data?.applications ?? [];
|
||||
const issuedEndorsements = data?.issued ?? [];
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
|
||||
if (applying) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => setApplying(false)}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>Apply for Endorsement</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag State Endorsement of Foreign CoC</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<ApplicationWizard onDone={() => setApplying(false)} />
|
||||
</Stack>
|
||||
const endorsementApplications = (applications?.items ?? []).filter((app) =>
|
||||
ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
);
|
||||
const inFlight = endorsementApplications.filter(
|
||||
(app) => !TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
const issued = (licenses?.items ?? []).filter((license) =>
|
||||
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
|
||||
);
|
||||
const issuedPage = issuedTable.paginate(issued);
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, t('endorsement.fetchFailed', 'Could not fetch endorsement')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingProfile || loadingApplications) {
|
||||
return <PageLoader label={t('endorsement.loading', 'Loading Endorsements…')} height={400} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
|
||||
<Stack maw={860} mx="auto">
|
||||
<Title order={2}>{t('endorsement.title', 'My Endorsements')}</Title>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Endorsements</Title>
|
||||
<Text fz="sm" c="dimmed">STCW Reg I/10 — Flag-state endorsement of foreign-issued Certificates of Competency</Text>
|
||||
<Text fw={600} mb={6}>
|
||||
{t('endorsement.eligibility.title', 'Eligibility')}
|
||||
</Text>
|
||||
<List spacing={4} size="sm">
|
||||
<EligibilityItem
|
||||
ok={registered}
|
||||
label={
|
||||
registered
|
||||
? t('endorsement.eligibility.registered', {
|
||||
defaultValue: 'Registered seafarer ({{number}})',
|
||||
number: profile?.seafarerNumber,
|
||||
})
|
||||
: t(
|
||||
'endorsement.eligibility.registrationRequired',
|
||||
'Active seafarer registration required',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
|
||||
Apply for Endorsement
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseCoc', 'Endorse a CoC')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
||||
>
|
||||
{t('endorsement.endorseGoc', 'Endorse a GOC')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
{t('endorsement.registrationNotice.prefix', 'Complete your')}{' '}
|
||||
<Text
|
||||
component="span"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
>
|
||||
{t('endorsement.registrationNotice.link', 'seafarer registration')}
|
||||
</Text>{' '}
|
||||
{t(
|
||||
'endorsement.registrationNotice.suffix',
|
||||
'first — endorsement applications are refused without it.',
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{t('endorsement.inProgress', 'Applications in progress')}</Title>
|
||||
{inFlight.map((app) => (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||
? t('applications.actions.continue', 'Continue')
|
||||
: t('applications.actions.view', 'View')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Info panel */}
|
||||
<Paper withBorder radius="lg" p="md" bg="var(--mantine-color-blue-light)">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<ThemeIcon size={48} radius="md" color="blue" variant="light"><IconRubberStamp size={24} /></ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} fz="sm">What is an Endorsement?</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
|
||||
{[
|
||||
{ icon: IconShieldCheck, color: 'blue', title: 'Flag-State Recognition', desc: 'Under STCW Reg I/10, Ethiopia (flag state) must endorse foreign CoC certificates before a seafarer can serve on Ethiopian-flagged vessels.' },
|
||||
{ icon: IconCircleCheck, color: 'teal', title: 'Co-Terminous Validity', desc: 'The endorsement is valid for the same period as your foreign CoC. It must be revalidated whenever the foreign CoC is revalidated.' },
|
||||
{ icon: IconClock, color: 'orange', title: 'Processing Time', desc: 'Typical processing time is 10–15 working days after all documents are verified.' },
|
||||
].map(({ icon: Icon, color, title, desc }) => (
|
||||
<Card key={title} withBorder radius="md" p="sm">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
||||
<Text fz="xs" fw={700}>{title}</Text>
|
||||
</Group>
|
||||
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Active applications */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Endorsement Applications</Text>
|
||||
{endorsementApps.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No active endorsement applications.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{endorsementApps.map((app) => (
|
||||
<Paper key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={700}>{app.cocType}</Text>
|
||||
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {formatDate(app.submitted)}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_COLOR[app.status] ?? "gray"} variant="light">{humanStatus(app.status)}</Badge>
|
||||
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Alert variant="light" color={STATUS_COLOR[app.status] ?? "gray"} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
|
||||
<Text fz="xs">{humanStatus(app.status)}</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Issued endorsements */}
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">My Endorsements</Text>
|
||||
{issuedEndorsements.length === 0 ? (
|
||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
||||
No endorsements issued yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{issuedEndorsements.map((end) => (
|
||||
<Card key={end.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">{end.cocType}</Text>
|
||||
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[end.status] ?? "gray"} variant="light">{humanStatus(end.status)}</Badge>
|
||||
</Group>
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={2} spacing="xs" mb="sm">
|
||||
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
|
||||
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
|
||||
</SimpleGrid>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
|
||||
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Preview modal */}
|
||||
<Modal
|
||||
opened={!!previewId}
|
||||
onClose={() => setPreviewId(null)}
|
||||
title={<Text fw={700} fz="sm">Endorsement Certificate</Text>}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<iframe src={BLANK_PDF} style={{ width: '100%', height: '70vh', border: 'none', borderRadius: rem(8) }} title="Endorsement" />
|
||||
</Modal>
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>{t('endorsement.issuedEndorsements', 'Issued endorsements')}</Title>
|
||||
<AdvancedTable
|
||||
tableName={t('endorsement.issuedEndorsements', 'Issued endorsements')}
|
||||
columns={endorsementColumns({ t, showDate, localized, onDownload: download })}
|
||||
data={issuedPage.rows}
|
||||
itemCount={issuedPage.itemCount}
|
||||
pageIndex={issuedPage.pageIndex}
|
||||
onPageChange={issuedTable.setPageIndex}
|
||||
pageSize={issuedTable.pageSize}
|
||||
refresh={refetchLicenses}
|
||||
isLoading={fetchingLicenses}
|
||||
emptyText={t('endorsement.emptyIssued', 'No endorsements issued yet.')}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementPage;
|
||||
|
||||
71
apps/portal/src/app/features/endorsement/pages/columns.tsx
Normal file
71
apps/portal/src/app/features/endorsement/pages/columns.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconCertificate } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||
|
||||
interface EndorsementColumnsArgs {
|
||||
t: TFunction;
|
||||
showDate: (date: string) => string;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
onDownload: (licenseId: string) => void;
|
||||
}
|
||||
|
||||
export function endorsementColumns({
|
||||
t,
|
||||
showDate,
|
||||
localized,
|
||||
onDownload,
|
||||
}: EndorsementColumnsArgs): AdvancedColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
header: t('endorsement.columns.certificateNumber', 'Certificate №'),
|
||||
accessorKey: 'certificateNumber',
|
||||
cell: ({ row }) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{row.original.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('endorsement.columns.type', 'Type'),
|
||||
cell: ({ row }) => localized(row.original.licenseType?.name),
|
||||
},
|
||||
{
|
||||
header: t('endorsement.columns.issued', 'Issued'),
|
||||
cell: ({ row }) => showDate(row.original.issueDate),
|
||||
},
|
||||
{
|
||||
header: t('endorsement.columns.expires', 'Expires'),
|
||||
cell: ({ row }) => showDate(row.original.expiryDate),
|
||||
},
|
||||
{
|
||||
header: t('common.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{t(
|
||||
`endorsement.columns.licenseStatus.${row.original.status}`,
|
||||
row.original.status,
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => onDownload(row.original.id)}
|
||||
>
|
||||
{t('common.download')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
|
||||
@@ -21,17 +22,20 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
|
||||
export function registrationColumns(deps: {
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||
can: (required?: string[]) => boolean;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
onDownloadSlip: (registration: MyRegistration) => void;
|
||||
onStartExam: (registration: MyRegistration) => void;
|
||||
}): AdvancedColumn<MyRegistration>[] {
|
||||
},
|
||||
): AdvancedColumn<MyRegistration>[] {
|
||||
return [
|
||||
{
|
||||
header: 'Admission №',
|
||||
header: t('exams.columns.admission'),
|
||||
cell: ({ row }) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{row.original.admissionNumber}
|
||||
@@ -39,19 +43,19 @@ export function registrationColumns(deps: {
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Examination',
|
||||
header: t('exams.columns.examination'),
|
||||
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
|
||||
},
|
||||
{
|
||||
header: 'Date',
|
||||
header: t('exams.columns.date'),
|
||||
cell: ({ row }) => deps.showDate(row.original.exam?.date),
|
||||
},
|
||||
{
|
||||
header: 'Venue',
|
||||
header: t('exams.columns.venue'),
|
||||
cell: ({ row }) => row.original.exam?.venue ?? '—',
|
||||
},
|
||||
{
|
||||
header: 'Attempt',
|
||||
header: t('exams.columns.attempt'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
@@ -59,25 +63,25 @@ export function registrationColumns(deps: {
|
||||
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{row.original.kind === 'RETAKE'
|
||||
? `Retake · ${row.original.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
? t('exams.columns.retake', { n: row.original.attemptNumber })
|
||||
: t('exams.columns.firstSitting')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Attendance',
|
||||
header: t('exams.columns.attendance'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{row.original.attendanceStatus}
|
||||
{t(`exams.columns.attendanceStatus.${row.original.attendanceStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Slip',
|
||||
header: t('exams.columns.slip'),
|
||||
cell: ({ row }) =>
|
||||
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
|
||||
<Button
|
||||
@@ -86,12 +90,12 @@ export function registrationColumns(deps: {
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => deps.onDownloadSlip(row.original)}
|
||||
>
|
||||
Slip
|
||||
{t('exams.columns.slip')}
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
header: 'Exam',
|
||||
header: t('exams.columns.exam'),
|
||||
cell: ({ row }) => {
|
||||
const exam = row.original.exam;
|
||||
const attemptStatus = row.original.attempt?.status;
|
||||
@@ -100,14 +104,14 @@ export function registrationColumns(deps: {
|
||||
if (attemptStatus === 'SUBMITTED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
Completed
|
||||
{t('exams.columns.completed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (attemptStatus === 'EXPIRED') {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Time expired
|
||||
{t('exams.columns.timeExpired')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -121,7 +125,9 @@ export function registrationColumns(deps: {
|
||||
leftSection={<IconPlayerPlay size={13} />}
|
||||
onClick={() => deps.onStartExam(row.original)}
|
||||
>
|
||||
{attemptStatus === 'IN_PROGRESS' ? 'Resume exam' : 'Take exam'}
|
||||
{attemptStatus === 'IN_PROGRESS'
|
||||
? t('exams.columns.resumeExam')
|
||||
: t('exams.columns.takeExam')}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
@@ -129,25 +135,28 @@ export function registrationColumns(deps: {
|
||||
];
|
||||
}
|
||||
|
||||
export function resultColumns(deps: {
|
||||
export function resultColumns(
|
||||
t: TFunction,
|
||||
deps: {
|
||||
/** Permission check from usePermissions() — hooks can't run in a cell. */
|
||||
can: (required?: string[]) => boolean;
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
appeals: MyAppeal[];
|
||||
onAppeal: (result: MyResult) => void;
|
||||
}): AdvancedColumn<MyResult>[] {
|
||||
},
|
||||
): AdvancedColumn<MyResult>[] {
|
||||
return [
|
||||
{
|
||||
header: 'Examination',
|
||||
header: t('exams.columns.examination'),
|
||||
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
|
||||
},
|
||||
{
|
||||
header: 'Published',
|
||||
header: t('exams.columns.published'),
|
||||
cell: ({ row }) => deps.showDate(row.original.publishedAt),
|
||||
},
|
||||
{
|
||||
header: 'Score',
|
||||
header: t('exams.columns.score'),
|
||||
cell: ({ row }) => (
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.totalScore}
|
||||
@@ -155,23 +164,24 @@ export function resultColumns(deps: {
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Outcome',
|
||||
header: t('exams.columns.outcome'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={row.original.status === 'PASSED' ? 'teal' : 'red'}
|
||||
>
|
||||
{row.original.status}
|
||||
{t(`exams.columns.outcomeStatus.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Appeal',
|
||||
header: t('exams.columns.appeal'),
|
||||
cell: ({ row }) => {
|
||||
const appeal = deps.appeals.find((a) => a.resultId === row.original.id);
|
||||
return appeal ? (
|
||||
<Badge size="sm" variant="light" color="grape">
|
||||
{appeal.appealNumber} · {appeal.status}
|
||||
{appeal.appealNumber} ·{' '}
|
||||
{t(`exams.columns.appealStatus.${appeal.status}`)}
|
||||
</Badge>
|
||||
) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
|
||||
<Button
|
||||
@@ -181,7 +191,7 @@ export function resultColumns(deps: {
|
||||
leftSection={<IconGavel size={13} />}
|
||||
onClick={() => deps.onAppeal(row.original)}
|
||||
>
|
||||
Appeal
|
||||
{t('exams.columns.appeal')}
|
||||
</Button>
|
||||
) : null;
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -13,7 +12,8 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconClipboardList } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdvancedTable, PageLoader, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
useApiQuery,
|
||||
@@ -82,6 +82,7 @@ export interface MyAppeal {
|
||||
* when a mark looks wrong.
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
@@ -127,18 +128,21 @@ export function ExamsPage() {
|
||||
method: 'POST',
|
||||
}).unwrap()) as { admissionNumber?: string };
|
||||
notify.success(
|
||||
`Registered — admission number ${result.admissionNumber ?? 'issued'}`,
|
||||
t('exams.notify.registered', {
|
||||
admissionNumber:
|
||||
result.admissionNumber ?? t('exams.notify.admissionNumberPending'),
|
||||
}),
|
||||
);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Could not register');
|
||||
const key = extractErrorMessage(error, t('exams.notify.registerFailed'));
|
||||
notify.error(
|
||||
key === 'seafarer_registration_required'
|
||||
? 'An active seafarer registration is required to sit examinations.'
|
||||
? t('exams.notify.seafarerRequired')
|
||||
: key === 'already_registered_for_exam'
|
||||
? 'You are already registered for this session.'
|
||||
? t('exams.notify.alreadyRegistered')
|
||||
: key === 'subject_already_passed'
|
||||
? 'You have already passed this subject — a resit is not needed.'
|
||||
? t('exams.notify.alreadyPassed')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
@@ -152,7 +156,7 @@ export function ExamsPage() {
|
||||
);
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, 'Could not generate the admission slip'),
|
||||
extractErrorMessage(error, t('exams.notify.slipFailed')),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -165,28 +169,26 @@ export function ExamsPage() {
|
||||
method: 'POST',
|
||||
body: { reason: appealReason.trim() },
|
||||
}).unwrap()) as { appealNumber?: string };
|
||||
notify.success(`Appeal ${appeal.appealNumber ?? ''} submitted`);
|
||||
notify.success(
|
||||
t('exams.notify.appealSubmitted', { appealNumber: appeal.appealNumber ?? '' }),
|
||||
);
|
||||
setAppealFor(null);
|
||||
setAppealReason('');
|
||||
refetchAppeals();
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Could not submit the appeal');
|
||||
const key = extractErrorMessage(error, t('exams.notify.appealFailed'));
|
||||
notify.error(
|
||||
key.startsWith('appeal_window_closed')
|
||||
? `The appeal window (${key.split(':')[1] ?? ''} days from publication) has closed.`
|
||||
? t('exams.notify.appealWindowClosed', { days: key.split(':')[1] ?? '' })
|
||||
: key === 'appeal_already_open'
|
||||
? 'An appeal on this result is already being considered.'
|
||||
? t('exams.notify.appealAlreadyOpen')
|
||||
: key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingOpen || loadingMine || loadingResults) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
return <PageLoader label={t('exams.loading')} height={400} />;
|
||||
}
|
||||
|
||||
const pagedRegistrations = registrationTable.paginate(mine ?? []);
|
||||
@@ -194,14 +196,14 @@ export function ExamsPage() {
|
||||
|
||||
return (
|
||||
<Stack maw={900} mx="auto">
|
||||
<Title order={2}>Examinations</Title>
|
||||
<Title order={2}>{t('exams.title')}</Title>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Open sessions</Title>
|
||||
<Title order={4}>{t('exams.openSessions')}</Title>
|
||||
{(open ?? []).length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No upcoming sessions are open for registration.
|
||||
{t('exams.noOpenSessions')}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -218,7 +220,7 @@ export function ExamsPage() {
|
||||
</div>
|
||||
{registeredExamIds.has(exam.id) ? (
|
||||
<Badge color="teal" variant="light">
|
||||
Registered
|
||||
{t('exams.registered')}
|
||||
</Badge>
|
||||
) : (
|
||||
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly>
|
||||
@@ -228,7 +230,7 @@ export function ExamsPage() {
|
||||
leftSection={<IconClipboardList size={14} />}
|
||||
onClick={() => register(exam)}
|
||||
>
|
||||
Register
|
||||
{t('exams.register')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
@@ -239,10 +241,10 @@ export function ExamsPage() {
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>My registrations</Title>
|
||||
<Title order={4}>{t('exams.myRegistrations')}</Title>
|
||||
<AdvancedTable<MyRegistration>
|
||||
tableName="My registrations"
|
||||
columns={registrationColumns({
|
||||
tableName={t('exams.myRegistrations')}
|
||||
columns={registrationColumns(t, {
|
||||
can,
|
||||
localized,
|
||||
showDate,
|
||||
@@ -255,15 +257,15 @@ export function ExamsPage() {
|
||||
onPageChange={registrationTable.setPageIndex}
|
||||
pageSize={registrationTable.pageSize}
|
||||
refresh={refetch}
|
||||
emptyText="No exam registrations yet."
|
||||
emptyText={t('exams.noRegistrations')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>My results</Title>
|
||||
<Title order={4}>{t('exams.myResults')}</Title>
|
||||
<AdvancedTable<MyResult>
|
||||
tableName="My results"
|
||||
columns={resultColumns({
|
||||
tableName={t('exams.myResults')}
|
||||
columns={resultColumns(t, {
|
||||
can,
|
||||
localized,
|
||||
showDate,
|
||||
@@ -276,40 +278,42 @@ export function ExamsPage() {
|
||||
onPageChange={resultTable.setPageIndex}
|
||||
pageSize={resultTable.pageSize}
|
||||
refresh={refetchResults}
|
||||
emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them."
|
||||
emptyText={t('exams.noResults')}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(appealFor)}
|
||||
onClose={() => setAppealFor(null)}
|
||||
title="Request a review of this result"
|
||||
title={t('exams.appealModal.title')}
|
||||
radius="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Explain what you believe went wrong with the marking or the
|
||||
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
|
||||
Appeals must be lodged within 14 days of publication.
|
||||
{t('exams.appealModal.body', {
|
||||
examTitle:
|
||||
localized(appealFor?.exam?.title) ||
|
||||
t('exams.appealModal.defaultExamTitle'),
|
||||
})}
|
||||
</Text>
|
||||
<Textarea
|
||||
minRows={4}
|
||||
autosize
|
||||
label="Grounds for appeal"
|
||||
label={t('exams.appealModal.reasonLabel')}
|
||||
value={appealReason}
|
||||
onChange={(event) => setAppealReason(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setAppealFor(null)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={appealing}
|
||||
disabled={appealReason.trim().length === 0}
|
||||
onClick={submitAppeal}
|
||||
>
|
||||
Submit appeal
|
||||
{t('exams.appealModal.submit')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Badge,
|
||||
Divider,
|
||||
Group,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
conditionHolds,
|
||||
displayFieldValue,
|
||||
type Attachment,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type LicenseTypeRequirements,
|
||||
} from "@ema-platform/api";
|
||||
import { useDateDisplayer } from "@ema-platform/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentSlots } from "./DocumentSlots";
|
||||
|
||||
interface Props {
|
||||
/** Every form section (not just the "review" group) in wizard-step order. */
|
||||
sections: FormSectionConfig[];
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
localized: (value: { en?: string; am?: string } | undefined) => string;
|
||||
config: LicenseTypeRequirements;
|
||||
attachments: Attachment[];
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only "what was filed" view for a submitted application: every
|
||||
* answered section as a labelled table, then the uploaded documents.
|
||||
*
|
||||
* Shown instead of the wizard once there is nothing left to step through —
|
||||
* the stepper is for filling a form in, not for re-reading one that is
|
||||
* already someone else's decision to make.
|
||||
*/
|
||||
export function ApplicationSummary({
|
||||
sections,
|
||||
formData,
|
||||
localized,
|
||||
config,
|
||||
attachments,
|
||||
applicationId,
|
||||
}: Props) {
|
||||
const showDate = useDateDisplayer();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
// Shared with the officer's review screen, so the applicant and the reviewer
|
||||
// never read the same answer two different ways.
|
||||
const display = (field: FormFieldConfig, raw: unknown) =>
|
||||
displayFieldValue(field, raw, {
|
||||
language: i18n.language,
|
||||
showDate,
|
||||
currency: config.feeCurrency,
|
||||
}) || "—";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{sections.map((section) => {
|
||||
const fields = (section.fields ?? []).filter((f) =>
|
||||
conditionHolds(f.showWhen, formData),
|
||||
);
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder p="lg" radius="md" key={section.key}>
|
||||
<Group justify="space-between" align="center" mb="xs">
|
||||
<Title order={5}>{localized(section.title)}</Title>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{fields.length} {fields.length === 1 ? "detail" : "details"}
|
||||
</Badge>
|
||||
</Group>
|
||||
{localized(section.description) && (
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{localized(section.description)}
|
||||
</Text>
|
||||
)}
|
||||
<Divider mb="md" />
|
||||
|
||||
{/* Label above value in two columns — a definition list reads far
|
||||
better than a bordered grid when most answers are short. */}
|
||||
<Grid gutter="md">
|
||||
{fields.map((field) => {
|
||||
const value = display(
|
||||
field,
|
||||
formData[section.key]?.[field.key],
|
||||
);
|
||||
const answered = value !== "—";
|
||||
return (
|
||||
<Grid.Col
|
||||
span={{
|
||||
base: 12,
|
||||
sm: field.type === "TEXTAREA" ? 12 : 6,
|
||||
}}
|
||||
key={field.key}
|
||||
>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{localized(field.label)}
|
||||
</Text>
|
||||
<Text
|
||||
fz="sm"
|
||||
mt={2}
|
||||
c={answered ? undefined : "dimmed"}
|
||||
fs={answered ? undefined : "italic"}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{answered ? value : "Not provided"}
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Title order={5} mb="sm">
|
||||
Documents
|
||||
</Title>
|
||||
<Divider mb="md" />
|
||||
<DocumentSlots
|
||||
requirements={config.documentRequirements}
|
||||
attachments={attachments}
|
||||
formData={formData}
|
||||
ownerType="APPLICATION"
|
||||
ownerId={applicationId}
|
||||
readOnly
|
||||
onUploaded={() => {
|
||||
// Read-only here — nothing to react to, but DocumentSlots
|
||||
// requires the callback.
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Checkbox,
|
||||
Grid,
|
||||
Input,
|
||||
NumberInput,
|
||||
Select,
|
||||
Textarea,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
interface Props {
|
||||
section: FormSectionConfig;
|
||||
@@ -97,6 +100,7 @@ export function ConfigDrivenSection({
|
||||
onVesselSelected,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
const fields = [...(section.fields ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
@@ -127,10 +131,32 @@ export function ConfigDrivenSection({
|
||||
// own vessel register, so this overrides whatever type the backend
|
||||
// configured, the same way nationality overrides SELECT above.
|
||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
||||
// Stores a location-tree uuid, so it needs the cascading picker the
|
||||
// profile's Address tab uses — configured as TEXT because the field
|
||||
// types have no LOCATION member, which left a required field asking
|
||||
// the applicant to type a uuid by hand.
|
||||
const isLocation = field.key === 'locationId' || labelEn.trim() === 'location';
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||
{isNationality ? (
|
||||
{isLocation ? (
|
||||
// LocationPicker renders its own cascade of Selects and takes no
|
||||
// label/error props, so the wrapper supplies them.
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
description={localized(field.helpText) || undefined}
|
||||
withAsterisk={field.required}
|
||||
error={error}
|
||||
>
|
||||
<LocationPicker
|
||||
value={(value as string) ?? undefined}
|
||||
onChange={(id) => onChange(field.key, id)}
|
||||
required={field.required}
|
||||
maxDepth={3}
|
||||
disabled={common.disabled}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
) : isNationality ? (
|
||||
<CountrySelect
|
||||
{...common}
|
||||
demonym
|
||||
@@ -140,7 +166,7 @@ export function ConfigDrivenSection({
|
||||
) : isVesselPicker ? (
|
||||
<Select
|
||||
{...common}
|
||||
placeholder="Select a registered vessel"
|
||||
placeholder={t('licensing.vesselPicker.placeholder')}
|
||||
data={vessels.map((v) => ({ value: v.id, label: `${v.name} — ${v.registrationNumber}` }))}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconCheck,
|
||||
IconFileUpload,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
conditionHolds,
|
||||
@@ -24,6 +22,8 @@ import {
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@@ -59,8 +59,12 @@ export function DocumentSlots({
|
||||
readOnly,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
|
||||
null,
|
||||
);
|
||||
const resetRefs = useRef<Record<string, () => void>>({});
|
||||
|
||||
const required = requirements.filter(
|
||||
@@ -73,7 +77,11 @@ export function DocumentSlots({
|
||||
async function handle(documentKey: string, file: File | null) {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
|
||||
setError(
|
||||
t('licensing.msg.fileTooLarge', {
|
||||
size: `${(file.size / 1024 / 1024).toFixed(1)}MB`,
|
||||
}),
|
||||
);
|
||||
resetRefs.current[documentKey]?.();
|
||||
return;
|
||||
}
|
||||
@@ -96,6 +104,7 @@ export function DocumentSlots({
|
||||
{required.map((requirement) => {
|
||||
const existing = attachments.find((a) => a.documentKey === requirement.key);
|
||||
const uploaded = Boolean(existing?.files?.length);
|
||||
const fileUrl = existing?.files?.[0]?.url;
|
||||
const flagRemark = flagged[requirement.key];
|
||||
const locked = readOnly || (restrictToFlagged && !flagRemark);
|
||||
|
||||
@@ -121,17 +130,17 @@ export function DocumentSlots({
|
||||
</Text>
|
||||
{requirement.mode === 'CONDITIONAL' && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
conditional
|
||||
{t('licensing.documents.conditional')}
|
||||
</Badge>
|
||||
)}
|
||||
{requirement.mode === 'OPTIONAL' && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
optional
|
||||
{t('common.optional')}
|
||||
</Badge>
|
||||
)}
|
||||
{uploaded && !flagRemark && (
|
||||
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
uploaded
|
||||
{t('licensing.documents.uploaded')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -143,21 +152,24 @@ export function DocumentSlots({
|
||||
)}
|
||||
{flagRemark && (
|
||||
<Text size="xs" c="orange.7" mt={4}>
|
||||
Officer: {flagRemark}
|
||||
{t('licensing.documents.officerRemark', { name: flagRemark })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{existing?.files?.[0]?.url && (
|
||||
{fileUrl && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
component="a"
|
||||
href={existing.files[0].url}
|
||||
target="_blank"
|
||||
onClick={() =>
|
||||
setPreview({
|
||||
url: fileUrl,
|
||||
title: localized(requirement.name),
|
||||
})
|
||||
}
|
||||
>
|
||||
View
|
||||
{t('licensing.documents.view')}
|
||||
</Button>
|
||||
)}
|
||||
{!locked && (
|
||||
@@ -175,14 +187,16 @@ export function DocumentSlots({
|
||||
variant={uploaded ? 'light' : 'filled'}
|
||||
leftSection={
|
||||
busy === requirement.key ? (
|
||||
<Loader size={12} />
|
||||
<Loader size={12} type="oval" />
|
||||
) : (
|
||||
<IconFileUpload size={14} />
|
||||
)
|
||||
}
|
||||
disabled={busy === requirement.key}
|
||||
>
|
||||
{uploaded ? 'Replace' : 'Upload'}
|
||||
{uploaded
|
||||
? t('licensing.documents.replace')
|
||||
: t('licensing.documents.upload')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
@@ -192,6 +206,12 @@ export function DocumentSlots({
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(preview)}
|
||||
onClose={() => setPreview(null)}
|
||||
url={preview?.url ?? ''}
|
||||
title={preview?.title}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
LICENSE_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
*/
|
||||
export function useRenewLicense() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [createApplication, { isLoading: isRenewing }] =
|
||||
useCreateApplicationMutation();
|
||||
|
||||
@@ -50,7 +52,7 @@ export function useRenewLicense() {
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), 'Could not start the renewal');
|
||||
notify.error(extractErrorMessage(err), t('licensing.card.renewFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,13 +84,14 @@ export function LicenseCard({
|
||||
const renewable = license.renewable ?? false;
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(license.licenseType?.name) || 'Licence'}
|
||||
{localized(license.licenseType?.name) || t('licensing.card.fallbackName')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{license.certificateNumber}
|
||||
@@ -99,7 +102,7 @@ export function LicenseCard({
|
||||
variant="light"
|
||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
||||
>
|
||||
{expired ? 'Expired' : license.status}
|
||||
{expired ? t('licensing.card.expired') : license.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
@@ -107,11 +110,10 @@ export function LicenseCard({
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{expired ? 'Expired on' : 'Valid until'}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{showDate(license.expiryDate)}
|
||||
{expired
|
||||
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
|
||||
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
|
||||
</Text>
|
||||
</Box>
|
||||
<RequirePermission
|
||||
@@ -121,7 +123,7 @@ export function LicenseCard({
|
||||
]}
|
||||
hideOnly
|
||||
>
|
||||
<Tooltip label="Download certificate">
|
||||
<Tooltip label={t('licensing.card.downloadCertificate')}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="md"
|
||||
@@ -150,8 +152,8 @@ export function LicenseCard({
|
||||
onClick={onRenew}
|
||||
>
|
||||
{expired
|
||||
? 'Renew — this licence has expired'
|
||||
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
||||
? t('licensing.card.renewExpired')
|
||||
: t('licensing.card.renewDays', { count: days })}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseCategory, LicenseType } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* The licence catalogue an applicant chooses from, grouped by category.
|
||||
@@ -52,16 +53,17 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
|
||||
WAIVER_SERVICES: IconShieldOff,
|
||||
};
|
||||
|
||||
function formatFee(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return 'No fee';
|
||||
function formatFee(amount: string | number | null, currency: string, noFeeLabel: string): string {
|
||||
if (amount === null || amount === '') return noFeeLabel;
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value)) return 'No fee';
|
||||
if (!Number.isFinite(value)) return noFeeLabel;
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
export function LicenseCatalogue() {
|
||||
const navigate = useNavigate();
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
const { data: types } = useGetLicenseTypesQuery();
|
||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
||||
@@ -79,10 +81,15 @@ export function LicenseCatalogue() {
|
||||
const { groups, orphans } = useMemo(() => {
|
||||
const active = (types?.items ?? [])
|
||||
.filter((t) => t.isActive)
|
||||
// Person-centric registrations (seafarer) are not operator licences:
|
||||
// they can never be declared as a mode, have their own entry points,
|
||||
// and would only confuse this catalogue — even under "show all".
|
||||
.filter((t) => t.requiresOperatorMode !== false)
|
||||
// Logistics licences only: this is the operator catalogue, not the
|
||||
// seafarer certificate or vessel/seafarer document catalogue — those
|
||||
// have their own entry points. `familyKind` is the real data-model
|
||||
// classification (set on the type at seed time); `requiresOperatorMode`
|
||||
// was the proxy this used before that column existed and happened to
|
||||
// agree for every type seeded so far, but a type can only be trusted to
|
||||
// stay in sync with the catalogue it belongs in if the catalogue reads
|
||||
// its actual family instead of a flag with a different purpose.
|
||||
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
|
||||
// Only what the applicant operates as. The server enforces the same rule
|
||||
// on create; this is what stops them starting an application they will
|
||||
// be refused at the end of.
|
||||
@@ -116,19 +123,17 @@ export function LicenseCatalogue() {
|
||||
<IconBuildingWarehouse size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
Tell us what you operate as
|
||||
{t('licensing.catalogue.emptyTitle')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={520}>
|
||||
Licences are offered against your mode of operation — freight
|
||||
forwarder, shipping agent, multimodal transport operator and so on.
|
||||
Choose yours and the licences you can apply for appear here.
|
||||
{t('licensing.catalogue.emptyBody')}
|
||||
</Text>
|
||||
<Group gap="sm" mt="xs">
|
||||
<Button size="xs" onClick={() => navigate('/profile#operations')}>
|
||||
Set my operations
|
||||
{t('licensing.catalogue.setOperations')}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" onClick={() => setShowAll(true)}>
|
||||
Browse all licences
|
||||
{t('licensing.catalogue.browseAll')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -145,8 +150,7 @@ export function LicenseCatalogue() {
|
||||
<IconFileText size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No licence types are available yet. Contact EMA if you were
|
||||
expecting one.
|
||||
{t('licensing.catalogue.noneAvailable')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -167,10 +171,10 @@ export function LicenseCatalogue() {
|
||||
{showAll && hasDeclared && (
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Showing every licence, including ones outside your operations.
|
||||
{t('licensing.catalogue.showingAll')}
|
||||
</Text>
|
||||
<Anchor size="xs" onClick={() => setShowAll(false)}>
|
||||
Show only mine
|
||||
{t('licensing.catalogue.showOnlyMine')}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
@@ -188,8 +192,8 @@ export function LicenseCatalogue() {
|
||||
{orphans.length > 0 && (
|
||||
<CategoryGroup
|
||||
icon={IconFileText}
|
||||
title="Other licences"
|
||||
description="Licence types that have not been assigned a category."
|
||||
title={t('licensing.catalogue.otherLicences')}
|
||||
description={t('licensing.catalogue.otherLicencesDescription')}
|
||||
licenseTypes={orphans}
|
||||
canApply={canApply}
|
||||
onSelect={select}
|
||||
@@ -198,10 +202,10 @@ export function LicenseCatalogue() {
|
||||
{hasDeclared && !showAll && (
|
||||
<Group gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Only licences matching your declared operations are shown.
|
||||
{t('licensing.catalogue.showingMine')}
|
||||
</Text>
|
||||
<Anchor size="xs" onClick={() => setShowAll(true)}>
|
||||
Browse all licences
|
||||
{t('licensing.catalogue.browseAll')}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
@@ -261,6 +265,7 @@ function LicenseTypeCard({
|
||||
onSelect: (type: LicenseType) => void;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||
|
||||
return (
|
||||
@@ -293,23 +298,23 @@ function LicenseTypeCard({
|
||||
<Box>
|
||||
<Group gap={6} mt="sm">
|
||||
<Badge size="sm" variant="light" color="emaPrimary">
|
||||
{formatFee(type.feeNewApplication, type.feeCurrency)}
|
||||
{formatFee(type.feeNewApplication, type.feeCurrency, t('licensing.catalogue.noFee'))}
|
||||
</Badge>
|
||||
{capital && (
|
||||
<Tooltip label="Minimum capital that must be evidenced by a bank letter">
|
||||
<Tooltip label={t('licensing.catalogue.capitalTooltip')}>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Capital {capital.toLocaleString('en-US')}
|
||||
{t('licensing.catalogue.capitalBadge', { amount: capital.toLocaleString('en-US') })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{type.issuesCertificate ? (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
{type.validityMonths} months
|
||||
{t('licensing.catalogue.validityBadge', { months: type.validityMonths })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="Concludes with an EMA decision rather than a certificate">
|
||||
<Tooltip label={t('licensing.catalogue.evaluationTooltip')}>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Evaluation only
|
||||
{t('licensing.catalogue.evaluationOnly')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -323,7 +328,9 @@ function LicenseTypeCard({
|
||||
color={canApply ? undefined : 'gray'}
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
>
|
||||
{canApply ? 'Start application' : 'Add to my operations'}
|
||||
{canApply
|
||||
? t('licensing.catalogue.startApplication')
|
||||
: t('licensing.catalogue.addToOperations')}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</Box>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { ActionIcon, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { IconCheck, IconEye } from '@tabler/icons-react';
|
||||
import {
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
useGetAttachmentsQuery,
|
||||
type StaffEvidenceRequirement,
|
||||
} from '@ema-platform/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PdfPreviewModal } from '@ema-platform/ui';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@@ -30,26 +32,34 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
ownerId: staffId,
|
||||
});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
|
||||
null,
|
||||
);
|
||||
const localized = useLocalized();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!evidence?.length) return null;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{evidence.map((item) => {
|
||||
const uploaded = attachments.some(
|
||||
const attachment = attachments.find(
|
||||
(a) => a.documentKey === item.docKey && a.files?.length,
|
||||
);
|
||||
const uploaded = Boolean(attachment);
|
||||
const fileUrl = attachment?.files?.[0]?.url;
|
||||
return (
|
||||
<Group key={item.docKey} gap={4} wrap="nowrap">
|
||||
<FileButton
|
||||
key={item.docKey}
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message: `File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`,
|
||||
message: t('licensing.msg.fileTooLarge', {
|
||||
size: `${(file.size / 1024 / 1024).toFixed(1)}MB`,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -74,7 +84,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
disabled={readOnly || busy === item.docKey}
|
||||
leftSection={
|
||||
busy === item.docKey ? (
|
||||
<Loader size={10} />
|
||||
<Loader size={10} type="oval" />
|
||||
) : uploaded ? (
|
||||
<IconCheck size={12} />
|
||||
) : undefined
|
||||
@@ -85,8 +95,27 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
{uploaded && fileUrl && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
aria-label={t('licensing.documents.view', 'View')}
|
||||
onClick={() =>
|
||||
setPreview({ url: fileUrl, title: localized(item.label) })
|
||||
}
|
||||
>
|
||||
<IconEye size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
<PdfPreviewModal
|
||||
opened={Boolean(preview)}
|
||||
onClose={() => setPreview(null)}
|
||||
url={preview?.url ?? ''}
|
||||
title={preview?.title}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
@@ -20,16 +18,17 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconPencil,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
buildWizardSteps,
|
||||
conditionHolds,
|
||||
@@ -37,6 +36,8 @@ import {
|
||||
extractValidationIssues,
|
||||
useLocalized,
|
||||
validateSections,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
useAddStaffMutation,
|
||||
useCreateApplicationMutation,
|
||||
useGetApplicationQuery,
|
||||
@@ -52,17 +53,52 @@ import {
|
||||
type FormFieldConfig,
|
||||
type ValidationIssue,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui';
|
||||
} from "@ema-platform/api";
|
||||
import {
|
||||
getCountryCode,
|
||||
getCountryName,
|
||||
ModalFooter,
|
||||
splitPersonName,
|
||||
} from "@ema-platform/ui";
|
||||
import {
|
||||
LICENSE_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
RequirePermission,
|
||||
useCurrentProfile,
|
||||
} from '@ema-platform/auth';
|
||||
import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection';
|
||||
import { DocumentSlots } from '../components/DocumentSlots';
|
||||
import { StaffEvidence } from '../components/StaffEvidence';
|
||||
} from "@ema-platform/auth";
|
||||
import { ApplicationSummary } from "../components/ApplicationSummary";
|
||||
import {
|
||||
ConfigDrivenSection,
|
||||
fillFromVessel,
|
||||
} from "../components/ConfigDrivenSection";
|
||||
import { DocumentSlots } from "../components/DocumentSlots";
|
||||
import { StaffEvidence } from "../components/StaffEvidence";
|
||||
import { useAppSelector } from "../../../store/hooks";
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
|
||||
function readSourcePath(
|
||||
context: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, key) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[key]
|
||||
: undefined,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// Name fields predate the generic `source` metadata in some persisted form
|
||||
// schemas. Keep their profile mapping here so existing applications/configs
|
||||
// receive the same prefill as newly seeded schemas.
|
||||
const LEGACY_PROFILE_SOURCES: Record<string, string> = {
|
||||
firstName: "profile.firstName",
|
||||
middleName: "profile.middleName",
|
||||
lastName: "profile.lastName",
|
||||
};
|
||||
|
||||
/**
|
||||
* The applicant wizard, rendered entirely from the license type's
|
||||
@@ -70,10 +106,11 @@ import { StaffEvidence } from '../components/StaffEvidence';
|
||||
* `typeCode` decides which configuration is loaded.
|
||||
*/
|
||||
export function LicenseApplicationPage() {
|
||||
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
||||
const { typeCode = "FREIGHT_FORWARDER", applicationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
@@ -86,6 +123,9 @@ export function LicenseApplicationPage() {
|
||||
|
||||
// Create (or resume) the draft up front, so uploads have a real owner to
|
||||
// attach to and nothing is lost if the browser is closed mid-wizard.
|
||||
// For a one-shot registration (e.g. seafarer) already submitted or further
|
||||
// along, the API returns that existing application instead of a new draft —
|
||||
// "Apply" reopens it rather than erroring, the same way it reopens a DRAFT.
|
||||
useEffect(() => {
|
||||
if (appId || !config) return;
|
||||
createApplication({ licenseType: typeCode })
|
||||
@@ -93,8 +133,8 @@ export function LicenseApplicationPage() {
|
||||
.then((app) => setAppId(app.id))
|
||||
.catch((err) =>
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not start application',
|
||||
color: "red",
|
||||
title: "Could not start application",
|
||||
message: extractErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
@@ -105,23 +145,37 @@ export function LicenseApplicationPage() {
|
||||
});
|
||||
const { data: attachments = [], refetch: refetchAttachments } =
|
||||
useGetAttachmentsQuery(
|
||||
{ ownerType: 'APPLICATION', ownerId: appId as string },
|
||||
{ ownerType: "APPLICATION", ownerId: appId as string },
|
||||
{ skip: !appId },
|
||||
);
|
||||
|
||||
const [patchSection] = usePatchSectionMutation();
|
||||
const [submitApplication, { isLoading: submitting }] = useSubmitApplicationMutation();
|
||||
const [resubmitApplication, { isLoading: resubmitting }] = useResubmitApplicationMutation();
|
||||
const [submitApplication, { isLoading: submitting }] =
|
||||
useSubmitApplicationMutation();
|
||||
const [resubmitApplication, { isLoading: resubmitting }] =
|
||||
useResubmitApplicationMutation();
|
||||
const [resolveRemark] = useResolveRemarkMutation();
|
||||
const [addStaff] = useAddStaffMutation();
|
||||
const [removeStaff] = useRemoveStaffMutation();
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>({});
|
||||
// A submitted (or otherwise non-draft) application opens straight to a
|
||||
// read-only summary — status up top, everything the applicant filled below
|
||||
// — instead of the entry step of a stepper there is nothing left to step
|
||||
// through. RESUBMIT_REQUIRED is still summary-first, but "Edit details"
|
||||
// drops into the ordinary wizard on the flagged sections.
|
||||
const [viewingSummary, setViewingSummary] = useState(true);
|
||||
const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>(
|
||||
{},
|
||||
);
|
||||
const [issues, setIssues] = useState<ValidationIssue[]>([]);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [staffModal, setStaffModal] = useState<string | null>(null);
|
||||
const [newStaff, setNewStaff] = useState({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
const [newStaff, setNewStaff] = useState({
|
||||
fullName: "",
|
||||
position: "",
|
||||
yearsOfExperience: 0,
|
||||
});
|
||||
|
||||
// Seed local edits from the server copy once it arrives.
|
||||
useEffect(() => {
|
||||
@@ -145,10 +199,18 @@ export function LicenseApplicationPage() {
|
||||
) => {
|
||||
for (const section of config.licenseType.formSchema.sections) {
|
||||
// English-pinned: matched against English substrings below ('nationality', 'fayda').
|
||||
const field = section.fields.find((f) => matchField((f.label.en ?? '').toLowerCase(), f.key));
|
||||
const field = section.fields.find((f) =>
|
||||
matchField((f.label.en ?? "").toLowerCase(), f.key),
|
||||
);
|
||||
if (!field) continue;
|
||||
if (next[section.key]?.[field.key]) return; // already set — leave it
|
||||
next = { ...next, [section.key]: { ...next[section.key], [field.key]: toFieldValue(field) } };
|
||||
next = {
|
||||
...next,
|
||||
[section.key]: {
|
||||
...next[section.key],
|
||||
[field.key]: toFieldValue(field),
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -158,15 +220,19 @@ export function LicenseApplicationPage() {
|
||||
// a CountrySelect (see ConfigDrivenSection), which takes alpha-2
|
||||
// codes regardless of the backend's configured field type.
|
||||
fill(
|
||||
(label, key) => key === 'nationality' || label.includes('nationality'),
|
||||
(label, key) =>
|
||||
key === "nationality" || label.includes("nationality"),
|
||||
() => getCountryCode(address.nationality) ?? address.nationality,
|
||||
);
|
||||
}
|
||||
if (address.idType === 'NID' && address.idNumber) {
|
||||
if (address.idType === "NID" && address.idNumber) {
|
||||
fill(
|
||||
(label, key) =>
|
||||
key === 'idNumber' || key === 'nationalId' || key === 'faydaNumber' ||
|
||||
label.includes('fayda') || label.includes('national id'),
|
||||
key === "idNumber" ||
|
||||
key === "nationalId" ||
|
||||
key === "faydaNumber" ||
|
||||
label.includes("fayda") ||
|
||||
label.includes("national id"),
|
||||
() => address.idNumber,
|
||||
);
|
||||
}
|
||||
@@ -175,23 +241,92 @@ export function LicenseApplicationPage() {
|
||||
// Also re-run after the server seed effect (above) replaces `draft`
|
||||
// wholesale — that effect can resolve after this one, wiping the
|
||||
// prefill back out since the server's own draft has none of this yet.
|
||||
}, [profile?.address, config, detail?.application?.id, detail?.application?.formData]);
|
||||
}, [
|
||||
profile?.address,
|
||||
config,
|
||||
detail?.application?.id,
|
||||
detail?.application?.formData,
|
||||
]);
|
||||
|
||||
// Generic fill for every field the config gives a `source` — the profile
|
||||
// value the applicant would otherwise retype. Seafarer registration's
|
||||
// Identity Details step is the case that drives this: it collects name,
|
||||
// gender, DOB and national ID *in the wizard* rather than sending the
|
||||
// applicant to `/profile` first, so those fields are editable and this is a
|
||||
// prefill, not a display.
|
||||
//
|
||||
// Editable sourced fields are filled only while still blank. Re-running
|
||||
// this effect (a refetched profile, a saved draft) must not overwrite what
|
||||
// the applicant has since typed — for a `readOnly` field the profile stays
|
||||
// authoritative, so those keep tracking it.
|
||||
useEffect(() => {
|
||||
if (!profile || !config) return;
|
||||
// `profile.firstName/middleName/lastName` stay blank until the applicant
|
||||
// saves the Maritime Profile tab once — a fresh signup arrives here
|
||||
// without ever having done that. Fall back to splitting the account's
|
||||
// `name.en` (the same name signup collected) so this step still
|
||||
// prefills instead of opening blank.
|
||||
const accountName = accountUser?.name ?? profile.user?.name;
|
||||
const nameFallback = accountName?.en
|
||||
? splitPersonName(accountName.en)
|
||||
: null;
|
||||
const context = {
|
||||
user: accountUser ?? profile.user,
|
||||
profile: {
|
||||
...profile,
|
||||
firstName: profile.firstName || nameFallback?.firstName || "",
|
||||
middleName: profile.middleName || nameFallback?.middleName || "",
|
||||
lastName: profile.lastName || nameFallback?.lastName || "",
|
||||
},
|
||||
};
|
||||
|
||||
setDraft((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
for (const section of config.licenseType.formSchema.sections) {
|
||||
for (const field of section.fields) {
|
||||
const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
|
||||
if (!source) continue;
|
||||
const current = next[section.key]?.[field.key];
|
||||
const untouched =
|
||||
current === undefined || current === null || current === "";
|
||||
if (!field.readOnly && !untouched) continue;
|
||||
const value = readSourcePath(context, source);
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (current === value) continue;
|
||||
next[section.key] = { ...next[section.key], [field.key]: value };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [
|
||||
profile,
|
||||
accountUser,
|
||||
config,
|
||||
detail?.application?.id,
|
||||
detail?.application?.formData,
|
||||
]);
|
||||
|
||||
const application = detail?.application;
|
||||
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
||||
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
|
||||
const flaggedSections = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks.filter((r) => r.targetType === 'FORM_SECTION').map((r) => [r.targetKey, r.remark]),
|
||||
openRemarks
|
||||
.filter((r) => r.targetType === "FORM_SECTION")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
);
|
||||
const flaggedDocuments = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
openRemarks.filter((r) => r.targetType === 'DOCUMENT').map((r) => [r.targetKey, r.remark]),
|
||||
openRemarks
|
||||
.filter((r) => r.targetType === "DOCUMENT")
|
||||
.map((r) => [r.targetKey, r.remark]),
|
||||
),
|
||||
[openRemarks],
|
||||
);
|
||||
@@ -212,14 +347,23 @@ export function LicenseApplicationPage() {
|
||||
);
|
||||
|
||||
if (loadingConfig || !config || !appId || !application) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||
}
|
||||
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
|
||||
// A submitted application stays editable until an officer takes it, which
|
||||
// mirrors the server's own rule (`assertEditable`): an applicant who spots
|
||||
// their own mistake can fix it instead of waiting to be sent back for it.
|
||||
// Once claimed it locks — the officer reading it must not have the form move
|
||||
// underneath them.
|
||||
const editableWhileSubmitted =
|
||||
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
||||
const readOnly =
|
||||
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||
!editableWhileSubmitted;
|
||||
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
||||
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
||||
// to the summary first.
|
||||
const showSummary = application.status !== "DRAFT" && viewingSummary;
|
||||
|
||||
// Vessel Information and Current Ownership are separate form sections, so
|
||||
// ConfigDrivenSection (one instance per section) can't fill both itself —
|
||||
@@ -245,9 +389,15 @@ export function LicenseApplicationPage() {
|
||||
const nationalityField = config?.licenseType.formSchema.sections
|
||||
.find((s) => s.key === sectionKey)
|
||||
// English-pinned: same reasoning as the fill() matcher above.
|
||||
?.fields.find((f) => f.key === 'nationality' || (f.label.en ?? '').toLowerCase().includes('nationality'));
|
||||
?.fields.find(
|
||||
(f) =>
|
||||
f.key === "nationality" ||
|
||||
(f.label.en ?? "").toLowerCase().includes("nationality"),
|
||||
);
|
||||
if (nationalityField && values[nationalityField.key]) {
|
||||
values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key];
|
||||
values[nationalityField.key] =
|
||||
getCountryName(values[nationalityField.key] as string) ||
|
||||
values[nationalityField.key];
|
||||
}
|
||||
try {
|
||||
await patchSection({
|
||||
@@ -257,8 +407,8 @@ export function LicenseApplicationPage() {
|
||||
}).unwrap();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Could not save',
|
||||
color: "red",
|
||||
title: "Could not save",
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
@@ -267,13 +417,17 @@ export function LicenseApplicationPage() {
|
||||
async function handleSubmit() {
|
||||
setIssues([]);
|
||||
if (!readOnly && currentStep?.sections?.length) {
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
const errors = validateSections(
|
||||
currentStep.sections,
|
||||
draft,
|
||||
i18n.language,
|
||||
);
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Incomplete',
|
||||
message: 'Complete the highlighted fields before submitting.',
|
||||
color: "red",
|
||||
title: "Incomplete",
|
||||
message: "Complete the highlighted fields before submitting.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -282,31 +436,39 @@ export function LicenseApplicationPage() {
|
||||
try {
|
||||
if (isAdjusting) {
|
||||
for (const remark of openRemarks) {
|
||||
await resolveRemark({ id: appId as string, remarkId: remark.id }).unwrap();
|
||||
await resolveRemark({
|
||||
id: appId as string,
|
||||
remarkId: remark.id,
|
||||
}).unwrap();
|
||||
}
|
||||
await resubmitApplication(appId as string).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Resubmitted',
|
||||
message: 'Your corrections were sent back to the reviewing officer.',
|
||||
color: "teal",
|
||||
title: "Resubmitted",
|
||||
message: "Your corrections were sent back to the reviewing officer.",
|
||||
});
|
||||
navigate("/licensing/applications");
|
||||
} else {
|
||||
await submitApplication(appId as string).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: 'Application submitted',
|
||||
message: 'You will be notified as it progresses.',
|
||||
color: "teal",
|
||||
title: "Application submitted",
|
||||
message: "You will be notified as it progresses.",
|
||||
});
|
||||
// Stays on the application rather than dropping the applicant into a
|
||||
// list: they have just filled a long form and the useful next screen is
|
||||
// what they submitted, with its status and — while it is still
|
||||
// unclaimed — the means to correct it.
|
||||
setViewingSummary(true);
|
||||
}
|
||||
navigate('/licensing/applications');
|
||||
} catch (err) {
|
||||
const found = extractValidationIssues(err);
|
||||
setIssues(found);
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Application incomplete',
|
||||
color: "red",
|
||||
title: "Application incomplete",
|
||||
message: found.length
|
||||
? `${found.length} item(s) still need attention.`
|
||||
? t('licenseApplication.notifications.applicationIncomplete.itemsNeedAttention', { count: found.length })
|
||||
: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
@@ -315,68 +477,85 @@ export function LicenseApplicationPage() {
|
||||
const currentStep = steps[active];
|
||||
|
||||
/**
|
||||
* Checks the current step before moving on.
|
||||
* Checks one step before moving past it.
|
||||
*
|
||||
* The server rejects an incomplete application anyway, but only at submit —
|
||||
* by then the applicant has walked through every step and has to hunt for
|
||||
* what was missing. Validating per step points at the field directly.
|
||||
*
|
||||
* Takes the step rather than reading `currentStep`, so a jump ahead can
|
||||
* check each step it passes over instead of only the one being left.
|
||||
*/
|
||||
async function validateCurrentStep(): Promise<boolean> {
|
||||
async function validateStep(index: number): Promise<boolean> {
|
||||
const step = steps[index];
|
||||
// The wizard does not render until the configuration has loaded, but this
|
||||
// is declared above that guard, so narrow it here too.
|
||||
if (!currentStep || !config) return true;
|
||||
if (!step || !config) return true;
|
||||
|
||||
if (currentStep.kind === 'sections') {
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
if (step.kind === "sections") {
|
||||
const errors = validateSections(step.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
const count = Object.keys(errors).length;
|
||||
if (count > 0) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Incomplete',
|
||||
message: `Complete ${count} required field${count > 1 ? 's' : ''} to continue.`,
|
||||
color: "red",
|
||||
title: "Incomplete",
|
||||
message: `Complete ${count} required field${count > 1 ? "s" : ""} to continue.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep.kind === 'staff') {
|
||||
if (step.kind === "staff") {
|
||||
const missing = config.staffRoleRequirements
|
||||
.filter(
|
||||
(role) =>
|
||||
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey).length <
|
||||
role.minCount,
|
||||
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey)
|
||||
.length < role.minCount,
|
||||
)
|
||||
.map((role) => `${localized(role.name)} (${role.minCount} required)`);
|
||||
.map((role) =>
|
||||
t('licenseApplication.notifications.staffIncomplete.roleRequired', {
|
||||
name: localized(role.name),
|
||||
count: role.minCount,
|
||||
}),
|
||||
);
|
||||
if (missing.length) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Staff incomplete',
|
||||
message: `Still needed: ${missing.join(', ')}.`,
|
||||
color: "red",
|
||||
title: "Staff incomplete",
|
||||
message: `Still needed: ${missing.join(", ")}.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentStep.kind === 'documents') {
|
||||
if (step.kind === "documents") {
|
||||
const supplied = new Set(
|
||||
attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
|
||||
);
|
||||
const missing = config.documentRequirements
|
||||
.filter(
|
||||
(req) =>
|
||||
req.mode === 'ALWAYS' ||
|
||||
(req.mode === 'CONDITIONAL' && conditionHolds(req.conditionExpression, draft)),
|
||||
req.mode === "ALWAYS" ||
|
||||
(req.mode === "CONDITIONAL" &&
|
||||
conditionHolds(req.conditionExpression, draft)),
|
||||
)
|
||||
.filter((req) => !supplied.has(req.key))
|
||||
.map((req) => localized(req.name));
|
||||
if (missing.length) {
|
||||
const shown = missing.slice(0, 3).join(', ');
|
||||
const extra =
|
||||
missing.length > 3
|
||||
? ` ${t('licenseApplication.notifications.documentsMissing.andMore', {
|
||||
count: missing.length - 3,
|
||||
})}`
|
||||
: '';
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Documents missing',
|
||||
message: `Upload: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ` and ${missing.length - 3} more` : ''}.`,
|
||||
color: "red",
|
||||
title: "Documents missing",
|
||||
message: `Upload: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ` and ${missing.length - 3} more` : ""}.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -386,52 +565,97 @@ export function LicenseApplicationPage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The step the applicant is on — what `Continue` validates. */
|
||||
async function validateCurrentStep(): Promise<boolean> {
|
||||
return validateStep(active);
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
// A locked step during an adjustment round has nothing to validate.
|
||||
if (!readOnly && !(await validateCurrentStep())) return;
|
||||
if (currentStep?.kind === 'sections') {
|
||||
for (const section of currentStep.sections) await saveSection(section.key);
|
||||
if (currentStep?.kind === "sections") {
|
||||
for (const section of currentStep.sections)
|
||||
await saveSection(section.key);
|
||||
}
|
||||
setFieldErrors({});
|
||||
setActive((s) => Math.min(steps.length - 1, s + 1));
|
||||
}
|
||||
|
||||
/** Going back is always allowed; going forward validates each step passed. */
|
||||
/**
|
||||
* Going back is always allowed; going forward validates every step passed.
|
||||
*
|
||||
* `target` used to be discarded on the forward path — the handler validated
|
||||
* the current step and then advanced by exactly one, so clicking "4" from
|
||||
* step 1 landed on step 2. Two steps then showed the same content one click
|
||||
* apart, which reads as a broken wizard rather than a refused jump, and made
|
||||
* the later sections look absent entirely.
|
||||
*
|
||||
* Each step between here and `target` is validated and saved in order, so a
|
||||
* jump ahead cannot skip a required field the way a plain `setActive` would.
|
||||
* The walk stops at the first step that fails, leaving the applicant on it
|
||||
* with its errors showing.
|
||||
*/
|
||||
async function goToStep(target: number) {
|
||||
if (target <= active) {
|
||||
setActive(target);
|
||||
return;
|
||||
}
|
||||
if (!readOnly && !(await validateCurrentStep())) return;
|
||||
if (currentStep?.kind === 'sections') {
|
||||
for (const section of currentStep.sections) await saveSection(section.key);
|
||||
|
||||
for (let step = active; step < target; step++) {
|
||||
if (!readOnly && !(await validateStep(step))) {
|
||||
setActive(step);
|
||||
return;
|
||||
}
|
||||
const passed = steps[step];
|
||||
if (passed?.kind === "sections") {
|
||||
for (const section of passed.sections) await saveSection(section.key);
|
||||
}
|
||||
}
|
||||
|
||||
setFieldErrors({});
|
||||
setActive(active + 1);
|
||||
setActive(target);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group justify="space-between" mb="xs" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>{localized(config.licenseType.name)}</Title>
|
||||
<Group gap="xs" mt={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{application.applicationNumber} ·{' '}
|
||||
<Badge size="sm" variant="light">
|
||||
{application.status.replace(/_/g, ' ')}
|
||||
{application.applicationNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLORS[application.status]}
|
||||
>
|
||||
{STATUS_LABELS[application.status]}
|
||||
</Badge>
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
<Group gap="md" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
Fee: {config.fee ?? '—'} {config.feeCurrency}
|
||||
Fee: {config.fee ?? "—"} {config.feeCurrency}
|
||||
</Text>
|
||||
{showSummary && !readOnly && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => setViewingSummary(false)}
|
||||
>
|
||||
Edit details
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{isAdjusting && (
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title="Corrections requested"
|
||||
title={t('licenseApplication.correctionsRequested.title')}
|
||||
mb="md"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
@@ -441,14 +665,32 @@ export function LicenseApplicationPage() {
|
||||
</Text>
|
||||
))}
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Only the items listed above can be changed.
|
||||
{t('licenseApplication.correctionsRequested.onlyListed')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showSummary && editableWhileSubmitted && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Submitted — still correctable"
|
||||
mb="md"
|
||||
>
|
||||
Your application is in the queue. You can still change any detail
|
||||
until a reviewing officer picks it up; after that, corrections happen
|
||||
only if they ask for them.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title="Still missing" mb="md">
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title="Still missing"
|
||||
mb="md"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
{issues.map((issue, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
@@ -459,6 +701,18 @@ export function LicenseApplicationPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showSummary && (
|
||||
<ApplicationSummary
|
||||
sections={sections}
|
||||
formData={application.formData}
|
||||
localized={localized}
|
||||
config={config}
|
||||
attachments={attachments}
|
||||
applicationId={appId as string}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSummary && (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
|
||||
{steps.map((step) => (
|
||||
@@ -466,7 +720,7 @@ export function LicenseApplicationPage() {
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{currentStep?.kind === 'sections' && (
|
||||
{currentStep?.kind === "sections" && (
|
||||
<Stack gap="lg">
|
||||
{currentStep.sections.map((section, index) => {
|
||||
const locked = isAdjusting && !flaggedSections[section.key];
|
||||
@@ -477,7 +731,11 @@ export function LicenseApplicationPage() {
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
{locked && (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />} mb="md">
|
||||
<Alert
|
||||
color="gray"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
mb="md"
|
||||
>
|
||||
This section was accepted and is locked for this round.
|
||||
</Alert>
|
||||
)}
|
||||
@@ -492,7 +750,10 @@ export function LicenseApplicationPage() {
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
|
||||
[section.key]: {
|
||||
...(prev[section.key] ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
// Clear the error as soon as the applicant addresses it.
|
||||
setFieldErrors((prev) => {
|
||||
@@ -508,10 +769,12 @@ export function LicenseApplicationPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{currentStep?.kind === 'staff' && (
|
||||
{currentStep?.kind === "staff" && (
|
||||
<Stack>
|
||||
{config.staffRoleRequirements.map((role) => {
|
||||
const members = (detail?.staff ?? []).filter((s) => s.roleKey === role.roleKey);
|
||||
const members = (detail?.staff ?? []).filter(
|
||||
(s) => s.roleKey === role.roleKey,
|
||||
);
|
||||
return (
|
||||
<Card withBorder key={role.roleKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
@@ -525,12 +788,16 @@ export function LicenseApplicationPage() {
|
||||
` · each needs ${role.requiredEvidence
|
||||
.filter((e) => e.mandatory)
|
||||
.map((e) => localized(e.label))
|
||||
.join(', ')}`}
|
||||
.join(", ")}`}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
{members.length >= role.minCount && (
|
||||
<Badge color="teal" size="sm" leftSection={<IconCheck size={10} />}>
|
||||
<Badge
|
||||
color="teal"
|
||||
size="sm"
|
||||
leftSection={<IconCheck size={10} />}
|
||||
>
|
||||
complete
|
||||
</Badge>
|
||||
)}
|
||||
@@ -549,17 +816,25 @@ export function LicenseApplicationPage() {
|
||||
|
||||
<Stack gap="xs">
|
||||
{members.map((member) => (
|
||||
<Card withBorder key={member.id} padding="sm" radius="sm">
|
||||
<Group justify="space-between" mb={member.id ? 'xs' : 0}>
|
||||
<Card
|
||||
withBorder
|
||||
key={member.id}
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
mb={member.id ? "xs" : 0}
|
||||
>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{member.fullName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{member.position ?? '—'}
|
||||
{member.position ?? "—"}
|
||||
{member.yearsOfExperience
|
||||
? ` · ${member.yearsOfExperience} yrs`
|
||||
: ''}
|
||||
: ""}
|
||||
</Text>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
@@ -567,7 +842,10 @@ export function LicenseApplicationPage() {
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await removeStaff({ id: appId, staffId: member.id });
|
||||
await removeStaff({
|
||||
id: appId,
|
||||
staffId: member.id,
|
||||
});
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
@@ -590,7 +868,7 @@ export function LicenseApplicationPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{currentStep?.kind === 'documents' && (
|
||||
{currentStep?.kind === "documents" && (
|
||||
<DocumentSlots
|
||||
requirements={config.documentRequirements}
|
||||
attachments={attachments}
|
||||
@@ -607,7 +885,7 @@ export function LicenseApplicationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep?.kind === 'review' && (
|
||||
{currentStep?.kind === "review" && (
|
||||
<Stack>
|
||||
{currentStep.sections.map((section) => (
|
||||
<div key={section.key}>
|
||||
@@ -625,7 +903,10 @@ export function LicenseApplicationPage() {
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
|
||||
[section.key]: {
|
||||
...(prev[section.key] ?? {}),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -656,7 +937,7 @@ export function LicenseApplicationPage() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{String(draft[section.key]?.[field.key] ?? '—')}
|
||||
{String(draft[section.key]?.[field.key] ?? "—")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -694,34 +975,41 @@ export function LicenseApplicationPage() {
|
||||
disabled={readOnly}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isAdjusting ? 'Resubmit corrections' : 'Submit application'}
|
||||
{isAdjusting ? "Resubmit corrections" : "Submit application"}
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(staffModal)}
|
||||
onClose={() => setStaffModal(null)}
|
||||
title="Add staff member"
|
||||
title={t('licenseApplication.staff.addStaffMember')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Full name"
|
||||
label={t('licenseApplication.staff.fullName')}
|
||||
withAsterisk
|
||||
value={newStaff.fullName}
|
||||
onChange={(e) => setNewStaff({ ...newStaff, fullName: e.currentTarget.value })}
|
||||
onChange={(e) =>
|
||||
setNewStaff({ ...newStaff, fullName: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Position"
|
||||
label={t('licenseApplication.staff.position')}
|
||||
value={newStaff.position}
|
||||
onChange={(e) => setNewStaff({ ...newStaff, position: e.currentTarget.value })}
|
||||
onChange={(e) =>
|
||||
setNewStaff({ ...newStaff, position: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Years of experience"
|
||||
label={t('licenseApplication.staff.yearsOfExperience')}
|
||||
value={newStaff.yearsOfExperience}
|
||||
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
|
||||
onChange={(v) =>
|
||||
setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
<ModalFooter>
|
||||
@@ -729,12 +1017,16 @@ export function LicenseApplicationPage() {
|
||||
onClick={async () => {
|
||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
setNewStaff({
|
||||
fullName: "",
|
||||
position: "",
|
||||
yearsOfExperience: 0,
|
||||
});
|
||||
setStaffModal(null);
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
{t('licenseApplication.staff.add')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
|
||||
@@ -13,9 +13,11 @@ interface LocationPickerProps {
|
||||
required?: boolean;
|
||||
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
|
||||
maxDepth?: number;
|
||||
/** Locks every level — a submitted application, or a section under review. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
|
||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth, disabled }: LocationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
||||
@@ -204,7 +206,9 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
{levels.map((levelIdx) => {
|
||||
const options = buildOptions(levelIdx);
|
||||
const currentValue = selectedChain[levelIdx]?.id ?? null;
|
||||
const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1];
|
||||
// Either the whole picker is locked, or this level has no parent
|
||||
// choice yet to narrow it.
|
||||
const isDisabled = disabled || (levelIdx > 0 && !selectedChain[levelIdx - 1]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
export interface NamePair {
|
||||
en: string;
|
||||
am: string;
|
||||
}
|
||||
/**
|
||||
* Re-exported from the shared contract so both apps read one definition.
|
||||
*
|
||||
* The portal and backoffice each kept their own copy of this model and drifted:
|
||||
* the two `Location` shapes disagreed on `locationType`/`children`/timestamps,
|
||||
* and `NamePair` dropped the `om`/`so` names the backend stores. Importers keep
|
||||
* this path; the model itself now lives in `@ema-platform/api`.
|
||||
*/
|
||||
export type {
|
||||
Location,
|
||||
LocationType,
|
||||
ListResponse,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export interface LocationType {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
locationTypeId: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
count: number;
|
||||
items: T[];
|
||||
}
|
||||
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
|
||||
export type { Bilingual as NamePair } from '@ema-platform/api';
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconCalendar,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDownload,
|
||||
IconFileDescription,
|
||||
IconHeart,
|
||||
IconInfoCircle,
|
||||
IconTrash,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
interface MedicalCert {
|
||||
id: string;
|
||||
issuedBy: string;
|
||||
issuedDate: string;
|
||||
expiryDate: string;
|
||||
status: 'Valid' | 'Expiring' | 'Expired' | 'Pending' | 'Rejected';
|
||||
restrictions: string;
|
||||
/** Days left, computed server-side so every screen agrees on the date. */
|
||||
daysRemaining?: number;
|
||||
}
|
||||
|
||||
/** The medical card's whole state, as `/medical/my` returns it. */
|
||||
interface MedicalOverview {
|
||||
current: MedicalCert | null;
|
||||
history: MedicalCert[];
|
||||
warningDays: number;
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
Valid: 'teal', Expiring: 'orange', Expired: 'red', Pending: 'yellow',
|
||||
};
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
export function MedicalCertificatePage() {
|
||||
// The card's whole state comes from one call: the current certificate, the
|
||||
// ones before it, and the validity the server computed. Deriving "expiring"
|
||||
// in the browser would let a wrong client clock disagree with the gate that
|
||||
// blocks an application.
|
||||
const { data: medical } = useApiQuery<MedicalOverview>({
|
||||
url: '/medical/my',
|
||||
method: 'GET',
|
||||
});
|
||||
const current = medical?.current ?? null;
|
||||
const history = medical?.history ?? [];
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [doctorName, setDoctorName] = useState('');
|
||||
const [issuedDate, setIssuedDate] = useState('');
|
||||
const [expiryDate, setExpiryDate] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const resetRef = useRef<() => void>(null);
|
||||
|
||||
// Server's count where it gave one: it is the same figure the eligibility
|
||||
// gate uses, and a browser clock that is wrong or in another timezone would
|
||||
// otherwise show a different number than the officer sees.
|
||||
const days = current
|
||||
? (current.daysRemaining ?? daysUntil(current.expiryDate))
|
||||
: 0;
|
||||
const progressVal = current
|
||||
? Math.max(0, Math.min(100, (days / 730) * 100))
|
||||
: 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uploadedFile || !issuedDate || !expiryDate) {
|
||||
notify.error('Please fill all fields and upload the certificate file.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setSubmitting(false);
|
||||
notify.success('Medical certificate submitted for verification. EMA will review within 2 working days.');
|
||||
setUploadedFile(null);
|
||||
setDoctorName('');
|
||||
setIssuedDate('');
|
||||
setExpiryDate('');
|
||||
resetRef.current?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Title order={3}>Medical Certificate</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
STCW requires a valid medical certificate for all seafarers. Valid for 2 years (1 year if under 18).
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Validity alert */}
|
||||
{current && days <= 90 && days > 0 && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={17} />}>
|
||||
Your medical certificate expires in <strong>{days} days</strong> ({formatDate(current.expiryDate)}).
|
||||
Please visit an EMA-approved medical centre and upload your renewed certificate below.
|
||||
</Alert>
|
||||
)}
|
||||
{current && days <= 0 && (
|
||||
<Alert variant="light" color="red" icon={<IconAlertCircle size={17} />}>
|
||||
Your medical certificate <strong>has expired</strong>. You cannot join a vessel until a valid certificate is uploaded and verified.
|
||||
</Alert>
|
||||
)}
|
||||
{!current && (
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={17} />}>
|
||||
No medical certificate on record. Upload your certificate below to complete your profile and apply for a Seaman Book.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{/* Current certificate */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="red" size={36} radius="md">
|
||||
<IconHeart size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Current Certificate</Text>
|
||||
</Group>
|
||||
|
||||
{current ? (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
|
||||
<Badge color={STATUS_COLOR[current.status]} variant="light">{current.status}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Certificate ID</Text>
|
||||
<Text fz="sm">{current.id}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issued By</Text>
|
||||
<Text fz="sm" ta="right" maw={200}>{current.issuedBy}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Issue Date</Text>
|
||||
<Text fz="sm">{formatDate(current.issuedDate)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Expiry Date</Text>
|
||||
<Text fz="sm" fw={700} c={days <= 90 ? 'orange' : days <= 0 ? 'red' : undefined}>
|
||||
{formatDate(current.expiryDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Restrictions</Text>
|
||||
<Text fz="sm">{current.restrictions}</Text>
|
||||
</Group>
|
||||
|
||||
{/* Validity bar */}
|
||||
<Box mt="xs">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz="xs" c="dimmed">Validity remaining</Text>
|
||||
<Text fz="xs" fw={600} c={days <= 90 ? 'orange' : 'teal'}>{Math.max(0, days)} days</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progressVal}
|
||||
color={days <= 30 ? 'red' : days <= 90 ? 'orange' : 'teal'}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={13} />}
|
||||
mt="xs"
|
||||
>
|
||||
Download Certificate
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box ta="center" py="xl">
|
||||
<ThemeIcon variant="light" color="gray" size={48} radius="xl" mx="auto" mb="sm">
|
||||
<IconFileDescription size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed">No certificate on record</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Upload new certificate */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconUpload size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>{current ? 'Upload Renewal' : 'Upload Certificate'}</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Issuing Doctor / Medical Centre"
|
||||
placeholder="e.g. Dr. Alemu Bekele — EMA Medical Centre"
|
||||
value={doctorName}
|
||||
onChange={(e) => setDoctorName(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<TextInput
|
||||
label="Issue Date"
|
||||
type="date"
|
||||
value={issuedDate}
|
||||
onChange={(e) => setIssuedDate(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label="Expiry Date"
|
||||
type="date"
|
||||
value={expiryDate}
|
||||
onChange={(e) => setExpiryDate(e.currentTarget.value)}
|
||||
size="sm"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text fz="sm" fw={500} mb={4}>Certificate File <Text span c="red">*</Text></Text>
|
||||
{uploadedFile ? (
|
||||
<Card withBorder radius="sm" p="xs">
|
||||
<Group gap="xs">
|
||||
<IconCircleCheck size={15} color="var(--mantine-color-teal-6)" />
|
||||
<Text fz="xs" c="teal.7" style={{ flex: 1 }} truncate>{uploadedFile.name}</Text>
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => { setUploadedFile(null); resetRef.current?.(); }}>
|
||||
<IconTrash size={13} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<FileButton resetRef={resetRef} onChange={setUploadedFile} accept="application/pdf,image/jpeg,image/png">
|
||||
{(props) => (
|
||||
<Button size="sm" variant="default" leftSection={<IconUpload size={14} />} fullWidth {...props}>
|
||||
Choose File (PDF / JPG / PNG, max 5MB)
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} p="xs">
|
||||
<Text fz="xs">
|
||||
Your certificate will be reviewed by an EMA Medical Officer within <strong>2 working days</strong>.
|
||||
Notifications will be sent by email and SMS.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
leftSection={<IconCheck size={15} />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!uploadedFile || !issuedDate || !expiryDate}
|
||||
>
|
||||
Submit for Verification
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Notification schedule */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconCalendar size={18} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Expiry Notification Schedule</Text>
|
||||
</Group>
|
||||
<Timeline active={days <= 30 ? 2 : days <= 60 ? 1 : days <= 90 ? 0 : -1} bulletSize={24} lineWidth={2}>
|
||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="90 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">First reminder — time to book your medical examination</Text>
|
||||
</Timeline.Item>
|
||||
<Timeline.Item bullet={<IconAlertTriangle size={13} />} title="60 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">Second reminder — urgent renewal required</Text>
|
||||
</Timeline.Item>
|
||||
<Timeline.Item bullet={<IconAlertCircle size={13} />} title="30 Days Before Expiry">
|
||||
<Text fz="xs" c="dimmed">Final reminder — certificate expires very soon</Text>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Paper>
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Text fw={700} mb="md">Certificate History</Text>
|
||||
<Stack gap="xs">
|
||||
{history.map((cert) => (
|
||||
<Card key={cert.id} withBorder radius="sm" p="sm">
|
||||
<Group justify="space-between" wrap="wrap" gap="xs">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="gray" size={32} radius="md">
|
||||
<IconFileDescription size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fz="sm" fw={600}>{cert.id}</Text>
|
||||
<Text fz="xs" c="dimmed">{formatDate(cert.issuedDate)} → {formatDate(cert.expiryDate)}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color={STATUS_COLOR[cert.status]} variant="light" size="sm">{cert.status}</Badge>
|
||||
<Button size="xs" variant="subtle" leftSection={<IconDownload size={12} />}>Download</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,15 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconBellOff, IconCheck } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useLocalized,
|
||||
useGetNotificationsQuery,
|
||||
@@ -21,15 +20,10 @@ import {
|
||||
useMarkNotificationReadMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
|
||||
type Tab = 'all' | 'unseen' | 'seen';
|
||||
|
||||
const EMPTY_COPY: Record<Tab, string> = {
|
||||
all: 'No notifications yet.',
|
||||
unseen: 'Nothing unread.',
|
||||
seen: 'No read notifications.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The applicant's notification inbox.
|
||||
*
|
||||
@@ -37,10 +31,16 @@ const EMPTY_COPY: Record<Tab, string> = {
|
||||
* every transition — this previously listed a fixed array of invented alerts.
|
||||
*/
|
||||
export function NotificationsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
const EMPTY_COPY: Record<Tab, string> = {
|
||||
all: t('notifications.empty.all'),
|
||||
unseen: t('notifications.empty.unseen'),
|
||||
seen: t('notifications.empty.seen'),
|
||||
};
|
||||
const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
|
||||
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });
|
||||
const [markRead] = useMarkNotificationReadMutation();
|
||||
@@ -57,9 +57,9 @@ export function NotificationsPage() {
|
||||
|
||||
return (
|
||||
<Container size="md" py="md">
|
||||
<Title order={3}>Notifications</Title>
|
||||
<Title order={3}>{t('notifications.title')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
|
||||
{unread > 0 ? t('notifications.unread', { count: unread }) : t('notifications.allCaughtUp')}
|
||||
</Text>
|
||||
|
||||
<SegmentedControl
|
||||
@@ -68,23 +68,21 @@ export function NotificationsPage() {
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as Tab)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Unseen', value: 'unseen' },
|
||||
{ label: 'Seen', value: 'seen' },
|
||||
{ label: t('notifications.tabs.all'), value: 'all' },
|
||||
{ label: t('notifications.tabs.unseen'), value: 'unseen' },
|
||||
{ label: t('notifications.tabs.seen'), value: 'seen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
<PageLoader label={t('notifications.loading')} height={350} />
|
||||
) : items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">{EMPTY_COPY[tab]}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You will be notified as your applications progress.
|
||||
{t('notifications.emptyBody')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -112,7 +110,7 @@ export function NotificationsPage() {
|
||||
</Text>
|
||||
{!n.isSeen && (
|
||||
<Badge size="xs" variant="light">
|
||||
new
|
||||
{t('notifications.new')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -133,7 +131,7 @@ export function NotificationsPage() {
|
||||
markRead(n.id);
|
||||
}}
|
||||
>
|
||||
Mark read
|
||||
{t('notifications.markRead')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetMyOperatorTypesQuery } from '@ema-platform/api';
|
||||
import { PageLoader } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Sends an applicant who has not said what they operate as to the step that
|
||||
@@ -25,6 +26,8 @@ const ALWAYS_ALLOWED = [
|
||||
// them (SEAFARER_REGISTRATION, CERTIFICATE_OF_COMPETENCY/PROFICIENCY).
|
||||
'/seafarer-registration',
|
||||
'/seafarer/records',
|
||||
'/seafarer/sea-service',
|
||||
'/seafarer/medical',
|
||||
'/seafarer-registry',
|
||||
'/exams',
|
||||
'/certificates',
|
||||
@@ -64,6 +67,7 @@ function isModeFreeLicensingRoute(pathname: string): boolean {
|
||||
}
|
||||
|
||||
export function RequireOperations({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const { pathname } = useLocation();
|
||||
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
|
||||
|
||||
@@ -75,11 +79,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
|
||||
}
|
||||
|
||||
// A failed lookup must not lock anyone out of the portal — the server still
|
||||
@@ -93,11 +93,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
|
||||
// the same tick; deciding on the pre-save cache bounced the applicant
|
||||
// straight back to the screen they had just completed.
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
|
||||
}
|
||||
return <Navigate to="/onboarding/operations" replace />;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Container, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OperationsFormContent } from "../../profile/components/OperationsFormContent";
|
||||
|
||||
/**
|
||||
* Where a fresh applicant lands after declaring themselves. Someone who says
|
||||
* "I own a vessel" came here to register, so they are taken straight to that
|
||||
* form instead of a dashboard that only links to it. A seafarer goes to
|
||||
* `/profile` instead — registration is built from the profile
|
||||
* "I own a vessel" came here to register, so they are taken to that service's
|
||||
* own page — which lists what they already hold and starts the application —
|
||||
* rather than dropped straight into the wizard with no way back. A seafarer
|
||||
* goes to `/profile` instead — registration is built from the profile
|
||||
* (`RequireSeafarerProfile`), and a brand-new signup has none of it yet, so
|
||||
* sending them straight to the wizard would only bounce them back here.
|
||||
* Seafarer wins when both are ticked; the other form is one nav click away.
|
||||
* Seafarer wins when both are ticked; the other page is one nav click away.
|
||||
*
|
||||
* Seafarer goes to its own registration page, whose Identity Details step
|
||||
* collects the profile answers itself — no detour via `/profile`. Seafarer
|
||||
* wins when both are ticked; the other form is one nav click away.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: '/profile',
|
||||
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
|
||||
SEAFARER_REGISTRATION: "/seafarer-registration",
|
||||
VESSEL_REGISTRATION: "/licensing/VESSEL_REGISTRATION/apply",
|
||||
};
|
||||
|
||||
function nextStepFor(selectedKeys: string[]): string {
|
||||
const key = Object.keys(NEXT_STEP).find((k) => selectedKeys.includes(k));
|
||||
return key ? NEXT_STEP[key] : '/dashboard';
|
||||
return key ? NEXT_STEP[key] : "/dashboard";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,17 +39,16 @@ function nextStepFor(selectedKeys: string[]): string {
|
||||
* soon as the profile has at least one mode.
|
||||
*/
|
||||
export function OperationsOnboardingPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={3}>What do you operate as?</Title>
|
||||
<Title order={3}>{t("onboarding.operations.title")}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
The Authority licenses by mode of operation. Tell us what your
|
||||
company does and we will show you the licences you can apply for —
|
||||
you can change this later from your profile.
|
||||
{t("onboarding.operations.body")}
|
||||
</Text>
|
||||
</div>
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState } from 'react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useInitiateDocumentPaymentMutation,
|
||||
useInitiatePaymentMutation,
|
||||
type InitiatePaymentResult,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
@@ -15,21 +17,31 @@ import {
|
||||
*/
|
||||
export function useApplicationPayment() {
|
||||
const [initiate, { isLoading }] = useInitiatePaymentMutation();
|
||||
const [initiateDocument, { isLoading: isLoadingDocument }] =
|
||||
useInitiateDocumentPaymentMutation();
|
||||
const [redirecting, setRedirecting] = useState(false);
|
||||
|
||||
async function pay(
|
||||
applicationId: string,
|
||||
provider = 'TELEBIRR',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await initiate({
|
||||
id: applicationId,
|
||||
provider,
|
||||
const platform = () =>
|
||||
// Deep links only work inside a mobile browser; assume web otherwise.
|
||||
platform: /Android|iPhone|iPad/i.test(navigator.userAgent)
|
||||
? 'mobile'
|
||||
: 'web',
|
||||
}).unwrap();
|
||||
/Android|iPhone|iPad/i.test(navigator.userAgent) ? 'mobile' : 'web';
|
||||
|
||||
/** A licence application's fee. */
|
||||
function pay(applicationId: string, provider = 'TELEBIRR'): Promise<void> {
|
||||
return handOver(() =>
|
||||
initiate({ id: applicationId, provider, platform: platform() }).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
/** A Seaman Book / BTC fee — same gateway, its own endpoint. */
|
||||
function payDocument(documentId: string, provider = 'TELEBIRR'): Promise<void> {
|
||||
return handOver(() =>
|
||||
initiateDocument({ id: documentId, provider, platform: platform() }).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
async function handOver(start: () => Promise<InitiatePaymentResult>): Promise<void> {
|
||||
try {
|
||||
const result = await start();
|
||||
|
||||
const action = result.clientAction;
|
||||
|
||||
@@ -66,5 +78,5 @@ export function useApplicationPayment() {
|
||||
}
|
||||
}
|
||||
|
||||
return { pay, isPaying: isLoading || redirecting };
|
||||
return { pay, payDocument, isPaying: isLoading || isLoadingDocument || redirecting };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -17,7 +16,8 @@ import {
|
||||
IconCircleCheck,
|
||||
IconClockHour4,
|
||||
} from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
@@ -31,36 +31,43 @@ const MAX_ATTEMPTS = 10;
|
||||
* their account is the worst possible outcome here.
|
||||
*/
|
||||
export function PaymentCheckPage() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
const { data, refetch, isLoading } = useGetApplicationPaymentQuery(
|
||||
applicationId,
|
||||
{ skip: !applicationId },
|
||||
);
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
|
||||
const { data, refetch, isLoading } = documentId ? documentPayment : applicationPayment;
|
||||
const subject = documentId ? `documentId=${documentId}` : `applicationId=${applicationId}`;
|
||||
const hasSubject = Boolean(applicationId || documentId);
|
||||
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
|
||||
|
||||
const status = data?.status ?? null;
|
||||
const settled = status === 'PAID' || status === 'FAILED' || status === 'CANCELLED';
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId || settled || attempts >= MAX_ATTEMPTS) return;
|
||||
if (!hasSubject || settled || attempts >= MAX_ATTEMPTS) return;
|
||||
const timer = setTimeout(() => {
|
||||
refetch();
|
||||
setAttempts((n) => n + 1);
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [applicationId, settled, attempts, refetch]);
|
||||
}, [hasSubject, settled, attempts, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'PAID') navigate(`/payments/success?applicationId=${applicationId}`);
|
||||
if (status === 'PAID') navigate(`/payments/success?${subject}`);
|
||||
if (status === 'FAILED' || status === 'CANCELLED') {
|
||||
navigate(`/payments/failure?applicationId=${applicationId}`);
|
||||
navigate(`/payments/failure?${subject}`);
|
||||
}
|
||||
}, [status, applicationId, navigate]);
|
||||
}, [status, subject, navigate]);
|
||||
|
||||
if (!applicationId) {
|
||||
if (!hasSubject) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Card withBorder padding="xl">
|
||||
@@ -68,12 +75,12 @@ export function PaymentCheckPage() {
|
||||
<ThemeIcon size={48} radius="xl" color="orange" variant="light">
|
||||
<IconAlertTriangle size={24} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>We could not identify this payment</Title>
|
||||
<Title order={4}>{t('payments.check.notFoundTitle')}</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open the application from your list to check its payment status.
|
||||
{t('payments.check.notFoundBody')}
|
||||
</Text>
|
||||
<Button onClick={() => navigate('/licensing/applications')}>
|
||||
My applications
|
||||
<Button onClick={() => navigate(backTo)}>
|
||||
{t('payments.myApplications')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -92,18 +99,16 @@ export function PaymentCheckPage() {
|
||||
<ThemeIcon size={48} radius="xl" color="yellow" variant="light">
|
||||
<IconClockHour4 size={24} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>Still confirming your payment</Title>
|
||||
<Title order={4}>{t('payments.check.stillConfirmingTitle')}</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Telebirr has not confirmed this payment yet. If the money has
|
||||
left your account it will be applied automatically — there is no
|
||||
need to pay again.
|
||||
{t('payments.check.stillConfirmingBody')}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => { setAttempts(0); refetch(); }}>
|
||||
Check again
|
||||
{t('payments.check.checkAgain')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/licensing/applications')}>
|
||||
My applications
|
||||
<Button onClick={() => navigate(backTo)}>
|
||||
{t('payments.myApplications')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
@@ -114,9 +119,9 @@ export function PaymentCheckPage() {
|
||||
<IconCircleCheck size={24} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Title order={4}>Confirming your payment…</Title>
|
||||
<Title order={4}>{t('payments.check.confirmingTitle')}</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
This usually takes a few seconds. Please do not close this page.
|
||||
{t('payments.check.confirmingBody')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,16 +10,23 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
|
||||
|
||||
/** Shown when Telebirr reported the payment as failed or cancelled. */
|
||||
export function PaymentFailurePage() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
|
||||
const { data } = documentId ? documentPayment : applicationPayment;
|
||||
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
@@ -28,18 +35,16 @@ export function PaymentFailurePage() {
|
||||
<ThemeIcon size={56} radius="xl" color="red" variant="light">
|
||||
<IconAlertTriangle size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Payment not completed</Title>
|
||||
<Title order={3}>{t('payments.failure.title')}</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{data?.failureReason
|
||||
? data.failureReason
|
||||
: 'The payment was not completed. Nothing has been charged.'}
|
||||
{data?.failureReason ? data.failureReason : t('payments.failure.defaultReason')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Your application is unchanged and you can try again at any time.
|
||||
{t('payments.failure.unchanged')}
|
||||
</Text>
|
||||
<Group mt="md">
|
||||
<Button variant="default" onClick={() => navigate('/licensing/applications')}>
|
||||
My applications
|
||||
<Button variant="default" onClick={() => navigate(backTo)}>
|
||||
{t('payments.myApplications')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -11,18 +11,25 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetApplicationPaymentQuery, useGetDocumentPaymentQuery } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** Confirmation that the licence fee has been received. */
|
||||
export function PaymentSuccessPage() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
// Seaman Book / BTC fees come back with `documentId` instead.
|
||||
const documentId = params.get('documentId') ?? '';
|
||||
const applicationPayment = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
const documentPayment = useGetDocumentPaymentQuery(documentId, { skip: !documentId });
|
||||
const { data } = documentId ? documentPayment : applicationPayment;
|
||||
const backTo = documentId ? '/seaman-book' : '/licensing/applications';
|
||||
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
@@ -31,10 +38,9 @@ export function PaymentSuccessPage() {
|
||||
<ThemeIcon size={56} radius="xl" color="teal" variant="light">
|
||||
<IconCircleCheck size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={3}>Payment received</Title>
|
||||
<Title order={3}>{t('payments.success.title')}</Title>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Thank you. Your licence fee has been paid and your application is
|
||||
being finalised. You will be notified when your certificate is ready.
|
||||
{t('payments.success.body')}
|
||||
</Text>
|
||||
|
||||
{data && (
|
||||
@@ -42,24 +48,24 @@ export function PaymentSuccessPage() {
|
||||
<Divider my="xs" w="100%" />
|
||||
<Stack gap={4} w="100%">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Amount</Text>
|
||||
<Text size="sm" c="dimmed">{t('payments.fields.amount')}</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{Number(data.amount).toLocaleString()} {data.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Method</Text>
|
||||
<Text size="sm" c="dimmed">{t('payments.fields.method')}</Text>
|
||||
<Text size="sm">{data.provider}</Text>
|
||||
</Group>
|
||||
{data.providerRef && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Reference</Text>
|
||||
<Text size="sm" c="dimmed">{t('payments.fields.reference')}</Text>
|
||||
<Text size="sm" ff="monospace">{data.providerRef}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{data.paidAt && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Paid</Text>
|
||||
<Text size="sm" c="dimmed">{t('payments.fields.paid')}</Text>
|
||||
<Text size="sm">{showDate(data.paidAt)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
@@ -67,8 +73,8 @@ export function PaymentSuccessPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button mt="md" onClick={() => navigate('/licensing/applications')}>
|
||||
Back to my applications
|
||||
<Button mt="md" onClick={() => navigate(backTo)}>
|
||||
{t('payments.success.backToApplications')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -2,19 +2,24 @@ import { useCallback, useMemo } from 'react';
|
||||
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
||||
import type { Location, LocationType } from '../../location/types/location';
|
||||
|
||||
export const addressSchema = z.object({
|
||||
idType: z.string().min(1, 'Select ID type'),
|
||||
idNumber: z.string().trim().min(1, 'Enter ID number'),
|
||||
// Zod schemas can't call hooks, so the schema is built from `t` by the
|
||||
// caller (ProfilePage) rather than defined once at module scope.
|
||||
export function addressSchema(t: TFunction) {
|
||||
return z.object({
|
||||
idType: z.string().min(1, t('profileAddress.validation.idTypeRequired')),
|
||||
idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
|
||||
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
||||
nationality: z.string().min(1, 'Select nationality'),
|
||||
nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
|
||||
primaryPhoneNumber: ethiopianPhone,
|
||||
secondaryPhoneNumber: optionalEthiopianPhone,
|
||||
email: z.string().trim().email('Invalid email').optional().or(z.literal('')),
|
||||
email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
subCityId: z.string().optional(),
|
||||
@@ -28,17 +33,20 @@ export const addressSchema = z.object({
|
||||
emergencyContactPhone: optionalEthiopianPhone,
|
||||
emergencyContactRelation: z.string().trim().optional(),
|
||||
});
|
||||
}
|
||||
|
||||
export type AddressValues = z.infer<typeof addressSchema>;
|
||||
export type AddressValues = z.infer<ReturnType<typeof addressSchema>>;
|
||||
|
||||
// Backend rejects anything outside this set:
|
||||
// "idType must be one of the following values: NID, VITAL, PASSPORT, DRIVERS_LICENSE"
|
||||
export const ID_TYPES = [
|
||||
{ value: 'NID', label: 'National Id' },
|
||||
{ value: 'VITAL', label: 'Vital ID' },
|
||||
{ value: 'PASSPORT', label: 'Passport' },
|
||||
{ value: 'DRIVERS_LICENSE', label: "Driver's License" },
|
||||
function idTypeOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: 'NID', label: t('profileAddress.idTypeOptions.NID') },
|
||||
{ value: 'VITAL', label: t('profileAddress.idTypeOptions.VITAL') },
|
||||
{ value: 'PASSPORT', label: t('profileAddress.idTypeOptions.PASSPORT') },
|
||||
{ value: 'DRIVERS_LICENSE', label: t('profileAddress.idTypeOptions.DRIVERS_LICENSE') },
|
||||
] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Location types are data-driven rows (no fixed depth), so the chain is
|
||||
@@ -79,6 +87,7 @@ export function AddressFormContent({
|
||||
watch,
|
||||
trigger,
|
||||
}: AddressFormContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: typesRes } = useGetLocationTypesQuery();
|
||||
const locationTypes = typesRes?.items;
|
||||
|
||||
@@ -113,10 +122,10 @@ export function AddressFormContent({
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="ID Type"
|
||||
placeholder="Select"
|
||||
label={t('profileFields.idType')}
|
||||
placeholder={t('profileAddress.idTypePlaceholder')}
|
||||
required
|
||||
data={ID_TYPES}
|
||||
data={idTypeOptions(t)}
|
||||
error={errors.idType?.message}
|
||||
value={watch('idType')}
|
||||
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
|
||||
@@ -124,14 +133,14 @@ export function AddressFormContent({
|
||||
name="idType"
|
||||
/>
|
||||
<TextInput
|
||||
label="ID Number"
|
||||
placeholder="Enter ID number"
|
||||
label={t('profileFields.idNumber')}
|
||||
placeholder={t('profileAddress.idNumberPlaceholder')}
|
||||
required
|
||||
{...register('idNumber')}
|
||||
error={errors.idNumber?.message}
|
||||
/>
|
||||
<CountrySelect
|
||||
label="Nationality"
|
||||
label={t('profileFields.nationality')}
|
||||
demonym
|
||||
required
|
||||
value={watch('nationality') || null}
|
||||
@@ -139,23 +148,23 @@ export function AddressFormContent({
|
||||
error={errors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Primary Phone"
|
||||
description="From your account, edit it in the Personal tab"
|
||||
label={t('profileFields.primaryPhoneNumber')}
|
||||
description={t('profileAddress.accountManagedHint')}
|
||||
required
|
||||
readOnly
|
||||
{...register('primaryPhoneNumber')}
|
||||
error={errors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Secondary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
label={t('profileAddress.secondaryPhoneNumber')}
|
||||
placeholder={t('profileAddress.phonePlaceholder')}
|
||||
{...register('secondaryPhoneNumber')}
|
||||
error={errors.secondaryPhoneNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
label={t('profileFields.email')}
|
||||
type="email"
|
||||
description="From your account, edit it in the Personal tab"
|
||||
description={t('profileAddress.accountManagedHint')}
|
||||
readOnly
|
||||
{...register('email')}
|
||||
error={errors.email?.message}
|
||||
@@ -163,44 +172,49 @@ export function AddressFormContent({
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
{t('profileAddress.addressSection')}
|
||||
</Text>
|
||||
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
|
||||
{/* City / Sub-city / Woreda — the picker's depth. Kebele is a seeded
|
||||
level but nothing collects it, so `kebeleId` stays unset rather than
|
||||
borrowing the woreda's id. */}
|
||||
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||
<TextInput
|
||||
label="Street Address"
|
||||
placeholder="Street name, house number"
|
||||
label={t('profileFields.streetAddress')}
|
||||
placeholder={t('profileAddress.streetAddressPlaceholder')}
|
||||
{...register('streetAddress')}
|
||||
error={errors.streetAddress?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Postal Address"
|
||||
placeholder="P.O. Box"
|
||||
label={t('profileAddress.postalAddress')}
|
||||
placeholder={t('profileAddress.postalAddressPlaceholder')}
|
||||
{...register('postalAddress')}
|
||||
error={errors.postalAddress?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Emergency Contact <Text span c="dimmed" fz="xs" tt="none" fw={400}>(optional)</Text>
|
||||
{t('profileAddress.emergencyContactSection')}{' '}
|
||||
<Text span c="dimmed" fz="xs" tt="none" fw={400}>
|
||||
{t('profileAddress.emergencyContactOptional')}
|
||||
</Text>
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label="Contact Name"
|
||||
placeholder="Full name"
|
||||
label={t('profileAddress.contactName')}
|
||||
placeholder={t('profileAddress.contactNamePlaceholder')}
|
||||
{...register('emergencyContactName')}
|
||||
error={errors.emergencyContactName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
label={t('profileAddress.contactPhone')}
|
||||
placeholder={t('profileAddress.phonePlaceholder')}
|
||||
{...register('emergencyContactPhone')}
|
||||
error={errors.emergencyContactPhone?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Relationship"
|
||||
placeholder="Spouse, Parent, etc."
|
||||
label={t('profileAddress.relationship')}
|
||||
placeholder={t('profileAddress.relationshipPlaceholder')}
|
||||
{...register('emergencyContactRelation')}
|
||||
error={errors.emergencyContactRelation?.message}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
useGetMyOperatorTypesQuery,
|
||||
useUpdateMyOperatorTypesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { useUpdateMyAccountTypeMutation } from '@ema-platform/auth';
|
||||
import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
@@ -34,6 +36,29 @@ const PERSONAL_REGISTRATION_KEYS = [
|
||||
'VESSEL_REGISTRATION',
|
||||
];
|
||||
|
||||
/**
|
||||
* Declared modes -> portal account type.
|
||||
*
|
||||
* The account type is what the server computes portal permissions from, and
|
||||
* therefore what decides which services appear in the sidebar at all. Modes
|
||||
* of operation grant nothing on their own: an applicant who declared
|
||||
* "freight forwarder" here was still left on the seafarer default and never
|
||||
* saw the logistics or vessel entries.
|
||||
*
|
||||
* One type per account against a multi-select, so the widest capability wins:
|
||||
* a company representative who also sails still needs the company services,
|
||||
* and the seafarer side stays reachable through their own registration.
|
||||
*/
|
||||
function accountTypeFor(keys: string[]): string | null {
|
||||
if (keys.some((key) => !PERSONAL_REGISTRATION_KEYS.includes(key))) {
|
||||
// Historical enum value for the logistics company representative.
|
||||
return 'FRIGHTER';
|
||||
}
|
||||
if (keys.includes('VESSEL_REGISTRATION')) return 'VESSEL_OWNER';
|
||||
if (keys.includes('SEAFARER_REGISTRATION')) return 'SEAFARER';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant's modes of operation — what they do, and therefore which
|
||||
* licences the portal offers them.
|
||||
@@ -53,9 +78,11 @@ export function OperationsFormContent({
|
||||
*/
|
||||
onSaved?: (selectedKeys: string[]) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
|
||||
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
|
||||
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
|
||||
const [setAccountType] = useUpdateMyAccountTypeMutation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const declaredIds = useMemo(
|
||||
@@ -116,21 +143,32 @@ export function OperationsFormContent({
|
||||
async function persist() {
|
||||
try {
|
||||
await save({ licenseTypeIds: selected }).unwrap();
|
||||
const selectedKeys = options
|
||||
.filter((t) => selected.includes(t.id))
|
||||
.map((t) => t.key);
|
||||
|
||||
const type = accountTypeFor(selectedKeys);
|
||||
if (type) {
|
||||
// Refused for a registered seafarer, whose type the authority owns
|
||||
// (`registered_seafarer_type_is_fixed`), and for staff. The modes are
|
||||
// already stored either way, so a refusal here is not the applicant's
|
||||
// problem to see.
|
||||
await setAccountType({ type }).unwrap().catch(() => undefined);
|
||||
}
|
||||
|
||||
setConfirmingRemoval(false);
|
||||
notify.success(
|
||||
'The licences you can apply for have been updated to match.',
|
||||
'Operations updated',
|
||||
);
|
||||
onSaved?.(
|
||||
options.filter((t) => selected.includes(t.id)).map((t) => t.key),
|
||||
t('profileOperations.updateSuccessBody'),
|
||||
t('profileOperations.updateSuccessTitle'),
|
||||
);
|
||||
onSaved?.(selectedKeys);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), 'Could not save');
|
||||
notify.error(extractErrorMessage(err), t('profileOperations.updateErrorTitle'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingTypes || loadingMine) {
|
||||
return <Loader size="sm" />;
|
||||
return <Loader size="sm" type="oval" />;
|
||||
}
|
||||
|
||||
const removedNames = options
|
||||
@@ -140,10 +178,9 @@ export function OperationsFormContent({
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<div>
|
||||
<Title order={5}>Mode of operation</Title>
|
||||
<Title order={5}>{t('profileOperations.title')}</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
What your company operates as. This decides which licences you are
|
||||
offered — you can change it whenever your business changes.
|
||||
{t('profileOperations.description')}
|
||||
</Text>
|
||||
|
||||
<Checkbox.Group value={selected} onChange={setSelected}>
|
||||
@@ -157,7 +194,7 @@ export function OperationsFormContent({
|
||||
<Text size="sm">{localized(type.name)}</Text>
|
||||
{declaredIds.includes(type.id) && (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
Current
|
||||
{t('profileOperations.current')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -203,8 +240,7 @@ export function OperationsFormContent({
|
||||
|
||||
{options.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No licence types are configured yet. Contact EMA if you were
|
||||
expecting one.
|
||||
{t('profileOperations.emptyState')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,16 +250,17 @@ export function OperationsFormContent({
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="No operations selected"
|
||||
title={t('profileOperations.noneSelectedTitle')}
|
||||
>
|
||||
With none selected you will not be offered any licence to apply for.
|
||||
Existing applications and issued licences are unaffected.
|
||||
{t('profileOperations.noneSelectedBody')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{lastChanged ? `Last changed ${showDate(lastChanged)}` : 'Not set yet'}
|
||||
{lastChanged
|
||||
? t('profileOperations.lastChanged', { date: showDate(lastChanged) })
|
||||
: t('profileOperations.notSetYet')}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
{dirty && (
|
||||
@@ -232,7 +269,7 @@ export function OperationsFormContent({
|
||||
size="sm"
|
||||
onClick={() => setSelected(declaredIds)}
|
||||
>
|
||||
Discard changes
|
||||
{t('profileOperations.discardChanges')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -244,7 +281,7 @@ export function OperationsFormContent({
|
||||
removed.length > 0 ? setConfirmingRemoval(true) : persist()
|
||||
}
|
||||
>
|
||||
Save operations
|
||||
{t('profileOperations.saveOperations')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -254,31 +291,29 @@ export function OperationsFormContent({
|
||||
<Modal
|
||||
opened={confirmingRemoval}
|
||||
onClose={() => setConfirmingRemoval(false)}
|
||||
title="Remove from your operations?"
|
||||
title={t('profileOperations.removeModalTitle')}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
You are removing{' '}
|
||||
{t('profileOperations.removingPrefix')}{' '}
|
||||
<Text span fw={600}>
|
||||
{removedNames.join(', ')}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You will no longer be offered a new application of that type.
|
||||
Applications already filed carry on as they are, and licences
|
||||
already issued to you stay valid and can still be renewed.
|
||||
{t('profileOperations.removeConsequence')}
|
||||
</Text>
|
||||
<ModalFooter gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setConfirmingRemoval(false)}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button color="orange" loading={saving} onClick={persist}>
|
||||
Remove and save
|
||||
{t('profileOperations.removeAndSave')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||
|
||||
export const profileSchema = z.object({
|
||||
professionId: z.string().min(1, 'Select your profession'),
|
||||
firstName: z.string().min(3, 'First name must be at least 3 characters'),
|
||||
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
|
||||
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
|
||||
gender: z.string().min(1, 'Select your gender'),
|
||||
dob: z.string().min(1, 'Select your date of birth'),
|
||||
// dob comes in as a plain yyyy-MM-dd string; skip on empty/malformed so
|
||||
// dobRequired's own message takes precedence.
|
||||
function isAtLeast18(dob: string): boolean {
|
||||
const birth = new Date(dob);
|
||||
if (Number.isNaN(birth.getTime())) return true;
|
||||
const today = new Date();
|
||||
const cutoff = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate());
|
||||
return birth <= cutoff;
|
||||
}
|
||||
|
||||
export const profileSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
professionId: z.string().min(1, t('profileForm.validation.professionRequired')),
|
||||
firstName: z.string().min(3, t('profileForm.validation.firstNameMin')),
|
||||
middleName: z.string().min(3, t('profileForm.validation.middleNameMin')),
|
||||
lastName: z.string().min(3, t('profileForm.validation.lastNameMin')),
|
||||
gender: z.string().min(1, t('profileForm.validation.genderRequired')),
|
||||
dob: z
|
||||
.string()
|
||||
.min(1, t('profileForm.validation.dobRequired'))
|
||||
.refine((value) => isAtLeast18(value), {
|
||||
message: t('profileForm.validation.dobMinAge'),
|
||||
}),
|
||||
pob: z.string().optional(),
|
||||
maritalStatus: z.string().min(1, 'Select your marital status'),
|
||||
maritalStatus: z.string().min(1, t('profileForm.validation.maritalStatusRequired')),
|
||||
});
|
||||
|
||||
export type ProfileValues = z.infer<typeof profileSchema>;
|
||||
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
|
||||
|
||||
export const GENDERS = ['MALE', 'FEMALE'] as const;
|
||||
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
|
||||
@@ -39,11 +57,13 @@ export function ProfileFormContent({
|
||||
professionsLoading,
|
||||
professionOptions,
|
||||
}: ProfileFormContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Select
|
||||
label="Profession"
|
||||
placeholder={professionsLoading ? 'Loading...' : 'Select'}
|
||||
label={t('profileFields.professionId')}
|
||||
placeholder={professionsLoading ? t('common.loading') : t('common.select')}
|
||||
required
|
||||
data={professionOptions}
|
||||
error={errors.professionId?.message}
|
||||
@@ -53,34 +73,34 @@ export function ProfileFormContent({
|
||||
name="professionId"
|
||||
searchable
|
||||
disabled={professionsLoading}
|
||||
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
|
||||
rightSection={professionsLoading ? <Loader size="xs" type="oval" /> : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="Enter first name"
|
||||
label={t('profileFields.firstName')}
|
||||
placeholder={t('profileForm.placeholders.firstName')}
|
||||
required
|
||||
{...register('firstName')}
|
||||
error={errors.firstName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Middle Name"
|
||||
placeholder="Enter middle name"
|
||||
label={t('profileFields.middleName')}
|
||||
placeholder={t('profileForm.placeholders.middleName')}
|
||||
required
|
||||
{...register('middleName')}
|
||||
error={errors.middleName?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Enter last name"
|
||||
label={t('profileFields.lastName')}
|
||||
placeholder={t('profileForm.placeholders.lastName')}
|
||||
required
|
||||
{...register('lastName')}
|
||||
error={errors.lastName?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Gender"
|
||||
placeholder="Select"
|
||||
label={t('profileFields.gender')}
|
||||
placeholder={t('common.select')}
|
||||
required
|
||||
data={[...GENDERS]}
|
||||
data={GENDERS.map((g) => ({ value: g, label: t(`profileForm.genders.${g}`) }))}
|
||||
error={errors.gender?.message}
|
||||
value={watch('gender')}
|
||||
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
|
||||
@@ -88,7 +108,7 @@ export function ProfileFormContent({
|
||||
name="gender"
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label="Date of Birth"
|
||||
label={t('profileFields.dob')}
|
||||
required
|
||||
value={watch('dob')}
|
||||
onChange={(val) => setValue('dob', val, { shouldValidate: true })}
|
||||
@@ -97,16 +117,16 @@ export function ProfileFormContent({
|
||||
error={errors.dob?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Place of Birth"
|
||||
placeholder="City, Region"
|
||||
label={t('profileFields.pob')}
|
||||
placeholder={t('profileForm.placeholders.pob')}
|
||||
{...register('pob')}
|
||||
error={errors.pob?.message}
|
||||
/>
|
||||
<Select
|
||||
label="Marital Status"
|
||||
placeholder="Select"
|
||||
label={t('profileFields.maritalStatus')}
|
||||
placeholder={t('common.select')}
|
||||
required
|
||||
data={[...MARITAL_STATUSES]}
|
||||
data={MARITAL_STATUSES.map((m) => ({ value: m, label: t(`profileForm.maritalStatuses.${m}`) }))}
|
||||
error={errors.maritalStatus?.message}
|
||||
value={watch('maritalStatus')}
|
||||
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ProfileRequirementGate({
|
||||
})}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{requirement.reason}</Text>
|
||||
<Text size="sm">{t(requirement.reason)}</Text>
|
||||
<List size="sm" spacing={2}>
|
||||
{gaps.map((field) => (
|
||||
<List.Item key={field}>
|
||||
|
||||
@@ -1,105 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Navigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
PROFILE_FIELD_SECTION,
|
||||
useCurrentProfile,
|
||||
type ProfileRequirement,
|
||||
} from '@ema-platform/auth';
|
||||
import type { ProfileRequirement } from "@ema-platform/auth";
|
||||
|
||||
/**
|
||||
* Seafarer registration is filled in from the profile (nationality, ID,
|
||||
* names, contact details) — the server refuses an application missing them,
|
||||
* so they're asked for up front instead of at submit time.
|
||||
* The identity a seafarer registration is built from.
|
||||
*
|
||||
* Only the fields the Personal, Maritime Profile and Address tabs actually
|
||||
* mark required — matches `profileSchema` / `addressSchema`, so the gate is
|
||||
* always satisfiable by finishing those tabs and never blocks on an optional
|
||||
* field (place of birth, region/city/woreda, emergency contact) the forms
|
||||
* don't star.
|
||||
* Not a gate: the registration form (`/seafarer-registration`) collects these
|
||||
* itself and prefills from the profile where it can. `ProfilePage` reads it
|
||||
* to show what a registration will need.
|
||||
*/
|
||||
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||
fields: [
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'gender',
|
||||
'dob',
|
||||
'maritalStatus',
|
||||
'professionId',
|
||||
'idType',
|
||||
'idNumber',
|
||||
'nationality',
|
||||
'primaryPhoneNumber',
|
||||
'email',
|
||||
"firstName",
|
||||
"middleName",
|
||||
"lastName",
|
||||
"gender",
|
||||
"dob",
|
||||
"maritalStatus",
|
||||
"professionId",
|
||||
"idType",
|
||||
"idNumber",
|
||||
"nationality",
|
||||
"primaryPhoneNumber",
|
||||
"email",
|
||||
],
|
||||
reason:
|
||||
'Seafarer registration is built from your profile — these details fill it in for you.',
|
||||
"Seafarer registration is built from your profile — these details fill it in for you.",
|
||||
};
|
||||
|
||||
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
||||
|
||||
/**
|
||||
* Sends an applicant with an incomplete profile to `/profile` before they can
|
||||
* reach seafarer registration. Wraps `/seafarer-registration` directly and
|
||||
* `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the
|
||||
* latter is the shared wizard route every licence type renders through, so
|
||||
* without it the gate is a decoration a deep link skips.
|
||||
*
|
||||
* Fires before the wizard starts, not mid-application, so nothing is lost —
|
||||
* unlike the case `ProfileRequirementGate`'s doc comment warns against
|
||||
* (mid-flow redirects on the old, deleted setup wizard).
|
||||
*/
|
||||
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const { typeCode } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const { isLoading, isFetching, error, gapsFor } = useCurrentProfile();
|
||||
|
||||
// Shared wizard route — only the seafarer type is gated here.
|
||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||
const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : [];
|
||||
const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!redirecting) return;
|
||||
const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', ');
|
||||
notify.info(t('profileGate.seafarerRedirect', { fields }));
|
||||
// Fire once per redirect, not on every render while gaps/gapsFor are
|
||||
// recreated — the toast content is captured at the moment it fires.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [redirecting, pathname]);
|
||||
|
||||
if (!gated) return <>{children}</>;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// A failed lookup must not lock anyone out — the server still refuses the
|
||||
// application for a profile it can't fill in from.
|
||||
if (error) return <>{children}</>;
|
||||
|
||||
if (gaps.length === 0) return <>{children}</>;
|
||||
|
||||
// Gaps while a save is still landing are not an answer yet. Saving a
|
||||
// profile tab invalidates this query and the applicant may already be
|
||||
// headed back here in the same tick — deciding on the pre-save cache would
|
||||
// bounce them off the screen they just finished.
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const target = PROFILE_FIELD_SECTION[gaps[0]];
|
||||
return <Navigate to={`/profile#${target}`} replace />;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user