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:
2026-05-07 20:59:28 +02:00
parent 267c2b6d1f
commit 3e4d9d6310
87 changed files with 5593 additions and 902 deletions

View File

@@ -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>
);
}

View File

@@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
import { getClientTabs } from '@/components/clients/client-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
import type { Address } from '@/components/shared/addresses-editor';
@@ -91,6 +92,10 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
// Topbar breadcrumb hint: replaces "Clients <uuid>" with
// "Clients Mary Smith". Hint clears on unmount.
useBreadcrumbHint(data ? { parents: [], current: data.fullName } : null);
useRealtimeInvalidation({
'client:updated': [['clients', clientId]],
'client:archived': [['clients', clientId]],

View File

@@ -16,11 +16,12 @@ export const clientFilterDefinitions: FilterDefinition[] = [
{ label: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' },
{ label: 'Other', value: 'other' },
],
},
{
key: 'nationality',
label: 'Nationality',
label: 'Country',
type: 'text',
placeholder: 'Filter by nationality...',
},

View File

@@ -334,7 +334,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<Select
value={watch('source') ?? ''}
onValueChange={(v) =>
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker' | 'other')
}
>
<SelectTrigger>
@@ -345,6 +345,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
@@ -30,9 +31,16 @@ import {
import { ClientForm } from '@/components/clients/client-form';
import { clientFilterDefinitions } from '@/components/clients/client-filters';
import { ClientCard } from '@/components/clients/client-card';
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
import {
CLIENT_COLUMN_OPTIONS,
CLIENT_DEFAULT_HIDDEN,
getClientColumns,
type ClientRow,
} from '@/components/clients/client-columns';
import { ColumnPicker } from '@/components/shared/column-picker';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { apiFetch } from '@/lib/api/client';
export function ClientList() {
@@ -49,6 +57,7 @@ export function ClientList() {
const [tagChoice, setTagChoice] = useState<string[]>([]);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [bulkArchiveIds, setBulkArchiveIds] = useState<string[]>([]);
const [saveViewOpen, setSaveViewOpen] = useState(false);
const { can } = usePermissions();
const canHardDelete = can('admin', 'permanently_delete_clients');
@@ -119,6 +128,13 @@ export function ClientList() {
onArchive: (client) => setArchiveClient(client),
});
// Per-user column visibility, persisted into user_profiles.preferences
// via /api/v1/me. Hidden IDs are the source of truth — `actions` and
// `select` columns aren't user-toggleable so they're never in the
// hidden set. New columns surface for existing users by default.
const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return (
<div className="space-y-4">
<PageHeader
@@ -144,20 +160,33 @@ export function ClientList() {
/>
<SavedViewsDropdown
entityType="clients"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
<ColumnPicker
columns={CLIENT_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="clients"
currentFilters={filters}
currentSort={sort}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {

View File

@@ -6,6 +6,7 @@ import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries';
@@ -35,6 +36,7 @@ const SOURCE_OPTIONS = [
{ value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
];
const CONTACT_METHOD_OPTIONS = [
@@ -150,18 +152,36 @@ function OverviewTab({
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<EditableRow label="Country">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
// Auto-default the timezone to the country's primary
// zone when none is set yet — saves the rep a click
// and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a
// value the rep deliberately picked.
const patch: { nationalityIso: string | null; timezone?: string | null } = {
nationalityIso: iso,
};
if (iso && !client.timezone) {
const defaultTz = primaryTimezoneFor(iso as CountryCode);
if (defaultTz) patch.timezone = defaultTz;
}
await mutation.mutateAsync(patch);
}}
data-testid="client-nationality-inline"
data-testid="client-country-inline"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
value={
client.timezone ??
(client.nationalityIso
? primaryTimezoneFor(client.nationalityIso as CountryCode)
: null)
}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
@@ -267,7 +287,14 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
id: 'notes',
label: 'Notes',
badge: client.noteCount,
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
content: (
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
aggregate
/>
),
},
{
id: 'files',

View File

@@ -110,7 +110,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" />
GDPR export
</Button>

View File

@@ -69,6 +69,7 @@ export function PortalInviteButton({
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => {
reset();
setOpen(true);