Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-19 10:23:33 +03:00
74 changed files with 7969 additions and 3617 deletions

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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';

View File

@@ -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',
};

View File

@@ -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

View File

@@ -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}</>;

View File

@@ -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;

View File

@@ -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),

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -286,9 +286,8 @@ export const am: Translations = {
addDetails: 'እነዚህን መረጃዎች ጨምር',
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
seafarerBanner: 'ባህረኛ ምዝገባ የመገለጫ መረጃ ያስፈልጋል።',
checkingProfile: 'የባህረኛ መገለጫ በመፈተሽ ላይ…',
seafarerBanner:
'ባህረኛ ምዝገባ እነዚህን መረጃዎች ይጠይቃል፤ ከጸደቀ በኋላም መገለጫዎን ያዘምናል። እዚህ አስቀድመው ቢሞሏቸው እዚያ እንደገና መተየብ አይኖርብዎትም።',
},
profileSections: {

View File

@@ -286,8 +286,8 @@ export const en = {
viewProfile: 'View full profile',
seafarerReason:
'Seafarer registration is built from your profile — these details fill it in for you.',
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
seafarerBanner: 'Profile details are needed for seafarer registration.',
seafarerBanner:
'Seafarer registration asks for these details and updates your profile once approved. Filling them in here first saves you typing them there.',
checkingProfile: 'Checking seafarer profile…',
},

View File

@@ -19,12 +19,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,
@@ -68,27 +72,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],
},
],
},
{
@@ -117,22 +187,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() {
@@ -148,30 +220,23 @@ 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 }) => ({
...rest,
label: t(i18nKey),
badge:
rest.to === "/notifications" && unseen?.count
? unseen.count
: undefined,
})),
items: section.items.map(({ i18nKey, ...rest }) => ({
...rest,
label: t(i18nKey),
badge:
rest.to === "/notifications" && unseen?.count
? unseen.count
: undefined,
})),
}));
// 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);