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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
||||
import { MoreHorizontal, Pencil, Archive, Mail, MessageCircle, Phone } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { stageDotClass, stageLabel } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ColumnPickerOption } from '@/components/shared/column-picker';
|
||||
|
||||
export interface ClientRow {
|
||||
id: string;
|
||||
@@ -24,24 +28,58 @@ export interface ClientRow {
|
||||
createdAt: string;
|
||||
primaryEmail?: string | null;
|
||||
primaryPhone?: string | null;
|
||||
/** E.164 (digits + leading +) — used to build wa.me / tel: links. */
|
||||
primaryPhoneE164?: string | null;
|
||||
yachtCount?: number;
|
||||
companyCount?: number;
|
||||
interestCount?: number;
|
||||
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||
/**
|
||||
* Berths the client has interests in (active only) with the most-active
|
||||
* interest's stage attached. Sorted server-side: open deals first, most
|
||||
* progressed stage first, then mooring alphabetical. Each chip in the
|
||||
* list view links to the interest, not the berth — that's the action
|
||||
* sales reps want.
|
||||
*/
|
||||
linkedBerths?: Array<{
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
interestId: string;
|
||||
stage: string;
|
||||
outcome: string | null;
|
||||
}>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
qualified: 'Qualified',
|
||||
eoi_sent: 'EOI sent',
|
||||
eoi_signed: 'EOI signed',
|
||||
deposit: 'Deposit',
|
||||
contract: 'Contract',
|
||||
signed: 'Signed',
|
||||
closed_won: 'Won',
|
||||
closed_lost: 'Lost',
|
||||
};
|
||||
/**
|
||||
* Picker manifest — drives the `<ColumnPicker>` dropdown next to the
|
||||
* filter bar. Order here is the order shown in the menu. `alwaysVisible`
|
||||
* marks columns the user can't hide (otherwise the table is unusable).
|
||||
*
|
||||
* "Latest stage" used to be a default-on column, but each Berths chip
|
||||
* now carries its own per-interest stage (color dot + label), so the
|
||||
* standalone column was duplicating the same information. Kept in the
|
||||
* picker for users who want a single coarse "what's their most recent
|
||||
* stage" indicator regardless of berth.
|
||||
*/
|
||||
export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
|
||||
{ id: 'fullName', label: 'Name', alwaysVisible: true },
|
||||
{ id: 'email', label: 'Email' },
|
||||
{ id: 'phone', label: 'Phone' },
|
||||
{ id: 'country', label: 'Country' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
{ id: 'berths', label: 'Berths' },
|
||||
{ id: 'latestStage', label: 'Latest stage (legacy)' },
|
||||
{ id: 'createdAt', label: 'Created' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default-hidden columns for a fresh user. The hook merges this with
|
||||
* the user's saved overrides — once they explicitly toggle a column,
|
||||
* their choice wins. New columns surface for existing users by default
|
||||
* (they're absent from the user's stored hidden list).
|
||||
*/
|
||||
export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage'];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
@@ -83,7 +121,17 @@ export function getClientColumns({
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.primaryEmail;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{value}</span>;
|
||||
return (
|
||||
<a
|
||||
href={`mailto:${value}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline"
|
||||
title={`Email ${value}`}
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{value}</span>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,8 +140,38 @@ export function getClientColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.primaryPhone;
|
||||
const e164 = row.original.primaryPhoneE164;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{value}</span>;
|
||||
// wa.me requires the E.164 digits without the leading +; fall
|
||||
// back to a tel: link when the contact hasn't been normalized
|
||||
// yet (legacy rows imported before the i18n PhoneInput shipped).
|
||||
const waDigits = e164 ? e164.replace(/[^0-9]/g, '') : null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<a
|
||||
href={e164 ? `tel:${e164}` : `tel:${value}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline"
|
||||
title={`Call ${value}`}
|
||||
>
|
||||
<Phone className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span>{value}</span>
|
||||
</a>
|
||||
{waDigits && (
|
||||
<a
|
||||
href={`https://wa.me/${waDigits}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-emerald-600 hover:text-emerald-700"
|
||||
title={`WhatsApp ${value}`}
|
||||
aria-label={`WhatsApp ${value}`}
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -122,22 +200,88 @@ export function getClientColumns({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'berths',
|
||||
header: 'Berths',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const list = row.original.linkedBerths ?? [];
|
||||
if (list.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
// Show the 2 most-actionable interests inline (sorted server-
|
||||
// side: open before closed, most-progressed stage first). The
|
||||
// remainder collapses behind a "+N" popover so the row stays
|
||||
// single-line even for clients with many historical interests.
|
||||
const VISIBLE = 2;
|
||||
const head = list.slice(0, VISIBLE);
|
||||
const overflow = list.slice(VISIBLE);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{head.map((b) => (
|
||||
<BerthInterestChip key={b.id} berth={b} portSlug={portSlug} />
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
+{overflow.length}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
All linked berths
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{list.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/${portSlug}/interests/${b.interestId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
b.outcome ? 'bg-muted-foreground/40' : stageDotClass(b.stage),
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-foreground">{b.mooringNumber}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{b.outcome
|
||||
? `${stageLabel(b.stage)} · ${b.outcome.replace(/_/g, ' ')}`
|
||||
: stageLabel(b.stage)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hidden by default — the per-berth stage is now carried by each
|
||||
// chip in the Berths column, so this standalone column is only
|
||||
// useful when a user has explicitly toggled it on.
|
||||
id: 'latestStage',
|
||||
header: 'Latest stage',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const latest = row.original.latestInterest;
|
||||
if (!latest) return <span className="text-muted-foreground">-</span>;
|
||||
const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{stageLabel}
|
||||
</Badge>
|
||||
{latest.mooringNumber && (
|
||||
<span className="text-muted-foreground">{latest.mooringNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{stageLabel(latest.stage)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -183,3 +327,50 @@ export function getClientColumns({
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single berth-with-stage chip used in the inline (top-2) chip row of
|
||||
* the Berths column. Shows mooring + full stage label, with a colored
|
||||
* dot for stage reinforcement (decorative — the label carries the
|
||||
* meaning so color-blind / no-hover users don't lose anything).
|
||||
*
|
||||
* Click target is the *interest*, not the berth — the user almost
|
||||
* always wants to act on the deal, not look at the berth's static
|
||||
* specs. Outcome-set rows (won/lost/cancelled) get a muted dot so they
|
||||
* read as historical context rather than active work.
|
||||
*/
|
||||
function BerthInterestChip({
|
||||
berth,
|
||||
portSlug,
|
||||
}: {
|
||||
berth: NonNullable<ClientRow['linkedBerths']>[number];
|
||||
portSlug: string;
|
||||
}) {
|
||||
const isClosed = berth.outcome !== null;
|
||||
const label = isClosed
|
||||
? `${stageLabel(berth.stage)} · ${berth.outcome!.replace(/_/g, ' ')}`
|
||||
: stageLabel(berth.stage);
|
||||
return (
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${berth.interestId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={`Open interest · ${berth.mooringNumber} · ${label}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs transition-colors',
|
||||
'border-border bg-background hover:bg-accent',
|
||||
isClosed && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
isClosed ? 'bg-muted-foreground/40' : stageDotClass(berth.stage),
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-foreground">{berth.mooringNumber}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user