Files
pn-new-crm/src/components/berths/berth-columns.tsx
Matt a49ee1c347 fix(audit-wave-9): adopt StatusPill for berth + user status badges
- Extend StatusPill with berth (available/under_offer/sold) and user
  (enabled/disabled) variants so every "this thing is in state X" pill
  shares one primitive and palette.
- Swap berth-card, berth-detail-header, berth-columns from ad-hoc
  bg-green-100 / bg-yellow-100 / bg-red-100 Tailwind tuples to
  <StatusPill status="...">.
- Swap UserList Active/Disabled <Badge> and user-card Inactive pill to
  StatusPill; Super-Admin chip kept as a domain-specific accent (violet).

Closes ui/ux M1+M2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:54:13 +02:00

382 lines
11 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 { type ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Pencil, Activity } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
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 { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
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;
};
/**
* 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: '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>
);
}
function ActionsCell({ row }: { row: { original: BerthRow } }) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const berth = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
<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" />
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" />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
return parts.filter((p): p is string => Boolean(p)).join(sep);
}
function formatMoney(amount: string | null, currency: string): string | null {
if (!amount) return null;
return formatCurrency(amount, currency, { maxFractionDigits: 0 });
}
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 }) => <StatusBadge status={row.original.status} />,
},
{
id: 'latestInterestStage',
header: 'Latest deal stage',
enableSorting: false,
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: '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 }) => formatMoney(row.original.price, 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} />,
},
];