mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 14:08:12 +00:00
213 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|