Files
pn-new-crm/src/components/berths/berth-columns.tsx
Matt 39c19b2340
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s
feat(berths): click-to-change status from the list (chip → reason modal)
Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:01:40 +02:00

596 lines
19 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, 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 { BerthStatusQuickEdit } from './berth-status-quick-edit';
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">
<BerthStatusQuickEdit berthId={r.id} currentStatus={r.status}>
<StatusBadge status={r.status} />
</BerthStatusQuickEdit>
{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;
});
}