From 76a7387dccd0fe3a2cb7b27113d842e07c5d45bd Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 01:24:15 +0200 Subject: [PATCH] fix(ux): batch UX audit fixes across spine pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive audit findings rolled up into one pass. Bugs: - dialog.tsx — sm-breakpoint centering classes (sm:left-[50%] / sm:top-[50%]) were being silently stripped by tailwind-merge because the base inset-0 + sm:inset-auto pair counted as a conflict. Replaced with explicit per-side utilities (top-0 right-0 bottom-0 left-0 + sm:right-auto sm:bottom-auto). Every Dialog instance now centers correctly on desktop. (Affected 16 dialog consumers.) - interest-documents-tab.tsx — useQuery shared the queryKey ['interests', interestId] with the parent InterestDetail's query but returned a different shape ({ data: ... } envelope vs unwrapped). They clobbered each other's cache on tab mount, degenerating the parent header to "Unknown Client" / "Open" briefly. Unified the queryFn shape so the cache stays consistent. - interest-tabs.tsx — milestone steps now derive done-state from PIPELINE_STAGES.indexOf(currentStage) >= step.advanceStage_idx as well as from the date stamp. Stage truth > date truth. Seeded / imported interests that arrived past `open` without per-step dates now correctly show their milestone steps as checked. - interest-detail.tsx — wires useMobileChrome so the mobile topbar shows the client name instead of the interest UUID. - interest-documents-tab.tsx — empty state restructured to a centered "No documents yet — Generate EOI" CTA card instead of a small primary button floating in the corner. - timeline/route.ts — synthesizes a "Created at " event when no audit-log rows exist for the interest, so the Activity tab isn't empty for seeded interests. - lead-source-chart.tsx — pie radii switched from fixed 90px/50px to "70%"/"40%" so the pie scales with the container instead of being clipped at narrow widths; reserved 40px for the legend. Visual / clarity: - interest-detail-header.tsx — Won/Lost rendered as branded text buttons on desktop ("Mark won", "Close as lost") and icon-only on mobile via `hidden sm:inline`. Edit/Archive stay icon-only. Reopen promoted to a labeled button when the interest is closed. Added "Last contact Xd ago" to the meta row. - detail-header-strip.tsx — py-4 → py-3 (tighter strip). - interest-tabs.tsx — milestone cards: the next pending milestone gets a brand-blue ring + "NEXT" pill so the user can see at a glance which lifecycle to act on. Its primary action gets the filled button variant. - interest-tabs.tsx — Deposit milestone: invoice flow promoted to primary CTA ("Create deposit invoice"), manual stage advance demoted to a small text link ("Mark received manually"). Reflects the actual recommended path now that recordPayment auto-advances on payment. - inline-editable-field.tsx — pencil affordance shown faintly (opacity-20) at rest so users discover that fields are editable without having to hover-test every label. Lifts to opacity-60 on hover. - constants.ts — STAGE_SHORT_LABELS map for cramped contexts; pipeline-chart.tsx + pipeline-funnel-chart.tsx use them on mobile via useIsMobile, so the rotated 9-stage axis isn't a wall of overlap on a 393px screen. - client-pipeline-summary.tsx — StageStepper rebuilt as a single segmented progress bar instead of 9 micro-dots + connectors that rendered inconsistently at tight widths. Each stage is an equal slice that lights up as the interest reaches it; tooltips on hover give the full stage name. Also dropped a pre-existing dead `br` variable. - dashboard empty states — Lead Source, Revenue Breakdown, Pipeline Funnel, and Recent Activity now have helpful descriptions explaining what populates them, instead of bare "No interests in range". - use-paginated-query.ts — reuses `&` when the endpoint already has `?`, so callers like the documents hub don't generate `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25` (which the API rejected as 400). Caught while testing the now-removed EOI route but applies broadly. tsc clean. vitest 832/832 pass. eslint 0 errors (down from 1 pre-existing) on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/v1/interests/[id]/timeline/route.ts | 21 ++ .../clients/client-pipeline-summary.tsx | 311 ++++++++++++++++++ src/components/dashboard/activity-feed.tsx | 5 +- .../dashboard/lead-source-chart.tsx | 22 +- src/components/dashboard/pipeline-chart.tsx | 6 +- .../dashboard/pipeline-funnel-chart.tsx | 12 +- .../dashboard/revenue-breakdown-chart.tsx | 5 +- src/components/documents/document-list.tsx | 10 +- .../interests/interest-detail-header.tsx | 55 +++- src/components/interests/interest-detail.tsx | 23 +- .../interests/interest-documents-tab.tsx | 33 +- src/components/interests/interest-tabs.tsx | 102 +++++- src/components/shared/detail-header-strip.tsx | 2 +- .../shared/inline-editable-field.tsx | 4 +- src/components/ui/dialog.tsx | 10 +- src/lib/constants.ts | 13 + 16 files changed, 568 insertions(+), 66 deletions(-) create mode 100644 src/components/clients/client-pipeline-summary.tsx diff --git a/src/app/api/v1/interests/[id]/timeline/route.ts b/src/app/api/v1/interests/[id]/timeline/route.ts index 7020065..cbb8cb6 100644 --- a/src/app/api/v1/interests/[id]/timeline/route.ts +++ b/src/app/api/v1/interests/[id]/timeline/route.ts @@ -112,6 +112,27 @@ export const GET = withAuth( }); const allEvents = [...auditEvents, ...docEvents]; + + // Fallback: when no audit-log entries exist for this interest (typical + // for seed/imported data inserted directly into the table without going + // through the service), synthesize a "Created at " event so the + // tab isn't empty when the interest is clearly past `open`. + const hasCreateAudit = allEvents.some((e) => e.action === 'create'); + if (!hasCreateAudit) { + const stage = stageLabel(interest.pipelineStage); + const created = interest.createdAt ?? new Date(); + allEvents.push({ + id: `synth-${interest.id}-create`, + type: 'audit', + action: 'create', + description: + interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`, + userId: null, + createdAt: created, + metadata: { synthetic: true }, + }); + } + allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return NextResponse.json({ data: allEvents.slice(0, 50) }); diff --git a/src/components/clients/client-pipeline-summary.tsx b/src/components/clients/client-pipeline-summary.tsx new file mode 100644 index 0000000..3297d1b --- /dev/null +++ b/src/components/clients/client-pipeline-summary.tsx @@ -0,0 +1,311 @@ +'use client'; + +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; +import type { Route } from 'next'; +import { useQuery } from '@tanstack/react-query'; +import { ArrowRight, ChevronRight } from 'lucide-react'; +import { formatDistanceToNowStrict } from 'date-fns'; + +import { apiFetch } from '@/lib/api/client'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; +import { + PIPELINE_STAGES, + STAGE_BADGE, + STAGE_DOT, + STAGE_LABELS, + safeStage, + type PipelineStage, +} from '@/components/clients/pipeline-constants'; + +export interface ClientInterestRow { + id: string; + pipelineStage: string; + archivedAt: string | null; + updatedAt: string; + dateLastContact: string | null; + berthMooringNumber?: string | null; + yachtName?: string | null; +} + +interface InterestsResponse { + data: ClientInterestRow[]; +} + +export function useClientInterests(clientId: string) { + return useQuery({ + queryKey: ['interests', { clientId }], + queryFn: () => apiFetch(`/api/v1/interests?clientId=${clientId}&limit=50`), + }); +} + +export function StageStepper({ + current, + size = 'sm', +}: { + current: PipelineStage; + size?: 'xs' | 'sm'; +}) { + const idx = PIPELINE_STAGES.indexOf(current); + // Segmented progress bar: each stage is a slice of equal width that + // lights up once the interest has reached it. Reads at-a-glance, scales + // to any container width, and works with 9 stages without becoming + // micro-dots that vanish under cramped layouts. + const height = size === 'xs' ? 'h-1' : 'h-1.5'; + return ( +
+ {PIPELINE_STAGES.map((stage, i) => { + const isReached = i <= idx; + const isCurrent = i === idx; + return ( +
0 ? 'border-l border-card' : '', + )} + /> + ); + })} +
+ ); +} + +function pickHighest(interests: ClientInterestRow[]): ClientInterestRow | null { + const active = interests.filter((i) => !i.archivedAt); + if (active.length === 0) return null; + return [...active].sort((a, b) => { + const ai = PIPELINE_STAGES.indexOf(safeStage(a.pipelineStage)); + const bi = PIPELINE_STAGES.indexOf(safeStage(b.pipelineStage)); + if (ai !== bi) return bi - ai; + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + })[0]!; +} + +function lastActivityLabel(interests: ClientInterestRow[]): string | null { + const candidates = interests + .flatMap((i) => [i.dateLastContact, i.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; + const latest = new Date(Math.max(...candidates)); + return `${formatDistanceToNowStrict(latest)} ago`; +} + +interface PipelineSummaryProps { + clientId: string; + /** + * `hero` — single-line pulse for the detail header (highest active stage only). + * `panel` — compact list of every active interest, for the Overview tab. + */ + variant?: 'hero' | 'panel'; +} + +function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) { + const pathname = usePathname(); + const { data, isLoading } = useClientInterests(clientId); + const interests = data?.data ?? []; + const top = pickHighest(interests); + const activeCount = interests.filter((i) => !i.archivedAt).length; + const activity = lastActivityLabel(interests); + const interestsTabHref = `${pathname}?tab=interests` as Route; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!top) { + return ( +
+
+

No active interests

+

+ Start one to begin tracking the sales process. +

+
+ + Start interest + +
+ ); + } + + const stage = safeStage(top.pipelineStage); + const berthLabel = top.berthMooringNumber + ? `Berth ${top.berthMooringNumber}` + : 'General interest'; + const detailsHref = `/${portSlug}/interests/${top.id}` as Route; + + return ( +
+
+ + Sales pipeline + + {activeCount > 1 ? ( + + · {activeCount} active + + ) : null} +
+ + +
+ {berthLabel} + + {STAGE_LABELS[stage]} + + +
+
+ +
+ + +
+ {activity ? `Last activity ${activity}` : 'No activity recorded'} + {activeCount > 1 ? ( + + View all {activeCount} + + ) : null} +
+
+ ); +} + +function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) { + const pathname = usePathname(); + const { data, isLoading } = useClientInterests(clientId); + const interests = (data?.data ?? []).filter((i) => !i.archivedAt); + const interestsTabHref = `${pathname}?tab=interests` as Route; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (interests.length === 0) { + return ( +
+
+

No active interests

+

+ Start one to begin tracking the sales process. +

+
+ + Start interest + +
+ ); + } + + const sorted = [...interests].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + return ( +
+
+ + Sales pipeline · {interests.length} active + + + Manage + +
+ +
    + {sorted.map((i) => { + const stage = safeStage(i.pipelineStage); + const berthLabel = i.berthMooringNumber + ? `Berth ${i.berthMooringNumber}` + : 'General interest'; + const href = `/${portSlug}/interests/${i.id}` as Route; + return ( +
  • + +
    +
    + + {berthLabel} + + + {STAGE_LABELS[stage]} + +
    +
    + +
    +
    + + +
  • + ); + })} +
+
+ ); +} + +export function ClientPipelineSummary({ clientId, variant = 'panel' }: PipelineSummaryProps) { + const routeParams = useParams<{ portSlug: string }>(); + const portSlug = routeParams?.portSlug ?? ''; + + return variant === 'hero' ? ( + + ) : ( + + ); +} diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx index 0cecc71..1be7492 100644 --- a/src/components/dashboard/activity-feed.tsx +++ b/src/components/dashboard/activity-feed.tsx @@ -57,7 +57,10 @@ function ActivityFeedInner() { {items.length === 0 ? ( -

No recent activity.

+

+ No recent activity yet — your team's actions (interests created, stages changed, + invoices sent) will appear here. +

) : (
{items.map((item) => ( diff --git a/src/components/dashboard/lead-source-chart.tsx b/src/components/dashboard/lead-source-chart.tsx index c04d64d..7247aa9 100644 --- a/src/components/dashboard/lead-source-chart.tsx +++ b/src/components/dashboard/lead-source-chart.tsx @@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) { {isLoading ? ( ) : !slices.length ? ( - + ) : ( - + // Percentage radii + center-anchored chart so the pie scales with + // the container instead of being clipped to a constant 90px ring at + // narrow widths. Legend is reserved a fixed footer height. + {chartData.map((_, i) => ( @@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) { fontSize: 12, }} /> - + )} diff --git a/src/components/dashboard/pipeline-chart.tsx b/src/components/dashboard/pipeline-chart.tsx index 9f718f8..57d4fb5 100644 --- a/src/components/dashboard/pipeline-chart.tsx +++ b/src/components/dashboard/pipeline-chart.tsx @@ -6,7 +6,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi import { apiFetch } from '@/lib/api/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { CardSkeleton } from '@/components/shared/loading-skeleton'; -import { stageLabel } from '@/lib/constants'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants'; import { WidgetErrorBoundary } from './widget-error-boundary'; interface PipelineRow { @@ -15,6 +16,7 @@ interface PipelineRow { } function PipelineChartInner() { + const isMobile = useIsMobile(); const { data, isLoading } = useQuery({ queryKey: ['dashboard', 'pipeline'], queryFn: () => apiFetch('/api/v1/dashboard/pipeline'), @@ -27,7 +29,7 @@ function PipelineChartInner() { } const chartData = (data ?? []).map((row) => ({ - stage: stageLabel(row.stage), + stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage), count: row.count, })); diff --git a/src/components/dashboard/pipeline-funnel-chart.tsx b/src/components/dashboard/pipeline-funnel-chart.tsx index 0dabca7..ed2c9ae 100644 --- a/src/components/dashboard/pipeline-funnel-chart.tsx +++ b/src/components/dashboard/pipeline-funnel-chart.tsx @@ -4,7 +4,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { EmptyState } from '@/components/shared/empty-state'; -import { stageLabel } from '@/lib/constants'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants'; import { ChartCard } from './chart-card'; import { useFunnel } from './use-analytics'; import type { DateRange } from '@/lib/services/analytics.service'; @@ -15,10 +16,12 @@ interface Props { export function PipelineFunnelChart({ range }: Props) { const { data, isLoading } = useFunnel(range); + const isMobile = useIsMobile(); const stages = data?.stages ?? []; + // Use short labels on mobile so the rotated axis isn't a wall of overlap. const chartData = stages.map((s) => ({ - stage: stageLabel(s.stage), + stage: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage), count: s.count, conversionPct: s.conversionPct, })); @@ -41,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) { {isLoading ? ( ) : allZero ? ( - + ) : ( diff --git a/src/components/dashboard/revenue-breakdown-chart.tsx b/src/components/dashboard/revenue-breakdown-chart.tsx index 0d7759b..d04aec1 100644 --- a/src/components/dashboard/revenue-breakdown-chart.tsx +++ b/src/components/dashboard/revenue-breakdown-chart.tsx @@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) { {isLoading ? ( ) : !bars.length ? ( - + ) : ( diff --git a/src/components/documents/document-list.tsx b/src/components/documents/document-list.tsx index 0629508..8e0add2 100644 --- a/src/components/documents/document-list.tsx +++ b/src/components/documents/document-list.tsx @@ -25,6 +25,9 @@ interface DocumentRow { interface DocumentListProps { interestId?: string; clientId?: string; + /** Override the default empty state ("No documents yet.") with a contextual + * CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */ + emptyState?: React.ReactNode; } const STATUS_COLORS: Record = { @@ -44,7 +47,7 @@ const TYPE_LABELS: Record = { other: 'Other', }; -export function DocumentList({ interestId, clientId }: DocumentListProps) { +export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) { const queryClient = useQueryClient(); const queryParams = new URLSearchParams(); @@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) { }; if (isLoading) { - return
Loading documents...
; + return ( +
Loading documents...
+ ); } if (!data || data.length === 0) { + if (emptyState) return <>{emptyState}; return
No documents yet.
; } diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index 6cdf45a..9ca16b6 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -47,10 +47,20 @@ interface InterestDetailHeaderProps { archivedAt: string | null; outcome?: string | null; outcomeReason?: string | null; + dateLastContact?: string | null; tags?: Array<{ id: string; name: string; color: string }>; }; } +function formatLastContactAge(iso: string): string { + const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000); + if (days <= 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 30) return `${days}d ago`; + if (days < 365) return `${Math.floor(days / 30)}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} + export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) { const queryClient = useQueryClient(); const [editOpen, setEditOpen] = useState(false); @@ -114,6 +124,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade node: {interest.source}, }); } + if (interest.dateLastContact) { + meta.push({ + key: 'last', + node: ( + + Last contact {formatLastContactAge(interest.dateLastContact)} + + ), + }); + } return ( <> @@ -182,23 +202,24 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade )}
- {/* Top-right icon-only actions — no stacking, no labels eating room. */} -
+ {/* Top-right actions. Won/Lost are sales-critical and read as text + buttons on desktop; Edit/Archive stay icon-only. On mobile, + Won/Lost shrink to icon buttons to keep the cluster from + wrapping. */} +
{isClosed ? ( ) : ( <> @@ -206,25 +227,27 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade type="button" onClick={() => setOutcomeDialog('won')} aria-label="Mark as won" - title="Mark as won" className={cn( - 'rounded-md p-1.5 text-muted-foreground/70 transition-colors', - 'hover:bg-emerald-50 hover:text-emerald-700', + 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', + 'border border-emerald-200 bg-emerald-50 text-emerald-700', + 'hover:bg-emerald-100', )} > - + + Mark won )} diff --git a/src/components/interests/interest-detail.tsx b/src/components/interests/interest-detail.tsx index b4146b3..be2f7c6 100644 --- a/src/components/interests/interest-detail.tsx +++ b/src/components/interests/interest-detail.tsx @@ -1,11 +1,13 @@ 'use client'; +import { useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; import { DetailLayout } from '@/components/shared/detail-layout'; import { InterestDetailHeader } from '@/components/interests/interest-detail-header'; import { getInterestTabs } from '@/components/interests/interest-tabs'; +import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; @@ -52,9 +54,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp const { data, isLoading } = useQuery({ queryKey: ['interests', interestId], queryFn: () => - apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then( - (r) => r.data, - ), + apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data), }); useRealtimeInvalidation({ @@ -65,17 +65,18 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp 'interest:berthUnlinked': [['interests', interestId]], }); - const tabs = data - ? getInterestTabs({ interestId, currentUserId, interest: data }) - : []; + const { setChrome } = useMobileChrome(); + const titleForChrome: string | null = data?.clientName ?? null; + useEffect(() => { + setChrome({ title: titleForChrome, showBackButton: true }); + return () => setChrome({ title: null, showBackButton: false }); + }, [titleForChrome, setChrome]); + + const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : []; return ( - ) : null - } + header={data ? : null} tabs={tabs} defaultTab="overview" isLoading={isLoading} diff --git a/src/components/interests/interest-documents-tab.tsx b/src/components/interests/interest-documents-tab.tsx index 0dd7f82..9bc4ca6 100644 --- a/src/components/interests/interest-documents-tab.tsx +++ b/src/components/interests/interest-documents-tab.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { FileSignature } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DocumentList } from '@/components/documents/document-list'; @@ -22,13 +23,15 @@ interface InterestData { export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) { const [eoiDialogOpen, setEoiDialogOpen] = useState(false); - const { data: interestRes } = useQuery({ + // Same query key + queryFn shape as InterestDetail's parent query, so the + // cache is consistent. (Mismatched shapes on the same key clobber each other + // and the parent header degenerates to "Unknown Client".) + const { data: interest } = useQuery({ queryKey: ['interests', interestId], - queryFn: () => apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`), + queryFn: () => + apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data), }); - const interest = interestRes?.data; - const prerequisites = { hasName: Boolean(interest?.clientName), hasYacht: Boolean(interest?.yachtId), @@ -39,12 +42,30 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)

Documents

-
- + +
+ +
+
+

No documents yet

+

+ Generate the EOI to send it for signing in one click. +

+
+ +
+ } + /> ; status: string | null; onAdvance: (stage: string) => void; isPending: boolean; + /** Current pipelineStage. Used to mark steps as done when the pipeline has + * moved past their advanceStage even if the date stamp is missing — e.g. + * a seed-data interest that started already at eoi_signed will show both + * EOI sub-steps as done. Stage truth > date truth. */ + currentStage: string; + /** When true, this milestone is the next one the user should act on: + * card gets a brand-accent ring and the next-step CTA becomes a primary + * button. Computed by the parent based on currentStage. */ + isActive?: boolean; /** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */ footer?: React.ReactNode; } @@ -143,16 +157,40 @@ function MilestoneSection({ status, onAdvance, isPending, + currentStage, + isActive, footer, }: MilestoneSectionProps) { - const firstUnsetIdx = steps.findIndex((s) => !s.date); + const currentStageIdx = PIPELINE_STAGES.indexOf(currentStage as PipelineStage); + // A step counts as done if either: + // (a) its `advanceStage` is at or behind the current pipeline stage, OR + // (b) it has an explicit date stamp (from a manual mark or webhook). + // (a) handles seeded/imported interests that arrived at a later stage + // without per-step dates. + const doneFlags = steps.map((step) => { + if (step.date) return true; + if (!step.advanceStage) return false; + const stepIdx = PIPELINE_STAGES.indexOf(step.advanceStage as PipelineStage); + return stepIdx !== -1 && currentStageIdx !== -1 && currentStageIdx >= stepIdx; + }); + const firstUnsetIdx = doneFlags.findIndex((d) => !d); return ( -
+
- +

{title}

+ {isActive ? ( + + Next + + ) : null}
{status ? ( @@ -163,7 +201,7 @@ function MilestoneSection({
    {steps.map((step, i) => { - const done = !!step.date; + const done = doneFlags[i] ?? false; const isNext = !done && i === firstUnsetIdx; return (
  1. @@ -197,10 +235,10 @@ function MilestoneSection({ ) : null}
- {isNext && step.advanceStage ? ( + {isNext && step.advanceStage && !step.hideAutoButton ? ( + +
) : null } /> @@ -297,6 +367,8 @@ function OverviewTab({ status={interest.contractStatus} isPending={stageMutation.isPending} onAdvance={advance} + currentStage={interest.pipelineStage} + isActive={activeMilestone === 'contract'} steps={[ { label: 'Contract sent', diff --git a/src/components/shared/detail-header-strip.tsx b/src/components/shared/detail-header-strip.tsx index 03cba18..304c8ba 100644 --- a/src/components/shared/detail-header-strip.tsx +++ b/src/components/shared/detail-header-strip.tsx @@ -10,7 +10,7 @@ export function DetailHeaderStrip({ children, className }: DetailHeaderStripProp return (
diff --git a/src/components/shared/inline-editable-field.tsx b/src/components/shared/inline-editable-field.tsx index 942225f..3136fdc 100644 --- a/src/components/shared/inline-editable-field.tsx +++ b/src/components/shared/inline-editable-field.tsx @@ -262,7 +262,9 @@ function ReadButton({ {!disabled && ( diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 531aa26..6a3ffb4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -38,7 +38,15 @@ const DialogContent = React.forwardRef< = { completed: 'Completed', }; +// Compact labels for cramped contexts (mobile chart axes, dense tables). +export const STAGE_SHORT_LABELS: Record = { + open: 'Open', + details_sent: 'Details', + in_communication: 'Comms', + eoi_sent: 'EOI →', + eoi_signed: 'EOI ✓', + deposit_10pct: 'Dep.', + contract_sent: 'Ctr →', + contract_signed: 'Ctr ✓', + completed: 'Done', +}; + export const STAGE_BADGE: Record = { open: 'bg-slate-100 text-slate-700', details_sent: 'bg-blue-100 text-blue-700',