feat: add vessel selection support in LicenseApplicationPage and ConfigDrivenSection

This commit is contained in:
estifanos
2026-08-11 07:36:48 +00:00
parent 948b68c7c0
commit 0e8a82c518
2 changed files with 72 additions and 0 deletions

View File

@@ -9,7 +9,9 @@ import {
import {
conditionHolds,
localized,
type FormFieldConfig,
type FormSectionConfig,
type Vessel,
} from '@ema-platform/api';
import { CountrySelect } from '@ema-platform/ui';
@@ -21,6 +23,48 @@ interface Props {
disabled?: boolean;
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
errors?: Record<string, string>;
/** The applicant's registered vessels, for the vessel-picker field. */
vessels?: Vessel[];
}
/**
* Maps a Vessel's own fields onto whichever sibling fields the backend put
* in the same section — same dual key/label matching the nationality prefill
* in LicenseApplicationPage uses, so this doesn't depend on exact field-key
* naming in the seeded formSchema.
*/
const VESSEL_FIELD_FILLERS: {
matches: (label: string, key: string) => boolean;
value: (vessel: Vessel) => unknown;
}[] = [
{ matches: (l, k) => k === 'registrationNumber' || l.includes('registration number'), value: (v) => v.registrationNumber },
{ matches: (l, k) => k === 'vesselName' || l.includes('vessel name'), value: (v) => v.name },
{ matches: (l, k) => k === 'category' || k === 'vesselCategory' || l.includes('vessel category'), value: (v) => v.category },
{ matches: (l, k) => k === 'vesselType' || l.includes('vessel type'), value: (v) => v.vesselType },
{ matches: (l, k) => k === 'imoNumber' || l.includes('imo'), value: (v) => v.imoNumber },
{ matches: (l, k) => k === 'hullNumber' || l.includes('hull number'), value: (v) => v.hullNumber },
{ matches: (l, k) => k === 'flagState' || l.includes('flag state') || l.includes('flag'), value: (v) => v.flagState },
{ matches: (l, k) => k === 'portOfRegistry' || l.includes('port of registry'), value: (v) => v.portOfRegistry },
{ matches: (l, k) => k === 'grossTonnage' || l.includes('gross tonnage'), value: (v) => v.grossTonnage },
{ matches: (l, k) => k === 'passengerCapacity' || l.includes('passenger capacity'), value: (v) => v.passengerCapacity },
{ matches: (l, k) => k === 'lengthMeters' || l.includes('length'), value: (v) => v.lengthMeters },
{ matches: (l, k) => k === 'yearBuilt' || l.includes('year built'), value: (v) => v.yearBuilt },
{ matches: (l, k) => k === 'engineType' || l.includes('engine type'), value: (v) => v.engineType },
{ matches: (l, k) => k === 'enginePowerKw' || l.includes('engine power'), value: (v) => v.enginePowerKw },
{ matches: (l, k) => k === 'numberOfEngines' || l.includes('number of engines'), value: (v) => v.numberOfEngines },
{ matches: (l, k) => k === 'hullMaterial' || l.includes('hull material'), value: (v) => v.hullMaterial },
];
function fillFromVessel(
vessel: Vessel,
fields: FormFieldConfig[],
onChange: (key: string, value: unknown) => void,
) {
for (const f of fields) {
const label = localized(f.label).toLowerCase();
const filler = VESSEL_FIELD_FILLERS.find((m) => m.matches(label, f.key));
if (filler) onChange(f.key, filler.value(vessel) ?? '');
}
}
/**
@@ -37,6 +81,7 @@ export function ConfigDrivenSection({
onChange,
disabled,
errors = {},
vessels = [],
}: Props) {
const fields = [...(section.fields ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
@@ -62,6 +107,10 @@ export function ConfigDrivenSection({
// Same field the profile Address tab collects — give it the same
// searchable, flag-labeled picker instead of a plain option list.
const isNationality = field.key === 'nationality' || label.toLowerCase().includes('nationality');
// Options here can't be seeded statically — they're the applicant's
// 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(label.trim());
return (
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
@@ -72,6 +121,23 @@ export function ConfigDrivenSection({
value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)}
/>
) : isVesselPicker ? (
<Select
{...common}
placeholder="Select a registered vessel"
data={vessels.map((v) => ({ value: v.id, label: `${v.name}${v.registrationNumber}` }))}
value={(value as string) ?? null}
onChange={(v) => {
onChange(field.key, v);
const vessel = vessels.find((x) => x.id === v);
// Selecting a different vessel replaces whatever the last
// selection filled in — unlike the nationality/ID prefill,
// this is a live user choice, not a one-time background fill.
if (vessel) fillFromVessel(vessel, fields, onChange);
}}
searchable
clearable={!field.required}
/>
) : field.type === 'SELECT' ? (
<Select
{...common}

View File

@@ -41,6 +41,7 @@ import {
useGetApplicationQuery,
useGetAttachmentsQuery,
useGetLicenseTypeRequirementsQuery,
useGetMyVesselsQuery,
usePatchSectionMutation,
useRemoveStaffMutation,
useResolveRemarkMutation,
@@ -68,6 +69,9 @@ export function LicenseApplicationPage() {
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
const { profile } = useCurrentProfile();
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
// here rather than deeper down since it's the shared source of draft state.
const { data: vessels } = useGetMyVesselsQuery();
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId);
@@ -456,6 +460,7 @@ export function LicenseApplicationPage() {
values={draft[section.key] ?? {}}
formData={draft}
errors={fieldErrors}
vessels={vessels}
disabled={readOnly || locked}
onChange={(key, value) => {
setDraft((prev) => ({
@@ -587,6 +592,7 @@ export function LicenseApplicationPage() {
values={draft[section.key] ?? {}}
formData={draft}
errors={fieldErrors}
vessels={vessels}
disabled={readOnly}
onChange={(key, value) => {
setDraft((prev) => ({