From 0e8a82c518a17eac5802f30592b90f21a55f1953 Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 11 Aug 2026 07:36:48 +0000 Subject: [PATCH] feat: add vessel selection support in LicenseApplicationPage and ConfigDrivenSection --- .../components/ConfigDrivenSection.tsx | 66 +++++++++++++++++++ .../pages/LicenseApplicationPage.tsx | 6 ++ 2 files changed, 72 insertions(+) diff --git a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx index 1e0ba931c..1d17b5882 100644 --- a/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx +++ b/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx @@ -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; + /** 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 ( @@ -72,6 +121,23 @@ export function ConfigDrivenSection({ value={(value as string) ?? null} onChange={(v) => onChange(field.key, v)} /> + ) : isVesselPicker ? ( + (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) => ({