Files
emaui/apps/portal/src/app/features/licensing/components/ConfigDrivenSection.tsx

213 lines
8.8 KiB
TypeScript

import {
Checkbox,
Grid,
NumberInput,
Select,
Textarea,
TextInput,
} from '@mantine/core';
import {
conditionHolds,
localized,
type FormFieldConfig,
type FormSectionConfig,
type Vessel,
} from '@ema-platform/api';
import { CountrySelect } from '@ema-platform/ui';
interface Props {
section: FormSectionConfig;
values: Record<string, unknown>;
formData: Record<string, Record<string, unknown>>;
onChange: (key: string, value: unknown) => void;
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) ?? '');
}
}
/**
* Renders one form section from the license type's configuration.
*
* Nothing here is Freight-Forwarder specific — adding a license type or moving
* a field is a backend config change, which is the whole point of the
* config-driven design.
*/
export function ConfigDrivenSection({
section,
values,
formData,
onChange,
disabled,
errors = {},
vessels = [],
}: Props) {
const fields = [...(section.fields ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
return (
<Grid>
{fields.map((field) => {
if (!conditionHolds(field.showWhen, formData)) return null;
const label = localized(field.label);
const value = values?.[field.key];
const error = errors[`${section.key}.${field.key}`];
const common = {
label,
description: localized(field.helpText) || undefined,
withAsterisk: field.required,
error,
// Read-only fields come from the user account and must not be edited.
disabled: disabled || field.readOnly,
};
const span = field.type === 'TEXTAREA' ? 12 : 6;
// 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}>
{isNationality ? (
<CountrySelect
{...common}
demonym
value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)}
/>
) : isVesselPicker ? (
// ponytail: swapped for a plain text input while testing —
// vessels list is empty until the backend seed exists. Paste a
// vessel id here to exercise the autofill; restore the Select
// below once GET /vessels/mine returns real rows.
// <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);
// if (vessel) fillFromVessel(vessel, fields, onChange);
// }}
// searchable
// clearable={!field.required}
// />
<TextInput
{...common}
placeholder="Paste a vessel id (testing)"
value={(value as string) ?? ''}
onChange={(e) => {
const v = e.currentTarget.value;
onChange(field.key, v);
const vessel = vessels.find((x) => x.id === v);
if (vessel) fillFromVessel(vessel, fields, onChange);
}}
/>
) : field.type === 'SELECT' ? (
<Select
{...common}
data={(field.options ?? []).map((o) => ({
value: o.value,
label: localized(o.label),
}))}
value={(value as string) ?? null}
onChange={(v) => onChange(field.key, v)}
clearable={!field.required}
/>
) : field.type === 'BOOLEAN' ? (
<Checkbox
label={label}
error={error}
disabled={common.disabled}
checked={Boolean(value)}
onChange={(e) => onChange(field.key, e.currentTarget.checked)}
mt="md"
/>
) : field.type === 'NUMBER' || field.type === 'MONEY' ? (
<NumberInput
{...common}
value={(value as number) ?? ''}
onChange={(v) => onChange(field.key, v === '' ? null : Number(v))}
// Deliberately not clamped with min/max: Mantine would rewrite
// the entered figure on blur, quietly turning a capital of
// 900,000 into the 1,500,000 threshold. Validation reports the
// problem instead, leaving what the applicant typed intact.
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
/>
) : field.type === 'DATE' ? (
<TextInput
{...common}
type="date"
value={(value as string) ?? ''}
onChange={(e) => onChange(field.key, e.currentTarget.value)}
/>
) : field.type === 'TEXTAREA' ? (
<Textarea
{...common}
autosize
minRows={3}
value={(value as string) ?? ''}
onChange={(e) => onChange(field.key, e.currentTarget.value)}
/>
) : (
<TextInput
{...common}
type={field.type === 'EMAIL' ? 'email' : 'text'}
value={(value as string) ?? ''}
onChange={(e) => onChange(field.key, e.currentTarget.value)}
/>
)}
</Grid.Col>
);
})}
</Grid>
);
}