Merge remote-tracking branch 'origin/dev' into fix/coc-exam-workflow-defects

# Conflicts:
#	apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx
#	libs/api/src/lib/features/licensing/licensing-api.ts
#	libs/api/src/lib/features/licensing/licensing.types.ts
This commit is contained in:
mihretue
2026-08-31 09:16:24 +00:00
101 changed files with 8172 additions and 2077 deletions

View File

@@ -28,19 +28,21 @@ import {
IconEye,
IconInfoCircle,
IconShieldCheck,
IconTrash,
} from '@tabler/icons-react';
import { authStorage, useCurrentProfile } from '@ema-platform/auth';
import {
extractErrorMessage,
useApiQuery,
useBypassPaymentMutation,
useDiscardApplicationMutation,
useGetPaymentCapabilitiesQuery,
} from '@ema-platform/api';
import {
useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery,
} from '@ema-platform/api';
import { PdfPreviewModal } from '@ema-platform/ui';
import { ConfirmModal, PdfPreviewModal } from '@ema-platform/ui';
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
import { examStageFor, registrationForApplication } from '../../licensing/exam-stage';
import type { MyRegistration } from '../../exams/pages/ExamsPage';
@@ -61,6 +63,8 @@ interface CertificatesOverview {
id: string;
applicationId: string;
type: string;
/** Which license type this is a draft of — gates the Apply buttons. */
licenseTypeKey: string | null;
submitted: string;
status: string;
/** The fee owed at the current status, or null when nothing is due. */
@@ -137,9 +141,7 @@ function formatDate(value: string | null | undefined): string {
});
}
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();
@@ -172,6 +174,8 @@ export function CertificatesPage() {
// never rendered. Same shortcut My Applications offers.
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation();
const [discardTarget, setDiscardTarget] = useState<{ id: string; applicationId: string } | null>(null);
const { data, refetch } = useApiQuery<CertificatesOverview>({
url: '/certificates/my',
@@ -194,6 +198,22 @@ export function CertificatesPage() {
notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) });
}
};
const handleDiscard = async () => {
if (!discardTarget) return;
try {
await discardApplication(discardTarget.applicationId).unwrap();
notifications.show({
color: 'teal',
title: 'Draft discarded',
message: `${discardTarget.id} has been deleted.`,
});
setDiscardTarget(null);
refetch();
} catch (err) {
notifications.show({ color: 'red', title: 'Could not discard', message: extractErrorMessage(err) });
}
};
const certificates = data?.certificates ?? [];
const applications = data?.applications ?? [];
@@ -222,6 +242,12 @@ export function CertificatesPage() {
// The three queries below only build the human-readable reason list for
// the tooltip/banner — the gate itself is the one boolean.
const { profile, eligibleForCoc: canApply } = useCurrentProfile();
// One open draft per certificate type: the API resumes the existing draft
// rather than stacking a second, so the button says so instead of looking
// like it did nothing.
const draftTypeKeys = new Set(
applications.filter((a) => a.status === 'DRAFT').map((a) => a.licenseTypeKey),
);
const { data: seaServiceRecords } = useGetMySeaServiceRecordsQuery();
const { data: medicalCertificates } = useGetMyMedicalCertificatesQuery();
@@ -289,23 +315,25 @@ export function CertificatesPage() {
disabled, so the reason still shows on hover. */}
<span>
<Group gap="xs">
<Button
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_COMPETENCY/apply')}
disabled={!canApply}
>
Apply for CoC
</Button>
<Button
variant="light"
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate('/licensing/CERTIFICATE_OF_PROFICIENCY/apply')}
disabled={!canApply}
>
Apply for CoP
</Button>
{([
{ key: 'CERTIFICATE_OF_COMPETENCY', label: 'CoC', variant: 'filled' as const },
{ key: 'CERTIFICATE_OF_PROFICIENCY', label: 'CoP', variant: 'light' as const },
]).map(({ key, label, variant }) => {
const hasDraft = draftTypeKeys.has(key);
return (
<Button
key={key}
variant={variant}
leftSection={<IconShieldCheck size={15} />}
rightSection={<IconArrowRight size={15} />}
onClick={() => navigate(`/licensing/${key}/apply`)}
disabled={!canApply || hasDraft}
title={hasDraft ? `You already have a ${label} draft — continue it below.` : undefined}
>
{`Apply for ${label}`}
</Button>
);
})}
</Group>
</span>
</Tooltip>
@@ -410,8 +438,21 @@ export function CertificatesPage() {
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/applications/${app.applicationId}`)}
>
Details
{app.status === 'DRAFT' ? 'Continue' : 'Details'}
</Text>
{/* Drafts only: once submitted the filing is a record,
and withdrawing it is the officer's call. */}
{app.status === 'DRAFT' && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={13} />}
onClick={() => setDiscardTarget({ id: app.id, applicationId: app.applicationId })}
>
Discard
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
@@ -457,6 +498,16 @@ export function CertificatesPage() {
)}
</Paper>
<ConfirmModal
opened={!!discardTarget}
onClose={() => setDiscardTarget(null)}
onConfirm={handleDiscard}
loading={discarding}
title="Discard draft"
message={`Delete draft ${discardTarget?.id ?? ''}? Anything filled in so far is lost.`}
confirmLabel="Discard"
/>
<PdfPreviewModal
opened={!!previewUrl}
onClose={() => setPreviewUrl(null)}

View File

@@ -0,0 +1,378 @@
import { useRef, useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Divider,
FileButton,
Group,
Loader,
Modal,
Progress,
SimpleGrid,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import {
IconAlertTriangle,
IconPaperclip,
IconRefresh,
IconTrash,
IconUpload,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import { useLocalized } from '@ema-platform/api';
import {
replacePersonalDocumentFile,
uploadPersonalDocumentFile,
useDeletePersonalDocumentFileMutation,
useGetMyPersonalDocumentsQuery,
type AttachmentFile,
type PersonalDocumentError,
type PersonalDocumentSlot,
type PersonalDocumentUploadResult,
} from '@ema-platform/api';
import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
/**
* A refused delete comes back through RTK, which nests the server's payload
* under `data`. Uploads report theirs directly — see the XHR helper.
*/
function errorBody(err: unknown): PersonalDocumentError {
const payload = (err as { data?: { message?: unknown } })?.data?.message;
return typeof payload === 'object' && payload !== null
? (payload as PersonalDocumentError)
: { message: typeof payload === 'string' ? payload : undefined };
}
/**
* The applicant's own document vault.
*
* Slots are configuration, not code: the backoffice decides which documents
* everyone keeps and how many files each holds, so labels, accepted types and
* limits all arrive with the data. The upload button knows it is full for the
* same reason the server refuses a third file.
*/
export function PersonalDocumentSlots({
onPreview,
}: {
onPreview: (preview: { url: string; title: string; mimeType?: string | null }) => void;
}) {
const { t } = useTranslation();
const localized = useLocalized();
const { data, isLoading, refetch } = useGetMyPersonalDocumentsQuery();
const [deleteFile] = useDeletePersonalDocumentFileMutation();
const [busy, setBusy] = useState<string | null>(null);
// Percent for the upload in flight. A video is minutes of waiting, so the
// bar is the difference between waiting and reloading the page.
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AttachmentFile | null>(null);
// Mantine's FileButton clears its input through a ref object, and there is
// one input per slot and per file, so the objects are kept by key.
const resetRefs = useRef<Record<string, { current: (() => void) | null }>>({});
function resetRef(key: string) {
resetRefs.current[key] ??= { current: null };
return resetRefs.current[key] as { current: () => void };
}
function clearInput(key: string) {
resetRefs.current[key]?.current?.();
}
/**
* Checked here as well as on the server so the common mistakes — a PDF where
* a photograph belongs, a 12 MB scan — never cost a round trip.
*/
function rejectFile(slot: PersonalDocumentSlot, file: File): string | null {
if (slot.allowedMimeTypes.length && !slot.allowedMimeTypes.includes(file.type)) {
return t('documents.personal.errors.unsupported_document_type', {
allowed: slot.allowedMimeTypes.join(', '),
});
}
if (file.size > slot.maxSizeMb * 1024 * 1024) {
return t('documents.personal.errors.document_too_large', {
maxBytes: slot.maxSizeMb * 1024 * 1024,
});
}
return null;
}
function describe(body: PersonalDocumentError): string {
return t(`documents.personal.errors.${body.message ?? 'unknown'}`, {
...body,
defaultValue: t('documents.personal.errors.unknown'),
});
}
/** Deletes, which still go through RTK and refetch themselves. */
async function run(busyKey: string, action: () => Promise<unknown>) {
setBusy(busyKey);
setError(null);
try {
await action();
} catch (err) {
setError(describe(errorBody(err)));
} finally {
setBusy(null);
clearInput(busyKey);
}
}
/**
* Uploads, which report progress and so bypass RTK — the vault is refetched
* by hand once the file has landed.
*/
async function send(
busyKey: string,
action: (onProgress: (percent: number) => void) => Promise<PersonalDocumentUploadResult>,
) {
setBusy(busyKey);
setProgress(0);
setError(null);
const result = await action(setProgress);
if (result.ok) await refetch();
else setError(describe(result.error));
setBusy(null);
setProgress(null);
clearInput(busyKey);
}
function handleUpload(slot: PersonalDocumentSlot, file: File | null) {
if (!file) return;
const rejected = rejectFile(slot, file);
if (rejected) {
setError(rejected);
clearInput(slot.key);
return;
}
return send(slot.key, (onProgress) =>
uploadPersonalDocumentFile({ documentKey: slot.key, file, onProgress }),
);
}
function handleReplace(slot: PersonalDocumentSlot, fileId: string, file: File | null) {
if (!file) return;
const rejected = rejectFile(slot, file);
if (rejected) {
setError(rejected);
clearInput(fileId);
return;
}
return send(fileId, (onProgress) =>
replacePersonalDocumentFile({ fileId, file, onProgress }),
);
}
async function confirmDelete() {
if (!deleteTarget) return;
await run(deleteTarget.id, () => deleteFile(deleteTarget.id).unwrap());
setDeleteTarget(null);
}
/** The bar belongs to the card whose slot, or whose file, is uploading. */
function isThisSlot(slot: PersonalDocumentSlot, busyKey: string) {
return busyKey === slot.key || slot.files.some((f) => f.id === busyKey);
}
if (isLoading) return <Loader size="sm" type="oval" />;
const slots = data?.slots ?? [];
if (slots.length === 0) {
return (
<Text fz="sm" c="dimmed">
{t('documents.personal.empty')}
</Text>
);
}
return (
<Stack gap="md">
<Text fz="sm" c="dimmed">
{t('documents.personal.description')}
</Text>
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} onClose={() => setError(null)} withCloseButton>
{error}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{slots.map((slot) => {
const full = slot.maxFiles !== null && slot.files.length >= slot.maxFiles;
return (
<Card
key={slot.key}
withBorder
radius="md"
padding="md"
style={{
borderStyle: slot.files.length ? 'solid' : 'dashed',
borderColor: slot.files.length ? 'var(--mantine-color-teal-4)' : undefined,
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fz="sm" fw={600}>
{localized(slot.name)}
</Text>
{slot.description && (
<Text fz="xs" c="dimmed">
{localized(slot.description)}
</Text>
)}
</div>
<Badge
size="sm"
variant="light"
color={slot.files.length ? 'teal' : 'gray'}
style={{ flexShrink: 0 }}
>
{slot.maxFiles === null
? t('documents.personal.fileCountUnlimited', { count: slot.files.length })
: t('documents.personal.fileCount', {
count: slot.files.length,
max: slot.maxFiles,
})}
</Badge>
</Group>
<Divider my="sm" />
{slot.files.length === 0 ? (
<Text fz="xs" c="dimmed">
{t('documents.files.none')}
</Text>
) : (
<Stack gap={6}>
{slot.files.map((file) => (
<Group key={file.id} gap={6} wrap="nowrap">
<IconPaperclip size={14} />
<Text
fz="xs"
style={{ flex: 1, cursor: file.url ? 'pointer' : undefined }}
c={file.url ? 'blue' : undefined}
truncate
onClick={() =>
file.url &&
onPreview({
url: file.url,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{file.originalName}
</Text>
<RequirePermission
anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]}
hideOnly
>
<Group gap={2} wrap="nowrap">
<FileButton
resetRef={resetRef(file.id)}
onChange={(picked) => handleReplace(slot, file.id, picked)}
accept={slot.allowedMimeTypes.join(',')}
>
{(props) => (
<Tooltip label={t('licensing.documents.replace')}>
<ActionIcon
variant="subtle"
size="sm"
loading={busy === file.id}
{...props}
>
<IconRefresh size={13} />
</ActionIcon>
</Tooltip>
)}
</FileButton>
<Tooltip label={t('documents.personal.delete')}>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => setDeleteTarget(file)}
>
<IconTrash size={13} />
</ActionIcon>
</Tooltip>
</Group>
</RequirePermission>
</Group>
))}
</Stack>
)}
{busy !== null && progress !== null && isThisSlot(slot, busy) && (
<Stack gap={2} mt="sm">
<Progress value={progress} size="sm" radius="xl" animated />
<Text fz="xs" c="dimmed" ta="right">
{t('documents.personal.uploading', { percent: progress })}
</Text>
</Stack>
)}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<Tooltip label={t('documents.personal.slotFull')} disabled={!full}>
<div>
<FileButton
resetRef={resetRef(slot.key)}
onChange={(picked) => handleUpload(slot, picked)}
accept={slot.allowedMimeTypes.join(',')}
>
{(props) => (
<Button
mt="sm"
size="xs"
variant="light"
fullWidth
leftSection={<IconUpload size={13} />}
loading={busy === slot.key}
disabled={full}
{...props}
>
{t('licensing.documents.upload')}
</Button>
)}
</FileButton>
</div>
</Tooltip>
</RequirePermission>
</Card>
);
})}
</SimpleGrid>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title={t('documents.personal.confirmDelete.title')}
size="sm"
centered
>
<Stack gap="md">
<Text fz="sm">
{t('documents.personal.confirmDelete.body', {
name: deleteTarget?.originalName ?? '',
})}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setDeleteTarget(null)}>
{t('common.cancel')}
</Button>
<Button color="red" loading={busy === deleteTarget?.id} onClick={confirmDelete}>
{t('documents.personal.confirmDelete.confirm')}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,6 +1,54 @@
import { Container } from '@mantine/core';
import { FeatureUnavailable } from '@ema-platform/ui';
import { useState } from 'react';
import {
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconAnchor,
IconCertificate,
IconEye,
IconHeartbeat,
IconIdBadge2,
IconPaperclip,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useDateDisplayer } from '@ema-platform/shared';
import { FilePreviewModal, notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetAttachmentsQuery,
useGetCertificateUrlMutation,
useGetMyLicensesQuery,
useGetMyMedicalCertificatesQuery,
useGetMySeaServiceRecordsQuery,
useGetMySeafarerDocumentsQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
type SeafarerDocument,
type SeafarerRecordStatus,
} from '@ema-platform/api';
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots';
/** What the viewer needs: the link, a caption, and how to render it. */
type Preview = { url: string; title: string; mimeType?: string | null };
const RECORD_STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'blue',
VERIFIED: 'teal',
REJECTED: 'red',
};
/**
* Placeholder until this feature has a backend.
@@ -8,14 +56,403 @@ import { useTranslation } from 'react-i18next';
* This page previously rendered invented figures/records that were
* indistinguishable from real ones.
*/
function RecordFiles({
ownerType,
ownerId,
onPreview,
}: {
ownerType: 'SEA_SERVICE_RECORD' | 'MEDICAL_CERTIFICATE' | 'SEAFARER_REGISTRATION';
ownerId: string;
onPreview: (preview: Preview) => void;
}) {
const { t } = useTranslation();
const { data, isLoading } = useGetAttachmentsQuery({ ownerType, ownerId });
const files = (data ?? []).flatMap((a) => a.files);
if (isLoading) return <Loader size="xs" type="oval" />;
if (files.length === 0)
return (
<Text fz="xs" c="dimmed">
{t('documents.files.none')}
</Text>
);
return (
<Stack gap={4}>
{files.map((file) => (
<Group key={file.id} gap={6} wrap="nowrap">
<IconPaperclip size={14} />
{file.url ? (
<Anchor
component="button"
type="button"
fz="xs"
onClick={() =>
onPreview({
url: file.url as string,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{file.originalName}
</Anchor>
) : (
<Text fz="xs">{file.originalName}</Text>
)}
</Group>
))}
</Stack>
);
}
function FieldRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fz="xs" c="dimmed">
{label}
</Text>
<Text fz="xs" fw={500} ta="right">
{value}
</Text>
</Group>
);
}
/** Seaman Book / BTC — issued on their own workflow, not as licences. */
function IssuedDocumentCard({
document,
onPreview,
}: {
document: SeafarerDocument;
onPreview: (preview: Preview) => void;
}) {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const [download, { isFetching }] = useLazyGetMySeafarerDocumentDownloadQuery();
const issued = document.status === 'ISSUED';
async function open() {
try {
const { url } = await download(document.id).unwrap();
onPreview({ url, title: t(`documents.kind.${document.kind}`) });
} catch (err) {
notify.error(extractErrorMessage(err), t('documents.openFailed'));
}
}
return (
<Card withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color={issued ? 'teal' : 'gray'} radius="md">
<IconCertificate size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{t(`documents.kind.${document.kind}`)}
</Text>
<Text fz="xs" c="dimmed">
{document.documentNumber ?? document.requestNumber}
</Text>
</div>
</Group>
<Badge size="sm" variant="light" color={issued ? 'teal' : 'gray'} style={{ flexShrink: 0 }}>
{t(`documents.documentStatus.${document.status}`, { defaultValue: document.status })}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
{document.issueDate && (
<FieldRow label={t('seaRecords.columns.issued')} value={showDate(document.issueDate)} />
)}
{document.expiryDate && (
<FieldRow label={t('seaRecords.columns.expires')} value={showDate(document.expiryDate)} />
)}
</Stack>
<Button
mt="sm"
size="xs"
variant="light"
fullWidth
leftSection={<IconEye size={14} />}
disabled={!issued}
loading={isFetching}
onClick={open}
>
{issued ? t('documents.view') : t('documents.notIssued')}
</Button>
</Card>
);
}
export function DocumentVaultPage() {
const { t } = useTranslation();
const showDate = useDateDisplayer();
const [preview, setPreview] = useState<Preview | null>(null);
const { data: licences, isLoading: loadingLicences } = useGetMyLicensesQuery();
const { data: issuedDocuments } = useGetMySeafarerDocumentsQuery();
const { data: medicals, isLoading: loadingMedicals } = useGetMyMedicalCertificatesQuery();
const { data: seaService, isLoading: loadingSeaService } = useGetMySeaServiceRecordsQuery();
const [getCertificateUrl, { isLoading: isDownloadingCert }] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
async function openCertificate(licenseId: string) {
try {
const { url } = await getCertificateUrl(licenseId).unwrap();
setPreview({ url, title: t('licensing.card.downloadCertificate') });
} catch (err) {
notify.error(extractErrorMessage(err), t('documents.openFailed'));
}
}
const licenceItems = licences?.items ?? [];
const issued = [issuedDocuments?.seamanBook, issuedDocuments?.btc].filter(
(d): d is SeafarerDocument => Boolean(d),
);
return (
<Container size="lg" py="xl">
<FeatureUnavailable
title={t('featureUnavailable.documents.title')}
description={t('featureUnavailable.documents.description')}
<Stack gap="md">
<div>
<Title order={3}>{t('documents.title')}</Title>
<Text fz="sm" c="dimmed">
{t('documents.subtitle')}
</Text>
</div>
<Tabs defaultValue="license" variant="outline" radius="md" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="license" leftSection={<IconCertificate size={16} />}>
{t('documents.tabs.license')}
</Tabs.Tab>
<Tabs.Tab value="medical" leftSection={<IconHeartbeat size={16} />}>
{t('documents.tabs.medical')}
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
{t('documents.tabs.seaService')}
</Tabs.Tab>
<Tabs.Tab value="personal" leftSection={<IconIdBadge2 size={16} />}>
{t('documents.tabs.personal')}
</Tabs.Tab>
</Tabs.List>
{/* ── Licences and EMA-issued documents ───────────────────────── */}
<Tabs.Panel value="license">
<Stack gap="xl">
{issued.length > 0 && (
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
{t('documents.issuedTitle')}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{issued.map((document) => (
<IssuedDocumentCard
key={document.id}
document={document}
onPreview={setPreview}
/>
))}
</SimpleGrid>
</div>
)}
<div>
<Text fw={700} fz="sm" mb="sm" tt="uppercase" c="dimmed">
{t('documents.licensesTitle')}
</Text>
{loadingLicences ? (
<Loader size="sm" type="oval" />
) : licenceItems.length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.licenses')}
</Text>
) : (
// Two per row, not three: licence type names run long
// ("Multimodal Transport Operator License") and a third
// column truncates them.
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{licenceItems.map((licence) => (
<LicenseCard
key={licence.id}
license={licence}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
onDownload={() => openCertificate(licence.id)}
onRenew={() => renewLicense(licence)}
/>
))}
</SimpleGrid>
)}
</div>
</Stack>
</Tabs.Panel>
{/* ── Medical certificates ────────────────────────────────────── */}
<Tabs.Panel value="medical">
{loadingMedicals ? (
<Loader size="sm" type="oval" />
) : (medicals ?? []).length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.medical')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{(medicals ?? []).map((record) => (
<Card key={record.id} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="pink" radius="md">
<IconHeartbeat size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{record.issuerName}
</Text>
{record.certificateNumber && (
<Text fz="xs" c="dimmed">
{t('seaRecords.columns.certNumber', {
number: record.certificateNumber,
})}
</Text>
)}
</div>
</Group>
<Badge
size="sm"
variant="light"
color={RECORD_STATUS_COLOR[record.status]}
style={{ flexShrink: 0 }}
>
{t(`seaRecords.columns.recordStatus.${record.status}`, {
defaultValue: record.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
<FieldRow
label={t('seaRecords.columns.issued')}
value={showDate(record.issueDate)}
/>
<FieldRow
label={t('seaRecords.columns.expires')}
value={showDate(record.expiryDate)}
/>
<FieldRow
label={t('seaRecords.columns.fitness')}
value={t(`seaRecords.columns.fitnessOptions.${record.fitnessStatus}`, {
defaultValue: record.fitnessStatus,
})}
/>
</Stack>
<Divider my="sm" />
<RecordFiles
ownerType="MEDICAL_CERTIFICATE"
ownerId={record.id}
onPreview={setPreview}
/>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
{/* ── Sea service records ─────────────────────────────────────── */}
<Tabs.Panel value="sea-service">
{loadingSeaService ? (
<Loader size="sm" type="oval" />
) : (seaService ?? []).length === 0 ? (
<Text fz="sm" c="dimmed">
{t('documents.empty.seaService')}
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{(seaService ?? []).map((record) => (
<Card key={record.id} withBorder radius="md" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon size="lg" variant="light" color="blue" radius="md">
<IconAnchor size={18} stroke={1.5} />
</ThemeIcon>
<div>
<Text fz="sm" fw={600}>
{record.vesselName}
</Text>
{record.imoNumber && (
<Text fz="xs" c="dimmed">
{t('seaRecords.columns.imo', { number: record.imoNumber })}
</Text>
)}
</div>
</Group>
<Badge
size="sm"
variant="light"
color={RECORD_STATUS_COLOR[record.status]}
style={{ flexShrink: 0 }}
>
{t(`seaRecords.columns.recordStatus.${record.status}`, {
defaultValue: record.status,
})}
</Badge>
</Group>
<Divider my="sm" />
<Stack gap={4}>
<FieldRow label={t('seaRecords.columns.rank')} value={record.rank} />
<FieldRow
label={t('seaRecords.columns.from')}
value={showDate(record.engagementDate)}
/>
<FieldRow
label={t('seaRecords.columns.to')}
value={showDate(record.dischargeDate)}
/>
</Stack>
<Divider my="sm" />
<RecordFiles
ownerType="SEA_SERVICE_RECORD"
ownerId={record.id}
onPreview={setPreview}
/>
</Card>
))}
</SimpleGrid>
)}
</Tabs.Panel>
{/* ── The applicant's own document vault ──────────────────────── */}
<Tabs.Panel value="personal">
{/* Owned by the profile, not by a registration or an application:
the slots are configured in the backoffice and the files travel
with the person. */}
<PersonalDocumentSlots onPreview={setPreview} />
</Tabs.Panel>
</Tabs>
</Stack>
<FilePreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
mimeType={preview?.mimeType}
labels={{
unsupported: t('documents.preview.unsupported'),
openInNewTab: t('documents.preview.openInNewTab'),
close: t('documents.preview.close'),
}}
/>
</Container>
);

View File

@@ -37,6 +37,12 @@ interface Props {
flagged?: Record<string, string>;
/** When set, only flagged slots accept a new upload. */
restrictToFlagged?: boolean;
/**
* Requirement keys opened because a flagged section drives their condition —
* a category correction can make documents newly required, and those have to
* be uploadable even though the officer flagged no document.
*/
alsoUnlocked?: string[];
onUploaded: () => void;
readOnly?: boolean;
}
@@ -55,6 +61,7 @@ export function DocumentSlots({
ownerId,
flagged = {},
restrictToFlagged = false,
alsoUnlocked = [],
onUploaded,
readOnly,
}: Props) {
@@ -106,7 +113,11 @@ export function DocumentSlots({
const uploaded = Boolean(existing?.files?.length);
const fileUrl = existing?.files?.[0]?.url;
const flagRemark = flagged[requirement.key];
const locked = readOnly || (restrictToFlagged && !flagRemark);
const locked =
readOnly ||
(restrictToFlagged &&
!flagRemark &&
!alsoUnlocked.includes(requirement.key));
return (
<Card

View File

@@ -10,7 +10,7 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { IconDownload, IconRefresh } from '@tabler/icons-react';
import { IconAlertTriangle, IconDownload, IconRefresh } from '@tabler/icons-react';
import {
extractErrorMessage,
useLocalized,
@@ -59,6 +59,36 @@ export function useRenewLicense() {
return { renewLicense, isRenewing };
}
/**
* Damaged/Reissue reuses the same wizard, application kind REISSUE — the
* "Damage Information" step and the Reissue document set only appear because
* the created application carries that kind, exactly the way RENEWAL's own
* fields do above.
*/
export function useReissueLicense() {
const navigate = useNavigate();
const { t } = useTranslation();
const [createApplication, { isLoading: isReissuing }] =
useCreateApplicationMutation();
async function reissueLicense(license: IssuedLicense) {
const typeKey = license.licenseType?.key;
if (!typeKey) return;
try {
const application = await createApplication({
licenseType: typeKey,
kind: 'REISSUE',
previousLicenseId: license.id,
}).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) {
notify.error(extractErrorMessage(err), t('licensing.card.reissueFailed'));
}
}
return { reissueLicense, isReissuing };
}
function daysUntil(date: string): number {
const ms = new Date(date).getTime() - Date.now();
return Math.ceil(ms / 86_400_000);
@@ -68,20 +98,30 @@ export function LicenseCard({
license,
isDownloading,
isRenewing,
isReissuing,
onDownload,
onRenew,
onReissue,
}: {
license: IssuedLicense;
isDownloading: boolean;
isRenewing: boolean;
isReissuing?: boolean;
onDownload: () => void;
onRenew: () => void;
onReissue?: () => void;
}) {
// The API computes both in the authority's timezone; the local fallbacks are
// only for a cached response from before those fields existed.
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
const expired = license.status === 'EXPIRED' || days < 0;
// Suspended, cancelled and superseded are none of them "valid until" their
// expiry date — the card used to say exactly that, because only EXPIRED was
// treated as not-current. A suspended licence read as a live one with a grey
// badge.
const current = license.status === 'ACTIVE' && !expired;
const renewable = license.renewable ?? false;
const reissuable = license.reissuable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
const { t } = useTranslation();
@@ -100,9 +140,15 @@ export function LicenseCard({
<Badge
size="sm"
variant="light"
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
color={expired ? 'red' : current ? 'teal' : 'gray'}
>
{expired ? t('licensing.card.expired') : license.status}
{/* The raw enum was rendered here, so an Amharic page showed
"SUSPENDED" among otherwise translated text. */}
{expired
? t('licensing.card.expired')
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Badge>
</Group>
@@ -113,8 +159,19 @@ export function LicenseCard({
<Text size="sm" fw={500}>
{expired
? t('licensing.card.expiredOn', { date: showDate(license.expiryDate) })
: t('licensing.card.validUntil', { date: showDate(license.expiryDate) })}
: current
? t('licensing.card.validUntil', { date: showDate(license.expiryDate) })
: t(`licensing.card.status.${license.status}`, {
defaultValue: license.status,
})}
</Text>
{/* Why it stopped being current. The API returns it; the card threw
it away, leaving the holder to guess. */}
{!current && license.statusReason && (
<Text size="xs" c="dimmed" mt={2}>
{t('licensing.card.statusReason', { reason: license.statusReason })}
</Text>
)}
</Box>
<RequirePermission
anyOf={[
@@ -157,6 +214,25 @@ export function LicenseCard({
</Button>
</RequirePermission>
)}
{/* Damaged/Reissue has no window — a lost or damaged document can be
replaced at any point in its validity, unlike Renewal above. */}
{reissuable && onReissue && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button
fullWidth
mt="xs"
size="xs"
variant="subtle"
color="gray"
loading={isReissuing}
leftSection={<IconAlertTriangle size={14} />}
onClick={onReissue}
>
{t('licensing.card.reportDamaged')}
</Button>
</RequirePermission>
)}
</Card>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Anchor,
Badge,
Box,
@@ -20,6 +21,7 @@ import {
IconBuildingWarehouse,
IconChevronRight,
IconFileText,
IconLock,
IconShieldOff,
IconShip,
IconTrendingUp,
@@ -45,9 +47,9 @@ const CATEGORY_ICONS: Record<LicenseCategory, typeof IconShip> = {
CARGO_FREIGHT: IconBuildingWarehouse,
SHIPPING_AGENCY: IconShip,
INVESTMENT: IconTrendingUp,
// The three below are filtered out of this catalogue today
// (requiresOperatorMode is false for all of them), and are listed only so
// the record stays total if that ever changes.
// The three below appear only when the applicant has declared a licence
// type in them (see the family filter below); listed here so the record
// stays total either way.
MARITIME_PERSONNEL: IconShip,
VESSEL_SERVICES: IconAnchor,
WAIVER_SERVICES: IconShieldOff,
@@ -81,15 +83,19 @@ export function LicenseCatalogue() {
const { groups, orphans } = useMemo(() => {
const active = (types?.items ?? [])
.filter((t) => t.isActive)
// Logistics licences only: this is the operator catalogue, not the
// seafarer certificate or vessel/seafarer document catalogue — those
// have their own entry points. `familyKind` is the real data-model
// classification (set on the type at seed time); `requiresOperatorMode`
// was the proxy this used before that column existed and happened to
// agree for every type seeded so far, but a type can only be trusted to
// stay in sync with the catalogue it belongs in if the catalogue reads
// its actual family instead of a flag with a different purpose.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE')
// The logistics family, plus whatever this applicant actually declared.
//
// `familyKind` is the real data-model classification and is what keeps
// browse-all to the operator catalogue rather than every certificate and
// document type in the system. But it is not what decides eligibility:
// the Operations tab also offers the personal registrations (seafarer,
// vessel) and the seafarer endorsement, which are DOCUMENT/CERTIFICATE
// family, so an applicant who declared one of those was shown an empty
// catalogue — allowed to file, and offered nothing to file. Each of
// those keys already has an entry point at `/licensing/<key>/apply`
// (a router redirect for SEAFARER_REGISTRATION and SEAMAN_BOOK, the
// generic wizard for the rest), so the card leads somewhere real.
.filter((t) => t.familyKind === 'LOGISTICS_LICENSE' || declared.has(t.id))
// Only what the applicant operates as. The server enforces the same rule
// on create; this is what stops them starting an application they will
// be refused at the end of.
@@ -273,8 +279,8 @@ function LicenseTypeCard({
withBorder
radius="md"
padding="md"
style={{ cursor: 'pointer', height: '100%' }}
onClick={() => onSelect(type)}
style={{ cursor: canApply ? 'pointer' : 'default', height: '100%' }}
onClick={canApply ? () => onSelect(type) : undefined}
>
<Stack gap="xs" justify="space-between" h="100%">
<Box>
@@ -282,11 +288,19 @@ function LicenseTypeCard({
<Text fw={600} size="sm" lh={1.35}>
{localized(type.name)}
</Text>
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
{canApply ? (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
) : (
<IconLock
size={16}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0, marginTop: 2 }}
/>
)}
</Group>
{type.description && (
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
@@ -325,13 +339,45 @@ function LicenseTypeCard({
mt="sm"
size="xs"
variant="light"
color={canApply ? undefined : 'gray'}
disabled={!canApply}
rightSection={<IconArrowRight size={14} />}
>
{canApply
? t('licensing.catalogue.startApplication')
: t('licensing.catalogue.addToOperations')}
{t('licensing.catalogue.startApplication')}
</Button>
{/* A disabled button on its own only says "no". This says why, and
where to go about it — the licence is offered against a declared
mode of operation, and the server refuses a create for one the
applicant has not declared. Called out rather than set in dimmed
small print: it is the only thing on a locked card the applicant
can act on. */}
{!canApply && (
<Alert
variant="light"
color="orange"
radius="md"
mt="sm"
p="xs"
>
<Text size="xs" lh={1.4}>
{t('licensing.catalogue.lockedHint')}
</Text>
<Anchor
size="xs"
fw={600}
component="button"
type="button"
mt={4}
onClick={(event) => {
// The card is inert while locked, but the anchor inside it
// must not re-trigger anything if that ever changes.
event.stopPropagation();
onSelect(type);
}}
>
{t('licensing.catalogue.addToOperations')}
</Anchor>
</Alert>
)}
</RequirePermission>
</Box>
</Stack>

View File

@@ -0,0 +1,54 @@
import { Group, Paper, Stack, Text } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { IssuancePeriod } from '@ema-platform/api';
/**
* Read-only view of the pickup appointment a team leader assigned (spec
* §19-21 — the office decides who comes in when, the applicant doesn't pick
* a slot). Shown once payment is confirmed and the licence type prints once
* and hands the document over in person.
*/
export function PickupSchedulingPanel({
scheduledDate,
scheduledPeriod,
}: {
scheduledDate: string | null;
scheduledPeriod: IssuancePeriod | null;
}) {
const { t } = useTranslation();
return (
<Paper withBorder p="md" radius="md">
<Group gap="xs" mb="sm">
<IconCalendarEvent size={16} />
<Text fw={600} size="sm">
{t('pickup.title')}
</Text>
</Group>
{scheduledDate ? (
<Stack gap={4}>
<Text size="sm">
{t('pickup.scheduledFor', {
date: scheduledDate,
period:
scheduledPeriod === 'AFTERNOON'
? t('pickup.afternoon')
: t('pickup.morning'),
})}
</Text>
<Text size="sm" c="dimmed">
{t('pickup.setByOffice')}
</Text>
</Stack>
) : (
<Text size="sm" c="dimmed">
{t('pickup.awaitingSchedule')}
</Text>
)}
</Paper>
);
}
export default PickupSchedulingPanel;

View File

@@ -4,6 +4,7 @@ import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Container,
@@ -32,6 +33,8 @@ import { useTranslation } from "react-i18next";
import {
buildWizardSteps,
conditionHolds,
conditionSections,
sectionsDependingOn,
extractErrorMessage,
extractValidationIssues,
useLocalized,
@@ -65,6 +68,7 @@ import {
PORTAL_PERMISSIONS,
RequirePermission,
useCurrentProfile,
usePermissions,
} from "@ema-platform/auth";
import { ApplicationSummary } from "../components/ApplicationSummary";
import {
@@ -72,6 +76,7 @@ import {
fillFromVessel,
} from "../components/ConfigDrivenSection";
import { DocumentSlots } from "../components/DocumentSlots";
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks";
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
@@ -115,9 +120,14 @@ export function LicenseApplicationPage() {
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
const { profile } = useCurrentProfile();
const { can: hasPermission, known: permissionsKnown } = usePermissions();
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
// here rather than deeper down since it's the shared source of draft state.
const { data: vessels } = useGetMyVesselsQuery();
// Skipped for accounts without VIEW_OWN_VESSELS (e.g. freight forwarders):
// the API 403s for them, since vessels belong to VESSEL_OWNER accounts.
const { data: vessels } = useGetMyVesselsQuery(undefined, {
skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
});
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId);
@@ -352,24 +362,60 @@ export function LicenseApplicationPage() {
),
[roundRemarks],
);
const hasSectionRemarks = Object.keys(flaggedSections).length > 0;
const hasDocRemarks = Object.keys(flaggedDocuments).length > 0;
const hasStaffRemarks = roundRemarks.some((r) => r.targetType === "STAFF");
// Nothing at all came back for this round (a detail response that predates
// the remarks, say) — lock nothing rather than freeze the whole application
// with no way forward. Any remark present means the round is itemised, so
// only what the officer flagged opens: a documents-only round leaves every
// form section frozen, and a sections-only round leaves every document as
// filed.
const roundIsItemised = isAdjusting && roundRemarks.length > 0;
// An answer the officer flagged can decide which fields *other* sections
// require — the vessel category is the live example. Freeze those and the
// applicant is shown newly-required fields they cannot fill, and cannot
// resubmit; the server unlocks them the same way.
const cascadeUnlocked = useMemo(
() =>
sectionsDependingOn(
config?.licenseType?.formSchema?.sections ?? [],
new Set(Object.keys(flaggedSections)),
),
[config, flaggedSections],
);
const unlockedDocuments = useMemo(
() =>
(config?.documentRequirements ?? [])
.filter((requirement) =>
conditionSections(requirement.conditionExpression).some(
(sectionKey) => sectionKey in flaggedSections,
),
)
.map((requirement) => requirement.key),
[config, flaggedSections],
);
// A round that flagged no form sections carries no section locks — mirror of
// the server's fallback, without which a documents-only correction round
// froze every field and the applicant could not edit anything at all.
const isSectionLocked = (sectionKey: string) =>
isAdjusting && hasSectionRemarks && !flaggedSections[sectionKey];
roundIsItemised &&
!flaggedSections[sectionKey] &&
!cascadeUnlocked.has(sectionKey);
const staffLocked = roundIsItemised && !hasStaffRemarks;
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section.
// short instead of showing a page per section. A Damaged/Reissue
// application skips Staff and Documents outright — it asks nothing beyond
// the Damage Information step, regardless of what the licence type
// otherwise requires for a new application or renewal.
const isReissue = application?.kind === 'REISSUE';
const steps = useMemo(
() =>
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
hasStaff: isReissue ? false : (config?.staffRoleRequirements?.length ?? 0) > 0,
hasDocuments: !isReissue,
language: i18n.language,
applicationKind: application?.kind,
}),
[config, draft, i18n.language],
[config, draft, i18n.language, application?.kind, isReissue],
);
const sections = useMemo(
() => steps.flatMap((step) => step.sections),
@@ -688,6 +734,11 @@ export function LicenseApplicationPage() {
>
{STATUS_LABELS[application.status]}
</Badge>
{detail?.issuedLicenseStatus === "SUPERSEDED" && (
<Badge size="sm" variant="light" color="gray">
{t("licensing.certificateSuperseded", "Certificate superseded")}
</Badge>
)}
</Group>
</div>
<Group gap="md" align="center">
@@ -727,6 +778,17 @@ export function LicenseApplicationPage() {
</Alert>
)}
{config.licenseType.requiresIssuanceScheduling &&
(application.status === "PAYMENT_CONFIRMED" ||
application.status === "SCHEDULED") && (
<Box mb="md">
<PickupSchedulingPanel
scheduledDate={application.scheduledIssuanceDate}
scheduledPeriod={application.scheduledIssuancePeriod}
/>
</Box>
)}
{showSummary && editableWhileSubmitted && (
<Alert
color="blue"
@@ -862,7 +924,7 @@ export function LicenseApplicationPage() {
complete
</Badge>
)}
{!readOnly && (
{!readOnly && !staffLocked && (
<Button
size="xs"
variant="light"
@@ -898,7 +960,7 @@ export function LicenseApplicationPage() {
: ""}
</Text>
</div>
{!readOnly && (
{!readOnly && !staffLocked && (
<ActionIcon
variant="subtle"
color="red"
@@ -917,7 +979,7 @@ export function LicenseApplicationPage() {
<StaffEvidence
staffId={member.id}
evidence={role.requiredEvidence}
readOnly={readOnly}
readOnly={readOnly || staffLocked}
onUploaded={refetch}
/>
</Card>
@@ -937,7 +999,8 @@ export function LicenseApplicationPage() {
ownerType="APPLICATION"
ownerId={appId}
flagged={flaggedDocuments}
restrictToFlagged={isAdjusting && hasDocRemarks}
restrictToFlagged={roundIsItemised}
alsoUnlocked={unlockedDocuments}
readOnly={readOnly}
onUploaded={() => {
refetchAttachments();

View File

@@ -1,5 +1,5 @@
import { Button, Group } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import { IconDownload, IconTrash } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { LicenseApplication } from '@ema-platform/api';
@@ -33,6 +33,7 @@ export function applicationActionsColumn(
onRetakeExam: (app: LicenseApplication) => void;
onRegisterForExam: () => void;
onOpen: (app: LicenseApplication) => void;
onDiscard: (app: LicenseApplication) => void;
},
): AdvancedColumn<LicenseApplication> {
return {
@@ -128,6 +129,19 @@ export function applicationActionsColumn(
: t('applications.actions.view')}
</Button>
)}
{/* Drafts only: after submission the filing is a record an officer
may already be reading, so it is withdrawn, not deleted. */}
{app.status === 'DRAFT' && (
<Button
size="xs"
variant="subtle"
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => deps.onDiscard(app)}
>
{t('applications.actions.discard')}
</Button>
)}
</Group>
);
},

View File

@@ -1,4 +1,4 @@
import { Badge, Box, Progress, Text } from '@mantine/core';
import { Badge, Box, Group, Progress, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
@@ -6,10 +6,23 @@ import {
STATUS_PROGRESS,
applicantOrCompanyName,
localized,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
const KIND_LABEL: Record<ApplicationKind, string> = {
NEW: 'applications.table.kindNew',
RENEWAL: 'applications.table.kindRenewal',
REISSUE: 'applications.table.kindReissue',
};
const KIND_COLOR: Record<ApplicationKind, string> = {
NEW: 'blue',
RENEWAL: 'teal',
REISSUE: 'orange',
};
export function applicationColumns(
t: TFunction,
deps: {
@@ -26,9 +39,16 @@ export function applicationColumns(
header: t('applications.table.licence'),
cell: ({ row }) => (
<Box>
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
{row.original.kind !== 'NEW' && (
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
{t(KIND_LABEL[row.original.kind])}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{row.original.applicationNumber}
</Text>

View File

@@ -29,10 +29,10 @@ import {
IconSearch,
IconX,
} from '@tabler/icons-react';
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
import { AdvancedTable, AmharicDatePicker, ConfirmModal, EmptyState, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../components/LicenseCard';
import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard';
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
import { notifications } from '@mantine/notifications';
import {
@@ -42,12 +42,14 @@ import {
applicantOrCompanyName,
extractErrorMessage,
useBypassPaymentMutation,
useDiscardApplicationMutation,
useGetCertificateUrlMutation,
useGetMyApplicationsQuery,
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useApiQuery,
useRetakeExamMutation,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
@@ -114,7 +116,10 @@ export function MyApplicationsPage() {
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const [discardApplication, { isLoading: discarding }] = useDiscardApplicationMutation();
const [discardTarget, setDiscardTarget] = useState<{ id: string; label: string } | null>(null);
const { renewLicense, isRenewing } = useRenewLicense();
const { reissueLicense, isReissuing } = useReissueLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
const { can } = usePermissions();
@@ -146,6 +151,26 @@ export function MyApplicationsPage() {
}
}
/** Throws away an unfinished draft, after the applicant confirms. */
async function handleDiscard() {
if (!discardTarget) return;
try {
await discardApplication(discardTarget.id).unwrap();
notifications.show({
color: 'teal',
title: t('applications.actions.discarded'),
message: discardTarget.label,
});
setDiscardTarget(null);
} catch (err) {
notifications.show({
color: 'red',
title: t('applications.actions.discardFailed'),
message: extractErrorMessage(err),
});
}
}
/**
* Re-opens the examination fee for a failed candidate.
*
@@ -220,10 +245,13 @@ export function MyApplicationsPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
const [kindFilter, setKindFilter] = useState<ApplicationKind | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
const hasFilters = Boolean(
search || statusFilter || kindFilter || dateFrom || dateTo || bucketFilter,
);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const counts = useMemo(() => {
@@ -241,6 +269,7 @@ export function MyApplicationsPage() {
if (!haystack.includes(q)) return false;
}
if (statusFilter && app.status !== statusFilter) return false;
if (kindFilter && app.kind !== kindFilter) return false;
// Drafts have no submittedAt, so date filtering falls back to createdAt
// rather than silently excluding every draft from a date-ranged search.
const at = app.submittedAt ?? app.createdAt;
@@ -258,13 +287,14 @@ export function MyApplicationsPage() {
const bAt = b.submittedAt ?? b.createdAt;
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
});
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
}, [allItems, search, statusFilter, kindFilter, dateFrom, dateTo, bucketFilter]);
const page = paginate(items);
function clearFilters() {
setSearch('');
setStatusFilter(null);
setKindFilter(null);
setDateFrom('');
setDateTo('');
setBucketFilter(null);
@@ -319,6 +349,8 @@ export function MyApplicationsPage() {
onRegisterForExam: () => navigate('/exams'),
onOpen: (app) =>
navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`),
onDiscard: (app) =>
setDiscardTarget({ id: app.id, label: app.applicationNumber }),
}),
];
@@ -421,6 +453,22 @@ export function MyApplicationsPage() {
clearable
w={200}
/>
<Select
label={t('applications.filters.kind')}
placeholder={t('applications.filters.any')}
data={[
{ value: 'NEW', label: t('applications.table.kindNew') },
{ value: 'RENEWAL', label: t('applications.table.kindRenewal') },
{ value: 'REISSUE', label: t('applications.table.kindReissue') },
]}
value={kindFilter}
onChange={(v) => {
setKindFilter(v as ApplicationKind | null);
setPageIndex(0);
}}
clearable
w={160}
/>
<AmharicDatePicker
label={t('applications.filters.from')}
value={dateFrom}
@@ -511,8 +559,10 @@ export function MyApplicationsPage() {
license={license}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
isReissuing={isReissuing}
onDownload={() => downloadCertificate(license.id)}
onRenew={() => renewLicense(license)}
onReissue={() => reissueLicense(license)}
/>
))}
</SimpleGrid>
@@ -522,6 +572,19 @@ export function MyApplicationsPage() {
{tab === 'apply' && <LicenseCatalogue />}
</Stack>
<ConfirmModal
opened={!!discardTarget}
onClose={() => setDiscardTarget(null)}
onConfirm={handleDiscard}
loading={discarding}
title={t('applications.actions.discard')}
message={t('applications.actions.discardConfirm', {
number: discardTarget?.label ?? '',
})}
confirmLabel={t('applications.actions.discard')}
cancelLabel={t('common.cancel')}
/>
</Container>
);
}

