Drain the long-tail audit queue captured in alpha-uat-master.md.
- next-intl ripped out (zero useTranslations callers ever existed):
package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
border-r/rounded-l-/rounded-r-) inside JSX className literals.
Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
label enabled at warn; 4 empty <th>/<td> action placeholders gain
sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
"Payments - Not received yet" or "Payments - \$X received - N payments
- Expand"; per-interest collapse state persists in localStorage; the
RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
text-muted-foreground/{60,70,80} hits dropped to plain
text-muted-foreground for AA contrast on muted bg. Icon-only
(aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
rewritten with cumulative state through today. Items genuinely still
open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
pixel E2E verification, and website-cutover work parked here so
they don't get lost in the CRM audit doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
580 lines
18 KiB
TypeScript
580 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { type ColumnDef } from '@tanstack/react-table';
|
||
import { MoreHorizontal, Pencil, Activity, RefreshCw } 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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* #67 Phase 2: small amber chip beside the status pill flagging rows
|
||
* whose status was set manually and has no backing interest. These are
|
||
* the candidates for the catch-up wizard - the rep flipped a berth to
|
||
* "Under Offer" or "Sold" without ever creating the matching deal.
|
||
*/
|
||
function ManualBadge() {
|
||
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>
|
||
);
|
||
}
|
||
|
||
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 isManualUnreconciled = r.statusOverrideMode === 'manual' && !r.latestInterestStage;
|
||
return (
|
||
<div className="inline-flex items-center gap-1.5">
|
||
<StatusBadge status={r.status} />
|
||
{isManualUnreconciled ? <ManualBadge /> : 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;
|
||
});
|
||
}
|