Files
pn-new-crm/src/components/interests/interest-columns.tsx
Matt 3e4d9d6310 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>
2026-05-07 20:59:28 +02:00

315 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 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>
),
},
];
}