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 <stage>" 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) <noreply@anthropic.com>
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
'use client';
|
|
|
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
|
|
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
|
import { EmptyState } from '@/components/shared/empty-state';
|
|
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';
|
|
|
|
interface Props {
|
|
range: DateRange;
|
|
}
|
|
|
|
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: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage),
|
|
count: s.count,
|
|
conversionPct: s.conversionPct,
|
|
}));
|
|
const allZero = stages.every((s) => s.count === 0);
|
|
|
|
function toCsv(): string | null {
|
|
if (!stages.length) return null;
|
|
const header = 'stage,count,conversion_pct';
|
|
const rows = stages.map((s) => `${s.stage},${s.count},${s.conversionPct}`);
|
|
return [header, ...rows].join('\n');
|
|
}
|
|
|
|
return (
|
|
<ChartCard
|
|
title="Pipeline Funnel"
|
|
description="Interests by stage with conversion rate vs. open"
|
|
exportFilename={`pipeline-funnel-${range}`}
|
|
toCsv={toCsv}
|
|
>
|
|
{isLoading ? (
|
|
<CardSkeleton />
|
|
) : allZero ? (
|
|
<EmptyState
|
|
title="No interests in range"
|
|
description="Conversion through Open → EOI → Deposit → Contract appears here. Try a longer date range, or add an interest to see it."
|
|
/>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
<XAxis
|
|
dataKey="stage"
|
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
|
angle={-40}
|
|
textAnchor="end"
|
|
interval={0}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
|
allowDecimals={false}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: 'hsl(var(--popover))',
|
|
border: '1px solid hsl(var(--border))',
|
|
borderRadius: '6px',
|
|
fontSize: 12,
|
|
}}
|
|
formatter={(value, _name, item) => {
|
|
const pct = (item?.payload as { conversionPct?: number } | undefined)
|
|
?.conversionPct;
|
|
return [`${value} (${pct ?? 0}%)`, 'Count'];
|
|
}}
|
|
/>
|
|
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</ChartCard>
|
|
);
|
|
}
|