mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: enhance location handling and localization; improve fallback mechanisms and unify type definitions
This commit is contained in:
@@ -140,7 +140,10 @@ export function LocationForm({
|
||||
placeholder={t('location.selectType')}
|
||||
data={allAtLevel.map((lt) => ({
|
||||
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')}
|
||||
size="sm"
|
||||
|
||||
@@ -177,7 +177,7 @@ export function LocationTree({
|
||||
if (!search) return tree;
|
||||
|
||||
const matches = (loc: Location): boolean => {
|
||||
const nameMatch = loc.names.en
|
||||
const nameMatch = (loc.names.en ?? '')
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
const childMatch =
|
||||
|
||||
@@ -26,7 +26,10 @@ export function locationTypeColumns(
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useDeleteLocationTypeMutation,
|
||||
} from '../../api/location-api';
|
||||
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import type { LocationType } from '../../types/location';
|
||||
import { locationTypeColumns } from './columns';
|
||||
import { locationTypeColumnActions } from './actions';
|
||||
|
||||
@@ -68,12 +69,14 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => {
|
||||
const handleEdit = (type: LocationType) => {
|
||||
setEditingId(type.id);
|
||||
form.setValues({
|
||||
code: type.code,
|
||||
namesEn: type.names.en,
|
||||
namesAm: type.names.am,
|
||||
// The form's inputs are controlled strings; a locale the row never had
|
||||
// must edit as empty rather than reading back "undefined".
|
||||
namesEn: type.names.en ?? '',
|
||||
namesAm: type.names.am ?? '',
|
||||
level: type.level,
|
||||
});
|
||||
setShowForm(true);
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
export interface NamePair {
|
||||
en: string;
|
||||
am: string;
|
||||
}
|
||||
/**
|
||||
* Re-exported from the shared contract so both apps read one definition.
|
||||
*
|
||||
* 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 {
|
||||
id: string;
|
||||
code: string;
|
||||
names: NamePair;
|
||||
level: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
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[];
|
||||
}
|
||||
/** @deprecated Use `Bilingual` from `@ema-platform/api` — it carries om/so too. */
|
||||
export type NamePair = Bilingual;
|
||||
|
||||
export interface CreateLocationTypePayload {
|
||||
code: string;
|
||||
names: NamePair;
|
||||
names: Bilingual;
|
||||
level: number;
|
||||
}
|
||||
|
||||
@@ -41,7 +28,7 @@ export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
|
||||
|
||||
export interface CreateLocationPayload {
|
||||
code: string;
|
||||
names: NamePair;
|
||||
names: Bilingual;
|
||||
locationTypeId: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
@@ -184,9 +184,7 @@ test.describe('seafarer registration', () => {
|
||||
deleteApplicant(applicant.email);
|
||||
});
|
||||
|
||||
test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('selecting seafarer opens the registration wizard', async ({ page }) => {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
@@ -195,17 +193,19 @@ test.describe('seafarer registration', () => {
|
||||
.first()
|
||||
.check();
|
||||
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
|
||||
// from, so the gate collects it rather than opening an uncompletable form.
|
||||
// Straight to the form they came for. The wizard collects the identity
|
||||
// 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 expect(page).toHaveURL(/\/profile/, { 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 });
|
||||
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
@@ -300,9 +300,10 @@ test.describe('seafarer registration', () => {
|
||||
]);
|
||||
|
||||
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(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 ({
|
||||
@@ -328,13 +329,15 @@ test.describe('seafarer registration', () => {
|
||||
expect(profile[0][1]).toBe('ACTIVE');
|
||||
|
||||
// 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);
|
||||
expect(children.map((r) => r[0])).toEqual([
|
||||
'BTC_BASIC_TRAINING',
|
||||
'SEAMAN_BOOK',
|
||||
expect(children.map((r) => [r[0], r[1]])).toEqual([
|
||||
['BTC_BASIC_TRAINING', 'PAYMENT_PENDING'],
|
||||
['SEAMAN_BOOK', 'SUBMITTED'],
|
||||
]);
|
||||
expect(children.every((r) => r[1] === 'SUBMITTED')).toBe(true);
|
||||
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Checkbox,
|
||||
Grid,
|
||||
Input,
|
||||
NumberInput,
|
||||
Select,
|
||||
Textarea,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
|
||||
interface Props {
|
||||
section: FormSectionConfig;
|
||||
@@ -127,10 +129,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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -4,15 +4,17 @@ import { OperationsFormContent } from '../../profile/components/OperationsFormCo
|
||||
|
||||
/**
|
||||
* 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
|
||||
* form instead of a dashboard that only links to it. A seafarer goes to
|
||||
* `/profile` instead — registration is built from the profile
|
||||
* (`RequireSeafarerProfile`), and a brand-new signup has none of it yet, so
|
||||
* sending them straight to the wizard would only bounce them back here.
|
||||
* "I own a vessel" or "I am a seafarer" came here to register, so they are
|
||||
* taken straight to that form instead of a dashboard that only links to it.
|
||||
*
|
||||
* Seafarer used to detour via `/profile` because the wizard refused to open
|
||||
* 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.
|
||||
*/
|
||||
const NEXT_STEP: Record<string, string> = {
|
||||
SEAFARER_REGISTRATION: '/profile',
|
||||
SEAFARER_REGISTRATION: '/licensing/SEAFARER_REGISTRATION/apply',
|
||||
VESSEL_REGISTRATION: '/licensing/VESSEL_REGISTRATION/apply',
|
||||
};
|
||||
|
||||
|
||||
@@ -165,7 +165,9 @@ export function AddressFormContent({
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
</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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -167,7 +167,11 @@ export function SeamanBookPage() {
|
||||
<div>
|
||||
<Text fw={700}>Application {application.id}</Text>
|
||||
<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>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './lib/base-api';
|
||||
export * from './lib/query-and-mutation';
|
||||
export * from './lib/session';
|
||||
export * from './lib/features/licensing';
|
||||
export * from './lib/features/location';
|
||||
export * from './lib/features/seafarer';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
|
||||
@@ -183,7 +183,13 @@ export function localized(value: Bilingual | undefined, language = 'en'): string
|
||||
if (!value) return '';
|
||||
// `||` not `??`: an empty Amharic string is "not translated", not a value —
|
||||
// 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 || '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,18 @@
|
||||
// seafarer domain, and two copies would drift.
|
||||
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 —
|
||||
|
||||
1
libs/api/src/lib/features/location/index.ts
Normal file
1
libs/api/src/lib/features/location/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './location.types';
|
||||
47
libs/api/src/lib/features/location/location.types.ts
Normal file
47
libs/api/src/lib/features/location/location.types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user