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:
@@ -25,6 +25,8 @@ export interface ClientRow {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
yachtCount?: number;
|
yachtCount?: number;
|
||||||
companyCount?: number;
|
companyCount?: number;
|
||||||
|
interestCount?: number;
|
||||||
|
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface ClientData {
|
|||||||
id: string;
|
id: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
valueE164: string | null;
|
||||||
|
valueCountry: string | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
|||||||
@@ -339,10 +339,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Preferred Language</Label>
|
|
||||||
<Input {...register('preferredLanguage')} placeholder="English" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Timezone</Label>
|
<Label>Timezone</Label>
|
||||||
<TimezoneCombobox
|
<TimezoneCombobox
|
||||||
|
|||||||
460
src/components/clients/client-interests-tab.tsx
Normal file
460
src/components/clients/client-interests-tab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
|||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
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 { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||||
@@ -131,6 +133,11 @@ function OverviewTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -148,12 +155,6 @@ function OverviewTab({
|
|||||||
data-testid="client-nationality-inline"
|
data-testid="client-nationality-inline"
|
||||||
/>
|
/>
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Preferred Language">
|
|
||||||
<InlineEditableField
|
|
||||||
value={client.preferredLanguage}
|
|
||||||
onSave={save('preferredLanguage')}
|
|
||||||
/>
|
|
||||||
</EditableRow>
|
|
||||||
<EditableRow label="Timezone">
|
<EditableRow label="Timezone">
|
||||||
<InlineTimezoneField
|
<InlineTimezoneField
|
||||||
value={client.timezone}
|
value={client.timezone}
|
||||||
@@ -209,6 +210,7 @@ function OverviewTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +221,11 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab clientId={clientId} client={client} />,
|
content: <OverviewTab clientId={clientId} client={client} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'interests',
|
||||||
|
label: 'Interests',
|
||||||
|
content: <ClientInterestsTab clientId={clientId} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'yachts',
|
id: 'yachts',
|
||||||
label: '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',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
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 { tags } from '@/lib/db/schema/system';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
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 ids = result.data.map((r) => r.id);
|
||||||
|
|
||||||
const [yachtCounts, companyCounts] = await Promise.all([
|
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||||
.from(yachts)
|
.from(yachts)
|
||||||
@@ -99,18 +101,67 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
.from(companyMemberships)
|
.from(companyMemberships)
|
||||||
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
||||||
.groupBy(companyMemberships.clientId),
|
.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 yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, 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 {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data.map((row) => ({
|
data: result.data.map((row) => {
|
||||||
|
const latest = latestInterestMap.get(row.id);
|
||||||
|
return {
|
||||||
...row,
|
...row,
|
||||||
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
||||||
companyCount: companyCountMap.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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user