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

@@ -14,6 +14,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { BerthRow } from './berth-columns'; import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone';
const STATUS_VARIANTS: Record<string, string> = { const STATUS_VARIANTS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200', available: 'bg-green-100 text-green-800 border-green-200',
@@ -27,12 +28,6 @@ const STATUS_LABELS: Record<string, string> = {
sold: 'Sold', sold: 'Sold',
}; };
const ACCENT_CLASS: Record<string, string> = {
available: 'bg-emerald-400',
under_offer: 'bg-amber-400',
sold: 'bg-slate-400',
};
function formatPrice(price: string, currency: string): string { function formatPrice(price: string, currency: string): string {
try { try {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
@@ -57,7 +52,9 @@ export function BerthCard({ berth }: BerthCardProps) {
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status; const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
const statusColor = const statusColor =
STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'; STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted';
const accentClass = ACCENT_CLASS[berth.status] ?? 'bg-slate-300'; // Accent stripe groups visually by dock (A-row, B-row, ...). Status is
// already conveyed by the pill below, so the stripe is dock-keyed.
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
// Dimensions string // Dimensions string
let dimText: string | null = null; let dimText: string | null = null;

View File

@@ -27,6 +27,7 @@ import { Textarea } from '@/components/ui/textarea';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form'; import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths'; import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
@@ -193,7 +194,15 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{STATUS_LABELS[berth.status] ?? berth.status} {STATUS_LABELS[berth.status] ?? berth.status}
</span> </span>
</div> </div>
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>} {berth.area && (
<div className="mt-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-white ${mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400'}`}
>
{berth.area} Dock
</span>
</div>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:shrink-0">

View File

@@ -162,6 +162,12 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<p className="text-sm text-muted-foreground">
Berth-spec PDF: the dimensional drawing or surveyor sheet for this slip.
Versioned so a misparse can be rolled back. For documents tied to a
prospect on this berth (EOI, contract, etc.), open the matching deal
in the Interests tab.
</p>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-3"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-sm font-medium">Current PDF</CardTitle> <CardTitle className="text-sm font-medium">Current PDF</CardTitle>

View File

@@ -1,5 +1,5 @@
import type { FilterDefinition } from '@/components/shared/filter-bar'; import type { FilterDefinition } from '@/components/shared/filter-bar';
import { BERTH_STATUSES } from '@/lib/constants'; import { BERTH_AREAS, BERTH_STATUSES, toSelectOptions } from '@/lib/constants';
export const berthFilterDefinitions: FilterDefinition[] = [ export const berthFilterDefinitions: FilterDefinition[] = [
{ {
@@ -19,9 +19,9 @@ export const berthFilterDefinitions: FilterDefinition[] = [
}, },
{ {
key: 'area', key: 'area',
label: 'Area', label: 'Dock',
type: 'text', type: 'select',
placeholder: 'Filter by area...', options: toSelectOptions(BERTH_AREAS),
}, },
{ {
key: 'tenureType', key: 'tenureType',

View File

@@ -25,6 +25,7 @@ import { toastError } from '@/lib/api/toast-error';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths'; import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
import { import {
BERTH_AREAS, BERTH_AREAS,
BERTH_BOW_FACING_OPTIONS,
BERTH_SIDE_PONTOON_OPTIONS, BERTH_SIDE_PONTOON_OPTIONS,
BERTH_MOORING_TYPES, BERTH_MOORING_TYPES,
BERTH_CLEAT_TYPES, BERTH_CLEAT_TYPES,
@@ -211,7 +212,11 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="bowFacing">Bow Facing</Label> <Label htmlFor="bowFacing">Bow Facing</Label>
<Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" /> <SelectOrEmpty
value={watch('bowFacing')}
onChange={(v) => setValue('bowFacing', v)}
options={BERTH_BOW_FACING_OPTIONS}
/>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -8,6 +8,17 @@ import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { apiFetch } from '@/lib/api/client'; 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 { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab'; import { BerthInterestsTab } from './berth-interests-tab';
import { BerthInterestPulse } from './berth-interest-pulse'; import { BerthInterestPulse } from './berth-interest-pulse';
@@ -96,6 +107,8 @@ function EditableSpec({
patch, patch,
numeric = false, numeric = false,
suffix, suffix,
selectOptions,
linkedUnit,
}: { }: {
label: string; label: string;
value: string | null; value: string | null;
@@ -103,28 +116,54 @@ function EditableSpec({
patch: ReturnType<typeof useBerthPatch>; patch: ReturnType<typeof useBerthPatch>;
numeric?: boolean; numeric?: boolean;
suffix?: string; 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 ( 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"> <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="text-muted-foreground">{label}</span>
<span className="font-medium sm:max-w-[60%] sm:text-right"> <span className="font-medium sm:max-w-[60%] sm:text-right">
<InlineEditableField {selectOptions ? (
value={value} <InlineEditableField
onSave={async (next) => { variant="select"
if (numeric) { value={value}
if (next === null || next.trim() === '') { options={toSelectOptions(selectOptions)}
await patch.mutateAsync({ [field]: null }); 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; return;
} }
const n = Number.parseFloat(next); await patch.mutateAsync({ [field]: next });
if (Number.isNaN(n)) throw new Error('Must be a number'); }}
await patch.mutateAsync({ [field]: n }); placeholder={suffix ? `e.g. 25${suffix ? ` ${suffix}` : ''}` : undefined}
return; />
} )}
await patch.mutateAsync({ [field]: next });
}}
placeholder={suffix ? `e.g. 25${suffix ? ` ${suffix}` : ''}` : undefined}
/>
</span> </span>
</div> </div>
); );
@@ -175,6 +214,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix="ft"
linkedUnit={{ field: 'lengthM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Width (ft)" label="Width (ft)"
@@ -183,6 +223,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix="ft"
linkedUnit={{ field: 'widthM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Draft (ft)" label="Draft (ft)"
@@ -191,10 +232,20 @@ function OverviewTab({ berth }: { berth: BerthData }) {
patch={patch} patch={patch}
numeric numeric
suffix="ft" 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 <SpecRow
label="Nominal Boat Size" label="Nominal Boat Size (m)"
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)} value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)?.split(' / ')[1] ?? null}
/> />
<EditableSpec <EditableSpec
label="Water Depth (ft)" label="Water Depth (ft)"
@@ -203,26 +254,36 @@ function OverviewTab({ berth }: { berth: BerthData }) {
patch={patch} patch={patch}
numeric numeric
suffix="ft" suffix="ft"
linkedUnit={{ field: 'waterDepthM', multiplier: 0.3048 }}
/> />
<EditableSpec <EditableSpec
label="Mooring Type" label="Mooring Type"
value={berth.mooringType} value={berth.mooringType}
field="mooringType" field="mooringType"
patch={patch} patch={patch}
selectOptions={BERTH_MOORING_TYPES}
/> />
<EditableSpec <EditableSpec
label="Side Pontoon" label="Side Pontoon"
value={berth.sidePontoon} value={berth.sidePontoon}
field="sidePontoon" field="sidePontoon"
patch={patch} patch={patch}
selectOptions={BERTH_SIDE_PONTOON_OPTIONS}
/> />
<EditableSpec <EditableSpec
label="Bow Facing" label="Bow Facing"
value={berth.bowFacing} value={berth.bowFacing}
field="bowFacing" field="bowFacing"
patch={patch} 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} /> <SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
</CardContent> </CardContent>
</Card> </Card>
@@ -255,24 +316,28 @@ function OverviewTab({ berth }: { berth: BerthData }) {
value={berth.cleatType} value={berth.cleatType}
field="cleatType" field="cleatType"
patch={patch} patch={patch}
selectOptions={BERTH_CLEAT_TYPES}
/> />
<EditableSpec <EditableSpec
label="Cleat Capacity" label="Cleat Capacity"
value={berth.cleatCapacity} value={berth.cleatCapacity}
field="cleatCapacity" field="cleatCapacity"
patch={patch} patch={patch}
selectOptions={BERTH_CLEAT_CAPACITIES}
/> />
<EditableSpec <EditableSpec
label="Bollard Type" label="Bollard Type"
value={berth.bollardType} value={berth.bollardType}
field="bollardType" field="bollardType"
patch={patch} patch={patch}
selectOptions={BERTH_BOLLARD_TYPES}
/> />
<EditableSpec <EditableSpec
label="Bollard Capacity" label="Bollard Capacity"
value={berth.bollardCapacity} value={berth.bollardCapacity}
field="bollardCapacity" field="bollardCapacity"
patch={patch} patch={patch}
selectOptions={BERTH_BOLLARD_CAPACITIES}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -282,9 +347,14 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle> <CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 divide-y"> <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" 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' && ( {berth.tenureType === 'fixed_term' && (
<> <>

View File

@@ -129,6 +129,8 @@ export type BerthStatus = (typeof BERTH_STATUSES)[number];
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const; export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
export const BERTH_BOW_FACING_OPTIONS = ['North', 'South', 'East', 'West'] as const;
export const BERTH_SIDE_PONTOON_OPTIONS = [ export const BERTH_SIDE_PONTOON_OPTIONS = [
'No', 'No',
'Quay SB', 'Quay SB',
@@ -166,6 +168,13 @@ export const BERTH_ACCESS_OPTIONS = [
'Car (3.5t) to Vessel', 'Car (3.5t) to Vessel',
] as const; ] as const;
/** Helper to map a readonly enum tuple into shadcn `<Select>` `{value,label}` objects. */
export function toSelectOptions<T extends readonly string[]>(
values: T,
): Array<{ value: T[number]; label: T[number] }> {
return values.map((v) => ({ value: v, label: v }));
}
// ─── Lead Categories ───────────────────────────────────────────────────────── // ─── Lead Categories ─────────────────────────────────────────────────────────
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const; export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;