Enhance certificate designer and seafarer registration features with image support and dynamic department/rank options

This commit is contained in:
Nati
2026-08-24 14:15:55 +00:00
parent 4a3ad7c2e6
commit bd65fc0eab
16 changed files with 320 additions and 115 deletions

View File

@@ -9,9 +9,14 @@ import {
} from '@mantine/core';
import {
conditionHolds,
useGetActiveDepartmentsQuery,
useGetRanksQuery,
useLocalized,
type Bilingual,
type Department,
type FormFieldConfig,
type FormSectionConfig,
type Rank,
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
@@ -82,6 +87,41 @@ export function fillFromVessel(
}
}
/**
* Options for a SELECT field.
*
* A department/rank field's seed `options` are a label cache that goes stale
* the moment a backoffice admin adds a department or rank — the field's
* value is already resolved server-side (profile department, or
* `eligibility.nextRank`), correct either way, but a value missing from a
* stale cache renders as a blank Select. Live-fetched departments/ranks take
* over the labels for these two `source`s; the seed's own `options` still
* cover every other SELECT unchanged.
*/
function selectOptions(
field: FormFieldConfig,
currentValue: string | undefined,
departments: Department[] | undefined,
ranks: Rank[],
localized: (v?: Bilingual) => string,
): { value: string; label: string }[] {
if (field.source === 'profile.seafarerDepartment' && departments) {
return departments.map((d) => ({ value: d.code, label: localized(d.name) }));
}
if (field.source === 'eligibility.nextRank') {
const options = ranks.map((r) => ({ value: r.key, label: localized(r.name) }));
// The resolved rank might not be on THIS field's ladder (rank/rankEngine,
// proficiencyDeck/Engine share one `eligibility.nextRank` source but only
// one is ever populated) — still show it rather than a blank Select.
if (currentValue && !options.some((o) => o.value === currentValue)) {
const known = ranks.find((r) => r.key === currentValue);
options.push({ value: currentValue, label: known ? localized(known.name) : currentValue });
}
return options;
}
return (field.options ?? []).map((o) => ({ value: o.value, label: localized(o.label) }));
}
/**
* Renders one form section from the license type's configuration.
*
@@ -105,6 +145,21 @@ export function ConfigDrivenSection({
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
// A department/rank SELECT ships with a hardcoded `options` label list in
// the seed, so a department or rank added later in the backoffice has no
// label there and would render as a blank Select even though the field's
// value (resolved server-side) is correct. Skipped when the section has
// neither kind of field, so most sections never pay for these two queries.
const needsDepartmentLabels = fields.some(
(f) => f.source === 'profile.seafarerDepartment',
);
const needsRankLabels = fields.some((f) => f.source === 'eligibility.nextRank');
const { data: departments } = useGetActiveDepartmentsQuery(undefined, {
skip: !needsDepartmentLabels,
});
const { data: rankRes } = useGetRanksQuery(undefined, { skip: !needsRankLabels });
const ranks = rankRes?.items ?? [];
return (
<Grid>
{fields.map((field) => {
@@ -183,10 +238,7 @@ export function ConfigDrivenSection({
) : field.type === 'SELECT' ? (
<Select
{...common}
data={(field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label),
}))}
data={selectOptions(field, value as string | undefined, departments, ranks, localized)}
value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)}
clearable={!field.required}

View File

@@ -3,6 +3,8 @@ import {
SEAFARER_REGISTRATION_FIELD_LABELS,
SEAFARER_REGISTRATION_SECTIONS,
displaySeafarerAnswer,
useGetActiveDepartmentsQuery,
useLocalized,
type Attachment,
type SaveSeafarerRegistration,
} from '@ema-platform/api';
@@ -16,6 +18,10 @@ export function RegistrationSummary({
answers: SaveSeafarerRegistration;
attachments?: Attachment[];
}) {
const localized = useLocalized();
const { data: departments } = useGetActiveDepartmentsQuery();
const departmentOptions = departments?.map((d) => ({ value: d.code, label: localized(d.name) }));
return (
<Stack gap="md">
{SEAFARER_REGISTRATION_SECTIONS.map((section) => (
@@ -35,7 +41,9 @@ export function RegistrationSummary({
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{displaySeafarerAnswer(field, answers[field])}</Text>
<Text size="sm">
{displaySeafarerAnswer(field, answers[field], departmentOptions)}
</Text>
</Table.Td>
</Table.Tr>
))}

View File

@@ -6,6 +6,8 @@ import {
GENDER_OPTIONS,
HAIR_COLOR_OPTIONS,
MARITAL_STATUS_OPTIONS,
useGetActiveDepartmentsQuery,
useLocalized,
} from '@ema-platform/api';
import {
DateField,
@@ -72,8 +74,21 @@ export function IdentityDetailsStep(
);
}
/** Step 2 — Identity, Address and Physical Characteristics. */
/**
* Step 2 — Identity, Address and Physical Characteristics.
*
* The department list is backoffice-managed (see the Ranks & Departments
* configuration tab), so this fetches the live set rather than a fixed
* three — falling back to it only until the query resolves, so the field
* is never an empty flash.
*/
export function ApplicantDetailsStep(p: StepProps) {
const localized = useLocalized();
const { data: departments } = useGetActiveDepartmentsQuery();
const departmentOptions =
departments?.map((d) => ({ value: d.code, label: localized(d.name) })) ??
DEPARTMENT_OPTIONS;
return (
<Stack gap="lg">
<SectionTitle title="Identity" />
@@ -94,7 +109,7 @@ export function ApplicantDetailsStep(p: StepProps) {
name="department"
label="Department"
required
options={DEPARTMENT_OPTIONS}
options={departmentOptions}
description="The STCW department you serve in. Determines which certificates, examinations and services apply to you."
/>
</Grid>