'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; /** Server-resolved human label (client name, yacht name, …) when the * underlying entity still exists. Falls back to the id prefix in the UI. */ label: string | null; userId: string | null; fieldChanged: string | null; oldValue: unknown; newValue: unknown; metadata: Record | null; createdAt: string; } /** camelCase / snake_case field name → "Title Case" so the audit log * reads naturally ("fullName" → "Full Name", "phone_number" → "Phone * Number"). Single-word fields stay capitalized. */ function humanizeFieldName(name: string): string { return name .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (c) => c.toUpperCase()); } /** Render a JSON-ish value as a short, single-line preview. Strings come * through as-is; objects flatten to "k: v, k: v"; arrays compress to a * count; nulls / empty render as em-dash. */ function shortValue(value: unknown): string { if (value === null || value === undefined || value === '') return '—'; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`; if (typeof value === 'object') { const entries = Object.entries(value as Record); if (entries.length === 0) return '—'; return entries .slice(0, 3) .map(([k, v]) => `${humanizeFieldName(k)}: ${typeof v === 'string' ? v : JSON.stringify(v)}`) .join(', '); } return String(value); } /** Build a "Field: old → new" diff string for the activity row's second * line. Returns null when there's nothing useful to show. * * Audit logs for updates store the per-field diff inside `oldValue` as * `{ field: { old, new }, … }` (see entity-diff.ts), so that's the * shape we pattern-match first. Falls back to a fieldChanged/old→new * pair when those are present, and finally to a key-by-key compare of * two flat objects in `oldValue` vs `newValue`. */ function buildDiffLine(item: ActivityItem): string | null { // Shape A: oldValue = { field: { old, new }, … } if ( item.action === 'update' && item.oldValue && typeof item.oldValue === 'object' && !Array.isArray(item.oldValue) ) { const diffMap = item.oldValue as Record; const entries = Object.entries(diffMap).filter(([, v]) => { return v && typeof v === 'object' && 'old' in (v as object) && 'new' in (v as object); }); if (entries.length > 0) { return entries .slice(0, 2) .map(([field, v]) => { const { old, new: nextValue } = v as { old: unknown; new: unknown }; return `${humanizeFieldName(field)}: ${shortValue(old)} → ${shortValue(nextValue)}`; }) .join(' · '); } } // Shape B: single-field change with explicit columns. if (item.fieldChanged) { return `${humanizeFieldName(item.fieldChanged)}: ${shortValue(item.oldValue)} → ${shortValue(item.newValue)}`; } // Shape C: flat oldValue vs flat newValue. if ( item.action === 'update' && item.oldValue && typeof item.oldValue === 'object' && item.newValue && typeof item.newValue === 'object' ) { const oldObj = item.oldValue as Record; const newObj = item.newValue as Record; const keys = Object.keys(oldObj).filter((k) => k in newObj); if (keys.length === 0) return null; return keys .slice(0, 2) .map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k])} → ${shortValue(newObj[k])}`) .join(' · '); } return null; } const ACTION_VARIANTS: Record = { create: 'default', update: 'secondary', delete: 'destructive', archive: 'outline', restore: 'secondary', }; function ActionBadge({ action }: { action: string }) { const variant = ACTION_VARIANTS[action] ?? 'outline'; return ( {action} ); } function ActivityFeedInner() { const { data, isLoading } = useQuery({ queryKey: ['dashboard', 'activity'], queryFn: () => apiFetch('/api/v1/dashboard/activity'), staleTime: 30_000, retry: 2, }); if (isLoading) { return ; } const items = data ?? []; return ( Recent Activity {items.length === 0 ? (

No recent activity yet - your team's actions (interests created, stages changed, invoices sent) will appear here.

) : (
{items.map((item) => { const diffLine = buildDiffLine(item); return (

{item.label ? ( <> {item.label} {item.entityType} ) : ( <> {item.entityType} {item.entityId && ( {item.entityId.slice(0, 8)} )} )}

{diffLine ? (

{diffLine}

) : null}

{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}

); })}
)}
); } export function ActivityFeed() { return ( ); }