Files
pn-new-crm/src/components/dashboard/activity-feed.tsx

101 lines
3.0 KiB
TypeScript
Raw Normal View History

'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 ? (
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>
2026-05-02 01:24:15 +02:00
<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) => (
<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>
);
}