mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 ?? [];
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Container } from '@mantine/core';
|
|
||||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder until this feature has a backend.
|
|
||||||
*
|
|
||||||
* This page previously rendered hardcoded sample records, which were
|
|
||||||
* indistinguishable from real ones.
|
|
||||||
*/
|
|
||||||
export function VesselRegistrationHeadDashboardPage() {
|
|
||||||
return (
|
|
||||||
<Container size="lg" py="xl">
|
|
||||||
<FeatureUnavailable
|
|
||||||
title="Vessel registration overview"
|
|
||||||
description="Vessel registration is not connected to the backend yet, so there are no figures to report."
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VesselRegistrationHeadDashboardPage;
|
|
||||||
@@ -109,7 +109,7 @@ function VesselDetailDrawer({
|
|||||||
</Text>
|
</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.
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Container } from '@mantine/core';
|
|
||||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder until this feature has a backend.
|
|
||||||
*
|
|
||||||
* This page previously rendered hardcoded sample records, which were
|
|
||||||
* indistinguishable from real ones.
|
|
||||||
*/
|
|
||||||
export function VesselRegistrationReportPage() {
|
|
||||||
return (
|
|
||||||
<Container size="lg" py="xl">
|
|
||||||
<FeatureUnavailable
|
|
||||||
title="Vessel registration report"
|
|
||||||
description="Vessel registration is not connected to the backend yet, so there is nothing to report on."
|
|
||||||
/>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VesselRegistrationReportPage;
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Badge, Card, Group, SimpleGrid, Text, Tooltip } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconAlarm,
|
||||||
|
IconAnchor,
|
||||||
|
IconCalendarStats,
|
||||||
|
IconCoin,
|
||||||
|
IconClockHour4,
|
||||||
|
IconScale,
|
||||||
|
IconShip,
|
||||||
|
IconThumbUp,
|
||||||
|
type Icon,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import type { VesselReport } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
DASH,
|
||||||
|
deltaColor,
|
||||||
|
formatDelta,
|
||||||
|
formatMoney,
|
||||||
|
formatNumber,
|
||||||
|
formatPercent,
|
||||||
|
} from './report-format';
|
||||||
|
|
||||||
|
interface TileProps {
|
||||||
|
icon: Icon;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
/** The second line: what the headline figure is made of. */
|
||||||
|
detail?: string;
|
||||||
|
/** Hover text for anything the headline alone would misrepresent. */
|
||||||
|
hint?: string;
|
||||||
|
delta?: { text: string; color: string };
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({ icon: TileIcon, label, value, detail, hint, delta, color = 'blue' }: TileProps) {
|
||||||
|
const card = (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.3}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TileIcon size={18} stroke={1.6} color={`var(--mantine-color-${color}-6)`} />
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs" align="baseline" wrap="nowrap">
|
||||||
|
<Text fz={26} fw={700} lh={1.1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{delta && (
|
||||||
|
<Badge size="sm" variant="light" color={delta.color}>
|
||||||
|
{delta.text}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{detail && (
|
||||||
|
<Text size="xs" c="dimmed" mt={6} lh={1.4}>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return hint ? (
|
||||||
|
<Tooltip label={hint} multiline w={260} withArrow>
|
||||||
|
{card}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
card
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headline figures.
|
||||||
|
*
|
||||||
|
* Two different scopes sit side by side here and the labels have to keep them
|
||||||
|
* apart: the register totals describe the whole book regardless of the date
|
||||||
|
* filter, while "new in period" and the pipeline figures answer to it. A tile
|
||||||
|
* reading "12 vessels" under a one-month filter would be taken for the size of
|
||||||
|
* the national fleet.
|
||||||
|
*/
|
||||||
|
export function KpiTiles({ report }: { report: VesselReport }) {
|
||||||
|
const { register, fleet, pipeline, certificates, revenue } = report.kpis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||||
|
<Tile
|
||||||
|
icon={IconShip}
|
||||||
|
label="Vessels on the register"
|
||||||
|
value={formatNumber(register.total)}
|
||||||
|
detail={`${formatNumber(register.registered)} registered · ${formatNumber(register.suspended)} suspended · ${formatNumber(register.deregistered)} deregistered`}
|
||||||
|
hint="The whole register. Not affected by the date filter."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconCalendarStats}
|
||||||
|
color="teal"
|
||||||
|
label="New in period"
|
||||||
|
value={formatNumber(register.registeredInPeriod)}
|
||||||
|
detail={`${formatNumber(register.registeredInPreviousPeriod)} in the previous period`}
|
||||||
|
delta={{
|
||||||
|
text: formatDelta(register.changePct),
|
||||||
|
color: deltaColor(register.changePct),
|
||||||
|
}}
|
||||||
|
hint="Vessels entered on the register inside the selected window, against the equally long window before it."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconScale}
|
||||||
|
color="indigo"
|
||||||
|
label="Fleet tonnage"
|
||||||
|
value={formatNumber(fleet.totalGrossTonnage)}
|
||||||
|
// The coverage count is not decoration: an average over 2 of 300 hulls
|
||||||
|
// is a different claim from an average over all of them.
|
||||||
|
detail={`avg ${formatNumber(fleet.avgGrossTonnage, { decimals: 1 })} GT across ${formatNumber(fleet.grossTonnageKnownFor)} of ${formatNumber(register.total)} vessels`}
|
||||||
|
hint="Gross tonnage is optional on the register, so the average covers only the vessels that declared one."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconAnchor}
|
||||||
|
color="cyan"
|
||||||
|
label="Average age"
|
||||||
|
value={
|
||||||
|
fleet.avgAgeYears === null
|
||||||
|
? DASH
|
||||||
|
: formatNumber(fleet.avgAgeYears, { decimals: 1, suffix: ' yrs' })
|
||||||
|
}
|
||||||
|
detail={`${formatNumber(fleet.seaGoing)} sea-going · ${formatNumber(fleet.inlandWaterway)} inland · known for ${formatNumber(fleet.ageKnownFor)}`}
|
||||||
|
hint="Derived from the build year, which not every entry carries."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconThumbUp}
|
||||||
|
color="green"
|
||||||
|
label="Approval rate"
|
||||||
|
value={formatPercent(pipeline.approvalRatePct)}
|
||||||
|
detail={`${formatNumber(pipeline.approved)} approved · ${formatNumber(pipeline.rejected)} rejected · ${formatNumber(pipeline.inProgress)} in flight`}
|
||||||
|
hint="Approved as a share of decided applications. Drafts and applications still in the queue are excluded."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconClockHour4}
|
||||||
|
color="grape"
|
||||||
|
label="Processing time"
|
||||||
|
value={
|
||||||
|
pipeline.medianProcessingDays === null
|
||||||
|
? DASH
|
||||||
|
: formatNumber(pipeline.medianProcessingDays, {
|
||||||
|
decimals: 1,
|
||||||
|
suffix: ' d',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
detail={`median · mean ${formatNumber(pipeline.avgProcessingDays, { decimals: 1, suffix: ' d' })} · ${formatNumber(pipeline.avgAdjustmentRounds, { decimals: 2 })} adjustment rounds`}
|
||||||
|
hint="Submission to decision. Only applications that have been decided are counted."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconAlarm}
|
||||||
|
color="orange"
|
||||||
|
label="Certificates expiring"
|
||||||
|
value={formatNumber(certificates.expiringIn30)}
|
||||||
|
detail={`within 30 days · ${formatNumber(certificates.expiringIn60)} within 60 · ${formatNumber(certificates.expiringIn90)} within 90`}
|
||||||
|
hint="Cumulative: a certificate due in a fortnight is counted in all three figures."
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
icon={IconCoin}
|
||||||
|
color="yellow"
|
||||||
|
label="Fees collected"
|
||||||
|
value={formatMoney(revenue.paid, revenue.currency)}
|
||||||
|
detail={`${formatMoney(revenue.pending, revenue.currency)} outstanding · ${formatNumber(revenue.failedCount)} failed`}
|
||||||
|
hint={
|
||||||
|
revenue.mixedCurrency
|
||||||
|
? 'The register holds payments in more than one currency; this total sums across them.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Card, Group, SimpleGrid, Text } from '@mantine/core';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { BreakdownItem, ReportGranularity, VesselReport } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
expiryBands,
|
||||||
|
formatBucket,
|
||||||
|
formatNumber,
|
||||||
|
officerLabel,
|
||||||
|
sliceColor,
|
||||||
|
} from './report-format';
|
||||||
|
|
||||||
|
// Recharts is unused elsewhere in this repo, so the shared setup lives here
|
||||||
|
// rather than being repeated per chart: one grid style, one tooltip style, one
|
||||||
|
// axis style, and a fixed height so the dashboard's rows line up.
|
||||||
|
const CHART_HEIGHT = 260;
|
||||||
|
const AXIS = { fontSize: 11, stroke: 'var(--mantine-color-dimmed)' } as const;
|
||||||
|
const GRID = 'var(--mantine-color-default-border)';
|
||||||
|
|
||||||
|
const TOOLTIP_STYLE = {
|
||||||
|
background: 'var(--mantine-color-body)',
|
||||||
|
border: '1px solid var(--mantine-color-default-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ChartCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
empty,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
/** True when there is genuinely nothing to draw — say so, don't draw axes. */
|
||||||
|
empty?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{empty ? (
|
||||||
|
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||||
|
Nothing to show for this filter yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={CHART_HEIGHT}>
|
||||||
|
{children as never}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ranked breakdown as horizontal bars.
|
||||||
|
*
|
||||||
|
* Horizontal because the labels are flag states, ports and vessel types —
|
||||||
|
* words, which a vertical axis can show in full instead of rotating them.
|
||||||
|
*/
|
||||||
|
function BreakdownBars({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
items: BreakdownItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
|
||||||
|
<BarChart data={items} layout="vertical" margin={{ left: 8, right: 16 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} {...AXIS} />
|
||||||
|
<YAxis type="category" dataKey="label" width={130} {...AXIS} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
formatter={(value, _name, entry) => [
|
||||||
|
countWithShare(value, entry),
|
||||||
|
'Vessels',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Cell key={item.key} fill={sliceColor(item, index)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownDonut({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
items: BreakdownItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ChartCard title={title} subtitle={subtitle} empty={items.length === 0}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={items}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="label"
|
||||||
|
innerRadius="52%"
|
||||||
|
outerRadius="78%"
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Cell key={item.key} fill={sliceColor(item, index)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={TOOLTIP_STYLE}
|
||||||
|
formatter={(value, name, entry) => [countWithShare(value, entry), name]}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
height={36}
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "12 (7.5%)" for a breakdown tooltip.
|
||||||
|
*
|
||||||
|
* The share comes off the payload rather than being recomputed: the API's
|
||||||
|
* percentage is of the whole, including the slices folded into "Other", and
|
||||||
|
* dividing by what is on screen would quietly disagree with it.
|
||||||
|
*/
|
||||||
|
function countWithShare(value: unknown, entry: unknown): string {
|
||||||
|
const count = typeof value === 'number' ? value : Number(value ?? 0);
|
||||||
|
const payload = (entry as { payload?: BreakdownItem } | undefined)?.payload;
|
||||||
|
const share = payload?.percentage ?? 0;
|
||||||
|
return `${formatNumber(count)} (${formatNumber(share, { decimals: 1 })}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when every bucket in a zero-filled series is empty. */
|
||||||
|
const allZero = (values: number[]): boolean =>
|
||||||
|
values.every((value) => value === 0);
|
||||||
|
|
||||||
|
export function ReportCharts({ report }: { report: VesselReport }) {
|
||||||
|
const { timeSeries, breakdowns, kpis } = report;
|
||||||
|
const granularity: ReportGranularity = report.filters.granularity;
|
||||||
|
const tick = (bucket: string) => formatBucket(bucket, granularity);
|
||||||
|
// Recharts types the tooltip label as a ReactNode; only a string is ever a
|
||||||
|
// bucket key, and anything else is passed through untouched.
|
||||||
|
const tickLabel = (label: unknown) =>
|
||||||
|
typeof label === 'string' ? tick(label) : String(label ?? '');
|
||||||
|
|
||||||
|
// Expiry counts arrive cumulative; drawn side by side they have to be
|
||||||
|
// disjoint or the three bars double-count each other.
|
||||||
|
const expiry = expiryBands(kpis.certificates);
|
||||||
|
|
||||||
|
const officers = breakdowns.byOfficer.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: officerLabel(item.key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
|
||||||
|
<ChartCard
|
||||||
|
title="Registrations over time"
|
||||||
|
subtitle="count and gross tonnage"
|
||||||
|
empty={allZero(timeSeries.registrations.map((b) => b.count))}
|
||||||
|
>
|
||||||
|
<AreaChart data={timeSeries.registrations}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||||
|
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||||
|
<YAxis yAxisId="count" allowDecimals={false} {...AXIS} />
|
||||||
|
<YAxis yAxisId="tonnage" orientation="right" {...AXIS} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Area
|
||||||
|
yAxisId="count"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
name="Vessels"
|
||||||
|
stroke="var(--mantine-color-blue-6)"
|
||||||
|
fill="var(--mantine-color-blue-2)"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
yAxisId="tonnage"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="grossTonnage"
|
||||||
|
name="Gross tonnage"
|
||||||
|
stroke="var(--mantine-color-teal-6)"
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Application throughput"
|
||||||
|
subtitle="decisions land in the month they were made"
|
||||||
|
empty={allZero(
|
||||||
|
timeSeries.applications.flatMap((b) => [
|
||||||
|
b.submitted,
|
||||||
|
b.approved,
|
||||||
|
b.rejected,
|
||||||
|
]),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BarChart data={timeSeries.applications}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||||
|
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||||
|
<YAxis allowDecimals={false} {...AXIS} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar
|
||||||
|
dataKey="submitted"
|
||||||
|
name="Submitted"
|
||||||
|
fill="var(--mantine-color-blue-4)"
|
||||||
|
/>
|
||||||
|
{/* Approved and rejected stack: together they are the decisions
|
||||||
|
made in that bucket, which reads against intake beside it. */}
|
||||||
|
<Bar
|
||||||
|
dataKey="approved"
|
||||||
|
name="Approved"
|
||||||
|
stackId="decided"
|
||||||
|
fill="var(--mantine-color-teal-6)"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="rejected"
|
||||||
|
name="Rejected"
|
||||||
|
stackId="decided"
|
||||||
|
fill="var(--mantine-color-red-6)"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Fees collected"
|
||||||
|
subtitle={kpis.revenue.currency}
|
||||||
|
empty={allZero(timeSeries.revenue.map((b) => b.amount))}
|
||||||
|
>
|
||||||
|
<LineChart data={timeSeries.revenue}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||||
|
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||||
|
<YAxis {...AXIS} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="amount"
|
||||||
|
name={`Paid (${kpis.revenue.currency})`}
|
||||||
|
stroke="var(--mantine-color-yellow-7)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard
|
||||||
|
title="Incidents over time"
|
||||||
|
empty={allZero(timeSeries.incidents.map((b) => b.count))}
|
||||||
|
>
|
||||||
|
<BarChart data={timeSeries.incidents}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={GRID} />
|
||||||
|
<XAxis dataKey="bucket" tickFormatter={tick} {...AXIS} />
|
||||||
|
<YAxis allowDecimals={false} {...AXIS} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} labelFormatter={tickLabel} />
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
name="Incidents"
|
||||||
|
fill="var(--mantine-color-orange-6)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartCard>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||||
|
<BreakdownDonut title="Register status" items={breakdowns.byStatus} />
|
||||||
|
<BreakdownDonut title="Category" items={breakdowns.byCategory} />
|
||||||
|
<ChartCard
|
||||||
|
title="Certificate expiry"
|
||||||
|
subtitle="disjoint bands"
|
||||||
|
empty={allZero(expiry.map((band) => band.count))}
|
||||||
|
>
|
||||||
|
<BarChart data={expiry} layout="vertical" margin={{ left: 8, right: 16 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke={GRID} horizontal={false} />
|
||||||
|
<XAxis type="number" allowDecimals={false} {...AXIS} />
|
||||||
|
<YAxis type="category" dataKey="label" width={110} {...AXIS} />
|
||||||
|
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
name="Certificates"
|
||||||
|
fill="var(--mantine-color-orange-6)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<BreakdownBars title="Tonnage bands" items={breakdowns.byTonnageBand} />
|
||||||
|
<BreakdownBars title="Age bands" items={breakdowns.byAgeBand} />
|
||||||
|
<BreakdownBars title="Length bands" items={breakdowns.byLengthBand} />
|
||||||
|
|
||||||
|
<BreakdownBars
|
||||||
|
title="Flag states"
|
||||||
|
subtitle="top slices, rest grouped"
|
||||||
|
items={breakdowns.byFlagState}
|
||||||
|
/>
|
||||||
|
<BreakdownBars
|
||||||
|
title="Ports of registry"
|
||||||
|
subtitle="top slices, rest grouped"
|
||||||
|
items={breakdowns.byPortOfRegistry}
|
||||||
|
/>
|
||||||
|
<BreakdownBars title="Vessel types" items={breakdowns.byVesselType} />
|
||||||
|
|
||||||
|
<BreakdownBars title="Hull material" items={breakdowns.byHullMaterial} />
|
||||||
|
<BreakdownBars title="Engine type" items={breakdowns.byEngineType} />
|
||||||
|
<BreakdownBars title="Build decade" items={breakdowns.byBuildDecade} />
|
||||||
|
|
||||||
|
<BreakdownBars
|
||||||
|
title="Application status"
|
||||||
|
items={breakdowns.byApplicationStatus}
|
||||||
|
/>
|
||||||
|
<BreakdownDonut
|
||||||
|
title="New vs renewal"
|
||||||
|
items={breakdowns.byApplicationKind}
|
||||||
|
/>
|
||||||
|
<BreakdownDonut
|
||||||
|
title="Incident severity"
|
||||||
|
subtitle="free text on the register"
|
||||||
|
items={breakdowns.byIncidentSeverity}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BreakdownBars
|
||||||
|
title="Officer workload"
|
||||||
|
subtitle="user id — names not resolved"
|
||||||
|
items={officers}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
MultiSelect,
|
||||||
|
SegmentedControl,
|
||||||
|
TextInput,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { DatePickerInput } from '@mantine/dates';
|
||||||
|
import { IconDownload, IconSearch, IconX } from '@tabler/icons-react';
|
||||||
|
import type {
|
||||||
|
ReportGranularity,
|
||||||
|
VesselCategory,
|
||||||
|
VesselReport,
|
||||||
|
VesselReportQuery,
|
||||||
|
VesselStatus,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
import { optionsFrom } from './report-format';
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'SEA_GOING', label: 'Sea-going' },
|
||||||
|
{ value: 'INLAND_WATERWAY', label: 'Inland waterway' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'REGISTERED', label: 'Registered' },
|
||||||
|
{ value: 'SUSPENDED', label: 'Suspended' },
|
||||||
|
{ value: 'DEREGISTERED', label: 'Deregistered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GRANULARITY_OPTIONS = [
|
||||||
|
{ value: 'DAY', label: 'Day' },
|
||||||
|
{ value: 'WEEK', label: 'Week' },
|
||||||
|
{ value: 'MONTH', label: 'Month' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ReportFiltersProps {
|
||||||
|
query: VesselReportQuery;
|
||||||
|
onChange: (next: VesselReportQuery) => void;
|
||||||
|
/**
|
||||||
|
* The last successful response. Flag states, ports and vessel types are free
|
||||||
|
* text on the register with no lookup endpoint behind them, so the only
|
||||||
|
* honest source for the options is what the register actually holds.
|
||||||
|
*/
|
||||||
|
report?: VesselReport;
|
||||||
|
onExport: () => void;
|
||||||
|
exporting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportFilters({
|
||||||
|
query,
|
||||||
|
onChange,
|
||||||
|
report,
|
||||||
|
onExport,
|
||||||
|
exporting,
|
||||||
|
}: ReportFiltersProps) {
|
||||||
|
// The search box is local so typing does not refetch on every keystroke; it
|
||||||
|
// is pushed up on a debounce.
|
||||||
|
const [search, setSearch] = useState(query.search ?? '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(query.search ?? '');
|
||||||
|
}, [query.search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const current = query.search ?? '';
|
||||||
|
if (search === current) return;
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => onChange({ ...query, search: search.trim() || undefined }),
|
||||||
|
350,
|
||||||
|
);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, query, onChange]);
|
||||||
|
|
||||||
|
const set = <K extends keyof VesselReportQuery>(
|
||||||
|
key: K,
|
||||||
|
value: VesselReportQuery[K],
|
||||||
|
) => onChange({ ...query, [key]: value });
|
||||||
|
|
||||||
|
// Mantine 8 works in `YYYY-MM-DD` strings here, which is exactly what the
|
||||||
|
// API wants — no Date round trip, and no timezone to shift the day.
|
||||||
|
const range: [string | null, string | null] = [
|
||||||
|
query.from ?? null,
|
||||||
|
query.to ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
Boolean(query.search) ||
|
||||||
|
Boolean(query.from) ||
|
||||||
|
Boolean(query.to) ||
|
||||||
|
[
|
||||||
|
query.category,
|
||||||
|
query.status,
|
||||||
|
query.flagState,
|
||||||
|
query.portOfRegistry,
|
||||||
|
query.vesselType,
|
||||||
|
].some((values) => (values ?? []).length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md" mb="md">
|
||||||
|
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||||
|
<DatePickerInput
|
||||||
|
type="range"
|
||||||
|
label="Period"
|
||||||
|
placeholder="Last 12 months"
|
||||||
|
value={range}
|
||||||
|
// Both ends before refetching: a half-set range would send `from`
|
||||||
|
// with no `to` and redraw the charts against a window the user is
|
||||||
|
// still in the middle of choosing.
|
||||||
|
onChange={([from, to]) => {
|
||||||
|
if (from && !to) return;
|
||||||
|
onChange({
|
||||||
|
...query,
|
||||||
|
from: from ?? undefined,
|
||||||
|
to: to ?? undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
w={250}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
data={GRANULARITY_OPTIONS}
|
||||||
|
value={query.granularity ?? 'MONTH'}
|
||||||
|
onChange={(value) => set('granularity', value as ReportGranularity)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="Category"
|
||||||
|
placeholder="All"
|
||||||
|
data={CATEGORY_OPTIONS}
|
||||||
|
value={query.category ?? []}
|
||||||
|
onChange={(value) => set('category', value as VesselCategory[])}
|
||||||
|
clearable
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="Status"
|
||||||
|
placeholder="All"
|
||||||
|
data={STATUS_OPTIONS}
|
||||||
|
value={query.status ?? []}
|
||||||
|
onChange={(value) => set('status', value as VesselStatus[])}
|
||||||
|
clearable
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="Flag state"
|
||||||
|
placeholder="All"
|
||||||
|
data={optionsFrom(report?.breakdowns.byFlagState)}
|
||||||
|
value={query.flagState ?? []}
|
||||||
|
onChange={(value) => set('flagState', value)}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="Port of registry"
|
||||||
|
placeholder="All"
|
||||||
|
data={optionsFrom(report?.breakdowns.byPortOfRegistry)}
|
||||||
|
value={query.portOfRegistry ?? []}
|
||||||
|
onChange={(value) => set('portOfRegistry', value)}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="Vessel type"
|
||||||
|
placeholder="All"
|
||||||
|
data={optionsFrom(report?.breakdowns.byVesselType)}
|
||||||
|
value={query.vesselType ?? []}
|
||||||
|
onChange={(value) => set('vesselType', value)}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Search"
|
||||||
|
placeholder="Name, register №, IMO or owner"
|
||||||
|
leftSection={<IconSearch size={14} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
w={250}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap="xs" ml="auto">
|
||||||
|
{filtered && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconX size={14} />}
|
||||||
|
onClick={() => onChange({})}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
loading={exporting}
|
||||||
|
onClick={onExport}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Badge, Card, Group, SimpleGrid, Table, Text } from '@mantine/core';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { VesselReport } from '@ema-platform/api';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import { expiryUrgency, formatNumber } from './report-format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worklists.
|
||||||
|
*
|
||||||
|
* Plain Mantine tables rather than `AdvancedTable`: every one of these is
|
||||||
|
* already capped server-side by `tableLimit`, so the pagination, search and
|
||||||
|
* column-picker that component brings would all be controls over a list that
|
||||||
|
* is only ever ten rows of a much longer one. Each card links out to the screen
|
||||||
|
* that does own the full list.
|
||||||
|
*/
|
||||||
|
function TableCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
to,
|
||||||
|
linkLabel,
|
||||||
|
empty,
|
||||||
|
head,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
to?: string;
|
||||||
|
linkLabel?: string;
|
||||||
|
empty: boolean;
|
||||||
|
head: string[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="md">
|
||||||
|
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{to && (
|
||||||
|
<Text component={Link} to={to} size="xs" c="blue">
|
||||||
|
{linkLabel ?? 'View all'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{empty ? (
|
||||||
|
<Text size="sm" c="dimmed" py="lg" ta="center">
|
||||||
|
Nothing to show.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table highlightOnHover verticalSpacing="xs" fz="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{head.map((column) => (
|
||||||
|
<Table.Th key={column}>{column}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{children}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportTables({ report }: { report: VesselReport }) {
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const { tables, filters } = report;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="md">
|
||||||
|
<TableCard
|
||||||
|
title="Certificates expiring"
|
||||||
|
subtitle={`within ${filters.expiringWithinDays} days`}
|
||||||
|
to="/licence-register"
|
||||||
|
empty={tables.expiringCertificates.length === 0}
|
||||||
|
head={['Vessel', 'Certificate', 'Expires', 'Days']}
|
||||||
|
>
|
||||||
|
{tables.expiringCertificates.map((row) => (
|
||||||
|
<Table.Tr key={row.vesselId}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{row.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.registrationNumber}
|
||||||
|
{row.ownerName ? ` · ${row.ownerName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{row.certificateNumber ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{showDate(row.expiryDate)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={expiryUrgency(row.daysToExpiry)}
|
||||||
|
>
|
||||||
|
{/* 0 is today, and a certificate is valid through its last day. */}
|
||||||
|
{row.daysToExpiry === 0
|
||||||
|
? 'Today'
|
||||||
|
: `${formatNumber(row.daysToExpiry)} d`}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</TableCard>
|
||||||
|
|
||||||
|
<TableCard
|
||||||
|
title="Recent registrations"
|
||||||
|
to="/vessel-registration-queue"
|
||||||
|
linkLabel="Open register"
|
||||||
|
empty={tables.recentRegistrations.length === 0}
|
||||||
|
head={['Vessel', 'Category', 'Flag', 'Registered']}
|
||||||
|
>
|
||||||
|
{tables.recentRegistrations.map((row) => (
|
||||||
|
<Table.Tr key={row.vesselId}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{row.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.registrationNumber}
|
||||||
|
{row.vesselType ? ` · ${row.vesselType}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{row.category === 'SEA_GOING' ? 'Sea-going' : 'Inland'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{row.flagState ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{showDate(row.registeredAt)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</TableCard>
|
||||||
|
|
||||||
|
<TableCard
|
||||||
|
title="Applications in the queue"
|
||||||
|
subtitle="oldest first"
|
||||||
|
to="/licence-review/type/VESSEL_REGISTRATION"
|
||||||
|
linkLabel="Open queue"
|
||||||
|
empty={tables.pendingApplications.length === 0}
|
||||||
|
head={['Application', 'Status', 'Submitted', 'Open']}
|
||||||
|
>
|
||||||
|
{tables.pendingApplications.map((row) => (
|
||||||
|
<Table.Tr key={row.applicationNumber}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{row.applicationNumber}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.kind === 'RENEWAL' ? 'Renewal' : 'New'}
|
||||||
|
{row.adjustmentRound > 0
|
||||||
|
? ` · ${row.adjustmentRound} adjustment round${row.adjustmentRound === 1 ? '' : 's'}`
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{row.status.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{row.submittedAt ? showDate(row.submittedAt) : '—'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={row.daysOpen > 30 ? 'red' : row.daysOpen > 14 ? 'orange' : 'gray'}
|
||||||
|
>
|
||||||
|
{formatNumber(row.daysOpen)} d
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</TableCard>
|
||||||
|
|
||||||
|
<TableCard
|
||||||
|
title="Recent incidents"
|
||||||
|
empty={tables.recentIncidents.length === 0}
|
||||||
|
head={['Vessel', 'Occurred', 'Severity', 'Reported by']}
|
||||||
|
>
|
||||||
|
{tables.recentIncidents.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{row.vesselName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{row.description}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{showDate(row.occurredAt)}</Table.Td>
|
||||||
|
<Table.Td>{row.severity ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light" color={row.reportedByOfficer ? 'blue' : 'gray'}>
|
||||||
|
{row.reportedByOfficer ? 'Officer' : 'Owner'}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</TableCard>
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Alert, Container, Group, Text, Title } from '@mantine/core';
|
||||||
|
import { IconAlertTriangle, IconShip } from '@tabler/icons-react';
|
||||||
|
import {
|
||||||
|
ApiErrorAlert,
|
||||||
|
EmptyState,
|
||||||
|
PageLoader,
|
||||||
|
notify,
|
||||||
|
} from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
import {
|
||||||
|
downloadAuthedFile,
|
||||||
|
extractErrorMessage,
|
||||||
|
useGetVesselReportQuery,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
import type { VesselReportQuery } from '@ema-platform/api';
|
||||||
|
import { KpiTiles } from './KpiTiles';
|
||||||
|
import { ReportCharts } from './ReportCharts';
|
||||||
|
import { ReportFilters } from './ReportFilters';
|
||||||
|
import { ReportTables } from './ReportTables';
|
||||||
|
import { queryToSearchParams, searchParamsToQuery } from './report-format';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vessel registration dashboard (module 11).
|
||||||
|
*
|
||||||
|
* One `GET /vessels/report` call fills the whole screen — KPIs, four time
|
||||||
|
* series, fifteen breakdowns and four worklists — so the filter bar drives a
|
||||||
|
* single refetch rather than a dozen independent ones.
|
||||||
|
*
|
||||||
|
* Filter state lives in the URL. A filtered dashboard is the thing an officer
|
||||||
|
* wants to send someone, and rebuilding six selects from a description is not
|
||||||
|
* how that conversation should go.
|
||||||
|
*/
|
||||||
|
export function VesselRegistrationReportPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
|
const query: VesselReportQuery = useMemo(
|
||||||
|
() => searchParamsToQuery(searchParams),
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setQuery = useCallback(
|
||||||
|
(next: VesselReportQuery) => {
|
||||||
|
// `replace` so a session of narrowing filters does not bury the page the
|
||||||
|
// officer arrived from under twenty history entries.
|
||||||
|
setSearchParams(queryToSearchParams(next), { replace: true });
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: report, isLoading, isFetching, error } = useGetVesselReportQuery(
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportCsv = useCallback(async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const params = queryToSearchParams(query).toString();
|
||||||
|
const { rowCount, truncated } = await downloadAuthedFile(
|
||||||
|
`/vessels/report/export${params ? `?${params}` : ''}`,
|
||||||
|
'vessel-register.csv',
|
||||||
|
);
|
||||||
|
if (truncated) {
|
||||||
|
notify.error(
|
||||||
|
`Export cut off at ${rowCount ?? 'the row limit'} rows. Narrow the filter and export again.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notify.success(
|
||||||
|
`Exported ${rowCount ?? 'the filtered'} vessel${rowCount === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notify.error(extractErrorMessage(err, 'Could not export the register'));
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Only the very first load blanks the page; a filter change keeps the last
|
||||||
|
// report on screen so the dashboard does not flash between every tweak.
|
||||||
|
if (isLoading) return <PageLoader />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" py="md">
|
||||||
|
<Group justify="space-between" mb="md" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Vessel registration report</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{report
|
||||||
|
? `Register-wide totals with a ${showDate(report.filters.from)} – ${showDate(report.filters.to)} window on the trends.`
|
||||||
|
: 'The national vessel register at a glance.'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ReportFilters
|
||||||
|
query={query}
|
||||||
|
onChange={setQuery}
|
||||||
|
report={report}
|
||||||
|
onExport={exportCsv}
|
||||||
|
exporting={exporting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <ApiErrorAlert error={error} title="Could not load the report" />}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
{report.truncated && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
mb="md"
|
||||||
|
title="Partial figures"
|
||||||
|
>
|
||||||
|
The register is larger than this report can scan in one pass, so
|
||||||
|
every figure below covers only part of it. Narrow the filter for
|
||||||
|
an exact answer.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.kpis.register.total === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={IconShip}
|
||||||
|
title="No vessels match this filter"
|
||||||
|
description={
|
||||||
|
Object.keys(query).length > 0
|
||||||
|
? 'Nothing on the register matches the current filter. Clear it to see the whole book.'
|
||||||
|
: 'No vessels have been registered yet. Entries appear here once a registration certificate is issued.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ opacity: isFetching ? 0.6 : 1, transition: 'opacity 120ms' }}>
|
||||||
|
<KpiTiles report={report} />
|
||||||
|
<div style={{ marginTop: 'var(--mantine-spacing-md)' }}>
|
||||||
|
<ReportCharts report={report} />
|
||||||
|
</div>
|
||||||
|
<ReportTables report={report} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VesselRegistrationReportPage;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { BreakdownItem, CertificateKpis } from '@ema-platform/api';
|
||||||
|
import {
|
||||||
|
DASH,
|
||||||
|
defaultRange,
|
||||||
|
deltaColor,
|
||||||
|
expiryBands,
|
||||||
|
expiryUrgency,
|
||||||
|
formatBucket,
|
||||||
|
formatDelta,
|
||||||
|
formatNumber,
|
||||||
|
formatPercent,
|
||||||
|
officerLabel,
|
||||||
|
optionsFrom,
|
||||||
|
queryToSearchParams,
|
||||||
|
searchParamsToQuery,
|
||||||
|
sliceColor,
|
||||||
|
} from './report-format';
|
||||||
|
|
||||||
|
const certificates = (partial: Partial<CertificateKpis>): CertificateKpis => ({
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
expired: 0,
|
||||||
|
suspended: 0,
|
||||||
|
expiringIn30: 0,
|
||||||
|
expiringIn60: 0,
|
||||||
|
expiringIn90: 0,
|
||||||
|
missingCertificate: 0,
|
||||||
|
...partial,
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = (key: string, count = 1): BreakdownItem => ({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
count,
|
||||||
|
percentage: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatNumber', () => {
|
||||||
|
it('renders a dash for a figure the API had no answer for', () => {
|
||||||
|
expect(formatNumber(null)).toBe(DASH);
|
||||||
|
expect(formatNumber(undefined)).toBe(DASH);
|
||||||
|
expect(formatNumber(Number.NaN)).toBe(DASH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a real zero', () => {
|
||||||
|
expect(formatNumber(0)).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours decimals and a suffix', () => {
|
||||||
|
expect(formatNumber(12.345, { decimals: 2 })).toBe('12.35');
|
||||||
|
expect(formatNumber(7, { suffix: ' GT' })).toBe('7 GT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPercent / formatDelta', () => {
|
||||||
|
it('distinguishes no answer from zero', () => {
|
||||||
|
expect(formatPercent(null)).toBe(DASH);
|
||||||
|
expect(formatPercent(0)).toBe('0.0%');
|
||||||
|
expect(formatDelta(null)).toBe(DASH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs a positive change', () => {
|
||||||
|
expect(formatDelta(12.5)).toBe('+12.5%');
|
||||||
|
expect(formatDelta(-4)).toBe('-4.0%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colours a flat or absent change neutrally', () => {
|
||||||
|
expect(deltaColor(null)).toBe('gray');
|
||||||
|
expect(deltaColor(0)).toBe('gray');
|
||||||
|
expect(deltaColor(1)).toBe('teal');
|
||||||
|
expect(deltaColor(-1)).toBe('red');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expiryBands', () => {
|
||||||
|
it('differences the API cumulative counts into disjoint bands', () => {
|
||||||
|
expect(
|
||||||
|
expiryBands(
|
||||||
|
certificates({ expiringIn30: 4, expiringIn60: 9, expiringIn90: 11 }),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ label: 'Within 30 days', count: 4 },
|
||||||
|
{ label: '31–60 days', count: 5 },
|
||||||
|
{ label: '61–90 days', count: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never draws a negative bar if the counts are not monotonic', () => {
|
||||||
|
const bands = expiryBands(
|
||||||
|
certificates({ expiringIn30: 9, expiringIn60: 4, expiringIn90: 4 }),
|
||||||
|
);
|
||||||
|
expect(bands.every((band) => band.count >= 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expiryUrgency', () => {
|
||||||
|
it('escalates on the boundaries', () => {
|
||||||
|
expect(expiryUrgency(0)).toBe('red');
|
||||||
|
expect(expiryUrgency(7)).toBe('red');
|
||||||
|
expect(expiryUrgency(8)).toBe('orange');
|
||||||
|
expect(expiryUrgency(30)).toBe('orange');
|
||||||
|
expect(expiryUrgency(31)).toBe('gray');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('officerLabel', () => {
|
||||||
|
it('spells out the unassigned bucket and shortens a uuid', () => {
|
||||||
|
expect(officerLabel('UNASSIGNED')).toBe('Unassigned');
|
||||||
|
expect(officerLabel('c8d0a151-91e9-433e-b221-db331480b10f')).toBe(
|
||||||
|
'c8d0a151…',
|
||||||
|
);
|
||||||
|
expect(officerLabel('short')).toBe('short');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sliceColor', () => {
|
||||||
|
it('mutes the bookkeeping slices and cycles the rest', () => {
|
||||||
|
const muted = sliceColor(item('OTHER'), 0);
|
||||||
|
expect(sliceColor(item('Unknown'), 3)).toBe(muted);
|
||||||
|
expect(sliceColor(item('SEA_GOING'), 0)).not.toBe(muted);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable for a given position', () => {
|
||||||
|
expect(sliceColor(item('A'), 2)).toBe(sliceColor(item('B'), 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBucket', () => {
|
||||||
|
it('reads a month bucket as a month and a day bucket as a day', () => {
|
||||||
|
expect(formatBucket('2026-03-01', 'MONTH')).toMatch(/2026/);
|
||||||
|
expect(formatBucket('2026-03-04', 'DAY')).not.toMatch(/2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an unparseable bucket through rather than printing NaN', () => {
|
||||||
|
expect(formatBucket('not-a-date', 'MONTH')).toBe('not-a-date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultRange', () => {
|
||||||
|
it('spans the twelve months the API defaults to', () => {
|
||||||
|
const [from, to] = defaultRange(new Date('2026-08-18T00:00:00Z'));
|
||||||
|
expect(from.toISOString().slice(0, 10)).toBe('2025-08-18');
|
||||||
|
expect(to.toISOString().slice(0, 10)).toBe('2026-08-18');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('url round trip', () => {
|
||||||
|
it('drops empty values so an untouched dashboard has a clean link', () => {
|
||||||
|
const params = queryToSearchParams({
|
||||||
|
search: '',
|
||||||
|
category: [],
|
||||||
|
topN: 15,
|
||||||
|
});
|
||||||
|
expect(params.toString()).toBe('topN=15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the filter state a shared link carries', () => {
|
||||||
|
const query = {
|
||||||
|
from: '2026-01-01',
|
||||||
|
to: '2026-08-18',
|
||||||
|
granularity: 'WEEK' as const,
|
||||||
|
status: ['REGISTERED' as const, 'SUSPENDED' as const],
|
||||||
|
flagState: ['Ethiopia'],
|
||||||
|
search: 'abay',
|
||||||
|
topN: 20,
|
||||||
|
};
|
||||||
|
expect(searchParamsToQuery(queryToSearchParams(query))).toEqual(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a hand-edited value the API would reject', () => {
|
||||||
|
const query = searchParamsToQuery(
|
||||||
|
new URLSearchParams('topN=abc&granularity=YEAR'),
|
||||||
|
);
|
||||||
|
expect(query.topN).toBeUndefined();
|
||||||
|
expect(query.granularity).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('optionsFrom', () => {
|
||||||
|
it('offers the register values but not the bookkeeping slices', () => {
|
||||||
|
expect(
|
||||||
|
optionsFrom([item('Ethiopia'), item('Unknown'), item('OTHER')]),
|
||||||
|
).toEqual(['Ethiopia']);
|
||||||
|
expect(optionsFrom(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type {
|
||||||
|
BreakdownItem,
|
||||||
|
CertificateKpis,
|
||||||
|
ReportGranularity,
|
||||||
|
VesselReportQuery,
|
||||||
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
|
/** Nothing measurable is not zero — an em dash says so without lying. */
|
||||||
|
export const DASH = '—';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A figure the API may legitimately have no answer for.
|
||||||
|
*
|
||||||
|
* `avgGrossTonnage` is null on an empty register and `approvalRatePct` is null
|
||||||
|
* until something has been decided; rendering either as 0 would report a fleet
|
||||||
|
* that weighs nothing and a service that approves nobody.
|
||||||
|
*/
|
||||||
|
export function formatNumber(
|
||||||
|
value: number | null | undefined,
|
||||||
|
options: { decimals?: number; suffix?: string } = {},
|
||||||
|
): string {
|
||||||
|
if (value === null || value === undefined || Number.isNaN(value)) return DASH;
|
||||||
|
const text = value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: options.decimals ?? 0,
|
||||||
|
maximumFractionDigits: options.decimals ?? 0,
|
||||||
|
});
|
||||||
|
return options.suffix ? `${text}${options.suffix}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value: number | null | undefined): string {
|
||||||
|
return value === null || value === undefined
|
||||||
|
? DASH
|
||||||
|
: `${formatNumber(value, { decimals: 1 })}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMoney(value: number, currency: string): string {
|
||||||
|
return `${formatNumber(value, { decimals: 2 })} ${currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A signed delta for the change-vs-previous chip. */
|
||||||
|
export function formatDelta(value: number | null): string {
|
||||||
|
if (value === null) return DASH;
|
||||||
|
const sign = value > 0 ? '+' : '';
|
||||||
|
return `${sign}${formatNumber(value, { decimals: 1 })}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deltaColor(value: number | null): string {
|
||||||
|
if (value === null || value === 0) return 'gray';
|
||||||
|
return value > 0 ? 'teal' : 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API's expiry counts are cumulative — a certificate due in eleven days is
|
||||||
|
* inside the 30-, 60- and 90-day figures, which is how a renewals desk reads
|
||||||
|
* them. Stacked side by side in a chart that reads as three separate groups,
|
||||||
|
* so they are differenced into disjoint bands first.
|
||||||
|
*/
|
||||||
|
export function expiryBands(
|
||||||
|
certificates: CertificateKpis,
|
||||||
|
): Array<{ label: string; count: number }> {
|
||||||
|
const { expiringIn30, expiringIn60, expiringIn90 } = certificates;
|
||||||
|
return [
|
||||||
|
{ label: 'Within 30 days', count: expiringIn30 },
|
||||||
|
// Math.max guards against a server that ever answers non-monotonically —
|
||||||
|
// a negative bar is worse than a zero one.
|
||||||
|
{ label: '31–60 days', count: Math.max(0, expiringIn60 - expiringIn30) },
|
||||||
|
{ label: '61–90 days', count: Math.max(0, expiringIn90 - expiringIn60) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Red inside a week, orange inside a month, otherwise unremarkable. */
|
||||||
|
export function expiryUrgency(daysToExpiry: number): string {
|
||||||
|
if (daysToExpiry <= 7) return 'red';
|
||||||
|
if (daysToExpiry <= 30) return 'orange';
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Officer ids are IAM uuids, which make useless axis labels. Until the
|
||||||
|
* dashboard has a name lookup, shorten them and keep "UNASSIGNED" readable.
|
||||||
|
*/
|
||||||
|
export function officerLabel(key: string): string {
|
||||||
|
if (key === 'UNASSIGNED') return 'Unassigned';
|
||||||
|
return key.length > 8 ? `${key.slice(0, 8)}…` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart colours, assigned by position so a slice keeps its colour between
|
||||||
|
* renders. Mantine's palette rather than invented hex codes, so the charts
|
||||||
|
* follow the theme the rest of the app is built on.
|
||||||
|
*/
|
||||||
|
const PALETTE = [
|
||||||
|
'var(--mantine-color-blue-6)',
|
||||||
|
'var(--mantine-color-teal-6)',
|
||||||
|
'var(--mantine-color-orange-6)',
|
||||||
|
'var(--mantine-color-grape-6)',
|
||||||
|
'var(--mantine-color-cyan-6)',
|
||||||
|
'var(--mantine-color-lime-7)',
|
||||||
|
'var(--mantine-color-pink-6)',
|
||||||
|
'var(--mantine-color-indigo-6)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MUTED = 'var(--mantine-color-gray-5)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Unknown" and "Other" are bookkeeping slices rather than findings, so they
|
||||||
|
* always take the muted colour instead of competing with the real categories
|
||||||
|
* for one of the bright ones.
|
||||||
|
*/
|
||||||
|
export function sliceColor(item: BreakdownItem, index: number): string {
|
||||||
|
if (item.key === 'OTHER' || item.key === 'Unknown') return MUTED;
|
||||||
|
return PALETTE[index % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket keys are ISO dates; the axis wants something a human reads. */
|
||||||
|
export function formatBucket(
|
||||||
|
bucket: string,
|
||||||
|
granularity: ReportGranularity,
|
||||||
|
): string {
|
||||||
|
const date = new Date(bucket);
|
||||||
|
if (Number.isNaN(date.getTime())) return bucket;
|
||||||
|
if (granularity === 'MONTH') {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default window the API applies when none is given: the last 12 months. */
|
||||||
|
export function defaultRange(now: Date): [Date, Date] {
|
||||||
|
const from = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()),
|
||||||
|
);
|
||||||
|
return [from, now];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ISO_DAY_LENGTH = 10;
|
||||||
|
|
||||||
|
export const toIsoDay = (date: Date): string =>
|
||||||
|
date.toISOString().slice(0, ISO_DAY_LENGTH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filter state as URL search params, so a filtered dashboard is a
|
||||||
|
* shareable link rather than something the next person has to rebuild.
|
||||||
|
*
|
||||||
|
* Empty arrays and blank strings are dropped rather than serialised, which
|
||||||
|
* keeps an untouched dashboard's URL clean and lets the API apply its own
|
||||||
|
* defaults instead of being handed an empty filter to honour.
|
||||||
|
*/
|
||||||
|
export function queryToSearchParams(query: VesselReportQuery): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value === undefined || value === null || value === '') continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) continue;
|
||||||
|
params.set(key, value.join(','));
|
||||||
|
} else {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARRAY_KEYS = [
|
||||||
|
'category',
|
||||||
|
'status',
|
||||||
|
'flagState',
|
||||||
|
'portOfRegistry',
|
||||||
|
'vesselType',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NUMBER_KEYS = ['expiringWithinDays', 'topN', 'tableLimit'] as const;
|
||||||
|
|
||||||
|
/** The inverse, for restoring state from a shared link. */
|
||||||
|
export function searchParamsToQuery(
|
||||||
|
params: URLSearchParams,
|
||||||
|
): VesselReportQuery {
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
for (const key of ARRAY_KEYS) {
|
||||||
|
const raw = params.get(key);
|
||||||
|
if (raw) query[key] = raw.split(',').filter(Boolean);
|
||||||
|
}
|
||||||
|
for (const key of NUMBER_KEYS) {
|
||||||
|
const raw = params.get(key);
|
||||||
|
// An unparseable number in a hand-edited URL is ignored rather than sent
|
||||||
|
// on to fail the API's validation pipe.
|
||||||
|
if (raw !== null && raw !== '' && Number.isFinite(Number(raw))) {
|
||||||
|
query[key] = Number(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of ['from', 'to', 'search'] as const) {
|
||||||
|
const raw = params.get(key);
|
||||||
|
if (raw) query[key] = raw;
|
||||||
|
}
|
||||||
|
const granularity = params.get('granularity');
|
||||||
|
if (granularity === 'DAY' || granularity === 'WEEK' || granularity === 'MONTH') {
|
||||||
|
query.granularity = granularity;
|
||||||
|
}
|
||||||
|
return query as VesselReportQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multi-select options a filter offers, taken from the breakdown the last
|
||||||
|
* response carried — there is no lookup endpoint for flag states or ports, and
|
||||||
|
* the register is the only place that knows which ones are in use.
|
||||||
|
*
|
||||||
|
* "Unknown" is dropped: it stands for a missing value, and there is nothing to
|
||||||
|
* filter the register down to.
|
||||||
|
*/
|
||||||
|
export function optionsFrom(items: BreakdownItem[] | undefined): string[] {
|
||||||
|
return (items ?? [])
|
||||||
|
.filter((item) => item.key !== 'Unknown' && item.key !== 'OTHER')
|
||||||
|
.map((item) => item.key);
|
||||||
|
}
|
||||||
@@ -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: "ያልተሟሉ ሰነዶች",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ configureTokenRefresh({
|
|||||||
},
|
},
|
||||||
onAuthFailure: () => {
|
onAuthFailure: () => {
|
||||||
store.dispatch(logout());
|
store.dispatch(logout());
|
||||||
window.location.href = '/login';
|
window.location.href = '/';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
// },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>[] =
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 10–15 working days after all documents are verified.' },
|
|
||||||
].map(({ icon: Icon, color, title, desc }) => (
|
|
||||||
<Card key={title} withBorder radius="md" p="sm">
|
|
||||||
<Group gap="xs" mb={4}>
|
|
||||||
<ThemeIcon size={20} radius="sm" color={color} variant="light"><Icon size={12} /></ThemeIcon>
|
|
||||||
<Text fz="xs" fw={700}>{title}</Text>
|
|
||||||
</Group>
|
|
||||||
<Text fz="xs" c="dimmed" lh={1.4}>{desc}</Text>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Active applications */}
|
|
||||||
<Paper withBorder radius="lg" p="xl">
|
|
||||||
<Text fw={700} mb="md">My Endorsement Applications</Text>
|
|
||||||
{endorsementApps.length === 0 ? (
|
|
||||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
|
||||||
No active endorsement applications.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{endorsementApps.map((app) => (
|
|
||||||
<Paper key={app.id} withBorder radius="md" p="md">
|
|
||||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fz="sm" fw={700}>{app.cocType}</Text>
|
|
||||||
<Text fz="xs" c="dimmed">CoC No: {app.foreignCocNo} · {app.issuingCountry} · Submitted {formatDate(app.submitted)}</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge color={STATUS_COLOR[app.status] ?? "gray"} variant="light">{humanStatus(app.status)}</Badge>
|
|
||||||
<Text fz="xs" c="blue.7" fw={600}>{app.id}</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Alert variant="light" color={STATUS_COLOR[app.status] ?? "gray"} icon={<IconInfoCircle size={13} />} p="xs" mt="sm">
|
|
||||||
<Text fz="xs">{humanStatus(app.status)}</Text>
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Issued endorsements */}
|
|
||||||
<Paper withBorder radius="lg" p="xl">
|
|
||||||
<Text fw={700} mb="md">My Endorsements</Text>
|
|
||||||
{issuedEndorsements.length === 0 ? (
|
|
||||||
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
|
|
||||||
No endorsements issued yet.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
{issuedEndorsements.map((end) => (
|
|
||||||
<Card key={end.id} withBorder radius="md" p="md">
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon size={36} radius="md" color="teal" variant="light"><IconRubberStamp size={18} /></ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={700} fz="sm">{end.cocType}</Text>
|
|
||||||
<Text fz="xs" c="dimmed">{end.endorsementNo}</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Badge color={STATUS_COLOR[end.status] ?? "gray"} variant="light">{humanStatus(end.status)}</Badge>
|
|
||||||
</Group>
|
|
||||||
<Divider mb="sm" />
|
|
||||||
<SimpleGrid cols={2} spacing="xs" mb="sm">
|
|
||||||
<div><Text fz="xs" c="dimmed">Foreign CoC No.</Text><Text fz="sm" fw={500}>{end.foreignCocNo}</Text></div>
|
|
||||||
<div><Text fz="xs" c="dimmed">Issuing Country</Text><Text fz="sm" fw={500}>{end.issuingCountry}</Text></div>
|
|
||||||
<div><Text fz="xs" c="dimmed">Issued</Text><Text fz="sm" fw={500}>{end.issued}</Text></div>
|
|
||||||
<div><Text fz="xs" c="dimmed">Expires</Text><Text fz="sm" fw={500}>{end.expiry}</Text></div>
|
|
||||||
</SimpleGrid>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Button size="xs" variant="light" leftSection={<IconEye size={12} />} onClick={() => setPreviewId(end.id)}>View</Button>
|
|
||||||
<Button size="xs" variant="default" leftSection={<IconDownload size={12} />}>Download</Button>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
</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;
|
||||||
|
|||||||
71
apps/portal/src/app/features/endorsement/pages/columns.tsx
Normal file
71
apps/portal/src/app/features/endorsement/pages/columns.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Badge, Button, Text } from '@mantine/core';
|
||||||
|
import { IconCertificate } from '@tabler/icons-react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||||
|
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||||
|
|
||||||
|
interface EndorsementColumnsArgs {
|
||||||
|
t: TFunction;
|
||||||
|
showDate: (date: string) => string;
|
||||||
|
localized: (value: Bilingual | undefined) => string;
|
||||||
|
onDownload: (licenseId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endorsementColumns({
|
||||||
|
t,
|
||||||
|
showDate,
|
||||||
|
localized,
|
||||||
|
onDownload,
|
||||||
|
}: EndorsementColumnsArgs): AdvancedColumn<IssuedLicense>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
header: t('endorsement.columns.certificateNumber', 'Certificate №'),
|
||||||
|
accessorKey: 'certificateNumber',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text ff="monospace" size="sm" fw={600}>
|
||||||
|
{row.original.certificateNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('endorsement.columns.type', 'Type'),
|
||||||
|
cell: ({ row }) => localized(row.original.licenseType?.name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('endorsement.columns.issued', 'Issued'),
|
||||||
|
cell: ({ row }) => showDate(row.original.issueDate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('endorsement.columns.expires', 'Expires'),
|
||||||
|
cell: ({ row }) => showDate(row.original.expiryDate),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('common.status'),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
`endorsement.columns.licenseStatus.${row.original.status}`,
|
||||||
|
row.original.status,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
leftSection={<IconCertificate size={14} />}
|
||||||
|
onClick={() => onDownload(row.original.id)}
|
||||||
|
>
|
||||||
|
{t('common.download')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Badge, Button, Text } from '@mantine/core';
|
import { 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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -103,4 +103,4 @@ export function RequireSeafarerProfile({
|
|||||||
if (error) return <>{children}</>;
|
if (error) return <>{children}</>;
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: 'የባህር ማዕድ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ configureTokenRefresh({
|
|||||||
|
|
||||||
onAuthFailure: () => {
|
onAuthFailure: () => {
|
||||||
store.dispatch(logout());
|
store.dispatch(logout());
|
||||||
window.location.href = "/login";
|
window.location.href = "/";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
480
docs/vessel-registration-report-frontend.md
Normal file
480
docs/vessel-registration-report-frontend.md
Normal 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` | 1–365 | 90 | horizon for the expiring-certificates table |
|
||||||
|
| `topN` | 1–50 | 15 | slices kept per high-cardinality chart |
|
||||||
|
| `tableLimit` | 1–200 | 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
|
||||||
|
(`"100–499 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.
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user