mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 23:35:43 +00:00
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:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/portal/src/app/features/profile/api/signature-api.ts
Normal file
46
apps/portal/src/app/features/profile/api/signature-api.ts
Normal 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;
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 750–3000 kW. Every Certificate of Competency you apply for is issued under this limit.'
|
||||
: 'Above covers ships of 3000 gross tonnage or more; Below covers 500–3000 GT. Every Certificate of Competency you apply for is issued under this limit.'
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -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'],
|
||||
|
||||
145
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal file
145
apps/portal/src/app/features/seafarer/pages/Biometrics/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: 'ይህ ፋይል በመዝገብዎ ላይ የለም።',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user