From e9359fc431e6b681c1e4d112feba48945a2bbbda Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sun, 3 May 2026 16:14:37 +0200 Subject: [PATCH] feat(client): interests tab + pipeline summary panel + list-row counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/clients/client-columns.tsx | 2 + src/components/clients/client-detail.tsx | 2 + src/components/clients/client-form.tsx | 4 - .../clients/client-interests-tab.tsx | 460 ++++++++++++++++++ src/components/clients/client-tabs.tsx | 160 +++--- src/lib/services/clients.service.ts | 65 ++- 6 files changed, 601 insertions(+), 92 deletions(-) create mode 100644 src/components/clients/client-interests-tab.tsx diff --git a/src/components/clients/client-columns.tsx b/src/components/clients/client-columns.tsx index c8614a2..a93eeb3 100644 --- a/src/components/clients/client-columns.tsx +++ b/src/components/clients/client-columns.tsx @@ -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 }>; } diff --git a/src/components/clients/client-detail.tsx b/src/components/clients/client-detail.tsx index 2c6e77c..6c4b220 100644 --- a/src/components/clients/client-detail.tsx +++ b/src/components/clients/client-detail.tsx @@ -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; diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 5d665f9..5598231 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -339,10 +339,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) { -
- - -
= { + 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. + + ); +} + +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 ( +
  • + {done ? ( + + ) : ( + + )} + + {label} + + {date ?? hint} +
  • + ); +} + +/** + * 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(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 ( + { + if (!next) onClose(); + }} + > + + +
    +
    + {berthLabel} + {yachtLabel ? ( +

    {yachtLabel}

    + ) : null} +
    + {stage ? ( + + {STAGE_LABELS[stage]} + + ) : null} +
    +
    + +
    + {/* Pipeline-stepper segmented bar — the same primitive used on the + row card, so the at-a-glance progress hint is consistent + across surfaces. */} + {stage ? ( +
    +

    + Pipeline progress +

    + +
    + ) : 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". */} +
    +

    + Milestones +

    +
    +
    +

    EOI

    +
      + + +
    +
    +
    +

    Deposit

    +
      + +
    +
    +
    +

    Contract

    +
      + + +
    +
    +
    +
    + + {/* 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. */} +
    + {leadLabel ? ( + <> +
    Lead
    +
    {leadLabel}
    + + ) : null} + {sourceLabel ? ( + <> +
    Source
    +
    {sourceLabel}
    + + ) : null} + {lastContactDate ? ( + <> +
    Last contact
    +
    {lastContactDate}
    + + ) : null} + {activity ? ( + <> +
    Last activity
    +
    {activity}
    + + ) : null} +
    + + {notesPreview ? ( +
    +

    + Notes +

    +

    + {notesPreview} +

    +
    + ) : null} + + +
    +
    +
    + ); +} + +function InterestSkeleton() { + return ( +
    + + + +
    + ); +} + +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(null); + + const { data, isLoading, isError } = useClientInterests(clientId); + + if (isLoading) { + return ( +
    + + +
    + ); + } + + if (isError) { + return

    Could not load interests for this client.

    ; + } + + const interests = data?.data ?? []; + + if (interests.length === 0) { + return ( + <> + setCreateOpen(true), + }} + /> + + + ); + } + + const active = interests.filter((i) => !i.archivedAt); + const archived = interests.filter((i) => i.archivedAt); + + return ( +
    +
    + +
    + + {active.length > 0 ? ( +
    + {active.map((i) => ( + + ))} +
    + ) : null} + + {archived.length > 0 ? ( +
    +

    + Archived +

    +
    + {archived.map((i) => ( + + ))} +
    +
    + ) : null} + + setPreviewInterest(null)} + /> + + +
    + ); +} diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 8be018a..9764650 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -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,82 +133,82 @@ function OverviewTab({ }; return ( -
    - {/* Personal Info */} -
    -

    Personal Information

    -
    - - - - - { - await mutation.mutateAsync({ nationalityIso: iso }); - }} - data-testid="client-nationality-inline" - /> - - - - - - { - await mutation.mutateAsync({ timezone: tz }); - }} - data-testid="client-timezone-inline" - /> - - - - -
    +
    +
    +
    - {/* Contacts */} -
    -

    Contact Details

    - -
    +
    + {/* Personal Info */} +
    +

    Personal Information

    +
    + + + + + { + await mutation.mutateAsync({ nationalityIso: iso }); + }} + data-testid="client-nationality-inline" + /> + + + { + await mutation.mutateAsync({ timezone: tz }); + }} + data-testid="client-timezone-inline" + /> + + + + +
    +
    - {/* Source */} -
    -

    Source

    -
    - - - - - - -
    -
    + {/* Contacts */} +
    +

    Contact Details

    + +
    - {/* Tags */} -
    -

    Tags

    - + {/* Source */} +
    +

    Source

    +
    + + + + + + +
    +
    + + {/* Tags */} +
    +

    Tags

    + +
    ); @@ -219,6 +221,11 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt label: 'Overview', content: , }, + { + id: 'interests', + label: 'Interests', + content: , + }, { id: 'yachts', label: 'Yachts', @@ -251,15 +258,6 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt /> ), }, - { - id: 'interests', - label: 'Interests', - content: ( -
    -

    Interests will appear here once created.

    -
    - ), - }, { id: 'notes', label: 'Notes', diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index 9825983..7bfc5ac 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -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(); + 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) => ({ - ...row, - yachtCount: yachtCountMap.get(row.id) ?? 0, - companyCount: companyCountMap.get(row.id) ?? 0, - })), + 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, + }; + }), }; }