feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.
EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.
Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.
Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.
Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).
Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).
Berth interest list overhaul:
- Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
- Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
- Per-letter row tinting via colored left-border accent + dot in cell
- Documents tab merged Files (single attachments section)
Topbar improvements:
- Always-visible back arrow on detail pages (path depth > 2)
- Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
push their entity hierarchy (Clients › Mary Smith › Interest › B17)
- Tighter spacing, softer separators, 160px crumb truncation
DataTable upgrades:
- Page-size selector with All option (validator cap raised to 1000)
- getRowClassName slot for per-row styling (used by berth tinting)
- Fixed Radix SelectItem crash on empty-string values via __any__
sentinel (was crashing every list page that opened a select filter)
Interest list:
- Configurable columns picker
- Stage cell clickable into detail
- TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
- Save view moved into ColumnPicker menu; Views button hidden when
no views are saved
- Pipeline kanban board endpoint at /api/v1/interests/board with
minimal projection, 5000-row cap + truncated banner, filter
pass-through
Mobile chrome + sidebar collapse removed (always-expanded design choice).
User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,20 +12,100 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
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',
|
||||
@@ -90,46 +170,167 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(amount));
|
||||
}
|
||||
|
||||
export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'mooringNumber',
|
||||
header: 'Mooring #',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
|
||||
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 } = row.original;
|
||||
const { lengthM, widthM, draftM, widthIsMinimum } = row.original;
|
||||
if (!lengthM && !widthM) return '-';
|
||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||
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 { price, priceCurrency } = row.original;
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: priceCurrency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(price));
|
||||
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'),
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Anchor } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { BerthCard } from './berth-card';
|
||||
import { berthColumns, type BerthRow } from './berth-columns';
|
||||
import {
|
||||
berthColumns,
|
||||
BERTH_COLUMN_OPTIONS,
|
||||
BERTH_DEFAULT_HIDDEN,
|
||||
type BerthRow,
|
||||
} from './berth-columns';
|
||||
import { berthFilterDefinitions } from './berth-filters';
|
||||
import { Anchor } from 'lucide-react';
|
||||
import { mooringLetterTone } from './mooring-letter-tone';
|
||||
|
||||
export function BerthList() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
|
||||
const { data, pagination, isLoading, sort, setSort, filters, setFilter, clearFilters, setPage } =
|
||||
usePaginatedQuery<BerthRow>({
|
||||
queryKey: ['berths'],
|
||||
endpoint: '/api/v1/berths',
|
||||
filterDefinitions: berthFilterDefinitions,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
setPage,
|
||||
setPageSize,
|
||||
} = usePaginatedQuery<BerthRow>({
|
||||
queryKey: ['berths'],
|
||||
endpoint: '/api/v1/berths',
|
||||
filterDefinitions: berthFilterDefinitions,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'berth:updated': [['berths']],
|
||||
'berth:statusChanged': [['berths']],
|
||||
});
|
||||
|
||||
// Persisted column visibility — same pattern as ClientList / InterestList.
|
||||
const { hidden, setHidden } = useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -46,21 +68,21 @@ export function BerthList() {
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SavedViewsDropdown
|
||||
entityType="berths"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable<BerthRow>
|
||||
columns={berthColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
@@ -69,11 +91,15 @@ export function BerthList() {
|
||||
total: pagination.total,
|
||||
totalPages: pagination.totalPages,
|
||||
}}
|
||||
onPaginationChange={(page) => setPage(page)}
|
||||
onPaginationChange={(page, pageSize) => {
|
||||
setPage(page);
|
||||
setPageSize(pageSize);
|
||||
}}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
|
||||
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
|
||||
50
src/components/berths/mooring-letter-tone.ts
Normal file
50
src/components/berths/mooring-letter-tone.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Maps a berth's mooring-letter prefix (A, B, C…) to a subtle visual
|
||||
* accent. Pontoons cluster physically — A row is one dock, B another
|
||||
* — so the berth grid reads at a glance when each pontoon's rows
|
||||
* share a colour cue. Earlier iteration tinted the entire row
|
||||
* background; that proved visually noisy. This version keeps rows
|
||||
* white and surfaces the colour as a coloured left border, plus a
|
||||
* matching dot the column factory uses inside the Mooring # cell.
|
||||
*
|
||||
* Cycle wraps at the 8th letter; ports with more pontoons get
|
||||
* repeats (fine in practice — they don't sit adjacent on the page).
|
||||
*/
|
||||
const BORDER_CYCLE = [
|
||||
'border-l-4 border-l-rose-400',
|
||||
'border-l-4 border-l-amber-400',
|
||||
'border-l-4 border-l-emerald-400',
|
||||
'border-l-4 border-l-sky-400',
|
||||
'border-l-4 border-l-violet-400',
|
||||
'border-l-4 border-l-orange-400',
|
||||
'border-l-4 border-l-teal-400',
|
||||
'border-l-4 border-l-fuchsia-400',
|
||||
] as const;
|
||||
|
||||
const DOT_CYCLE = [
|
||||
'bg-rose-400',
|
||||
'bg-amber-400',
|
||||
'bg-emerald-400',
|
||||
'bg-sky-400',
|
||||
'bg-violet-400',
|
||||
'bg-orange-400',
|
||||
'bg-teal-400',
|
||||
'bg-fuchsia-400',
|
||||
] as const;
|
||||
|
||||
function indexFor(mooringNumber: string | null | undefined): number | null {
|
||||
if (!mooringNumber) return null;
|
||||
const letter = mooringNumber.charAt(0).toUpperCase();
|
||||
if (letter < 'A' || letter > 'Z') return null;
|
||||
return (letter.charCodeAt(0) - 'A'.charCodeAt(0)) % BORDER_CYCLE.length;
|
||||
}
|
||||
|
||||
export function mooringLetterTone(mooringNumber: string | null | undefined): string | undefined {
|
||||
const i = indexFor(mooringNumber);
|
||||
return i === null ? undefined : BORDER_CYCLE[i];
|
||||
}
|
||||
|
||||
export function mooringLetterDot(mooringNumber: string | null | undefined): string | undefined {
|
||||
const i = indexFor(mooringNumber);
|
||||
return i === null ? undefined : DOT_CYCLE[i];
|
||||
}
|
||||
Reference in New Issue
Block a user