fix(ux): batch UX audit fixes across spine pages

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>
This commit is contained in:
Matt Ciaccio
2026-05-02 01:24:15 +02:00
parent 868b1f40c0
commit 76a7387dcc
16 changed files with 568 additions and 66 deletions

View File

@@ -57,7 +57,10 @@ function ActivityFeedInner() {
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity.</p>
<p className="text-sm text-muted-foreground">
No recent activity yet your team&apos;s actions (interests created, stages changed,
invoices sent) will appear here.
</p>
) : (
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
{items.map((item) => (

View File

@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
{isLoading ? (
<CardSkeleton />
) : !slices.length ? (
<EmptyState title="No interests in range" />
<EmptyState
title="No interests in range"
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
/>
) : (
<ResponsiveContainer width="100%" height={260}>
// 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.
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
innerRadius={50}
cy="45%"
outerRadius="70%"
innerRadius="40%"
paddingAngle={2}
>
{chartData.map((_, i) => (
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Legend
verticalAlign="bottom"
height={40}
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
/>
</PieChart>
</ResponsiveContainer>
)}

View File

@@ -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<PipelineRow[]>({
queryKey: ['dashboard', 'pipeline'],
queryFn: () => apiFetch<PipelineRow[]>('/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,
}));

View File

@@ -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 ? (
<CardSkeleton />
) : allZero ? (
<EmptyState title="No interests in range" description="Try a longer date range." />
<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 }}>

View File

@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
{isLoading ? (
<CardSkeleton />
) : !bars.length ? (
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
<EmptyState
title="No invoices in range"
description="Issued, paid, and overdue totals appear here once you create invoices."
/>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>