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:
@@ -14,6 +14,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BerthRow } from './berth-columns';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
|
||||
const STATUS_VARIANTS: Record<string, string> = {
|
||||
available: 'bg-green-100 text-green-800 border-green-200',
|
||||
@@ -27,12 +28,6 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
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 {
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
@@ -57,7 +52,9 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
|
||||
const statusColor =
|
||||
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
|
||||
let dimText: string | null = null;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { BerthForm } from './berth-form';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
||||
@@ -193,7 +194,15 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
{STATUS_LABELS[berth.status] ?? berth.status}
|
||||
</span>
|
||||
</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 className="flex flex-wrap items-center gap-2 sm:shrink-0">
|
||||
|
||||
@@ -162,6 +162,12 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-sm font-medium">Current PDF</CardTitle>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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[] = [
|
||||
{
|
||||
@@ -19,9 +19,9 @@ export const berthFilterDefinitions: FilterDefinition[] = [
|
||||
},
|
||||
{
|
||||
key: 'area',
|
||||
label: 'Area',
|
||||
type: 'text',
|
||||
placeholder: 'Filter by area...',
|
||||
label: 'Dock',
|
||||
type: 'select',
|
||||
options: toSelectOptions(BERTH_AREAS),
|
||||
},
|
||||
{
|
||||
key: 'tenureType',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { toastError } from '@/lib/api/toast-error';
|
||||
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
|
||||
import {
|
||||
BERTH_AREAS,
|
||||
BERTH_BOW_FACING_OPTIONS,
|
||||
BERTH_SIDE_PONTOON_OPTIONS,
|
||||
BERTH_MOORING_TYPES,
|
||||
BERTH_CLEAT_TYPES,
|
||||
@@ -211,7 +212,11 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
|
||||
@@ -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_BOW_FACING_OPTIONS = ['North', 'South', 'East', 'West'] as const;
|
||||
|
||||
export const BERTH_SIDE_PONTOON_OPTIONS = [
|
||||
'No',
|
||||
'Quay SB',
|
||||
@@ -166,6 +168,13 @@ export const BERTH_ACCESS_OPTIONS = [
|
||||
'Car (3.5t) to Vessel',
|
||||
] 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
|
||||
|
||||
Reference in New Issue
Block a user