View File

@@ -0,0 +1,46 @@
import { baseApi } from '@ema-platform/api';
/**
* The caller's specimen signature.
*
* Upload is multipart rather than the presign+PUT flow used for documents:
* the API validates type and size on the way through, which it cannot do when
* bytes go straight to storage. `signatureUrl` on the profile is an
* object-storage key, so the stored signature is displayed through a
* short-lived link from `GET me/signature` rather than read off the profile.
*/
const signatureApi = baseApi
.enhanceEndpoints({ addTagTypes: ['CurrentProfile', 'MySignature'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMySignature: builder.query<{ url: string | null }, void>({
query: () => ({ url: '/profiles/me/signature' }),
providesTags: ['MySignature'],
}),
uploadMySignature: builder.mutation<{ signatureUrl: string }, File>({
query: (file) => {
const body = new FormData();
body.append('file', file);
// No Content-Type header: fetch sets it with the multipart boundary,
// and naming it here would omit the boundary and fail to parse.
return { url: '/profiles/me/signature', method: 'POST', body };
},
invalidatesTags: (_r, error) =>
error ? [] : ['MySignature', 'CurrentProfile'],
}),
deleteMySignature: builder.mutation<{ signatureUrl: null }, void>({
query: () => ({ url: '/profiles/me/signature', method: 'DELETE' }),
invalidatesTags: (_r, error) =>
error ? [] : ['MySignature', 'CurrentProfile'],
}),
}),
overrideExisting: false,
});
export const {
useGetMySignatureQuery,
useUploadMySignatureMutation,
useDeleteMySignatureMutation,
} = signatureApi;

