mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 01:48:13 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Divider,
|
||||
Group,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
conditionHolds,
|
||||
displayFieldValue,
|
||||
type Attachment,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type LicenseTypeRequirements,
|
||||
} from "@ema-platform/api";
|
||||
import { useDateDisplayer } from "@ema-platform/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentSlots } from "./DocumentSlots";
|
||||
|
||||
interface Props {
|
||||
@@ -33,42 +46,83 @@ export function ApplicationSummary({
|
||||
attachments,
|
||||
applicationId,
|
||||
}: Props) {
|
||||
return (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="lg">
|
||||
{sections.map((section) => (
|
||||
<div key={section.key}>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{(section.fields ?? [])
|
||||
.filter((f) => conditionHolds(f.showWhen, formData))
|
||||
.map((field) => (
|
||||
<Table.Tr key={field.key}>
|
||||
<Table.Td w="45%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(field.label)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{String(formData[section.key]?.[field.key] ?? "—")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
const showDate = useDateDisplayer();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
<div>
|
||||
<Divider mb="md" />
|
||||
// Shared with the officer's review screen, so the applicant and the reviewer
|
||||
// never read the same answer two different ways.
|
||||
const display = (field: FormFieldConfig, raw: unknown) =>
|
||||
displayFieldValue(field, raw, {
|
||||
language: i18n.language,
|
||||
showDate,
|
||||
currency: config.feeCurrency,
|
||||
}) || "—";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{sections.map((section) => {
|
||||
const fields = (section.fields ?? []).filter((f) =>
|
||||
conditionHolds(f.showWhen, formData),
|
||||
);
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder p="lg" radius="md" key={section.key}>
|
||||
<Group justify="space-between" align="center" mb="xs">
|
||||
<Title order={5}>{localized(section.title)}</Title>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{fields.length} {fields.length === 1 ? "detail" : "details"}
|
||||
</Badge>
|
||||
</Group>
|
||||
{localized(section.description) && (
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{localized(section.description)}
|
||||
</Text>
|
||||
)}
|
||||
<Divider mb="md" />
|
||||
|
||||
{/* Label above value in two columns — a definition list reads far
|
||||
better than a bordered grid when most answers are short. */}
|
||||
<Grid gutter="md">
|
||||
{fields.map((field) => {
|
||||
const value = display(
|
||||
field,
|
||||
formData[section.key]?.[field.key],
|
||||
);
|
||||
const answered = value !== "—";
|
||||
return (
|
||||
<Grid.Col
|
||||
span={{
|
||||
base: 12,
|
||||
sm: field.type === "TEXTAREA" ? 12 : 6,
|
||||
}}
|
||||
key={field.key}
|
||||
>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{localized(field.label)}
|
||||
</Text>
|
||||
<Text
|
||||
fz="sm"
|
||||
mt={2}
|
||||
c={answered ? undefined : "dimmed"}
|
||||
fs={answered ? undefined : "italic"}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{answered ? value : "Not provided"}
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Title order={5} mb="sm">
|
||||
Documents
|
||||
</Title>
|
||||
<Divider mb="md" />
|
||||
<DocumentSlots
|
||||
requirements={config.documentRequirements}
|
||||
attachments={attachments}
|
||||
@@ -81,8 +135,7 @@ export function ApplicationSummary({
|
||||
// requires the callback.
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Checkbox,
|
||||
Grid,
|
||||
Input,
|
||||
NumberInput,
|
||||
Select,
|
||||
Textarea,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
interface Props {
|
||||
section: FormSectionConfig;
|
||||
@@ -129,10 +131,32 @@ export function ConfigDrivenSection({
|
||||
// own vessel register, so this overrides whatever type the backend
|
||||
// configured, the same way nationality overrides SELECT above.
|
||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
||||
// Stores a location-tree uuid, so it needs the cascading picker the
|
||||
// profile's Address tab uses — configured as TEXT because the field
|
||||
// types have no LOCATION member, which left a required field asking
|
||||
// the applicant to type a uuid by hand.
|
||||
const isLocation = field.key === 'locationId' || labelEn.trim() === 'location';
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||
{isNationality ? (
|
||||
{isLocation ? (
|
||||
// LocationPicker renders its own cascade of Selects and takes no
|
||||
// label/error props, so the wrapper supplies them.
|
||||
<Input.Wrapper
|
||||
label={label}
|
||||
description={localized(field.helpText) || undefined}
|
||||
withAsterisk={field.required}
|
||||
error={error}
|
||||
>
|
||||
<LocationPicker
|
||||
value={(value as string) ?? undefined}
|
||||
onChange={(id) => onChange(field.key, id)}
|
||||
required={field.required}
|
||||
maxDepth={3}
|
||||
disabled={common.disabled}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
) : isNationality ? (
|
||||
<CountrySelect
|
||||
{...common}
|
||||
demonym
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconPencil,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
@@ -53,7 +54,12 @@ import {
|
||||
type ValidationIssue,
|
||||
type Vessel,
|
||||
} from "@ema-platform/api";
|
||||
import { getCountryCode, getCountryName, ModalFooter } from "@ema-platform/ui";
|
||||
import {
|
||||
getCountryCode,
|
||||
getCountryName,
|
||||
ModalFooter,
|
||||
splitPersonName,
|
||||
} from "@ema-platform/ui";
|
||||
import {
|
||||
LICENSE_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
@@ -67,9 +73,13 @@ import {
|
||||
} from "../components/ConfigDrivenSection";
|
||||
import { DocumentSlots } from "../components/DocumentSlots";
|
||||
import { StaffEvidence } from "../components/StaffEvidence";
|
||||
import { useAppSelector } from "../../../store/hooks";
|
||||
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
|
||||
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
|
||||
function readSourcePath(context: Record<string, unknown>, path: string): unknown {
|
||||
function readSourcePath(
|
||||
context: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
@@ -81,6 +91,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
|
||||
@@ -91,6 +110,7 @@ export function LicenseApplicationPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const accountUser = useAppSelector((state) => state.auth.user);
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
@@ -103,6 +123,9 @@ export function LicenseApplicationPage() {
|
||||
|
||||
// Create (or resume) the draft up front, so uploads have a real owner to
|
||||
// attach to and nothing is lost if the browser is closed mid-wizard.
|
||||
// For a one-shot registration (e.g. seafarer) already submitted or further
|
||||
// along, the API returns that existing application instead of a new draft —
|
||||
// "Apply" reopens it rather than erroring, the same way it reopens a DRAFT.
|
||||
useEffect(() => {
|
||||
if (appId || !config) return;
|
||||
createApplication({ licenseType: typeCode })
|
||||
@@ -225,32 +248,65 @@ export function LicenseApplicationPage() {
|
||||
detail?.application?.formData,
|
||||
]);
|
||||
|
||||
// Generic fill for every field the config marks `readOnly` with a
|
||||
// `source` — e.g. seafarer registration's read-only Identity Details step,
|
||||
// which shows what's already on the profile instead of asking again.
|
||||
// `readOnly` fields are never sent by the applicant and the server skips
|
||||
// them at validation, so this is display-only; the profile itself is what
|
||||
// an edit has to go through.
|
||||
// Generic fill for every field the config gives a `source` — the profile
|
||||
// value the applicant would otherwise retype. Seafarer registration's
|
||||
// Identity Details step is the case that drives this: it collects name,
|
||||
// gender, DOB and national ID *in the wizard* rather than sending the
|
||||
// applicant to `/profile` first, so those fields are editable and this is a
|
||||
// prefill, not a display.
|
||||
//
|
||||
// Editable sourced fields are filled only while still blank. Re-running
|
||||
// this effect (a refetched profile, a saved draft) must not overwrite what
|
||||
// the applicant has since typed — for a `readOnly` field the profile stays
|
||||
// authoritative, so those keep tracking it.
|
||||
useEffect(() => {
|
||||
if (!profile || !config) return;
|
||||
const context = { user: profile.user, profile };
|
||||
// `profile.firstName/middleName/lastName` stay blank until the applicant
|
||||
// saves the Maritime Profile tab once — a fresh signup arrives here
|
||||
// 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 accountName = accountUser?.name ?? profile.user?.name;
|
||||
const nameFallback = accountName?.en
|
||||
? splitPersonName(accountName.en)
|
||||
: null;
|
||||
const context = {
|
||||
user: accountUser ?? profile.user,
|
||||
profile: {
|
||||
...profile,
|
||||
firstName: profile.firstName || nameFallback?.firstName || "",
|
||||
middleName: profile.middleName || nameFallback?.middleName || "",
|
||||
lastName: profile.lastName || nameFallback?.lastName || "",
|
||||
},
|
||||
};
|
||||
|
||||
setDraft((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
for (const section of config.licenseType.formSchema.sections) {
|
||||
for (const field of section.fields) {
|
||||
if (!field.readOnly || !field.source) continue;
|
||||
const value = readSourcePath(context, field.source);
|
||||
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, source);
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (next[section.key]?.[field.key] === value) continue;
|
||||
if (current === value) continue;
|
||||
next[section.key] = { ...next[section.key], [field.key]: value };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
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";
|
||||
@@ -294,7 +350,16 @@ export function LicenseApplicationPage() {
|
||||
return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
|
||||
}
|
||||
|
||||
const readOnly = !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status);
|
||||
// A submitted application stays editable until an officer takes it, which
|
||||
// mirrors the server's own rule (`assertEditable`): an applicant who spots
|
||||
// their own mistake can fix it instead of waiting to be sent back for it.
|
||||
// Once claimed it locks — the officer reading it must not have the form move
|
||||
// underneath them.
|
||||
const editableWhileSubmitted =
|
||||
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
||||
const readOnly =
|
||||
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||
!editableWhileSubmitted;
|
||||
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
||||
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
||||
// to the summary first.
|
||||
@@ -382,6 +447,7 @@ export function LicenseApplicationPage() {
|
||||
title: "Resubmitted",
|
||||
message: "Your corrections were sent back to the reviewing officer.",
|
||||
});
|
||||
navigate("/licensing/applications");
|
||||
} else {
|
||||
await submitApplication(appId as string).unwrap();
|
||||
notifications.show({
|
||||
@@ -389,8 +455,12 @@ export function LicenseApplicationPage() {
|
||||
title: "Application submitted",
|
||||
message: "You will be notified as it progresses.",
|
||||
});
|
||||
// Stays on the application rather than dropping the applicant into a
|
||||
// list: they have just filled a long form and the useful next screen is
|
||||
// what they submitted, with its status and — while it is still
|
||||
// unclaimed — the means to correct it.
|
||||
setViewingSummary(true);
|
||||
}
|
||||
navigate("/licensing/applications");
|
||||
} catch (err) {
|
||||
const found = extractValidationIssues(err);
|
||||
setIssues(found);
|
||||
@@ -568,10 +638,11 @@ export function LicenseApplicationPage() {
|
||||
<Text size="sm" c="dimmed">
|
||||
Fee: {config.fee ?? "—"} {config.feeCurrency}
|
||||
</Text>
|
||||
{showSummary && isAdjusting && (
|
||||
{showSummary && !readOnly && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => setViewingSummary(false)}
|
||||
>
|
||||
Edit details
|
||||
@@ -600,6 +671,19 @@ export function LicenseApplicationPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showSummary && editableWhileSubmitted && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Submitted — still correctable"
|
||||
mb="md"
|
||||
>
|
||||
Your application is in the queue. You can still change any detail
|
||||
until a reviewing officer picks it up; after that, corrections happen
|
||||
only if they ask for them.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
|
||||
@@ -13,9 +13,11 @@ interface LocationPickerProps {
|
||||
required?: boolean;
|
||||
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
|
||||
maxDepth?: number;
|
||||
/** Locks every level — a submitted application, or a section under review. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
|
||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth, disabled }: LocationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
||||
@@ -204,7 +206,9 @@ export function LocationPicker({ value, onChange, onChainChange, required, maxDe
|
||||
{levels.map((levelIdx) => {
|
||||
const options = buildOptions(levelIdx);
|
||||
const currentValue = selectedChain[levelIdx]?.id ?? null;
|
||||
const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1];
|
||||
// Either the whole picker is locked, or this level has no parent
|
||||
// choice yet to narrow it.
|
||||
const isDisabled = disabled || (levelIdx > 0 && !selectedChain[levelIdx - 1]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
export interface NamePair {
|
||||
en: string;
|
||||
am: string;
|
||||
}
|
||||
/**
|
||||
* Re-exported from the shared contract so both apps read one definition.
|
||||
*
|
||||
* The portal and backoffice each kept their own copy of this model and drifted:
|
||||
* the two `Location` shapes disagreed on `locationType`/`children`/timestamps,
|
||||
* and `NamePair` dropped the `om`/`so` names the backend stores. Importers keep
|
||||
* this path; the model itself now lives in `@ema-platform/api`.
|
||||
*/
|
||||
export type {
|
||||
Location,
|
||||
LocationType,
|
||||
ListResponse,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export interface LocationType {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
locationTypeId: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
count: number;
|
||||
items: T[];
|
||||
}
|
||||
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
|
||||
export type { Bilingual as NamePair } from '@ema-platform/api';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
|
||||
* where the catalogue offers it.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: '/profile',
|
||||
SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
|
||||
VESSEL_REGISTRATION: '/vessel-registration',
|
||||
};
|
||||
|
||||
|
||||
@@ -174,7 +174,9 @@ export function AddressFormContent({
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
{t('profileAddress.addressSection')}
|
||||
</Text>
|
||||
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
|
||||
{/* City / Sub-city / Woreda — the picker's depth. Kebele is a seeded
|
||||
level but nothing collects it, so `kebeleId` stays unset rather than
|
||||
borrowing the woreda's id. */}
|
||||
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||
<TextInput
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageLoader, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
PROFILE_FIELD_SECTION,
|
||||
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";
|
||||
|
||||
/**
|
||||
* Seafarer registration is filled in from the profile (nationality, ID,
|
||||
* names, contact details) — the server refuses an application missing them,
|
||||
* so they're asked for up front instead of at submit time.
|
||||
* The identity the seafarer wizard needs before it can produce a registration.
|
||||
*
|
||||
* Only the fields the Personal, Maritime Profile and Address tabs actually
|
||||
* mark required — matches `profileSchema` / `addressSchema`, so the gate is
|
||||
* always satisfiable by finishing those tabs and never blocks on an optional
|
||||
* field (place of birth, region/city/woreda, emergency contact) the forms
|
||||
* don't star.
|
||||
* No longer a gate on opening the wizard: the Identity Details step collects
|
||||
* these itself, so an applicant with an empty profile starts in registration
|
||||
* rather than being sent to `/profile` to prepare for it. Kept because
|
||||
* `ProfilePage` still reads it to show what a seafarer registration will need.
|
||||
*/
|
||||
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",
|
||||
],
|
||||
// Translation key, not literal text — `ProfileRequirementGate` runs it
|
||||
// through `t()` at render time (it can't be translated here: this object
|
||||
@@ -42,38 +34,38 @@ export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||
reason: 'profileGate.seafarerReason',
|
||||
};
|
||||
|
||||
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
||||
const REGISTRATION_TYPE_KEY = "SEAFARER_REGISTRATION";
|
||||
|
||||
/**
|
||||
* Sends an applicant with an incomplete profile to `/profile` before they can
|
||||
* reach seafarer registration. Wraps `/seafarer-registration` directly and
|
||||
* `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the
|
||||
* latter is the shared wizard route every licence type renders through, so
|
||||
* without it the gate is a decoration a deep link skips.
|
||||
* Opens the existing registration summary when the applicant already holds a
|
||||
* seafarer number, avoiding an attempt to create a duplicate registration.
|
||||
*
|
||||
* Fires before the wizard starts, not mid-application, so nothing is lost —
|
||||
* unlike the case `ProfileRequirementGate`'s doc comment warns against
|
||||
* (mid-flow redirects on the old, deleted setup wizard).
|
||||
* It deliberately does *not* gate on profile completeness any more. Selecting
|
||||
* Seafarer Registration now opens the wizard, and the Identity Details step
|
||||
* collects name, gender, DOB, marital status, nationality and national ID
|
||||
* itself — an empty profile is a thing the wizard fills, not a reason to be
|
||||
* sent away from it. Those answers reach the profile when a reviewer approves
|
||||
* the registration (`CompletionEffectService.registerSeafarer`).
|
||||
*
|
||||
* Wraps `/seafarer-registration` directly and `/licensing/:typeCode/apply`
|
||||
* 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 }) {
|
||||
const { t } = useTranslation();
|
||||
export function RequireSeafarerProfile({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { typeCode } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const { isLoading, isFetching, error, gapsFor, profile } = useCurrentProfile();
|
||||
const { isLoading, error, profile } = useCurrentProfile();
|
||||
|
||||
// Shared wizard route — only the seafarer type is gated here.
|
||||
// Shared wizard route — only the seafarer type is checked here.
|
||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||
const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : [];
|
||||
const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!redirecting) return;
|
||||
const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', ');
|
||||
notify.info(t('profileGate.seafarerRedirect', { fields }));
|
||||
// Fire once per redirect, not on every render while gaps/gapsFor are
|
||||
// recreated — the toast content is captured at the moment it fires.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [redirecting, pathname]);
|
||||
const registered = Boolean(profile?.seafarerNumber);
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery(undefined, {
|
||||
skip: !gated || !registered,
|
||||
});
|
||||
|
||||
if (!gated) return <>{children}</>;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode, splitPersonName, joinPersonName } from '@ema-platform/ui';
|
||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||
import { PORTAL_PERMISSIONS, setUser, useCurrentProfile, usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
@@ -89,15 +89,6 @@ function getInitials(name: string, fallback: string) {
|
||||
return letters.toUpperCase();
|
||||
}
|
||||
|
||||
function splitProfileName(fullName: string) {
|
||||
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
|
||||
return { firstName, middleName, lastName: lastName.join(' ') };
|
||||
}
|
||||
|
||||
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
|
||||
return [firstName, middleName, lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -205,7 +196,7 @@ export function ProfilePage() {
|
||||
// already holds so the form does not flash empty on a refetch.
|
||||
const currentProfile = resolvedProfile ?? storedProfile;
|
||||
if (currentProfile) {
|
||||
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
|
||||
const accountName = user?.name?.en ? splitPersonName(user.name.en) : null;
|
||||
setLoadedProfile({
|
||||
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
||||
firstName: accountName?.firstName || currentProfile.firstName || '',
|
||||
@@ -286,7 +277,7 @@ export function ProfilePage() {
|
||||
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
const profileName = splitProfileName(values.nameEn);
|
||||
const profileName = splitPersonName(values.nameEn);
|
||||
const saves: Promise<unknown>[] = [
|
||||
updateTrigger({
|
||||
url: '/auth/update-profile',
|
||||
@@ -363,7 +354,7 @@ export function ProfilePage() {
|
||||
const onSaveProfile = async (values: ProfileValues) => {
|
||||
if (!profileId) return;
|
||||
|
||||
const fullName = formatProfileName(values);
|
||||
const fullName = joinPersonName(values);
|
||||
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
|
||||
notify.error(t('profile.nameMismatch'));
|
||||
return;
|
||||
|
||||
@@ -11,8 +11,6 @@ export interface AddressPayload {
|
||||
regionId?: string;
|
||||
cityId?: string;
|
||||
subCityId?: string;
|
||||
/** Legacy spelling still accepted by the address upsert endpoint. */
|
||||
subcityId?: string;
|
||||
woredaId?: string;
|
||||
kebeleId?: string;
|
||||
streetAddress?: string;
|
||||
@@ -28,15 +26,15 @@ export interface AddressPayload {
|
||||
emergencyContactRelation?: string;
|
||||
}
|
||||
|
||||
/** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */
|
||||
/** Blank optional strings drop out. */
|
||||
export function toAddressPayload(values: AddressValues): AddressPayload {
|
||||
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
|
||||
const regionId = clean(values.regionId);
|
||||
// The location service uses the selected City for the profile's region.
|
||||
// Send that same id as cityId too, because profile completeness requires
|
||||
// both fields even when the location tree has no separate region node.
|
||||
// The seeded location tree tops out at CITY — Addis Ababa is a city-state,
|
||||
// so a selected city stands in for the region and both ids are the same
|
||||
// node. Profile completeness requires both, and the picker only ever yields
|
||||
// one of them.
|
||||
const cityId = clean(values.cityId) ?? regionId;
|
||||
const subCityId = clean(values.subCityId);
|
||||
|
||||
return {
|
||||
idType: values.idType.trim(),
|
||||
@@ -45,10 +43,12 @@ export function toAddressPayload(values: AddressValues): AddressPayload {
|
||||
nationality: getCountryName(values.nationality),
|
||||
regionId,
|
||||
cityId,
|
||||
subCityId,
|
||||
subcityId: subCityId, // legacy spelling, same value
|
||||
subCityId: clean(values.subCityId),
|
||||
woredaId: clean(values.woredaId),
|
||||
kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId
|
||||
// Left unset rather than mirroring woredaId: the picker stops at woreda,
|
||||
// and copying that id here filed a WOREDA-typed node in kebele_id, so the
|
||||
// column could not be trusted to mean what it says.
|
||||
kebeleId: clean(values.kebeleId),
|
||||
streetAddress: clean(values.streetAddress),
|
||||
primaryPhoneNumber: values.primaryPhoneNumber,
|
||||
secondaryPhoneNumber: clean(values.secondaryPhoneNumber),
|
||||
|
||||
@@ -171,6 +171,8 @@ function SeaServiceTab() {
|
||||
const [form, setForm] = useState(EMPTY_SEA_SERVICE);
|
||||
const [grossTonnage, setGrossTonnage] = useState<number | ''>('');
|
||||
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
||||
const [uploadingEvidence, setUploadingEvidence] = useState(false);
|
||||
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -217,27 +219,27 @@ function SeaServiceTab() {
|
||||
if (editing) {
|
||||
await updateRecord({ id: editing.id, body }).unwrap();
|
||||
} else {
|
||||
recordId = (await createRecord(body).unwrap()).id;
|
||||
const created = await createRecord(body).unwrap();
|
||||
recordId = created.id;
|
||||
notify.success('Sea-service record added');
|
||||
}
|
||||
|
||||
if (evidenceFile && recordId) {
|
||||
setUploading(true);
|
||||
setUploadingEvidence(true);
|
||||
const result = await uploadDocument({
|
||||
ownerType: 'SEA_SERVICE_RECORD',
|
||||
ownerId: recordId,
|
||||
documentKey: 'evidence',
|
||||
file: evidenceFile,
|
||||
});
|
||||
setUploading(false);
|
||||
if (!result.ok) {
|
||||
setUploadingEvidence(false);
|
||||
if (result.ok) {
|
||||
notify.success('Evidence uploaded');
|
||||
} else {
|
||||
notify.error(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify.success(
|
||||
editing
|
||||
? t('seaRecords.seaService.updated')
|
||||
: t('seaRecords.seaService.added'),
|
||||
);
|
||||
|
||||
setModalOpen(false);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('seaRecords.seaService.saveFailed')));
|
||||
@@ -387,7 +389,17 @@ function SeaServiceTab() {
|
||||
setForm({ ...form, dutiesDescription: e.target.value })
|
||||
}
|
||||
/>
|
||||
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
||||
<FileButton onChange={setEvidenceFile} accept="image/*,application/pdf">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
leftSection={<IconFileUpload size={16} />}
|
||||
>
|
||||
{evidenceFile ? evidenceFile.name : 'Attach evidence (optional)'}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
@@ -395,7 +407,7 @@ function SeaServiceTab() {
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!valid}
|
||||
loading={creating || updating || uploading}
|
||||
loading={creating || updating || uploadingEvidence}
|
||||
>
|
||||
{editing ? t('common.save') : t('seaRecords.seaService.addRecord')}
|
||||
</Button>
|
||||
@@ -439,7 +451,7 @@ function MedicalTab() {
|
||||
const [evidenceFor, setEvidenceFor] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(EMPTY_MEDICAL);
|
||||
const [evidenceFile, setEvidenceFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingEvidence, setUploadingEvidence] = useState(false);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
@@ -478,27 +490,27 @@ function MedicalTab() {
|
||||
if (editing) {
|
||||
await updateCertificate({ id: editing.id, body }).unwrap();
|
||||
} else {
|
||||
certificateId = (await createCertificate(body).unwrap()).id;
|
||||
const created = await createCertificate(body).unwrap();
|
||||
certificateId = created.id;
|
||||
notify.success('Medical certificate added');
|
||||
}
|
||||
|
||||
if (evidenceFile && certificateId) {
|
||||
setUploading(true);
|
||||
setUploadingEvidence(true);
|
||||
const result = await uploadDocument({
|
||||
ownerType: 'MEDICAL_CERTIFICATE',
|
||||
ownerId: certificateId,
|
||||
documentKey: 'evidence',
|
||||
file: evidenceFile,
|
||||
});
|
||||
setUploading(false);
|
||||
if (!result.ok) {
|
||||
setUploadingEvidence(false);
|
||||
if (result.ok) {
|
||||
notify.success('Evidence uploaded');
|
||||
} else {
|
||||
notify.error(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
notify.success(
|
||||
editing
|
||||
? t('seaRecords.medical.updated')
|
||||
: t('seaRecords.medical.added'),
|
||||
);
|
||||
|
||||
setModalOpen(false);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, t('seaRecords.medical.saveFailed')));
|
||||
@@ -622,7 +634,17 @@ function MedicalTab() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<EvidenceField file={evidenceFile} onChange={setEvidenceFile} />
|
||||
<FileButton onChange={setEvidenceFile} accept="image/*,application/pdf">
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="light"
|
||||
leftSection={<IconFileUpload size={16} />}
|
||||
>
|
||||
{evidenceFile ? evidenceFile.name : 'Attach evidence (optional)'}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
@@ -630,7 +652,7 @@ function MedicalTab() {
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!valid}
|
||||
loading={creating || updating || uploading}
|
||||
loading={creating || updating || uploadingEvidence}
|
||||
>
|
||||
{editing ? t('common.save') : t('seaRecords.medical.add')}
|
||||
</Button>
|
||||
|
||||
@@ -30,14 +30,22 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
interface ApplicationSummary {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
|
||||
interface SeamanBookOverview {
|
||||
application: {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
} | null;
|
||||
application: ApplicationSummary | null;
|
||||
/**
|
||||
* The Basic Training Certificate opened alongside the book by an approved
|
||||
* seafarer registration — a separate application, separately numbered and
|
||||
* separately billed, so it is shown as its own card rather than merged in.
|
||||
*/
|
||||
btcApplication: ApplicationSummary | null;
|
||||
book: {
|
||||
id: string;
|
||||
issuedDate: string;
|
||||
@@ -122,6 +130,76 @@ function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-flight application: its number, where it stands, and the stages left.
|
||||
*
|
||||
* Shared by the Seaman Book and the BTC because an approved registration opens
|
||||
* both and they move independently — the book waits on a TRB inspection while
|
||||
* the BTC goes straight to payment, so a single merged card would have to lie
|
||||
* about one of them.
|
||||
*/
|
||||
function ApplicationCard({
|
||||
title,
|
||||
application,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
application: ApplicationSummary;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const activeStep = stageIndexFor(application.status);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconBook2 size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>
|
||||
{title} — {application.id}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{/* An approved seafarer registration opens this application as a
|
||||
draft, so it can be here before anyone has filed it. Calling
|
||||
that "Submitted" would misreport where it stands. */}
|
||||
{application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '}
|
||||
{formatDate(application.submittedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[application.status] ?? 'gray'}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
{application.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
description={i <= activeStep ? 'Done' : 'Pending'}
|
||||
icon={
|
||||
i <= activeStep ? (
|
||||
<IconCircleCheck size={16} />
|
||||
) : (
|
||||
<IconClock size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,6 +212,7 @@ export function SeamanBookPage() {
|
||||
});
|
||||
|
||||
const application = data?.application ?? null;
|
||||
const btcApplication = data?.btcApplication ?? null;
|
||||
const eligibility = data?.eligibility;
|
||||
const bstItems = eligibility?.bstModules ?? [];
|
||||
const bstDone = bstItems.filter((b) => b.done).length;
|
||||
@@ -141,9 +220,10 @@ export function SeamanBookPage() {
|
||||
// The server decides: the same checklist gates the submission, so a screen
|
||||
// that judged eligibility for itself could offer a button the API refuses.
|
||||
const isEligible = data?.eligible ?? false;
|
||||
const submitted = Boolean(application);
|
||||
|
||||
const activeStep = stageIndexFor(application?.status);
|
||||
// Either service already being in flight means there is nothing to apply for
|
||||
// here — an approved registration opens both, so offering "Apply" alongside
|
||||
// them would invite a duplicate the server refuses anyway.
|
||||
const submitted = Boolean(application || btcApplication);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -156,55 +236,22 @@ export function SeamanBookPage() {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Active application status */}
|
||||
{/* Active application status — one card per service in flight. */}
|
||||
{application && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconBook2 size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Application {application.id}</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Submitted {formatDate(application.submittedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[application.status] ?? 'gray'}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
{application.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Progress stepper */}
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
description={i <= activeStep ? 'Done' : 'Pending'}
|
||||
icon={
|
||||
i <= activeStep ? (
|
||||
<IconCircleCheck size={16} />
|
||||
) : (
|
||||
<IconClock size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<ApplicationCard title="Seaman Book" application={application}>
|
||||
{data?.book && (
|
||||
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
|
||||
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
|
||||
Please visit the EMA office to collect it, bringing your National ID.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</ApplicationCard>
|
||||
)}
|
||||
{btcApplication && (
|
||||
<ApplicationCard
|
||||
title="Basic Training Certificate"
|
||||
application={btcApplication}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No active application — eligibility + apply */}
|
||||
|
||||
Reference in New Issue
Block a user