feat(berths): inline-edit on berth detail (12 spec fields + tag editor)

Berth detail page was the last entity using read-only SpecRow widgets
while clients/yachts/companies all use the click-to-edit
InlineEditableField pattern. Marina staffers couldn't update
length/width/draft/price etc without exporting and re-importing.

- New EditableSpec wrapper preserves the SpecRow look + null-hiding
  behaviour but defers the value to InlineEditableField with a per-
  field PATCH callback.
- useBerthPatch hook hits PATCH /api/v1/berths/{id} (already shipped)
  and invalidates the React Query cache for both the list and the
  individual berth.
- Numeric helper handles the schema's NUMERIC-as-string convention:
  empty input → null, non-numeric → throws, valid → coerced to number.
- 12 fields now editable: lengthFt, widthFt, draftFt, waterDepth,
  mooringType, sidePontoon, bowFacing, access, powerCapacity, voltage,
  cleatType, cleatCapacity, bollardType, bollardCapacity, price.
- Tags card uses InlineTagEditor instead of read-only badges, matching
  the yacht/client pattern. The /api/v1/berths/[id]/tags endpoint was
  already in place.
- Dropped the formatDim/formatPower/formatVoltage/price helpers that
  inlined the metric column or currency suffix; the editable layout
  shows ft/kW/V suffixes inline with the field labels instead. The
  metric column is editable separately if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 15:16:18 +02:00
parent adba73fcca
commit 9240cf1808

View File

@@ -1,9 +1,13 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TagBadge } from '@/components/shared/tag-badge';
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 { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab';
import { BerthInterestPulse } from './berth-interest-pulse';
@@ -61,7 +65,73 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
function useBerthPatch(berthId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Record<string, string | number | boolean | null>) =>
apiFetch(`/api/v1/berths/${berthId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['berths', berthId] });
qc.invalidateQueries({ queryKey: ['berths'] });
},
});
}
/**
* Editable spec row. Wraps SpecRow with InlineEditableField for fields
* the operator commonly tweaks (length, width, draft, side pontoon, etc).
* Read-only fields (mooringNumber, area) keep using plain SpecRow.
*
* Numeric fields are stored as strings in the schema (postgres NUMERIC);
* the `numeric` flag tells us to parse before sending and display "-" when
* blank.
*/
function EditableSpec({
label,
value,
field,
patch,
numeric = false,
suffix,
}: {
label: string;
value: string | null;
field: string;
patch: ReturnType<typeof useBerthPatch>;
numeric?: boolean;
suffix?: string;
}) {
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 });
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}
/>
</span>
</div>
);
}
function OverviewTab({ berth }: { berth: BerthData }) {
const patch = useBerthPatch(berth.id);
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
const fmt = (v: string | null, fractionDigits = 2): string | null => {
if (v == null || v === '') return null;
@@ -73,15 +143,9 @@ function OverviewTab({ berth }: { berth: BerthData }) {
});
};
const formatDim = (ft: string | null, m: string | null) => {
const parts = [];
const ftFmt = fmt(ft);
const mFmt = fmt(m);
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
// Read-only display helper for the metric column on dimensions —
// mirrors the pre-edit "X ft / Y m" rendering for fields where only
// the foot value is editable today.
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
const ftFmt = fmt(ft, 0);
const mFmt = fmt(m);
@@ -91,24 +155,6 @@ function OverviewTab({ berth }: { berth: BerthData }) {
return parts.length > 0 ? parts.join(' / ') : null;
};
const formatPower = (kw: string | null) => {
const v = fmt(kw, 0);
return v ? `${v} kW` : null;
};
const formatVoltage = (v: string | null) => {
const fv = fmt(v, 0);
return fv ? `${fv} V` : null;
};
const price = berth.price
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: berth.priceCurrency || 'USD',
maximumFractionDigits: 0,
}).format(Number(berth.price))
: null;
return (
<div className="space-y-6">
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
@@ -122,32 +168,61 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
<SpecRow
label="Width"
value={
formatDim(berth.widthFt, berth.widthM)
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
: null
}
<EditableSpec
label="Length (ft)"
value={berth.lengthFt}
field="lengthFt"
patch={patch}
numeric
suffix="ft"
/>
<EditableSpec
label="Width (ft)"
value={berth.widthFt}
field="widthFt"
patch={patch}
numeric
suffix="ft"
/>
<EditableSpec
label="Draft (ft)"
value={berth.draftFt}
field="draftFt"
patch={patch}
numeric
suffix="ft"
/>
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow
label="Nominal Boat Size"
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
/>
<SpecRow
label="Water Depth"
value={
berth.waterDepth || berth.waterDepthM
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
: null
}
<EditableSpec
label="Water Depth (ft)"
value={berth.waterDepth}
field="waterDepth"
patch={patch}
numeric
suffix="ft"
/>
<SpecRow label="Mooring Type" value={berth.mooringType} />
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
<SpecRow label="Bow Facing" value={berth.bowFacing} />
<SpecRow label="Access" value={berth.access} />
<EditableSpec
label="Mooring Type"
value={berth.mooringType}
field="mooringType"
patch={patch}
/>
<EditableSpec
label="Side Pontoon"
value={berth.sidePontoon}
field="sidePontoon"
patch={patch}
/>
<EditableSpec
label="Bow Facing"
value={berth.bowFacing}
field="bowFacing"
patch={patch}
/>
<EditableSpec label="Access" value={berth.access} field="access" patch={patch} />
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
</CardContent>
</Card>
@@ -159,12 +234,46 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
<SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
<SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} />
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
<EditableSpec
label="Power Capacity (kW)"
value={berth.powerCapacity}
field="powerCapacity"
patch={patch}
numeric
suffix="kW"
/>
<EditableSpec
label="Voltage (V)"
value={berth.voltage}
field="voltage"
patch={patch}
numeric
suffix="V"
/>
<EditableSpec
label="Cleat Type"
value={berth.cleatType}
field="cleatType"
patch={patch}
/>
<EditableSpec
label="Cleat Capacity"
value={berth.cleatCapacity}
field="cleatCapacity"
patch={patch}
/>
<EditableSpec
label="Bollard Type"
value={berth.bollardType}
field="bollardType"
patch={patch}
/>
<EditableSpec
label="Bollard Capacity"
value={berth.bollardCapacity}
field="bollardCapacity"
patch={patch}
/>
</CardContent>
</Card>
@@ -184,24 +293,28 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<SpecRow label="End Date" value={berth.tenureEndDate} />
</>
)}
<SpecRow label="Price" value={price} />
<EditableSpec
label={`Price (${berth.priceCurrency || 'USD'})`}
value={berth.price}
field="price"
patch={patch}
numeric
/>
</CardContent>
</Card>
{berth.tags.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1.5">
{berth.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<InlineTagEditor
endpoint={`/api/v1/berths/${berth.id}/tags`}
currentTags={berth.tags}
invalidateKey={['berths', berth.id]}
/>
</CardContent>
</Card>
</div>
</div>
</div>