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>
101 lines
3.0 KiB
TypeScript
101 lines
3.0 KiB
TypeScript
'use client';
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
|
|
|
interface ActivityItem {
|
|
id: string;
|
|
action: string;
|
|
entityType: string;
|
|
entityId: string | null;
|
|
userId: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
create: 'default',
|
|
update: 'secondary',
|
|
delete: 'destructive',
|
|
archive: 'outline',
|
|
restore: 'secondary',
|
|
};
|
|
|
|
function ActionBadge({ action }: { action: string }) {
|
|
const variant = ACTION_VARIANTS[action] ?? 'outline';
|
|
return (
|
|
<Badge variant={variant} className="shrink-0 capitalize text-xs">
|
|
{action}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function ActivityFeedInner() {
|
|
const { data, isLoading } = useQuery<ActivityItem[]>({
|
|
queryKey: ['dashboard', 'activity'],
|
|
queryFn: () => apiFetch<ActivityItem[]>('/api/v1/dashboard/activity'),
|
|
staleTime: 30_000,
|
|
retry: 2,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return <CardSkeleton />;
|
|
}
|
|
|
|
const items = data ?? [];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Recent Activity</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{items.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No recent activity yet — your team'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) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
|
|
>
|
|
<ActionBadge action={item.action} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-foreground">
|
|
<span className="font-medium capitalize">{item.entityType}</span>
|
|
{item.entityId && (
|
|
<span className="ml-1 text-muted-foreground font-mono text-xs">
|
|
{item.entityId.slice(0, 8)}
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ActivityFeed() {
|
|
return (
|
|
<WidgetErrorBoundary>
|
|
<ActivityFeedInner />
|
|
</WidgetErrorBoundary>
|
|
);
|
|
}
|