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:
2026-05-09 04:10:24 +02:00
parent 4d6a293534
commit e13232e2ad
7 changed files with 129 additions and 33 deletions

View File

@@ -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' && (
<>