mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FormFieldConfig,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
LicenseStatus,
|
||||
@@ -178,12 +179,64 @@ export function applicantOrCompanyName(app: LicenseApplication): string | undefi
|
||||
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One form answer as a person should read it back.
|
||||
*
|
||||
* The stored value is not it: a SELECT holds the option's `value`, so an
|
||||
* unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` —
|
||||
* the codes the database wants, not the words that were chosen. Shared by the
|
||||
* applicant's summary and the officer's review so the two never describe the
|
||||
* same application differently.
|
||||
*
|
||||
* `showDate` is passed in rather than imported: date display is a hook
|
||||
* (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function.
|
||||
*/
|
||||
export function displayFieldValue(
|
||||
field: Pick<FormFieldConfig, 'type' | 'options'>,
|
||||
raw: unknown,
|
||||
opts: {
|
||||
language?: string;
|
||||
showDate?: (value: string) => string;
|
||||
currency?: string;
|
||||
} = {},
|
||||
): string {
|
||||
if (raw === null || raw === undefined || raw === '') return '';
|
||||
const { language = 'en', showDate, currency } = opts;
|
||||
|
||||
switch (field.type) {
|
||||
case 'BOOLEAN':
|
||||
return raw ? 'Yes' : 'No';
|
||||
case 'DATE':
|
||||
return showDate?.(String(raw)) || String(raw);
|
||||
case 'SELECT': {
|
||||
const option = field.options?.find((o) => o.value === raw);
|
||||
// Falls back to the stored value rather than blanking: an option removed
|
||||
// from the config since this was filed still has to show what was chosen.
|
||||
return option ? localized(option.label, language) : String(raw);
|
||||
}
|
||||
case 'MONEY': {
|
||||
const amount = Number(raw);
|
||||
return Number.isFinite(amount)
|
||||
? `${amount.toLocaleString()} ${currency ?? ''}`.trim()
|
||||
: String(raw);
|
||||
}
|
||||
default:
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a bilingual value for the active language, falling back to English. */
|
||||
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 —
|
||||
@@ -329,8 +340,37 @@ export interface ApplicationRemark {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who filed the application, from their profile.
|
||||
*
|
||||
* Served alongside the form because a person-centric service (seafarer
|
||||
* registration, a certificate) has no `companyName` to identify itself by — a
|
||||
* reviewer opening one otherwise sees only an application number and has to
|
||||
* infer the human from the answers.
|
||||
*/
|
||||
export interface ApplicationApplicant {
|
||||
profileId: string;
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
gender: string | null;
|
||||
dob: string | null;
|
||||
pob: string | null;
|
||||
maritalStatus: string | null;
|
||||
seafarerNumber: string | null;
|
||||
seafarerStatus: string | null;
|
||||
seafarerDepartment: string | null;
|
||||
nationality: string | null;
|
||||
idType: string | null;
|
||||
idNumber: string | null;
|
||||
primaryPhoneNumber: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ApplicationDetail {
|
||||
application: LicenseApplication;
|
||||
/** Null when the applicant has no profile row (never expected in practice). */
|
||||
applicant: ApplicationApplicant | null;
|
||||
staff: ApplicationStaff[];
|
||||
attachments: Attachment[];
|
||||
history: StatusHistoryEntry[];
|
||||
|
||||
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[];
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useErrorHandler, passwordSchema, PasswordRequirements, joinPersonName } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
@@ -124,7 +124,7 @@ export function SignupPage() {
|
||||
username: values.username,
|
||||
phoneNumber: values.phoneNumber,
|
||||
userType: values.userType,
|
||||
name: { en: values.nameEn, am: values.nameAm ?? '' },
|
||||
name: { en: joinPersonName(values), am: values.nameAm ?? '' },
|
||||
password: values.password,
|
||||
confirmPassword: values.confirmPassword,
|
||||
};
|
||||
@@ -213,23 +213,38 @@ export function SignupPage() {
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||||
<TextInput
|
||||
label={t('signup.firstNameLabel', 'First name')}
|
||||
placeholder={t('signup.firstNamePlaceholder', 'Abebe')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.firstName?.message}
|
||||
{...register('firstName')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('signup.nameEnLabel', 'Full name (English)')}
|
||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...register('nameEn')}
|
||||
error={errors.middleName?.message}
|
||||
{...register('middleName')}
|
||||
/>
|
||||
<TextInput
|
||||
label={t('signup.nameAmLabel', 'Name (Amharic)')}
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
label={t('signup.lastNameLabel', 'Last name')}
|
||||
placeholder={t('signup.lastNamePlaceholder', 'Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...register('nameAm')}
|
||||
error={errors.lastName?.message}
|
||||
{...register('lastName')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label={t('signup.nameAmLabel', 'Name (Amharic)')}
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...register('nameAm')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label={t('signup.emailLabel', 'Email address')}
|
||||
|
||||
@@ -26,3 +26,4 @@ export * from "./lib/feedback/use-error-handler";
|
||||
export * from "./lib/data/useServerTable";
|
||||
export * from "./lib/landing/LandingPage";
|
||||
export * from "./lib/landing/landing-copy";
|
||||
export * from "./lib/utils/person-name";
|
||||
|
||||
19
libs/ui/src/lib/utils/person-name.ts
Normal file
19
libs/ui/src/lib/utils/person-name.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Splits and joins a person's name between the account's single `name.en`
|
||||
* string and the profile's separate `firstName`/`middleName`/`lastName`
|
||||
* fields.
|
||||
*
|
||||
* The account has no first/middle/last columns of its own (that model lives
|
||||
* only on the profile), so this is the one place that heuristic lives —
|
||||
* reused everywhere a name crosses that boundary: the signup form joins into
|
||||
* it, the Identity Details wizard step and the Profile page's Personal tab
|
||||
* both split out of it.
|
||||
*/
|
||||
export function splitPersonName(fullName: string) {
|
||||
const [firstName = '', middleName = '', ...rest] = fullName.trim().split(/\s+/);
|
||||
return { firstName, middleName, lastName: rest.join(' ') };
|
||||
}
|
||||
|
||||
export function joinPersonName(parts: { firstName: string; middleName?: string; lastName: string }) {
|
||||
return [parts.firstName, parts.middleName, parts.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user