Refactor LicenseApplicationPage and RequireSeafarerProfile components

- Updated imports for better readability in LicenseApplicationPage.
- Introduced legacy profile sources for backward compatibility in LicenseApplicationPage.
- Enhanced user profile handling by utilizing accountUser in LicenseApplicationPage.
- Improved application state management in LicenseApplicationPage to include accountUser.
- Refactored RequireSeafarerProfile to check for existing applications and handle navigation accordingly.
- Cleaned up navigation items in PortalLayout for better readability and maintainability.
This commit is contained in:
nati14575
2026-08-19 09:42:23 +03:00
parent d70b2e76cd
commit 130082dcc4
7 changed files with 6574 additions and 421 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
import {
ActionIcon,
Alert,
@@ -13,7 +13,7 @@ import {
Text,
TextInput,
Tooltip,
} from '@mantine/core';
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
@@ -22,23 +22,26 @@ import {
IconFileText,
IconRotate,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
conditionHolds,
useClearDocumentReviewMutation,
useGetDocumentReviewsQuery,
useLocalized,
useReviewDocumentMutation,
type Attachment,
type DocumentRequirement,
} from '@ema-platform/api';
import { notifications } from '@mantine/notifications';
} from "@ema-platform/api";
import { notifications } from "@mantine/notifications";
interface DocumentsTabProps {
applicationId: string;
attachments: Attachment[];
/** From the licence type config, so completeness is measured against rules. */
requirements: DocumentRequirement[];
/** Applicant answers used to evaluate conditional document requirements. */
formData: Record<string, Record<string, unknown>>;
/** documentKey -> remark. Owned by the review page. */
flags: Record<string, string>;
onToggleFlag: (documentKey: string) => void;
@@ -58,6 +61,7 @@ export function DocumentsTab({
applicationId,
attachments,
requirements,
formData,
flags,
onToggleFlag,
onFlagRemark,
@@ -80,16 +84,16 @@ export function DocumentsTab({
async function decide(
documentKey: string,
decision: 'ACCEPTED' | 'REJECTED',
decision: "ACCEPTED" | "REJECTED",
attachmentId?: string,
) {
const reason = rejecting[documentKey]?.trim();
if (decision === 'REJECTED' && !reason) {
if (decision === "REJECTED" && !reason) {
// The applicant is shown this verbatim, so refuse to send an empty one.
notifications.show({
color: 'red',
title: t('review.documents.reasonRequired', 'A reason is required'),
message: '',
color: "red",
title: t("review.documents.reasonRequired", "A reason is required"),
message: "",
});
return;
}
@@ -98,7 +102,7 @@ export function DocumentsTab({
id: applicationId,
documentKey,
decision,
reason: decision === 'REJECTED' ? reason : undefined,
reason: decision === "REJECTED" ? reason : undefined,
attachmentId,
}).unwrap();
setRejecting((prev) => {
@@ -108,24 +112,29 @@ export function DocumentsTab({
});
} catch {
notifications.show({
color: 'red',
title: t('review.documents.saveFailed', 'Could not save the verdict'),
message: '',
color: "red",
title: t("review.documents.saveFailed", "Could not save the verdict"),
message: "",
});
}
}
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
const mandatory = requirements.filter(
(r) =>
r.mode === "ALWAYS" ||
(r.mode === "CONDITIONAL" &&
conditionHolds(r.conditionExpression, formData)),
);
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
const completeness = mandatory.length
? Math.round(((mandatory.length - missing.length) / mandatory.length) * 100)
: 100;
const previewFile = preview?.files?.[0];
const isImage = previewFile?.mimeType?.startsWith('image/');
const isPdf = previewFile?.mimeType === 'application/pdf';
const isImage = previewFile?.mimeType?.startsWith("image/");
const isPdf = previewFile?.mimeType === "application/pdf";
return (
<Stack gap="md">
@@ -133,25 +142,30 @@ export function DocumentsTab({
<Paper withBorder p="md">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
{t('review.documents.completeness', 'Required documents')}
{t("review.documents.completeness", "Required documents")}
</Text>
<Text size="sm" c={missing.length ? 'orange' : 'teal'} fw={600}>
<Text size="sm" c={missing.length ? "orange" : "teal"} fw={600}>
{mandatory.length - missing.length}/{mandatory.length}
</Text>
</Group>
<Progress
value={completeness}
color={missing.length ? 'orange' : 'teal'}
aria-label={t('review.documents.completenessLabel', {
color={missing.length ? "orange" : "teal"}
aria-label={t("review.documents.completenessLabel", {
value: completeness,
defaultValue: '{{value}}% of required documents uploaded',
defaultValue: "{{value}}% of required documents uploaded",
})}
/>
{missing.length > 0 && (
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
<Alert
mt="sm"
color="orange"
icon={<IconAlertCircle size={16} />}
variant="light"
>
<Text size="sm">
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
{missing.map((r) => localized(r.name) || r.key).join(', ')}
{t("review.documents.missing", "Not yet uploaded")}:{" "}
{missing.map((r) => localized(r.name) || r.key).join(", ")}
</Text>
</Alert>
)}
@@ -174,45 +188,55 @@ export function DocumentsTab({
opaque badge painted over the bleeding text. Nested here
with its own wrap, the name truncates cleanly instead. */}
<Group gap={6} wrap="wrap" align="center">
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
<Text
size="sm"
fw={500}
truncate
style={{ maxWidth: "100%" }}
>
{localized(
requirementByKey.get(attachment.documentKey)?.name,
) || attachment.documentKey}
</Text>
{verdict && (
<Tooltip
label={
verdict.reason ??
t('review.documents.reviewedBy', {
name: verdict.reviewedByName ?? '—',
defaultValue: 'Reviewed by {{name}}',
t("review.documents.reviewedBy", {
name: verdict.reviewedByName ?? "—",
defaultValue: "Reviewed by {{name}}",
})
}
>
<Badge
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
color={
verdict.decision === "ACCEPTED" ? "teal" : "red"
}
variant="light"
size="sm"
leftSection={
verdict.decision === 'ACCEPTED' ? (
verdict.decision === "ACCEPTED" ? (
<IconCheck size={11} />
) : (
<IconX size={11} />
)
}
>
{verdict.decision === 'ACCEPTED'
? t('review.documents.accepted', 'Accepted')
: t('review.documents.rejected', 'Rejected')}
{verdict.decision === "ACCEPTED"
? t("review.documents.accepted", "Accepted")
: t("review.documents.rejected", "Rejected")}
</Badge>
</Tooltip>
)}
{flagged && (
<Badge color="orange" variant="light" size="sm">
{t('review.documents.flagged', 'Correction requested')}
{t("review.documents.flagged", "Correction requested")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
{file?.originalName ??
t("review.documents.noFile", "No file")}
</Text>
</div>
</Group>
@@ -221,8 +245,11 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.preview', 'Preview')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
? t("review.documents.preview", "Preview")
: t(
"review.documents.noFileUploaded",
"Nothing uploaded yet",
)
}
>
<span>
@@ -233,15 +260,18 @@ export function DocumentsTab({
disabled={!file?.url}
onClick={() => setPreview(attachment)}
>
{t('review.documents.view', 'View')}
{t("review.documents.view", "View")}
</Button>
</span>
</Tooltip>
<Tooltip
label={
file?.url
? t('review.documents.download', 'Download')
: t('review.documents.noFileUploaded', 'Nothing uploaded yet')
? t("review.documents.download", "Download")
: t(
"review.documents.noFileUploaded",
"Nothing uploaded yet",
)
}
>
<span>
@@ -253,7 +283,7 @@ export function DocumentsTab({
download={file?.originalName}
target="_blank"
rel="noreferrer"
aria-label={t('review.documents.download', 'Download')}
aria-label={t("review.documents.download", "Download")}
>
<IconDownload size={16} />
</ActionIcon>
@@ -266,19 +296,28 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.accept', 'Accept')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
? t("review.documents.accept", "Accept")
: t(
"review.documents.nothingToJudge",
"Nothing uploaded to judge",
)
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'ACCEPTED' ? 'filled' : 'light'}
variant={
verdict?.decision === "ACCEPTED" ? "filled" : "light"
}
color="teal"
loading={saving}
disabled={!file?.url}
aria-label={t('review.documents.accept', 'Accept')}
aria-label={t("review.documents.accept", "Accept")}
onClick={() =>
decide(attachment.documentKey, 'ACCEPTED', attachment.id)
decide(
attachment.documentKey,
"ACCEPTED",
attachment.id,
)
}
>
<IconCheck size={16} />
@@ -288,20 +327,25 @@ export function DocumentsTab({
<Tooltip
label={
file?.url
? t('review.documents.reject', 'Reject')
: t('review.documents.nothingToJudge', 'Nothing uploaded to judge')
? t("review.documents.reject", "Reject")
: t(
"review.documents.nothingToJudge",
"Nothing uploaded to judge",
)
}
>
<span>
<ActionIcon
variant={verdict?.decision === 'REJECTED' ? 'filled' : 'light'}
variant={
verdict?.decision === "REJECTED" ? "filled" : "light"
}
color="red"
disabled={!file?.url}
aria-label={t('review.documents.reject', 'Reject')}
aria-label={t("review.documents.reject", "Reject")}
onClick={() =>
setRejecting((prev) => ({
...prev,
[attachment.documentKey]: verdict?.reason ?? '',
[attachment.documentKey]: verdict?.reason ?? "",
}))
}
>
@@ -310,11 +354,11 @@ export function DocumentsTab({
</span>
</Tooltip>
{verdict && (
<Tooltip label={t('review.documents.clear', 'Clear verdict')}>
<Tooltip label={t("review.documents.clear", "Clear verdict")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t('review.documents.clear', 'Clear verdict')}
aria-label={t("review.documents.clear", "Clear verdict")}
onClick={() =>
clearReview({
id: applicationId,
@@ -330,7 +374,7 @@ export function DocumentsTab({
size="xs"
checked={flagged}
onChange={() => onToggleFlag(attachment.documentKey)}
label={t('review.documents.includeInAdjustment', 'Send back')}
label={t("review.documents.includeInAdjustment", "Send back")}
/>
</Group>
</Group>
@@ -342,8 +386,8 @@ export function DocumentsTab({
size="xs"
autoFocus
placeholder={t(
'review.documents.rejectReason',
'Why must this document be corrected?',
"review.documents.rejectReason",
"Why must this document be corrected?",
)}
value={rejecting[attachment.documentKey]}
onChange={(e) => {
@@ -363,10 +407,10 @@ export function DocumentsTab({
loading={saving}
disabled={!rejecting[attachment.documentKey]?.trim()}
onClick={() =>
decide(attachment.documentKey, 'REJECTED', attachment.id)
decide(attachment.documentKey, "REJECTED", attachment.id)
}
>
{t('review.documents.confirmReject', 'Reject')}
{t("review.documents.confirmReject", "Reject")}
</Button>
</Group>
)}
@@ -376,15 +420,20 @@ export function DocumentsTab({
mt="sm"
size="xs"
placeholder={t(
'review.documents.adjustmentNote',
'What must the applicant correct?',
"review.documents.adjustmentNote",
"What must the applicant correct?",
)}
value={flags[attachment.documentKey]}
onChange={(e) => onFlagRemark(attachment.documentKey, e.currentTarget.value)}
onChange={(e) =>
onFlagRemark(attachment.documentKey, e.currentTarget.value)
}
error={
flags[attachment.documentKey].trim()
? undefined
: t('review.documents.reasonRequired', 'A reason is required')
: t(
"review.documents.reasonRequired",
"A reason is required",
)
}
/>
)}
@@ -399,8 +448,9 @@ export function DocumentsTab({
size="xl"
title={
preview
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
: ''
? localized(requirementByKey.get(preview.documentKey)?.name) ||
preview.documentKey
: ""
}
// Focus is trapped and returned so keyboard users are not dropped at
// the top of the page when the drawer closes.
@@ -411,22 +461,28 @@ export function DocumentsTab({
isPdf ? (
<iframe
src={previewFile.url}
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ width: '100%', height: '80vh', border: 'none' }}
title={
preview?.documentKey ??
t("review.documents.previewFallback", "document")
}
style={{ width: "100%", height: "80vh", border: "none" }}
/>
) : isImage ? (
<img
src={previewFile.url}
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
style={{ maxWidth: '100%' }}
alt={
preview?.documentKey ??
t("review.documents.previewFallback", "document")
}
style={{ maxWidth: "100%" }}
/>
) : (
// Anything the browser will not render inline still gets a way out.
<Stack align="center" gap="sm" py="xl">
<Text size="sm" c="dimmed">
{t(
'review.documents.noInlinePreview',
'This file type cannot be previewed in the browser.',
"review.documents.noInlinePreview",
"This file type cannot be previewed in the browser.",
)}
</Text>
<Button
@@ -436,7 +492,7 @@ export function DocumentsTab({
rel="noreferrer"
leftSection={<IconDownload size={16} />}
>
{t('review.documents.downloadShort', 'Download')}
{t("review.documents.downloadShort", "Download")}
</Button>
</Stack>
)

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Badge,
@@ -106,7 +106,9 @@ export function LicenseQueuePage() {
const density = useAppSelector((state) => state.preferences.density);
const [view, setView] = useState<SavedViewId>(
() => (searchParams.get("view") as SavedViewId) || readLastView(),
() =>
(searchParams.get("view") as SavedViewId) ||
(typeCode === "BTC_BASIC_TRAINING" ? "all" : readLastView()),
);
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
@@ -116,6 +118,19 @@ export function LicenseQueuePage() {
const [helpOpen, setHelpOpen] = useState(false);
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Auto-created BTC requests start at PAYMENT_PENDING, which is not part of
// the unassigned officer work pool. A dedicated BTC Queue must therefore
// open its all-status view so those requests are visible immediately.
useEffect(() => {
if (
typeCode === "BTC_BASIC_TRAINING" &&
!searchParams.has("view") &&
view !== "all"
) {
setView("all");
}
}, [typeCode, searchParams, view]);
const urlFilter = useMemo(
() => filterFromSearchParams(searchParams),
[searchParams],

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,12 @@ import {
type ValidationIssue,
type Vessel,
} from "@ema-platform/api";
import { getCountryCode, getCountryName, ModalFooter, splitPersonName } from "@ema-platform/ui";
import {
getCountryCode,
getCountryName,
ModalFooter,
splitPersonName,
} from "@ema-platform/ui";
import {
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
@@ -70,9 +75,13 @@ import {
} from "../components/ConfigDrivenSection";
import { DocumentSlots } from "../components/DocumentSlots";
import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks";
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
function readSourcePath(context: Record<string, unknown>, path: string): unknown {
function readSourcePath(
context: Record<string, unknown>,
path: string,
): unknown {
return path
.split(".")
.reduce<unknown>(
@@ -84,6 +93,15 @@ function readSourcePath(context: Record<string, unknown>, path: string): unknown
);
}
// Name fields predate the generic `source` metadata in some persisted form
// schemas. Keep their profile mapping here so existing applications/configs
// receive the same prefill as newly seeded schemas.
const LEGACY_PROFILE_SOURCES: Record<string, string> = {
firstName: "profile.firstName",
middleName: "profile.middleName",
lastName: "profile.lastName",
};
/**
* The applicant wizard, rendered entirely from the license type's
* configuration. The same page serves every license type — the route's
@@ -94,6 +112,7 @@ export function LicenseApplicationPage() {
const navigate = useNavigate();
const { i18n } = useTranslation();
const localized = useLocalized();
const accountUser = useAppSelector((state) => state.auth.user);
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
@@ -249,11 +268,12 @@ export function LicenseApplicationPage() {
// without ever having done that. Fall back to splitting the account's
// `name.en` (the same name signup collected) so this step still
// prefills instead of opening blank.
const nameFallback = profile.user?.name?.en
? splitPersonName(profile.user.name.en)
const accountName = accountUser?.name ?? profile.user?.name;
const nameFallback = accountName?.en
? splitPersonName(accountName.en)
: null;
const context = {
user: profile.user,
user: accountUser ?? profile.user,
profile: {
...profile,
firstName: profile.firstName || nameFallback?.firstName || "",
@@ -267,12 +287,13 @@ export function LicenseApplicationPage() {
const next = { ...prev };
for (const section of config.licenseType.formSchema.sections) {
for (const field of section.fields) {
if (!field.source) continue;
const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
if (!source) continue;
const current = next[section.key]?.[field.key];
const untouched =
current === undefined || current === null || current === "";
if (!field.readOnly && !untouched) continue;
const value = readSourcePath(context, field.source);
const value = readSourcePath(context, source);
if (value === undefined || value === null || value === "") continue;
if (current === value) continue;
next[section.key] = { ...next[section.key], [field.key]: value };
@@ -281,7 +302,13 @@ export function LicenseApplicationPage() {
}
return changed ? next : prev;
});
}, [profile, config, detail?.application?.id, detail?.application?.formData]);
}, [
profile,
accountUser,
config,
detail?.application?.id,
detail?.application?.formData,
]);
const application = detail?.application;
const isAdjusting = application?.status === "RESUBMIT_REQUIRED";

View File

@@ -1,6 +1,7 @@
import { Center, Loader } from '@mantine/core';
import { Navigate, useParams } from 'react-router-dom';
import { useCurrentProfile, type ProfileRequirement } from '@ema-platform/auth';
import { Center, Loader } from "@mantine/core";
import { Navigate, useParams } from "react-router-dom";
import { useCurrentProfile, type ProfileRequirement } from "@ema-platform/auth";
import { useGetMyApplicationsQuery } from "@ema-platform/api";
/**
* The identity the seafarer wizard needs before it can produce a registration.
@@ -12,28 +13,28 @@ import { useCurrentProfile, type ProfileRequirement } from '@ema-platform/auth';
*/
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
fields: [
'firstName',
'middleName',
'lastName',
'gender',
'dob',
'maritalStatus',
'professionId',
'idType',
'idNumber',
'nationality',
'primaryPhoneNumber',
'email',
"firstName",
"middleName",
"lastName",
"gender",
"dob",
"maritalStatus",
"professionId",
"idType",
"idNumber",
"nationality",
"primaryPhoneNumber",
"email",
],
reason:
'Seafarer registration is built from your profile — these details fill it in for you.',
"Seafarer registration is built from your profile — these details fill it in for you.",
};
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
const REGISTRATION_TYPE_KEY = "SEAFARER_REGISTRATION";
/**
* Guards the seafarer wizard against the one case it cannot serve: an
* applicant who already holds a seafarer number.
* Opens the existing registration summary when the applicant already holds a
* seafarer number, avoiding an attempt to create a duplicate registration.
*
* It deliberately does *not* gate on profile completeness any more. Selecting
* Seafarer Registration now opens the wizard, and the Identity Details step
@@ -46,12 +47,21 @@ const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
* when `typeCode` is the seafarer type — the latter is the shared wizard route
* every licence type renders through, so without it a deep link skips this.
*/
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
export function RequireSeafarerProfile({
children,
}: {
children: React.ReactNode;
}) {
const { typeCode } = useParams();
const { isLoading, error, profile } = useCurrentProfile();
// Shared wizard route — only the seafarer type is checked here.
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
const registered = Boolean(profile?.seafarerNumber);
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery(undefined, {
skip: !gated || !registered,
});
if (!gated) return <>{children}</>;
@@ -63,13 +73,28 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
);
}
// Already registered: the number is permanent and the server now refuses a
// second registration outright (409 seafarer_already_registered). Sending
// them on beats opening a wizard whose first act — creating the draft — is
// the call that fails. Checked after the loading guard so an unresolved
// profile is never read as "not registered".
if (profile?.seafarerNumber) {
return <Navigate to="/seaman-book" replace />;
// The number is permanent and the server refuses a second registration.
// Keep the registration tab useful by opening the completed application in
// its read-only summary instead.
if (registered) {
if (loadingApplications) {
return (
<Center h={200}>
<Loader />
</Center>
);
}
const registration = applications?.items.find(
(application) => application.licenseType?.key === REGISTRATION_TYPE_KEY,
);
if (registration) {
return (
<Navigate
to={`/licensing/${REGISTRATION_TYPE_KEY}/applications/${registration.id}`}
replace
/>
);
}
}
// A failed lookup must not lock anyone out — an unreadable profile says

View File

@@ -20,12 +20,16 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { notify, AppHeader, AppSidebar, filterByPermissions } from "@ema-platform/ui";
import {
notify,
AppHeader,
AppSidebar,
filterByPermissions,
} from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui";
import {
BrandMark,
logout,
useCurrentProfile,
usePermissions,
LICENSE_PERMISSIONS,
PORTAL_PERMISSIONS,
@@ -69,27 +73,93 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
{
label: "nav.groupLicensing",
items: [
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck, permissions: [L.VIEW_OWN_APPLICATIONS] },
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff, permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER] },
{
to: "/licensing/applications",
label: "My Applications",
i18nKey: "nav.myApplications",
icon: IconTruck,
permissions: [L.VIEW_OWN_APPLICATIONS],
},
{
to: "/waiver",
label: "Waiver",
i18nKey: "nav.waiver",
icon: IconShieldOff,
permissions: [P.APPLY_WAIVER, P.VIEW_WAIVER_LETTER],
},
],
},
{
label: "nav.groupSeafarer",
items: [
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList, permissions: [P.APPLY_SEAFARER_REGISTRATION] },
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.seamanBook', icon: IconBook2, permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL] },
{ to: '/licensing/BTC_BASIC_TRAINING/apply', label: 'Basic Training Certificate', i18nKey: 'nav.btc', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck, permissions: [P.VIEW_OWN_CERTIFICATES] },
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList, permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM] },
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, permissions: [P.VIEW_OWN_CERTIFICATES] },
{
to: "/seafarer-registration",
label: "Seafarer Registration",
i18nKey: "nav.seafarerRegistration",
icon: IconList,
permissions: [P.APPLY_SEAFARER_REGISTRATION],
},
{
to: "/seafarer/records",
label: "My Sea Records",
i18nKey: "nav.seaRecords",
icon: IconList,
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
},
{
to: "/seaman-book",
label: "Seaman Book",
i18nKey: "nav.seamanBook",
icon: IconBook2,
permissions: [P.VIEW_OWN_SEA_SERVICE, P.VIEW_OWN_MEDICAL],
},
{
to: "/licensing/BTC_BASIC_TRAINING/apply",
label: "Basic Training Certificate",
i18nKey: "nav.btc",
icon: IconShieldCheck,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
{
to: "/certificates",
label: "Certificates",
i18nKey: "nav.certificates",
icon: IconShieldCheck,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
{
to: "/exams",
label: "Examinations",
i18nKey: "nav.exams",
icon: IconList,
permissions: [P.VIEW_OWN_EXAM, P.APPLY_EXAM],
},
{
to: "/endorsements",
label: "Endorsements",
i18nKey: "nav.endorsements",
icon: IconRubberStamp,
permissions: [P.VIEW_OWN_CERTIFICATES],
},
],
},
{
label: "nav.groupVessels",
items: [
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS] },
{
to: "/vessel-registration",
label: "Vessel Registration",
i18nKey: "nav.vesselRegistration",
icon: IconShip,
permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS],
},
{
to: "/vessel-ownership-transfer",
label: "Ownership Transfer",
i18nKey: "nav.ownershipTransfer",
icon: IconArrowsExchange,
permissions: [P.APPLY_VESSEL_REGISTRATION, P.VIEW_OWN_VESSELS],
},
],
},
{
@@ -118,22 +188,24 @@ const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
];
const PAGE_META: Record<string, { i18nKey: string }> = {
'/dashboard': { i18nKey: 'nav.dashboard' },
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
'/vessel-ownership-transfer': { i18nKey: 'nav.ownershipTransfer' },
'/licensing/applications': { i18nKey: 'nav.myApplications' },
'/waiver': { i18nKey: 'nav.waiver' },
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
'/seafarer/records': { i18nKey: 'nav.seaRecords' },
'/seaman-book': { i18nKey: 'nav.myApplication' },
'/certificates': { i18nKey: 'nav.certificates' },
'/exams': { i18nKey: 'nav.exams' },
'/endorsements': { i18nKey: 'nav.endorsements' },
'/documents': { i18nKey: 'nav.documents' },
'/notifications':{ i18nKey: 'nav.notifications' },
'/profile': { i18nKey: 'nav.profile' },
'/support': { i18nKey: 'nav.support' },
"/dashboard": { i18nKey: "nav.dashboard" },
"/vessel-registration-dashboard": {
i18nKey: "nav.vesselRegistrationDashboard",
},
"/vessel-registration": { i18nKey: "nav.vesselRegistration" },
"/vessel-ownership-transfer": { i18nKey: "nav.ownershipTransfer" },
"/licensing/applications": { i18nKey: "nav.myApplications" },
"/waiver": { i18nKey: "nav.waiver" },
"/seafarer-registration": { i18nKey: "nav.seafarerRegistration" },
"/seafarer/records": { i18nKey: "nav.seaRecords" },
"/seaman-book": { i18nKey: "nav.myApplication" },
"/certificates": { i18nKey: "nav.certificates" },
"/exams": { i18nKey: "nav.exams" },
"/endorsements": { i18nKey: "nav.endorsements" },
"/documents": { i18nKey: "nav.documents" },
"/notifications": { i18nKey: "nav.notifications" },
"/profile": { i18nKey: "nav.profile" },
"/support": { i18nKey: "nav.support" },
};
export function PortalLayout() {
@@ -149,18 +221,11 @@ export function PortalLayout() {
refetchOnMountOrArgChange: false,
});
const { permissions: granted, known } = usePermissions();
// A seafarer registers once; the number is permanent. Once it exists the
// registration item is dropped rather than left to bounce off
// RequireSeafarerProfile's redirect.
const { profile } = useCurrentProfile();
const registered = Boolean(profile?.seafarerNumber);
const sections = useMemo(() => {
const translated = NAV_SECTIONS.map((section) => ({
label: section.label,
items: section.items
.filter((item) => !(registered && item.to === "/seafarer-registration"))
.map(({ i18nKey, ...rest }) => ({
items: section.items.map(({ i18nKey, ...rest }) => ({
...rest,
label: t(i18nKey),
badge:
@@ -172,7 +237,7 @@ export function PortalLayout() {
// Unfiltered until the grant list has loaded — same fail-open rule as
// RequirePermission: a moment of extra nav beats a flash of empty nav.
return known ? filterByPermissions(translated, granted) : translated;
}, [t, unseen?.count, granted, known, registered]);
}, [t, unseen?.count, granted, known]);
// Breadcrumb trail
const segments = location.pathname.split("/").filter(Boolean);