Files
pn-new-crm/src/components/berths/berth-columns.tsx
Matt e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
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>
2026-05-26 18:48:46 +02:00

580 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
});
}