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>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||
import { MoreHorizontal, Pencil, Archive, MessageSquare } from 'lucide-react';
|
||
import type { ColumnDef } from '@tanstack/react-table';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||
|
||
export interface InterestRow {
|
||
id: string;
|
||
clientId: string;
|
||
clientName: string | null;
|
||
yachtId?: string | null;
|
||
yachtName?: string | null;
|
||
berthId: string | null;
|
||
berthMooringNumber: string | null;
|
||
pipelineStage: string;
|
||
leadCategory: string | null;
|
||
source: string | null;
|
||
archivedAt: string | null;
|
||
createdAt: string;
|
||
/** Surfaced by listInterests for the row-level sales-triage signals
|
||
* (last-activity relative time, comment-icon, urgency badges). */
|
||
updatedAt?: string;
|
||
dateLastContact?: string | null;
|
||
dateEoiSent?: string | null;
|
||
dateDepositReceived?: string | null;
|
||
eoiStatus?: string | null;
|
||
outcome?: string | null;
|
||
/** Imperial; nullable. Recommender treats nulls as "no constraint" on
|
||
* that axis. Rendered as a compact "60×18×6 ft" string in the list. */
|
||
desiredLengthFt?: string | number | null;
|
||
desiredWidthFt?: string | number | null;
|
||
desiredDraftFt?: string | number | null;
|
||
notesCount?: number;
|
||
tags?: Array<{ id: string; name: string; color: string }>;
|
||
}
|
||
|
||
function formatDim(value: string | number | null | undefined): string {
|
||
if (value === null || value === undefined || value === '') return '?';
|
||
const n = typeof value === 'number' ? value : parseFloat(value);
|
||
if (!Number.isFinite(n)) return '?';
|
||
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||
}
|
||
|
||
function formatDesiredSize(row: InterestRow): string | null {
|
||
const { desiredLengthFt, desiredWidthFt, desiredDraftFt } = row;
|
||
if (
|
||
(desiredLengthFt === null || desiredLengthFt === undefined || desiredLengthFt === '') &&
|
||
(desiredWidthFt === null || desiredWidthFt === undefined || desiredWidthFt === '') &&
|
||
(desiredDraftFt === null || desiredDraftFt === undefined || desiredDraftFt === '')
|
||
) {
|
||
return null;
|
||
}
|
||
return `${formatDim(desiredLengthFt)}×${formatDim(desiredWidthFt)}×${formatDim(desiredDraftFt)} ft`;
|
||
}
|
||
|
||
const SOURCE_LABELS: Record<string, string> = {
|
||
website: 'Website',
|
||
manual: 'Manual',
|
||
referral: 'Referral',
|
||
broker: 'Broker',
|
||
};
|
||
|
||
/**
|
||
* Toggleable columns for the InterestList ColumnPicker. `actions` and
|
||
* `clientName` are intentionally omitted from this list — actions is a
|
||
* row-control column that should never be hidden, and clientName is the
|
||
* primary entity identifier (a row with no name has no useful purpose).
|
||
*/
|
||
export const INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||
{ id: 'yachtName', label: 'Yacht' },
|
||
{ id: 'berthMooringNumber', label: 'Berth' },
|
||
{ id: 'desiredSize', label: 'Desired size' },
|
||
{ id: 'pipelineStage', label: 'Stage' },
|
||
{ id: 'eoiStatus', label: 'EOI status' },
|
||
{ id: 'source', label: 'Source' },
|
||
{ id: 'dateLastContact', label: 'Last contact' },
|
||
];
|
||
|
||
/**
|
||
* Columns hidden by default for users who haven't customised their view.
|
||
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default —
|
||
* power-users can turn them back on via the column picker.
|
||
*/
|
||
export const INTEREST_DEFAULT_HIDDEN: string[] = ['desiredSize', 'eoiStatus'];
|
||
|
||
const EOI_STATUS_LABELS: Record<string, { label: string; tone: string }> = {
|
||
waiting_for_signatures: { label: 'Waiting', tone: 'bg-amber-100 text-amber-900' },
|
||
signed: { label: 'Signed', tone: 'bg-emerald-100 text-emerald-900' },
|
||
expired: { label: 'Expired', tone: 'bg-rose-100 text-rose-900' },
|
||
};
|
||
|
||
interface GetColumnsOptions {
|
||
portSlug: string;
|
||
onEdit: (interest: InterestRow) => void;
|
||
onArchive: (interest: InterestRow) => void;
|
||
}
|
||
|
||
export function getInterestColumns({
|
||
portSlug,
|
||
onEdit,
|
||
onArchive,
|
||
}: GetColumnsOptions): ColumnDef<InterestRow, unknown>[] {
|
||
return [
|
||
{
|
||
id: 'clientName',
|
||
accessorKey: 'clientName',
|
||
header: 'Client',
|
||
cell: ({ row }) => {
|
||
const notesCount = row.original.notesCount ?? 0;
|
||
return (
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<Link
|
||
href={`/${portSlug}/clients/${row.original.clientId}`}
|
||
className="truncate font-medium text-primary hover:underline"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{row.original.clientName ?? '-'}
|
||
</Link>
|
||
{notesCount > 0 ? (
|
||
<span
|
||
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||
className="inline-flex items-center text-muted-foreground"
|
||
>
|
||
<MessageSquare className="size-3.5" />
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'yachtName',
|
||
accessorKey: 'yachtName',
|
||
header: 'Yacht',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const name = row.original.yachtName;
|
||
if (!name) return <span className="text-muted-foreground">-</span>;
|
||
const yachtId = row.original.yachtId;
|
||
if (!yachtId) return <span className="truncate text-sm">{name}</span>;
|
||
return (
|
||
<Link
|
||
href={`/${portSlug}/yachts/${yachtId}`}
|
||
className="truncate text-primary hover:underline text-sm"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{name}
|
||
</Link>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'berthMooringNumber',
|
||
accessorKey: 'berthMooringNumber',
|
||
header: 'Berth',
|
||
cell: ({ row }) => {
|
||
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
||
return <span className="text-muted-foreground">-</span>;
|
||
}
|
||
return (
|
||
<Link
|
||
href={`/${portSlug}/berths/${row.original.berthId}`}
|
||
className="text-primary hover:underline text-sm"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{row.original.berthMooringNumber}
|
||
</Link>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'desiredSize',
|
||
header: 'Berth size desired',
|
||
enableSorting: false,
|
||
cell: ({ row }) => {
|
||
const label = formatDesiredSize(row.original);
|
||
if (!label) return <span className="text-muted-foreground">-</span>;
|
||
return <span className="text-sm tabular-nums">{label}</span>;
|
||
},
|
||
},
|
||
{
|
||
id: 'pipelineStage',
|
||
accessorKey: 'pipelineStage',
|
||
header: 'Stage',
|
||
cell: ({ row }) => {
|
||
const stage = row.original.pipelineStage;
|
||
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
|
||
return (
|
||
<Link
|
||
href={`/${portSlug}/interests/${row.original.id}`}
|
||
className="flex flex-col gap-1 items-start hover:opacity-80 transition-opacity"
|
||
>
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
||
>
|
||
{stageLabel(stage)}
|
||
</span>
|
||
{badges.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1">
|
||
{badges.map((b) => (
|
||
<span
|
||
key={b.id}
|
||
title={b.detail}
|
||
aria-label={b.detail}
|
||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
|
||
>
|
||
{b.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</Link>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'eoiStatus',
|
||
accessorKey: 'eoiStatus',
|
||
header: 'EOI status',
|
||
enableSorting: false,
|
||
cell: ({ getValue }) => {
|
||
const status = getValue() as string | null;
|
||
if (!status) return <span className="text-muted-foreground">-</span>;
|
||
const meta = EOI_STATUS_LABELS[status];
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||
meta?.tone ?? 'bg-muted text-muted-foreground'
|
||
}`}
|
||
>
|
||
{meta?.label ?? status}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'source',
|
||
accessorKey: 'source',
|
||
header: 'Source',
|
||
cell: ({ getValue }) => {
|
||
const source = getValue() as string | null;
|
||
if (!source) return <span className="text-muted-foreground">-</span>;
|
||
return (
|
||
<Badge variant="outline" className="text-xs">
|
||
{SOURCE_LABELS[source] ?? source}
|
||
</Badge>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
// Sales-triage default: prefer the explicit dateLastContact, fall back
|
||
// to updatedAt. Sortable on dateLastContact server-side; the column
|
||
// header label ("Last activity") makes the fallback semantics clear.
|
||
id: 'dateLastContact',
|
||
accessorKey: 'dateLastContact',
|
||
header: 'Last activity',
|
||
cell: ({ row }) => {
|
||
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||
if (!lastIso) {
|
||
return <span className="text-muted-foreground text-sm">-</span>;
|
||
}
|
||
const d = new Date(lastIso);
|
||
return (
|
||
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: 'actions',
|
||
header: '',
|
||
enableSorting: false,
|
||
size: 48,
|
||
cell: ({ row }) => (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||
Edit
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||
Archive
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
),
|
||
},
|
||
];
|
||
}
|