'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 = { available: 'available', under_offer: 'under_offer', sold: 'sold', }; const BERTH_STATUS_LABELS: Record = { available: 'Available', under_offer: 'Under Offer', sold: 'Sold', }; function StatusBadge({ status }: { status: string }) { return ( {BERTH_STATUS_LABELS[status] ?? status} ); } /** * 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 ( Manual ); } return ( Manual ); } 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 ( <> { e.stopPropagation(); router.push(`/${params.portSlug}/berths/${berth.id}`); }} > View details { e.stopPropagation(); router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`); }} > Edit {isManualUnreconciled ? ( { e.stopPropagation(); setCatchUpOpen(true); }} > Catch up… ) : null} {isManualUnreconciled ? ( ) : null} ); } function ActiveInterestsCell({ berthId, count }: { berthId: string; count: number }) { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; return ; } /** * 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 {display ?? '-'}; } return (
e.stopPropagation()} className="inline-flex"> { 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); }} />
); } function joinNonNull(parts: Array, 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[] = [ { accessorKey: 'mooringNumber', header: 'Mooring #', cell: ({ row }) => { const dot = mooringLetterDot(row.original.mooringNumber); return ( {dot && } {row.original.mooringNumber} ); }, }, { 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 (
{isManual ? : null}
); }, }, { id: 'latestInterestStage', header: 'Latest deal stage', enableSorting: true, cell: ({ row }) => { const s = row.original.latestInterestStage; if (!s) return -; return ( {stageLabel(s)} ); }, }, { id: 'activeInterestCount', accessorKey: 'activeInterestCount', header: 'Active interests', cell: ({ row }) => ( ), }, { 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 }) => ( ), }, { 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 (
{tags.slice(0, 3).map((tag) => ( ))} {tags.length > 3 && ( +{tags.length - 3} )}
); }, }, { id: 'actions', header: '', enableSorting: false, size: 48, cell: ({ 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[] { 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; }); }