View File

@@ -0,0 +1,28 @@
import { SignaturePad } from '@ema-platform/ui';
import {
useDeleteMySignatureMutation,
useGetMySignatureQuery,
useUploadMySignatureMutation,
} from '../api/signature-api';
/**
* The seafarer's own specimen signature, printed on documents issued to them
* (`{{seafarerSignature}}`). Distinct from an officer's signing signature,
* which the backoffice manages against a different endpoint.
*/
export function MySignaturePad() {
const { data, isLoading } = useGetMySignatureQuery();
const [upload, { isLoading: isUploading }] = useUploadMySignatureMutation();
const [remove, { isLoading: isDeleting }] = useDeleteMySignatureMutation();
return (
<SignaturePad
currentUrl={data?.url ?? null}
isLoading={isLoading}
isUploading={isUploading}
isDeleting={isDeleting}
onUpload={(file) => upload(file).unwrap()}
onDelete={() => remove().unwrap()}
/>
);
}

View File

@@ -38,6 +38,7 @@ import {
IconMapPin,
IconMoon,
IconSettings,
IconSignature,
IconShieldLock,
IconSun,
IconUser,
@@ -67,6 +68,7 @@ import {
import { useSaveMyAddressMutation } from '../api/address-api';
import { toAddressPayload } from '../types/address';
import { OperationsFormContent } from '../components/OperationsFormContent';
import { MySignaturePad } from '../components/SignaturePad';
import { SEAFARER_PROFILE_REQUIREMENT } from '../components/RequireSeafarerProfile';
import classes from './ProfilePage.module.css';
@@ -76,6 +78,7 @@ const VALID_TABS = [
'profile',
'address',
'operations',
'signature',
'security',
'preferences',
];
@@ -633,6 +636,9 @@ export function ProfilePage() {
>
{t('profile.tabs.operations')}
</Tabs.Tab>
<Tabs.Tab value="signature" leftSection={<IconSignature size={18} />}>
{t('profile.tabs.signature')}
</Tabs.Tab>
<Tabs.Tab value="security" leftSection={<IconShieldLock size={18} />}>
{t('profile.tabs.security')}
</Tabs.Tab>
@@ -808,6 +814,13 @@ export function ProfilePage() {
</Paper>
</Tabs.Panel>
{/* ---- Signature (printed on issued documents) ---- */}
<Tabs.Panel value="signature" pt="md">
<Paper p="xl" shadow="sm" radius="lg" withBorder>
<MySignaturePad />
</Paper>
</Tabs.Panel>
{/* ---- Security ---- */}
<Tabs.Panel value="security" pt="md">
<Stack gap="lg">

View File

@@ -6,6 +6,7 @@ import {
GENDER_OPTIONS,
HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS,
RANK_TIER_OPTIONS,
isEthiopianNationality,
useGetActiveDepartmentsQuery,
useLocalized,
@@ -130,6 +131,18 @@ export function ApplicantDetailsStep(p: StepProps) {
options={departmentOptions}
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
/>
<SelectField
{...p}
name="tier"
label="Certificate Limitation"
required
options={RANK_TIER_OPTIONS}
description={
p.form.department === 'ENGINE'
? 'Above covers ships of 3000 kW propulsion power or more; Below covers 7503000 kW. Every Certificate of Competency you apply for is issued under this limit.'
: 'Above covers ships of 3000 gross tonnage or more; Below covers 5003000 GT. Every Certificate of Competency you apply for is issued under this limit.'
}
/>
</Grid>
<Divider />

View File

@@ -59,7 +59,7 @@ const STEPS = [
*/
const REQUIRED_BY_STEP: AnswerKey[][] = [
['firstName', 'lastName', 'gender', 'dateOfBirth', 'maritalStatus', 'nationality'],
['placeOfBirth', 'department', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['placeOfBirth', 'department', 'tier', 'locationId', 'hairColor', 'eyeColor', 'heightCm', 'weightKg'],
['medicalCertificateNumber', 'medicalIssuerName', 'medicalIssueDate'],
[],
['declarationAccepted'],

View File

@@ -0,0 +1,145 @@
import {
Alert,
Badge,
Card,
CopyButton,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import {
IconCheck,
IconCopy,
IconFingerprint,
IconIdBadge2,
IconInfoCircle,
IconScan,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useCurrentProfile } from '@ema-platform/auth';
import { useGetMyBiometricEnrollmentsQuery } from '@ema-platform/api';
const MODALITY_LABEL: Record<string, string> = {
FINGERPRINT: 'Fingerprint',
FACE: 'Face',
};
/**
* View-only: the seafarer's BSID and what is enrolled against it. Capture
* stays counter-side with a scanner — there is no self-enrollment flow here.
*/
export function BiometricsPage() {
const { t } = useTranslation();
const { data: enrollments, isLoading } = useGetMyBiometricEnrollmentsQuery();
// The BSID lives on the profile, stamped by staff once a capture is
// confirmed — it is not a property of any one enrollment, so it is read
// from the profile rather than from the rows below.
const { profile, isLoading: profileLoading } = useCurrentProfile();
const bsid = profile?.bsid ?? null;
const rows = enrollments ?? [];
return (
<Stack>
<Group gap="xs">
<IconFingerprint size={22} />
<Title order={2}>{t('biometrics.title', 'Biometrics')}</Title>
</Group>
<Alert variant="light" color="blue" icon={<IconInfoCircle size={16} />}>
{t(
'biometrics.pageIntro',
'Fingerprint and face enrollment happen in person at an EMA counter. This page shows what is on file for you.',
)}
</Alert>
<Paper withBorder radius="lg" p="xl">
<Group gap="sm" mb={rows.length || isLoading ? 'lg' : 0} align="flex-start">
<ThemeIcon size={40} radius="md" color="indigo" variant="light">
<IconIdBadge2 size={20} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
{t('biometrics.bsidLabel', 'Biometric Subject ID')}
</Text>
{profileLoading ? (
<Loader size="xs" mt={6} />
) : bsid ? (
<CopyButton value={bsid} timeout={1500}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t('biometrics.copied', 'Copied') : t('biometrics.copy', 'Copy')}
withArrow
>
<UnstyledButton onClick={copy}>
<Group gap={6} align="center">
<Text ff="monospace" fw={700} fz="lg">
{bsid}
</Text>
{copied ? <IconCheck size={15} /> : <IconCopy size={15} />}
</Group>
</UnstyledButton>
</Tooltip>
)}
</CopyButton>
) : (
<Text fz="sm" c="dimmed" mt={2}>
{t(
'biometrics.bsidPending',
'Not issued yet. Your BSID is generated once your enrolment is confirmed at the counter.',
)}
</Text>
)}
</div>
</Group>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
<Alert variant="light" color="gray" icon={<IconInfoCircle size={15} />}>
{t('biometrics.empty', 'No biometric enrollment on file yet.')}
</Alert>
) : (
<Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{rows.map((e) => (
<Card key={e.id} withBorder radius="md" p="md">
<Group justify="space-between">
<Group gap="sm">
<ThemeIcon size={36} radius="md" color="blue" variant="light">
<IconScan size={18} />
</ThemeIcon>
<div>
<Text fw={700} fz="sm">
{MODALITY_LABEL[e.modality] ?? e.modality}
</Text>
<Text fz="xs" c="dimmed">
Enrolled {new Date(e.enrolledAt).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</Text>
</div>
</Group>
<Badge color="teal" variant="light">
{e.status}
</Badge>
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
)}
</Paper>
</Stack>
);
}

View File

@@ -22,11 +22,15 @@ import {
IconFileDescription,
IconInfoCircle,
IconPrinter,
IconRefresh,
IconReplace,
IconShield,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
@@ -34,6 +38,8 @@ import {
useGetMySeafarerDocumentsQuery,
useGetPaymentCapabilitiesQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
type SeafarerDocument,
type SeafarerDocumentStatus,
} from "@ema-platform/api";
@@ -73,9 +79,20 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const activeStep = stageIndexFor(document.status);
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
try {
await action().unwrap();
onChanged();
} catch (err) {
notifications.show({ color: "red", title: "Request failed", message: extractErrorMessage(err) });
}
}
async function download() {
try {
const { url } = await getDownload(document.id).unwrap();
@@ -102,9 +119,16 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
</Text>
</div>
</Group>
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
<Group gap="xs">
{document.requestKind !== "NEW" && (
<Badge color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]} variant="outline" size="lg">
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
</Badge>
)}
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
</Group>
</Group>
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
@@ -165,9 +189,29 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
</span>
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconRefresh size={14} />}
loading={renewing}
onClick={() => renewOrReplace(() => renew(document.id))}
>
Renew
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconReplace size={14} />}
loading={replacing}
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
>
Report Lost/Damaged
</Button>
</Group>
</Group>
</Alert>
)}

View File

@@ -59,6 +59,7 @@ export const am: Translations = {
seaRecords: 'የባህር መዝገቦቼ',
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀት',
biometrics: 'ባዮሜትሪክ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
@@ -192,6 +193,7 @@ export const am: Translations = {
search: 'ፈልግ',
searchPlaceholder: 'ቁጥር ወይም አመልካች',
status: 'ሁኔታ',
kind: 'ዓይነት',
any: 'ማንኛውም',
from: 'ከ',
to: 'እስከ',
@@ -215,6 +217,9 @@ export const am: Translations = {
applicant: 'አመልካች',
progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር',
kindNew: 'አዲስ',
kindRenewal: 'እድሳት',
kindReissue: 'ምትክ',
},
actions: {
continue: 'ቀጥል',
@@ -224,6 +229,10 @@ export const am: Translations = {
view: 'ይመልከቱ',
bypass: 'ክፍያ ዝለል',
renew: 'አድስ',
discard: 'አጥፋ',
discardConfirm: 'ረቂቅ {{number}} ይጥፋ? እስካሁን የተሞላው ሁሉ ይጠፋል።',
discarded: 'ረቂቁ ጠፍቷል',
discardFailed: 'ረቂቁን ማጥፋት አልተቻለም',
},
notice: {
paymentReceived:
@@ -342,9 +351,31 @@ export const am: Translations = {
profile: 'መገለጫ',
address: 'አድራሻ',
operations: 'የስራ ዘርፍ',
signature: 'ፊርማ',
security: 'ደህንነት',
preferences: 'ምርጫዎች',
},
signature: {
title: 'የፊርማ ናሙና',
description: 'አንድ ጊዜ ይሳሉ ወይም ይጫኑ፤ በሚሰጡዎት ሰነዶች ላይ ይታተማል።',
reissueNotice:
'ፊርማዎን መቀየር አስቀድሞ የተሰጠን ሰነድ አይለውጥም — ከአሁን በኋላ በሚሰጡ ሰነዶች ላይ ብቻ ይሠራል።',
current: 'የተመዘገበ ፊርማ',
currentAlt: 'የተቀመጠ ፊርማዎ',
none: 'እስካሁን የተመዘገበ ፊርማ የለም።',
modeDraw: 'ይሳሉ',
modeUpload: 'ይጫኑ',
save: 'ፊርማ አስቀምጥ',
clear: 'አጽዳ',
choose: 'ምስል ይምረጡ',
fileHint: 'PNG ወይም JPEG፣ እስከ 2 ሜባ።',
remove: 'አስወግድ',
saved: 'ፊርማ ተቀምጧል።',
removed: 'ፊርማ ተወግዷል።',
badType: 'PNG እና JPEG ምስሎች ብቻ ይፈቀዳሉ።',
tooLarge: 'ምስሉ ከ2 ሜባ ይበልጣል።',
drawFailed: 'ሥዕሉን ማንበብ አልተቻለም። እባክዎ እንደገና ይሞክሩ።',
},
maritimeSection: {
title: 'የባህር ሙያ መገለጫ',
subtitle: 'የባህር ሙያ ዝርዝሮችዎ',
@@ -612,6 +643,28 @@ export const am: Translations = {
createOne: "አንድ ይፍጠሩ",
},
fayda: {
continueWith: "በፋይዳ ይቀጥሉ",
orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ",
verifiedTitle: "በፋይዳ ተረጋግጧል",
verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።",
discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ",
fieldVerified: "ከፋይዳ",
fieldConflict: "በሌላ መለያ ተይዟል",
conflictBody:
"አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።",
brandTitle: "በፋይዳ በማረጋገጥ ላይ",
brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።",
verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…",
failedTitle: "ማረጋገጡ አልተጠናቀቀም",
backToSignup: "ወደ ምዝገባ ተመለስ",
cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።",
rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።",
invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።",
sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።",
stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።",
},
signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
@@ -787,6 +840,7 @@ export const am: Translations = {
},
licensing: {
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
vesselPicker: {
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
},
@@ -811,6 +865,16 @@ export const am: Translations = {
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
status: {
ACTIVE: 'የፀና',
EXPIRED: 'ጊዜው ያለፈበት',
SUSPENDED: 'የታገደ',
CANCELLED: 'የተሰረዘ',
SUPERSEDED: 'በአዲስ የምስክር ወረቀት የተተካ',
},
statusReason: 'ምክንያት፦ {{reason}}',
},
catalogue: {
emptyTitle: 'የሚሰሩበትን የስራ ዘርፍ ይንገሩን',
@@ -832,9 +896,20 @@ export const am: Translations = {
evaluationOnly: 'ግምገማ ብቻ',
startApplication: 'ማመልከቻ ጀምር',
addToOperations: 'ወደ ስራ ዘርፎቼ ጨምር',
lockedHint:
'ከተመዘገቡ የስራ ዘርፎችዎ ውስጥ ስላልሆነ እስካሁን ማመልከት አይችሉም።',
},
},
pickup: {
title: 'የሰነድ መረከቢያ',
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
morning: 'ጠዋት',
afternoon: 'ከሰዓት በኋላ',
},
certificates: {
title: "የእኔ የምስክር ወረቀቶች",
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
@@ -1291,4 +1366,73 @@ export const am: Translations = {
},
},
},
documents: {
title: 'ሰነዶቼ',
subtitle: 'EMA ያወጣልዎት እያንዳንዱ ሰነድ፣ እንዲሁም ከመዝገቦችዎ ጋር የተያያዙ ፋይሎች።',
tabs: {
license: 'ፈቃዶች',
medical: 'ሕክምና',
seaService: 'የባህር አገልግሎት',
personal: 'የግል መረጃ',
},
issuedTitle: 'በ EMA የተሰጡ ሰነዶች',
licensesTitle: 'የምስክር ወረቀቶች እና ፈቃዶች',
kind: {
SEAMAN_BOOK: 'የመርከበኛ መጽሐፍ',
BTC_BASIC_TRAINING: 'የመሠረታዊ ስልጠና የምስክር ወረቀት (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'ምዝገባ በመጠበቅ ላይ',
PAYMENT_PENDING: 'ክፍያ በመጠበቅ ላይ',
PAID: 'ተከፍሏል',
PAYMENT_CONFIRMED: 'ሰነድ በመዘጋጀት ላይ',
SCHEDULED: 'የመውሰጃ ቀጠሮ ተይዟል',
ISSUED: 'ተሰጥቷል',
REJECTED: 'ተቀባይነት አላገኘም',
CANCELLED: 'ተሰርዟል',
},
view: 'ይመልከቱ',
notIssued: 'እስካሁን አልተሰጠም',
openFailed: 'ሰነዱን መክፈት አልተቻለም',
files: {
none: 'ምንም የተያያዘ ፋይል የለም።',
},
preview: {
unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።',
openInNewTab: 'በአዲስ ትር ክፈት',
close: 'ዝጋ',
},
empty: {
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
seaService: 'እስካሁን በመዝገብ ላይ የባህር አገልግሎት መዝገብ የለም።',
},
personal: {
description: 'የማንነትና የትምህርት ሰነዶችዎ። እዚህ አንድ ጊዜ ይስቀሉ፤ በመዝገብዎ ላይ ይቆያሉ።',
empty: 'እስካሁን የተዋቀረ የግል ሰነድ የለም።',
uploaded: 'ተሰቅሏል',
missing: 'አልተሰቀለም',
fileCount: '{{count}} ከ {{max}}',
fileCountUnlimited_one: '{{count}} ፋይል',
fileCountUnlimited_other: '{{count}} ፋይሎች',
slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።',
uploading: 'በመስቀል ላይ… {{percent}}%',
delete: 'ፋይል አስወግድ',
confirmDelete: {
title: 'ይህን ፋይል ያስወግዱ?',
body: '"{{name}}"ን ያስወግዱ? በኋላ እንደገና መስቀል ይችላሉ።',
confirm: 'አስወግድ',
},
errors: {
unknown: 'ፋይሉ ሊቀመጥ አልቻለም። እንደገና ይሞክሩ።',
unknown_document_key: 'ይህ ሰነድ አሁን አይሰበሰብም።',
unsupported_document_type: 'ይህ የፋይል አይነት እዚህ አይፈቀድም። የተፈቀዱት፦ {{allowed}}።',
document_too_large: 'ፋይሉ በጣም ትልቅ ነው።',
document_file_required: 'የሚሰቀል ፋይል ይምረጡ።',
slot_full: 'ይህ ሰነድ ቀድሞውኑ {{maxFiles}} ፋይል(ሎች) ይዟል። በምትኩ አንዱን ይተኩ።',
document_file_not_found: 'ይህ ፋይል በመዝገብዎ ላይ የለም።',
},
},
},
};

View File

@@ -49,6 +49,7 @@ export const en = {
seaRecords: 'My Sea Records',
seaService: 'Sea Service',
medical: 'Medical Certificate',
biometrics: 'Biometrics',
myApplication: 'My Application',
vesselRegistration: 'Vessel Registration',
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
@@ -192,6 +193,7 @@ export const en = {
search: 'Search',
searchPlaceholder: 'Number or applicant',
status: 'Status',
kind: 'Type',
any: 'Any',
from: 'From',
to: 'To',
@@ -215,6 +217,9 @@ export const en = {
applicant: 'Applicant',
progress: 'Progress',
applicationNumber: 'Application №',
kindNew: 'New',
kindRenewal: 'Renewal',
kindReissue: 'Replacement',
},
actions: {
continue: 'Continue',
@@ -224,6 +229,11 @@ export const en = {
view: 'View',
bypass: 'Bypass payment',
renew: 'Renew',
discard: 'Discard',
discardConfirm:
'Delete draft {{number}}? Anything filled in so far is lost.',
discarded: 'Draft discarded',
discardFailed: 'Could not discard draft',
},
notice: {
paymentReceived:
@@ -346,9 +356,32 @@ export const en = {
profile: 'Profile',
address: 'Address',
operations: 'Operations',
signature: 'Signature',
security: 'Security',
preferences: 'Preferences',
},
signature: {
title: 'Specimen signature',
description:
'Drawn or uploaded once and printed on the documents issued to you.',
reissueNotice:
'Changing your signature does not alter a document already issued — it applies to whatever is issued from now on.',
current: 'Signature on file',
currentAlt: 'Your stored signature',
none: 'No signature on file yet.',
modeDraw: 'Draw',
modeUpload: 'Upload',
save: 'Save signature',
clear: 'Clear',
choose: 'Choose image',
fileHint: 'PNG or JPEG, up to 2 MB.',
remove: 'Remove',
saved: 'Signature saved.',
removed: 'Signature removed.',
badType: 'Only PNG and JPEG images are accepted.',
tooLarge: 'That image is larger than 2 MB.',
drawFailed: 'Could not read the drawing. Please try again.',
},
maritimeSection: {
title: 'Maritime Profile',
subtitle: 'Your professional maritime details',
@@ -616,6 +649,28 @@ export const en = {
createOne: 'Create one',
},
fayda: {
continueWith: 'Continue with Fayda',
orFillManually: 'or fill in your details',
verifiedTitle: 'Verified with Fayda',
verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.',
discard: 'Clear these details and fill the form myself',
fieldVerified: 'From Fayda',
fieldConflict: 'Already used by another account',
conflictBody:
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
brandTitle: 'Verifying with Fayda',
brandSubtitle: 'One moment while we confirm your identity.',
verifying: 'Verifying your Fayda identity\u2026',
failedTitle: 'Verification incomplete',
backToSignup: 'Back to sign up',
cancelled: 'Fayda verification was cancelled. You can still sign up manually.',
rejected: 'Fayda could not verify your identity. Please try again.',
invalidCallback: 'This verification link is incomplete. Please start again.',
sessionLost: 'Your verification session has expired. Please start again.',
stateMismatch: 'This verification could not be trusted. Please start again.',
},
signup: {
usernameMinLength: 'Username must be at least 3 characters',
nameEnRequired: 'Name (English) is required',
@@ -791,6 +846,7 @@ export const en = {
},
licensing: {
certificateSuperseded: 'Certificate superseded',
vesselPicker: {
placeholder: 'Select a registered vessel',
},
@@ -815,6 +871,16 @@ export const en = {
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
reportDamaged: 'Report damaged / request replacement',
reissueFailed: 'Could not start the replacement request',
status: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
SUSPENDED: 'Suspended',
CANCELLED: 'Cancelled',
SUPERSEDED: 'Replaced by a newer certificate',
},
statusReason: 'Reason: {{reason}}',
},
catalogue: {
emptyTitle: 'Tell us what you operate as',
@@ -836,9 +902,20 @@ export const en = {
evaluationOnly: 'Evaluation only',
startApplication: 'Start application',
addToOperations: 'Add to my operations',
lockedHint:
'Not one of your declared operations, so it cannot be applied for yet.',
},
},
pickup: {
title: 'Document Pickup',
scheduledFor: 'Visit the office on {{date}} ({{period}}) to collect your document.',
setByOffice: 'This appointment was scheduled by the licensing office.',
awaitingSchedule: 'The licensing office will assign a pickup date once your payment is confirmed.',
morning: 'Morning',
afternoon: 'Afternoon',
},
certificates: {
title: 'My Certificates',
loading: 'Loading Certificates…',
@@ -1297,6 +1374,78 @@ export const en = {
},
},
},
documents: {
title: 'My documents',
subtitle:
'Every document EMA has issued you, and the files attached to your records.',
tabs: {
license: 'Licences',
medical: 'Medical',
seaService: 'Sea Service',
personal: 'Personal Data',
},
issuedTitle: 'EMA-issued documents',
licensesTitle: 'Certificates and licences',
kind: {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate (BTC)',
},
documentStatus: {
AWAITING_REGISTRATION: 'Awaiting registration',
PAYMENT_PENDING: 'Payment pending',
PAID: 'Paid',
PAYMENT_CONFIRMED: 'Preparing document',
SCHEDULED: 'Pickup scheduled',
ISSUED: 'Issued',
REJECTED: 'Rejected',
CANCELLED: 'Cancelled',
},
view: 'View',
notIssued: 'Not issued yet',
openFailed: 'Could not open the document',
files: {
none: 'No files attached.',
},
preview: {
unsupported:
'This file type cannot be shown here. Open it in a new tab to download it.',
openInNewTab: 'Open in a new tab',
close: 'Close',
},
empty: {
licenses: 'No certificates or licences have been issued to you yet.',
medical: 'No medical certificates on file yet.',
seaService: 'No sea-service records on file yet.',
},
personal: {
description:
'Your identity and education documents. Upload them once here and they stay on your record.',
empty: 'No personal documents are configured yet.',
uploaded: 'Uploaded',
missing: 'Not uploaded',
fileCount: '{{count}} of {{max}}',
fileCountUnlimited_one: '{{count}} file',
fileCountUnlimited_other: '{{count}} files',
slotFull: 'This document is complete. Replace or remove a file to change it.',
uploading: 'Uploading… {{percent}}%',
delete: 'Remove file',
confirmDelete: {
title: 'Remove this file?',
body: 'Remove "{{name}}"? You can upload it again afterwards.',
confirm: 'Remove',
},
errors: {
unknown: 'The file could not be saved. Try again.',
unknown_document_key: 'This document is no longer being collected.',
unsupported_document_type: 'That file type is not accepted here. Allowed: {{allowed}}.',
document_too_large: 'That file is too large.',
document_file_required: 'Choose a file to upload.',
slot_full: 'This document already holds {{maxFiles}} file(s). Replace one instead.',
document_file_not_found: 'That file is no longer on your record.',
},
},
},
};
export type Translations = typeof en;

View File

@@ -5,6 +5,7 @@ import {
IconArrowsExchange,
IconBell,
IconBook2,
IconFingerprint,
IconFolderOpen,
IconHeadset,
IconHome2,
@@ -131,6 +132,13 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
icon: IconShieldCheck,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
{
to: "/seafarer/biometrics",
label: "Biometrics",
i18nKey: "nav.biometrics",
icon: IconFingerprint,
permissions: [P.VIEW_OWN_BIOMETRICS],
},
{
to: "/exams",
label: "Examinations",
@@ -206,6 +214,7 @@ const PAGE_META: Record<string, { i18nKey: string }> = {
"/seaman-book": { i18nKey: "nav.seamanBook" },
"/basic-training-certificate": { i18nKey: "nav.btc" },
"/certificates": { i18nKey: "nav.certificates" },
"/seafarer/biometrics": { i18nKey: "nav.biometrics" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },
"/documents": { i18nKey: "nav.documents" },

View File

@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
import {
LoginPage,
SignupPage,
FaydaCallbackPage,
OTPVerificationPage,
ForgotPasswordPage,
SetPasswordPage,
@@ -28,6 +29,7 @@ import { OperationsOnboardingPage } from "./features/onboarding/pages/Operations
import { ProfilePage } from "./features/profile/pages/ProfilePage";
import { SupportPage } from "./features/support/pages/SupportPage";
import { MedicalRecordsPage, SeaServicePage } from "./features/seafarer/pages/SeaRecords";
import { BiometricsPage } from "./features/seafarer/pages/Biometrics";
import { SeafarerRegistrationPage } from "./features/seafarer-registration/pages/SeafarerRegistrationPage";
import { ExamsPage } from "./features/exams/pages/ExamsPage";
import { ExamAttemptPage } from "./features/exam-attempt/pages/ExamAttemptPage";
@@ -67,6 +69,16 @@ export const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },
// Where Fayda returns the applicant. Public by necessity — they have no
// account yet. It redeems the code and hands control back to /signup.
//
// Two paths for one page: whichever is registered with Fayda has to match the
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
// bare /callback. The descriptive path is kept so the route still reads as
// part of signup once that can be changed.
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
{ path: "/callback", element: <FaydaCallbackPage /> },
// Completes the forgot-password flow; the reset message links here. The
// IAM package generates `/reset-password` links, `/set-password` is the
// first-time-credential variant — one page serves both.
@@ -205,6 +217,14 @@ export const router = createBrowserRouter([
</RequirePermission>
),
},
{
path: "/seafarer/biometrics",
element: (
<RequirePermission anyOf={[P.VIEW_OWN_BIOMETRICS]}>
<BiometricsPage />
</RequirePermission>
),
},
{ path: "/seafarer/records", element: <Navigate to="/seafarer/sea-service" replace /> },
{
path: "/exams",