Merge pull request #18 from Tria-plc/estif-branch-1

Estif branch 1
This commit is contained in:
Nati Nigussie
2026-08-19 17:05:23 +03:00
committed by GitHub
133 changed files with 9753 additions and 2848 deletions

View File

@@ -6,9 +6,122 @@
<link rel="icon" type="image/png" href="/ema-logo.png" /> <link rel="icon" type="image/png" href="/ema-logo.png" />
<link rel="apple-touch-icon" href="/ema-logo.png" /> <link rel="apple-touch-icon" href="/ema-logo.png" />
<title>EMA Backoffice</title> <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> </head>
<body> <body>
<div id="root"></div> <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> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extractErrorMessage } from '@ema-platform/api'; 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 * 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() { export function useTemplatePreview() {
const { t } = useTranslation(); const { t } = useTranslation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return useCallback( const open = useCallback(
async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => { async ({ hbsSource, licenseTypeId, landscape }: PreviewArgs) => {
try { try {
// The preview returns a PDF stream, not JSON, so it bypasses RTK Query // 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()); if (!response.ok) throw new Error(await response.text());
const url = URL.createObjectURL(await response.blob()); setPreviewUrl(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);
} catch (err) { } catch (err) {
notifications.show({ notifications.show({
color: 'red', color: 'red',
@@ -52,4 +51,13 @@ export function useTemplatePreview() {
}, },
[t], [t],
); );
const close = useCallback(() => {
setPreviewUrl((current) => {
if (current) URL.revokeObjectURL(current);
return null;
});
}, []);
return { previewUrl, open, close };
} }

View File

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

View File

@@ -31,6 +31,7 @@ import {
AdvancedTable, AdvancedTable,
useServerTable, useServerTable,
ModalFooter, ModalFooter,
PageLoader,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import { LocationPage } from "../../../location/pages/LocationPage"; import { LocationPage } from "../../../location/pages/LocationPage";
import { CertificationPage } from "../../../certification/pages/CertificationPage"; import { CertificationPage } from "../../../certification/pages/CertificationPage";
@@ -294,11 +295,7 @@ function ProfessionTab() {
]; ];
if (isLoading) { if (isLoading) {
return ( return <PageLoader label="Loading Configuration…" height={400} />;
<Center py="xl">
<Loader />
</Center>
);
} }
if (isError) { if (isError) {

View File

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

View File

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

View File

@@ -76,9 +76,13 @@ export function DecisionBar({
role="region" role="region"
aria-label={t('review.decisionBar', 'Decision bar')} 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. */} {/* 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"> <Badge color={STATUS_COLORS[status]} variant="light" size="lg">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])} {t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge> </Badge>
@@ -124,7 +128,7 @@ export function DecisionBar({
</Group> </Group>
{/* Right: the decision. */} {/* Right: the decision. */}
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="wrap" justify="flex-end" style={{ flex: '0 1 auto' }}>
{primary.map((action) => ( {primary.map((action) => (
<ActionButton <ActionButton
key={action.id} key={action.id}
@@ -194,10 +198,17 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
const button = ( const button = (
<Button <Button
size={size} size={size}
variant={action.emphasis === 'filled' ? 'filled' : action.emphasis ?? 'light'} variant={
action.emphasis === 'filled'
? 'filled'
: action.emphasis === 'subtle'
? 'default'
: 'light'
}
color={action.color} color={action.color}
loading={busy} loading={busy}
disabled={!action.enabled} disabled={!action.enabled}
style={{ flexShrink: 0 }}
onClick={() => onAction(action)} onClick={() => onAction(action)}
> >
{t(action.labelKey)} {t(action.labelKey)}
@@ -207,7 +218,13 @@ function ActionButton({ action, busy, size, onAction }: ActionButtonProps) {
if (action.enabled) return button; if (action.enabled) return button;
return ( 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> <span style={{ display: 'inline-flex', cursor: 'not-allowed' }}>{button}</span>
</Tooltip> </Tooltip>
); );
@@ -234,7 +251,13 @@ function MenuAction({
); );
if (action.enabled) return item; if (action.enabled) return item;
return ( return (
<Tooltip label={action.disabledReason} withArrow position="left"> <Tooltip
label={action.disabledReason}
withArrow
position="left"
multiline
w={280}
>
<div>{item}</div> <div>{item}</div>
</Tooltip> </Tooltip>
); );

View File

@@ -34,6 +34,7 @@ import {
type DocumentRequirement, type DocumentRequirement,
} from "@ema-platform/api"; } from "@ema-platform/api";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { PdfPreviewModal } from "@ema-platform/ui";
interface DocumentsTabProps { interface DocumentsTabProps {
applicationId: string; applicationId: string;
@@ -441,8 +442,20 @@ 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 <Drawer
opened={Boolean(preview)} opened={Boolean(preview) && !isPdf}
onClose={() => setPreview(null)} onClose={() => setPreview(null)}
position="right" position="right"
size="xl" size="xl"
@@ -458,16 +471,7 @@ export function DocumentsTab({
returnFocus returnFocus
> >
{previewFile?.url ? ( {previewFile?.url ? (
isPdf ? ( isImage ? (
<iframe
src={previewFile.url}
title={
preview?.documentKey ??
t("review.documents.previewFallback", "document")
}
style={{ width: "100%", height: "80vh", border: "none" }}
/>
) : isImage ? (
<img <img
src={previewFile.url} src={previewFile.url}
alt={ alt={

View File

@@ -268,11 +268,18 @@ export interface ResolveContext {
needsFlags: string; needsFlags: string;
needsCapital: string; needsCapital: string;
needsInspection: string; needsInspection: string;
needsDocumentReviews: string;
}; };
/** Number of sections/documents the officer has flagged for correction. */ /** Number of sections/documents the officer has flagged for correction. */
flaggedCount: number; flaggedCount: number;
/** True when an inspection is scheduled and awaiting a result. */ /** True when an inspection is scheduled and awaiting a result. */
hasPendingInspection: boolean; 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;
} }
/** /**
@@ -358,6 +365,13 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
return disabled(reasons.notAssigned); 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) { if (action.id === 'request-adjustment' && ctx.flaggedCount === 0) {
return disabled(reasons.needsFlags); return disabled(reasons.needsFlags);
} }

View File

@@ -120,14 +120,19 @@ export function licenseQueueColumns(
{ {
header: sortableHeader(t("queue.statusCol", "Status"), "status"), header: sortableHeader(t("queue.statusCol", "Status"), "status"),
label: t("queue.statusCol", "Status"), label: t("queue.statusCol", "Status"),
cell: ({ row }) => ( cell: ({ row }) => {
<Badge color={STATUS_COLORS[row.original.status]} variant="light"> const label = t(
{t( `queue.statusValues.${row.original.status}`,
`queue.statusValues.${row.original.status}`, STATUS_LABELS[row.original.status],
STATUS_LABELS[row.original.status], );
)} return (
</Badge> <Tooltip label={label} withArrow>
), <Badge color={STATUS_COLORS[row.original.status]} variant="light">
{label}
</Badge>
</Tooltip>
);
},
}, },
{ {
header: sortableHeader( header: sortableHeader(

View File

@@ -45,6 +45,7 @@ import {
useLazyExportApplicationsQuery, useLazyExportApplicationsQuery,
type LicenseApplication, type LicenseApplication,
type LicenseStatus, type LicenseStatus,
type LicenseType,
type QueueFilter, type QueueFilter,
} from "@ema-platform/api"; } from "@ema-platform/api";
import { import {
@@ -75,6 +76,17 @@ import { licenseQueueActionsColumn } from "./actions";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const SEARCH_DEBOUNCE_MS = 300; 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[] = [ const ALL_STATUSES: LicenseStatus[] = [
"SUBMITTED", "SUBMITTED",
"UNDER_REVIEW", "UNDER_REVIEW",
@@ -92,6 +104,24 @@ const ALL_STATUSES: LicenseStatus[] = [
"REJECTED", "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. * The officer work pool.
* *
@@ -153,14 +183,37 @@ export function LicenseQueuePage() {
const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0]; const activeView = SAVED_VIEWS.find((v) => v.id === view) ?? SAVED_VIEWS[0];
const { data: licenseTypes } = useGetLicenseTypesQuery(); const { data: licenseTypes } = useGetLicenseTypesQuery();
const { data: counts } = useGetQueueCountsQuery();
// A `/licence-review/type/:typeCode` deep link pins the type facet. // A `/licence-review/type/:typeCode` deep link pins the type facet.
const pinnedTypeId = useMemo(() => { const pinnedTypeId = useMemo(() => {
if (!typeCode) return undefined; if (!typeCode) return undefined;
return licenseTypes?.items?.find((type) => type.key === typeCode)?.id; return licenseTypes?.items?.find((type) => type.key === typeCode)?.id;
}, [typeCode, licenseTypes]); }, [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( const filter: QueueFilter = useMemo(
() => ({ () => ({
...activeView.filter, ...activeView.filter,
@@ -264,6 +317,21 @@ export function LicenseQueuePage() {
updateUrl(next, view, 1); 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 toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
const dir = const dir =
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC" urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
@@ -494,7 +562,7 @@ export function LicenseQueuePage() {
<MultiSelect <MultiSelect
label={t("queue.status", "Status")} label={t("queue.status", "Status")}
placeholder={t("queue.anyStatus", "Any")} placeholder={t("queue.anyStatus", "Any")}
data={ALL_STATUSES.map((s) => ({ data={statusOptions.map((s) => ({
value: s, value: s,
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]), label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
}))} }))}
@@ -512,7 +580,7 @@ export function LicenseQueuePage() {
label: localized(type.name, i18n.language) || type.key, label: localized(type.name, i18n.language) || type.key,
}))} }))}
value={urlFilter.licenseTypeId ?? null} value={urlFilter.licenseTypeId ?? null}
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })} onChange={(v) => changeType(v ?? undefined)}
clearable clearable
w={220} w={220}
/> />

View File

@@ -48,6 +48,7 @@ import {
useFinalApproveMutation, useFinalApproveMutation,
useGetApplicationForReviewQuery, useGetApplicationForReviewQuery,
useGetAttachmentsQuery, useGetAttachmentsQuery,
useGetDocumentReviewsQuery,
useGetInspectionsQuery, useGetInspectionsQuery,
useGetAssignableOfficersQuery, useGetAssignableOfficersQuery,
useGetLicenseTypeRequirementsQuery, useGetLicenseTypeRequirementsQuery,
@@ -58,12 +59,14 @@ import {
useResumeApplicationMutation, useResumeApplicationMutation,
useScheduleInspectionMutation, useScheduleInspectionMutation,
type RemarkTargetType, type RemarkTargetType,
type StaffEvidenceRequirement,
} from "@ema-platform/api"; } from "@ema-platform/api";
import { import {
AdvancedTable, AdvancedTable,
AmharicDatePicker, AmharicDatePicker,
ErrorState, ErrorState,
ModalFooter, ModalFooter,
PdfPreviewModal,
useServerTable, useServerTable,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import { useDateDisplayer } from "@ema-platform/shared"; import { useDateDisplayer } from "@ema-platform/shared";
@@ -180,6 +183,19 @@ export function LicenseReviewPage() {
), ),
[requirements], [requirements],
); );
// What each role is *required* to produce (CV, work agreement, ERB
// certificate). Without this the tab can only list what was uploaded, so a
// missing CV looks identical to a role that never needed one.
const evidenceByRole = useMemo(
() =>
new Map(
requirements?.staffRoleRequirements.map((r) => [
r.roleKey,
r.requiredEvidence ?? [],
]) ?? [],
),
[requirements],
);
const [completeReview] = useCompleteReviewMutation(); const [completeReview] = useCompleteReviewMutation();
const [requestAdjustment] = useRequestAdjustmentMutation(); const [requestAdjustment] = useRequestAdjustmentMutation();
@@ -200,6 +216,9 @@ export function LicenseReviewPage() {
// Real officer list, so Assign and Escalate name a person instead of // Real officer list, so Assign and Escalate name a person instead of
// silently reassigning to whoever already held the application. // silently reassigning to whoever already held the application.
const { data: officers = [] } = useGetAssignableOfficersQuery(); const { data: officers = [] } = useGetAssignableOfficersQuery();
// Same cached query the Documents tab reads, so the decision bar reacts the
// moment a verdict is saved.
const { data: documentReviews = [] } = useGetDocumentReviewsQuery(id, { skip: !id });
const staffTable = useServerTable(); const staffTable = useServerTable();
const [flags, setFlags] = useState<FlagMap>({}); const [flags, setFlags] = useState<FlagMap>({});
@@ -253,7 +272,24 @@ export function LicenseReviewPage() {
return map; return map;
}, [flags]); }, [flags]);
const pendingInspection = inspections.find((i) => i.status === "SCHEDULED"); const pendingInspection = inspections.find((i) => i.status === 'SCHEDULED');
// Approving means every uploaded document was accepted — one unjudged or
// rejected file is enough to keep the decision buttons dead. The counts feed
// the hover explanation, so the officer sees how much is left rather than
// just that the button is dead.
const documentProgress = useMemo(() => {
const attachments = data?.attachments ?? [];
const accepted = new Set(
documentReviews
.filter((review) => review.decision === 'ACCEPTED')
.map((review) => review.documentKey),
);
const acceptedCount = attachments.filter((a) => accepted.has(a.documentKey)).length;
return { acceptedCount, total: attachments.length };
}, [data?.attachments, documentReviews]);
const allDocumentsAccepted =
documentProgress.total > 0 && documentProgress.acceptedCount === documentProgress.total;
const flagged = Object.entries(flags); const flagged = Object.entries(flags);
/** /**
@@ -293,31 +329,26 @@ export function LicenseReviewPage() {
can, can,
flaggedCount: flagged.length, flaggedCount: flagged.length,
hasPendingInspection: Boolean(pendingInspection), hasPendingInspection: Boolean(pendingInspection),
allDocumentsAccepted,
reasons: { reasons: {
wrongStatus: t( wrongStatus: t('review.disabled.wrongStatus', 'Not available at this stage'),
"review.disabled.wrongStatus", notAssigned: t('review.disabled.notAssigned', 'Assigned to another officer'),
"Not available at this stage", noPermission: t('review.disabled.noPermission', 'You do not have permission'),
), needsFlags: t('review.disabled.needsFlags', 'Flag at least one item to request a correction'),
notAssigned: t( needsCapital: t('review.disabled.needsCapital', 'Record the verified capital first'),
"review.disabled.notAssigned", needsInspection: t('review.disabled.needsInspection', 'Requires an inspection result'),
"Assigned to another officer", needsDocumentReviews:
), documentProgress.total === 0
noPermission: t( ? t(
"review.disabled.noPermission", 'review.disabled.needsDocumentsUploaded',
"You do not have permission", 'No documents uploaded to review yet',
), )
needsFlags: t( : t('review.disabled.needsDocumentReviews', {
"review.disabled.needsFlags", accepted: documentProgress.acceptedCount,
"Flag at least one item to request a correction", total: documentProgress.total,
), defaultValue:
needsCapital: t( 'Accept all documents first — {{accepted}} of {{total}} accepted. Open the Documents tab and accept the rest.',
"review.disabled.needsCapital", }),
"Record the verified capital first",
),
needsInspection: t(
"review.disabled.needsInspection",
"Requires an inspection result",
),
}, },
}); });
}, [data, currentUserId, can, flagged.length, pendingInspection, t]); }, [data, currentUserId, can, flagged.length, pendingInspection, t]);
@@ -996,6 +1027,7 @@ export function LicenseReviewPage() {
renderEvidence: (member) => ( renderEvidence: (member) => (
<StaffEvidenceCell <StaffEvidenceCell
staffId={member.id} staffId={member.id}
required={evidenceByRole.get(member.roleKey) ?? []}
fallback={member.documents} fallback={member.documents}
/> />
), ),
@@ -1344,36 +1376,96 @@ export function LicenseReviewPage() {
*/ */
function StaffEvidenceCell({ function StaffEvidenceCell({
staffId, staffId,
required,
fallback, fallback,
}: { }: {
staffId: string; staffId: string;
/** What this person's role must produce, from the licence-type config. */
required: StaffEvidenceRequirement[];
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[]; fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
}) { }) {
const localized = useLocalized();
const { t } = useTranslation();
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const { data: attachments } = useGetAttachmentsQuery({ const { data: attachments } = useGetAttachmentsQuery({
ownerType: "APPLICATION_STAFF", ownerType: "APPLICATION_STAFF",
ownerId: staffId, ownerId: staffId,
}); });
const docs = attachments?.length ? attachments : (fallback ?? []); const docs = attachments?.length ? attachments : (fallback ?? []);
const uploadedBy = new Map(docs.map((d) => [d.documentKey, d]));
// Drive the list off the requirements, not off what happens to have been
// uploaded: a mandatory CV that is absent has to be visible as absent, which
// is the whole point of the officer looking at this column. Anything
// uploaded outside the list still gets shown rather than silently dropped.
const extras = docs.filter(
(d) => !required.some((r) => r.docKey === d.documentKey),
);
if (!required.length && !extras.length) {
return (
<Text size="xs" c="dimmed">
</Text>
);
}
const badge = (
key: string,
label: string,
url: string | undefined,
mandatory: boolean,
) => {
const missing = !url;
return (
<Tooltip
key={key}
label={
missing
? mandatory
? t("review.evidenceMissingRequired", "Required — not uploaded")
: t("review.evidenceMissing", "Not uploaded")
: t("licensing.documents.view", "View")
}
withArrow
>
<Badge
size="xs"
variant={missing ? "outline" : "light"}
color={missing ? (mandatory ? "red" : "gray") : "teal"}
style={missing ? undefined : { cursor: "pointer" }}
onClick={
missing ? undefined : () => setPreview({ url: url, title: label })
}
>
{label}
{missing && mandatory ? " *" : ""}
</Badge>
</Tooltip>
);
};
return ( return (
<Group gap={4}> <Group gap={4}>
{docs.map((doc) => { {required.map((item) =>
const url = doc.files?.[0]?.url; badge(
return ( item.docKey,
<Badge localized(item.label) || item.docKey,
key={doc.id} uploadedBy.get(item.docKey)?.files?.[0]?.url,
size="xs" item.mandatory,
variant="light" ),
component={url ? "a" : undefined} )}
href={url} {extras.map((doc) =>
target={url ? "_blank" : undefined} badge(doc.id, doc.documentKey, doc.files?.[0]?.url, false),
rel={url ? "noreferrer" : undefined} )}
style={url ? { cursor: "pointer" } : undefined} <PdfPreviewModal
> opened={Boolean(preview)}
{doc.documentKey} onClose={() => setPreview(null)}
</Badge> url={preview?.url ?? ""}
); title={preview?.title}
})} />
</Group> </Group>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import type {
MedicalCertificate, MedicalCertificate,
SeaServiceRecord, SeaServiceRecord,
SeafarerProfileSummary, SeafarerProfileSummary,
SeafarerRecordStatus,
} from '@ema-platform/api'; } from '@ema-platform/api';
export function ownerName(profile?: SeafarerProfileSummary): string { export function ownerName(profile?: SeafarerProfileSummary): string {
@@ -16,6 +17,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( export function medicalColumns(
t: TFunction, t: TFunction,
showDate: (date: string) => string, showDate: (date: string) => string,
@@ -72,6 +95,7 @@ export function medicalColumns(
</Badge> </Badge>
), ),
}, },
statusColumn<MedicalCertificate>(t),
]; ];
} }
@@ -127,5 +151,6 @@ export function seaServiceColumns(
</Text> </Text>
), ),
}, },
statusColumn<SeaServiceRecord>(t),
]; ];
} }

View File

@@ -9,6 +9,7 @@ import {
Loader, Loader,
Modal, Modal,
Paper, Paper,
SegmentedControl,
Stack, Stack,
Tabs, Tabs,
Text, Text,
@@ -22,7 +23,12 @@ import {
IconPaperclip, IconPaperclip,
IconStethoscope, IconStethoscope,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui'; import {
AdvancedTable,
notify,
PdfPreviewModal,
type AdvancedColumn,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { import {
extractErrorMessage, extractErrorMessage,
@@ -32,7 +38,11 @@ import {
useVerifyMedicalCertificateMutation, useVerifyMedicalCertificateMutation,
useVerifySeaServiceRecordMutation, useVerifySeaServiceRecordMutation,
} from '@ema-platform/api'; } 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 { medicalColumns, seaServiceColumns, ownerName } from './columns';
import { medicalActionsColumn, seaServiceActionsColumn } from './actions'; import { medicalActionsColumn, seaServiceActionsColumn } from './actions';
@@ -104,6 +114,9 @@ function AttachmentsModal({
{ ownerType, ownerId: ownerId ?? '' }, { ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId }, { skip: !ownerId },
); );
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const files = useMemo( const files = useMemo(
() => (attachments ?? []).flatMap((a) => a.files ?? []), () => (attachments ?? []).flatMap((a) => a.files ?? []),
@@ -151,10 +164,9 @@ function AttachmentsModal({
size="compact-xs" size="compact-xs"
variant="light" variant="light"
leftSection={<IconEye size={14} />} leftSection={<IconEye size={14} />}
component="a" onClick={() =>
href={file.url} setPreview({ url: file.url as string, title: file.originalName })
target="_blank" }
rel="noopener noreferrer"
> >
{t('recordVerification.view', 'View')} {t('recordVerification.view', 'View')}
</Button> </Button>
@@ -168,6 +180,12 @@ function AttachmentsModal({
)) ))
)} )}
</Stack> </Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Modal> </Modal>
); );
} }
@@ -180,19 +198,20 @@ function AttachmentsModal({
*/ */
export function MedicalVerificationPage() { export function MedicalVerificationPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
const { const {
data: pendingMedical, data: pendingMedical,
isLoading: loadingMedical, isLoading: loadingMedical,
isFetching: fetchingMedical, isFetching: fetchingMedical,
refetch: refetchMedical, refetch: refetchMedical,
} = useGetPendingMedicalQuery(); } = useGetPendingMedicalQuery(filter);
const { const {
data: pendingSeaService, data: pendingSeaService,
isLoading: loadingSeaService, isLoading: loadingSeaService,
isFetching: fetchingSeaService, isFetching: fetchingSeaService,
refetch: refetchSeaService, refetch: refetchSeaService,
} = useGetPendingSeaServiceQuery(); } = useGetPendingSeaServiceQuery(filter);
const [verifyMedical, { isLoading: rulingMedical }] = const [verifyMedical, { isLoading: rulingMedical }] =
useVerifyMedicalCertificateMutation(); useVerifyMedicalCertificateMutation();
@@ -251,6 +270,12 @@ export function MedicalVerificationPage() {
return pendingSeaServiceList.slice(start, start + seaServicePageSize); return pendingSeaServiceList.slice(start, start + seaServicePageSize);
}, [pendingSeaServiceList, seaServicePage, seaServicePageSize]); }, [pendingSeaServiceList, seaServicePage, seaServicePageSize]);
const changeFilter = useCallback((value: string) => {
setFilter(value as RecordQueueFilter);
setMedicalPage(0);
setSeaServicePage(0);
}, []);
const handleMedicalPageSizeChange = useCallback((size: number) => { const handleMedicalPageSizeChange = useCallback((size: number) => {
setMedicalPageSize(size); setMedicalPageSize(size);
setMedicalPage(0); setMedicalPage(0);
@@ -261,6 +286,34 @@ export function MedicalVerificationPage() {
setSeaServicePage(0); 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( const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
() => [ () => [
...medicalColumns(t, showDate), ...medicalColumns(t, showDate),
@@ -348,6 +401,7 @@ export function MedicalVerificationPage() {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="medical" pt="md"> <Tabs.Panel value="medical" pt="md">
{statusFilter}
<AdvancedTable <AdvancedTable
columns={medicalTableColumns} columns={medicalTableColumns}
data={pagedMedical} data={pagedMedical}
@@ -362,11 +416,12 @@ export function MedicalVerificationPage() {
onPageSizeChange={handleMedicalPageSizeChange} onPageSizeChange={handleMedicalPageSizeChange}
refresh={refetchMedical} refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical} isLoading={loadingMedical || fetchingMedical}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} emptyText={emptyText}
/> />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="sea-service" pt="md"> <Tabs.Panel value="sea-service" pt="md">
{statusFilter}
<AdvancedTable <AdvancedTable
columns={seaServiceTableColumns} columns={seaServiceTableColumns}
data={pagedSeaService} data={pagedSeaService}
@@ -381,7 +436,7 @@ export function MedicalVerificationPage() {
onPageSizeChange={handleSeaServicePageSizeChange} onPageSizeChange={handleSeaServicePageSizeChange}
refresh={refetchSeaService} refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService} isLoading={loadingSeaService || fetchingSeaService}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} emptyText={emptyText}
/> />
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>

View File

@@ -30,6 +30,7 @@ import {
AdvancedTable, AdvancedTable,
useServerTable, useServerTable,
type AdvancedColumn, type AdvancedColumn,
PageLoader,
} from '@ema-platform/ui'; } from '@ema-platform/ui';
import { import {
extractErrorMessage, extractErrorMessage,
@@ -64,11 +65,7 @@ export function PaymentConfigPage() {
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable(); const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
if (isLoading) { if (isLoading) {
return ( return <PageLoader label="Loading Payment Configuration…" height={400} />;
<Center h={300}>
<Loader />
</Center>
);
} }
if (error) { if (error) {

View File

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

View File

@@ -44,7 +44,7 @@ import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui'; import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api'; 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 type { AuthUser } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { useAppDispatch, useAppSelector } from '../../../store/hooks';
@@ -87,7 +87,16 @@ export function ProfilePage() {
const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingPassword, setIsSavingPassword] = useState(false);
// UI-only preferences (no backend wiring yet). // 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 [twoStepEnabled, setTwoStepEnabled] = useState(false);
// const {
// enabled: twoStepEnabled,
// isLoading: twoStepLoading,
// isSaving: twoStepSaving,
// setEnabled: setTwoStepEnabled,
// } = useTwoFactor();
const [emailNotifications, setEmailNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true);
// Load the latest profile from the server on mount so the form always // Load the latest profile from the server on mount so the form always
@@ -395,8 +404,9 @@ export function ProfilePage() {
{/* ---- Security ---- */} {/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md"> <Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder> <Stack gap="lg">
<form onSubmit={handlePasswordSubmit(onChangePassword)}> <Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>{t('profile.security')}</Title> <Title order={5}>{t('profile.security')}</Title>
@@ -449,7 +459,7 @@ export function ProfilePage() {
backgroundColor: backgroundColor:
i <= score i <= score
? `var(--mantine-color-${strengthColors[score]}-6)` ? `var(--mantine-color-${strengthColors[score]}-6)`
: 'var(--mantine-color-gray-2)', : 'var(--mantine-color-default-border)',
}} }}
/> />
))} ))}
@@ -471,6 +481,15 @@ export function ProfilePage() {
<Switch <Switch
checked={twoStepEnabled} checked={twoStepEnabled}
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)} 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> </Group>
@@ -484,8 +503,11 @@ export function ProfilePage() {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
<ActiveSessions />
</Stack>
</Tabs.Panel> </Tabs.Panel>
{/* ---- Preferences ---- */} {/* ---- Preferences ---- */}
@@ -525,7 +547,7 @@ export function ProfilePage() {
) : ( ) : (
<IconCircle <IconCircle
size={20} size={20}
color="var(--mantine-color-gray-4)" color="var(--mantine-color-dimmed)"
/> />
)} )}
</Group> </Group>
@@ -558,7 +580,7 @@ export function ProfilePage() {
color={ color={
active active
? 'var(--mantine-color-emaPrimary-6)' ? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)' : 'var(--mantine-color-dimmed)'
} }
/> />
<Text fw={600} size="sm" style={{ flex: 1 }}> <Text fw={600} size="sm" style={{ flex: 1 }}>
@@ -600,7 +622,7 @@ export function ProfilePage() {
color={ color={
active active
? 'var(--mantine-color-emaPrimary-6)' ? 'var(--mantine-color-emaPrimary-6)'
: 'var(--mantine-color-gray-6)' : 'var(--mantine-color-dimmed)'
} }
/> />
<Text fw={600} size="sm" style={{ flex: 1 }}> <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 align="flex-start" justify="space-between" wrap="nowrap">
<Group gap="sm" 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> <div>
<Text fw={600}>{t('profile.notifications.title')}</Text> <Text fw={600}>{t('profile.notifications.title')}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,6 @@ export const am: Translations = {
soon: "በቅርቡ", soon: "በቅርቡ",
details: "ዝርዝር", details: "ዝርዝር",
licenceReview: "የፈቃድ ማመልከቻዎች", licenceReview: "የፈቃድ ማመልከቻዎች",
vesselRegistrationHeadDashboard: "የመርከብ ምዝገባ ኃላፊ ዳሽቦርድ",
vesselRegistrationApplicationQueue: "የመርከብ ምዝገባ ወረፋ", vesselRegistrationApplicationQueue: "የመርከብ ምዝገባ ወረፋ",
vesselRegistrationQueue: "የመርከብ መዝገብ", vesselRegistrationQueue: "የመርከብ መዝገብ",
vesselFormBuilder: "የመርከብ ቅጽ መገንቢያ", vesselFormBuilder: "የመርከብ ቅጽ መገንቢያ",
@@ -107,6 +106,8 @@ export const am: Translations = {
common: { common: {
logout: "ውጣ", logout: "ውጣ",
idleLogoutTitle: "ክፍለ ጊዜው አልቋል",
idleLogoutMessage: "ለ15 ደቂቃ እንቅስቃሴ ባለማድረግዎ ምክንያት ወጥተዋል።",
profile: "መገለጫ", profile: "መገለጫ",
settings: "ቅንብሮች", settings: "ቅንብሮች",
export: "ላክ", export: "ላክ",
@@ -454,9 +455,41 @@ export const am: Translations = {
dark: "ሌሊት", dark: "ሌሊት",
system: "ሲስተም", 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: { twoStep: {
title: "ባለሁለት ደረጃ ማረጋገጫ", title: "ባለሁለት ደረጃ ማረጋገጫ",
desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።", desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",
saved: "ባለሁለት ደረጃ ማረጋገጫ ተዘምኗል",
}, },
layout: { layout: {
title: "አቀማመጥ", title: "አቀማመጥ",
@@ -952,6 +985,9 @@ export const am: Translations = {
needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ", needsFlags: "ማስተካከያ ለመጠየቅ ቢያንስ አንድ ነገር ምልክት ያድርጉ",
needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ", needsCapital: "መጀመሪያ የተረጋገጠውን ካፒታል ይመዝግቡ",
needsInspection: "የምርመራ ውጤት ያስፈልጋል", needsInspection: "የምርመራ ውጤት ያስፈልጋል",
needsDocumentReviews:
"መጀመሪያ ሁሉንም ሰነዶች ይቀበሉ — ከ{{total}} {{accepted}} ተቀብለዋል። የሰነዶች ትር ከፍተው ቀሪዎቹን ይቀበሉ።",
needsDocumentsUploaded: "እስካሁን የሚገመገም ሰነድ አልተጫነም",
}, },
reasons: { reasons: {
incompleteDocuments: "ያልተሟሉ ሰነዶች", incompleteDocuments: "ያልተሟሉ ሰነዶች",

View File

@@ -65,7 +65,6 @@ export const en = {
userManagement: 'User Management', userManagement: 'User Management',
seamanBookQueue: 'Seaman Book Queue', seamanBookQueue: 'Seaman Book Queue',
btcQueue: 'BTC Queue', btcQueue: 'BTC Queue',
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
vesselRegistrationApplicationQueue: 'Vessel Registration Queue', vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
vesselRegistrationQueue: 'Vessel Register', vesselRegistrationQueue: 'Vessel Register',
vesselFormBuilder: 'Vessel Form Builder', vesselFormBuilder: 'Vessel Form Builder',
@@ -105,6 +104,8 @@ export const en = {
common: { common: {
logout: 'Log out', logout: 'Log out',
idleLogoutTitle: 'Session ended',
idleLogoutMessage: 'You were signed out after 15 minutes of inactivity.',
profile: 'Profile', profile: 'Profile',
settings: 'Settings', settings: 'Settings',
export: 'Export', export: 'Export',
@@ -452,9 +453,41 @@ export const en = {
dark: 'Dark', dark: 'Dark',
system: 'System', 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: { twoStep: {
title: 'Two-step verification', title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.', desc: 'Require a one-time code from your phone each time you sign in.',
saved: 'Two-step verification updated',
}, },
layout: { layout: {
title: 'Layout', title: 'Layout',
@@ -959,6 +992,9 @@ export const en = {
needsFlags: 'Flag at least one item to request a correction', needsFlags: 'Flag at least one item to request a correction',
needsCapital: 'Record the verified capital first', needsCapital: 'Record the verified capital first',
needsInspection: 'Requires an inspection result', 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: { reasons: {
incompleteDocuments: 'Incomplete documents', incompleteDocuments: 'Incomplete documents',

View File

@@ -1,9 +1,9 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AppShell } from '@mantine/core'; import { AppShell, Drawer } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui'; import type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
@@ -26,26 +26,24 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116; 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() { export function BackofficeLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [opened, { toggle: toggleNav }] = useDisclosure(); const [opened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const user = useAppSelector((state) => state.auth.user); const user = useAppSelector((state) => state.auth.user);
const layoutMode = useAppSelector((state) => state.preferences.layoutMode); const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
const { permissions: granted, known } = usePermissions(); 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 // Badges reflect real pending work. One grouped request on a timer, shared
// by the sidebar and the top bar via the RTK cache. // by the sidebar and the top bar via the RTK cache.
const { data: counts } = useGetQueueCountsQuery(undefined, { const { data: counts } = useGetQueueCountsQuery(undefined, {
@@ -89,9 +87,20 @@ export function BackofficeLayout() {
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
dispatch(logout()); dispatch(logout());
dispatch(baseApi.util.resetApiState()); dispatch(baseApi.util.resetApiState());
navigate("/login"); navigate("/");
}, [dispatch, 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); const segments = location.pathname.split('/').filter(Boolean);
// Label each crumb from the nav item it corresponds to, falling back to a // 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 // readable form of the path segment. Every crumb was previously labelled
@@ -141,7 +150,10 @@ export function BackofficeLayout() {
? { ? {
width: collapsed ? 72 : 264, width: collapsed ? 72 : 264,
breakpoint: "sm", 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 : undefined
} }
@@ -222,6 +234,34 @@ export function BackofficeLayout() {
{/* Registered once for the whole backoffice; opens on ⌘K anywhere. */} {/* Registered once for the whole backoffice; opens on ⌘K anywhere. */}
<CommandPalette sections={sections} /> <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> </AppShell>
); );
} }

View File

@@ -108,12 +108,11 @@ export const NAV_SECTIONS: NavSection[] = [
{ {
label: 'nav.groupVessels', label: 'nav.groupVessels',
items: [ 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: '/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: '/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-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] },
], ],
}, },
{ {

View File

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

View File

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

View File

@@ -35,7 +35,6 @@ import { VesselRegistrationQueuePage } from '../features/vessel-registration/pag
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage'; import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage'; import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage'; 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 { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage'; import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage'; import { LicenseReviewPage } from '../features/license-review/pages/LicenseReviewPage';
@@ -71,7 +70,6 @@ const router = createBrowserRouter([
element: <BackofficeLayout />, element: <BackofficeLayout />,
children: [ children: [
{ path: 'dashboard', element: <DashboardPage /> }, { 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: 'logistics-head-dashboard', element: guard(APPLICATION_QUEUE, <LogisticsHeadDashboardPage />) },
{ path: 'profile', element: <ProfilePage /> }, { path: 'profile', element: <ProfilePage /> },
{ path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], <ConfigurationPage />) }, { path: 'configuration', element: guard([P.VIEW_LICENSE_TYPES, P.VIEW_TEMPLATES], <ConfigurationPage />) },
@@ -95,7 +93,7 @@ const router = createBrowserRouter([
{ path: 'vessel-registration-queue', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationQueuePage />) }, { 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/new', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationFormBuilderPage />) },
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> }, // { 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', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> }, { path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
// Config-driven review workspace, shared by every licence type. // Config-driven review workspace, shared by every licence type.

View File

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

View File

@@ -13,6 +13,15 @@ export default defineConfig({
port: 4201, port: 4201,
host: 'localhost', 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' }, preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths()], plugins: [react(), nxViteTsPaths()],
resolve: { resolve: {
@@ -33,4 +42,14 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
reportCompressedSize: 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'],
// },
}); });

View File

@@ -13,9 +13,115 @@
document.documentElement.setAttribute('data-mantine-color-scheme', s); document.documentElement.setAttribute('data-mantine-color-scheme', s);
} catch (e) {} } catch (e) {}
</script> </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> </head>
<body> <body>
<div id="root"></div> <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> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,10 @@
import { Component } from 'react'; import { Component } from 'react';
import type { ReactNode, ErrorInfo } from 'react'; import type { ReactNode, ErrorInfo } from 'react';
import { Center, Paper, Title, Text, Button } from '@mantine/core'; 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; children: ReactNode;
} }
@@ -11,7 +13,7 @@ interface State {
error: Error | null; error: Error | null;
} }
export class ErrorBoundary extends Component<Props, State> { class ErrorBoundaryBase extends Component<Props, State> {
state: State = { hasError: false, error: null }; state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State { static getDerivedStateFromError(error: Error): State {
@@ -24,12 +26,13 @@ export class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
const { t } = this.props;
return ( return (
<Center h="100vh"> <Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}> <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"> <Text c="dimmed" size="sm" mb="lg">
{this.state.error?.message || 'An unexpected error occurred.'} {this.state.error?.message || t('errorBoundary.message')}
</Text> </Text>
<Button <Button
fullWidth fullWidth
@@ -38,7 +41,7 @@ export class ErrorBoundary extends Component<Props, State> {
window.location.href = '/'; window.location.href = '/';
}} }}
> >
Reload page {t('errorBoundary.reload')}
</Button> </Button>
</Paper> </Paper>
</Center> </Center>
@@ -48,3 +51,5 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.children; return this.props.children;
} }
} }
export const ErrorBoundary = withTranslation()(ErrorBoundaryBase);

View File

@@ -1,15 +1,16 @@
import Cookies from 'js-cookie'; import { Navigate } from 'react-router-dom';
import { LandingPage } from '@ema-platform/ui'; 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. * Public `/`. Signed-in visitors skip the landing page entirely and go
* Auth state is read the same way ProtectedRoute does (token cookie or * straight to the dashboard — the landing page is a front door for people
* storage fallback) so the header can show "Go to dashboard" instead of * who aren't in yet, not a screen for people who already are.
* Login/Sign Up without gating the route itself.
*/ */
export function LandingRoute() { 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" />;
} }

View File

@@ -31,6 +31,7 @@ import {
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -275,6 +276,8 @@ export function BasicSafetyTrainingPage() {
const isExpiringSoon = days !== null && days <= 180 && days > 0; const isExpiringSoon = days !== null && days <= 180 && days > 0;
const isExpired = days !== null && days <= 0; const isExpired = days !== null && days <= 0;
const { t } = useTranslation();
return ( return (
<Stack gap="md"> <Stack gap="md">
{/* Header */} {/* Header */}

View File

@@ -8,7 +8,6 @@ import {
Divider, Divider,
Group, Group,
Loader, Loader,
Modal,
Paper, Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
@@ -36,6 +35,7 @@ import {
useGetMySeaServiceRecordsQuery, useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery, useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock data // Mock data
@@ -353,21 +353,12 @@ export function CertificatesPage() {
)} )}
</Paper> </Paper>
{/* Preview modal */} <PdfPreviewModal
<Modal
opened={!!previewUrl} opened={!!previewUrl}
onClose={() => setPreviewUrl(null)} onClose={() => setPreviewUrl(null)}
title={<Text fw={700} fz="sm">{previewTitle}</Text>} url={previewUrl ?? ''}
size="95vw" title={previewTitle}
radius="lg" />
fullScreen
>
<iframe
src={previewUrl ?? ''}
style={{ width: '100%', height: '90vh', border: 'none', borderRadius: 8 }}
title={previewTitle}
/>
</Modal>
</Stack> </Stack>
); );
} }

View File

@@ -1,4 +1,5 @@
import { Badge, Progress, Text } from '@mantine/core'; import { Badge, Progress, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import { import {
STATUS_COLORS, STATUS_COLORS,
@@ -8,10 +9,12 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import type { LicenseApplication } 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 }) => ( cell: ({ row }) => (
<> <>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
@@ -24,13 +27,13 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
), ),
}, },
{ {
header: 'Licence', header: t('applications.table.licence'),
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text> <Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
), ),
}, },
{ {
header: 'Status', header: t('common.status'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge variant="light" color={STATUS_COLORS[row.original.status]}> <Badge variant="light" color={STATUS_COLORS[row.original.status]}>
{STATUS_LABELS[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, size: 180,
cell: ({ row }) => ( cell: ({ row }) => (
<Progress <Progress
@@ -50,3 +53,4 @@ export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
), ),
}, },
]; ];
}

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { import {
Alert, Alert,
Anchor, Anchor,
@@ -10,7 +12,6 @@ import {
Center, Center,
Container, Container,
Group, Group,
Loader,
Paper, Paper,
SimpleGrid, SimpleGrid,
Stack, Stack,
@@ -36,7 +37,7 @@ import {
useGetMyApplicationsQuery, useGetMyApplicationsQuery,
useGetMyLicensesQuery, useGetMyLicensesQuery,
} from '@ema-platform/api'; } 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 type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue'; import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard'; import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
@@ -64,15 +65,20 @@ function daysUntil(date: string): number {
return Math.ceil(ms / 86_400_000); return Math.ceil(ms / 86_400_000);
} }
function formatMoney(amount: string | number | null, currency: string): string { function formatMoney(
if (amount === null || amount === '') return 'No fee'; amount: string | number | null,
currency: string,
t: TFunction,
): string {
if (amount === null || amount === '') return t('dashboard.noFee');
const value = Number(amount); 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}`; return `${value.toLocaleString('en-US')} ${currency}`;
} }
export function DashboardPage() { export function DashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const displayName = useSelector( const displayName = useSelector(
(state: { auth: { user?: { name?: { en?: string }; username?: string } } }) => (state: { auth: { user?: { name?: { en?: string }; username?: string } } }) =>
state.auth.user?.name?.en || state.auth.user?.username || '', state.auth.user?.name?.en || state.auth.user?.username || '',
@@ -108,11 +114,7 @@ export function DashboardPage() {
} }
if (isLoading) { if (isLoading) {
return ( return <PageLoader label={t('dashboard.loading')} height={450} />;
<Center h={400}>
<Loader />
</Center>
);
} }
return ( return (
@@ -137,17 +139,15 @@ export function DashboardPage() {
color="orange" color="orange"
radius="md" radius="md"
icon={<IconClockHour4 size={18} />} icon={<IconClockHour4 size={18} />}
title={ title={t('applications.notice.expiringSoon', { count: expiringSoon.length })}
expiringSoon.length === 1
? 'A licence is expiring soon'
: `${expiringSoon.length} licences are expiring soon`
}
> >
<Text size="sm"> <Text size="sm">
{expiringSoon {expiringSoon
.map( .map((l) =>
(l) => t('dashboard.expiringSoon.detail', {
`${l.certificateNumber} expires in ${daysUntil(l.expiryDate)} days`, certificateNumber: l.certificateNumber,
days: daysUntil(l.expiryDate),
}),
) )
.join(' · ')} .join(' · ')}
</Text> </Text>
@@ -165,9 +165,9 @@ export function DashboardPage() {
<GetStartedPanel /> <GetStartedPanel />
) : ( ) : (
<> <>
<Section title="My licences"> <Section title={t('dashboard.sections.myLicences.title')}>
{heldLicenses.length === 0 ? ( {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"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{heldLicenses.map((license) => ( {heldLicenses.map((license) => (
@@ -185,20 +185,20 @@ export function DashboardPage() {
</Section> </Section>
<Section <Section
title="My applications" title={t('dashboard.sections.myApplications.title')}
action={ action={
items.length > 0 ? ( items.length > 0 ? (
<Anchor <Anchor
size="sm" size="sm"
onClick={() => navigate('/licensing/applications')} onClick={() => navigate('/licensing/applications')}
> >
View all {t('common.viewAll')}
</Anchor> </Anchor>
) : undefined ) : undefined
} }
> >
{items.length === 0 ? ( {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 <ApplicationTable
applications={items.slice(0, 6)} applications={items.slice(0, 6)}
@@ -211,8 +211,8 @@ export function DashboardPage() {
)} )}
<Section <Section
title="Apply for a licence" title={t('dashboard.sections.apply.title')}
description="Choose the licence that matches the service your company provides." description={t('dashboard.sections.apply.description')}
> >
<LicenseCatalogue /> <LicenseCatalogue />
</Section> </Section>
@@ -232,10 +232,14 @@ function Hero({
applicationCount: number; applicationCount: number;
licenseCount: number; licenseCount: number;
}) { }) {
const { t } = useTranslation();
const summary = const summary =
applicationCount === 0 && licenseCount === 0 applicationCount === 0 && licenseCount === 0
? 'Apply for a maritime or logistics licence and track it through to issue.' ? t('dashboard.hero.summaryEmpty')
: `You have ${applicationCount} application${applicationCount === 1 ? '' : 's'} and ${licenseCount} active licence${licenseCount === 1 ? '' : 's'}.`; : t('dashboard.hero.summary', {
applications: t('dashboard.hero.applicationsCount', { count: applicationCount }),
licences: t('dashboard.hero.licencesCount', { count: licenseCount }),
});
return ( return (
<Paper <Paper
@@ -250,10 +254,10 @@ function Hero({
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Box> <Box>
<Text size="sm" style={{ opacity: 0.85 }}> <Text size="sm" style={{ opacity: 0.85 }}>
Ethiopian Maritime Authority {t('app.authority')}
</Text> </Text>
<Title order={2} mt={4} c="white"> <Title order={2} mt={4} c="white">
{displayName ? `Welcome back, ${displayName}` : 'Welcome back'} {displayName ? t('dashboard.welcomeName', { name: displayName }) : t('dashboard.welcome')}
</Title> </Title>
<Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}> <Text size="sm" mt="xs" style={{ opacity: 0.9, maxWidth: 560 }}>
{summary} {summary}
@@ -280,6 +284,7 @@ function ActionRequired({
applications: LicenseApplication[]; applications: LicenseApplication[];
navigate: (path: string) => void; navigate: (path: string) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Card <Card
withBorder withBorder
@@ -292,12 +297,12 @@ function ActionRequired({
<IconAlertTriangle size={14} /> <IconAlertTriangle size={14} />
</ThemeIcon> </ThemeIcon>
<Text fw={600} size="sm"> <Text fw={600} size="sm">
Waiting on you {t('dashboard.waitingOnYou')}
</Text> </Text>
</Group> </Group>
<Stack gap="xs"> <Stack gap="xs">
{applications.map((app) => { {applications.map((app) => {
const detail = detailFor(app); const detail = detailFor(app, t);
return ( return (
<Paper key={app.id} radius="sm" p="sm" withBorder> <Paper key={app.id} radius="sm" p="sm" withBorder>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
@@ -339,7 +344,10 @@ function ActionRequired({
} }
/** What the applicant has to do next, and where that happens. */ /** What the applicant has to do next, and where that happens. */
function detailFor(app: LicenseApplication): { function detailFor(
app: LicenseApplication,
t: TFunction,
): {
message: string; message: string;
cta: string; cta: string;
color: string; color: string;
@@ -353,22 +361,24 @@ function detailFor(app: LicenseApplication): {
switch (app.status) { switch (app.status) {
case 'RESUBMIT_REQUIRED': case 'RESUBMIT_REQUIRED':
return { return {
message: 'A reviewer asked for corrections before this can proceed.', message: t('dashboard.actionRequired.messages.resubmit'),
cta: 'Fix now', cta: t('dashboard.actionRequired.cta.fixNow'),
color: 'orange', color: 'orange',
path: wizard, path: wizard,
}; };
case 'PAYMENT_PENDING': case 'PAYMENT_PENDING':
return { return {
message: `Approved — ${formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB')} due before the certificate is issued.`, message: t('dashboard.actionRequired.messages.paymentPending', {
cta: 'Pay now', amount: formatMoney(app.feeAmount, app.feeCurrency ?? 'ETB', t),
}),
cta: t('dashboard.actionRequired.cta.payNow'),
color: 'yellow', color: 'yellow',
path: '/licensing/applications', path: '/licensing/applications',
}; };
default: default:
return { return {
message: 'This application is still a draft and has not been filed.', message: t('dashboard.actionRequired.messages.draft'),
cta: 'Continue', cta: t('common.continue'),
color: 'blue', color: 'blue',
path: wizard, path: wizard,
}; };
@@ -386,11 +396,12 @@ function StatRow({
activeLicenses: number; activeLicenses: number;
expiringSoon: number; expiringSoon: number;
}) { }) {
const { t } = useTranslation();
const stats = [ const stats = [
{ label: 'In progress', value: inProgress, icon: IconClockHour4, color: 'blue' }, { label: t('applications.stats.inProgress'), value: inProgress, icon: IconClockHour4, color: 'blue' },
{ label: 'Waiting on you', value: needsMe, icon: IconAlertTriangle, color: 'orange' }, { label: t('dashboard.waitingOnYou'), value: needsMe, icon: IconAlertTriangle, color: 'orange' },
{ label: 'Active licences', value: activeLicenses, icon: IconCertificate, color: 'teal' }, { label: t('applications.stats.activeLicences'), value: activeLicenses, icon: IconCertificate, color: 'teal' },
{ label: 'Expiring soon', value: expiringSoon, icon: IconClockHour4, color: 'grape' }, { label: t('dashboard.stats.expiringSoon'), value: expiringSoon, icon: IconClockHour4, color: 'grape' },
]; ];
return ( return (
@@ -456,12 +467,13 @@ function ApplicationTable({
navigate: (path: string) => void; navigate: (path: string) => void;
onRefresh: () => void; onRefresh: () => void;
}) { }) {
const { t } = useTranslation();
const table = useServerTable(); const table = useServerTable();
const paged = table.paginate(applications); const paged = table.paginate(applications);
return ( return (
<AdvancedTable <AdvancedTable
tableName="My applications" tableName={t('dashboard.sections.myApplications.title')}
columns={dashboardApplicationColumns} columns={dashboardApplicationColumns(t)}
data={paged.rows} data={paged.rows}
itemCount={paged.itemCount} itemCount={paged.itemCount}
pageIndex={paged.pageIndex} pageIndex={paged.pageIndex}
@@ -486,6 +498,7 @@ function ApplicationTable({
* it, and a button that only scrolls the page is noise. * it, and a button that only scrolls the page is noise.
*/ */
function GetStartedPanel() { function GetStartedPanel() {
const { t } = useTranslation();
return ( return (
<Card withBorder radius="md" padding="xl"> <Card withBorder radius="md" padding="xl">
<Group gap="md" wrap="nowrap" align="flex-start"> <Group gap="md" wrap="nowrap" align="flex-start">
@@ -493,11 +506,9 @@ function GetStartedPanel() {
<IconCertificate size={24} stroke={1.5} /> <IconCertificate size={24} stroke={1.5} />
</ThemeIcon> </ThemeIcon>
<Box> <Box>
<Title order={4}>Get started</Title> <Title order={4}>{t('dashboard.getStarted.title')}</Title>
<Text size="sm" c="dimmed" mt={4} maw={620}> <Text size="sm" c="dimmed" mt={4} maw={620}>
You have not filed an application yet. Choose the licence that {t('dashboard.getStarted.body')}
matches what your company does your applications and the licences
issued to you will appear here as you go.
</Text> </Text>
</Box> </Box>
</Group> </Group>

View File

@@ -1,5 +1,6 @@
import { Container } from '@mantine/core'; import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui'; import { FeatureUnavailable } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
/** /**
* Placeholder until this feature has a backend. * Placeholder until this feature has a backend.
@@ -8,11 +9,13 @@ import { FeatureUnavailable } from '@ema-platform/ui';
* indistinguishable from real ones. * indistinguishable from real ones.
*/ */
export function DocumentVaultPage() { export function DocumentVaultPage() {
const { t } = useTranslation();
return ( return (
<Container size="lg" py="xl"> <Container size="lg" py="xl">
<FeatureUnavailable <FeatureUnavailable
title="My documents" title={t('featureUnavailable.documents.title')}
description="A central document vault is not connected to the backend yet. Documents you upload with a licence application are stored with that application." description={t('featureUnavailable.documents.description')}
/> />
</Container> </Container>
); );

View File

@@ -1,476 +1,225 @@
import { useState } from 'react';
import { useApiQuery } from '@ema-platform/api';
import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Badge, Badge,
Button, Button,
Card, Card,
Divider,
FileInput,
Group, Group,
List, List,
Modal,
Paper,
SimpleGrid,
Stack, Stack,
Stepper,
Text, Text,
TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
rem,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAlertCircle,
IconArrowLeft,
IconArrowRight, IconArrowRight,
IconCheck,
IconCircleCheck, IconCircleCheck,
IconClock, IconCircleX,
IconDownload,
IconEye,
IconFileDescription,
IconInfoCircle, IconInfoCircle,
IconRubberStamp,
IconShieldCheck,
IconUpload,
} from '@tabler/icons-react'; } 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';
// --------------------------------------------------------------------------- const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
// Mock data — existing endorsement applications
// --------------------------------------------------------------------------- function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
/** What `/endorsements/my` returns. */ return (
interface EndorsementsOverview { <List.Item
issued: { icon={
id: string; <ThemeIcon
endorsementNo: string; color={ok ? 'teal' : 'red'}
cocType: string; variant="light"
foreignCocNo: string; size="sm"
issuingCountry: string; radius="xl"
issued: string; >
expiry: string; {ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
status: string; </ThemeIcon>
}[]; }
applications: { >
id: string; {label}
applicationId: string; </List.Item>
cocType: string; );
foreignCocNo: string;
issuingCountry: string;
submitted: string;
status: string;
}[];
} }
// Keyed by the workflow's own status values so an unmapped one falls back to /**
// grey rather than rendering colourless. * Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
const STATUS_COLOR: Record<string, string> = { * two application entry points (CoC / GOC), and the seafarer's endorsement
DRAFT: 'gray', * applications and issued endorsements. The wizard itself is the
SUBMITTED: 'blue', * config-driven licensing flow.
UNDER_REVIEW: 'yellow', */
UNDER_EVALUATION: 'yellow', export function EndorsementPage() {
RESUBMIT_REQUIRED: 'orange', const { t } = useTranslation();
APPROVED: 'teal', const navigate = useNavigate();
REJECTED: 'red', const { profile, isLoading: loadingProfile } = useCurrentProfile();
PAYMENT_PENDING: 'orange', const { data: applications, isLoading: loadingApplications } =
PAYMENT_CONFIRMED: 'blue', useGetMyApplicationsQuery();
CERTIFICATE_ISSUED: 'teal', const {
COMPLETED: 'teal', data: licenses,
ACTIVE: 'teal', isFetching: fetchingLicenses,
EXPIRED: 'red', refetch: refetchLicenses,
SUSPENDED: 'orange', } = useGetMyLicensesQuery();
}; const showDate = useDateDisplayer();
const localized = useLocalized();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const issuedTable = useServerTable();
function humanStatus(status: string): string { const registered =
return status Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
.toLowerCase()
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
function formatDate(value: string | null | undefined): string { const endorsementApplications = (applications?.items ?? []).filter((app) =>
if (!value) return ''; ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
return new Date(value).toLocaleDateString('en-GB', { );
day: '2-digit', const inFlight = endorsementApplications.filter(
month: 'short', (app) => !TERMINAL_STATUSES.includes(app.status),
year: 'numeric', );
}); const issued = (licenses?.items ?? []).filter((license) =>
} ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
);
const issuedPage = issuedTable.paginate(issued);
// blank PDF async function download(licenseId: string) {
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G'; 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) {
// Application wizard return <PageLoader label={t('endorsement.loading', 'Loading Endorsements…')} height={400} />;
// ---------------------------------------------------------------------------
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) {
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 ( return (
<Stack gap="lg"> <Stack maw={860} mx="auto">
<Stepper active={step} size="sm"> <Title order={2}>{t('endorsement.title', 'My Endorsements')}</Title>
<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 */} <Card withBorder radius="md" p="lg">
{step === 0 && ( <Group justify="space-between" align="flex-start">
<Paper withBorder radius="lg" p="xl"> <div>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={15} />} mb="lg"> <Text fw={600} mb={6}>
<Text fz="sm"> {t('endorsement.eligibility.title', 'Eligibility')}
<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> </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>
<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> </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 /> </Card>
<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 */} {inFlight.length > 0 && (
{step === 1 && ( <Stack gap="xs">
<Stack gap="md"> <Title order={4}>{t('endorsement.inProgress', 'Applications in progress')}</Title>
<Alert variant="light" color="yellow" icon={<IconAlertCircle size={15} />}> {inFlight.map((app) => (
<Text fz="sm"> <Card key={app.id} withBorder radius="md" p="md">
A <strong>certified translation</strong> is required if your foreign CoC is not in English. <Group justify="space-between">
All documents must be clear, legible, and complete. <div>
</Text> <Text fw={600}>{app.applicationNumber}</Text>
</Alert> <Text size="xs" c="dimmed">
{localized(app.licenseType?.name)}
<Paper withBorder radius="lg" p="xl"> </Text>
<Text fw={700} mb="md">Required Documents</Text> </div>
<Stack gap="md"> <Group>
{[ <Badge color={STATUS_COLORS[app.status]}>
{ key: 'foreignCoc' as keyof Docs, label: 'Foreign CoC (Original or Certified Copy)', required: true }, {t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
{ key: 'translation' as keyof Docs, label: 'Certified Translation (only if CoC is not in English)', required: false }, </Badge>
{ key: 'medical' as keyof Docs, label: 'Valid Medical Fitness Certificate (STCW Reg I/2)', required: true }, <Button
{ key: 'seamanBook' as keyof Docs, label: 'Ethiopian Seaman Book', required: true }, size="compact-sm"
{ key: 'photo' as keyof Docs, label: 'Passport-Size Photo', required: true }, variant="light"
].map((slot) => ( onClick={() =>
<FileInput navigate(
key={slot.key} `/licensing/${app.licenseType?.key}/applications/${app.id}`,
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]} {app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
onChange={(f) => setDocs((prev) => ({ ...prev, [slot.key]: f }))} ? t('applications.actions.continue', 'Continue')
accept=".pdf,.jpg,.jpeg,.png" : t('applications.actions.view', 'View')}
clearable </Button>
/>
))}
</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} />}
</ThemeIcon>
<Text fz="xs" c={item.done ? undefined : 'dimmed'}>{item.label}</Text>
</Group> </Group>
))} </Group>
</Stack> </Card>
</Paper> ))}
</Stack> </Stack>
)} )}
{/* Step 2 — Payment */} <Stack gap="xs">
{step === 2 && ( <Title order={4}>{t('endorsement.issuedEndorsements', 'Issued endorsements')}</Title>
<Paper withBorder radius="lg" p="xl"> <AdvancedTable
<Text fw={700} mb="md">Endorsement Fee</Text> tableName={t('endorsement.issuedEndorsements', 'Issued endorsements')}
<Paper withBorder radius="md" p="md" bg="gray.0" mb="lg"> columns={endorsementColumns({ t, showDate, localized, onDownload: download })}
{[ data={issuedPage.rows}
{ label: 'Application Processing Fee', amount: 300 }, itemCount={issuedPage.itemCount}
{ label: 'Document Verification Fee', amount: 200 }, pageIndex={issuedPage.pageIndex}
{ label: 'Endorsement Issuance Fee', amount: 500 }, onPageChange={issuedTable.setPageIndex}
].map(({ label, amount }) => ( pageSize={issuedTable.pageSize}
<Group key={label} justify="space-between" mb="xs"> refresh={refetchLicenses}
<Text fz="sm">{label}</Text> isLoading={fetchingLicenses}
<Text fz="sm" fw={600}>ETB {amount}</Text> emptyText={t('endorsement.emptyIssued', 'No endorsements issued yet.')}
</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>
</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
// ---------------------------------------------------------------------------
export function EndorsementPage() {
const navigate = useNavigate();
const [applying, setApplying] = useState(false);
const [previewId, setPreviewId] = useState<string | null>(null);
const { data } = useApiQuery<EndorsementsOverview>({
url: '/endorsements/my',
method: 'GET',
});
const endorsementApps = data?.applications ?? [];
const issuedEndorsements = data?.issued ?? [];
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> </Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="sm">
<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>
</div>
<Button leftSection={<IconRubberStamp size={15} />} rightSection={<IconArrowRight size={15} />} onClick={() => setApplying(true)}>
Apply for Endorsement
</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 1015 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>
)}
</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> </Stack>
); );
} }
export default EndorsementPage;

View 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>
),
},
];
}

View File

@@ -1,5 +1,6 @@
import { Badge, Button, Text } from '@mantine/core'; import { Badge, Button, Text } from '@mantine/core';
import { IconFileText, IconGavel } from '@tabler/icons-react'; import { IconFileText, IconGavel } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { Bilingual } from '@ema-platform/api'; import type { Bilingual } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
@@ -19,16 +20,19 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red', DISQUALIFIED: 'red',
}; };
export function registrationColumns(deps: { export function registrationColumns(
/** Permission check from usePermissions() — hooks can't run in a cell. */ t: TFunction,
can: (required?: string[]) => boolean; deps: {
localized: (value: Bilingual | undefined) => string; /** Permission check from usePermissions() — hooks can't run in a cell. */
showDate: (value: string | null | undefined) => string; can: (required?: string[]) => boolean;
onDownloadSlip: (registration: MyRegistration) => void; localized: (value: Bilingual | undefined) => string;
}): AdvancedColumn<MyRegistration>[] { showDate: (value: string | null | undefined) => string;
onDownloadSlip: (registration: MyRegistration) => void;
},
): AdvancedColumn<MyRegistration>[] {
return [ return [
{ {
header: 'Admission', header: t('exams.columns.admission'),
cell: ({ row }) => ( cell: ({ row }) => (
<Text ff="monospace" size="sm" fw={600}> <Text ff="monospace" size="sm" fw={600}>
{row.original.admissionNumber} {row.original.admissionNumber}
@@ -36,19 +40,19 @@ export function registrationColumns(deps: {
), ),
}, },
{ {
header: 'Examination', header: t('exams.columns.examination'),
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—', cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
}, },
{ {
header: 'Date', header: t('exams.columns.date'),
cell: ({ row }) => deps.showDate(row.original.exam?.date), cell: ({ row }) => deps.showDate(row.original.exam?.date),
}, },
{ {
header: 'Venue', header: t('exams.columns.venue'),
cell: ({ row }) => row.original.exam?.venue ?? '—', cell: ({ row }) => row.original.exam?.venue ?? '—',
}, },
{ {
header: 'Attempt', header: t('exams.columns.attempt'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
size="sm" size="sm"
@@ -56,25 +60,25 @@ export function registrationColumns(deps: {
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'} color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
> >
{row.original.kind === 'RETAKE' {row.original.kind === 'RETAKE'
? `Retake · ${row.original.attemptNumber}` ? t('exams.columns.retake', { n: row.original.attemptNumber })
: 'First sitting'} : t('exams.columns.firstSitting')}
</Badge> </Badge>
), ),
}, },
{ {
header: 'Attendance', header: t('exams.columns.attendance'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
size="sm" size="sm"
variant="light" variant="light"
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'} color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
> >
{row.original.attendanceStatus} {t(`exams.columns.attendanceStatus.${row.original.attendanceStatus}`)}
</Badge> </Badge>
), ),
}, },
{ {
header: 'Slip', header: t('exams.columns.slip'),
cell: ({ row }) => cell: ({ row }) =>
deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? ( deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button <Button
@@ -83,32 +87,35 @@ export function registrationColumns(deps: {
leftSection={<IconFileText size={13} />} leftSection={<IconFileText size={13} />}
onClick={() => deps.onDownloadSlip(row.original)} onClick={() => deps.onDownloadSlip(row.original)}
> >
Slip {t('exams.columns.slip')}
</Button> </Button>
) : null, ) : null,
}, },
]; ];
} }
export function resultColumns(deps: { export function resultColumns(
/** Permission check from usePermissions() — hooks can't run in a cell. */ t: TFunction,
can: (required?: string[]) => boolean; deps: {
localized: (value: Bilingual | undefined) => string; /** Permission check from usePermissions() — hooks can't run in a cell. */
showDate: (value: string | null | undefined) => string; can: (required?: string[]) => boolean;
appeals: MyAppeal[]; localized: (value: Bilingual | undefined) => string;
onAppeal: (result: MyResult) => void; showDate: (value: string | null | undefined) => string;
}): AdvancedColumn<MyResult>[] { appeals: MyAppeal[];
onAppeal: (result: MyResult) => void;
},
): AdvancedColumn<MyResult>[] {
return [ return [
{ {
header: 'Examination', header: t('exams.columns.examination'),
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—', cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
}, },
{ {
header: 'Published', header: t('exams.columns.published'),
cell: ({ row }) => deps.showDate(row.original.publishedAt), cell: ({ row }) => deps.showDate(row.original.publishedAt),
}, },
{ {
header: 'Score', header: t('exams.columns.score'),
cell: ({ row }) => ( cell: ({ row }) => (
<Text fw={600} size="sm"> <Text fw={600} size="sm">
{row.original.totalScore} {row.original.totalScore}
@@ -116,23 +123,24 @@ export function resultColumns(deps: {
), ),
}, },
{ {
header: 'Outcome', header: t('exams.columns.outcome'),
cell: ({ row }) => ( cell: ({ row }) => (
<Badge <Badge
variant="light" variant="light"
color={row.original.status === 'PASSED' ? 'teal' : 'red'} color={row.original.status === 'PASSED' ? 'teal' : 'red'}
> >
{row.original.status} {t(`exams.columns.outcomeStatus.${row.original.status}`)}
</Badge> </Badge>
), ),
}, },
{ {
header: 'Appeal', header: t('exams.columns.appeal'),
cell: ({ row }) => { cell: ({ row }) => {
const appeal = deps.appeals.find((a) => a.resultId === row.original.id); const appeal = deps.appeals.find((a) => a.resultId === row.original.id);
return appeal ? ( return appeal ? (
<Badge size="sm" variant="light" color="grape"> <Badge size="sm" variant="light" color="grape">
{appeal.appealNumber} · {appeal.status} {appeal.appealNumber} ·{' '}
{t(`exams.columns.appealStatus.${appeal.status}`)}
</Badge> </Badge>
) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? ( ) : deps.can([PORTAL_PERMISSIONS.VIEW_OWN_EXAM]) ? (
<Button <Button
@@ -142,7 +150,7 @@ export function resultColumns(deps: {
leftSection={<IconGavel size={13} />} leftSection={<IconGavel size={13} />}
onClick={() => deps.onAppeal(row.original)} onClick={() => deps.onAppeal(row.original)}
> >
Appeal {t('exams.columns.appeal')}
</Button> </Button>
) : null; ) : null;
}, },

View File

@@ -4,7 +4,6 @@ import {
Button, Button,
Card, Card,
Group, Group,
Loader,
Modal, Modal,
Stack, Stack,
Text, Text,
@@ -12,7 +11,8 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconClipboardList } from '@tabler/icons-react'; 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 { useDateDisplayer } from '@ema-platform/shared';
import { import {
useApiQuery, useApiQuery,
@@ -79,6 +79,7 @@ export interface MyAppeal {
* when a mark looks wrong. * when a mark looks wrong.
*/ */
export function ExamsPage() { export function ExamsPage() {
const { t } = useTranslation();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized(); const localized = useLocalized();
const [appealFor, setAppealFor] = useState<MyResult | null>(null); const [appealFor, setAppealFor] = useState<MyResult | null>(null);
@@ -123,18 +124,21 @@ export function ExamsPage() {
method: 'POST', method: 'POST',
}).unwrap()) as { admissionNumber?: string }; }).unwrap()) as { admissionNumber?: string };
notify.success( notify.success(
`Registered — admission number ${result.admissionNumber ?? 'issued'}`, t('exams.notify.registered', {
admissionNumber:
result.admissionNumber ?? t('exams.notify.admissionNumberPending'),
}),
); );
refetch(); refetch();
} catch (error) { } catch (error) {
const key = extractErrorMessage(error, 'Could not register'); const key = extractErrorMessage(error, t('exams.notify.registerFailed'));
notify.error( notify.error(
key === 'seafarer_registration_required' key === 'seafarer_registration_required'
? 'An active seafarer registration is required to sit examinations.' ? t('exams.notify.seafarerRequired')
: key === 'already_registered_for_exam' : key === 'already_registered_for_exam'
? 'You are already registered for this session.' ? t('exams.notify.alreadyRegistered')
: key === 'subject_already_passed' : key === 'subject_already_passed'
? 'You have already passed this subject — a resit is not needed.' ? t('exams.notify.alreadyPassed')
: key, : key,
); );
} }
@@ -148,7 +152,7 @@ export function ExamsPage() {
); );
} catch (error) { } catch (error) {
notify.error( notify.error(
extractErrorMessage(error, 'Could not generate the admission slip'), extractErrorMessage(error, t('exams.notify.slipFailed')),
); );
} }
}; };
@@ -161,28 +165,26 @@ export function ExamsPage() {
method: 'POST', method: 'POST',
body: { reason: appealReason.trim() }, body: { reason: appealReason.trim() },
}).unwrap()) as { appealNumber?: string }; }).unwrap()) as { appealNumber?: string };
notify.success(`Appeal ${appeal.appealNumber ?? ''} submitted`); notify.success(
t('exams.notify.appealSubmitted', { appealNumber: appeal.appealNumber ?? '' }),
);
setAppealFor(null); setAppealFor(null);
setAppealReason(''); setAppealReason('');
refetchAppeals(); refetchAppeals();
} catch (error) { } catch (error) {
const key = extractErrorMessage(error, 'Could not submit the appeal'); const key = extractErrorMessage(error, t('exams.notify.appealFailed'));
notify.error( notify.error(
key.startsWith('appeal_window_closed') 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' : key === 'appeal_already_open'
? 'An appeal on this result is already being considered.' ? t('exams.notify.appealAlreadyOpen')
: key, : key,
); );
} }
}; };
if (loadingOpen || loadingMine || loadingResults) { if (loadingOpen || loadingMine || loadingResults) {
return ( return <PageLoader label={t('exams.loading')} height={400} />;
<Group justify="center" py="xl">
<Loader />
</Group>
);
} }
const pagedRegistrations = registrationTable.paginate(mine ?? []); const pagedRegistrations = registrationTable.paginate(mine ?? []);
@@ -190,14 +192,14 @@ export function ExamsPage() {
return ( return (
<Stack maw={900} mx="auto"> <Stack maw={900} mx="auto">
<Title order={2}>Examinations</Title> <Title order={2}>{t('exams.title')}</Title>
<Stack gap="xs"> <Stack gap="xs">
<Title order={4}>Open sessions</Title> <Title order={4}>{t('exams.openSessions')}</Title>
{(open ?? []).length === 0 ? ( {(open ?? []).length === 0 ? (
<Card withBorder radius="md" p="lg"> <Card withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center"> <Text size="sm" c="dimmed" ta="center">
No upcoming sessions are open for registration. {t('exams.noOpenSessions')}
</Text> </Text>
</Card> </Card>
) : ( ) : (
@@ -214,7 +216,7 @@ export function ExamsPage() {
</div> </div>
{registeredExamIds.has(exam.id) ? ( {registeredExamIds.has(exam.id) ? (
<Badge color="teal" variant="light"> <Badge color="teal" variant="light">
Registered {t('exams.registered')}
</Badge> </Badge>
) : ( ) : (
<RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly> <RequirePermission anyOf={[PORTAL_PERMISSIONS.APPLY_EXAM]} hideOnly>
@@ -224,7 +226,7 @@ export function ExamsPage() {
leftSection={<IconClipboardList size={14} />} leftSection={<IconClipboardList size={14} />}
onClick={() => register(exam)} onClick={() => register(exam)}
> >
Register {t('exams.register')}
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )}
@@ -235,10 +237,10 @@ export function ExamsPage() {
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Title order={4}>My registrations</Title> <Title order={4}>{t('exams.myRegistrations')}</Title>
<AdvancedTable<MyRegistration> <AdvancedTable<MyRegistration>
tableName="My registrations" tableName={t('exams.myRegistrations')}
columns={registrationColumns({ columns={registrationColumns(t, {
can, can,
localized, localized,
showDate, showDate,
@@ -250,15 +252,15 @@ export function ExamsPage() {
onPageChange={registrationTable.setPageIndex} onPageChange={registrationTable.setPageIndex}
pageSize={registrationTable.pageSize} pageSize={registrationTable.pageSize}
refresh={refetch} refresh={refetch}
emptyText="No exam registrations yet." emptyText={t('exams.noRegistrations')}
/> />
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Title order={4}>My results</Title> <Title order={4}>{t('exams.myResults')}</Title>
<AdvancedTable<MyResult> <AdvancedTable<MyResult>
tableName="My results" tableName={t('exams.myResults')}
columns={resultColumns({ columns={resultColumns(t, {
can, can,
localized, localized,
showDate, showDate,
@@ -271,40 +273,42 @@ export function ExamsPage() {
onPageChange={resultTable.setPageIndex} onPageChange={resultTable.setPageIndex}
pageSize={resultTable.pageSize} pageSize={resultTable.pageSize}
refresh={refetchResults} refresh={refetchResults}
emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them." emptyText={t('exams.noResults')}
/> />
</Stack> </Stack>
<Modal <Modal
opened={Boolean(appealFor)} opened={Boolean(appealFor)}
onClose={() => setAppealFor(null)} onClose={() => setAppealFor(null)}
title="Request a review of this result" title={t('exams.appealModal.title')}
radius="lg" radius="lg"
> >
<Stack> <Stack>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Explain what you believe went wrong with the marking or the {t('exams.appealModal.body', {
administration of {localized(appealFor?.exam?.title) || 'this examination'}. examTitle:
Appeals must be lodged within 14 days of publication. localized(appealFor?.exam?.title) ||
t('exams.appealModal.defaultExamTitle'),
})}
</Text> </Text>
<Textarea <Textarea
minRows={4} minRows={4}
autosize autosize
label="Grounds for appeal" label={t('exams.appealModal.reasonLabel')}
value={appealReason} value={appealReason}
onChange={(event) => setAppealReason(event.currentTarget.value)} onChange={(event) => setAppealReason(event.currentTarget.value)}
required required
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setAppealFor(null)}> <Button variant="default" onClick={() => setAppealFor(null)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
loading={appealing} loading={appealing}
disabled={appealReason.trim().length === 0} disabled={appealReason.trim().length === 0}
onClick={submitAppeal} onClick={submitAppeal}
> >
Submit appeal {t('exams.appealModal.submit')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -15,6 +15,7 @@ import {
type Vessel, type Vessel,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui'; import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import { LocationPicker } from '../../location/components/LocationPicker'; import { LocationPicker } from '../../location/components/LocationPicker';
interface Props { interface Props {
@@ -99,6 +100,7 @@ export function ConfigDrivenSection({
onVesselSelected, onVesselSelected,
}: Props) { }: Props) {
const localized = useLocalized(); const localized = useLocalized();
const { t } = useTranslation();
const fields = [...(section.fields ?? [])].sort( const fields = [...(section.fields ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
); );
@@ -164,7 +166,7 @@ export function ConfigDrivenSection({
) : isVesselPicker ? ( ) : isVesselPicker ? (
<Select <Select
{...common} {...common}
placeholder="Select a registered vessel" placeholder={t('licensing.vesselPicker.placeholder')}
data={vessels.map((v) => ({ value: v.id, label: `${v.name}${v.registrationNumber}` }))} data={vessels.map((v) => ({ value: v.id, label: `${v.name}${v.registrationNumber}` }))}
value={(value as string) ?? null} value={(value as string) ?? null}
onChange={(v) => { onChange={(v) => {

View File

@@ -1,6 +1,5 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { import {
ActionIcon,
Alert, Alert,
Badge, Badge,
Button, Button,
@@ -15,7 +14,6 @@ import {
IconAlertTriangle, IconAlertTriangle,
IconCheck, IconCheck,
IconFileUpload, IconFileUpload,
IconTrash,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { import {
conditionHolds, conditionHolds,
@@ -24,6 +22,8 @@ import {
type Attachment, type Attachment,
type DocumentRequirement, type DocumentRequirement,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -59,8 +59,12 @@ export function DocumentSlots({
readOnly, readOnly,
}: Props) { }: Props) {
const localized = useLocalized(); const localized = useLocalized();
const { t } = useTranslation();
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = 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 resetRefs = useRef<Record<string, () => void>>({});
const required = requirements.filter( const required = requirements.filter(
@@ -73,7 +77,11 @@ export function DocumentSlots({
async function handle(documentKey: string, file: File | null) { async function handle(documentKey: string, file: File | null) {
if (!file) return; if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) { 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]?.(); resetRefs.current[documentKey]?.();
return; return;
} }
@@ -96,6 +104,7 @@ export function DocumentSlots({
{required.map((requirement) => { {required.map((requirement) => {
const existing = attachments.find((a) => a.documentKey === requirement.key); const existing = attachments.find((a) => a.documentKey === requirement.key);
const uploaded = Boolean(existing?.files?.length); const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key]; const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark); const locked = readOnly || (restrictToFlagged && !flagRemark);
@@ -121,17 +130,17 @@ export function DocumentSlots({
</Text> </Text>
{requirement.mode === 'CONDITIONAL' && ( {requirement.mode === 'CONDITIONAL' && (
<Badge size="xs" variant="light" color="grape"> <Badge size="xs" variant="light" color="grape">
conditional {t('licensing.documents.conditional')}
</Badge> </Badge>
)} )}
{requirement.mode === 'OPTIONAL' && ( {requirement.mode === 'OPTIONAL' && (
<Badge size="xs" variant="light" color="gray"> <Badge size="xs" variant="light" color="gray">
optional {t('common.optional')}
</Badge> </Badge>
)} )}
{uploaded && !flagRemark && ( {uploaded && !flagRemark && (
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}> <Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
uploaded {t('licensing.documents.uploaded')}
</Badge> </Badge>
)} )}
</Group> </Group>
@@ -143,21 +152,24 @@ export function DocumentSlots({
)} )}
{flagRemark && ( {flagRemark && (
<Text size="xs" c="orange.7" mt={4}> <Text size="xs" c="orange.7" mt={4}>
Officer: {flagRemark} {t('licensing.documents.officerRemark', { name: flagRemark })}
</Text> </Text>
)} )}
</div> </div>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
{existing?.files?.[0]?.url && ( {fileUrl && (
<Button <Button
size="xs" size="xs"
variant="subtle" variant="subtle"
component="a" onClick={() =>
href={existing.files[0].url} setPreview({
target="_blank" url: fileUrl,
title: localized(requirement.name),
})
}
> >
View {t('licensing.documents.view')}
</Button> </Button>
)} )}
{!locked && ( {!locked && (
@@ -175,14 +187,16 @@ export function DocumentSlots({
variant={uploaded ? 'light' : 'filled'} variant={uploaded ? 'light' : 'filled'}
leftSection={ leftSection={
busy === requirement.key ? ( busy === requirement.key ? (
<Loader size={12} /> <Loader size={12} type="oval" />
) : ( ) : (
<IconFileUpload size={14} /> <IconFileUpload size={14} />
) )
} }
disabled={busy === requirement.key} disabled={busy === requirement.key}
> >
{uploaded ? 'Replace' : 'Upload'} {uploaded
? t('licensing.documents.replace')
: t('licensing.documents.upload')}
</Button> </Button>
)} )}
</FileButton> </FileButton>
@@ -192,6 +206,12 @@ export function DocumentSlots({
</Card> </Card>
); );
})} })}
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Stack> </Stack>
); );
} }

View File

@@ -19,6 +19,7 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { useTranslation } from 'react-i18next';
import { import {
LICENSE_PERMISSIONS, LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS, PORTAL_PERMISSIONS,
@@ -36,6 +37,7 @@ import {
*/ */
export function useRenewLicense() { export function useRenewLicense() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const [createApplication, { isLoading: isRenewing }] = const [createApplication, { isLoading: isRenewing }] =
useCreateApplicationMutation(); useCreateApplicationMutation();
@@ -50,7 +52,7 @@ export function useRenewLicense() {
}).unwrap(); }).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`); navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) { } 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 renewable = license.renewable ?? false;
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized(); const localized = useLocalized();
const { t } = useTranslation();
return ( return (
<Card withBorder radius="md" padding="md" className="ema-hover-lift"> <Card withBorder radius="md" padding="md" className="ema-hover-lift">
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Box> <Box>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{localized(license.licenseType?.name) || 'Licence'} {localized(license.licenseType?.name) || t('licensing.card.fallbackName')}
</Text> </Text>
<Text size="xs" c="dimmed" mt={2}> <Text size="xs" c="dimmed" mt={2}>
{license.certificateNumber} {license.certificateNumber}
@@ -99,7 +102,7 @@ export function LicenseCard({
variant="light" variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'} color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
> >
{expired ? 'Expired' : license.status} {expired ? t('licensing.card.expired') : license.status}
</Badge> </Badge>
</Group> </Group>
@@ -107,11 +110,10 @@ export function LicenseCard({
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Box> <Box>
<Text size="xs" c="dimmed">
{expired ? 'Expired on' : 'Valid until'}
</Text>
<Text size="sm" fw={500}> <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> </Text>
</Box> </Box>
<RequirePermission <RequirePermission
@@ -121,7 +123,7 @@ export function LicenseCard({
]} ]}
hideOnly hideOnly
> >
<Tooltip label="Download certificate"> <Tooltip label={t('licensing.card.downloadCertificate')}>
<ActionIcon <ActionIcon
variant="light" variant="light"
radius="md" radius="md"
@@ -150,8 +152,8 @@ export function LicenseCard({
onClick={onRenew} onClick={onRenew}
> >
{expired {expired
? 'Renew — this licence has expired' ? t('licensing.card.renewExpired')
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`} : t('licensing.card.renewDays', { count: days })}
</Button> </Button>
</RequirePermission> </RequirePermission>
)} )}

View File

@@ -32,6 +32,7 @@ import {
} from '@ema-platform/api'; } from '@ema-platform/api';
import type { LicenseCategory, LicenseType } from '@ema-platform/api'; import type { LicenseCategory, LicenseType } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { useTranslation } from 'react-i18next';
/** /**
* The licence catalogue an applicant chooses from, grouped by category. * The licence catalogue an applicant chooses from, grouped by category.
@@ -52,16 +53,17 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
WAIVER_SERVICES: IconShieldOff, WAIVER_SERVICES: IconShieldOff,
}; };
function formatFee(amount: string | number | null, currency: string): string { function formatFee(amount: string | number | null, currency: string, noFeeLabel: string): string {
if (amount === null || amount === '') return 'No fee'; if (amount === null || amount === '') return noFeeLabel;
const value = Number(amount); const value = Number(amount);
if (!Number.isFinite(value)) return 'No fee'; if (!Number.isFinite(value)) return noFeeLabel;
return `${value.toLocaleString('en-US')} ${currency}`; return `${value.toLocaleString('en-US')} ${currency}`;
} }
export function LicenseCatalogue() { export function LicenseCatalogue() {
const navigate = useNavigate(); const navigate = useNavigate();
const localized = useLocalized(); const localized = useLocalized();
const { t } = useTranslation();
const { data: types } = useGetLicenseTypesQuery(); const { data: types } = useGetLicenseTypesQuery();
const { data: categories } = useGetLicenseCategoriesQuery(); const { data: categories } = useGetLicenseCategoriesQuery();
const { data: operatorTypes, isLoading: loadingOperatorTypes } = const { data: operatorTypes, isLoading: loadingOperatorTypes } =
@@ -121,19 +123,17 @@ export function LicenseCatalogue() {
<IconBuildingWarehouse size={18} /> <IconBuildingWarehouse size={18} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
Tell us what you operate as {t('licensing.catalogue.emptyTitle')}
</Text> </Text>
<Text size="sm" c="dimmed" ta="center" maw={520}> <Text size="sm" c="dimmed" ta="center" maw={520}>
Licences are offered against your mode of operation freight {t('licensing.catalogue.emptyBody')}
forwarder, shipping agent, multimodal transport operator and so on.
Choose yours and the licences you can apply for appear here.
</Text> </Text>
<Group gap="sm" mt="xs"> <Group gap="sm" mt="xs">
<Button size="xs" onClick={() => navigate('/profile#operations')}> <Button size="xs" onClick={() => navigate('/profile#operations')}>
Set my operations {t('licensing.catalogue.setOperations')}
</Button> </Button>
<Button size="xs" variant="subtle" onClick={() => setShowAll(true)}> <Button size="xs" variant="subtle" onClick={() => setShowAll(true)}>
Browse all licences {t('licensing.catalogue.browseAll')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -150,8 +150,7 @@ export function LicenseCatalogue() {
<IconFileText size={18} /> <IconFileText size={18} />
</ThemeIcon> </ThemeIcon>
<Text size="sm" c="dimmed" ta="center"> <Text size="sm" c="dimmed" ta="center">
No licence types are available yet. Contact EMA if you were {t('licensing.catalogue.noneAvailable')}
expecting one.
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
@@ -172,10 +171,10 @@ export function LicenseCatalogue() {
{showAll && hasDeclared && ( {showAll && hasDeclared && (
<Group gap="xs"> <Group gap="xs">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Showing every licence, including ones outside your operations. {t('licensing.catalogue.showingAll')}
</Text> </Text>
<Anchor size="xs" onClick={() => setShowAll(false)}> <Anchor size="xs" onClick={() => setShowAll(false)}>
Show only mine {t('licensing.catalogue.showOnlyMine')}
</Anchor> </Anchor>
</Group> </Group>
)} )}
@@ -193,8 +192,8 @@ export function LicenseCatalogue() {
{orphans.length > 0 && ( {orphans.length > 0 && (
<CategoryGroup <CategoryGroup
icon={IconFileText} icon={IconFileText}
title="Other licences" title={t('licensing.catalogue.otherLicences')}
description="Licence types that have not been assigned a category." description={t('licensing.catalogue.otherLicencesDescription')}
licenseTypes={orphans} licenseTypes={orphans}
canApply={canApply} canApply={canApply}
onSelect={select} onSelect={select}
@@ -203,10 +202,10 @@ export function LicenseCatalogue() {
{hasDeclared && !showAll && ( {hasDeclared && !showAll && (
<Group gap="xs"> <Group gap="xs">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Only licences matching your declared operations are shown. {t('licensing.catalogue.showingMine')}
</Text> </Text>
<Anchor size="xs" onClick={() => setShowAll(true)}> <Anchor size="xs" onClick={() => setShowAll(true)}>
Browse all licences {t('licensing.catalogue.browseAll')}
</Anchor> </Anchor>
</Group> </Group>
)} )}
@@ -266,6 +265,7 @@ function LicenseTypeCard({
onSelect: (type: LicenseType) => void; onSelect: (type: LicenseType) => void;
}) { }) {
const localized = useLocalized(); const localized = useLocalized();
const { t } = useTranslation();
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null; const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
return ( return (
@@ -298,23 +298,23 @@ function LicenseTypeCard({
<Box> <Box>
<Group gap={6} mt="sm"> <Group gap={6} mt="sm">
<Badge size="sm" variant="light" color="emaPrimary"> <Badge size="sm" variant="light" color="emaPrimary">
{formatFee(type.feeNewApplication, type.feeCurrency)} {formatFee(type.feeNewApplication, type.feeCurrency, t('licensing.catalogue.noFee'))}
</Badge> </Badge>
{capital && ( {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"> <Badge size="sm" variant="light" color="gray">
Capital {capital.toLocaleString('en-US')} {t('licensing.catalogue.capitalBadge', { amount: capital.toLocaleString('en-US') })}
</Badge> </Badge>
</Tooltip> </Tooltip>
)} )}
{type.issuesCertificate ? ( {type.issuesCertificate ? (
<Badge size="sm" variant="light" color="teal"> <Badge size="sm" variant="light" color="teal">
{type.validityMonths} months {t('licensing.catalogue.validityBadge', { months: type.validityMonths })}
</Badge> </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"> <Badge size="sm" variant="light" color="gray">
Evaluation only {t('licensing.catalogue.evaluationOnly')}
</Badge> </Badge>
</Tooltip> </Tooltip>
)} )}
@@ -328,7 +328,9 @@ function LicenseTypeCard({
color={canApply ? undefined : 'gray'} color={canApply ? undefined : 'gray'}
rightSection={<IconArrowRight size={14} />} rightSection={<IconArrowRight size={14} />}
> >
{canApply ? 'Start application' : 'Add to my operations'} {canApply
? t('licensing.catalogue.startApplication')
: t('licensing.catalogue.addToOperations')}
</Button> </Button>
</RequirePermission> </RequirePermission>
</Box> </Box>

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core'; import { ActionIcon, Button, FileButton, Group, Loader } from '@mantine/core';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react'; import { IconCheck, IconEye } from '@tabler/icons-react';
import { import {
useLocalized, useLocalized,
uploadDocument, uploadDocument,
useGetAttachmentsQuery, useGetAttachmentsQuery,
type StaffEvidenceRequirement, type StaffEvidenceRequirement,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { useTranslation } from 'react-i18next';
import { PdfPreviewModal } from '@ema-platform/ui';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@@ -30,26 +32,34 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
ownerId: staffId, ownerId: staffId,
}); });
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
);
const localized = useLocalized(); const localized = useLocalized();
const { t } = useTranslation();
if (!evidence?.length) return null; if (!evidence?.length) return null;
return ( return (
<Group gap="xs"> <Group gap="xs">
{evidence.map((item) => { {evidence.map((item) => {
const uploaded = attachments.some( const attachment = attachments.find(
(a) => a.documentKey === item.docKey && a.files?.length, (a) => a.documentKey === item.docKey && a.files?.length,
); );
const uploaded = Boolean(attachment);
const fileUrl = attachment?.files?.[0]?.url;
return ( return (
<Group key={item.docKey} gap={4} wrap="nowrap">
<FileButton <FileButton
key={item.docKey}
accept="application/pdf,image/jpeg,image/png" accept="application/pdf,image/jpeg,image/png"
onChange={async (file) => { onChange={async (file) => {
if (!file) return; if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) { if (file.size > MAX_FILE_SIZE_BYTES) {
notifications.show({ notifications.show({
color: 'red', 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; return;
} }
@@ -74,7 +84,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
disabled={readOnly || busy === item.docKey} disabled={readOnly || busy === item.docKey}
leftSection={ leftSection={
busy === item.docKey ? ( busy === item.docKey ? (
<Loader size={10} /> <Loader size={10} type="oval" />
) : uploaded ? ( ) : uploaded ? (
<IconCheck size={12} /> <IconCheck size={12} />
) : undefined ) : undefined
@@ -85,8 +95,27 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
</Button> </Button>
)} )}
</FileButton> </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> </Group>
); );
} }

View File

@@ -6,11 +6,9 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
Center,
Container, Container,
Divider, Divider,
Group, Group,
Loader,
Modal, Modal,
NumberInput, NumberInput,
Paper, Paper,
@@ -76,7 +74,7 @@ import {
import { DocumentSlots } from "../components/DocumentSlots"; import { DocumentSlots } from "../components/DocumentSlots";
import { StaffEvidence } from "../components/StaffEvidence"; import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks"; 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. */ /** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
function readSourcePath( function readSourcePath(
context: Record<string, unknown>, context: Record<string, unknown>,
@@ -110,7 +108,7 @@ const LEGACY_PROFILE_SOURCES: Record<string, string> = {
export function LicenseApplicationPage() { export function LicenseApplicationPage() {
const { typeCode = "FREIGHT_FORWARDER", applicationId } = useParams(); const { typeCode = "FREIGHT_FORWARDER", applicationId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { i18n } = useTranslation(); const { t, i18n } = useTranslation();
const localized = useLocalized(); const localized = useLocalized();
const accountUser = useAppSelector((state) => state.auth.user); const accountUser = useAppSelector((state) => state.auth.user);
@@ -349,11 +347,7 @@ export function LicenseApplicationPage() {
); );
if (loadingConfig || !config || !appId || !application) { if (loadingConfig || !config || !appId || !application) {
return ( return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
<Center h={400}>
<Loader />
</Center>
);
} }
// A submitted application stays editable until an officer takes it, which // A submitted application stays editable until an officer takes it, which
@@ -474,7 +468,7 @@ export function LicenseApplicationPage() {
color: "red", color: "red",
title: "Application incomplete", title: "Application incomplete",
message: found.length message: found.length
? `${found.length} item(s) still need attention.` ? t('licenseApplication.notifications.applicationIncomplete.itemsNeedAttention', { count: found.length })
: extractErrorMessage(err), : extractErrorMessage(err),
}); });
} }
@@ -520,7 +514,12 @@ export function LicenseApplicationPage() {
(detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey) (detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey)
.length < role.minCount, .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) { if (missing.length) {
notifications.show({ notifications.show({
color: "red", color: "red",
@@ -546,6 +545,13 @@ export function LicenseApplicationPage() {
.filter((req) => !supplied.has(req.key)) .filter((req) => !supplied.has(req.key))
.map((req) => localized(req.name)); .map((req) => localized(req.name));
if (missing.length) { 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({ notifications.show({
color: "red", color: "red",
title: "Documents missing", title: "Documents missing",
@@ -649,7 +655,7 @@ export function LicenseApplicationPage() {
<Alert <Alert
color="orange" color="orange"
icon={<IconAlertTriangle size={16} />} icon={<IconAlertTriangle size={16} />}
title="Corrections requested" title={t('licenseApplication.correctionsRequested.title')}
mb="md" mb="md"
> >
<Stack gap={4}> <Stack gap={4}>
@@ -659,7 +665,7 @@ export function LicenseApplicationPage() {
</Text> </Text>
))} ))}
<Text size="xs" c="dimmed" mt={4}> <Text size="xs" c="dimmed" mt={4}>
Only the items listed above can be changed. {t('licenseApplication.correctionsRequested.onlyListed')}
</Text> </Text>
</Stack> </Stack>
</Alert> </Alert>
@@ -980,11 +986,11 @@ export function LicenseApplicationPage() {
<Modal <Modal
opened={Boolean(staffModal)} opened={Boolean(staffModal)}
onClose={() => setStaffModal(null)} onClose={() => setStaffModal(null)}
title="Add staff member" title={t('licenseApplication.staff.addStaffMember')}
> >
<Stack> <Stack>
<TextInput <TextInput
label="Full name" label={t('licenseApplication.staff.fullName')}
withAsterisk withAsterisk
value={newStaff.fullName} value={newStaff.fullName}
onChange={(e) => onChange={(e) =>
@@ -992,14 +998,14 @@ export function LicenseApplicationPage() {
} }
/> />
<TextInput <TextInput
label="Position" label={t('licenseApplication.staff.position')}
value={newStaff.position} value={newStaff.position}
onChange={(e) => onChange={(e) =>
setNewStaff({ ...newStaff, position: e.currentTarget.value }) setNewStaff({ ...newStaff, position: e.currentTarget.value })
} }
/> />
<NumberInput <NumberInput
label="Years of experience" label={t('licenseApplication.staff.yearsOfExperience')}
value={newStaff.yearsOfExperience} value={newStaff.yearsOfExperience}
onChange={(v) => onChange={(v) =>
setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 }) setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })
@@ -1020,7 +1026,7 @@ export function LicenseApplicationPage() {
refetch(); refetch();
}} }}
> >
Add {t('licenseApplication.staff.add')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</Stack> </Stack>

View File

@@ -32,6 +32,7 @@ import {
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
interface MedicalCert { interface MedicalCert {
id: string; id: string;
@@ -110,6 +111,8 @@ export function MedicalCertificatePage() {
resetRef.current?.(); resetRef.current?.();
}; };
const { t } = useTranslation();
return ( return (
<Stack gap="md"> <Stack gap="md">
{/* Header */} {/* Header */}

View File

@@ -4,16 +4,15 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
Center,
Container, Container,
Group, Group,
Loader,
SegmentedControl, SegmentedControl,
Stack, Stack,
Text, Text,
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconBellOff, IconCheck } from '@tabler/icons-react'; import { IconBellOff, IconCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { import {
useLocalized, useLocalized,
useGetNotificationsQuery, useGetNotificationsQuery,
@@ -21,15 +20,10 @@ import {
useMarkNotificationReadMutation, useMarkNotificationReadMutation,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { PageLoader } from '@ema-platform/ui';
type Tab = 'all' | 'unseen' | 'seen'; 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. * 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. * every transition — this previously listed a fixed array of invented alerts.
*/ */
export function NotificationsPage() { export function NotificationsPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const localized = useLocalized(); const localized = useLocalized();
const [tab, setTab] = useState<Tab>('all'); 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 all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' }); const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });
const [markRead] = useMarkNotificationReadMutation(); const [markRead] = useMarkNotificationReadMutation();
@@ -57,9 +57,9 @@ export function NotificationsPage() {
return ( return (
<Container size="md" py="md"> <Container size="md" py="md">
<Title order={3}>Notifications</Title> <Title order={3}>{t('notifications.title')}</Title>
<Text size="sm" c="dimmed" mb="md"> <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> </Text>
<SegmentedControl <SegmentedControl
@@ -68,23 +68,21 @@ export function NotificationsPage() {
value={tab} value={tab}
onChange={(v) => setTab(v as Tab)} onChange={(v) => setTab(v as Tab)}
data={[ data={[
{ label: 'All', value: 'all' }, { label: t('notifications.tabs.all'), value: 'all' },
{ label: 'Unseen', value: 'unseen' }, { label: t('notifications.tabs.unseen'), value: 'unseen' },
{ label: 'Seen', value: 'seen' }, { label: t('notifications.tabs.seen'), value: 'seen' },
]} ]}
/> />
{isLoading ? ( {isLoading ? (
<Center h={300}> <PageLoader label={t('notifications.loading')} height={350} />
<Loader />
</Center>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<Card withBorder padding="xl"> <Card withBorder padding="xl">
<Stack align="center" gap="xs"> <Stack align="center" gap="xs">
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" /> <IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
<Text c="dimmed">{EMPTY_COPY[tab]}</Text> <Text c="dimmed">{EMPTY_COPY[tab]}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You will be notified as your applications progress. {t('notifications.emptyBody')}
</Text> </Text>
</Stack> </Stack>
</Card> </Card>
@@ -112,7 +110,7 @@ export function NotificationsPage() {
</Text> </Text>
{!n.isSeen && ( {!n.isSeen && (
<Badge size="xs" variant="light"> <Badge size="xs" variant="light">
new {t('notifications.new')}
</Badge> </Badge>
)} )}
</Group> </Group>
@@ -133,7 +131,7 @@ export function NotificationsPage() {
markRead(n.id); markRead(n.id);
}} }}
> >
Mark read {t('notifications.markRead')}
</Button> </Button>
)} )}
</Group> </Group>

View File

@@ -1,6 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom'; 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 { 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 * Sends an applicant who has not said what they operate as to the step that
@@ -64,6 +65,7 @@ function isModeFreeLicensingRoute(pathname: string): boolean {
} }
export function RequireOperations({ children }: { children: React.ReactNode }) { export function RequireOperations({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery(); const { data, isLoading, isFetching, isError } = useGetMyOperatorTypesQuery();
@@ -75,11 +77,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
} }
if (isLoading) { if (isLoading) {
return ( return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
<Center h={200}>
<Loader />
</Center>
);
} }
// A failed lookup must not lock anyone out of the portal — the server still // A failed lookup must not lock anyone out of the portal — the server still
@@ -93,11 +91,7 @@ export function RequireOperations({ children }: { children: React.ReactNode }) {
// the same tick; deciding on the pre-save cache bounced the applicant // the same tick; deciding on the pre-save cache bounced the applicant
// straight back to the screen they had just completed. // straight back to the screen they had just completed.
if (isFetching) { if (isFetching) {
return ( return <PageLoader label={t('onboarding.checkingProfile')} height={350} />;
<Center h={200}>
<Loader />
</Center>
);
} }
return <Navigate to="/onboarding/operations" replace />; return <Navigate to="/onboarding/operations" replace />;
} }

View File

@@ -1,21 +1,24 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Container, Paper, Stack, Text, Title } from '@mantine/core'; import { Container, Paper, Stack, Text, Title } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { OperationsFormContent } from '../../profile/components/OperationsFormContent'; import { OperationsFormContent } from '../../profile/components/OperationsFormContent';
/** /**
* Where a fresh applicant lands after declaring themselves. Someone who says * Where a fresh applicant lands after declaring themselves. Someone who says
* "I own a vessel" or "I am a seafarer" came here to register, so they are * "I own a vessel" came here to register, so they are taken to that service's
* taken straight to that form instead of a dashboard that only links to it. * 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 page is one nav click away.
* *
* Seafarer used to detour via `/profile` because the wizard refused to open * A licence type with no page of its own falls through to the dashboard,
* without a complete one. It no longer does — the Identity Details step * where the catalogue offers it.
* collects those answers itself (`RequireSeafarerProfile`) — so the detour was
* only an extra screen between a new signup and the thing they came for.
* Seafarer wins when both are ticked; the other form is one nav click away.
*/ */
const NEXT_STEP: Record<string, string> = { const NEXT_STEP: Record<string, string> = {
SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply', SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply', VESSEL_REGISTRATION: '/vessel-registration',
}; };
function nextStepFor(selectedKeys: string[]): string { function nextStepFor(selectedKeys: string[]): string {
@@ -35,17 +38,16 @@ function nextStepFor(selectedKeys: string[]): string {
* soon as the profile has at least one mode. * soon as the profile has at least one mode.
*/ */
export function OperationsOnboardingPage() { export function OperationsOnboardingPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Container size="sm" py="xl"> <Container size="sm" py="xl">
<Stack gap="lg"> <Stack gap="lg">
<div> <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}> <Text size="sm" c="dimmed" mt={4}>
The Authority licenses by mode of operation. Tell us what your {t('onboarding.operations.body')}
company does and we will show you the licences you can apply for
you can change this later from your profile.
</Text> </Text>
</div> </div>
<Paper p="xl" shadow="sm" radius="lg" withBorder> <Paper p="xl" shadow="sm" radius="lg" withBorder>

View File

@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Card, Card,
Center,
Container, Container,
Group, Group,
Loader, Loader,
@@ -17,6 +16,7 @@ import {
IconCircleCheck, IconCircleCheck,
IconClockHour4, IconClockHour4,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery } from '@ema-platform/api'; import { useGetApplicationPaymentQuery } from '@ema-platform/api';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
@@ -31,6 +31,7 @@ const MAX_ATTEMPTS = 10;
* their account is the worst possible outcome here. * their account is the worst possible outcome here.
*/ */
export function PaymentCheckPage() { export function PaymentCheckPage() {
const { t } = useTranslation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? ''; const applicationId = params.get('applicationId') ?? '';
@@ -68,12 +69,12 @@ export function PaymentCheckPage() {
<ThemeIcon size={48} radius="xl" color="orange" variant="light"> <ThemeIcon size={48} radius="xl" color="orange" variant="light">
<IconAlertTriangle size={24} /> <IconAlertTriangle size={24} />
</ThemeIcon> </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"> <Text size="sm" c="dimmed" ta="center">
Open the application from your list to check its payment status. {t('payments.check.notFoundBody')}
</Text> </Text>
<Button onClick={() => navigate('/licensing/applications')}> <Button onClick={() => navigate('/licensing/applications')}>
My applications {t('payments.myApplications')}
</Button> </Button>
</Stack> </Stack>
</Card> </Card>
@@ -92,18 +93,16 @@ export function PaymentCheckPage() {
<ThemeIcon size={48} radius="xl" color="yellow" variant="light"> <ThemeIcon size={48} radius="xl" color="yellow" variant="light">
<IconClockHour4 size={24} /> <IconClockHour4 size={24} />
</ThemeIcon> </ThemeIcon>
<Title order={4}>Still confirming your payment</Title> <Title order={4}>{t('payments.check.stillConfirmingTitle')}</Title>
<Text size="sm" c="dimmed" ta="center"> <Text size="sm" c="dimmed" ta="center">
Telebirr has not confirmed this payment yet. If the money has {t('payments.check.stillConfirmingBody')}
left your account it will be applied automatically there is no
need to pay again.
</Text> </Text>
<Group> <Group>
<Button variant="default" onClick={() => { setAttempts(0); refetch(); }}> <Button variant="default" onClick={() => { setAttempts(0); refetch(); }}>
Check again {t('payments.check.checkAgain')}
</Button> </Button>
<Button onClick={() => navigate('/licensing/applications')}> <Button onClick={() => navigate('/licensing/applications')}>
My applications {t('payments.myApplications')}
</Button> </Button>
</Group> </Group>
</> </>
@@ -114,9 +113,9 @@ export function PaymentCheckPage() {
<IconCircleCheck size={24} /> <IconCircleCheck size={24} />
</ThemeIcon> </ThemeIcon>
)} )}
<Title order={4}>Confirming your payment</Title> <Title order={4}>{t('payments.check.confirmingTitle')}</Title>
<Text size="sm" c="dimmed" ta="center"> <Text size="sm" c="dimmed" ta="center">
This usually takes a few seconds. Please do not close this page. {t('payments.check.confirmingBody')}
</Text> </Text>
</> </>
)} )}

View File

@@ -10,10 +10,12 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery } from '@ema-platform/api'; import { useGetApplicationPaymentQuery } from '@ema-platform/api';
/** Shown when Telebirr reported the payment as failed or cancelled. */ /** Shown when Telebirr reported the payment as failed or cancelled. */
export function PaymentFailurePage() { export function PaymentFailurePage() {
const { t } = useTranslation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const applicationId = params.get('applicationId') ?? ''; const applicationId = params.get('applicationId') ?? '';
@@ -28,18 +30,16 @@ export function PaymentFailurePage() {
<ThemeIcon size={56} radius="xl" color="red" variant="light"> <ThemeIcon size={56} radius="xl" color="red" variant="light">
<IconAlertTriangle size={30} /> <IconAlertTriangle size={30} />
</ThemeIcon> </ThemeIcon>
<Title order={3}>Payment not completed</Title> <Title order={3}>{t('payments.failure.title')}</Title>
<Text size="sm" c="dimmed" ta="center"> <Text size="sm" c="dimmed" ta="center">
{data?.failureReason {data?.failureReason ? data.failureReason : t('payments.failure.defaultReason')}
? data.failureReason
: 'The payment was not completed. Nothing has been charged.'}
</Text> </Text>
<Text size="xs" c="dimmed" ta="center"> <Text size="xs" c="dimmed" ta="center">
Your application is unchanged and you can try again at any time. {t('payments.failure.unchanged')}
</Text> </Text>
<Group mt="md"> <Group mt="md">
<Button variant="default" onClick={() => navigate('/licensing/applications')}> <Button variant="default" onClick={() => navigate('/licensing/applications')}>
My applications {t('payments.myApplications')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -11,11 +11,13 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconCircleCheck } from '@tabler/icons-react'; import { IconCircleCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useGetApplicationPaymentQuery } from '@ema-platform/api'; import { useGetApplicationPaymentQuery } from '@ema-platform/api';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
/** Confirmation that the licence fee has been received. */ /** Confirmation that the licence fee has been received. */
export function PaymentSuccessPage() { export function PaymentSuccessPage() {
const { t } = useTranslation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
@@ -31,10 +33,9 @@ export function PaymentSuccessPage() {
<ThemeIcon size={56} radius="xl" color="teal" variant="light"> <ThemeIcon size={56} radius="xl" color="teal" variant="light">
<IconCircleCheck size={30} /> <IconCircleCheck size={30} />
</ThemeIcon> </ThemeIcon>
<Title order={3}>Payment received</Title> <Title order={3}>{t('payments.success.title')}</Title>
<Text size="sm" c="dimmed" ta="center"> <Text size="sm" c="dimmed" ta="center">
Thank you. Your licence fee has been paid and your application is {t('payments.success.body')}
being finalised. You will be notified when your certificate is ready.
</Text> </Text>
{data && ( {data && (
@@ -42,24 +43,24 @@ export function PaymentSuccessPage() {
<Divider my="xs" w="100%" /> <Divider my="xs" w="100%" />
<Stack gap={4} w="100%"> <Stack gap={4} w="100%">
<Group justify="space-between"> <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}> <Text size="sm" fw={600}>
{Number(data.amount).toLocaleString()} {data.currency} {Number(data.amount).toLocaleString()} {data.currency}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <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> <Text size="sm">{data.provider}</Text>
</Group> </Group>
{data.providerRef && ( {data.providerRef && (
<Group justify="space-between"> <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> <Text size="sm" ff="monospace">{data.providerRef}</Text>
</Group> </Group>
)} )}
{data.paidAt && ( {data.paidAt && (
<Group justify="space-between"> <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> <Text size="sm">{showDate(data.paidAt)}</Text>
</Group> </Group>
)} )}
@@ -68,7 +69,7 @@ export function PaymentSuccessPage() {
)} )}
<Button mt="md" onClick={() => navigate('/licensing/applications')}> <Button mt="md" onClick={() => navigate('/licensing/applications')}>
Back to my applications {t('payments.success.backToApplications')}
</Button> </Button>
</Stack> </Stack>
</Card> </Card>

View File

@@ -2,43 +2,51 @@ import { useCallback, useMemo } from 'react';
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core'; import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form'; import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui'; import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker'; import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api'; import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location, LocationType } from '../../location/types/location'; import type { Location, LocationType } from '../../location/types/location';
export const addressSchema = z.object({ // Zod schemas can't call hooks, so the schema is built from `t` by the
idType: z.string().min(1, 'Select ID type'), // caller (ProfilePage) rather than defined once at module scope.
idNumber: z.string().trim().min(1, 'Enter ID number'), export function addressSchema(t: TFunction) {
// Alpha-2 country code from CountrySelect; converted to a full name at submit. return z.object({
nationality: z.string().min(1, 'Select nationality'), idType: z.string().min(1, t('profileAddress.validation.idTypeRequired')),
primaryPhoneNumber: ethiopianPhone, idNumber: z.string().trim().min(1, t('profileAddress.validation.idNumberRequired')),
secondaryPhoneNumber: optionalEthiopianPhone, // Alpha-2 country code from CountrySelect; converted to a full name at submit.
email: z.string().trim().email('Invalid email').optional().or(z.literal('')), nationality: z.string().min(1, t('profileAddress.validation.nationalityRequired')),
regionId: z.string().optional(), primaryPhoneNumber: ethiopianPhone,
cityId: z.string().optional(), secondaryPhoneNumber: optionalEthiopianPhone,
subCityId: z.string().optional(), email: z.string().trim().email(t('profileAddress.validation.emailInvalid')).optional().or(z.literal('')),
woredaId: z.string().optional(), regionId: z.string().optional(),
kebeleId: z.string().optional(), cityId: z.string().optional(),
streetAddress: z.string().trim().optional(), subCityId: z.string().optional(),
postalAddress: z.string().trim().optional(), woredaId: z.string().optional(),
// Emergency contact is collected but never required — leaving it blank must kebeleId: z.string().optional(),
// not stop an applicant moving on. streetAddress: z.string().trim().optional(),
emergencyContactName: z.string().trim().optional(), postalAddress: z.string().trim().optional(),
emergencyContactPhone: optionalEthiopianPhone, // Emergency contact is collected but never required — leaving it blank must
emergencyContactRelation: z.string().trim().optional(), // not stop an applicant moving on.
}); emergencyContactName: z.string().trim().optional(),
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: // Backend rejects anything outside this set:
// "idType must be one of the following values: NID, VITAL, PASSPORT, DRIVERS_LICENSE" // "idType must be one of the following values: NID, VITAL, PASSPORT, DRIVERS_LICENSE"
export const ID_TYPES = [ function idTypeOptions(t: TFunction) {
{ value: 'NID', label: 'National Id' }, return [
{ value: 'VITAL', label: 'Vital ID' }, { value: 'NID', label: t('profileAddress.idTypeOptions.NID') },
{ value: 'PASSPORT', label: 'Passport' }, { value: 'VITAL', label: t('profileAddress.idTypeOptions.VITAL') },
{ value: 'DRIVERS_LICENSE', label: "Driver's License" }, { value: 'PASSPORT', label: t('profileAddress.idTypeOptions.PASSPORT') },
] as const; { value: 'DRIVERS_LICENSE', label: t('profileAddress.idTypeOptions.DRIVERS_LICENSE') },
] as const;
}
/** /**
* Location types are data-driven rows (no fixed depth), so the chain is * Location types are data-driven rows (no fixed depth), so the chain is
@@ -79,6 +87,7 @@ export function AddressFormContent({
watch, watch,
trigger, trigger,
}: AddressFormContentProps) { }: AddressFormContentProps) {
const { t } = useTranslation();
const { data: typesRes } = useGetLocationTypesQuery(); const { data: typesRes } = useGetLocationTypesQuery();
const locationTypes = typesRes?.items; const locationTypes = typesRes?.items;
@@ -113,10 +122,10 @@ export function AddressFormContent({
<> <>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select <Select
label="ID Type" label={t('profileFields.idType')}
placeholder="Select" placeholder={t('profileAddress.idTypePlaceholder')}
required required
data={ID_TYPES} data={idTypeOptions(t)}
error={errors.idType?.message} error={errors.idType?.message}
value={watch('idType')} value={watch('idType')}
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })} onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
@@ -124,14 +133,14 @@ export function AddressFormContent({
name="idType" name="idType"
/> />
<TextInput <TextInput
label="ID Number" label={t('profileFields.idNumber')}
placeholder="Enter ID number" placeholder={t('profileAddress.idNumberPlaceholder')}
required required
{...register('idNumber')} {...register('idNumber')}
error={errors.idNumber?.message} error={errors.idNumber?.message}
/> />
<CountrySelect <CountrySelect
label="Nationality" label={t('profileFields.nationality')}
demonym demonym
required required
value={watch('nationality') || null} value={watch('nationality') || null}
@@ -139,23 +148,23 @@ export function AddressFormContent({
error={errors.nationality?.message} error={errors.nationality?.message}
/> />
<TextInput <TextInput
label="Primary Phone" label={t('profileFields.primaryPhoneNumber')}
description="From your account, edit it in the Personal tab" description={t('profileAddress.accountManagedHint')}
required required
readOnly readOnly
{...register('primaryPhoneNumber')} {...register('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message} error={errors.primaryPhoneNumber?.message}
/> />
<TextInput <TextInput
label="Secondary Phone" label={t('profileAddress.secondaryPhoneNumber')}
placeholder="+251 9XX XXX XXX" placeholder={t('profileAddress.phonePlaceholder')}
{...register('secondaryPhoneNumber')} {...register('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message} error={errors.secondaryPhoneNumber?.message}
/> />
<TextInput <TextInput
label="Email" label={t('profileFields.email')}
type="email" type="email"
description="From your account, edit it in the Personal tab" description={t('profileAddress.accountManagedHint')}
readOnly readOnly
{...register('email')} {...register('email')}
error={errors.email?.message} error={errors.email?.message}
@@ -163,7 +172,7 @@ export function AddressFormContent({
</SimpleGrid> </SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm"> <Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address {t('profileAddress.addressSection')}
</Text> </Text>
{/* City / Sub-city / Woreda — the picker's depth. Kebele is a seeded {/* City / Sub-city / Woreda — the picker's depth. Kebele is a seeded
level but nothing collects it, so `kebeleId` stays unset rather than level but nothing collects it, so `kebeleId` stays unset rather than
@@ -171,38 +180,41 @@ export function AddressFormContent({
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} /> <LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<TextInput <TextInput
label="Street Address" label={t('profileFields.streetAddress')}
placeholder="Street name, house number" placeholder={t('profileAddress.streetAddressPlaceholder')}
{...register('streetAddress')} {...register('streetAddress')}
error={errors.streetAddress?.message} error={errors.streetAddress?.message}
/> />
<TextInput <TextInput
label="Postal Address" label={t('profileAddress.postalAddress')}
placeholder="P.O. Box" placeholder={t('profileAddress.postalAddressPlaceholder')}
{...register('postalAddress')} {...register('postalAddress')}
error={errors.postalAddress?.message} error={errors.postalAddress?.message}
/> />
</SimpleGrid> </SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm"> <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> </Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput <TextInput
label="Contact Name" label={t('profileAddress.contactName')}
placeholder="Full name" placeholder={t('profileAddress.contactNamePlaceholder')}
{...register('emergencyContactName')} {...register('emergencyContactName')}
error={errors.emergencyContactName?.message} error={errors.emergencyContactName?.message}
/> />
<TextInput <TextInput
label="Contact Phone" label={t('profileAddress.contactPhone')}
placeholder="+251 9XX XXX XXX" placeholder={t('profileAddress.phonePlaceholder')}
{...register('emergencyContactPhone')} {...register('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message} error={errors.emergencyContactPhone?.message}
/> />
<TextInput <TextInput
label="Relationship" label={t('profileAddress.relationship')}
placeholder="Spouse, Parent, etc." placeholder={t('profileAddress.relationshipPlaceholder')}
{...register('emergencyContactRelation')} {...register('emergencyContactRelation')}
error={errors.emergencyContactRelation?.message} error={errors.emergencyContactRelation?.message}
/> />

View File

@@ -12,6 +12,7 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react'; import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { import {
extractErrorMessage, extractErrorMessage,
useLocalized, useLocalized,
@@ -19,6 +20,7 @@ import {
useGetMyOperatorTypesQuery, useGetMyOperatorTypesQuery,
useUpdateMyOperatorTypesMutation, useUpdateMyOperatorTypesMutation,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { useUpdateMyAccountTypeMutation } from '@ema-platform/auth';
import { notify, ModalFooter } from '@ema-platform/ui'; import { notify, ModalFooter } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
@@ -34,6 +36,29 @@ const PERSONAL_REGISTRATION_KEYS = [
'VESSEL_REGISTRATION', '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 * The applicant's modes of operation — what they do, and therefore which
* licences the portal offers them. * licences the portal offers them.
@@ -53,9 +78,11 @@ export function OperationsFormContent({
*/ */
onSaved?: (selectedKeys: string[]) => void; onSaved?: (selectedKeys: string[]) => void;
} = {}) { } = {}) {
const { t } = useTranslation();
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery(); const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery(); const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation(); const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
const [setAccountType] = useUpdateMyAccountTypeMutation();
const localized = useLocalized(); const localized = useLocalized();
const declaredIds = useMemo( const declaredIds = useMemo(
@@ -116,21 +143,32 @@ export function OperationsFormContent({
async function persist() { async function persist() {
try { try {
await save({ licenseTypeIds: selected }).unwrap(); 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); setConfirmingRemoval(false);
notify.success( notify.success(
'The licences you can apply for have been updated to match.', t('profileOperations.updateSuccessBody'),
'Operations updated', t('profileOperations.updateSuccessTitle'),
);
onSaved?.(
options.filter((t) => selected.includes(t.id)).map((t) => t.key),
); );
onSaved?.(selectedKeys);
} catch (err) { } catch (err) {
notify.error(extractErrorMessage(err), 'Could not save'); notify.error(extractErrorMessage(err), t('profileOperations.updateErrorTitle'));
} }
} }
if (loadingTypes || loadingMine) { if (loadingTypes || loadingMine) {
return <Loader size="sm" />; return <Loader size="sm" type="oval" />;
} }
const removedNames = options const removedNames = options
@@ -140,10 +178,9 @@ export function OperationsFormContent({
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>Mode of operation</Title> <Title order={5}>{t('profileOperations.title')}</Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
What your company operates as. This decides which licences you are {t('profileOperations.description')}
offered you can change it whenever your business changes.
</Text> </Text>
<Checkbox.Group value={selected} onChange={setSelected}> <Checkbox.Group value={selected} onChange={setSelected}>
@@ -157,7 +194,7 @@ export function OperationsFormContent({
<Text size="sm">{localized(type.name)}</Text> <Text size="sm">{localized(type.name)}</Text>
{declaredIds.includes(type.id) && ( {declaredIds.includes(type.id) && (
<Badge size="xs" variant="light" color="teal"> <Badge size="xs" variant="light" color="teal">
Current {t('profileOperations.current')}
</Badge> </Badge>
)} )}
</Group> </Group>
@@ -203,8 +240,7 @@ export function OperationsFormContent({
{options.length === 0 && ( {options.length === 0 && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No licence types are configured yet. Contact EMA if you were {t('profileOperations.emptyState')}
expecting one.
</Text> </Text>
)} )}
</div> </div>
@@ -214,16 +250,17 @@ export function OperationsFormContent({
variant="light" variant="light"
color="orange" color="orange"
icon={<IconAlertTriangle size={18} />} 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. {t('profileOperations.noneSelectedBody')}
Existing applications and issued licences are unaffected.
</Alert> </Alert>
)} )}
<Group justify="space-between"> <Group justify="space-between">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{lastChanged ? `Last changed ${showDate(lastChanged)}` : 'Not set yet'} {lastChanged
? t('profileOperations.lastChanged', { date: showDate(lastChanged) })
: t('profileOperations.notSetYet')}
</Text> </Text>
<Group gap="sm"> <Group gap="sm">
{dirty && ( {dirty && (
@@ -232,7 +269,7 @@ export function OperationsFormContent({
size="sm" size="sm"
onClick={() => setSelected(declaredIds)} onClick={() => setSelected(declaredIds)}
> >
Discard changes {t('profileOperations.discardChanges')}
</Button> </Button>
)} )}
<Button <Button
@@ -244,7 +281,7 @@ export function OperationsFormContent({
removed.length > 0 ? setConfirmingRemoval(true) : persist() removed.length > 0 ? setConfirmingRemoval(true) : persist()
} }
> >
Save operations {t('profileOperations.saveOperations')}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -254,31 +291,29 @@ export function OperationsFormContent({
<Modal <Modal
opened={confirmingRemoval} opened={confirmingRemoval}
onClose={() => setConfirmingRemoval(false)} onClose={() => setConfirmingRemoval(false)}
title="Remove from your operations?" title={t('profileOperations.removeModalTitle')}
centered centered
> >
<Stack gap="md"> <Stack gap="md">
<Text size="sm"> <Text size="sm">
You are removing{' '} {t('profileOperations.removingPrefix')}{' '}
<Text span fw={600}> <Text span fw={600}>
{removedNames.join(', ')} {removedNames.join(', ')}
</Text> </Text>
. .
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
You will no longer be offered a new application of that type. {t('profileOperations.removeConsequence')}
Applications already filed carry on as they are, and licences
already issued to you stay valid and can still be renewed.
</Text> </Text>
<ModalFooter gap="sm"> <ModalFooter gap="sm">
<Button <Button
variant="default" variant="default"
onClick={() => setConfirmingRemoval(false)} onClick={() => setConfirmingRemoval(false)}
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button color="orange" loading={saving} onClick={persist}> <Button color="orange" loading={saving} onClick={persist}>
Remove and save {t('profileOperations.removeAndSave')}
</Button> </Button>
</ModalFooter> </ModalFooter>
</Stack> </Stack>

View File

@@ -1,20 +1,38 @@
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core'; import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form'; import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { AmharicDatePicker } from '@ema-platform/ui'; import { AmharicDatePicker } from '@ema-platform/ui';
export const profileSchema = z.object({ // dob comes in as a plain yyyy-MM-dd string; skip on empty/malformed so
professionId: z.string().min(1, 'Select your profession'), // dobRequired's own message takes precedence.
firstName: z.string().min(3, 'First name must be at least 3 characters'), function isAtLeast18(dob: string): boolean {
middleName: z.string().min(3, 'Middle name must be at least 3 characters'), const birth = new Date(dob);
lastName: z.string().min(3, 'Last name must be at least 3 characters'), if (Number.isNaN(birth.getTime())) return true;
gender: z.string().min(1, 'Select your gender'), const today = new Date();
dob: z.string().min(1, 'Select your date of birth'), const cutoff = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate());
pob: z.string().optional(), return birth <= cutoff;
maritalStatus: z.string().min(1, 'Select your marital status'), }
});
export type ProfileValues = z.infer<typeof profileSchema>; 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, t('profileForm.validation.maritalStatusRequired')),
});
export type ProfileValues = z.infer<ReturnType<typeof profileSchema>>;
export const GENDERS = ['MALE', 'FEMALE'] as const; export const GENDERS = ['MALE', 'FEMALE'] as const;
export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const; export const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'] as const;
@@ -39,11 +57,13 @@ export function ProfileFormContent({
professionsLoading, professionsLoading,
professionOptions, professionOptions,
}: ProfileFormContentProps) { }: ProfileFormContentProps) {
const { t } = useTranslation();
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select <Select
label="Profession" label={t('profileFields.professionId')}
placeholder={professionsLoading ? 'Loading...' : 'Select'} placeholder={professionsLoading ? t('common.loading') : t('common.select')}
required required
data={professionOptions} data={professionOptions}
error={errors.professionId?.message} error={errors.professionId?.message}
@@ -53,34 +73,34 @@ export function ProfileFormContent({
name="professionId" name="professionId"
searchable searchable
disabled={professionsLoading} disabled={professionsLoading}
rightSection={professionsLoading ? <Loader size="xs" /> : undefined} rightSection={professionsLoading ? <Loader size="xs" type="oval" /> : undefined}
/> />
<TextInput <TextInput
label="First Name" label={t('profileFields.firstName')}
placeholder="Enter first name" placeholder={t('profileForm.placeholders.firstName')}
required required
{...register('firstName')} {...register('firstName')}
error={errors.firstName?.message} error={errors.firstName?.message}
/> />
<TextInput <TextInput
label="Middle Name" label={t('profileFields.middleName')}
placeholder="Enter middle name" placeholder={t('profileForm.placeholders.middleName')}
required required
{...register('middleName')} {...register('middleName')}
error={errors.middleName?.message} error={errors.middleName?.message}
/> />
<TextInput <TextInput
label="Last Name" label={t('profileFields.lastName')}
placeholder="Enter last name" placeholder={t('profileForm.placeholders.lastName')}
required required
{...register('lastName')} {...register('lastName')}
error={errors.lastName?.message} error={errors.lastName?.message}
/> />
<Select <Select
label="Gender" label={t('profileFields.gender')}
placeholder="Select" placeholder={t('common.select')}
required required
data={[...GENDERS]} data={GENDERS.map((g) => ({ value: g, label: t(`profileForm.genders.${g}`) }))}
error={errors.gender?.message} error={errors.gender?.message}
value={watch('gender')} value={watch('gender')}
onChange={(val) => setValue('gender', val || '', { shouldValidate: true })} onChange={(val) => setValue('gender', val || '', { shouldValidate: true })}
@@ -88,7 +108,7 @@ export function ProfileFormContent({
name="gender" name="gender"
/> />
<AmharicDatePicker <AmharicDatePicker
label="Date of Birth" label={t('profileFields.dob')}
required required
value={watch('dob')} value={watch('dob')}
onChange={(val) => setValue('dob', val, { shouldValidate: true })} onChange={(val) => setValue('dob', val, { shouldValidate: true })}
@@ -97,16 +117,16 @@ export function ProfileFormContent({
error={errors.dob?.message} error={errors.dob?.message}
/> />
<TextInput <TextInput
label="Place of Birth" label={t('profileFields.pob')}
placeholder="City, Region" placeholder={t('profileForm.placeholders.pob')}
{...register('pob')} {...register('pob')}
error={errors.pob?.message} error={errors.pob?.message}
/> />
<Select <Select
label="Marital Status" label={t('profileFields.maritalStatus')}
placeholder="Select" placeholder={t('common.select')}
required required
data={[...MARITAL_STATUSES]} data={MARITAL_STATUSES.map((m) => ({ value: m, label: t(`profileForm.maritalStatuses.${m}`) }))}
error={errors.maritalStatus?.message} error={errors.maritalStatus?.message}
value={watch('maritalStatus')} value={watch('maritalStatus')}
onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })} onChange={(val) => setValue('maritalStatus', val || '', { shouldValidate: true })}

View File

@@ -59,7 +59,7 @@ export function ProfileRequirementGate({
})} })}
> >
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">{requirement.reason}</Text> <Text size="sm">{t(requirement.reason)}</Text>
<List size="sm" spacing={2}> <List size="sm" spacing={2}>
{gaps.map((field) => ( {gaps.map((field) => (
<List.Item key={field}> <List.Item key={field}>

View File

@@ -103,4 +103,4 @@ export function RequireSeafarerProfile({
if (error) return <>{children}</>; if (error) return <>{children}</>;
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -49,9 +49,9 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, splitPersonName, joinPersonName } from '@ema-platform/ui'; import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
import { useApiMutation, useLocalized } from '@ema-platform/api'; import { useApiMutation, useLocalized } from '@ema-platform/api';
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth'; import { ActiveSessions, PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import type { AuthUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth';
@@ -89,6 +89,15 @@ function getInitials(name: string, fallback: string) {
return letters.toUpperCase(); return letters.toUpperCase();
} }
function splitProfileName(fullName: string) {
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
return { firstName, middleName, lastName: lastName.join(' ') };
}
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
return [firstName, middleName, lastName].filter(Boolean).join(' ');
}
function normalizeName(name: string) { function normalizeName(name: string) {
return name.trim().replace(/\s+/g, ' '); return name.trim().replace(/\s+/g, ' ');
} }
@@ -120,7 +129,16 @@ export function ProfilePage() {
const [isSavingPassword, setIsSavingPassword] = useState(false); const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isSavingMaritime, setIsSavingMaritime] = useState(false); const [isSavingMaritime, setIsSavingMaritime] = useState(false);
// 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 [twoStepEnabled, setTwoStepEnabled] = useState(false);
// const {
// enabled: twoStepEnabled,
// isLoading: twoStepLoading,
// isSaving: twoStepSaving,
// setEnabled: setTwoStepEnabled,
// } = useTwoFactor();
const [emailNotifications, setEmailNotifications] = useState(true); const [emailNotifications, setEmailNotifications] = useState(true);
// ---- Profession list (for Profile tab) ---- // ---- Profession list (for Profile tab) ----
@@ -196,7 +214,7 @@ export function ProfilePage() {
// already holds so the form does not flash empty on a refetch. // already holds so the form does not flash empty on a refetch.
const currentProfile = resolvedProfile ?? storedProfile; const currentProfile = resolvedProfile ?? storedProfile;
if (currentProfile) { if (currentProfile) {
const accountName = user?.name?.en ? splitPersonName(user.name.en) : null; const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
setLoadedProfile({ setLoadedProfile({
professionId: currentProfile.professionId || currentProfile.profession?.id || '', professionId: currentProfile.professionId || currentProfile.profession?.id || '',
firstName: accountName?.firstName || currentProfile.firstName || '', firstName: accountName?.firstName || currentProfile.firstName || '',
@@ -215,7 +233,8 @@ export function ProfilePage() {
idType: currentProfile.address?.idType || '', idType: currentProfile.address?.idType || '',
idNumber: currentProfile.address?.idNumber || '', idNumber: currentProfile.address?.idNumber || '',
// Stored as a country name; the select works in alpha-2 codes. // Stored as a country name; the select works in alpha-2 codes.
nationality: getCountryCode(currentProfile.address?.nationality) || '', // Default to Ethiopian when no nationality is on record yet.
nationality: getCountryCode(currentProfile.address?.nationality) || 'ET',
primaryPhoneNumber: user?.phoneNumber || '', primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '', secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
email: user?.email || '', email: user?.email || '',
@@ -245,8 +264,8 @@ export function ProfilePage() {
nameEn: z nameEn: z
.string() .string()
.refine( .refine(
(name) => Object.values(splitPersonName(name)).every(Boolean), (name) => Object.values(splitProfileName(name)).every(Boolean),
{ message: 'Enter your first, middle, and last name' }, { message: t('profileForm.validation.nameParts') },
), ),
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }), nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }), username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
@@ -276,7 +295,7 @@ export function ProfilePage() {
setIsSavingProfile(true); setIsSavingProfile(true);
try { try {
const profileName = splitPersonName(values.nameEn); const profileName = splitProfileName(values.nameEn);
const saves: Promise<unknown>[] = [ const saves: Promise<unknown>[] = [
updateTrigger({ updateTrigger({
url: '/auth/update-profile', url: '/auth/update-profile',
@@ -346,16 +365,16 @@ export function ProfilePage() {
trigger: profileTriggerValidation, trigger: profileTriggerValidation,
formState: { errors: profileErrors }, formState: { errors: profileErrors },
} = useForm<ProfileValues>({ } = useForm<ProfileValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema(t)),
values: loadedProfile ?? undefined, values: loadedProfile ?? undefined,
}); });
const onSaveProfile = async (values: ProfileValues) => { const onSaveProfile = async (values: ProfileValues) => {
if (!profileId) return; if (!profileId) return;
const fullName = joinPersonName(values); const fullName = formatProfileName(values);
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) { if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
notify.error('Profile name must match the name in the Personal tab.'); notify.error(t('profile.nameMismatch'));
return; return;
} }
@@ -372,7 +391,7 @@ export function ProfilePage() {
// the address save below) — without this, `missing` stays stale until // the address save below) — without this, `missing` stays stale until
// a reload. // a reload.
refetchProfile(); refetchProfile();
notify.success('Profile updated'); notify.success(t('profile.profileUpdated'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -389,7 +408,7 @@ export function ProfilePage() {
trigger: addressTriggerValidation, trigger: addressTriggerValidation,
formState: { errors: addressErrors }, formState: { errors: addressErrors },
} = useForm<AddressValues>({ } = useForm<AddressValues>({
resolver: zodResolver(addressSchema), resolver: zodResolver(addressSchema(t)),
values: loadedAddress ?? undefined, values: loadedAddress ?? undefined,
}); });
@@ -400,7 +419,7 @@ export function ProfilePage() {
profileId, profileId,
body: toAddressPayload(values), body: toAddressPayload(values),
}).unwrap(); }).unwrap();
notify.success('Address saved'); notify.success(t('profile.addressSaved'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -580,19 +599,19 @@ export function ProfilePage() {
> >
<Tabs.List> <Tabs.List>
<Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}> <Tabs.Tab value="personal" leftSection={<IconUserCircle size={18} />}>
Personal {t('profile.tabs.personal')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<IconUser size={18} />}> <Tabs.Tab value="profile" leftSection={<IconUser size={18} />}>
Profile {t('profile.tabs.profile')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}> <Tabs.Tab value="address" leftSection={<IconMapPin size={18} />}>
Address {t('profile.tabs.address')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab <Tabs.Tab
value="operations" value="operations"
leftSection={<IconBuildingWarehouse size={18} />} leftSection={<IconBuildingWarehouse size={18} />}
> >
Operations {t('profile.tabs.operations')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}> <Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')} {t('profile.tabs.security')}
@@ -687,15 +706,15 @@ export function ProfilePage() {
<Center py="xl"><Loader /></Center> <Center py="xl"><Loader /></Center>
) : !loadedProfile ? ( ) : !loadedProfile ? (
<Text c="dimmed" ta="center" py="xl"> <Text c="dimmed" ta="center" py="xl">
No profile found. Complete your profile setup first. {t('profile.maritimeSection.noProfile')}
</Text> </Text>
) : ( ) : (
<form onSubmit={handleProfileSubmit(onSaveProfile)}> <form onSubmit={handleProfileSubmit(onSaveProfile)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>Maritime Profile</Title> <Title order={5}>{t('profile.maritimeSection.title')}</Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
Your professional maritime details {t('profile.maritimeSection.subtitle')}
</Text> </Text>
<ProfileFormContent <ProfileFormContent
register={registerProfile} register={registerProfile}
@@ -714,7 +733,7 @@ export function ProfilePage() {
loading={isSavingMaritime} loading={isSavingMaritime}
leftSection={<IconDeviceFloppy size={18} />} leftSection={<IconDeviceFloppy size={18} />}
> >
Save Profile {t('profile.maritimeSection.save')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -732,9 +751,9 @@ export function ProfilePage() {
<form onSubmit={handleAddressSubmit(onSaveAddress)}> <form onSubmit={handleAddressSubmit(onSaveAddress)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>Address & Contact</Title> <Title order={5}>{t('profile.addressSection.title')}</Title>
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
Your identity documents, contact details and emergency contact {t('profile.addressSection.subtitle')}
</Text> </Text>
<AddressFormContent <AddressFormContent
register={registerAddress} register={registerAddress}
@@ -769,8 +788,9 @@ export function ProfilePage() {
{/* ---- Security ---- */} {/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md"> <Tabs.Panel value="security" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder> <Stack gap="lg">
<form onSubmit={handlePasswordSubmit(onChangePassword)}> <Paper p="xl" shadow="sm" radius="lg" withBorder>
<form onSubmit={handlePasswordSubmit(onChangePassword)}>
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Title order={5}>{t('profile.security')}</Title> <Title order={5}>{t('profile.security')}</Title>
@@ -845,6 +865,15 @@ export function ProfilePage() {
<Switch <Switch
checked={twoStepEnabled} checked={twoStepEnabled}
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)} 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> </Group>
@@ -858,8 +887,11 @@ export function ProfilePage() {
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</form> </form>
</Paper> </Paper>
<ActiveSessions />
</Stack>
</Tabs.Panel> </Tabs.Panel>
{/* ---- Preferences ---- */} {/* ---- Preferences ---- */}
@@ -888,7 +920,7 @@ export function ProfilePage() {
{t(`language.${lng}`)} {t(`language.${lng}`)}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{lng === 'en' ? 'English (United States)' : 'Amharic'} {t(`profile.languageFull.${lng}`)}
</Text> </Text>
</div> </div>
{active ? ( {active ? (

View File

@@ -1,26 +1,30 @@
import { ActionIcon, Group, Tooltip } from '@mantine/core'; import { ActionIcon, Group, Tooltip } from '@mantine/core';
import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react'; import { IconEdit, IconPaperclip, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
import { PORTAL_PERMISSIONS } from '@ema-platform/auth'; import { PORTAL_PERMISSIONS } from '@ema-platform/auth';
export function seaServiceActionsColumn(handlers: { export function seaServiceActionsColumn(
/** Permission check from usePermissions() — hooks can't run in a cell. */ t: TFunction,
can: (required?: string[]) => boolean; handlers: {
onEvidence: (record: SeaServiceRecord) => void; /** Permission check from usePermissions() — hooks can't run in a cell. */
onEdit: (record: SeaServiceRecord) => void; can: (required?: string[]) => boolean;
onDelete: (record: SeaServiceRecord) => void; onEvidence: (record: SeaServiceRecord) => void;
}): AdvancedColumn<SeaServiceRecord> { onEdit: (record: SeaServiceRecord) => void;
onDelete: (record: SeaServiceRecord) => void;
},
): AdvancedColumn<SeaServiceRecord> {
return { return {
header: '', header: '',
label: 'Actions', label: t('common.actions'),
align: 'right', align: 'right',
cell: ({ row }) => { cell: ({ row }) => {
const record = row.original; const record = row.original;
const locked = record.status !== 'SUBMITTED'; const locked = record.status !== 'SUBMITTED';
return ( return (
<Group gap="xs" justify="flex-end" wrap="nowrap"> <Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Evidence"> <Tooltip label={t('seaRecords.actions.evidence')}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
onClick={() => handlers.onEvidence(record)} onClick={() => handlers.onEvidence(record)}
@@ -30,7 +34,7 @@ export function seaServiceActionsColumn(handlers: {
</Tooltip> </Tooltip>
{handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && ( {handlers.can([PORTAL_PERMISSIONS.EDIT_SEA_SERVICE]) && (
<> <>
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}> <Tooltip label={locked ? t('seaRecords.actions.frozen') : t('seaRecords.actions.edit')}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
disabled={locked} disabled={locked}
@@ -39,7 +43,7 @@ export function seaServiceActionsColumn(handlers: {
<IconEdit size={16} /> <IconEdit size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}> <Tooltip label={locked ? t('seaRecords.actions.frozen') : t('seaRecords.actions.delete')}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"
@@ -57,23 +61,26 @@ export function seaServiceActionsColumn(handlers: {
}; };
} }
export function medicalActionsColumn(handlers: { export function medicalActionsColumn(
/** Permission check from usePermissions() — hooks can't run in a cell. */ t: TFunction,
can: (required?: string[]) => boolean; handlers: {
onEvidence: (certificate: MedicalCertificate) => void; /** Permission check from usePermissions() — hooks can't run in a cell. */
onEdit: (certificate: MedicalCertificate) => void; can: (required?: string[]) => boolean;
onDelete: (certificate: MedicalCertificate) => void; onEvidence: (certificate: MedicalCertificate) => void;
}): AdvancedColumn<MedicalCertificate> { onEdit: (certificate: MedicalCertificate) => void;
onDelete: (certificate: MedicalCertificate) => void;
},
): AdvancedColumn<MedicalCertificate> {
return { return {
header: '', header: '',
label: 'Actions', label: t('common.actions'),
align: 'right', align: 'right',
cell: ({ row }) => { cell: ({ row }) => {
const certificate = row.original; const certificate = row.original;
const locked = certificate.status !== 'SUBMITTED'; const locked = certificate.status !== 'SUBMITTED';
return ( return (
<Group gap="xs" justify="flex-end" wrap="nowrap"> <Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="Scan / evidence"> <Tooltip label={t('seaRecords.actions.scanEvidence')}>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
onClick={() => handlers.onEvidence(certificate)} onClick={() => handlers.onEvidence(certificate)}
@@ -83,7 +90,9 @@ export function medicalActionsColumn(handlers: {
</Tooltip> </Tooltip>
{handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && ( {handlers.can([PORTAL_PERMISSIONS.UPLOAD_MEDICAL]) && (
<> <>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}> <Tooltip
label={locked ? t('seaRecords.actions.certificatesFrozen') : t('seaRecords.actions.edit')}
>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
disabled={locked} disabled={locked}
@@ -92,7 +101,9 @@ export function medicalActionsColumn(handlers: {
<IconEdit size={16} /> <IconEdit size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}> <Tooltip
label={locked ? t('seaRecords.actions.certificatesFrozen') : t('seaRecords.actions.delete')}
>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="red" color="red"

View File

@@ -1,4 +1,5 @@
import { Badge, Group, Text, Tooltip } from '@mantine/core'; import { Badge, Group, Text, Tooltip } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui'; import type { AdvancedColumn } from '@ema-platform/ui';
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api'; import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
@@ -8,18 +9,24 @@ const RECORD_STATUS_COLORS: Record<string, string> = {
REJECTED: 'red', REJECTED: 'red',
}; };
export const FITNESS_OPTIONS = [ export function fitnessOptions(t: TFunction) {
{ value: 'FIT', label: 'Fit' }, return [
{ value: 'FIT_WITH_RESTRICTIONS', label: 'Fit with restrictions' }, { value: 'FIT', label: t('seaRecords.columns.fitnessOptions.FIT') },
{ value: 'UNFIT', label: 'Unfit' }, {
]; value: 'FIT_WITH_RESTRICTIONS',
label: t('seaRecords.columns.fitnessOptions.FIT_WITH_RESTRICTIONS'),
},
{ value: 'UNFIT', label: t('seaRecords.columns.fitnessOptions.UNFIT') },
];
}
export function seaServiceColumns( export function seaServiceColumns(
t: TFunction,
showDate: (date: string) => string, showDate: (date: string) => string,
): AdvancedColumn<SeaServiceRecord>[] { ): AdvancedColumn<SeaServiceRecord>[] {
return [ return [
{ {
header: 'Vessel', header: t('seaRecords.columns.vessel'),
cell: ({ row }) => ( cell: ({ row }) => (
<> <>
<Text fw={600} size="sm"> <Text fw={600} size="sm">
@@ -27,32 +34,34 @@ export function seaServiceColumns(
</Text> </Text>
{row.original.imoNumber && ( {row.original.imoNumber && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
IMO {row.original.imoNumber} {t('seaRecords.columns.imo', { number: row.original.imoNumber })}
</Text> </Text>
)} )}
</> </>
), ),
}, },
{ header: 'Rank', accessorKey: 'rank' }, { header: t('seaRecords.columns.rank'), accessorKey: 'rank' },
{ {
header: 'From', header: t('seaRecords.columns.from'),
accessorKey: 'engagementDate', accessorKey: 'engagementDate',
cell: ({ row }) => showDate(row.original.engagementDate), cell: ({ row }) => showDate(row.original.engagementDate),
}, },
{ {
header: 'To', header: t('seaRecords.columns.to'),
accessorKey: 'dischargeDate', accessorKey: 'dischargeDate',
cell: ({ row }) => showDate(row.original.dischargeDate), cell: ({ row }) => showDate(row.original.dischargeDate),
}, },
{ {
header: 'Status', header: t('common.status'),
cell: ({ row }) => ( cell: ({ row }) => (
<Tooltip <Tooltip
label={row.original.verificationRemark ?? ''} label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark} disabled={!row.original.verificationRemark}
> >
<Badge color={RECORD_STATUS_COLORS[row.original.status]}> <Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status} {t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge> </Badge>
</Tooltip> </Tooltip>
), ),
@@ -61,12 +70,14 @@ export function seaServiceColumns(
} }
export function medicalColumns( export function medicalColumns(
t: TFunction,
showDate: (date: string) => string, showDate: (date: string) => string,
): AdvancedColumn<MedicalCertificate>[] { ): AdvancedColumn<MedicalCertificate>[] {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const options = fitnessOptions(t);
return [ return [
{ {
header: 'Issuer', header: t('seaRecords.columns.issuer'),
cell: ({ row }) => ( cell: ({ row }) => (
<> <>
<Text fw={600} size="sm"> <Text fw={600} size="sm">
@@ -74,41 +85,45 @@ export function medicalColumns(
</Text> </Text>
{row.original.certificateNumber && ( {row.original.certificateNumber && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{row.original.certificateNumber} {t('seaRecords.columns.certNumber', { number: row.original.certificateNumber })}
</Text> </Text>
)} )}
</> </>
), ),
}, },
{ {
header: 'Issued', header: t('seaRecords.columns.issued'),
accessorKey: 'issueDate', accessorKey: 'issueDate',
cell: ({ row }) => showDate(row.original.issueDate), cell: ({ row }) => showDate(row.original.issueDate),
}, },
{ {
header: 'Expires', header: t('seaRecords.columns.expires'),
cell: ({ row }) => ( cell: ({ row }) => (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
{showDate(row.original.expiryDate)} {showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>} {row.original.expiryDate < today && (
<Badge color="red">{t('seaRecords.columns.expired')}</Badge>
)}
</Group> </Group>
), ),
}, },
{ {
header: 'Fitness', header: t('seaRecords.columns.fitness'),
cell: ({ row }) => cell: ({ row }) =>
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus) options.find((o) => o.value === row.original.fitnessStatus)?.label ??
?.label ?? row.original.fitnessStatus, row.original.fitnessStatus,
}, },
{ {
header: 'Status', header: t('common.status'),
cell: ({ row }) => ( cell: ({ row }) => (
<Tooltip <Tooltip
label={row.original.verificationRemark ?? ''} label={row.original.verificationRemark ?? ''}
disabled={!row.original.verificationRemark} disabled={!row.original.verificationRemark}
> >
<Badge color={RECORD_STATUS_COLORS[row.original.status]}> <Badge color={RECORD_STATUS_COLORS[row.original.status]}>
{row.original.status} {t(`seaRecords.columns.recordStatus.${row.original.status}`, {
defaultValue: row.original.status,
})}
</Badge> </Badge>
</Tooltip> </Tooltip>
), ),

View File

@@ -27,7 +27,14 @@ import {
IconStethoscope, IconStethoscope,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState } from 'react'; import { useState } from 'react';
import { AdvancedTable, AmharicDatePicker, notify, useServerTable } from '@ema-platform/ui'; import { useTranslation } from 'react-i18next';
import {
AdvancedTable,
AmharicDatePicker,
notify,
PdfPreviewModal,
useServerTable,
} from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared'; import { useDateDisplayer } from '@ema-platform/shared';
import { import {
extractErrorMessage, extractErrorMessage,
@@ -49,7 +56,7 @@ import {
RequirePermission, RequirePermission,
usePermissions, usePermissions,
} from '@ema-platform/auth'; } from '@ema-platform/auth';
import { seaServiceColumns, medicalColumns, FITNESS_OPTIONS } from './columns'; import { seaServiceColumns, medicalColumns, fitnessOptions } from './columns';
import { seaServiceActionsColumn, medicalActionsColumn } from './actions'; import { seaServiceActionsColumn, medicalActionsColumn } from './actions';
/** /**
@@ -68,47 +75,39 @@ function EvidenceModal({
ownerId: string | null; ownerId: string | null;
onClose: () => void; onClose: () => void;
}) { }) {
const { data: attachments, refetch, isLoading } = useGetAttachmentsQuery( const { t } = useTranslation();
const { data: attachments, isLoading } = useGetAttachmentsQuery(
{ ownerType, ownerId: ownerId ?? '' }, { ownerType, ownerId: ownerId ?? '' },
{ skip: !ownerId }, { skip: !ownerId },
); );
const [uploading, setUploading] = useState(false); const [preview, setPreview] = useState<{ url: string; title: string } | null>(
null,
const upload = async (file: File | null) => { );
if (!file || !ownerId) return;
setUploading(true);
const result = await uploadDocument({
ownerType,
ownerId,
documentKey: 'evidence',
file,
});
setUploading(false);
if (result.ok) {
notify.success('Evidence uploaded');
refetch();
} else {
notify.error(result.error);
}
};
const files = (attachments ?? []).flatMap((a) => a.files); const files = (attachments ?? []).flatMap((a) => a.files);
return ( return (
<Modal opened={Boolean(ownerId)} onClose={onClose} title="Evidence" centered> <Modal opened={Boolean(ownerId)} onClose={onClose} title={t('seaRecords.evidence.title')} centered>
<Stack> <Stack>
{isLoading ? ( {isLoading ? (
<Loader size="sm" /> <Loader size="sm" type="oval" />
) : files.length === 0 ? ( ) : files.length === 0 ? (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No evidence uploaded yet. {t('seaRecords.evidence.none')}
</Text> </Text>
) : ( ) : (
files.map((file) => ( files.map((file) => (
<Group key={file.id} gap="xs"> <Group key={file.id} gap="xs">
<IconPaperclip size={16} /> <IconPaperclip size={16} />
{file.url ? ( {file.url ? (
<Anchor href={file.url} target="_blank" size="sm"> <Anchor
component="button"
type="button"
size="sm"
onClick={() =>
setPreview({ url: file.url as string, title: file.originalName })
}
>
{file.originalName} {file.originalName}
</Anchor> </Anchor>
) : ( ) : (
@@ -117,25 +116,52 @@ function EvidenceModal({
</Group> </Group>
)) ))
)} )}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<FileButton onChange={upload} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
loading={uploading}
leftSection={<IconFileUpload size={16} />}
>
Upload evidence
</Button>
)}
</FileButton>
</RequirePermission>
</Stack> </Stack>
<PdfPreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
/>
</Modal> </Modal>
); );
} }
/**
* Evidence picker that lives inside the add/edit form. The file is held in
* component state and uploaded right after the record is saved, because the
* attachment needs an owner id that only exists once the record does.
*/
function EvidenceField({
file,
onChange,
}: {
file: File | null;
onChange: (file: File | null) => void;
}) {
const { t } = useTranslation();
return (
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<Group gap="sm" align="center">
<FileButton onChange={onChange} accept="image/*,application/pdf">
{(props) => (
<Button
{...props}
variant="light"
leftSection={<IconFileUpload size={16} />}
>
{t('seaRecords.evidence.upload')}
</Button>
)}
</FileButton>
<Text size="sm" c={file ? undefined : 'dimmed'}>
{file ? file.name : t('seaRecords.evidence.none')}
</Text>
</Group>
</RequirePermission>
);
}
// ---------------------------------------------------------------- sea service // ---------------------------------------------------------------- sea service
const EMPTY_SEA_SERVICE = { const EMPTY_SEA_SERVICE = {
@@ -150,6 +176,7 @@ const EMPTY_SEA_SERVICE = {
}; };
function SeaServiceTab() { function SeaServiceTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const { can } = usePermissions(); const { can } = usePermissions();
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery(); const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
@@ -167,6 +194,7 @@ function SeaServiceTab() {
const [grossTonnage, setGrossTonnage] = useState<number | ''>(''); const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
const [evidenceFile, setEvidenceFile] = useState<File | null>(null); const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
const [uploadingEvidence, setUploadingEvidence] = useState(false); const [uploadingEvidence, setUploadingEvidence] = useState(false);
const [uploading, setUploading] = useState(false);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
@@ -211,7 +239,6 @@ function SeaServiceTab() {
let recordId = editing?.id; let recordId = editing?.id;
if (editing) { if (editing) {
await updateRecord({ id: editing.id, body }).unwrap(); await updateRecord({ id: editing.id, body }).unwrap();
notify.success('Sea-service record updated');
} else { } else {
const created = await createRecord(body).unwrap(); const created = await createRecord(body).unwrap();
recordId = created.id; recordId = created.id;
@@ -236,16 +263,16 @@ function SeaServiceTab() {
setModalOpen(false); setModalOpen(false);
} catch (error) { } catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the record')); notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
} }
}; };
const remove = async (record: SeaServiceRecord) => { const remove = async (record: SeaServiceRecord) => {
try { try {
await deleteRecord(record.id).unwrap(); await deleteRecord(record.id).unwrap();
notify.success('Record withdrawn'); notify.success(t('seaRecords.seaService.withdrawn'));
} catch (error) { } catch (error) {
notify.error(extractErrorMessage(error, 'Could not delete the record')); notify.error(extractErrorMessage(error, t('seaRecords.seaService.deleteFailed')));
} }
}; };
@@ -260,8 +287,8 @@ function SeaServiceTab() {
const page = paginate(records ?? []); const page = paginate(records ?? []);
const columns = [ const columns = [
...seaServiceColumns(showDate), ...seaServiceColumns(t, showDate),
seaServiceActionsColumn({ seaServiceActionsColumn(t, {
can, can,
onEvidence: (record) => setEvidenceFor(record.id), onEvidence: (record) => setEvidenceFor(record.id),
onEdit: openEdit, onEdit: openEdit,
@@ -274,25 +301,24 @@ function SeaServiceTab() {
<Group justify="space-between"> <Group justify="space-between">
<Group gap="sm"> <Group gap="sm">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Every engagement aboard a vessel, with its evidence. Verified {t('seaRecords.seaService.description')}
records feed certificate eligibility.
</Text> </Text>
{seaTime && seaTime.verifiedRecords > 0 && ( {seaTime && seaTime.verifiedRecords > 0 && (
<Badge variant="light" color="teal"> <Badge variant="light" color="teal">
Approved sea time: {seaTime.totalDays} days {t('seaRecords.seaService.approvedSeaTime', { days: seaTime.totalDays })}
</Badge> </Badge>
)} )}
</Group> </Group>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly> <RequirePermission anyOf={[PORTAL_PERMISSIONS.ADD_SEA_SERVICE]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}> <Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add sea service {t('seaRecords.seaService.add')}
</Button> </Button>
</RequirePermission> </RequirePermission>
</Group> </Group>
{(records ?? []).length === 0 ? ( {(records ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md"> <Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center"> <Text c="dimmed" ta="center">
No sea-service records yet. {t('seaRecords.seaService.empty')}
</Text> </Text>
</Paper> </Paper>
) : ( ) : (
@@ -300,7 +326,7 @@ function SeaServiceTab() {
<AdvancedTable <AdvancedTable
columns={columns} columns={columns}
data={page.rows} data={page.rows}
tableName="Sea service" tableName={t('seaRecords.seaService.tableName')}
itemCount={page.itemCount} itemCount={page.itemCount}
pageIndex={page.pageIndex} pageIndex={page.pageIndex}
onPageChange={setPageIndex} onPageChange={setPageIndex}
@@ -315,51 +341,51 @@ function SeaServiceTab() {
<Modal <Modal
opened={modalOpen} opened={modalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
title={editing ? 'Edit sea service' : 'Add sea service'} title={editing ? t('seaRecords.seaService.modal.editTitle') : t('seaRecords.seaService.modal.addTitle')}
centered centered
size="lg" size="lg"
> >
<Stack> <Stack>
<Group grow> <Group grow>
<TextInput <TextInput
label="Vessel name" label={t('seaRecords.seaService.fields.vesselName')}
required required
value={form.vesselName} value={form.vesselName}
onChange={(e) => setForm({ ...form, vesselName: e.target.value })} onChange={(e) => setForm({ ...form, vesselName: e.target.value })}
/> />
<TextInput <TextInput
label="IMO number" label={t('seaRecords.seaService.fields.imoNumber')}
value={form.imoNumber} value={form.imoNumber}
onChange={(e) => setForm({ ...form, imoNumber: e.target.value })} onChange={(e) => setForm({ ...form, imoNumber: e.target.value })}
/> />
</Group> </Group>
<Group grow> <Group grow>
<TextInput <TextInput
label="Vessel type" label={t('seaRecords.seaService.fields.vesselType')}
value={form.vesselType} value={form.vesselType}
onChange={(e) => setForm({ ...form, vesselType: e.target.value })} onChange={(e) => setForm({ ...form, vesselType: e.target.value })}
/> />
<TextInput <TextInput
label="Flag state" label={t('seaRecords.seaService.fields.flagState')}
value={form.flagState} value={form.flagState}
onChange={(e) => setForm({ ...form, flagState: e.target.value })} onChange={(e) => setForm({ ...form, flagState: e.target.value })}
/> />
<NumberInput <NumberInput
label="Gross tonnage" label={t('seaRecords.seaService.fields.grossTonnage')}
min={0} min={0}
value={grossTonnage} value={grossTonnage}
onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')} onChange={(v) => setGrossTonnage(typeof v === 'number' ? v : '')}
/> />
</Group> </Group>
<TextInput <TextInput
label="Rank / capacity" label={t('seaRecords.seaService.fields.rank')}
required required
value={form.rank} value={form.rank}
onChange={(e) => setForm({ ...form, rank: e.target.value })} onChange={(e) => setForm({ ...form, rank: e.target.value })}
/> />
<Group grow> <Group grow>
<AmharicDatePicker <AmharicDatePicker
label="Engagement date" label={t('seaRecords.seaService.fields.engagementDate')}
required required
value={form.engagementDate} value={form.engagementDate}
onChange={(val) => onChange={(val) =>
@@ -368,7 +394,7 @@ function SeaServiceTab() {
dateFormat="date" dateFormat="date"
/> />
<AmharicDatePicker <AmharicDatePicker
label="Discharge date" label={t('seaRecords.seaService.fields.dischargeDate')}
required required
value={form.dischargeDate} value={form.dischargeDate}
onChange={(val) => onChange={(val) =>
@@ -378,7 +404,7 @@ function SeaServiceTab() {
/> />
</Group> </Group>
<Textarea <Textarea
label="Duties" label={t('seaRecords.seaService.fields.duties')}
value={form.dutiesDescription} value={form.dutiesDescription}
onChange={(e) => onChange={(e) =>
setForm({ ...form, dutiesDescription: e.target.value }) setForm({ ...form, dutiesDescription: e.target.value })
@@ -397,14 +423,14 @@ function SeaServiceTab() {
</FileButton> </FileButton>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}> <Button variant="default" onClick={() => setModalOpen(false)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={save} onClick={save}
disabled={!valid} disabled={!valid}
loading={creating || updating || uploadingEvidence} loading={creating || updating || uploadingEvidence}
> >
{editing ? 'Save changes' : 'Add record'} {editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -431,6 +457,7 @@ const EMPTY_MEDICAL = {
}; };
function MedicalTab() { function MedicalTab() {
const { t } = useTranslation();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const { can } = usePermissions(); const { can } = usePermissions();
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery(); const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
@@ -483,7 +510,6 @@ function MedicalTab() {
let certificateId = editing?.id; let certificateId = editing?.id;
if (editing) { if (editing) {
await updateCertificate({ id: editing.id, body }).unwrap(); await updateCertificate({ id: editing.id, body }).unwrap();
notify.success('Medical certificate updated');
} else { } else {
const created = await createCertificate(body).unwrap(); const created = await createCertificate(body).unwrap();
certificateId = created.id; certificateId = created.id;
@@ -508,18 +534,16 @@ function MedicalTab() {
setModalOpen(false); setModalOpen(false);
} catch (error) { } catch (error) {
notify.error(extractErrorMessage(error, 'Could not save the certificate')); notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
} }
}; };
const remove = async (certificate: MedicalCertificate) => { const remove = async (certificate: MedicalCertificate) => {
try { try {
await deleteCertificate(certificate.id).unwrap(); await deleteCertificate(certificate.id).unwrap();
notify.success('Certificate withdrawn'); notify.success(t('seaRecords.medical.withdrawn'));
} catch (error) { } catch (error) {
notify.error( notify.error(extractErrorMessage(error, t('seaRecords.medical.deleteFailed')));
extractErrorMessage(error, 'Could not delete the certificate'),
);
} }
}; };
@@ -533,8 +557,8 @@ function MedicalTab() {
const page = paginate(certificates ?? []); const page = paginate(certificates ?? []);
const columns = [ const columns = [
...medicalColumns(showDate), ...medicalColumns(t, showDate),
medicalActionsColumn({ medicalActionsColumn(t, {
can, can,
onEvidence: (certificate) => setEvidenceFor(certificate.id), onEvidence: (certificate) => setEvidenceFor(certificate.id),
onEdit: openEdit, onEdit: openEdit,
@@ -546,19 +570,18 @@ function MedicalTab() {
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
STCW medical fitness certificates. An expired certificate blocks new {t('seaRecords.medical.description')}
applications that require one.
</Text> </Text>
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly> <RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_MEDICAL]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}> <Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
Add certificate {t('seaRecords.medical.add')}
</Button> </Button>
</RequirePermission> </RequirePermission>
</Group> </Group>
{(certificates ?? []).length === 0 ? ( {(certificates ?? []).length === 0 ? (
<Paper withBorder p="xl" radius="md"> <Paper withBorder p="xl" radius="md">
<Text c="dimmed" ta="center"> <Text c="dimmed" ta="center">
No medical certificates yet. {t('seaRecords.medical.empty')}
</Text> </Text>
</Paper> </Paper>
) : ( ) : (
@@ -566,7 +589,7 @@ function MedicalTab() {
<AdvancedTable <AdvancedTable
columns={columns} columns={columns}
data={page.rows} data={page.rows}
tableName="Medical certificates" tableName={t('seaRecords.medical.tableName')}
itemCount={page.itemCount} itemCount={page.itemCount}
pageIndex={page.pageIndex} pageIndex={page.pageIndex}
onPageChange={setPageIndex} onPageChange={setPageIndex}
@@ -581,20 +604,20 @@ function MedicalTab() {
<Modal <Modal
opened={modalOpen} opened={modalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
title={editing ? 'Edit medical certificate' : 'Add medical certificate'} title={editing ? t('seaRecords.medical.modal.editTitle') : t('seaRecords.medical.modal.addTitle')}
centered centered
size="lg" size="lg"
> >
<Stack> <Stack>
<Group grow> <Group grow>
<TextInput <TextInput
label="Issuing clinic / physician" label={t('seaRecords.medical.fields.issuerName')}
required required
value={form.issuerName} value={form.issuerName}
onChange={(e) => setForm({ ...form, issuerName: e.target.value })} onChange={(e) => setForm({ ...form, issuerName: e.target.value })}
/> />
<TextInput <TextInput
label="Certificate number" label={t('seaRecords.medical.fields.certificateNumber')}
value={form.certificateNumber} value={form.certificateNumber}
onChange={(e) => onChange={(e) =>
setForm({ ...form, certificateNumber: e.target.value }) setForm({ ...form, certificateNumber: e.target.value })
@@ -603,14 +626,14 @@ function MedicalTab() {
</Group> </Group>
<Group grow> <Group grow>
<AmharicDatePicker <AmharicDatePicker
label="Issue date" label={t('seaRecords.medical.fields.issueDate')}
required required
value={form.issueDate} value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })} onChange={(val) => setForm({ ...form, issueDate: val })}
dateFormat="date" dateFormat="date"
/> />
<AmharicDatePicker <AmharicDatePicker
label="Expiry date" label={t('seaRecords.medical.fields.expiryDate')}
required required
value={form.expiryDate} value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })} onChange={(val) => setForm({ ...form, expiryDate: val })}
@@ -618,14 +641,14 @@ function MedicalTab() {
/> />
</Group> </Group>
<Select <Select
label="Fitness outcome" label={t('seaRecords.medical.fields.fitnessOutcome')}
data={FITNESS_OPTIONS} data={fitnessOptions(t)}
value={form.fitnessStatus} value={form.fitnessStatus}
onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })} onChange={(v) => setForm({ ...form, fitnessStatus: v ?? 'FIT' })}
/> />
{form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && ( {form.fitnessStatus === 'FIT_WITH_RESTRICTIONS' && (
<Textarea <Textarea
label="Restrictions" label={t('seaRecords.medical.fields.restrictions')}
value={form.restrictions} value={form.restrictions}
onChange={(e) => onChange={(e) =>
setForm({ ...form, restrictions: e.target.value }) setForm({ ...form, restrictions: e.target.value })
@@ -645,14 +668,14 @@ function MedicalTab() {
</FileButton> </FileButton>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}> <Button variant="default" onClick={() => setModalOpen(false)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
onClick={save} onClick={save}
disabled={!valid} disabled={!valid}
loading={creating || updating || uploadingEvidence} loading={creating || updating || uploadingEvidence}
> >
{editing ? 'Save changes' : 'Add certificate'} {editing ? t('common.save') : t('seaRecords.medical.add')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -673,24 +696,24 @@ function MedicalTab() {
* officer verifies them. * officer verifies them.
*/ */
export function MySeaRecordsPage() { export function MySeaRecordsPage() {
const { t } = useTranslation();
return ( return (
<Stack> <Stack>
<Title order={2}>My Sea Records</Title> <Title order={2}>{t('seaRecords.title')}</Title>
<Alert <Alert
variant="light" variant="light"
color="blue" color="blue"
icon={<IconInfoCircle size={16} />} icon={<IconInfoCircle size={16} />}
> >
Records you add here are submitted for EMA verification. Once verified {t('seaRecords.pageIntro')}
they are frozen and count toward certificate eligibility.
</Alert> </Alert>
<Tabs defaultValue="sea-service" keepMounted={false}> <Tabs defaultValue="sea-service" keepMounted={false}>
<Tabs.List> <Tabs.List>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}> <Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service {t('seaRecords.tabs.seaService')}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}> <Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical Certificates {t('seaRecords.tabs.medical')}
</Tabs.Tab> </Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="sea-service" pt="md"> <Tabs.Panel value="sea-service" pt="md">

View File

@@ -13,8 +13,6 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAnchor, IconAnchor,
IconCheck,
IconClock,
IconInfoCircle, IconInfoCircle,
IconShip, IconShip,
} from '@tabler/icons-react'; } from '@tabler/icons-react';

View File

@@ -5,7 +5,6 @@ import {
Anchor, Anchor,
Box, Box,
Button, Button,
Center,
Divider, Divider,
Group, Group,
Paper, Paper,

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Anchor,
Box, Box,
Button, Button,
Divider, Divider,

View File

@@ -0,0 +1,76 @@
import { Badge, Button, Group, Progress, Text } from '@mantine/core';
import { IconArrowRight } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
STATUS_COLORS,
STATUS_LABELS,
STATUS_PROGRESS,
type LicenseApplication,
} from '@ema-platform/api';
/** Columns for the applicant's in-flight vessel registration applications. */
export function inFlightColumns(
t: TFunction,
deps: { onOpen: (app: LicenseApplication) => void },
): AdvancedColumn<LicenseApplication>[] {
return [
{
header: t('applications.table.applicationNumber'),
cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Text fw={600} fz="sm">{row.original.applicationNumber}</Text>
{row.original.kind === 'RENEWAL' && (
<Badge size="sm" variant="light">
{t('vesselRegistration.inFlight.renewalBadge')}
</Badge>
)}
</Group>
),
},
{
header: t('common.status'),
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
{STATUS_LABELS[row.original.status]}
</Badge>
),
},
{
header: t('applications.table.progress'),
size: 140,
cell: ({ row }) => (
<Progress
value={STATUS_PROGRESS[row.original.status]}
color={STATUS_COLORS[row.original.status]}
size="sm"
radius="xl"
/>
),
},
{
header: '',
label: t('common.actions'),
align: 'right',
cell: ({ row }) => {
const app = row.original;
const needsAction = app.status === 'RESUBMIT_REQUIRED';
return (
<Button
size="compact-sm"
variant={needsAction ? 'filled' : 'light'}
color={needsAction ? 'orange' : undefined}
rightSection={<IconArrowRight size={14} />}
onClick={() => deps.onOpen(app)}
>
{app.status === 'DRAFT'
? t('common.continue')
: needsAction
? t('vesselRegistration.inFlight.fix')
: t('vesselRegistration.inFlight.view')}
</Button>
);
},
},
];
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
Badge, Badge,
@@ -13,14 +14,12 @@ import {
Text, Text,
ThemeIcon, ThemeIcon,
Title, Title,
rem,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconAnchor, IconAnchor,
IconCheck, IconCheck,
IconCircleCheck, IconCircleCheck,
IconAlertCircle, IconAlertCircle,
IconFileDescription,
IconShieldCheck, IconShieldCheck,
IconCertificate, IconCertificate,
IconDownload, IconDownload,
@@ -28,12 +27,23 @@ import {
IconClockHour4, IconClockHour4,
IconTransferIn, IconTransferIn,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api'; import { AdvancedTable } from '@ema-platform/ui';
import { inFlightColumns } from '../inFlightColumns';
import {
TERMINAL_STATUSES,
useApiMutation,
useGetMyApplicationsQuery,
} from '@ema-platform/api';
import { authStorage } from '@ema-platform/auth'; import { authStorage } from '@ema-platform/auth';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Registration applications run through the config-driven licensing wizard. */
const REGISTRATION_TYPE_KEY = 'VESSEL_REGISTRATION';
const PAGE_SIZE = 5;
type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required'; type VesselRegStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected' | 'Correction Required';
type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)'; type VesselCategory = 'Inland Waterway Vessel' | 'Sea-going Vessel (International)';
type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable'; type RenewalStatus = 'Valid' | 'Due Soon' | 'Overdue' | 'Not Applicable';
@@ -120,6 +130,9 @@ function CertificateCard({ label, description }: { label: string; description: s
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function VesselRegistrationPage() { export function VesselRegistrationPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const { data: applications, isFetching, refetch } = useGetMyApplicationsQuery();
const [page, setPage] = useState(0);
const [registration, setRegistration] = useState<VesselRegistration | null>(null); const [registration, setRegistration] = useState<VesselRegistration | null>(null);
const [fetchTrigger] = useApiMutation<VesselRegistration>(); const [fetchTrigger] = useApiMutation<VesselRegistration>();
const fetched = useRef(false); const fetched = useRef(false);
@@ -134,6 +147,19 @@ export function VesselRegistrationPage() {
.catch(() => {/* no registration yet */}); .catch(() => {/* no registration yet */});
}, [fetchTrigger]); }, [fetchTrigger]);
// Drafts and anything still moving through review — the applicant's own
// registration applications, straight from the licensing queue.
const inFlight = (applications?.items ?? []).filter(
(app) =>
app.licenseType?.key === REGISTRATION_TYPE_KEY &&
!TERMINAL_STATUSES.includes(app.status),
);
const columns = inFlightColumns(t, {
onOpen: (app) =>
navigate(`/licensing/${REGISTRATION_TYPE_KEY}/applications/${app.id}`),
});
const certs = registration?.category === 'Sea-going Vessel (International)' const certs = registration?.category === 'Sea-going Vessel (International)'
? SEAGOING_CERTIFICATES ? SEAGOING_CERTIFICATES
: INLAND_CERTIFICATES; : INLAND_CERTIFICATES;
@@ -150,6 +176,25 @@ export function VesselRegistrationPage() {
</div> </div>
</Group> </Group>
{/* ── Drafts / submitted applications ───────────────────────────── */}
{inFlight.length > 0 && (
<Stack gap="sm">
<Text fw={700} fz="md">{t('vesselRegistration.inFlight.title')}</Text>
<AdvancedTable
tableName="vessel-registration-in-flight"
columns={columns}
// Client-side paging: getMyApplications returns the whole list.
data={inFlight.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)}
itemCount={inFlight.length}
pageIndex={page}
pageSize={PAGE_SIZE}
refresh={refetch}
isLoading={isFetching}
onPageChange={setPage}
/>
</Stack>
)}
{/* ── No registration yet ───────────────────────────────────────── */} {/* ── No registration yet ───────────────────────────────────────── */}
{!registration && ( {!registration && (
<> <>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
Badge, Badge,
@@ -7,7 +8,6 @@ import {
Divider, Divider,
Group, Group,
Paper, Paper,
SimpleGrid,
Stack, Stack,
Stepper, Stepper,
Text, Text,
@@ -38,6 +38,7 @@ function downloadCertificate(filename: string) {
} }
export function VesselRegistrationStatusPage() { export function VesselRegistrationStatusPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const showDate = useDateDisplayer(); const showDate = useDateDisplayer();
const { id } = useParams(); const { id } = useParams();
@@ -47,26 +48,30 @@ export function VesselRegistrationStatusPage() {
if (!reg) { if (!reg) {
return ( return (
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button> <Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>{t('common.back')}</Button>
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>Registration not found.</Alert> <Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>{t('vesselRegistration.status.notFound')}</Alert>
</Stack> </Stack>
); );
} }
const activeStep = reg.timeline.filter((t) => t.done).length - 1; const activeStep = reg.timeline.filter((step) => step.done).length - 1;
const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected'; const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected';
const handleDownload = (certName: string, certNumber: string) => { const handleDownload = (certName: string, certNumber: string) => {
downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`); downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`);
recordDownload(reg.id, certName); recordDownload(reg.id, certName);
forceUpdate((n) => n + 1); forceUpdate((n) => n + 1);
notify.success(`${certName} downloaded.`); notify.success(t('vesselRegistration.status.downloadedNotify', { certName }));
}; };
const renewalKey = reg.renewal === 'Overdue'
? (reg.expiryDate ? 'vesselRegistration.status.renewalOverdueWithExpiry' : 'vesselRegistration.status.renewalOverdue')
: (reg.expiryDate ? 'vesselRegistration.status.renewalDueSoonWithExpiry' : 'vesselRegistration.status.renewalDueSoon');
return ( return (
<Stack gap="md"> <Stack gap="md">
<Group gap="sm"> <Group gap="sm">
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button> <Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>{t('common.back')}</Button>
<div> <div>
<Title order={3}>{reg.vesselName}</Title> <Title order={3}>{reg.vesselName}</Title>
<Text fz="sm" c="dimmed">{reg.id} {reg.category}</Text> <Text fz="sm" c="dimmed">{reg.id} {reg.category}</Text>
@@ -78,8 +83,8 @@ export function VesselRegistrationStatusPage() {
<Group gap="sm"> <Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon> <ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
<div> <div>
<Text fw={700} fz="sm">Registration Status</Text> <Text fw={700} fz="sm">{t('vesselRegistration.status.title')}</Text>
<Text fz="xs" c="dimmed">Submitted {reg.submitted}</Text> <Text fz="xs" c="dimmed">{t('vesselRegistration.status.submitted', { date: reg.submitted })}</Text>
</div> </div>
</Group> </Group>
<Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge> <Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge>
@@ -94,15 +99,14 @@ export function VesselRegistrationStatusPage() {
p="sm" p="sm"
> >
<Text fz="sm"> <Text fz="sm">
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'} {t(renewalKey, { date: reg.expiryDate ? showDate(reg.expiryDate) : undefined })}
{reg.expiryDate ? ` — expires ${showDate(reg.expiryDate)}` : ''}.
</Text> </Text>
</Alert> </Alert>
)} )}
{reg.remarks && ( {reg.remarks && (
<Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm"> <Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm">
<Text fz="sm" fw={600} mb={2}>Officer Remarks</Text> <Text fz="sm" fw={600} mb={2}>{t('vesselRegistration.status.officerRemarks')}</Text>
<Text fz="sm">{reg.remarks}</Text> <Text fz="sm">{reg.remarks}</Text>
</Alert> </Alert>
)} )}
@@ -112,7 +116,7 @@ export function VesselRegistrationStatusPage() {
<Stepper.Step <Stepper.Step
key={i} key={i}
label={step.event} label={step.event}
description={step.date ?? 'Pending'} description={step.date ?? t('vesselRegistration.status.pending')}
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />} icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
/> />
))} ))}
@@ -120,23 +124,23 @@ export function VesselRegistrationStatusPage() {
{needsCorrection && ( {needsCorrection && (
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>Resubmit Application</Button> <Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>{t('vesselRegistration.status.resubmit')}</Button>
</Group> </Group>
)} )}
</Paper> </Paper>
{reg.status === 'Approved' && reg.certificates && ( {reg.status === 'Approved' && reg.certificates && (
<Paper withBorder radius="lg" p="xl"> <Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="md">Certificates</Text> <Text fw={700} mb="md">{t('vesselRegistration.status.certificatesTitle')}</Text>
<Stack gap="sm"> <Stack gap="sm">
{reg.certificates.map((cert) => ( {reg.certificates.map((cert) => (
<div key={cert.name}> <div key={cert.name}>
<Group justify="space-between" wrap="wrap" gap="sm"> <Group justify="space-between" wrap="wrap" gap="sm">
<div> <div>
<Text fw={600} fz="sm">{cert.name}</Text> <Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">Certificate No. {cert.number} Issued {showDate(cert.issueDate)}</Text> <Text fz="xs" c="dimmed">{t('vesselRegistration.status.certificateNumber', { number: cert.number, date: showDate(cert.issueDate) })}</Text>
{cert.downloads > 0 && ( {cert.downloads > 0 && (
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text> <Text fz="xs" c="dimmed">{t('vesselRegistration.status.downloadedCount', { count: cert.downloads })}</Text>
)} )}
</div> </div>
<Button <Button
@@ -144,7 +148,7 @@ export function VesselRegistrationStatusPage() {
leftSection={<IconDownload size={14} />} leftSection={<IconDownload size={14} />}
onClick={() => handleDownload(cert.name, cert.number)} onClick={() => handleDownload(cert.name, cert.number)}
> >
Download {t('common.download')}
</Button> </Button>
</Group> </Group>
<Divider mt="sm" /> <Divider mt="sm" />

View File

@@ -1,4 +1,5 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
Badge, Badge,
Button, Button,
@@ -29,11 +30,6 @@ import {
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER'; const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
const CATEGORY_LABELS: Record<string, string> = {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
};
const VESSEL_STATUS_COLORS: Record<string, string> = { const VESSEL_STATUS_COLORS: Record<string, string> = {
REGISTERED: 'green', REGISTERED: 'green',
SUSPENDED: 'orange', SUSPENDED: 'orange',
@@ -48,11 +44,17 @@ const VESSEL_STATUS_COLORS: Record<string, string> = {
* certificate/renewal/incident actions, which don't apply here. * certificate/renewal/incident actions, which don't apply here.
*/ */
export function VesselTransferPage() { export function VesselTransferPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery(); const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
const { data: applications, isLoading: loadingApplications } = const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery(); useGetMyApplicationsQuery();
const categoryLabels: Record<string, string> = {
INLAND_WATERWAY: t('vesselTransfer.table.categories.INLAND_WATERWAY'),
SEA_GOING: t('vesselTransfer.table.categories.SEA_GOING'),
};
const inFlight = (applications?.items ?? []).filter( const inFlight = (applications?.items ?? []).filter(
(app) => (app) =>
app.licenseType?.key === TRANSFER_TYPE_KEY && app.licenseType?.key === TRANSFER_TYPE_KEY &&
@@ -76,9 +78,9 @@ export function VesselTransferPage() {
return ( return (
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Title order={2}>Ownership Transfer</Title> <Title order={2}>{t('vesselTransfer.title')}</Title>
<Tooltip <Tooltip
label="Register a vessel first — there's nothing to transfer yet" label={t('vesselTransfer.startTransferDisabledTooltip')}
disabled={hasTransferableVessel} disabled={hasTransferableVessel}
> >
<Button <Button
@@ -86,7 +88,7 @@ export function VesselTransferPage() {
disabled={!hasTransferableVessel} disabled={!hasTransferableVessel}
onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)} onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)}
> >
Start transfer {t('vesselTransfer.startTransfer')}
</Button> </Button>
</Tooltip> </Tooltip>
</Group> </Group>
@@ -94,7 +96,7 @@ export function VesselTransferPage() {
{/* ----------------------------------------------------- in-flight */} {/* ----------------------------------------------------- in-flight */}
{inFlight.length > 0 && ( {inFlight.length > 0 && (
<Stack gap="sm"> <Stack gap="sm">
<Title order={4}>Transfers in progress</Title> <Title order={4}>{t('vesselTransfer.inFlight.title')}</Title>
{inFlight.map((app) => { {inFlight.map((app) => {
const isDraft = app.status === 'DRAFT'; const isDraft = app.status === 'DRAFT';
const needsAction = app.status === 'RESUBMIT_REQUIRED'; const needsAction = app.status === 'RESUBMIT_REQUIRED';
@@ -124,7 +126,11 @@ export function VesselTransferPage() {
) )
} }
> >
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'} {isDraft
? t('common.continue')
: needsAction
? t('vesselTransfer.inFlight.fix')
: t('vesselTransfer.inFlight.view')}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -136,22 +142,21 @@ export function VesselTransferPage() {
{/* ------------------------------------------------------- vessels */} {/* ------------------------------------------------------- vessels */}
<Stack gap="sm"> <Stack gap="sm">
<Title order={4}>My vessels</Title> <Title order={4}>{t('vesselTransfer.myVessels.title')}</Title>
{(vessels ?? []).length === 0 ? ( {(vessels ?? []).length === 0 ? (
<Paper withBorder radius="md" p="xl"> <Paper withBorder radius="md" p="xl">
<Stack align="center" gap="sm"> <Stack align="center" gap="sm">
<IconShip size={40} color="var(--mantine-color-blue-5)" /> <IconShip size={40} color="var(--mantine-color-blue-5)" />
<Text fw={600}>No registered vessels yet</Text> <Text fw={600}>{t('vesselTransfer.myVessels.empty.title')}</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}> <Text size="sm" c="dimmed" ta="center" maw={420}>
Ownership can only be transferred for a vessel already on the {t('vesselTransfer.myVessels.empty.body')}
register.
</Text> </Text>
<Button <Button
mt="xs" mt="xs"
variant="light" variant="light"
onClick={() => navigate('/vessel-registration')} onClick={() => navigate('/vessel-registration')}
> >
Go to Vessel Registration {t('vesselTransfer.myVessels.empty.cta')}
</Button> </Button>
</Stack> </Stack>
</Paper> </Paper>
@@ -160,10 +165,10 @@ export function VesselTransferPage() {
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Registration </Table.Th> <Table.Th>{t('vesselTransfer.table.registrationNumber')}</Table.Th>
<Table.Th>Vessel</Table.Th> <Table.Th>{t('vesselTransfer.table.vessel')}</Table.Th>
<Table.Th>Category</Table.Th> <Table.Th>{t('vesselTransfer.table.category')}</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>{t('common.status')}</Table.Th>
<Table.Th /> <Table.Th />
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -186,7 +191,7 @@ export function VesselTransferPage() {
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{CATEGORY_LABELS[vessel.category] ?? vessel.category} {categoryLabels[vessel.category] ?? vessel.category}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge
@@ -199,7 +204,7 @@ export function VesselTransferPage() {
<Table.Td> <Table.Td>
<Group justify="flex-end"> <Group justify="flex-end">
{canTransfer ? ( {canTransfer ? (
<Tooltip label="Start an ownership transfer for this vessel"> <Tooltip label={t('vesselTransfer.table.transferTooltip')}>
<Button <Button
size="compact-xs" size="compact-xs"
variant="light" variant="light"
@@ -208,12 +213,12 @@ export function VesselTransferPage() {
navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`) navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)
} }
> >
Transfer {t('vesselTransfer.table.transfer')}
</Button> </Button>
</Tooltip> </Tooltip>
) : ( ) : (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Not transferable {t('vesselTransfer.table.notTransferable')}
</Text> </Text>
)} )}
</Group> </Group>

View File

@@ -73,6 +73,7 @@ export const am: Translations = {
}, },
common: { common: {
select: 'ይምረጡ',
back: 'ተመለስ', back: 'ተመለስ',
continue: 'ቀጥል', continue: 'ቀጥል',
submit: 'አስገባ', submit: 'አስገባ',
@@ -114,6 +115,56 @@ export const am: Translations = {
dashboard: { dashboard: {
title: 'ዳሽቦርድ', title: 'ዳሽቦርድ',
quickActions: 'ፈጣን ድርጊቶች', quickActions: 'ፈጣን ድርጊቶች',
loading: 'ዳሽቦርድ በመጫን ላይ…',
welcome: 'እንኳን ደህና መጡ',
welcomeName: 'እንኳን ደህና መጡ፣ {{name}}',
waitingOnYou: 'እርምጃዎን ይጠብቃል',
noFee: 'ክፍያ የለም',
hero: {
summaryEmpty: 'የባህር ወይም የሎጂስቲክስ ፍቃድ ያመልክቱ እና እስከሚሰጥ ድረስ ይከታተሉት።',
summary: '{{applications}} እና {{licences}} አለዎት።',
applicationsCount_one: '{{count}} ማመልከቻ',
applicationsCount_other: '{{count}} ማመልከቻዎች',
licencesCount_one: '{{count}} ንቁ ፍቃድ',
licencesCount_other: '{{count}} ንቁ ፍቃዶች',
},
actionRequired: {
messages: {
resubmit: 'ገምጋሚው ከመቀጠሉ በፊት እርማት እንዲደረግ ጠይቋል።',
paymentPending: 'ጸድቋል — ምስክር ወረቀቱ ከመሰጠቱ በፊት {{amount}} መከፈል አለበት።',
draft: 'ይህ ማመልከቻ አሁንም ረቂቅ ሲሆን ገና አልገባም።',
},
cta: {
fixNow: 'አሁን አስተካክል',
payNow: 'አሁን ክፈል',
},
},
expiringSoon: {
detail: '{{certificateNumber}} በ{{days}} ቀናት ውስጥ ያበቃል',
},
stats: {
expiringSoon: 'በቅርቡ የሚያበቃ',
},
sections: {
myLicences: {
title: 'ፍቃዶቼ',
},
myApplications: {
title: 'ማመልከቻዎቼ',
empty: 'እስካሁን ምንም ማመልከቻ አላስገቡም። ለመጀመር ከታች ፍቃድ ይምረጡ።',
},
apply: {
title: 'ለፍቃድ ያመልክቱ',
description: 'ኩባንያዎ ከሚሰጠው አገልግሎት ጋር የሚዛመድ ፍቃድ ይምረጡ።',
},
},
getStarted: {
title: 'ይጀምሩ',
body: 'እስካሁን ማመልከቻ አላስገቡም። ኩባንያዎ ከሚሰራው ስራ ጋር የሚዛመድ ፍቃድ ይምረጡ — ማመልከቻዎችዎ እና የተሰጡዎት ፍቃዶች እየገፉ ሲሄዱ እዚህ ይታያሉ።',
},
table: {
application: 'ማመልከቻ',
},
}, },
applications: { applications: {
@@ -157,6 +208,7 @@ export const am: Translations = {
licence: 'ፍቃድ', licence: 'ፍቃድ',
applicant: 'አመልካች', applicant: 'አመልካች',
progress: 'ደረጃ', progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር',
}, },
actions: { actions: {
continue: 'ቀጥል', continue: 'ቀጥል',
@@ -234,9 +286,11 @@ export const am: Translations = {
addDetails: 'እነዚህን መረጃዎች ጨምር', addDetails: 'እነዚህን መረጃዎች ጨምር',
viewProfile: 'ሙሉ መገለጫ ይመልከቱ', viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።', seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
seafarerBanner: seafarerBanner:
'የባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።', 'የባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።',
}, },
profileSections: { profileSections: {
personal: 'የግል መረጃ', personal: 'የግል መረጃ',
@@ -264,10 +318,30 @@ export const am: Translations = {
verified: 'ተረጋግጧል', verified: 'ተረጋግጧል',
unverified: 'አልተረጋገጠም', unverified: 'አልተረጋገጠም',
tabs: { tabs: {
personal: 'የግል መረጃ',
profile: 'መገለጫ', profile: 'መገለጫ',
address: 'አድራሻ',
operations: 'የስራ ዘርፍ',
security: 'ደህንነት', security: 'ደህንነት',
preferences: 'ምርጫዎች', preferences: 'ምርጫዎች',
}, },
maritimeSection: {
title: 'የባህር ሙያ መገለጫ',
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
noProfile: 'ምንም መገለጫ አልተገኘም። መጀመሪያ መገለጫዎን ያጠናቅቁ።',
save: 'መገለጫ አስቀምጥ',
},
addressSection: {
title: 'አድራሻ እና መገናኛ',
subtitle: 'የመታወቂያ ሰነዶችዎ፣ የመገናኛ ዝርዝሮችዎ እና የአደጋ ጊዜ ተጠሪ',
save: 'አድራሻ አስቀምጥ',
},
addressSaved: 'አድራሻ ተቀምጧል',
nameMismatch: 'የመገለጫ ስም ከግል መረጃ ትር ውስጥ ካለው ስም ጋር መዛመድ አለበት።',
languageFull: {
en: 'እንግሊዝኛ (አሜሪካ)',
am: 'አማርኛ',
},
personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።', personalHint: 'ስምዎ በይፋዊ የ EMA ሰነዶች ላይ እንደሚታየው።',
languageTitle: 'ቋንቋ', languageTitle: 'ቋንቋ',
languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።', languageHint: 'በ EMA ፖርታል ላይ የሚጠቀሙበትን ቋንቋ ይምረጡ።',
@@ -278,9 +352,41 @@ export const am: Translations = {
dark: 'ጨለማ', dark: 'ጨለማ',
system: 'ሲስተም', 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: { twoStep: {
title: 'ባለ ሁለት ደረጃ ማረጋገጫ', title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።', desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
saved: 'ባለ ሁለት ደረጃ ማረጋገጫ ተዘምኗል',
}, },
notifications: { notifications: {
title: 'የኢሜይል ማሳወቂያዎች', title: 'የኢሜይል ማሳወቂያዎች',
@@ -322,6 +428,36 @@ export const am: Translations = {
}, },
}, },
profileForm: {
placeholders: {
firstName: 'የመጀመሪያ ስም ያስገቡ',
middleName: 'የአባት ስም ያስገቡ',
lastName: 'የአያት ስም ያስገቡ',
pob: 'ከተማ፣ ክልል',
},
genders: {
MALE: 'ወንድ',
FEMALE: 'ሴት',
},
maritalStatuses: {
SINGLE: 'ያላገባ/ች',
MARRIED: 'ያገባ/ች',
DIVORCED: 'የፈታ/ች',
WIDOWED: 'የሞተበት/ባት',
},
validation: {
professionRequired: 'ሙያዎን ይምረጡ',
firstNameMin: 'የመጀመሪያ ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
middleNameMin: 'የአባት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
lastNameMin: 'የአያት ስም ቢያንስ 3 ቁምፊዎች መሆን አለበት',
genderRequired: 'ጾታዎን ይምረጡ',
dobRequired: 'የትውልድ ቀንዎን ይምረጡ',
dobMinAge: 'ዕድሜዎ ቢያንስ 18 ዓመት መሆን አለበት',
maritalStatusRequired: 'የጋብቻ ሁኔታዎን ይምረጡ',
nameParts: 'የመጀመሪያ፣ የአባት እና የአያት ስምዎን ያስገቡ',
},
},
country: { country: {
select: 'አገር ይምረጡ', select: 'አገር ይምረጡ',
notFound: 'ምንም አገር አልተገኘም', notFound: 'ምንም አገር አልተገኘም',
@@ -422,6 +558,7 @@ export const am: Translations = {
signup: { signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል", usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል", nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
nameEnFullNameRequired: "እባክዎ ሙሉ ስምዎን ያስገቡ (የመጀመሪያ፣ የአባት እና የአያት ስም)",
phoneRequired: "ስልክ ቁጥር ያስፈልጋል", phoneRequired: "ስልክ ቁጥር ያስፈልጋል",
confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ", confirmPasswordRequired: "የይለፍ ቃልዎን ያረጋግጡ",
passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም", passwordsDontMatch: "የይለፍ ቃላት አይመሳሰሉም",
@@ -429,7 +566,7 @@ export const am: Translations = {
brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።", brandSubtitle: "የ{{appName}} አገልግሎቶችን ለመድረስ መለያዎን ይፍጠሩ።",
title: "መለያ ይፍጠሩ", title: "መለያ ይፍጠሩ",
subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።", subtitle: "ለመጀመር አንድ ደቂቃ ብቻ ይወስዳል።",
nameEnLabel: "ስም (እንግሊዝኛ)", nameEnLabel: "ሙሉ ስም (እንግሊዝኛ)",
nameEnPlaceholder: "አበበ በቀለ", nameEnPlaceholder: "አበበ በቀለ",
nameAmLabel: "ስም (አማርኛ)", nameAmLabel: "ስም (አማርኛ)",
nameAmPlaceholder: "ስም", nameAmPlaceholder: "ስም",
@@ -456,4 +593,637 @@ export const am: Translations = {
special: "አንድ ልዩ ምልክት", special: "አንድ ልዩ ምልክት",
}, },
}, },
errorBoundary: {
title: 'የሆነ ችግር ተከስቷል',
message: 'ያልተጠበቀ ስህተት ተከስቷል።',
reload: 'ገጹን እንደገና ይጫኑ',
},
featureUnavailable: {
documents: {
title: 'የእኔ ሰነዶች',
description: 'ማዕከላዊ የሰነድ ማከማቻ ገና ከሲስተሙ ጋር አልተገናኘም። ከፈቃድ ማመልከቻ ጋር የሚያስገቧቸው ሰነዶች ከዚያ ማመልከቻ ጋር ተያይዘው ይቀመጣሉ።',
},
medical: {
title: 'የሕክምና ምስክር ወረቀት',
description: 'የሕክምና ምስክር ወረቀቶች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
basicSafetyTraining: {
title: 'መሠረታዊ የደህንነት ስልጠና',
description: 'የመሠረታዊ የደህንነት ስልጠና (BST) መዝገቦች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
seamanBook: {
title: 'የመርከበኛ መጽሐፍ',
description: 'የመርከበኛ መጽሐፍ ማመልከቻዎች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
seamanBookApplication: {
title: 'ለመርከበኛ መጽሐፍ ያመልክቱ',
description: 'የመርከበኛ መጽሐፍ ማመልከቻዎች ገና ከሲስተሙ ጋር አልተገናኙም።',
},
},
notifications: {
title: 'ማሳወቂያዎች',
unread_one: '{{count}} ያልተነበበ',
unread_other: '{{count}} ያልተነበቡ',
allCaughtUp: 'ሁሉንም አይተዋል',
tabs: {
all: 'ሁሉም',
unseen: 'ያልታዩ',
seen: 'የታዩ',
},
empty: {
all: 'እስካሁን ምንም ማሳወቂያ የለም።',
unseen: 'ያልተነበበ ማሳወቂያ የለም።',
seen: 'የተነበበ ማሳወቂያ የለም።',
},
emptyBody: 'ማመልከቻዎ ሲራመድ ይታወቃሉ።',
new: 'አዲስ',
markRead: 'እንደተነበበ ምልክት አድርግ',
loading: 'ማሳወቂያዎች በመጫን ላይ…',
},
onboarding: {
checkingProfile: 'የስራ ማስፈጸሚያ መገለጫ በመፈተሽ ላይ…',
operations: {
title: 'እንደ ምን አይነት ኦፕሬተር ነው የሚሰሩት?',
body: 'ባለስልጣኑ ፍቃድ የሚሰጠው በስራ አይነት (ኦፕሬሽን ሞድ) መሰረት ነው። ኩባንያዎ የሚሰራውን ይንገሩን፤ ማመልከት የሚችሉባቸውን ፍቃዶች እናሳይዎታለን — ይህንን በኋላ ከመገለጫዎ መቀየር ይችላሉ።',
},
},
payments: {
myApplications: 'ማመልከቻዎቼ',
fields: {
amount: 'መጠን',
method: 'የክፍያ ዘዴ',
reference: 'ማጣቀሻ',
paid: 'የተከፈለበት ቀን',
},
check: {
notFoundTitle: 'ይህን ክፍያ ማወቅ አልቻልንም',
notFoundBody: 'የክፍያውን ሁኔታ ለመፈተሽ ማመልከቻውን ከዝርዝርዎ ውስጥ ይክፈቱ።',
stillConfirmingTitle: 'ክፍያዎ በማረጋገጥ ላይ ነው',
stillConfirmingBody: 'ቴሌብር ይህን ክፍያ እስካሁን አላረጋገጠም። ገንዘቡ ከሂሳብዎ ወጥቶ ከሆነ በራስ-ሰር ይተገበራል — እንደገና መክፈል አያስፈልግም።',
checkAgain: 'እንደገና ፈትሽ',
confirmingTitle: 'ክፍያዎ በማረጋገጥ ላይ…',
confirmingBody: 'ይህ በተለምዶ ጥቂት ሰከንዶች ይወስዳል። እባክዎ ይህን ገጽ አይዝጉ።',
},
failure: {
title: 'ክፍያው አልተጠናቀቀም',
defaultReason: 'ክፍያው አልተጠናቀቀም። ምንም ገንዘብ አልተከፈለም።',
unchanged: 'ማመልከቻዎ አልተለወጠም እና በማንኛውም ጊዜ እንደገና መሞከር ይችላሉ።',
},
success: {
title: 'ክፍያ ደርሷል',
body: 'እናመሰግናለን። የፍቃድ ክፍያዎ ተከፍሏል እናም ማመልከቻዎ በመጠናቀቅ ላይ ነው። የምስክር ወረቀትዎ ሲዘጋጅ ይታወቃሉ።',
backToApplications: 'ወደ ማመልከቻዎቼ ተመለስ',
},
},
profileAddress: {
secondaryPhoneNumber: 'ሁለተኛ ስልክ',
postalAddress: 'የፖስታ አድራሻ',
addressSection: 'አድራሻ',
emergencyContactSection: 'የአደጋ ጊዜ ተጠሪ',
emergencyContactOptional: '(አማራጭ)',
contactName: 'የተጠሪ ስም',
contactPhone: 'የተጠሪ ስልክ',
relationship: 'ዝምድና',
accountManagedHint: 'ከመለያዎ የተገኘ ነው፣ በግል መረጃ ትር ውስጥ ያስተካክሉት',
idTypePlaceholder: 'ይምረጡ',
idNumberPlaceholder: 'የመታወቂያ ቁጥር ያስገቡ',
phonePlaceholder: '+251 9XX XXX XXX',
streetAddressPlaceholder: 'የመንገድ ስም፣ የቤት ቁጥር',
postalAddressPlaceholder: 'ፖስታ ሳጥን',
contactNamePlaceholder: 'ሙሉ ስም',
relationshipPlaceholder: 'የትዳር ጓደኛ፣ ወላጅ፣ ወዘተ.',
idTypeOptions: {
NID: 'ብሔራዊ መታወቂያ',
VITAL: 'የልደት/ወሳኝ ኩነት መታወቂያ',
PASSPORT: 'ፓስፖርት',
DRIVERS_LICENSE: 'የመንጃ ፍቃድ',
},
validation: {
idTypeRequired: 'የመታወቂያ ዓይነት ይምረጡ',
idNumberRequired: 'የመታወቂያ ቁጥር ያስገቡ',
nationalityRequired: 'ዜግነት ይምረጡ',
emailInvalid: 'ልክ ያልሆነ ኢሜይል',
},
},
profileOperations: {
title: 'የስራ ዘርፍ',
description: 'ድርጅትዎ በምን ዘርፍ እንደሚሰራ። የትኞቹ ፈቃዶች እንደሚቀርቡልዎት የሚወስነው ይህ ነው — ንግድዎ ሲቀየር ማንኛውም ጊዜ መቀየር ይችላሉ።',
current: 'የአሁኑ',
emptyState: 'እስካሁን የተዋቀሩ የፈቃድ ዓይነቶች የሉም። የሚጠብቁት ካለ EMA ን ያነጋግሩ።',
noneSelectedTitle: 'ምንም የስራ ዘርፍ አልተመረጠም',
noneSelectedBody: 'ምንም ካልተመረጠ ምንም ፈቃድ ለማመልከት አይቀርብልዎትም። ነባር ማመልከቻዎችና የተሰጡ ፈቃዶች አይነኩም።',
lastChanged: 'መጨረሻ የተቀየረው {{date}}',
notSetYet: 'እስካሁን አልተዋቀረም',
discardChanges: 'ለውጦችን ተወው',
saveOperations: 'የስራ ዘርፎችን አስቀምጥ',
removeModalTitle: 'ከስራ ዘርፍዎ ውስጥ ማስወገድ ይፈልጋሉ?',
removingPrefix: 'እያስወገዱ ያሉት፦',
removeConsequence: 'ከዚያ ዓይነት ለአዲስ ማመልከቻ ከእንግዲህ አይቀርብልዎትም። አስቀድመው የቀረቡ ማመልከቻዎች እንደነበሩ ይቀጥላሉ፣ አስቀድመው የተሰጡ ፈቃዶችም ልክ ሆነው ይቆያሉ እንዲሁም ማደስ ይችላሉ።',
removeAndSave: 'አስወግድና አስቀምጥ',
updateSuccessTitle: 'የስራ ዘርፎች ተዘምነዋል',
updateSuccessBody: 'ማመልከት የሚችሉባቸው ፈቃዶች ተዛማጅ እንዲሆኑ ተዘምነዋል።',
updateErrorTitle: 'ማስቀመጥ አልተቻለም',
},
licensing: {
vesselPicker: {
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
},
msg: {
fileTooLarge: 'ፋይሉ ከ5ሜባ ገደብ በላይ ነው ({{size}})።',
},
documents: {
conditional: 'በሁኔታ ላይ የተመሠረተ',
uploaded: 'ተሰቅሏል',
officerRemark: 'ባለሥልጣን፦ {{name}}',
view: 'ይመልከቱ',
replace: 'ይተኩ',
upload: 'ይስቀሉ',
},
card: {
fallbackName: 'ፍቃድ',
expired: 'ጊዜው ያለፈበት',
expiredOn: 'ጊዜው ያለፈው በ{{date}}',
validUntil: 'እስከ {{date}} ድረስ የፀና',
downloadCertificate: 'የምስክር ወረቀት አውርድ',
renewExpired: 'አድስ — ይህ ፍቃድ ጊዜው አልፎበታል',
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
emptyBody:
'ፍቃዶች የሚቀርቡት እርስዎ በሚሠሩበት የስራ ዘርፍ መሠረት ነው — የጭነት አስተላላፊ፣ የመርከብ ወኪል፣ የተቀናጀ ትራንስፖርት ኦፕሬተር እና የመሳሰሉት። የእርስዎን ይምረጡ፣ ማመልከት የሚችሉባቸው ፍቃዶች እዚህ ይታያሉ።',
setOperations: 'የስራ ዘርፎቼን አዘጋጅ',
browseAll: 'ሁሉንም ፍቃዶች ያስሱ',
showOnlyMine: 'የኔን ብቻ አሳይ',
noneAvailable: 'እስካሁን ምንም የፍቃድ ዓይነት አልቀረበም። የሚጠብቁት ነገር ካለ ባለሥልጣኑን ያነጋግሩ።',
showingAll: 'ከስራ ዘርፎችዎ ውጪ ያሉትንም ጨምሮ ሁሉንም ፍቃዶች በማሳየት ላይ።',
showingMine: 'የተመዘገቡ የስራ ዘርፎችዎን የሚመለከቱ ፍቃዶች ብቻ ታይተዋል።',
otherLicences: 'ሌሎች ፍቃዶች',
otherLicencesDescription: 'ገና ምድብ ያልተሰጣቸው የፍቃድ ዓይነቶች።',
noFee: 'ክፍያ የለም',
capitalTooltip: 'በባንክ ደብዳቤ መረጋገጥ ያለበት ዝቅተኛ ካፒታል',
capitalBadge: 'ካፒታል {{amount}}',
validityBadge: '{{months}} ወራት',
evaluationTooltip: 'በምስክር ወረቀት ፈንታ በባለሥልጣኑ ውሳኔ የሚጠናቀቅ',
evaluationOnly: 'ግምገማ ብቻ',
startApplication: 'ማመልከቻ ጀምር',
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
},
},
certificates: {
title: "የእኔ የምስክር ወረቀቶች",
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
fetchFailed: "የምስክር ወረቀቱን ማግኘት አልተቻለም",
eligibility: {
title: "ብቁነት",
registered: "የተመዘገበ መርከበኛ ({{number}})",
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
medicalCurrent: "የአሁኑ የሕክምና የምስክር ወረቀት በመዝገብ ላይ አለ",
medicalRequired: "የአሁኑ የሕክምና የምስክር ወረቀት ያስፈልጋል",
seaTime: "የተረጋገጠ የባህር ጊዜ፦ {{days}} ቀናት (CoC 360, CoP 90 ያስፈልገዋል)",
},
applyCoc: "ለ CoC ያመልክቱ",
applyCop: "ለ CoP ያመልክቱ",
registrationNotice: {
prefix: "መጀመሪያ የ",
link: "መርከበኛ ምዝገባዎን",
suffix: "ያጠናቅቁ — ያለዚያ የምስክር ወረቀት ማመልከቻዎች ውድቅ ይደረጋሉ።",
},
inProgress: "በሂደት ላይ ያሉ ማመልከቻዎች",
issuedCertificates: "የወጡ የምስክር ወረቀቶች",
emptyIssued: "እስካሁን የወጣ የምስክር ወረቀት የለም።",
columns: {
certificateNumber: "የምስክር ወረቀት ቁጥር",
type: "ዓይነት",
issued: "የወጣበት ቀን",
expires: "የሚያበቃበት ቀን",
licenseStatus: {
ACTIVE: "ንቁ",
EXPIRED: "ጊዜው ያለፈበት",
SUSPENDED: "ታግዷል",
CANCELLED: "ተሰርዟል",
SUPERSEDED: "ተተክቷል",
},
},
},
licenseApplication: {
loading: 'ማመልከቻ በመጫን ላይ…',
fee: 'ክፍያ፡ {{amount}} {{currency}}',
review: 'ግምገማ',
resubmitCorrections: 'ማስተካከያዎችን እንደገና አስገባ',
submitApplication: 'ማመልከቻ አስገባ',
sectionLocked: 'ይህ ክፍል ተቀባይነት አግኝቶ ለዚህ ዙር ተቆልፏል።',
correctionsRequested: {
title: 'ማስተካከያ ተጠይቋል',
onlyListed: 'ከላይ የተዘረዘሩት ነገሮች ብቻ ሊቀየሩ ይችላሉ።',
},
stillMissing: {
title: 'አሁንም የጎደለ',
},
staff: {
addStaffMember: 'የሰራተኛ አባል ጨምር',
fullName: 'ሙሉ ስም',
position: 'የስራ መደብ',
yearsOfExperience: 'የስራ ልምድ ዓመታት',
add: 'ጨምር',
complete: 'ተጠናቅቋል',
requiredCount: '{{count}} ከ{{min}} የሚያስፈልጉ',
eachNeeds: '· እያንዳንዱ የሚያስፈልገው {{items}}',
yearsSuffix: '· {{count}} ዓመታት',
},
notifications: {
startFailed: {
title: 'ማመልከቻውን መጀመር አልተቻለም',
},
saveFailed: {
title: 'ማስቀመጥ አልተቻለም',
},
incomplete: {
title: 'ያልተሟላ',
message: 'ከማስገባትዎ በፊት የተጎሉትን መስኮች ያጠናቅቁ።',
},
incompleteFields_one: 'ለመቀጠል {{count}} አስፈላጊ መስክ ያጠናቅቁ።',
incompleteFields_other: 'ለመቀጠል {{count}} አስፈላጊ መስኮች ያጠናቅቁ።',
resubmitted: {
title: 'እንደገና ገብቷል',
message: 'ማስተካከያዎችዎ ለገምጋሚ ባለስልጣኑ ተልከዋል።',
},
submitted: {
title: 'ማመልከቻ ገብቷል',
message: 'እየገፋ ሲሄድ ይነገርዎታል።',
},
applicationIncomplete: {
title: 'ማመልከቻው ያልተሟላ ነው',
itemsNeedAttention_one: '{{count}} ንጥል አሁንም ትኩረት ይፈልጋል።',
itemsNeedAttention_other: '{{count}} ንጥሎች አሁንም ትኩረት ይፈልጋሉ።',
},
staffIncomplete: {
title: 'ሰራተኛ ያልተሟላ',
message: 'የሚያስፈልጉ፡ {{items}}።',
roleRequired: '{{name}} ({{count}} የሚያስፈልጉ)',
},
documentsMissing: {
title: 'ሰነዶች ይጎድላሉ',
message: 'ስቀል፡ {{items}}።',
andMore: 'እና ሌሎች {{count}}',
},
},
},
exams: {
title: 'ፈተናዎች',
openSessions: 'ክፍት ፈተናዎች',
noOpenSessions: 'ለምዝገባ ክፍት የሆነ መጪ ፈተና የለም።',
registered: 'ተመዝግቧል',
register: 'ይመዝገቡ',
myRegistrations: 'የእኔ ምዝገባዎች',
myResults: 'የእኔ ውጤቶች',
noRegistrations: 'እስካሁን የፈተና ምዝገባ የለም።',
noResults:
'እስካሁን የታተመ ውጤት የለም። ባለሥልጣኑ ካጸደቀና ካሳተመ በኋላ ውጤቶች እዚህ ይታያሉ።',
loading: 'የፈተና መርሃ ግብር በመጫን ላይ…',
notify: {
registered: 'ተመዝግበዋል — የመግቢያ ቁጥር {{admissionNumber}}',
admissionNumberPending: 'ወጥቷል',
registerFailed: 'መመዝገብ አልተቻለም',
seafarerRequired: 'ፈተና ለመቀመጥ ንቁ የመርከበኛ ምዝገባ ያስፈልጋል።',
alreadyRegistered: 'ለዚህ ፈተና አስቀድመው ተመዝግበዋል።',
alreadyPassed: 'ይህን ትምህርት አስቀድመው አልፈዋል — ድጋሚ መፈተን አያስፈልግም።',
slipFailed: 'የመግቢያ ወረቀት ማዘጋጀት አልተቻለም',
appealSubmitted: 'የይግባኝ {{appealNumber}} ቀርቧል',
appealFailed: 'ይግባኝ ማስገባት አልተቻለም',
appealWindowClosed:
'የይግባኝ ማቅረቢያ ጊዜው (ከታተመበት ቀን ጀምሮ {{days}} ቀናት) አልፏል።',
appealAlreadyOpen: 'በዚህ ውጤት ላይ ይግባኝ አስቀድሞ በመታየት ላይ ነው።',
},
appealModal: {
title: 'የዚህን ውጤት ግምገማ ይጠይቁ',
body: 'በ{{examTitle}} ውጤት አሰጣጥ ወይም አስተዳደር ላይ ስህተት ነው ብለው የሚያምኑትን ያብራሩ። ይግባኝ ከታተመበት ቀን ጀምሮ በ14 ቀናት ውስጥ መቅረብ አለበት።',
defaultExamTitle: 'ይህ ፈተና',
reasonLabel: 'የይግባኝ ምክንያት',
submit: 'ይግባኝ ያስገቡ',
},
columns: {
admission: 'የመግቢያ ቁጥር',
examination: 'ፈተና',
date: 'ቀን',
venue: 'ቦታ',
attempt: 'ሙከራ',
attendance: 'መገኘት',
slip: 'ወረቀት',
published: 'የታተመበት ቀን',
score: 'ውጤት',
outcome: 'ውጤት',
appeal: 'ይግባኝ',
retake: 'ድጋሚ · {{n}}',
firstSitting: 'የመጀመሪያ ሙከራ',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',
ABSENT: 'አልተገኘም',
LATE: 'ዘግይቷል',
WITHDRAWN: 'ወጥቷል',
DISQUALIFIED: 'ታግዷል',
},
outcomeStatus: {
PASSED: 'አልፏል',
FAILED: 'አልተሳካም',
},
appealStatus: {
SUBMITTED: 'ገብቷል',
UNDER_REVIEW: 'በግምገማ ላይ',
UPHELD: 'ጸድቋል',
REJECTED: 'ውድቅ ተደርጓል',
},
},
},
endorsement: {
title: "የእኔ ማረጋገጫዎች",
loading: "ማረጋገጫዎች በመጫን ላይ…",
fetchFailed: "ማረጋገጫውን ማግኘት አልተቻለም",
eligibility: {
title: "ብቁነት",
registered: "የተመዘገበ መርከበኛ ({{number}})",
registrationRequired: "ንቁ የመርከበኛ ምዝገባ ያስፈልጋል",
},
endorseCoc: "CoC ያረጋግጡ",
endorseGoc: "GOC ያረጋግጡ",
registrationNotice: {
prefix: "መጀመሪያ የ",
link: "መርከበኛ ምዝገባዎን",
suffix: "ያጠናቅቁ — ያለዚያ የማረጋገጫ ማመልከቻዎች ውድቅ ይደረጋሉ።",
},
inProgress: "በሂደት ላይ ያሉ ማመልከቻዎች",
issuedEndorsements: "የወጡ ማረጋገጫዎች",
emptyIssued: "እስካሁን የወጣ ማረጋገጫ የለም።",
columns: {
certificateNumber: "የምስክር ወረቀት ቁጥር",
type: "ዓይነት",
issued: "የወጣበት ቀን",
expires: "የሚያበቃበት ቀን",
licenseStatus: {
ACTIVE: "ንቁ",
EXPIRED: "ጊዜው ያለፈበት",
SUSPENDED: "ታግዷል",
CANCELLED: "ተሰርዟል",
SUPERSEDED: "ተተክቷል",
},
},
},
seafarer: {
title: 'የባህረኛ ምዝገባ',
status: {
ACTIVE: 'ንቁ',
PENDING: 'በመጠባበቅ ላይ',
SUSPENDED: 'ታግዷል',
INACTIVE: 'ንቁ ያልሆነ',
},
departments: {
DECK: 'ዴክ',
ENGINE: 'ምህንድስና',
CATERING: 'ኬተሪንግ',
},
registered: {
badgeTitle: 'የተመዘገበ ባህረኛ',
badgeSubtitle: 'ከኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣን ጋር ያለዎት ይፋዊ የባህረኛ መገለጫ።',
seafarerNumber: 'የባህረኛ ቁጥር',
department: 'ክፍል',
suspendedAlert: 'መገለጫዎ ታግዷል፦ {{reason}}',
recordsTitle: 'የባህር አገልግሎትና የሕክምና መዝገቦች',
recordsSubtitle: 'የባህር አገልግሎት ታሪክዎንና የሕክምና የምስክር ወረቀቶችዎን ወቅታዊ ያድርጉ — የምስክር ወረቀትና የባህረኛ መጽሐፍ ማመልከቻዎች ከእነሱ ይመዘናሉ።',
myRecords: 'መዝገቦቼ',
},
inFlight: {
subtitle: 'የገቡ ምዝገባዎች በ EMA የምዝገባ ባለሙያ ይገመገማሉ፤ ስለ እያንዳንዱ ውሳኔ ይነገርዎታል።',
needsAction: 'የምዝገባ ባለሙያው ማስተካከያ ጠይቋል። ምን መስተካከል እንዳለበት በትክክል ለማየት ማመልከቻውን ይክፈቱ።',
continueRegistration: 'ምዝገባ ይቀጥሉ',
fixAndResubmit: 'አስተካክለው እንደገና ያስገቡ',
viewApplication: 'ማመልከቻ ይመልከቱ',
},
notStarted: {
rejectedTitle: 'ቀደም ያለ ምዝገባ ውድቅ ተደርጓል',
rejectedDefault: 'ቀደም ያለ ምዝገባዎ ውድቅ ተደርጓል። እንደገና መመዝገብ ይችላሉ።',
heading: 'እንደ ባህረኛ ይመዝገቡ',
body: 'መጽደቅ ልዩ የባህረኛ ቁጥር ያለው ይፋዊ የባህረኛ መገለጫ ይፈጥርልዎታል — እያንዳንዱ የባሕር አገልግሎት የሚገነባበት ማንነት።',
needsTitle: 'የሚያስፈልግዎት፦',
checklist: {
photo: 'የፓስፖርት መጠን ያለው ፎቶግራፍ',
id: 'ብሔራዊ መታወቂያዎ (ፋይዳ) ወይም የቀበሌ መታወቂያ',
certificate: 'የትምህርት ማስረጃዎ',
medical: 'የሕክምና ብቁነት የምስክር ወረቀትና ፓስፖርት፣ አስቀድመው ካሉዎት',
},
start: 'ምዝገባ ይጀምሩ',
},
},
seaRecords: {
title: 'የባህር መዝገቦቼ',
pageIntro: 'እዚህ የሚያክሏቸው መዝገቦች ለ EMA ማረጋገጫ ይላካሉ። ከተረጋገጡ በኋላ ይዘጋሉ እንዲሁም ለምስክር ወረቀት ብቁነት ይቆጠራሉ።',
tabs: {
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀቶች',
},
evidence: {
title: 'ማስረጃ',
none: 'እስካሁን ምንም ማስረጃ አልተሰቀለም።',
upload: 'ማስረጃ ስቀል',
uploaded: 'ማስረጃ ተሰቅሏል',
},
seaService: {
description: 'በመርከብ ላይ የተደረገ እያንዳንዱ ተሳትፎ፣ ከማስረጃው ጋር። የተረጋገጡ መዝገቦች ለምስክር ወረቀት ብቁነት ይውላሉ።',
approvedSeaTime: 'የጸደቀ የባህር ጊዜ፦ {{days}} ቀናት',
add: 'የባህር አገልግሎት ጨምር',
empty: 'እስካሁን የባህር አገልግሎት መዝገብ የለም።',
tableName: 'የባህር አገልግሎት',
modal: {
editTitle: 'የባህር አገልግሎት አርትዕ',
addTitle: 'የባህር አገልግሎት ጨምር',
},
fields: {
vesselName: 'የመርከብ ስም',
imoNumber: 'የ IMO ቁጥር',
vesselType: 'የመርከብ አይነት',
flagState: 'የባንዲራ ሀገር',
grossTonnage: 'ጠቅላላ ቶኔጅ',
rank: 'ማዕረግ / ኃላፊነት',
engagementDate: 'የተቀጠሩበት ቀን',
dischargeDate: 'የተሰናበቱበት ቀን',
duties: 'ተግባራት',
},
addRecord: 'መዝገብ ጨምር',
updated: 'የባህር አገልግሎት መዝገብ ተዘምኗል',
added: 'የባህር አገልግሎት መዝገብ ታክሏል',
saveFailed: 'መዝገቡን ማስቀመጥ አልተቻለም',
withdrawn: 'መዝገብ ተነስቷል',
deleteFailed: 'መዝገቡን መሰረዝ አልተቻለም',
},
medical: {
description: 'የ STCW የሕክምና ብቁነት የምስክር ወረቀቶች። ጊዜው ያለፈበት የምስክር ወረቀት እሱን የሚያስፈልጋቸውን አዳዲስ ማመልከቻዎች ያግዳል።',
add: 'የምስክር ወረቀት ጨምር',
empty: 'እስካሁን የሕክምና የምስክር ወረቀት የለም።',
tableName: 'የሕክምና የምስክር ወረቀቶች',
modal: {
editTitle: 'የሕክምና የምስክር ወረቀት አርትዕ',
addTitle: 'የሕክምና የምስክር ወረቀት ጨምር',
},
fields: {
issuerName: 'የሰጠው ክሊኒክ / ሐኪም',
certificateNumber: 'የምስክር ወረቀት ቁጥር',
issueDate: 'የተሰጠበት ቀን',
expiryDate: 'የሚያበቃበት ቀን',
fitnessOutcome: 'የብቁነት ውጤት',
restrictions: 'ገደቦች',
},
updated: 'የሕክምና የምስክር ወረቀት ተዘምኗል',
added: 'የሕክምና የምስክር ወረቀት ታክሏል',
saveFailed: 'የምስክር ወረቀቱን ማስቀመጥ አልተቻለም',
withdrawn: 'የምስክር ወረቀት ተነስቷል',
deleteFailed: 'የምስክር ወረቀቱን መሰረዝ አልተቻለም',
},
columns: {
vessel: 'መርከብ',
imo: 'IMO {{number}}',
rank: 'ማዕረግ',
from: 'ከ',
to: 'እስከ',
issuer: 'ሰጪ',
certNumber: '№ {{number}}',
issued: 'የተሰጠበት',
expires: 'የሚያበቃበት',
expired: 'ጊዜው አልፏል',
fitness: 'ብቁነት',
fitnessOptions: {
FIT: 'ብቁ',
FIT_WITH_RESTRICTIONS: 'በገደብ ብቁ',
UNFIT: 'ብቁ ያልሆነ',
},
recordStatus: {
SUBMITTED: 'ገብቷል',
VERIFIED: 'ተረጋግጧል',
REJECTED: 'ውድቅ ተደርጓል',
},
},
actions: {
evidence: 'ማስረጃ',
scanEvidence: 'ስካን / ማስረጃ',
edit: 'አርትዕ',
delete: 'ሰርዝ',
frozen: 'የተረጋገጡ መዝገቦች ተዘግተዋል',
certificatesFrozen: 'የተረጋገጡ የምስክር ወረቀቶች ተዘግተዋል',
},
},
vesselRegistration: {
title: 'የመርከብ ምዝገባ',
registerButton: 'መርከብ ይመዝግቡ',
suspendedAlert:
'የታገደ መርከብ መንቀሳቀስ አይችልም። ስለ ዳግም ማቋቋም የኢትዮጵያ ባሕር ጉዳዮች ባለሥልጣንን ያግኙ።',
inFlight: {
title: 'በሂደት ላይ ያሉ ምዝገባዎች',
renewalBadge: 'እድሳት',
fix: 'አስተካክል',
view: 'ይመልከቱ',
},
myVessels: {
title: 'የእኔ መርከቦች',
empty: {
title: 'እስካሁን የተመዘገበ መርከብ የለም',
body: 'የውስጥ ውሃ መስመር ወይም የባህር ማዕድ መርከብ ይመዝግቡ። ማጽደቅ የምዝገባ የምስክር ወረቀት ያወጣል እንዲሁም መርከቧን በብሔራዊ መዝገብ ውስጥ ያስገባል።',
cta: 'ምዝገባ ጀምር',
},
},
notify: {
comingSoon: 'የማሻሻያ እና የምስክር ወረቀት ድግግሞሽ አገልግሎቶች በሚቀጥለው ስሪት ይመጣሉ።',
},
incident: {
modalTitle: 'አደጋ ሪፖርት አድርግ — {{vesselName}}',
dateLabel: 'የተከሰተበት ቀን',
locationLabel: 'ቦታ',
descriptionLabel: 'የተከሰተው ነገር',
submit: 'አደጋ መዝግብ',
recorded: 'አደጋው ተመዝግቧል',
recordFailed: 'አደጋውን መመዝገብ አልተቻለም',
},
certificate: {
fetchFailed: 'የምስክር ወረቀቱን ማግኘት አልተቻለም',
},
renewal: {
startFailed: 'እድሳቱን መጀመር አልተቻለም',
},
columns: {
registrationNumber: 'የምዝገባ ቁጥር',
vessel: 'መርከብ',
category: 'ምድብ',
certificate: 'የምስክር ወረቀት',
expiresIn: 'በ{{count}} ቀን ውስጥ ያበቃል',
renew: 'አድስ',
renewTooltip: 'ምዝገባውን አድስ',
certificateTooltip: 'የምስክር ወረቀት አውርድ',
incident: 'አደጋ',
incidentTooltip: 'አደጋ / ችግር ሪፖርት አድርግ',
categories: {
INLAND_WATERWAY: 'የውስጥ ውሃ መስመር',
SEA_GOING: 'የባህር ማዕድ',
},
},
status: {
notFound: 'ምዝገባ አልተገኘም።',
title: 'የምዝገባ ሁኔታ',
submitted: 'የገባው {{date}}',
officerRemarks: 'የመኮንን አስተያየት',
pending: 'በመጠባበቅ ላይ',
resubmit: 'ማመልከቻ እንደገና አስገባ',
certificatesTitle: 'የምስክር ወረቀቶች',
certificateNumber: 'የምስክር ወረቀት ቁጥር {{number}} — የወጣበት {{date}}',
downloadedCount_one: '{{count}} ጊዜ ወርዷል',
downloadedCount_other: '{{count}} ጊዜያት ወርዷል',
downloadedNotify: '{{certName}} ወርዷል።',
renewalOverdue: 'ምዝገባው እድሳት ጊዜው አልፎበታል።',
renewalOverdueWithExpiry: 'ምዝገባው እድሳት ጊዜው አልፎበታል — የሚያበቃው {{date}}።',
renewalDueSoon: 'ምዝገባው በቅርቡ እድሳት ይፈልጋል።',
renewalDueSoonWithExpiry: 'ምዝገባው በቅርቡ እድሳት ይፈልጋል — የሚያበቃው {{date}}።',
},
},
vesselTransfer: {
title: 'የባለቤትነት ዝውውር',
startTransfer: 'ዝውውር ጀምር',
startTransferDisabledTooltip: 'መጀመሪያ መርከብ ይመዝግቡ — እስካሁን የሚዛወር ነገር የለም',
inFlight: {
title: 'በሂደት ላይ ያሉ ዝውውሮች',
fix: 'አስተካክል',
view: 'ይመልከቱ',
},
myVessels: {
title: 'የእኔ መርከቦች',
empty: {
title: 'እስካሁን የተመዘገበ መርከብ የለም',
body: 'ባለቤትነት ሊዛወር የሚችለው ቀድሞ በመዝገብ ውስጥ ላለ መርከብ ብቻ ነው።',
cta: 'ወደ የመርከብ ምዝገባ ይሂዱ',
},
},
table: {
registrationNumber: 'የምዝገባ ቁጥር',
vessel: 'መርከብ',
category: 'ምድብ',
transfer: 'አዛውር',
transferTooltip: 'ለዚህ መርከብ የባለቤትነት ዝውውር ጀምር',
notTransferable: 'ሊዛወር አይችልም',
categories: {
INLAND_WATERWAY: 'የውስጥ ውሃ መስመር',
SEA_GOING: 'የባህር ማዕድ',
},
},
},
}; };

View File

@@ -72,6 +72,7 @@ export const en = {
}, },
common: { common: {
select: 'Select',
back: 'Back', back: 'Back',
continue: 'Continue', continue: 'Continue',
submit: 'Submit', submit: 'Submit',
@@ -113,6 +114,56 @@ export const en = {
dashboard: { dashboard: {
title: 'Dashboard', title: 'Dashboard',
quickActions: 'Quick actions', quickActions: 'Quick actions',
loading: 'Loading dashboard…',
welcome: 'Welcome back',
welcomeName: 'Welcome back, {{name}}',
waitingOnYou: 'Waiting on you',
noFee: 'No fee',
hero: {
summaryEmpty: 'Apply for a maritime or logistics licence and track it through to issue.',
summary: 'You have {{applications}} and {{licences}}.',
applicationsCount_one: '{{count}} application',
applicationsCount_other: '{{count}} applications',
licencesCount_one: '{{count}} active licence',
licencesCount_other: '{{count}} active licences',
},
actionRequired: {
messages: {
resubmit: 'A reviewer asked for corrections before this can proceed.',
paymentPending: 'Approved — {{amount}} due before the certificate is issued.',
draft: 'This application is still a draft and has not been filed.',
},
cta: {
fixNow: 'Fix now',
payNow: 'Pay now',
},
},
expiringSoon: {
detail: '{{certificateNumber}} expires in {{days}} days',
},
stats: {
expiringSoon: 'Expiring soon',
},
sections: {
myLicences: {
title: 'My licences',
},
myApplications: {
title: 'My applications',
empty: 'You have not filed any applications yet. Pick a licence below to get started.',
},
apply: {
title: 'Apply for a licence',
description: 'Choose the licence that matches the service your company provides.',
},
},
getStarted: {
title: 'Get started',
body: '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.',
},
table: {
application: 'Application',
},
}, },
applications: { applications: {
@@ -156,6 +207,7 @@ export const en = {
licence: 'Licence', licence: 'Licence',
applicant: 'Applicant', applicant: 'Applicant',
progress: 'Progress', progress: 'Progress',
applicationNumber: 'Application №',
}, },
actions: { actions: {
continue: 'Continue', continue: 'Continue',
@@ -236,6 +288,7 @@ export const en = {
'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.',
seafarerBanner: seafarerBanner:
'Seafarer registration asks for these details and updates your profile once approved. Filling them in here first saves you typing them there.', 'Seafarer registration asks for these details and updates your profile once approved. Filling them in here first saves you typing them there.',
checkingProfile: 'Checking seafarer profile…',
}, },
profileSections: { profileSections: {
@@ -264,10 +317,30 @@ export const en = {
verified: 'Verified', verified: 'Verified',
unverified: 'Unverified', unverified: 'Unverified',
tabs: { tabs: {
personal: 'Personal',
profile: 'Profile', profile: 'Profile',
address: 'Address',
operations: 'Operations',
security: 'Security', security: 'Security',
preferences: 'Preferences', preferences: 'Preferences',
}, },
maritimeSection: {
title: 'Maritime Profile',
subtitle: 'Your professional maritime details',
noProfile: 'No profile found. Complete your profile setup first.',
save: 'Save Profile',
},
addressSection: {
title: 'Address & Contact',
subtitle: 'Your identity documents, contact details and emergency contact',
save: 'Save Address',
},
addressSaved: 'Address saved',
nameMismatch: 'Profile name must match the name in the Personal tab.',
languageFull: {
en: 'English (United States)',
am: 'Amharic',
},
personalHint: 'Your name as it appears on official EMA documents.', personalHint: 'Your name as it appears on official EMA documents.',
languageTitle: 'Language', languageTitle: 'Language',
languageHint: 'Choose the language used across the EMA portal.', languageHint: 'Choose the language used across the EMA portal.',
@@ -278,9 +351,41 @@ export const en = {
dark: 'Dark', dark: 'Dark',
system: 'System', 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: { twoStep: {
title: 'Two-step verification', title: 'Two-step verification',
desc: 'Require a one-time code from your phone each time you sign in.', desc: 'Require a one-time code from your phone each time you sign in.',
saved: 'Two-step verification updated',
}, },
notifications: { notifications: {
title: 'Email notifications', title: 'Email notifications',
@@ -322,6 +427,36 @@ export const en = {
}, },
}, },
profileForm: {
placeholders: {
firstName: 'Enter first name',
middleName: 'Enter middle name',
lastName: 'Enter last name',
pob: 'City, Region',
},
genders: {
MALE: 'Male',
FEMALE: 'Female',
},
maritalStatuses: {
SINGLE: 'Single',
MARRIED: 'Married',
DIVORCED: 'Divorced',
WIDOWED: 'Widowed',
},
validation: {
professionRequired: 'Select your profession',
firstNameMin: 'First name must be at least 3 characters',
middleNameMin: 'Middle name must be at least 3 characters',
lastNameMin: 'Last name must be at least 3 characters',
genderRequired: 'Select your gender',
dobRequired: 'Select your date of birth',
dobMinAge: 'You must be at least 18 years old',
maritalStatusRequired: 'Select your marital status',
nameParts: 'Enter your first, middle, and last name',
},
},
country: { country: {
select: 'Select a country', select: 'Select a country',
notFound: 'No countries found', notFound: 'No countries found',
@@ -422,6 +557,7 @@ export const en = {
signup: { signup: {
usernameMinLength: 'Username must be at least 3 characters', usernameMinLength: 'Username must be at least 3 characters',
nameEnRequired: 'Name (English) is required', nameEnRequired: 'Name (English) is required',
nameEnFullNameRequired: 'Please enter your full name (first, middle, and last)',
phoneRequired: 'Phone number is required', phoneRequired: 'Phone number is required',
confirmPasswordRequired: 'Confirm your password', confirmPasswordRequired: 'Confirm your password',
passwordsDontMatch: 'Passwords do not match', passwordsDontMatch: 'Passwords do not match',
@@ -429,7 +565,7 @@ export const en = {
brandSubtitle: 'Create your account to access {{appName}} features.', brandSubtitle: 'Create your account to access {{appName}} features.',
title: 'Create account', title: 'Create account',
subtitle: 'It only takes a minute to get started.', subtitle: 'It only takes a minute to get started.',
nameEnLabel: 'Name (English)', nameEnLabel: 'Full name (English)',
nameEnPlaceholder: 'Abebe Bekele', nameEnPlaceholder: 'Abebe Bekele',
nameAmLabel: 'Name (Amharic)', nameAmLabel: 'Name (Amharic)',
nameAmPlaceholder: 'ስም', nameAmPlaceholder: 'ስም',
@@ -456,6 +592,641 @@ export const en = {
special: 'One special character', special: 'One special character',
}, },
}, },
errorBoundary: {
title: 'Something went wrong',
message: 'An unexpected error occurred.',
reload: 'Reload page',
},
featureUnavailable: {
documents: {
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.',
},
medical: {
title: 'Medical certificate',
description: 'Medical certificates are not connected to the backend yet.',
},
basicSafetyTraining: {
title: 'Basic Safety Training',
description: 'BST records are not connected to the backend yet.',
},
seamanBook: {
title: 'Seaman Book',
description: 'Seaman Book applications are not connected to the backend yet.',
},
seamanBookApplication: {
title: 'Apply for a Seaman Book',
description: 'Seaman Book applications are not connected to the backend yet.',
},
},
notifications: {
title: 'Notifications',
unread_one: '{{count}} unread',
unread_other: '{{count}} unread',
allCaughtUp: 'You are all caught up',
tabs: {
all: 'All',
unseen: 'Unseen',
seen: 'Seen',
},
empty: {
all: 'No notifications yet.',
unseen: 'Nothing unread.',
seen: 'No read notifications.',
},
emptyBody: 'You will be notified as your applications progress.',
new: 'new',
markRead: 'Mark read',
loading: 'Loading Notifications…',
},
onboarding: {
checkingProfile: 'Checking operations profile…',
operations: {
title: 'What do you operate as?',
body: '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.',
},
},
payments: {
myApplications: 'My applications',
fields: {
amount: 'Amount',
method: 'Method',
reference: 'Reference',
paid: 'Paid',
},
check: {
notFoundTitle: 'We could not identify this payment',
notFoundBody: 'Open the application from your list to check its payment status.',
stillConfirmingTitle: 'Still confirming your payment',
stillConfirmingBody: '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.',
checkAgain: 'Check again',
confirmingTitle: 'Confirming your payment…',
confirmingBody: 'This usually takes a few seconds. Please do not close this page.',
},
failure: {
title: 'Payment not completed',
defaultReason: 'The payment was not completed. Nothing has been charged.',
unchanged: 'Your application is unchanged and you can try again at any time.',
},
success: {
title: 'Payment received',
body: 'Thank you. Your licence fee has been paid and your application is being finalised. You will be notified when your certificate is ready.',
backToApplications: 'Back to my applications',
},
},
profileAddress: {
secondaryPhoneNumber: 'Secondary Phone',
postalAddress: 'Postal Address',
addressSection: 'Address',
emergencyContactSection: 'Emergency Contact',
emergencyContactOptional: '(optional)',
contactName: 'Contact Name',
contactPhone: 'Contact Phone',
relationship: 'Relationship',
accountManagedHint: 'From your account, edit it in the Personal tab',
idTypePlaceholder: 'Select',
idNumberPlaceholder: 'Enter ID number',
phonePlaceholder: '+251 9XX XXX XXX',
streetAddressPlaceholder: 'Street name, house number',
postalAddressPlaceholder: 'P.O. Box',
contactNamePlaceholder: 'Full name',
relationshipPlaceholder: 'Spouse, Parent, etc.',
idTypeOptions: {
NID: 'National Id',
VITAL: 'Vital ID',
PASSPORT: 'Passport',
DRIVERS_LICENSE: "Driver's License",
},
validation: {
idTypeRequired: 'Select ID type',
idNumberRequired: 'Enter ID number',
nationalityRequired: 'Select nationality',
emailInvalid: 'Invalid email',
},
},
profileOperations: {
title: 'Mode of operation',
description: 'What your company operates as. This decides which licences you are offered — you can change it whenever your business changes.',
current: 'Current',
emptyState: 'No licence types are configured yet. Contact EMA if you were expecting one.',
noneSelectedTitle: 'No operations selected',
noneSelectedBody: 'With none selected you will not be offered any licence to apply for. Existing applications and issued licences are unaffected.',
lastChanged: 'Last changed {{date}}',
notSetYet: 'Not set yet',
discardChanges: 'Discard changes',
saveOperations: 'Save operations',
removeModalTitle: 'Remove from your operations?',
removingPrefix: 'You are removing',
removeConsequence: '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.',
removeAndSave: 'Remove and save',
updateSuccessTitle: 'Operations updated',
updateSuccessBody: 'The licences you can apply for have been updated to match.',
updateErrorTitle: 'Could not save',
},
licensing: {
vesselPicker: {
placeholder: 'Select a registered vessel',
},
msg: {
fileTooLarge: 'File exceeds 5MB limit ({{size}}).',
},
documents: {
conditional: 'conditional',
uploaded: 'uploaded',
officerRemark: 'Officer: {{name}}',
view: 'View',
replace: 'Replace',
upload: 'Upload',
},
card: {
fallbackName: 'Licence',
expired: 'Expired',
expiredOn: 'Expired on {{date}}',
validUntil: 'Valid until {{date}}',
downloadCertificate: 'Download certificate',
renewExpired: 'Renew — this licence has expired',
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
emptyBody:
'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.',
setOperations: 'Set my operations',
browseAll: 'Browse all licences',
showOnlyMine: 'Show only mine',
noneAvailable: 'No licence types are available yet. Contact EMA if you were expecting one.',
showingAll: 'Showing every licence, including ones outside your operations.',
showingMine: 'Only licences matching your declared operations are shown.',
otherLicences: 'Other licences',
otherLicencesDescription: 'Licence types that have not been assigned a category.',
noFee: 'No fee',
capitalTooltip: 'Minimum capital that must be evidenced by a bank letter',
capitalBadge: 'Capital {{amount}}',
validityBadge: '{{months}} months',
evaluationTooltip: 'Concludes with an EMA decision rather than a certificate',
evaluationOnly: 'Evaluation only',
startApplication: 'Start application',
addToOperations: 'Add to my operations',
},
},
certificates: {
title: 'My Certificates',
loading: 'Loading Certificates…',
fetchFailed: 'Could not fetch certificate',
eligibility: {
title: 'Eligibility',
registered: 'Registered seafarer ({{number}})',
registrationRequired: 'Active seafarer registration required',
medicalCurrent: 'Current medical certificate on file',
medicalRequired: 'A current medical certificate is required',
seaTime: 'Verified sea time: {{days}} days (CoC needs 360, CoP 90)',
},
applyCoc: 'Apply for CoC',
applyCop: 'Apply for CoP',
registrationNotice: {
prefix: 'Complete your',
link: 'seafarer registration',
suffix: 'first — certificate applications are refused without it.',
},
inProgress: 'Applications in progress',
issuedCertificates: 'Issued certificates',
emptyIssued: 'No certificates issued yet.',
columns: {
certificateNumber: 'Certificate №',
type: 'Type',
issued: 'Issued',
expires: 'Expires',
licenseStatus: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Superseded',
},
},
},
licenseApplication: {
loading: 'Loading Application…',
fee: 'Fee: {{amount}} {{currency}}',
review: 'Review',
resubmitCorrections: 'Resubmit corrections',
submitApplication: 'Submit application',
sectionLocked: 'This section was accepted and is locked for this round.',
correctionsRequested: {
title: 'Corrections requested',
onlyListed: 'Only the items listed above can be changed.',
},
stillMissing: {
title: 'Still missing',
},
staff: {
addStaffMember: 'Add staff member',
fullName: 'Full name',
position: 'Position',
yearsOfExperience: 'Years of experience',
add: 'Add',
complete: 'complete',
requiredCount: '{{count}} of {{min}} required',
eachNeeds: '· each needs {{items}}',
yearsSuffix: '· {{count}} yrs',
},
notifications: {
startFailed: {
title: 'Could not start application',
},
saveFailed: {
title: 'Could not save',
},
incomplete: {
title: 'Incomplete',
message: 'Complete the highlighted fields before submitting.',
},
incompleteFields_one: 'Complete {{count}} required field to continue.',
incompleteFields_other: 'Complete {{count}} required fields to continue.',
resubmitted: {
title: 'Resubmitted',
message: 'Your corrections were sent back to the reviewing officer.',
},
submitted: {
title: 'Application submitted',
message: 'You will be notified as it progresses.',
},
applicationIncomplete: {
title: 'Application incomplete',
itemsNeedAttention_one: '{{count}} item still needs attention.',
itemsNeedAttention_other: '{{count}} items still need attention.',
},
staffIncomplete: {
title: 'Staff incomplete',
message: 'Still needed: {{items}}.',
roleRequired: '{{name}} ({{count}} required)',
},
documentsMissing: {
title: 'Documents missing',
message: 'Upload: {{items}}.',
andMore: 'and {{count}} more',
},
},
},
exams: {
title: 'Examinations',
openSessions: 'Open sessions',
noOpenSessions: 'No upcoming sessions are open for registration.',
registered: 'Registered',
register: 'Register',
myRegistrations: 'My registrations',
myResults: 'My results',
noRegistrations: 'No exam registrations yet.',
noResults:
'No results have been published yet. Marks appear here once the authority approves and publishes them.',
loading: 'Loading Exam Schedule…',
notify: {
registered: 'Registered — admission number {{admissionNumber}}',
admissionNumberPending: 'issued',
registerFailed: 'Could not register',
seafarerRequired:
'An active seafarer registration is required to sit examinations.',
alreadyRegistered: 'You are already registered for this session.',
alreadyPassed:
'You have already passed this subject — a resit is not needed.',
slipFailed: 'Could not generate the admission slip',
appealSubmitted: 'Appeal {{appealNumber}} submitted',
appealFailed: 'Could not submit the appeal',
appealWindowClosed:
'The appeal window ({{days}} days from publication) has closed.',
appealAlreadyOpen: 'An appeal on this result is already being considered.',
},
appealModal: {
title: 'Request a review of this result',
body: 'Explain what you believe went wrong with the marking or the administration of {{examTitle}}. Appeals must be lodged within 14 days of publication.',
defaultExamTitle: 'this examination',
reasonLabel: 'Grounds for appeal',
submit: 'Submit appeal',
},
columns: {
admission: 'Admission №',
examination: 'Examination',
date: 'Date',
venue: 'Venue',
attempt: 'Attempt',
attendance: 'Attendance',
slip: 'Slip',
published: 'Published',
score: 'Score',
outcome: 'Outcome',
appeal: 'Appeal',
retake: 'Retake · {{n}}',
firstSitting: 'First sitting',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',
ABSENT: 'Absent',
LATE: 'Late',
WITHDRAWN: 'Withdrawn',
DISQUALIFIED: 'Disqualified',
},
outcomeStatus: {
PASSED: 'Passed',
FAILED: 'Failed',
},
appealStatus: {
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under review',
UPHELD: 'Upheld',
REJECTED: 'Rejected',
},
},
},
endorsement: {
title: 'My Endorsements',
loading: 'Loading Endorsements…',
fetchFailed: 'Could not fetch endorsement',
eligibility: {
title: 'Eligibility',
registered: 'Registered seafarer ({{number}})',
registrationRequired: 'Active seafarer registration required',
},
endorseCoc: 'Endorse a CoC',
endorseGoc: 'Endorse a GOC',
registrationNotice: {
prefix: 'Complete your',
link: 'seafarer registration',
suffix: 'first — endorsement applications are refused without it.',
},
inProgress: 'Applications in progress',
issuedEndorsements: 'Issued endorsements',
emptyIssued: 'No endorsements issued yet.',
columns: {
certificateNumber: 'Certificate №',
type: 'Type',
issued: 'Issued',
expires: 'Expires',
licenseStatus: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Superseded',
},
},
},
seafarer: {
title: 'Seafarer Registration',
status: {
ACTIVE: 'Active',
PENDING: 'Pending',
SUSPENDED: 'Suspended',
INACTIVE: 'Inactive',
},
departments: {
DECK: 'Deck',
ENGINE: 'Engine',
CATERING: 'Catering',
},
registered: {
badgeTitle: 'Registered Seafarer',
badgeSubtitle: 'Your official seafarer profile with the Ethiopian Maritime Authority.',
seafarerNumber: 'Seafarer Number',
department: 'Department',
suspendedAlert: 'Your profile is suspended: {{reason}}',
recordsTitle: 'Sea service & medical records',
recordsSubtitle: 'Keep your sea-service history and medical certificates up to date — certificate and seaman-book applications draw on them.',
myRecords: 'My records',
},
inFlight: {
subtitle: 'Submitted registrations are reviewed by an EMA registration officer; you will be notified of every decision.',
needsAction: 'The registration officer asked for corrections. Open the application to see exactly what needs fixing.',
continueRegistration: 'Continue registration',
fixAndResubmit: 'Fix and resubmit',
viewApplication: 'View application',
},
notStarted: {
rejectedTitle: 'Previous registration rejected',
rejectedDefault: 'Your previous registration was rejected. You may register again.',
heading: 'Register as a seafarer',
body: 'Approval creates your official seafarer profile with a unique seafarer number — the identity every maritime service builds on.',
needsTitle: 'You will need:',
checklist: {
photo: 'A passport-size photograph',
id: 'Your National ID (Fayda) or Kebele ID',
certificate: 'Your educational certificate',
medical: 'A medical fitness certificate and passport, if you already hold them',
},
start: 'Start registration',
},
},
seaRecords: {
title: 'My Sea Records',
pageIntro: 'Records you add here are submitted for EMA verification. Once verified they are frozen and count toward certificate eligibility.',
tabs: {
seaService: 'Sea Service',
medical: 'Medical Certificates',
},
evidence: {
title: 'Evidence',
none: 'No evidence uploaded yet.',
upload: 'Upload evidence',
uploaded: 'Evidence uploaded',
},
seaService: {
description: 'Every engagement aboard a vessel, with its evidence. Verified records feed certificate eligibility.',
approvedSeaTime: 'Approved sea time: {{days}} days',
add: 'Add sea service',
empty: 'No sea-service records yet.',
tableName: 'Sea service',
modal: {
editTitle: 'Edit sea service',
addTitle: 'Add sea service',
},
fields: {
vesselName: 'Vessel name',
imoNumber: 'IMO number',
vesselType: 'Vessel type',
flagState: 'Flag state',
grossTonnage: 'Gross tonnage',
rank: 'Rank / capacity',
engagementDate: 'Engagement date',
dischargeDate: 'Discharge date',
duties: 'Duties',
},
addRecord: 'Add record',
updated: 'Sea-service record updated',
added: 'Sea-service record added',
saveFailed: 'Could not save the record',
withdrawn: 'Record withdrawn',
deleteFailed: 'Could not delete the record',
},
medical: {
description: 'STCW medical fitness certificates. An expired certificate blocks new applications that require one.',
add: 'Add certificate',
empty: 'No medical certificates yet.',
tableName: 'Medical certificates',
modal: {
editTitle: 'Edit medical certificate',
addTitle: 'Add medical certificate',
},
fields: {
issuerName: 'Issuing clinic / physician',
certificateNumber: 'Certificate number',
issueDate: 'Issue date',
expiryDate: 'Expiry date',
fitnessOutcome: 'Fitness outcome',
restrictions: 'Restrictions',
},
updated: 'Medical certificate updated',
added: 'Medical certificate added',
saveFailed: 'Could not save the certificate',
withdrawn: 'Certificate withdrawn',
deleteFailed: 'Could not delete the certificate',
},
columns: {
vessel: 'Vessel',
imo: 'IMO {{number}}',
rank: 'Rank',
from: 'From',
to: 'To',
issuer: 'Issuer',
certNumber: '№ {{number}}',
issued: 'Issued',
expires: 'Expires',
expired: 'Expired',
fitness: 'Fitness',
fitnessOptions: {
FIT: 'Fit',
FIT_WITH_RESTRICTIONS: 'Fit with restrictions',
UNFIT: 'Unfit',
},
recordStatus: {
SUBMITTED: 'Submitted',
VERIFIED: 'Verified',
REJECTED: 'Rejected',
},
},
actions: {
evidence: 'Evidence',
scanEvidence: 'Scan / evidence',
edit: 'Edit',
delete: 'Delete',
frozen: 'Verified records are frozen',
certificatesFrozen: 'Verified certificates are frozen',
},
},
vesselRegistration: {
title: 'Vessel Registration',
registerButton: 'Register a vessel',
suspendedAlert:
'A suspended vessel may not operate. Contact the Ethiopian Maritime Authority about reinstatement.',
inFlight: {
title: 'Registrations in progress',
renewalBadge: 'Renewal',
fix: 'Fix',
view: 'View',
},
myVessels: {
title: 'My vessels',
empty: {
title: 'No registered vessels yet',
body: 'Register an inland-waterway or sea-going vessel. Approval issues the registration certificate and enters the vessel in the national register.',
cta: 'Start registration',
},
},
notify: {
comingSoon: 'Amendment and duplicate-certificate services are coming in a later release.',
},
incident: {
modalTitle: 'Report incident — {{vesselName}}',
dateLabel: 'Date of occurrence',
locationLabel: 'Location',
descriptionLabel: 'What happened',
submit: 'Record incident',
recorded: 'Incident recorded',
recordFailed: 'Could not record the incident',
},
certificate: {
fetchFailed: 'Could not fetch the certificate',
},
renewal: {
startFailed: 'Could not start the renewal',
},
columns: {
registrationNumber: 'Registration №',
vessel: 'Vessel',
category: 'Category',
certificate: 'Certificate',
expiresIn: 'Expires in {{count}}d',
renew: 'Renew',
renewTooltip: 'Renew the registration',
certificateTooltip: 'Download certificate',
incident: 'Incident',
incidentTooltip: 'Report accident / incident',
categories: {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
},
},
status: {
notFound: 'Registration not found.',
title: 'Registration Status',
submitted: 'Submitted {{date}}',
officerRemarks: 'Officer Remarks',
pending: 'Pending',
resubmit: 'Resubmit Application',
certificatesTitle: 'Certificates',
certificateNumber: 'Certificate No. {{number}} — Issued {{date}}',
downloadedCount_one: 'Downloaded {{count}} time',
downloadedCount_other: 'Downloaded {{count}} times',
downloadedNotify: '{{certName}} downloaded.',
renewalOverdue: 'Registration is overdue for renewal.',
renewalOverdueWithExpiry: 'Registration is overdue for renewal — expires {{date}}.',
renewalDueSoon: 'Registration is due for renewal soon.',
renewalDueSoonWithExpiry: 'Registration is due for renewal soon — expires {{date}}.',
},
},
vesselTransfer: {
title: 'Ownership Transfer',
startTransfer: 'Start transfer',
startTransferDisabledTooltip: "Register a vessel first — there's nothing to transfer yet",
inFlight: {
title: 'Transfers in progress',
fix: 'Fix',
view: 'View',
},
myVessels: {
title: 'My vessels',
empty: {
title: 'No registered vessels yet',
body: 'Ownership can only be transferred for a vessel already on the register.',
cta: 'Go to Vessel Registration',
},
},
table: {
registrationNumber: 'Registration №',
vessel: 'Vessel',
category: 'Category',
transfer: 'Transfer',
transferTooltip: 'Start an ownership transfer for this vessel',
notTransferable: 'Not transferable',
categories: {
INLAND_WATERWAY: 'Inland Waterway',
SEA_GOING: 'Sea-going',
},
},
},
}; };
export type Translations = typeof en; export type Translations = typeof en;

View File

@@ -1,4 +1,4 @@
import { AppShell } from "@mantine/core"; import { AppShell, Drawer } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
IconArrowsExchange, IconArrowsExchange,
@@ -9,7 +9,6 @@ import {
IconHome2, IconHome2,
IconList, IconList,
IconRubberStamp, IconRubberStamp,
IconSend,
IconShieldCheck, IconShieldCheck,
IconShieldOff, IconShieldOff,
IconShip, IconShip,
@@ -264,7 +263,7 @@ export function PortalLayout() {
const handleLogout = () => { const handleLogout = () => {
dispatch(logout()); dispatch(logout());
dispatch(baseApi.util.resetApiState()); dispatch(baseApi.util.resetApiState());
navigate("/login"); navigate("/");
}; };
const displayName = user?.name?.en || user?.username || ""; const displayName = user?.name?.en || user?.username || "";
@@ -283,7 +282,10 @@ export function PortalLayout() {
navbar={{ navbar={{
width: sidebarCollapsed ? 72 : 264, width: sidebarCollapsed ? 72 : 264,
breakpoint: "sm", breakpoint: "sm",
collapsed: { mobile: !navOpened }, // 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 },
}} }}
padding="lg" padding="lg"
> >
@@ -334,6 +336,29 @@ export function PortalLayout() {
<Outlet /> <Outlet />
</div> </div>
</AppShell.Main> </AppShell.Main>
{/* 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. */}
<Drawer
opened={navOpened}
onClose={closeNav}
hiddenFrom="sm"
size="75%"
padding={0}
withCloseButton={false}
>
<AppSidebar
navItems={sections}
collapsed={false}
activePath={location.pathname}
onToggleCollapse={toggleSidebar}
onNavigate={go}
brandName={t("app.name")}
brandSubtitle={t("app.authority")}
brandLogo={<BrandMark size={32} />}
/>
</Drawer>
</AppShell> </AppShell>
); );
} }

View File

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

View File

@@ -464,6 +464,6 @@ export const router = createBrowserRouter([
}, },
// A typo'd URL should not maroon a signed-in user on the marketing page — // A typo'd URL should not maroon a signed-in user on the marketing page —
// ProtectedRoute sends anonymous visitors on to /login exactly as before. // ProtectedRoute sends anonymous visitors on to / (landing) instead.
{ path: "*", element: <Navigate to="/dashboard" replace /> }, { path: "*", element: <Navigate to="/dashboard" replace /> },
]); ]);

View File

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

View File

@@ -10,6 +10,15 @@ export default defineConfig({
envDir: '../../', envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/portal', cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' }, server: { port: 4200, host: 'localhost' },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4200, host: 'localhost' }, preview: { port: 4200, host: 'localhost' },
plugins: [react(), nxViteTsPaths()], plugins: [react(), nxViteTsPaths()],
resolve: { resolve: {

View File

@@ -0,0 +1,480 @@
# Vessel registration report — frontend integration brief
Paste the **Prompt** section below to Claude Code from the `emaui` repo root.
Everything after it is reference the prompt points at.
---
## Prompt
> Wire up the vessel registration report dashboard in the backoffice app.
>
> The backend endpoint is **new and already deployed** — `GET /api/vessels/report`
> plus `GET /api/vessels/report/export` (CSV). Nothing about it is mocked; do not
> invent sample data, and do not add a mock branch to `mock-base-query.ts`.
>
> The page it belongs on already exists as a placeholder:
> `apps/backoffice/src/app/features/vessel-registration/pages/VesselRegistrationReportPage.tsx`
> currently renders `<FeatureUnavailable />`, and its route is commented out at
> `apps/backoffice/src/app/router/index.tsx:98`. Replace the placeholder with the
> real dashboard and re-enable the route, guarded by `P.VIEW_VESSEL_REGISTRY`
> exactly like the vessel queue route two lines above it.
>
> Read `docs/vessel-registration-report-frontend.md` in this repo for the full
> response contract, the chart plan, and the conventions to follow. Follow the
> conventions already in the codebase over anything you would do by default:
> RTK Query in `libs/api`, Mantine 8 for layout, `recharts` for charts (already a
> dependency, not yet used anywhere — you are establishing the pattern), i18next
> for every user-visible string.
>
> Scope, in order:
> 1. Types + RTK Query endpoints in `libs/api/src/lib/features/vessel/`.
> 2. The page: filter bar, KPI tiles, charts, tables.
> 3. Export button.
> 4. Route + nav.
> 5. A vitest test for whatever pure logic you extract.
>
> Ask me before adding any new dependency. `recharts`, `@mantine/*`,
> `@mantine/dates`, `dayjs` and `@tabler/icons-react` are all already installed.
---
## 1. What the endpoint is
| | |
|---|---|
| Report | `GET /api/vessels/report` → JSON |
| Export | `GET /api/vessels/report/export``text/csv` |
| Permission | `can:View:vessel-registry` (`P.VIEW_VESSEL_REGISTRY`, `libs/auth/src/lib/permissions.constants.ts:48`) |
| Auth | Bearer, same as every other backoffice call |
One call fills the whole dashboard. Both routes take the **same** query
parameters, so the export button reuses whatever the filter bar holds.
Backend source, if you need to check a figure:
`emaback/emaapi/apps/server/emaapi/src/module/vessel/services/vessel-report.service.ts`.
### Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
| `from` | ISO date | 12 months before `to` | bounds the **time series and "in period" figures only** |
| `to` | ISO date | now | a bare `YYYY-MM-DD` covers that whole day |
| `granularity` | `DAY \| WEEK \| MONTH` | `MONTH` | bucket width; weeks are Monday-anchored |
| `category` | `SEA_GOING \| INLAND_WATERWAY`, repeatable or CSV | all | |
| `status` | `REGISTERED \| SUSPENDED \| DEREGISTERED`, repeatable or CSV | all | |
| `flagState` | string[], repeatable or CSV | all | |
| `portOfRegistry` | string[], repeatable or CSV | all | |
| `vesselType` | string[], repeatable or CSV | all | |
| `search` | string | — | name / register number / IMO / owner name |
| `expiringWithinDays` | 1365 | 90 | horizon for the expiring-certificates table |
| `topN` | 150 | 15 | slices kept per high-cardinality chart |
| `tableLimit` | 1200 | 10 | rows per table |
Arrays accept both `?status=A&status=B` and `?status=A,B`. RTK Query's `params`
serialises the array form correctly — pass arrays, not joined strings.
**Important distinction to carry into the UI copy:** the register-wide totals
(`kpis.register.total`, the status mix, every `breakdowns.*`) are **not**
windowed. Only `registeredInPeriod`, `submittedInPeriod`, `decidedInPeriod`,
`incidents.inPeriod` and the whole `timeSeries` block respect `from`/`to`.
Label the tiles accordingly or the dashboard will be misread.
## 2. Response contract
Add these to `libs/api/src/lib/features/vessel/vessel.types.ts`. Numeric fields
are real numbers (the backend already casts pg `numeric` strings) — unlike the
existing `Vessel` type, which still carries `string | number`.
```ts
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
/** One slice of a breakdown chart. Percentages are of the whole, and sum to 100. */
export interface BreakdownItem {
key: string;
label: string;
count: number;
percentage: number;
}
export interface VesselReportQuery {
from?: string;
to?: string;
granularity?: ReportGranularity;
category?: VesselCategory[];
status?: VesselStatus[];
flagState?: string[];
portOfRegistry?: string[];
vesselType?: string[];
search?: string;
expiringWithinDays?: number;
topN?: number;
tableLimit?: number;
}
export interface VesselReport {
generatedAt: string;
/** True when the register exceeded the 50k scan cap — figures are partial. */
truncated: boolean;
filters: Required<Pick<VesselReportQuery, 'granularity'>> & {
from: string;
to: string;
expiringWithinDays: number;
topN: number;
tableLimit: number;
category: VesselCategory[] | null;
status: VesselStatus[] | null;
flagState: string[] | null;
portOfRegistry: string[] | null;
vesselType: string[] | null;
search: string | null;
};
kpis: {
register: {
total: number;
registered: number;
suspended: number;
deregistered: number;
registeredInPeriod: number;
registeredInPreviousPeriod: number;
/** null when there is no previous period to compare against. */
changePct: number | null;
};
fleet: {
totalGrossTonnage: number;
avgGrossTonnage: number | null;
/** How many hulls the tonnage average actually covers. */
grossTonnageKnownFor: number;
totalPassengerCapacity: number;
avgLengthMeters: number | null;
avgAgeYears: number | null;
ageKnownFor: number;
seaGoing: number;
inlandWaterway: number;
};
pipeline: {
total: number;
draft: number;
inProgress: number;
approved: number;
rejected: number;
issued: number;
submittedInPeriod: number;
decidedInPeriod: number;
newCount: number;
renewalCount: number;
/** Approved ÷ settled. null when nothing has been decided yet. */
approvalRatePct: number | null;
avgProcessingDays: number | null;
medianProcessingDays: number | null;
avgAdjustmentRounds: number | null;
};
certificates: {
total: number;
active: number;
expired: number;
suspended: number;
/** Cumulative: a cert due in 11 days is in all three. */
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
missingCertificate: number;
};
incidents: {
total: number;
inPeriod: number;
reportedByOfficer: number;
reportedByOwner: number;
vesselsWithIncidents: number;
};
revenue: {
currency: string;
/** True when the register holds more than one currency — warn, don't sum blindly. */
mixedCurrency: boolean;
paid: number;
pending: number;
paidCount: number;
pendingCount: number;
failedCount: number;
};
};
timeSeries: {
/** `bucket` is an ISO date. Zero-filled across the window — no gaps. */
registrations: Array<{ bucket: string; count: number; grossTonnage: number }>;
applications: Array<{
bucket: string;
submitted: number;
approved: number;
rejected: number;
issued: number;
}>;
incidents: Array<{ bucket: string; count: number }>;
revenue: Array<{ bucket: string; amount: number; count: number }>;
};
breakdowns: {
byStatus: BreakdownItem[];
byCategory: BreakdownItem[];
byFlagState: BreakdownItem[];
byPortOfRegistry: BreakdownItem[];
byVesselType: BreakdownItem[];
byHullMaterial: BreakdownItem[];
byEngineType: BreakdownItem[];
byTonnageBand: BreakdownItem[];
byLengthBand: BreakdownItem[];
byAgeBand: BreakdownItem[];
byBuildDecade: BreakdownItem[];
byApplicationStatus: BreakdownItem[];
byApplicationKind: BreakdownItem[];
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
byOfficer: BreakdownItem[];
byIncidentSeverity: BreakdownItem[];
};
tables: {
expiringCertificates: Array<{
vesselId: string;
registrationNumber: string;
name: string;
ownerName: string | null;
ownerUserId: string;
certificateNumber: string | null;
expiryDate: string;
certificateStatus: string | null;
/** 0 means it expires today, which still counts as live. */
daysToExpiry: number;
}>;
recentRegistrations: Array<{
vesselId: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
flagState: string | null;
grossTonnage: number | null;
ownerName: string | null;
status: VesselStatus;
registeredAt: string;
}>;
recentIncidents: Array<{
id: string;
vesselId: string;
registrationNumber: string;
vesselName: string;
occurredAt: string;
severity: string | null;
location: string | null;
description: string;
reportedByOfficer: boolean;
}>;
pendingApplications: Array<{
applicationNumber: string;
status: string;
kind: 'NEW' | 'RENEWAL';
assignedOfficerId: string | null;
submittedAt: string | null;
adjustmentRound: number;
daysOpen: number;
}>;
};
}
```
### Contract details that will bite if ignored
- **`null` is not `0`.** Averages come back `null` when nothing measurable
exists (an empty register, no decided applications). Render an em dash, never
`0` or `NaN`. Same for `changePct` and `approvalRatePct`.
- **`grossTonnageKnownFor` / `ageKnownFor`** say how much of the fleet the
average covers. Show it as sub-text on the tile — an average over 2 of 300
hulls is misleading on its own.
- **`Unknown`** is a real breakdown key (missing flag state, no build year). It
is deliberate; do not filter it out.
- **`OTHER`** appears as the last slice of a capped breakdown, labelled
`Other (n)`. It exists so slices still sum to the total — do not drop it.
- **Expiry buckets are cumulative.** If you draw them as a bar chart, either
say "within 30 / 60 / 90 days" or difference them yourself into disjoint
bands. Do not present cumulative counts as if they were disjoint.
- **`truncated: true`** means the register passed the 50k scan cap and every
figure is partial. Show a persistent warning banner when it is set.
- **`byOfficer.key` is a uuid**, not a name. Resolve it against whatever user
lookup the backoffice already uses, or show a shortened id. Do not print the
raw uuid as a chart axis label.
- **`mixedCurrency: true`** means revenue was summed across currencies. Warn
rather than showing one total.
## 3. Where the code goes
### 3.1 API layer — `libs/api/src/lib/features/vessel/`
Extend the existing slice; do not create a new one.
`vessel-api.ts` already uses `baseApi.enhanceEndpoints({ addTagTypes: TAGS })`
followed by `injectEndpoints` — add to it:
```ts
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
query: (params) => ({ url: '/vessels/report', params: params ?? undefined }),
providesTags: () => [listTag('Vessel')],
}),
```
Export `useGetVesselReportQuery` from the bottom of the file and re-export the
new types through `vessel.types.ts` (already barrelled by `index.ts`).
**The CSV export is not an RTK Query endpoint.** `fetchBaseQuery` parses
responses as JSON and would mangle it. Follow the precedent in
`libs/api/src/lib/base-api/download.ts`: `openAuthedDocument` fetches with the
bearer token into a blob. Either reuse it or add a sibling
`downloadAuthedFile(path, fallbackName)` next to it that forces the anchor
download path rather than `window.open`. Note the backend sets
`Content-Disposition`, `X-Total-Rows` and `X-Truncated`, and the API's CORS
config exposes all three — read the filename from the header and fall back to a
local default only if it is absent.
### 3.2 The page — `apps/backoffice/src/app/features/vessel-registration/`
Replace `pages/VesselRegistrationReportPage.tsx`. Split it rather than shipping
one 600-line file; suggested layout, matching how `VesselRegistrationQueuePage`
is already organised as a directory:
```
pages/VesselRegistrationReportPage/
index.tsx // page shell: PageHeader, filter bar, layout, states
ReportFilters.tsx // the filter bar
KpiTiles.tsx
ReportCharts.tsx
ReportTables.tsx
report-format.ts // pure: em-dash formatting, cumulative→disjoint, palette
report-format.spec.ts // vitest
```
Keep the route import path working (`../features/vessel-registration/pages/VesselRegistrationReportPage`
resolves to the directory's `index.tsx`).
### 3.3 Route + nav
`apps/backoffice/src/app/router/index.tsx:98` — uncomment and guard it, matching
line 95:
```tsx
{ path: 'vessel-registration-report', element: guard([P.VIEW_VESSEL_REGISTRY], <VesselRegistrationReportPage />) },
```
Then add the nav entry wherever `vessel-registration-queue` is listed in the
sidebar config, gated on the same permission.
## 4. What to render
Use Mantine `Grid`/`SimpleGrid` for layout and `recharts` `<ResponsiveContainer>`
for every chart. Recharts is installed but unused — you are setting the house
style, so put shared axis/tooltip/colour setup in one place rather than
repeating props per chart.
### Filter bar (sticky, top)
Date range (`@mantine/dates` `DatePickerInput type="range"`), granularity
`SegmentedControl`, multi-selects for category / status / flag state / port /
vessel type, a debounced search input, and the export button. Seed the
multi-select options from the first response's `breakdowns` keys — no separate
lookup endpoint exists. Mirror the filter state into the URL query string so a
filtered dashboard is shareable, which is how the licence queue already behaves.
### KPI tiles (row 1)
| Tile | Fields |
|---|---|
| Registered vessels | `register.total`, with `registered / suspended / deregistered` beneath |
| New in period | `register.registeredInPeriod`, delta chip from `register.changePct` |
| Fleet tonnage | `fleet.totalGrossTonnage`, sub-text avg + `grossTonnageKnownFor` |
| Average age | `fleet.avgAgeYears`, sub-text `ageKnownFor` |
| Approval rate | `pipeline.approvalRatePct`, sub-text approved/rejected |
| Processing time | `pipeline.medianProcessingDays` median, avg as sub-text |
| Expiring soon | `certificates.expiringIn30`, sub-text 60/90 |
| Fees collected | `revenue.paid` + currency, sub-text pending |
### Charts (row 2+)
| Chart | Data | Type |
|---|---|---|
| Registrations over time | `timeSeries.registrations` | area or bar, `count`; tonnage on a second axis |
| Application throughput | `timeSeries.applications` | stacked bar — submitted vs approved vs rejected |
| Fees over time | `timeSeries.revenue` | line |
| Incidents over time | `timeSeries.incidents` | bar |
| Register status mix | `breakdowns.byStatus` | donut |
| Category split | `breakdowns.byCategory` | donut |
| Tonnage bands | `breakdowns.byTonnageBand` | horizontal bar |
| Age bands | `breakdowns.byAgeBand` | horizontal bar |
| Top flag states | `breakdowns.byFlagState` | horizontal bar |
| Top ports of registry | `breakdowns.byPortOfRegistry` | horizontal bar |
| Vessel types | `breakdowns.byVesselType` | horizontal bar |
| Application status funnel | `breakdowns.byApplicationStatus` | horizontal bar |
| Officer workload | `breakdowns.byOfficer` | horizontal bar, ids resolved to names |
| Incident severity | `breakdowns.byIncidentSeverity` | donut |
`BreakdownItem` is already chart-shaped: `label` on the axis, `count` as the
value, `percentage` in the tooltip. Do not recompute percentages.
Every breakdown can be empty (`[]`) on a fresh register — render `<EmptyState />`
from `@ema-platform/ui` inside the card, not an empty axis.
### Tables (bottom)
Use `AdvancedTable` from `@ema-platform/ui` (already exported from
`libs/ui/src/index.ts`). All four tables are server-limited by `tableLimit`, so
they are **not** paginated — do not wire pagination controls to them. Each gets
a "view all" link to the corresponding existing screen where one exists
(register, incident log, application queue).
- **Expiring certificates** — the renewals worklist. Colour `daysToExpiry`:
red ≤ 7, orange ≤ 30, otherwise neutral. `0` means today, still live.
- **Recent registrations** — link each row to the vessel detail screen.
- **Recent incidents** — severity is free text and may be `null`.
- **Pending applications** — sorted by `daysOpen` descending; link to the review
screen by `applicationNumber`.
### States
- Loading — `<PageLoader />`.
- Error — `<ApiErrorAlert />`, and use `useErrorHandler` if that is the pattern
in neighbouring pages.
- Empty register (`register.total === 0`) — `<EmptyState />` for the whole page,
explaining that no vessels are registered yet, rather than a grid of zeros.
- `truncated === true` — a persistent `Alert color="yellow"` above the tiles.
## 5. Rules
1. **No new dependencies** without asking. Everything needed is installed.
2. **Every user-visible string through i18next**, including chart axis labels,
tooltip text and band names. Note that band labels
(`"100499 GT"`, `"30 years and older"`, `"Unknown"`) arrive from the API
already rendered — map them to translation keys rather than printing raw
English into an Amharic UI.
3. **No client-side aggregation.** If a figure is not in the response, ask for
a backend change rather than deriving it in the browser. The one exception
is differencing the cumulative expiry buckets, which is presentational.
4. **Do not touch `mock-base-query.ts`.** This endpoint is live.
5. **Extract the pure bits** (formatters, cumulative→disjoint, colour
assignment) into `report-format.ts` and cover them with one vitest file. Do
not write component tests unless asked.
6. **Dates**`dayjs` is installed and used elsewhere. Backoffice dates render
in Gregorian; do not pull in the Ethiopic pickers unless neighbouring
backoffice pages already do.
7. Match the file, import and naming conventions of
`features/vessel-registration/pages/VesselRegistrationQueuePage/` — it is the
nearest sibling and the closest thing to a template.
## 6. Verifying
1. `npx nx run backoffice:build` and the repo's lint task must pass.
2. `npx nx test api` / the vitest task for whatever project holds
`report-format.spec.ts`.
3. Run the backoffice against a local API, sign in as a user holding
`can:View:vessel-registry`, and open `/vessel-registration-report`:
- tiles match `GET /api/vessels/report` in the network tab;
- changing the date range refetches and redraws only the time series, while
`register.total` stays put;
- `granularity=DAY` produces one bucket per day, zeros included;
- the export button downloads a CSV whose row count equals
`kpis.register.total`.
4. Sign in **without** the permission — the route must not resolve and the nav
entry must not appear.
5. Point at a database with an empty vessel register and confirm the page shows
the empty state rather than zeros, `NaN`, or a crash.

View File

@@ -6,4 +6,4 @@ export * from './lib/features/location';
export * from './lib/features/seafarer'; export * from './lib/features/seafarer';
export * from './lib/features/vessel'; export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument } from './lib/base-api/download'; export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -41,8 +41,13 @@ export const baseQueryWithReauth: BaseQueryFn<
try { try {
await _onTokenExpired(); await _onTokenExpired();
result = await baseQuery(args, api, extraOptions); result = await baseQuery(args, api, extraOptions);
} catch { } catch (err) {
_onAuthFailure?.(); // Only a rejected refresh token ends the session. A network blip or a
// 5xx leaves the original 401 for the screen to report, rather than
// throwing the user out of a session that is still valid.
if ((err as { sessionExpired?: boolean })?.sessionExpired) {
_onAuthFailure?.();
}
} }
} else { } else {
_onAuthFailure?.(); _onAuthFailure?.();

View File

@@ -41,3 +41,57 @@ export async function openAuthedDocument(
// Revoking immediately would race the new tab's load. // Revoking immediately would race the new tab's load.
setTimeout(() => URL.revokeObjectURL(url), 60_000); setTimeout(() => URL.revokeObjectURL(url), 60_000);
} }
/**
* Downloads an authenticated endpoint straight to a file.
*
* Same reason as `openAuthedDocument` for bypassing RTK Query — `fetchBaseQuery`
* would parse a CSV body as JSON — but a spreadsheet is something you save, not
* something the browser can display, so this always takes the anchor path.
*
* The server names the file via `Content-Disposition`, and the API's CORS
* config exposes that header along with `X-Total-Rows` and `X-Truncated`; those
* two are returned so a caller can say when an export was cut short instead of
* handing over a silently partial file.
*/
export async function downloadAuthedFile(
path: string,
fallbackName: string,
): Promise<{ rowCount: number | null; truncated: boolean }> {
const token = resolveTokenFromStorage();
const response = await fetch(`${BASE_API_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
let message = `${response.status}`;
try {
const body = await response.json();
message = body?.message ?? message;
} catch {
/* non-JSON error body — the status is all we have */
}
throw new Error(message);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filenameFrom(response.headers) ?? fallbackName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
const rows = response.headers.get('X-Total-Rows');
return {
rowCount: rows === null ? null : Number(rows),
truncated: response.headers.get('X-Truncated') === 'true',
};
}
/** `attachment; filename="vessel-register-2026-08-18.csv"` → the file name. */
function filenameFrom(headers: Headers): string | null {
const disposition = headers.get('Content-Disposition');
if (!disposition) return null;
const match = /filename="?([^";]+)"?/.exec(disposition);
return match?.[1] ?? null;
}

View File

@@ -398,8 +398,11 @@ export const licensingApi = baseApi
* claim or a decision refreshes the badges along with the list and the * claim or a decision refreshes the badges along with the list and the
* numbers can never drift from what the grid is showing. * numbers can never drift from what the grid is showing.
*/ */
getQueueCounts: builder.query<QueueCounts, void>({ getQueueCounts: builder.query<QueueCounts, string | void>({
query: () => ({ url: '/license-application-review/counts' }), query: (licenseTypeKey) => ({
url: '/license-application-review/counts',
params: licenseTypeKey ? { licenseTypeKey } : undefined,
}),
providesTags: () => [listTag('ApplicationQueue')], providesTags: () => [listTag('ApplicationQueue')],
}), }),

View File

@@ -173,6 +173,11 @@ export interface LicenseType {
*/ */
requiresOperatorMode: boolean; requiresOperatorMode: boolean;
formSchema: { sections: FormSectionConfig[] }; formSchema: { sections: FormSectionConfig[] };
/**
* Which course an application of this type runs. REGISTRATION skips the
* evaluation and inspection stages, so those statuses are unreachable for it.
*/
workflowProfile?: WorkflowProfile;
isActive: boolean; isActive: boolean;
/** Display order set by EMA; lower comes first. */ /** Display order set by EMA; lower comes first. */
sortOrder: number; sortOrder: number;
@@ -446,6 +451,9 @@ export type QueueSortField =
| "dueAt" | "dueAt"
| "claimedAt"; | "claimedAt";
/** Review → evaluation → (inspection) → approval, or the short registration course. */
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
/** Row counts behind the queue's saved-view tabs. */ /** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts { export interface QueueCounts {
unassigned: number; unassigned: number;

View File

@@ -3,6 +3,7 @@ import type {
CreateMedicalCertificate, CreateMedicalCertificate,
CreateSeaServiceRecord, CreateSeaServiceRecord,
MedicalCertificate, MedicalCertificate,
RecordQueueFilter,
SeaServiceRecord, SeaServiceRecord,
SeaTimeSummary, SeaTimeSummary,
SeafarerStatus, SeafarerStatus,
@@ -104,13 +105,25 @@ export const seafarerApi = baseApi
}), }),
// -------------------------------------------------------- verification // -------------------------------------------------------- verification
getPendingSeaService: builder.query<SeaServiceRecord[], void>({ getPendingSeaService: builder.query<
query: () => ({ url: '/sea-service-records/pending' }), SeaServiceRecord[],
RecordQueueFilter | void
>({
query: (status) => ({
url: '/sea-service-records/pending',
params: { status: status || 'SUBMITTED' },
}),
providesTags: () => [listTag('SeaServiceRecord')], providesTags: () => [listTag('SeaServiceRecord')],
}), }),
getPendingMedical: builder.query<MedicalCertificate[], void>({ getPendingMedical: builder.query<
query: () => ({ url: '/medical-certificates/pending' }), MedicalCertificate[],
RecordQueueFilter | void
>({
query: (status) => ({
url: '/medical-certificates/pending',
params: { status: status || 'SUBMITTED' },
}),
providesTags: () => [listTag('MedicalCertificate')], providesTags: () => [listTag('MedicalCertificate')],
}), }),

View File

@@ -1,4 +1,7 @@
export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED'; export type SeafarerRecordStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED';
/** Verification-queue filter: a record status, or ALL for no filter. */
export type RecordQueueFilter = SeafarerRecordStatus | 'ALL';
export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT'; export type MedicalFitness = 'FIT' | 'FIT_WITH_RESTRICTIONS' | 'UNFIT';
export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING'; export type SeafarerDepartment = 'DECK' | 'ENGINE' | 'CATERING';
export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED'; export type SeafarerStatus = 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED';

View File

@@ -3,6 +3,8 @@ import type {
CreateVesselIncident, CreateVesselIncident,
Vessel, Vessel,
VesselIncident, VesselIncident,
VesselReport,
VesselReportQuery,
VesselStatus, VesselStatus,
} from './vessel.types'; } from './vessel.types';
@@ -35,6 +37,22 @@ export const vesselApi = baseApi
providesTags: () => [listTag('Vessel')], providesTags: () => [listTag('Vessel')],
}), }),
/**
* The whole backoffice dashboard in one call — KPIs, time series,
* breakdowns and worklists. Backoffice only (`can:View:vessel-registry`).
*
* Array filters are passed as arrays, not joined strings: the API accepts
* both the repeated and the comma-separated form, and `params` serialises
* the repeated one.
*/
getVesselReport: builder.query<VesselReport, VesselReportQuery | void>({
query: (params) => ({
url: '/vessels/report',
params: params ?? undefined,
}),
providesTags: () => [listTag('Vessel')],
}),
getVessel: builder.query<Vessel, string>({ getVessel: builder.query<Vessel, string>({
query: (id) => ({ url: `/vessels/${id}` }), query: (id) => ({ url: `/vessels/${id}` }),
providesTags: (_r, _e, id) => [{ type: 'Vessel', id }], providesTags: (_r, _e, id) => [{ type: 'Vessel', id }],
@@ -77,6 +95,7 @@ export const vesselApi = baseApi
export const { export const {
useGetMyVesselsQuery, useGetMyVesselsQuery,
useGetVesselsQuery, useGetVesselsQuery,
useGetVesselReportQuery,
useGetVesselQuery, useGetVesselQuery,
useUpdateVesselStatusMutation, useUpdateVesselStatusMutation,
useGetVesselIncidentsQuery, useGetVesselIncidentsQuery,

View File

@@ -49,3 +49,247 @@ export interface CreateVesselIncident {
description: string; description: string;
severity?: string; severity?: string;
} }
// ---------------------------------------------------------------------------
// Vessel registration report (GET /vessels/report)
//
// One call fills the whole backoffice dashboard. Unlike `Vessel` above, every
// numeric field here is already a real number — the API casts the Postgres
// `numeric` strings before it answers.
export type ReportGranularity = 'DAY' | 'WEEK' | 'MONTH';
/**
* One slice of a breakdown chart.
*
* `percentage` is of the whole, not of the slices that survived the `topN`
* cut, so a set of slices always totals 100.
*/
export interface BreakdownItem {
key: string;
label: string;
count: number;
percentage: number;
}
export interface VesselReportQuery {
/** Bounds the time series and the "in period" figures only. */
from?: string;
to?: string;
granularity?: ReportGranularity;
category?: VesselCategory[];
status?: VesselStatus[];
flagState?: string[];
portOfRegistry?: string[];
vesselType?: string[];
search?: string;
expiringWithinDays?: number;
/** Slices kept per high-cardinality chart; the tail collapses into "Other". */
topN?: number;
tableLimit?: number;
}
export interface RegisterKpis {
total: number;
registered: number;
suspended: number;
deregistered: number;
registeredInPeriod: number;
registeredInPreviousPeriod: number;
/** Null when there is no previous period to compare against. */
changePct: number | null;
}
export interface FleetKpis {
totalGrossTonnage: number;
avgGrossTonnage: number | null;
/** How many hulls the tonnage average actually covers. */
grossTonnageKnownFor: number;
totalPassengerCapacity: number;
avgLengthMeters: number | null;
avgAgeYears: number | null;
ageKnownFor: number;
seaGoing: number;
inlandWaterway: number;
}
export interface PipelineKpis {
total: number;
draft: number;
inProgress: number;
approved: number;
rejected: number;
issued: number;
submittedInPeriod: number;
decidedInPeriod: number;
newCount: number;
renewalCount: number;
/** Approved over settled. Null while nothing has been decided. */
approvalRatePct: number | null;
avgProcessingDays: number | null;
medianProcessingDays: number | null;
avgAdjustmentRounds: number | null;
}
export interface CertificateKpis {
total: number;
active: number;
expired: number;
suspended: number;
/** Cumulative: a certificate due in 11 days is inside all three. */
expiringIn30: number;
expiringIn60: number;
expiringIn90: number;
missingCertificate: number;
}
export interface IncidentKpis {
total: number;
inPeriod: number;
reportedByOfficer: number;
reportedByOwner: number;
vesselsWithIncidents: number;
}
export interface RevenueKpis {
currency: string;
/** True when more than one currency was summed — warn rather than total. */
mixedCurrency: boolean;
paid: number;
pending: number;
paidCount: number;
pendingCount: number;
failedCount: number;
}
/** Bucketed series. `bucket` is an ISO date; the window is zero-filled. */
export interface RegistrationBucket {
bucket: string;
count: number;
grossTonnage: number;
}
export interface ApplicationBucket {
bucket: string;
submitted: number;
approved: number;
rejected: number;
issued: number;
}
export interface IncidentBucket {
bucket: string;
count: number;
}
export interface RevenueBucket {
bucket: string;
amount: number;
count: number;
}
export interface ExpiringCertificateRow {
vesselId: string;
registrationNumber: string;
name: string;
ownerName: string | null;
ownerUserId: string;
certificateNumber: string | null;
expiryDate: string;
certificateStatus: string | null;
/** 0 means it expires today, which still counts as live. */
daysToExpiry: number;
}
export interface RecentRegistrationRow {
vesselId: string;
registrationNumber: string;
name: string;
category: VesselCategory;
vesselType: string | null;
flagState: string | null;
grossTonnage: number | null;
ownerName: string | null;
status: VesselStatus;
registeredAt: string;
}
export interface RecentIncidentRow {
id: string;
vesselId: string;
registrationNumber: string;
vesselName: string;
occurredAt: string;
severity: string | null;
location: string | null;
description: string;
reportedByOfficer: boolean;
}
export interface PendingApplicationRow {
applicationNumber: string;
status: string;
kind: 'NEW' | 'RENEWAL';
assignedOfficerId: string | null;
submittedAt: string | null;
adjustmentRound: number;
daysOpen: number;
}
export interface VesselReport {
generatedAt: string;
/** True when the register passed the API's scan cap — figures are partial. */
truncated: boolean;
filters: {
from: string;
to: string;
granularity: ReportGranularity;
expiringWithinDays: number;
topN: number;
tableLimit: number;
category: VesselCategory[] | null;
status: VesselStatus[] | null;
flagState: string[] | null;
portOfRegistry: string[] | null;
vesselType: string[] | null;
search: string | null;
};
kpis: {
register: RegisterKpis;
fleet: FleetKpis;
pipeline: PipelineKpis;
certificates: CertificateKpis;
incidents: IncidentKpis;
revenue: RevenueKpis;
};
timeSeries: {
registrations: RegistrationBucket[];
applications: ApplicationBucket[];
incidents: IncidentBucket[];
revenue: RevenueBucket[];
};
breakdowns: {
byStatus: BreakdownItem[];
byCategory: BreakdownItem[];
byFlagState: BreakdownItem[];
byPortOfRegistry: BreakdownItem[];
byVesselType: BreakdownItem[];
byHullMaterial: BreakdownItem[];
byEngineType: BreakdownItem[];
byTonnageBand: BreakdownItem[];
byLengthBand: BreakdownItem[];
byAgeBand: BreakdownItem[];
byBuildDecade: BreakdownItem[];
byApplicationStatus: BreakdownItem[];
byApplicationKind: BreakdownItem[];
/** `key` is an IAM user uuid, or the literal "UNASSIGNED". */
byOfficer: BreakdownItem[];
byIncidentSeverity: BreakdownItem[];
};
tables: {
expiringCertificates: ExpiringCertificateRow[];
recentRegistrations: RecentRegistrationRow[];
recentIncidents: RecentIncidentRow[];
pendingApplications: PendingApplicationRow[];
};
}

View File

@@ -26,7 +26,7 @@ const queryApi = baseApi.injectEndpoints({
overrideExisting: false, overrideExisting: false,
}); });
export const { useApiQueryQuery, useApiMutationMutation } = queryApi; export const { useApiQueryQuery, useLazyApiQueryQuery, useApiMutationMutation } = queryApi;
export function useApiQuery<TData = unknown>( export function useApiQuery<TData = unknown>(
args: ApiQueryArgs, args: ApiQueryArgs,
@@ -37,6 +37,17 @@ export function useApiQuery<TData = unknown>(
}; };
} }
/**
* Same endpoint as `useApiQuery`, fetched on demand instead of on render — for
* the case where the arguments are only known at click time.
*/
export function useApiLazyQuery<TData = unknown>(): [
(args: ApiQueryArgs) => { unwrap: () => Promise<TData> },
] {
const [trigger] = useLazyApiQueryQuery();
return [trigger as unknown as (args: ApiQueryArgs) => { unwrap: () => Promise<TData> }];
}
type UseApiMutationResult<TData> = { type UseApiMutationResult<TData> = {
data: TData | undefined; data: TData | undefined;
isLoading: boolean; isLoading: boolean;

Some files were not shown because too many files have changed in this diff Show More