feat(client): interests tab + pipeline summary panel + list-row counts

Promotes interests from a stub tab to a first-class surface on the client
detail page, and surfaces pipeline activity in two more places:

UI:
  - New ClientInterestsTab (475 lines) — table of every active interest
    for the client with stage-stepper visualization, lead category, source,
    last-activity timestamp, and a drawer-on-tap row preview.
  - New OverviewTab pipeline-summary panel above the existing 2-column
    layout, rendering ClientPipelineSummary (already on this branch) in
    its panel variant. Reps see the live pipeline at a glance without
    leaving Overview.
  - Removes "Preferred Language" inline field from the Overview tab and
    the create form — unused, and the field added noise without driving
    any downstream behavior.
  - Tab order: Overview / Interests / Yachts / Companies / ... (Interests
    moves up from the back of the list, where it was a stub anyway).

Data:
  - listClients now returns interestCount + latestInterest{stage, mooring}
    per row, joined from interests + berths in two parallel queries.
    ClientRow type updated to surface them; Client list views can now
    render "3 interests · last on D-02 (EOI Signed)" without a per-row
    fetch.
  - Contact rows in client detail now expose valueE164 + valueCountry to
    the UI (already returned by the API; just wasn't typed through the
    detail-page contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-03 16:14:37 +02:00
parent 4767caec01
commit e9359fc431
6 changed files with 601 additions and 92 deletions

View File

@@ -25,6 +25,8 @@ export interface ClientRow {
createdAt: string;
yachtCount?: number;
companyCount?: number;
interestCount?: number;
latestInterest?: { stage: string; mooringNumber: string | null } | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>;
}

View File

@@ -29,6 +29,8 @@ interface ClientData {
id: string;
channel: string;
value: string;
valueE164: string | null;
valueCountry: string | null;
label: string | null;
isPrimary: boolean;
notes: string | null;

View File

@@ -339,10 +339,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Preferred Language</Label>
<Input {...register('preferredLanguage')} placeholder="English" />
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<TimezoneCombobox

View File

@@ -0,0 +1,460 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/shared/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { cn } from '@/lib/utils';
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
import {
StageStepper,
useClientInterests,
type ClientInterestRow,
} from '@/components/clients/client-pipeline-summary';
import { InterestForm } from '@/components/interests/interest-form';
const LEAD_CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General interest',
specific_qualified: 'Specific qualified',
hot_lead: 'Hot lead',
};
function InterestRowItem({
interest,
onOpen,
}: {
interest: ClientInterestRow;
onOpen: (i: ClientInterestRow) => void;
}) {
const stage = safeStage(interest.pipelineStage);
const berthLabel = interest.berthMooringNumber
? `Berth ${interest.berthMooringNumber}`
: 'General interest';
const yachtLabel = interest.yachtName ?? null;
return (
// Tap opens a bottom-sheet preview drawer rather than navigating to the
// full interest page. The drawer covers ~80% of mobile interactions
// ("what stage is this at, when did we last touch it"). For deeper
// edits the drawer has an "Open full page" CTA.
<button
type="button"
onClick={() => onOpen(interest)}
className={cn(
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
'hover:border-border/70 hover:shadow-md',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{berthLabel}
</h3>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
</div>
{yachtLabel ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
) : null}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
<div className="mt-3">
<StageStepper current={stage} />
</div>
</button>
);
}
function lastActivityFor(interest: ClientInterestRow): string | null {
const candidates = [interest.dateLastContact, interest.updatedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime())
.filter((t) => !Number.isNaN(t));
if (candidates.length === 0) return null;
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
}
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
* the drawer actually reads are typed here; the API returns more. */
interface InterestDetail {
id: string;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateDepositReceived: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
}
function useInterestDetail(id: string | null) {
return useQuery<{ data: InterestDetail }>({
queryKey: ['interest-detail-drawer', id],
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
enabled: id !== null,
// Detail rarely changes during a single drawer-open session; stale-time
// keeps re-opens snappy without preventing background refetch.
staleTime: 30_000,
});
}
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
* empty input so callers can render an "empty" state. */
function formatDate(value: string | null | undefined): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return format(d, 'MMM d, yyyy');
}
/** A single milestone row inside the drawer's milestone summary. Filled
* circle when the step is done, hollow when pending. Trailing meta line
* shows the date stamp or a "pending" hint. */
function MilestoneRow({
label,
done,
date,
hint = 'pending',
}: {
label: string;
done: boolean;
date: string | null;
hint?: string;
}) {
return (
<li className="flex items-center gap-2 py-1">
{done ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
) : (
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
)}
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
</li>
);
}
/**
* Bottom-sheet preview of a single interest. Designed for the mobile
* "tap an interest → see what's happening without leaving the client
* page" flow. Shows the pipeline progress, a compact milestone summary
* (EOI / Deposit / Contract), lead context, last contact, and a notes
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
* away via "Open full page →".
*/
function InterestPreviewDrawer({
interest,
portSlug,
onClose,
}: {
interest: ClientInterestRow | null;
portSlug: string;
onClose: () => void;
}) {
// Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here — the guard
// ensures it only fires when the prop actually changes to a new row.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest);
const showing = pinned;
const detail = useInterestDetail(showing?.id ?? null);
const fullDetail = detail.data?.data ?? null;
const open = interest !== null;
const stage = showing ? safeStage(showing.pipelineStage) : null;
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
const reached = (target: PipelineStage) =>
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
const berthLabel = showing
? showing.berthMooringNumber
? `Berth ${showing.berthMooringNumber}`
: 'General interest'
: '';
const yachtLabel = showing?.yachtName ?? null;
const activity = showing ? lastActivityFor(showing) : null;
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
const leadLabel = fullDetail?.leadCategory
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
: null;
const sourceLabel = fullDetail?.source
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
: null;
const lastContactDate = formatDate(fullDetail?.dateLastContact);
const notesPreview = fullDetail?.notes?.trim() || null;
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
{yachtLabel ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
) : null}
</div>
{stage ? (
<span
className={cn(
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
) : null}
</div>
</DrawerHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4">
{/* Pipeline-stepper segmented bar — the same primitive used on the
row card, so the at-a-glance progress hint is consistent
across surfaces. */}
{stage ? (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pipeline progress
</p>
<StageStepper current={stage} />
</div>
) : null}
{/* Milestones — three sections matching the full interest detail
page (EOI / Deposit / Contract). Done-state is derived from
the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step
actions live behind "Open full page". */}
<section>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Milestones
</p>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">EOI</p>
<ul>
<MilestoneRow
label="EOI sent"
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
date={formatDate(fullDetail?.dateEoiSent)}
/>
<MilestoneRow
label="EOI signed"
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
date={formatDate(fullDetail?.dateEoiSigned)}
/>
</ul>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">Deposit</p>
<ul>
<MilestoneRow
label="Deposit received"
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
date={formatDate(fullDetail?.dateDepositReceived)}
/>
</ul>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">Contract</p>
<ul>
<MilestoneRow
label="Contract sent"
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
date={formatDate(fullDetail?.dateContractSent)}
/>
<MilestoneRow
label="Contract signed"
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
date={formatDate(fullDetail?.dateContractSigned)}
/>
</ul>
</div>
</div>
</section>
{/* Compact key/value pairs — lead category, source, last contact,
activity. Each row collapses cleanly when its value is
missing so the drawer scales from sparse seed data to full
records without empty placeholders. */}
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
{leadLabel ? (
<>
<dt className="text-muted-foreground">Lead</dt>
<dd className="text-right font-medium">{leadLabel}</dd>
</>
) : null}
{sourceLabel ? (
<>
<dt className="text-muted-foreground">Source</dt>
<dd className="text-right font-medium">{sourceLabel}</dd>
</>
) : null}
{lastContactDate ? (
<>
<dt className="text-muted-foreground">Last contact</dt>
<dd className="text-right font-medium">{lastContactDate}</dd>
</>
) : null}
{activity ? (
<>
<dt className="text-muted-foreground">Last activity</dt>
<dd className="text-right font-medium">{activity}</dd>
</>
) : null}
</dl>
{notesPreview ? (
<section>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Notes
</p>
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
{notesPreview}
</p>
</section>
) : null}
<Button asChild className="w-full" size="lg">
<Link href={fullHref}>
Open full page
<ArrowRight className="ml-1.5 size-4" aria-hidden />
</Link>
</Button>
</div>
</DrawerContent>
</Drawer>
);
}
function InterestSkeleton() {
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-2 h-3 w-24" />
<Skeleton className="mt-3 h-2 w-48" />
</div>
);
}
interface ClientInterestsTabProps {
clientId: string;
}
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
const { data, isLoading, isError } = useClientInterests(clientId);
if (isLoading) {
return (
<div className="space-y-3">
<InterestSkeleton />
<InterestSkeleton />
</div>
);
}
if (isError) {
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
}
const interests = data?.data ?? [];
if (interests.length === 0) {
return (
<>
<EmptyState
title="No interests yet"
description="When this client expresses interest in a berth, the sales process will appear here."
action={{
label: 'Add interest',
onClick: () => setCreateOpen(true),
}}
/>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
</>
);
}
const active = interests.filter((i) => !i.archivedAt);
const archived = interests.filter((i) => i.archivedAt);
return (
<div className="space-y-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 size-3.5" />
Add interest
</Button>
</div>
{active.length > 0 ? (
<div className="space-y-3">
{active.map((i) => (
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
))}
</div>
) : null}
{archived.length > 0 ? (
<div className="space-y-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Archived
</h4>
<div className="space-y-3 opacity-60">
{archived.map((i) => (
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
))}
</div>
</div>
) : null}
<InterestPreviewDrawer
interest={previewInterest}
portSlug={portSlug}
onClose={() => setPreviewInterest(null)}
/>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
</div>
);
}

