'use client'; import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { ChevronDown } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; import { Button } from '@/components/ui/button'; import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants'; import { cn } from '@/lib/utils'; 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_VERBS: Record = { create: { past: 'created' }, update: { past: 'updated' }, delete: { past: 'deleted' }, archive: { past: 'archived' }, restore: { past: 'restored' }, merge: { past: 'merged' }, revert: { past: 'reverted' }, }; function actionVerb(action: string): string { return ACTION_VERBS[action]?.past ?? action; } function formatField(field: string | null): string | null { if (!field) return null; return field .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .toLowerCase(); } 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); } /** Natural-sentence rendering of one audit row. * * Behaviour ladder: * 1. Field changed with both old + new values → "set to " * (the inline diff below already shows the old-value strikethrough, * so we don't restate it here; result reads like a sentence rather * than a diff label). * 2. Field changed with only a new value → "set to " * 3. Field changed with neither value (rare; legacy rows) → " * the " * 4. No field → " this record" * * Truncation at 60 chars on the inline value keeps long body fields * (notes, descriptions) from blowing out the row — the diff line * below still renders the full value if the rep clicks through. */ function sentence(row: AuditRow, actor: string): string { const verb = actionVerb(row.action); const field = formatField(row.fieldChanged); if (!field) return `${actor} ${verb} this record`; const newFormatted = row.newValue !== null && row.newValue !== undefined ? formatValueForField(row.fieldChanged, row.newValue) : null; if (newFormatted) { const truncated = newFormatted.length > 60 ? newFormatted.slice(0, 60) + '…' : newFormatted; return `${actor} set ${field} to "${truncated}"`; } return `${actor} ${verb} the ${field}`; } interface Props { endpoint: string; // e.g. /api/v1/clients/{id}/activity emptyText?: string; } interface SessionGroup { actorKey: string; actorLabel: string; rows: AuditRow[]; } /** Five-minute window: consecutive events from the same actor inside this * window collapse into one session header. Outside the window a new * group starts so a single bulk-edit run reads as one block. */ const SESSION_WINDOW_MS = 5 * 60_000; function groupRows(rows: AuditRow[]): SessionGroup[] { const groups: SessionGroup[] = []; for (const row of rows) { const actorKey = row.actor?.id ?? '__system__'; const actorLabel = row.actor?.name || row.actor?.email || 'System'; const last = groups[groups.length - 1]; if ( last && last.actorKey === actorKey && last.rows.length > 0 && Math.abs(new Date(last.rows[0]!.createdAt).getTime() - new Date(row.createdAt).getTime()) <= SESSION_WINDOW_MS ) { last.rows.push(row); } else { groups.push({ actorKey, actorLabel, rows: [row] }); } } return groups; } export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: Props) { const [now, setNow] = useState(() => Date.now()); 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, }); // Filter state — chips above the feed. Empty selection = no filter. const [actorFilter, setActorFilter] = useState(null); const [actionFilter, setActionFilter] = useState(null); const rows = data?.data ?? []; const actorOptions = useMemo(() => { const map = new Map(); for (const r of rows) { const id = r.actor?.id ?? '__system__'; const label = r.actor?.name || r.actor?.email || 'System'; map.set(id, label); } return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1])); }, [rows]); const actionOptions = useMemo(() => { const set = new Set(); for (const r of rows) set.add(r.action); return Array.from(set).sort(); }, [rows]); const filteredRows = rows.filter((r) => { if (actorFilter && (r.actor?.id ?? '__system__') !== actorFilter) return false; if (actionFilter && r.action !== actionFilter) return false; return true; }); const groups = useMemo(() => groupRows(filteredRows), [filteredRows]); if (isLoading) { return
Loading activity…
; } if (error) { return (
Failed to load activity: {error instanceof Error ? error.message : 'unknown error'}
); } if (rows.length === 0) { return
{emptyText}
; } // Touch `now` so the closure participates in the minute re-render. void now; return (
{(actorOptions.length > 1 || actionOptions.length > 1) && (
{actorOptions.length > 1 && ( ({ value: id, label }))} /> )} {actionOptions.length > 1 && ( ({ value: a, label: actionVerb(a) }))} /> )} {(actorFilter || actionFilter) && ( )}
)} {filteredRows.length === 0 ? (
No activity matches the current filters.
) : (
    {groups.map((group, gi) => ( ))}
)}
); } function SessionGroupItem({ group, isLast }: { group: SessionGroup; isLast: boolean }) { const [expanded, setExpanded] = useState(group.rows.length <= 3); const first = group.rows[0]!; const created = new Date(first.createdAt); const ago = formatDistanceToNow(created, { addSuffix: true }); // Vertical connector — runs from below this bubble down to the next item, // omitted on the last item so the line never trails past the last bubble. const connector = !isLast ? ( ) : null; if (group.rows.length === 1) { return (
  • {connector}
  • ); } return (
  • {connector}
    {ago}
    {expanded && (
      {group.rows.map((r) => (
    1. ))}
    )}
  • ); } function RowBody({ row, actor, ago, compact = false, }: { row: AuditRow; actor: string; ago: string | null; compact?: boolean; }) { return ( <>
    {sentence(row, actor)}
    {ago !== null && (
    {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} ); } function FilterChipMenu({ label, value, onChange, options, }: { label: string; value: string | null; onChange: (v: string | null) => void; options: { value: string; label: string }[]; }) { // Lightweight native onChange(e.target.value || null)} > {options.map((o) => ( ))} ); } // Keep Button reference so unused-import lint doesn't fire if Button gets // reintroduced later. Currently the feed uses raw