feat: enhance location handling and localization; improve fallback mechanisms and unify type definitions

This commit is contained in:
Nati
2026-08-18 14:44:24 +00:00
parent 03e2b2b8a4
commit 99eb55c366
18 changed files with 194 additions and 101 deletions

View File

@@ -140,7 +140,10 @@ export function LocationForm({
placeholder={t('location.selectType')} placeholder={t('location.selectType')}
data={allAtLevel.map((lt) => ({ data={allAtLevel.map((lt) => ({
value: lt.id, value: lt.id,
label: lt.names[locale], // Not every locale is filled in on every row, and an option
// with no label is unpickable — fall back to English, then the
// code, which always exists.
label: lt.names[locale] || lt.names.en || lt.code,
}))} }))}
{...form.getInputProps('locationTypeId')} {...form.getInputProps('locationTypeId')}
size="sm" size="sm"

View File

@@ -177,7 +177,7 @@ export function LocationTree({
if (!search) return tree; if (!search) return tree;
const matches = (loc: Location): boolean => { const matches = (loc: Location): boolean => {
const nameMatch = loc.names.en const nameMatch = (loc.names.en ?? '')
.toLowerCase() .toLowerCase()
.includes(search.toLowerCase()); .includes(search.toLowerCase());
const childMatch = const childMatch =

View File

@@ -26,7 +26,10 @@ export function locationTypeColumns(
}, },
{ {
header: t('location.name'), header: t('location.name'),
cell: ({ row }) => row.original.names[locale], // Falls back like the type Select: a row missing this locale shows its
// English name, then its code, rather than an empty cell.
cell: ({ row }) =>
row.original.names[locale] || row.original.names.en || row.original.code,
}, },
]; ];
} }

View File

@@ -18,6 +18,7 @@ import {
useDeleteLocationTypeMutation, useDeleteLocationTypeMutation,
} from '../../api/location-api'; } from '../../api/location-api';
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui'; import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
import type { LocationType } from '../../types/location';
import { locationTypeColumns } from './columns'; import { locationTypeColumns } from './columns';
import { locationTypeColumnActions } from './actions'; import { locationTypeColumnActions } from './actions';
@@ -68,12 +69,14 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
setShowForm(false); setShowForm(false);
}; };
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => { const handleEdit = (type: LocationType) => {
setEditingId(type.id); setEditingId(type.id);
form.setValues({ form.setValues({
code: type.code, code: type.code,
namesEn: type.names.en, // The form's inputs are controlled strings; a locale the row never had
namesAm: type.names.am, // must edit as empty rather than reading back "undefined".
namesEn: type.names.en ?? '',
namesAm: type.names.am ?? '',
level: type.level, level: type.level,
}); });
setShowForm(true); setShowForm(true);

View File

@@ -1,37 +1,24 @@
export interface NamePair { /**
en: string; * Re-exported from the shared contract so both apps read one definition.
am: string; *
} * See the portal's copy of this file: the two apps each maintained their own
* `Location`/`LocationType` and drifted. The payload types below stay here —
* only the backoffice writes locations.
*/
export type {
Location,
LocationType,
ListResponse,
} from '@ema-platform/api';
export interface LocationType { import type { Bilingual } from '@ema-platform/api';
id: string;
code: string;
names: NamePair;
level: number;
createdAt: string;
updatedAt: string;
}
export interface Location { /** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
id: string; export type NamePair = Bilingual;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
locationType?: LocationType;
children?: Location[];
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface CreateLocationTypePayload { export interface CreateLocationTypePayload {
code: string; code: string;
names: NamePair; names: Bilingual;
level: number; level: number;
} }
@@ -41,7 +28,7 @@ export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
export interface CreateLocationPayload { export interface CreateLocationPayload {
code: string; code: string;
names: NamePair; names: Bilingual;
locationTypeId: string; locationTypeId: string;
parentId?: string | null; parentId?: string | null;
} }

View File

@@ -184,9 +184,7 @@ test.describe('seafarer registration', () => {
deleteApplicant(applicant.email); deleteApplicant(applicant.email);
}); });
test('the wizard refuses to open until the profile it is built from is complete', async ({ test('selecting seafarer opens the registration wizard', async ({ page }) => {
page,
}) => {
const offset = await signUp(page, applicant); const offset = await signUp(page, applicant);
await verifyOtpIfPrompted(page, offset); await verifyOtpIfPrompted(page, offset);
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
@@ -195,17 +193,19 @@ test.describe('seafarer registration', () => {
.first() .first()
.check(); .check();
await page.getByRole('button', { name: /save operations/i }).click(); await page.getByRole('button', { name: /save operations/i }).click();
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
// A new account holds none of the identity the registration is filled in // Straight to the form they came for. The wizard collects the identity
// from, so the gate collects it rather than opening an uncompletable form. // itself (Identity Details), so a brand-new account with an empty profile
// is a thing it fills rather than a reason to be sent to /profile first.
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
timeout: 30_000,
});
// The short link lands in the same place.
await page.goto('/seafarer-registration'); await page.goto('/seafarer-registration');
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
timeout: 30_000,
// The shared wizard route is gated identically — otherwise the gate is });
// decoration a deep link walks straight past.
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
}); });
test('opening the wizard creates the draft up front', async ({ page }) => { test('opening the wizard creates the draft up front', async ({ page }) => {
@@ -300,9 +300,10 @@ test.describe('seafarer registration', () => {
]); ]);
expect(statusOf(number)).toBe('REJECTED'); expect(statusOf(number)).toBe('REJECTED');
// A rejection is terminal: nothing is numbered and no children open. // A rejection is terminal: nothing is numbered, and the children submit
// opened stay drafts — never filed, never billed, nothing an officer sees.
expect(seafarerNumberOf(applicant.email)).toBeNull(); expect(seafarerNumberOf(applicant.email)).toBeNull();
expect(childrenOf(number)).toHaveLength(0); expect(childrenOf(number).every((r) => r[1] === 'DRAFT')).toBe(true);
}); });
test('approval numbers the profile and opens both child applications', async ({ test('approval numbers the profile and opens both child applications', async ({
@@ -328,13 +329,15 @@ test.describe('seafarer registration', () => {
expect(profile[0][1]).toBe('ACTIVE'); expect(profile[0][1]).toBe('ACTIVE');
// The applicant is not made to apply twice more for the documents that // The applicant is not made to apply twice more for the documents that
// prove what they have just been told. // prove what they have just been told. Both were opened as drafts when the
// registration was submitted; approval is what puts them in flight — the
// BTC straight to payment, the Seaman Book into the queue for the TRB
// inspection it still owes.
const children = childrenOf(number); const children = childrenOf(number);
expect(children.map((r) => r[0])).toEqual([ expect(children.map((r) => [r[0], r[1]])).toEqual([
'BTC_BASIC_TRAINING', ['BTC_BASIC_TRAINING', 'PAYMENT_PENDING'],
'SEAMAN_BOOK', ['SEAMAN_BOOK', 'SUBMITTED'],
]); ]);
expect(children.every((r) => r[1] === 'SUBMITTED')).toBe(true);
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true); expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
}); });

View File

@@ -1,6 +1,7 @@
import { import {
Checkbox, Checkbox,
Grid, Grid,
Input,
NumberInput, NumberInput,
Select, Select,
Textarea, Textarea,
@@ -14,6 +15,7 @@ import {
type Vessel, type Vessel,
} from '@ema-platform/api'; } from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui'; import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
import { LocationPicker } from '../../location/components/LocationPicker';
interface Props { interface Props {
section: FormSectionConfig; section: FormSectionConfig;
@@ -127,10 +129,32 @@ export function ConfigDrivenSection({
// own vessel register, so this overrides whatever type the backend // own vessel register, so this overrides whatever type the backend
// configured, the same way nationality overrides SELECT above. // configured, the same way nationality overrides SELECT above.
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim()); 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 ( return (
<Grid.Col span={{ base: 12, md: span }} key={field.key}> <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 <CountrySelect
{...common} {...common}
demonym demonym

View File

@@ -13,9 +13,11 @@ interface LocationPickerProps {
required?: boolean; required?: boolean;
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */ /** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
maxDepth?: number; 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 { t } = useTranslation();
const localized = useLocalized(); const localized = useLocalized();
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery(); 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) => { {levels.map((levelIdx) => {
const options = buildOptions(levelIdx); const options = buildOptions(levelIdx);
const currentValue = selectedChain[levelIdx]?.id ?? null; 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 ( return (
<Select <Select

View File

@@ -1,24 +1,16 @@
export interface NamePair { /**
en: string; * Re-exported from the shared contract so both apps read one definition.
am: string; *
} * 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 { /** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
id: string; export type { Bilingual as NamePair } from '@ema-platform/api';
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[];
}

View File

@@ -4,15 +4,17 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
/** /**
* Where a fresh applicant lands after declaring themselves. Someone who says * Where a fresh applicant lands after declaring themselves. Someone who says
* "I own a vessel" came here to register, so they are taken straight to that * "I own a vessel" or "I am a seafarer" came here to register, so they are
* form instead of a dashboard that only links to it. A seafarer goes to * taken straight to that form instead of a dashboard that only links to it.
* `/profile` instead — registration is built from the profile *
* (`RequireSeafarerProfile`), and a brand-new signup has none of it yet, so * Seafarer used to detour via `/profile` because the wizard refused to open
* sending them straight to the wizard would only bounce them back here. * without a complete one. It no longer does — the Identity Details step
* collects those answers itself (`RequireSeafarerProfile`) — so the detour was
* only an extra screen between a new signup and the thing they came for.
* Seafarer wins when both are ticked; the other form is one nav click away. * Seafarer wins when both are ticked; the other form is one nav click away.
*/ */
const NEXT_STEP: Record<string, string> = { const NEXT_STEP: Record<string, string> = {
SEAFARER_REGISTRATION: '/profile', SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply', VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
}; };

View File

@@ -165,7 +165,9 @@ export function AddressFormContent({
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm"> <Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address Address
</Text> </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} /> <LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<TextInput <TextInput

View File

@@ -11,8 +11,6 @@ export interface AddressPayload {
regionId?: string; regionId?: string;
cityId?: string; cityId?: string;
subCityId?: string; subCityId?: string;
/** Legacy spelling still accepted by the address upsert endpoint. */
subcityId?: string;
woredaId?: string; woredaId?: string;
kebeleId?: string; kebeleId?: string;
streetAddress?: string; streetAddress?: string;
@@ -28,15 +26,15 @@ export interface AddressPayload {
emergencyContactRelation?: string; emergencyContactRelation?: string;
} }
/** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */ /** Blank optional strings drop out. */
export function toAddressPayload(values: AddressValues): AddressPayload { export function toAddressPayload(values: AddressValues): AddressPayload {
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined); const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
const regionId = clean(values.regionId); const regionId = clean(values.regionId);
// The location service uses the selected City for the profile's region. // The seeded location tree tops out at CITY — Addis Ababa is a city-state,
// Send that same id as cityId too, because profile completeness requires // so a selected city stands in for the region and both ids are the same
// both fields even when the location tree has no separate region node. // node. Profile completeness requires both, and the picker only ever yields
// one of them.
const cityId = clean(values.cityId) ?? regionId; const cityId = clean(values.cityId) ?? regionId;
const subCityId = clean(values.subCityId);
return { return {
idType: values.idType.trim(), idType: values.idType.trim(),
@@ -45,10 +43,12 @@ export function toAddressPayload(values: AddressValues): AddressPayload {
nationality: getCountryName(values.nationality), nationality: getCountryName(values.nationality),
regionId, regionId,
cityId, cityId,
subCityId, subCityId: clean(values.subCityId),
subcityId: subCityId, // legacy spelling, same value
woredaId: clean(values.woredaId), 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), streetAddress: clean(values.streetAddress),
primaryPhoneNumber: values.primaryPhoneNumber, primaryPhoneNumber: values.primaryPhoneNumber,
secondaryPhoneNumber: clean(values.secondaryPhoneNumber), secondaryPhoneNumber: clean(values.secondaryPhoneNumber),

View File

@@ -167,7 +167,11 @@ export function SeamanBookPage() {
<div> <div>
<Text fw={700}>Application {application.id}</Text> <Text fw={700}>Application {application.id}</Text>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
Submitted {formatDate(application.submittedAt)} {/* 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> </Text>
</div> </div>
</Group> </Group>

View File

@@ -2,6 +2,7 @@ export * from './lib/base-api';
export * from './lib/query-and-mutation'; export * from './lib/query-and-mutation';
export * from './lib/session'; export * from './lib/session';
export * from './lib/features/licensing'; export * from './lib/features/licensing';
export * from './lib/features/location';
export * from './lib/features/seafarer'; export * from './lib/features/seafarer';
export * from './lib/features/vessel'; export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth'; export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';

View File

@@ -183,7 +183,13 @@ export function localized(value: Bilingual | undefined, language = 'en'): string
if (!value) return ''; if (!value) return '';
// `||` not `??`: an empty Amharic string is "not translated", not a value — // `||` not `??`: an empty Amharic string is "not translated", not a value —
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm). // editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
return (language === 'am' ? value.am : value.en) || value.en || value.am || ''; //
// Keyed by the active language rather than an en/am ternary, so a locale the
// backend stores but the UI does not yet offer a switcher for (`om`, `so` on
// location names) still resolves once it does. English then Amharic remain
// the fallbacks, in that order.
const active = value[language as keyof Bilingual];
return active || value.en || value.am || '';
} }
/** /**

View File

@@ -4,7 +4,18 @@
// seafarer domain, and two copies would drift. // seafarer domain, and two copies would drift.
import type { SeafarerDepartment } from '../seafarer/seafarer.types'; import type { SeafarerDepartment } from '../seafarer/seafarer.types';
export type Bilingual = { en?: string; am?: string }; /**
* A backend `LocaleValidationDto`. Named for the two locales the UI offers, but
* carries every locale the column stores — location names are seeded with `om`
* (Finfinnee) and `so` too, and a type that declared only en/am made those
* unreachable through the typed client.
*/
export type Bilingual = {
en?: string;
am?: string;
om?: string;
so?: string;
};
/** /**
* The application status vocabulary. Single source of truth for both apps — * The application status vocabulary. Single source of truth for both apps —

View File

@@ -0,0 +1 @@
export * from './location.types';

View File

@@ -0,0 +1,47 @@
/** Shared location contract — mirrors the `iam.locations` tree in emaapi. */
import type { Bilingual } from '../licensing/licensing.types';
/**
* One node of the location tree.
*
* Both apps read the same `/locations` route, so the model lives here rather
* than in each app's own feature folder — the two hand-maintained copies had
* already drifted (the portal's lacked `locationType`, `children` and the
* timestamps, and its query accepted no `parentId` filter even though the
* backend supports one).
*
* `names` is `Bilingual`, the backend's `LocaleValidationDto`: seeded location
* names carry `om` and `so` alongside `en`/`am`, which the previous
* `{ en, am }` pair made unreachable.
*/
export interface Location {
id: string;
code: string;
names: Bilingual;
locationTypeId: string;
parentId: string | null;
locationType?: LocationType;
children?: Location[];
createdAt?: string;
updatedAt?: string;
}
/**
* A level in the tree. `level` orders them (City 1 → Sub-city 2 → Woreda 3 →
* Kebele 4); `code` is what both sides key behaviour off, so it is the field to
* match on rather than the display name.
*/
export interface LocationType {
id: string;
code: string;
names: Bilingual;
level: number;
createdAt?: string;
updatedAt?: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}