feat(berths): NocoDB-aligned dropdown enums + dual-unit auto-fill
Pull verbatim SingleSelect choices from NocoDB Berths via MCP and lock
them into BERTH_*_OPTIONS / _TYPES in lib/constants.ts: Side Pontoon
(10 values), Mooring Type (5), Cleat Type (2), Cleat Capacity (2),
Bollard Type (2), Bollard Capacity (2), Access (5), Area (A–E), Bow
Facing (4-value UX-only constraint over a SingleLineText). Power
Capacity / Voltage stay numeric inputs (NocoDB stores Number).
Add `toSelectOptions()` mapper for shadcn `<Select>` `{value,label}`
pairs.
Wire every berth dropdown — both the modal form and the inline-edit
detail tabs — to `<Select>`. Inline `EditableSpec` gains
`selectOptions` for the variant and `linkedUnit { field, multiplier }`
to auto-patch the metric column on save (× 0.3048 for ft→m on length,
width, draft, nominal boat size, water depth).
Promote nominal boat size + tenure type from read-only `<SpecRow>` to
`<EditableSpec>` so reps can edit them. Tenure type currently uses the
validator's `'permanent' | 'fixed_term'` set; will swap to per-port
configurable list once Vocabularies admin lands (Wave 5).
Mobile berth cards: replace status-coloured stripe with
`mooringLetterDot()` so it groups by dock letter; status conveyed by
the existing pill below. Berth detail header: "{Letter} Dock" chip
instead of bare "A" / "B" text. Berth area filter: `<Select>` over
A/B/C/D/E (renamed to "Dock"). Documents tab gets a one-paragraph
explainer disambiguating the spec PDF from deal documents (Interests
tab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,17 @@ import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import {
|
||||
BERTH_ACCESS_OPTIONS,
|
||||
BERTH_BOLLARD_CAPACITIES,
|
||||
BERTH_BOLLARD_TYPES,
|
||||
BERTH_BOW_FACING_OPTIONS,
|
||||
BERTH_CLEAT_CAPACITIES,
|
||||
BERTH_CLEAT_TYPES,
|
||||
BERTH_MOORING_TYPES,
|
||||
BERTH_SIDE_PONTOON_OPTIONS,
|
||||
toSelectOptions,
|
||||
} from '@/lib/constants';
|
||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||
import { BerthInterestsTab } from './berth-interests-tab';
|
||||
import { BerthInterestPulse } from './berth-interest-pulse';
|
||||
@@ -96,6 +107,8 @@ function EditableSpec({
|
||||
patch,
|
||||
numeric = false,
|
||||
suffix,
|
||||
selectOptions,
|
||||
linkedUnit,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
@@ -103,28 +116,54 @@ function EditableSpec({
|
||||
patch: ReturnType<typeof useBerthPatch>;
|
||||
numeric?: boolean;
|
||||
suffix?: string;
|
||||
/** When provided, the inline editor uses a `<Select>` constrained to these
|
||||
* values. Mirrors the canonical NocoDB SingleSelect choices for the field. */
|
||||
selectOptions?: readonly string[];
|
||||
/** When set, saving this numeric field also patches the paired column with
|
||||
* the converted unit (e.g. ft → m via 0.3048). Mirrors the legacy NocoDB
|
||||
* Formula columns; without this the form's two-unit editors stay out of
|
||||
* sync after an inline edit. */
|
||||
linkedUnit?: { field: string; multiplier: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium sm:max-w-[60%] sm:text-right">
|
||||
<InlineEditableField
|
||||
value={value}
|
||||
onSave={async (next) => {
|
||||
if (numeric) {
|
||||
if (next === null || next.trim() === '') {
|
||||
await patch.mutateAsync({ [field]: null });
|
||||
{selectOptions ? (
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
value={value}
|
||||
options={toSelectOptions(selectOptions)}
|
||||
onSave={async (next) => {
|
||||
await patch.mutateAsync({ [field]: next });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditableField
|
||||
value={value}
|
||||
onSave={async (next) => {
|
||||
if (numeric) {
|
||||
if (next === null || next.trim() === '') {
|
||||
const clear = linkedUnit
|
||||
? { [field]: null, [linkedUnit.field]: null }
|
||||
: { [field]: null };
|
||||
await patch.mutateAsync(clear);
|
||||
return;
|
||||
}
|
||||
const n = Number.parseFloat(next);
|
||||
if (Number.isNaN(n)) throw new Error('Must be a number');
|
||||
const body: Record<string, number | null> = { [field]: n };
|
||||
if (linkedUnit) {
|
||||
body[linkedUnit.field] = Number((n * linkedUnit.multiplier).toFixed(2));
|
||||
}
|
||||
await patch.mutateAsync(body);
|
||||
return;
|
||||
}
|
||||
const n = Number.parseFloat(next);
|
||||
if (Number.isNaN(n)) throw new Error('Must be a number');
|
||||
await patch.mutateAsync({ [field]: n });
|
||||
return;
|
||||
}
|
||||
await patch.mutateAsync({ [field]: next });
|
||||
}}
|
||||
placeholder={suffix ? `e.g. 25${suffix ? ` ${suffix}` : ''}` : undefined}
|
||||
/>
|
||||
await patch.mutateAsync({ [field]: next });
|
||||
}}
|
||||
placeholder={suffix ? `e.g. 25${suffix ? ` ${suffix}` : ''}` : undefined}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -175,6 +214,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'lengthM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Width (ft)"
|
||||
@@ -183,6 +223,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'widthM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Draft (ft)"
|
||||
@@ -191,10 +232,20 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'draftM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Nominal Boat Size (ft)"
|
||||
value={berth.nominalBoatSize}
|
||||
field="nominalBoatSize"
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'nominalBoatSizeM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<SpecRow
|
||||
label="Nominal Boat Size"
|
||||
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||
label="Nominal Boat Size (m)"
|
||||
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)?.split(' / ')[1] ?? null}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Water Depth (ft)"
|
||||
@@ -203,26 +254,36 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'waterDepthM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Mooring Type"
|
||||
value={berth.mooringType}
|
||||
field="mooringType"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_MOORING_TYPES}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Side Pontoon"
|
||||
value={berth.sidePontoon}
|
||||
field="sidePontoon"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_SIDE_PONTOON_OPTIONS}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Bow Facing"
|
||||
value={berth.bowFacing}
|
||||
field="bowFacing"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_BOW_FACING_OPTIONS}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Access"
|
||||
value={berth.access}
|
||||
field="access"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_ACCESS_OPTIONS}
|
||||
/>
|
||||
<EditableSpec label="Access" value={berth.access} field="access" patch={patch} />
|
||||
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -255,24 +316,28 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
value={berth.cleatType}
|
||||
field="cleatType"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_CLEAT_TYPES}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Cleat Capacity"
|
||||
value={berth.cleatCapacity}
|
||||
field="cleatCapacity"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_CLEAT_CAPACITIES}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Bollard Type"
|
||||
value={berth.bollardType}
|
||||
field="bollardType"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_BOLLARD_TYPES}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Bollard Capacity"
|
||||
value={berth.bollardCapacity}
|
||||
field="bollardCapacity"
|
||||
patch={patch}
|
||||
selectOptions={BERTH_BOLLARD_CAPACITIES}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -282,9 +347,14 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<SpecRow
|
||||
{/* Tenure type values are admin-configurable per port (Wave 5).
|
||||
Today the list is the static union from the zod validator. */}
|
||||
<EditableSpec
|
||||
label="Tenure Type"
|
||||
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
||||
value={berth.tenureType}
|
||||
field="tenureType"
|
||||
patch={patch}
|
||||
selectOptions={['permanent', 'fixed_term'] as const}
|
||||
/>
|
||||
{berth.tenureType === 'fixed_term' && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user