New `src/lib/utils/currency.ts` is the single source of truth for display formatting (`formatCurrency`) and the supported-currency catalog (`SUPPORTED_CURRENCIES`, 10 codes covering the marina market). New shared components: - `<CurrencyInput>` — number input with leading symbol prefix and decimal inputMode, raw number value out via onChange. - `<CurrencySelect>` — Select dropdown over `SUPPORTED_CURRENCIES` with symbol + code + label per row, replaces the free-text 3-letter inputs that let reps type "EURO" or "$$$" into a 3-char ISO column. Threaded through every money input + display: - Forms: berth (price/currency), expense (amount/currency), invoice (currency Select + line-items unit-price + step-3 review totals). - Reads: berth-card / berth-columns / invoice-card / expense-card / dashboard KPIs / dashboard revenue-forecast / portal-invoices page. Each had its own ad-hoc `Intl.NumberFormat` wrapper with slightly different fallbacks; collapsed onto the shared helper. `InvoiceLineItems` gained a `currency` prop so the unit-price input prefix and the subtotal use the parent invoice's currency rather than hard-coded `en-US` formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
'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 { formatCurrency } from '@/lib/utils/currency';
|
||
import { mooringLetterDot } from './mooring-letter-tone';
|
||
|
||
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 }>;
|
||
};
|
||
|
||
/**
|
||
* 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: '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',
|
||
];
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
const variants: Record<string, string> = {
|
||
available: 'bg-green-100 text-green-800 border-green-200',
|
||
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||
sold: 'bg-red-100 text-red-800 border-red-200',
|
||
};
|
||
|
||
const labels: Record<string, string> = {
|
||
available: 'Available',
|
||
under_offer: 'Under Offer',
|
||
sold: 'Sold',
|
||
};
|
||
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${variants[status] ?? 'bg-muted text-muted-foreground'}`}
|
||
>
|
||
{labels[status] ?? status}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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: '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} />,
|
||
},
|
||
];
|