From 99eb55c3662e01abe188e3cded1284f7f09bc173 Mon Sep 17 00:00:00 2001 From: Nati Date: Tue, 18 Aug 2026 14:44:24 +0000 Subject: [PATCH] feat: enhance location handling and localization; improve fallback mechanisms and unify type definitions --- .../location/components/LocationForm.tsx | 5 +- .../location/components/LocationTree.tsx | 2 +- .../components/LocationTypeModal/columns.tsx | 5 +- .../components/LocationTypeModal/index.tsx | 9 ++-- .../app/features/location/types/location.ts | 47 +++++++------------ apps/e2e/src/seafarer-registration.spec.ts | 41 ++++++++-------- .../components/ConfigDrivenSection.tsx | 26 +++++++++- .../location/components/LocationPicker.tsx | 8 +++- .../app/features/location/types/location.ts | 38 ++++++--------- .../pages/OperationsOnboardingPage.tsx | 14 +++--- .../profile/components/AddressFormContent.tsx | 4 +- .../src/app/features/profile/types/address.ts | 20 ++++---- .../seaman-book/pages/SeamanBookPage.tsx | 6 ++- libs/api/src/index.ts | 1 + .../features/licensing/licensing.helpers.ts | 8 +++- .../lib/features/licensing/licensing.types.ts | 13 ++++- libs/api/src/lib/features/location/index.ts | 1 + .../lib/features/location/location.types.ts | 47 +++++++++++++++++++ 18 files changed, 194 insertions(+), 101 deletions(-) create mode 100644 libs/api/src/lib/features/location/index.ts create mode 100644 libs/api/src/lib/features/location/location.types.ts diff --git a/apps/backoffice/src/app/features/location/components/LocationForm.tsx b/apps/backoffice/src/app/features/location/components/LocationForm.tsx index 719de957a..5f658233b 100644 --- a/apps/backoffice/src/app/features/location/components/LocationForm.tsx +++ b/apps/backoffice/src/app/features/location/components/LocationForm.tsx @@ -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" diff --git a/apps/backoffice/src/app/features/location/components/LocationTree.tsx b/apps/backoffice/src/app/features/location/components/LocationTree.tsx index 311c7622e..ba66ec57c 100644 --- a/apps/backoffice/src/app/features/location/components/LocationTree.tsx +++ b/apps/backoffice/src/app/features/location/components/LocationTree.tsx @@ -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 = diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx index b06652e68..7cb852db2 100644 --- a/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/columns.tsx @@ -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, }, ]; } diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx index 2bb16aea6..d1e964ed2 100644 --- a/apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal/index.tsx @@ -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); diff --git a/apps/backoffice/src/app/features/location/types/location.ts b/apps/backoffice/src/app/features/location/types/location.ts index e0e81bdb9..ffdc02da6 100644 --- a/apps/backoffice/src/app/features/location/types/location.ts +++ b/apps/backoffice/src/app/features/location/types/location.ts @@ -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 { - 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; } diff --git a/apps/e2e/src/seafarer-registration.spec.ts b/apps/e2e/src/seafarer-registration.spec.ts index b68b7fc07..65df712cb 100644 --- a/apps/e2e/src/seafarer-registration.spec.ts +++ b/apps/e2e/src/seafarer-registration.spec.ts @@ -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); }); diff --git a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx index 8fd15be76..312c0d899 100644 --- a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx +++ b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx @@ -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 ( - {isNationality ? ( + {isLocation ? ( + // LocationPicker renders its own cascade of Selects and takes no + // label/error props, so the wrapper supplies them. + + onChange(field.key, id)} + required={field.required} + maxDepth={3} + disabled={common.disabled} + /> + + ) : isNationality ? ( { 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 (