- website_berth_autopromote_enabled (default OFF): a website registration for a specific, currently-available berth auto-creates a prospect (client + optional yacht + interest) and links the berth is_specific_interest=true, flipping the public map to Under Offer; general/residence/contact submissions stay capture-only. Marks the submission converted so a rep never double-creates it. - derivePublicStatus now honours a manual pin (soft pin): a manually-set status wins over the interest-derived Under Offer, but a real permanent tenancy or an explicit sold still override it. - berth rules engine respects a manual pin EXCEPT for sale triggers (-> sold), so a confirmed sale still wins but soft auto-changes never stomp a pin. - Reset-to-automatic action (service + API POST /berths/[id]/status/reset + UI) to drop a manual pin; lock badge on every manual override (list + detail); divergence banner prompting reset when a pinned-Available berth has a deal. - migration stage map updated to the §4b signed-off mapping: GQI -> enquiry unless it named a berth/size marker (-> qualified); SQI -> qualified. Tests: +public-berths soft-pin cases, +website-intake-promote helpers, +migration GQI marker rule. 1582 unit/integration green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
593 lines
18 KiB
TypeScript
593 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { type ColumnDef } from '@tanstack/react-table';
|
||
import { MoreHorizontal, Pencil, Activity, RefreshCw, Lock } from 'lucide-react';
|
||
import { useRouter, useParams } from 'next/navigation';
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import { TagBadge } from '@/components/shared/tag-badge';
|
||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||
import { formatCurrency } from '@/lib/utils/currency';
|
||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||
import { apiFetch } from '@/lib/api/client';
|
||
import { usePermissions } from '@/hooks/use-permissions';
|
||
import { mooringLetterDot } from './mooring-letter-tone';
|
||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||
import { CatchUpWizard } from '@/components/berths/catch-up-wizard';
|
||
import { ActiveInterestsPopover } from '@/components/berths/active-interests-popover';
|
||
|
||
export type BerthRow = {
|
||
id: string;
|
||
mooringNumber: string;
|
||
area: string | null;
|
||
status: string;
|
||
// Dimensions (both units; row falls back when one is null)
|
||
lengthFt: string | null;
|
||
widthFt: string | null;
|
||
draftFt: string | null;
|
||
lengthM: string | null;
|
||
widthM: string | null;
|
||
draftM: string | null;
|
||
widthIsMinimum: boolean | null;
|
||
// Capacity
|
||
nominalBoatSize: string | null;
|
||
nominalBoatSizeM: string | null;
|
||
waterDepth: string | null;
|
||
waterDepthM: string | null;
|
||
waterDepthIsMinimum: boolean | null;
|
||
// Pontoon details (NocoDB)
|
||
sidePontoon: string | null;
|
||
mooringType: string | null;
|
||
cleatType: string | null;
|
||
cleatCapacity: string | null;
|
||
bollardType: string | null;
|
||
bollardCapacity: string | null;
|
||
access: string | null;
|
||
bowFacing: string | null;
|
||
berthApproved: boolean | null;
|
||
// Power
|
||
powerCapacity: string | null;
|
||
voltage: string | null;
|
||
// Pricing
|
||
price: string | null;
|
||
priceCurrency: string;
|
||
weeklyRateHighUsd: string | null;
|
||
weeklyRateLowUsd: string | null;
|
||
dailyRateHighUsd: string | null;
|
||
dailyRateLowUsd: string | null;
|
||
pricingValidUntil: string | null;
|
||
// Tenure
|
||
tenureType: string;
|
||
tenureYears: number | null;
|
||
tenureStartDate: string | null;
|
||
tenureEndDate: string | null;
|
||
tags: Array<{ id: string; name: string; color: string }>;
|
||
/** Most-advanced pipeline stage among the berth's active interests. Null
|
||
* when no active interest is linked. Read-only; computed server-side. */
|
||
latestInterestStage?: string | null;
|
||
/** Count of non-terminal, non-archived interests linked to this berth.
|
||
* Drives the "Active interests" column + the demand sort. */
|
||
activeInterestCount?: number;
|
||
/** #67: source of the last status write. 'manual' when a human set it
|
||
* via the API; 'automated' when a berth-rule fired; null on rows that
|
||
* haven't been touched since seed. The reconciliation surface treats
|
||
* 'manual' + no latestInterestStage as a row needing catch-up. */
|
||
statusOverrideMode?: string | null;
|
||
};
|
||
|
||
/**
|
||
* Toggleable columns for the berth list ColumnPicker. Heavy NocoDB
|
||
* fields default to hidden; reps can switch them on per-table-view.
|
||
* `mooringNumber` is intentionally omitted from this list - it's the
|
||
* primary identifier and always visible.
|
||
*/
|
||
export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||
{ id: 'area', label: 'Area' },
|
||
{ id: 'status', label: 'Status' },
|
||
{ id: 'latestInterestStage', label: 'Latest deal stage' },
|
||
{ id: 'activeInterestCount', label: 'Active interests' },
|
||
{ id: 'sidePontoon', label: 'Side / Pontoon' },
|
||
{ id: 'dimensions', label: 'Dimensions' },
|
||
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
|
||
{ id: 'waterDepth', label: 'Water depth' },
|
||
{ id: 'mooringType', label: 'Mooring type' },
|
||
{ id: 'cleat', label: 'Cleat (type · capacity)' },
|
||
{ id: 'bollard', label: 'Bollard (type · capacity)' },
|
||
{ id: 'access', label: 'Access' },
|
||
{ id: 'bowFacing', label: 'Bow facing' },
|
||
{ id: 'berthApproved', label: 'Approved' },
|
||
{ id: 'power', label: 'Power (kW · V)' },
|
||
{ id: 'price', label: 'Price' },
|
||
{ id: 'rates', label: 'Daily / Weekly rates' },
|
||
{ id: 'pricingValidUntil', label: 'Pricing valid until' },
|
||
{ id: 'tenure', label: 'Tenure' },
|
||
{ id: 'tags', label: 'Tags' },
|
||
];
|
||
|
||
/** Hidden by default - power-users turn them on via the picker. */
|
||
export const BERTH_DEFAULT_HIDDEN: string[] = [
|
||
'tenure',
|
||
'sidePontoon',
|
||
'nominalBoatSize',
|
||
'waterDepth',
|
||
'mooringType',
|
||
'cleat',
|
||
'bollard',
|
||
'access',
|
||
'bowFacing',
|
||
'berthApproved',
|
||
'power',
|
||
'rates',
|
||
'pricingValidUntil',
|
||
];
|
||
|
||
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
|
||
available: 'available',
|
||
under_offer: 'under_offer',
|
||
sold: 'sold',
|
||
};
|
||
|
||
const BERTH_STATUS_LABELS: Record<string, string> = {
|
||
available: 'Available',
|
||
under_offer: 'Under Offer',
|
||
sold: 'Sold',
|
||
};
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
return (
|
||
<StatusPill status={BERTH_STATUS_PILL[status] ?? 'pending'}>
|
||
{BERTH_STATUS_LABELS[status] ?? status}
|
||
</StatusPill>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Chip beside the status pill flagging a manually-pinned status (wins over
|
||
* automatic derivation). Two variants:
|
||
* - 'catchup' (amber): manual AND no backing interest - a candidate for the
|
||
* catch-up wizard (rep flipped to Under Offer/Sold without a matching deal).
|
||
* - 'pinned' (slate + lock): manual WITH a backing deal - a deliberate pin.
|
||
*/
|
||
function ManualBadge({ variant }: { variant: 'catchup' | 'pinned' }) {
|
||
if (variant === 'catchup') {
|
||
return (
|
||
<span
|
||
className="inline-flex items-center rounded-full border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-xs font-medium uppercase tracking-wide text-amber-800"
|
||
title="Status set manually with no backing interest - needs catch-up"
|
||
>
|
||
Manual
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-700"
|
||
title="Status pinned manually - wins over automatic derivation"
|
||
>
|
||
<Lock className="h-3 w-3" aria-hidden />
|
||
Manual
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||
const router = useRouter();
|
||
const params = useParams<{ portSlug: string }>();
|
||
const berth = row.original;
|
||
const [catchUpOpen, setCatchUpOpen] = useState(false);
|
||
const isManualUnreconciled = berth.statusOverrideMode === 'manual' && !berth.latestInterestStage;
|
||
|
||
return (
|
||
<>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
aria-label={`Row actions for berth ${berth.mooringNumber ?? ''}`.trim()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||
<span className="sr-only">Open menu</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
router.push(`/${params.portSlug}/berths/${berth.id}`);
|
||
}}
|
||
>
|
||
<Activity className="mr-2 h-4 w-4" aria-hidden />
|
||
View details
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
|
||
}}
|
||
>
|
||
<Pencil className="mr-2 h-4 w-4" aria-hidden />
|
||
Edit
|
||
</DropdownMenuItem>
|
||
{isManualUnreconciled ? (
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setCatchUpOpen(true);
|
||
}}
|
||
>
|
||
<RefreshCw className="mr-2 h-4 w-4" aria-hidden />
|
||
Catch up…
|
||
</DropdownMenuItem>
|
||
) : null}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
{isManualUnreconciled ? (
|
||
<CatchUpWizard
|
||
berthId={catchUpOpen ? berth.id : null}
|
||
open={catchUpOpen}
|
||
onOpenChange={setCatchUpOpen}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ActiveInterestsCell({ berthId, count }: { berthId: string; count: number }) {
|
||
const params = useParams<{ portSlug: string }>();
|
||
const portSlug = params?.portSlug ?? '';
|
||
return <ActiveInterestsPopover berthId={berthId} portSlug={portSlug} count={count} />;
|
||
}
|
||
|
||
/**
|
||
* Price column cell. Reps with the `berths.update_prices` permission get
|
||
* a click-to-edit inline field — saves go through the focused price-only
|
||
* route so non-`edit` roles can retune pricing without unlocking the rest
|
||
* of the berth schema. Click stops bubbling so the row's navigate-to-
|
||
* detail handler doesn't fire while the rep is editing.
|
||
*/
|
||
function PriceCell({
|
||
berthId,
|
||
price,
|
||
currency,
|
||
}: {
|
||
berthId: string;
|
||
price: string | null;
|
||
currency: string;
|
||
}) {
|
||
const { can } = usePermissions();
|
||
const qc = useQueryClient();
|
||
const display = price ? (formatCurrency(price, currency, { maxFractionDigits: 0 }) ?? '-') : null;
|
||
|
||
const mutation = useMutation({
|
||
mutationFn: async (next: number | null) =>
|
||
apiFetch(`/api/v1/berths/${berthId}/price`, {
|
||
method: 'PATCH',
|
||
body: { price: next },
|
||
}),
|
||
onSuccess: () => {
|
||
void qc.invalidateQueries({ queryKey: ['berths'] });
|
||
},
|
||
});
|
||
|
||
if (!can('berths', 'update_prices')) {
|
||
return <span>{display ?? '-'}</span>;
|
||
}
|
||
|
||
return (
|
||
<div onClick={(e) => e.stopPropagation()} className="inline-flex">
|
||
<InlineEditableField
|
||
value={price ?? null}
|
||
displayValue={display}
|
||
emptyText="-"
|
||
placeholder="Enter price"
|
||
onSave={async (next) => {
|
||
const parsed = next === null || next.trim() === '' ? null : Number(next);
|
||
if (parsed !== null && (!Number.isFinite(parsed) || parsed < 0)) {
|
||
throw new Error('Price must be a positive number');
|
||
}
|
||
await mutation.mutateAsync(parsed);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
|
||
return parts.filter((p): p is string => Boolean(p)).join(sep);
|
||
}
|
||
|
||
/**
|
||
* Static column list rendered in metric units (the historical default).
|
||
* Most callers should use `getBerthColumns(unit)` instead, which lets the
|
||
* berth-list toolbar toggle render imperial when the rep prefers feet.
|
||
*/
|
||
export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||
{
|
||
accessorKey: 'mooringNumber',
|
||
header: 'Mooring #',
|
||
cell: ({ row }) => {
|
||
const dot = mooringLetterDot(row.original.mooringNumber);
|
||
return (
|
||
<span className="inline-flex items-center gap-2 font-medium">
|
||
{dot && <span className={`inline-block size-2 rounded-full ${dot}`} aria-hidden />}
|
||
{row.original.mooringNumber}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'area',
|
||
accessorKey: 'area',
|
||
header: 'Area',
|
||
cell: ({ row }) => row.original.area ?? '-',
|
||
},
|
||
{
|
||
id: 'status',
|
||
accessorKey: 'status',
|
||
header: 'Status',
|
||
cell: ({ row }) => {
|
||
const r = row.original;
|
||
const isManual = r.statusOverrideMode === 'manual';
|
||
const isManualUnreconciled = isManual && !r.latestInterestStage;
|
||
return (
|
||
<div className="inline-flex items-center gap-1.5">
|
||
<StatusBadge status={r.status} />
|
||
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'latestInterestStage',
|
||
header: 'Latest deal stage',
|
||
enableSorting: true,
|
||
cell: ({ row }) => {
|
||
const s = row.original.latestInterestStage;
|
||
if (!s) return <span className="text-muted-foreground">-</span>;
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(s)}`}
|
||
>
|
||
{stageLabel(s)}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'activeInterestCount',
|
||
accessorKey: 'activeInterestCount',
|
||
header: 'Active interests',
|
||
cell: ({ row }) => (
|
||
<ActiveInterestsCell
|
||
berthId={row.original.id}
|
||
count={row.original.activeInterestCount ?? 0}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: 'sidePontoon',
|
||
header: 'Side / Pontoon',
|
||
enableSorting: false,
|
||
cell: ({ row }) => row.original.sidePontoon ?? '-',
|
||
},
|
||
{
|
||
id: 'dimensions',
|
||
header: 'Dimensions',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const { lengthM, widthM, draftM, widthIsMinimum } = row.original;
|
||
if (!lengthM && !widthM) return '-';
|
||
const widthLabel = widthM ? `${widthIsMinimum ? '≥' : ''}${widthM}m` : '?';
|
||
const base = `${lengthM ?? '?'}m × ${widthLabel}`;
|
||
return draftM ? `${base} (draft ${draftM}m)` : base;
|
||
},
|
||
},
|
||
{
|
||
id: 'nominalBoatSize',
|
||
header: 'Boat size',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const m = row.original.nominalBoatSizeM;
|
||
const ft = row.original.nominalBoatSize;
|
||
if (!m && !ft) return '-';
|
||
return m ? `${m}m` : `${ft}ft`;
|
||
},
|
||
},
|
||
{
|
||
id: 'waterDepth',
|
||
header: 'Water depth',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const { waterDepthM, waterDepthIsMinimum } = row.original;
|
||
if (!waterDepthM) return '-';
|
||
return `${waterDepthIsMinimum ? '≥' : ''}${waterDepthM}m`;
|
||
},
|
||
},
|
||
{
|
||
id: 'mooringType',
|
||
header: 'Mooring type',
|
||
enableSorting: false,
|
||
cell: ({ row }) => row.original.mooringType ?? '-',
|
||
},
|
||
{
|
||
id: 'cleat',
|
||
header: 'Cleat',
|
||
enableSorting: false,
|
||
cell: ({ row }) => joinNonNull([row.original.cleatType, row.original.cleatCapacity]) || '-',
|
||
},
|
||
{
|
||
id: 'bollard',
|
||
header: 'Bollard',
|
||
enableSorting: false,
|
||
cell: ({ row }) => joinNonNull([row.original.bollardType, row.original.bollardCapacity]) || '-',
|
||
},
|
||
{
|
||
id: 'access',
|
||
header: 'Access',
|
||
enableSorting: false,
|
||
cell: ({ row }) => row.original.access ?? '-',
|
||
},
|
||
{
|
||
id: 'bowFacing',
|
||
header: 'Bow facing',
|
||
enableSorting: false,
|
||
cell: ({ row }) => row.original.bowFacing ?? '-',
|
||
},
|
||
{
|
||
id: 'berthApproved',
|
||
header: 'Approved',
|
||
enableSorting: false,
|
||
cell: ({ row }) => (row.original.berthApproved ? 'Yes' : 'No'),
|
||
},
|
||
{
|
||
id: 'power',
|
||
header: 'Power',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const kw = row.original.powerCapacity;
|
||
const v = row.original.voltage;
|
||
if (!kw && !v) return '-';
|
||
return joinNonNull([kw ? `${kw}kW` : null, v ? `${v}V` : null]);
|
||
},
|
||
},
|
||
{
|
||
id: 'price',
|
||
accessorKey: 'price',
|
||
header: 'Price',
|
||
cell: ({ row }) => (
|
||
<PriceCell
|
||
berthId={row.original.id}
|
||
price={row.original.price}
|
||
currency={row.original.priceCurrency}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: 'rates',
|
||
header: 'Rates (USD)',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const { dailyRateLowUsd, dailyRateHighUsd, weeklyRateLowUsd, weeklyRateHighUsd } =
|
||
row.original;
|
||
const daily =
|
||
dailyRateLowUsd && dailyRateHighUsd
|
||
? `${dailyRateLowUsd}–${dailyRateHighUsd}/d`
|
||
: dailyRateLowUsd
|
||
? `${dailyRateLowUsd}/d`
|
||
: null;
|
||
const weekly =
|
||
weeklyRateLowUsd && weeklyRateHighUsd
|
||
? `${weeklyRateLowUsd}–${weeklyRateHighUsd}/wk`
|
||
: weeklyRateLowUsd
|
||
? `${weeklyRateLowUsd}/wk`
|
||
: null;
|
||
return joinNonNull([daily, weekly]) || '-';
|
||
},
|
||
},
|
||
{
|
||
id: 'pricingValidUntil',
|
||
header: 'Pricing valid',
|
||
enableSorting: false,
|
||
cell: ({ row }) => row.original.pricingValidUntil ?? '-',
|
||
},
|
||
{
|
||
id: 'tenure',
|
||
accessorKey: 'tenureType',
|
||
header: 'Tenure',
|
||
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
|
||
},
|
||
{
|
||
id: 'tags',
|
||
header: 'Tags',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const { tags } = row.original;
|
||
if (!tags || tags.length === 0) return null;
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
{tags.slice(0, 3).map((tag) => (
|
||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||
))}
|
||
{tags.length > 3 && (
|
||
<span className="text-xs text-muted-foreground">+{tags.length - 3}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'actions',
|
||
header: '',
|
||
enableSorting: false,
|
||
size: 48,
|
||
cell: ({ row }) => <ActionsCell row={row} />,
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Returns a copy of `berthColumns` with the dimension-bearing cells
|
||
* rewritten to render in the requested unit. Used by `BerthList` so the
|
||
* column-header toggle can flip the rendering globally without each
|
||
* cell renderer reading a context.
|
||
*
|
||
* Imperial columns assume the canonical `*Ft` columns are populated
|
||
* (true by default - the import pipeline + bulk-add wizard write both,
|
||
* and the inline editor in yacht-tabs.tsx auto-fills the counterpart).
|
||
* Rows with only the metric counterpart fall through to `?` for that
|
||
* dimension; the cell still renders so the rep sees what's set.
|
||
*/
|
||
export function getBerthColumns(unit: 'ft' | 'm'): ColumnDef<BerthRow, unknown>[] {
|
||
if (unit === 'm') return berthColumns;
|
||
return berthColumns.map((col) => {
|
||
if (col.id === 'dimensions') {
|
||
return {
|
||
...col,
|
||
cell: ({ row }) => {
|
||
const { lengthFt, widthFt, draftFt, widthIsMinimum } = row.original;
|
||
if (!lengthFt && !widthFt) return '-';
|
||
const widthLabel = widthFt ? `${widthIsMinimum ? '≥' : ''}${widthFt}ft` : '?';
|
||
const base = `${lengthFt ?? '?'}ft × ${widthLabel}`;
|
||
return draftFt ? `${base} (draft ${draftFt}ft)` : base;
|
||
},
|
||
};
|
||
}
|
||
if (col.id === 'nominalBoatSize') {
|
||
return {
|
||
...col,
|
||
cell: ({ row }) => {
|
||
const ft = row.original.nominalBoatSize;
|
||
const m = row.original.nominalBoatSizeM;
|
||
if (!ft && !m) return '-';
|
||
return ft ? `${ft}ft` : `${m}m`;
|
||
},
|
||
};
|
||
}
|
||
if (col.id === 'waterDepth') {
|
||
// Water depth lacks a stored `*Ft` column today; convert from meters
|
||
// on the fly when the rep prefers ft. 1m = 3.2808ft (canonical
|
||
// ratio used in yacht-dimensions.ts).
|
||
return {
|
||
...col,
|
||
cell: ({ row }) => {
|
||
const { waterDepthM, waterDepthIsMinimum } = row.original;
|
||
if (!waterDepthM) return '-';
|
||
const ft = Number(waterDepthM) * 3.2808;
|
||
return `${waterDepthIsMinimum ? '≥' : ''}${ft.toFixed(1)}ft`;
|
||
},
|
||
};
|
||
}
|
||
return col;
|
||
});
|
||
}
|