'use client'; import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { apiFetch } from '@/lib/api/client'; import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants'; interface AuditRow { id: string; action: string; entityType: string; entityId: string | null; fieldChanged: string | null; oldValue: unknown; newValue: unknown; metadata: Record | null; createdAt: string; actor: { id: string; email: string; name: string | null } | null; } const ACTION_LABELS: Record = { create: 'Created', update: 'Updated', delete: 'Deleted', archive: 'Archived', restore: 'Restored', merge: 'Merged', revert: 'Reverted', }; function formatAction(action: string): string { return ACTION_LABELS[action] ?? action.charAt(0).toUpperCase() + action.slice(1); } function formatField(field: string | null): string | null { if (!field) return null; return field .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .toLowerCase(); } /** Resolve enum-typed values to their human-readable label so the row reads * "10% Deposit" instead of "deposit_10pct". Returns the raw value for any * unrecognised field/value. */ function formatValueForField(field: string | null, value: unknown): string { if (value === null || value === undefined) return ''; if (field) { const f = field.replace(/_/g, '').toLowerCase(); if (typeof value === 'string') { if (f === 'pipelinestage' || f === 'stage') { return STAGE_LABELS[value as PipelineStage] ?? formatEnum(value); } if (f === 'source') return formatSource(value) ?? value; if (f === 'leadcategory' || f === 'category' || f === 'outcome') { return formatEnum(value); } } } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return String(value); } return JSON.stringify(value); } function summarize(row: AuditRow): string { const verb = formatAction(row.action); const field = formatField(row.fieldChanged); if (field) return `${verb} ${field}`; return verb; } interface Props { endpoint: string; // e.g. /api/v1/clients/{id}/activity emptyText?: string; } export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: Props) { const [now, setNow] = useState(() => Date.now()); // Re-render every minute so "x minutes ago" stays accurate without a // refetch. useEffect(() => { const t = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(t); }, []); const { data, isLoading, error } = useQuery({ queryKey: ['entity-activity', endpoint], queryFn: () => apiFetch<{ data: AuditRow[] }>(endpoint), staleTime: 30_000, }); if (isLoading) { return
Loading activity…
; } if (error) { return (
Failed to load activity: {error instanceof Error ? error.message : 'unknown error'}
); } const rows = data?.data ?? []; if (rows.length === 0) { return
{emptyText}
; } return (
    {rows.map((row) => { const created = new Date(row.createdAt); const ago = formatDistanceToNow(created, { addSuffix: true }); // touch `now` so the closure participates in the minute re-render void now; const actor = row.actor?.name || row.actor?.email || 'System'; return (
  1. {summarize(row)} · {actor}
    {ago}
    {row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? (
    {row.oldValue !== null && row.oldValue !== undefined ? ( {formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)} ) : null} {row.newValue !== null && row.newValue !== undefined ? ( → {formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)} ) : null}
    ) : null}
  2. ); })}
); }