mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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')}
|
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"
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
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