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>
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
'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 { 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';
|
|
import { BerthDocumentsTab } from './berth-documents-tab';
|
|
|
|
type BerthData = {
|
|
id: string;
|
|
mooringNumber: string;
|
|
area: string | null;
|
|
status: string;
|
|
lengthFt: string | null;
|
|
lengthM: string | null;
|
|
widthFt: string | null;
|
|
widthM: string | null;
|
|
draftFt: string | null;
|
|
draftM: string | null;
|
|
widthIsMinimum: boolean | null;
|
|
nominalBoatSize: string | null;
|
|
nominalBoatSizeM: string | null;
|
|
waterDepth: string | null;
|
|
waterDepthM: string | null;
|
|
waterDepthIsMinimum: boolean | null;
|
|
sidePontoon: string | null;
|
|
powerCapacity: string | null;
|
|
voltage: string | null;
|
|
mooringType: string | null;
|
|
cleatType: string | null;
|
|
cleatCapacity: string | null;
|
|
bollardType: string | null;
|
|
bollardCapacity: string | null;
|
|
access: string | null;
|
|
price: string | null;
|
|
priceCurrency: string;
|
|
bowFacing: string | null;
|
|
berthApproved: boolean | null;
|
|
tenureType: string;
|
|
tenureYears: number | null;
|
|
tenureStartDate: string | null;
|
|
tenureEndDate: string | null;
|
|
statusLastChangedReason: string | null;
|
|
statusLastModified: string | null;
|
|
tags: Array<{ id: string; name: string; color: string }>;
|
|
};
|
|
|
|
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|
if (!value && value !== 0 && value !== false) return null;
|
|
// Mobile-first: stack vertically with label on top so long values
|
|
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
|
|
// From `sm` (>=640px) up: switch to the original two-column layout.
|
|
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">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|
|
const n = Number(v);
|
|
if (Number.isNaN(n)) return v;
|
|
return n.toLocaleString('en-US', {
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: fractionDigits,
|
|
});
|
|
};
|
|
|
|
// 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);
|
|
const parts: string[] = [];
|
|
if (ftFmt) parts.push(`${ftFmt} ft`);
|
|
if (mFmt) parts.push(`${mFmt} m`);
|
|
return parts.length > 0 ? parts.join(' / ') : null;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Sales pulse - top-of-page so reps doing berth-level triage can see
|
|
who's interested + how warm without clicking into the Interests tab. */}
|
|
<BerthInterestPulse berthId={berth.id} />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Specifications */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 divide-y">
|
|
<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="Nominal Boat Size"
|
|
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
|
/>
|
|
<EditableSpec
|
|
label="Water Depth (ft)"
|
|
value={berth.waterDepth}
|
|
field="waterDepth"
|
|
patch={patch}
|
|
numeric
|
|
suffix="ft"
|
|
/>
|
|
<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>
|
|
|
|
{/* Infrastructure & Pricing */}
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 divide-y">
|
|
<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>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 divide-y">
|
|
<SpecRow
|
|
label="Tenure Type"
|
|
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
|
|
/>
|
|
{berth.tenureType === 'fixed_term' && (
|
|
<>
|
|
<SpecRow label="Years" value={berth.tenureYears} />
|
|
<SpecRow label="Start Date" value={berth.tenureStartDate} />
|
|
<SpecRow label="End Date" value={berth.tenureEndDate} />
|
|
</>
|
|
)}
|
|
<EditableSpec
|
|
label={`Price (${berth.priceCurrency || 'USD'})`}
|
|
value={berth.price}
|
|
field="price"
|
|
patch={patch}
|
|
numeric
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function StubTab({ label }: { label: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
|
<p className="text-muted-foreground">{label} coming soon</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function buildBerthTabs(berth: BerthData): DetailTab[] {
|
|
return [
|
|
{
|
|
id: 'overview',
|
|
label: 'Overview',
|
|
content: <OverviewTab berth={berth} />,
|
|
},
|
|
{
|
|
id: 'interests',
|
|
label: 'Interests',
|
|
content: <BerthInterestsTab berthId={berth.id} />,
|
|
},
|
|
{
|
|
id: 'reservations',
|
|
label: 'Reservations',
|
|
content: <BerthReservationsTab berthId={berth.id} />,
|
|
},
|
|
{
|
|
id: 'documents',
|
|
label: 'Documents',
|
|
content: <BerthDocumentsTab berthId={berth.id} />,
|
|
},
|
|
{
|
|
id: 'waiting-list',
|
|
label: 'Waiting List',
|
|
content: <StubTab label="Waiting List" />,
|
|
},
|
|
{
|
|
id: 'maintenance',
|
|
label: 'Maintenance Log',
|
|
content: <StubTab label="Maintenance Log" />,
|
|
},
|
|
{
|
|
id: 'activity',
|
|
label: 'Activity',
|
|
content: (
|
|
<EntityActivityFeed
|
|
endpoint={`/api/v1/berths/${berth.id}/activity`}
|
|
emptyText="No activity recorded for this berth yet."
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
}
|