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:
@@ -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: <span className="capitalize">{interest.source}</span>,
|
||||
});
|
||||
}
|
||||
if (interest.dateLastContact) {
|
||||
meta.push({
|
||||
key: 'last',
|
||||
node: (
|
||||
<span className="text-foreground/70">
|
||||
Last contact {formatLastContactAge(interest.dateLastContact)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -182,23 +202,24 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right icon-only actions — no stacking, no labels eating room. */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{/* 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. */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PermissionGate resource="interests" action="change_stage">
|
||||
{isClosed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reopenMutation.mutate()}
|
||||
disabled={reopenMutation.isPending}
|
||||
aria-label="Reopen interest"
|
||||
title="Reopen interest"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5 hover:text-foreground',
|
||||
'disabled:opacity-50',
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
|
||||
'hover:bg-foreground/5 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
<RefreshCcw className="size-3.5" />
|
||||
Reopen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -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',
|
||||
)}
|
||||
>
|
||||
<Trophy className="size-4" />
|
||||
<Trophy className="size-3.5" />
|
||||
<span className="hidden sm:inline">Mark won</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('lost')}
|
||||
aria-label="Close as lost"
|
||||
title="Close as lost"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-rose-50 hover:text-rose-700',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'border border-rose-200 text-rose-700',
|
||||
'hover:bg-rose-50',
|
||||
)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
<XCircle className="size-3.5" />
|
||||
<span className="hidden sm:inline">Close as lost</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user