mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user