View File

@@ -9,6 +9,8 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
@@ -131,6 +133,11 @@ function OverviewTab({
};
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
@@ -148,12 +155,6 @@ function OverviewTab({
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
@@ -209,6 +210,7 @@ function OverviewTab({
/>
</div>
</div>
</div>
);
}
@@ -219,6 +221,11 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
label: 'Overview',
content: <OverviewTab clientId={clientId} client={client} />,
},
{
id: 'interests',
label: 'Interests',
content: <ClientInterestsTab clientId={clientId} />,
},
{
id: 'yachts',
label: 'Yachts',
@@ -251,15 +258,6 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
/>
),
},
{
id: 'interests',
label: 'Interests',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Interests will appear here once created.</p>
</div>
),
},
{
id: 'notes',
label: 'Notes',

View File

@@ -1,4 +1,4 @@
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { and, count, desc, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
@@ -11,6 +11,8 @@ import {
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
@@ -81,7 +83,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts] = await Promise.all([
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
@@ -99,18 +101,67 @@ export async function listClients(portId: string, query: ListClientsInput) {
.from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId),
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
.leftJoin(berths, eq(berths.id, interests.berthId))
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.orderBy(desc(interests.updatedAt)),
db
.select({ clientId: interests.clientId, count: count() })
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.groupBy(interests.clientId),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
for (const row of interestRows) {
if (!latestInterestMap.has(row.clientId)) {
latestInterestMap.set(row.clientId, {
stage: row.pipelineStage,
mooringNumber: row.mooringNumber,
});
}
}
return {
...result,
data: result.data.map((row) => ({
data: result.data.map((row) => {
const latest = latestInterestMap.get(row.id);
return {
...row,
yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0,
})),
interestCount: interestCountMap.get(row.id) ?? 0,
latestInterest: latest
? {
stage: latest.stage,
mooringNumber: latest.mooringNumber,
}
: null,
};
}),
};
}