'use client'; /** * Inline field-history surface for detail pages. * * The pattern: * * 1. Wrap the detail page (or just the section containing editable * fields) in . The * provider fires a single GET for every recorded override, keyed * by entity, and exposes a Map via context. * * 2. Pass `historyPath` to any InlineEditableField that maps to a * bindable path (see src/lib/templates/bindable-fields.ts). The * field renders a small clock icon next to the value when at least * one history row exists for that path. Click → popover with the * reverse-chronological diff list. * * Fields without `historyPath`, or fields whose path has zero history * rows, render nothing extra — the surface is purely additive. */ import { createContext, useContext, useMemo, type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Clock } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { apiFetch } from '@/lib/api/client'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { ScrollArea } from '@/components/ui/scroll-area'; import { getBindableField } from '@/lib/templates/bindable-fields'; import { cn } from '@/lib/utils'; export interface FieldHistoryScope { type: 'interest' | 'client'; id: string; } export interface FieldHistoryRow { id: string; fieldPath: string; oldValue: unknown; newValue: unknown; source: string; createdAt: string; } interface ContextValue { byPath: Map; isLoading: boolean; } const FieldHistoryContext = createContext({ byPath: new Map(), isLoading: false, }); interface ProviderProps { scope: FieldHistoryScope | null; children: ReactNode; } export function FieldHistoryProvider({ scope, children }: ProviderProps) { const { data, isLoading } = useQuery({ enabled: scope !== null, queryKey: ['field-history', scope?.type, scope?.id], queryFn: async () => { if (!scope) return [] as FieldHistoryRow[]; const url = scope.type === 'interest' ? `/api/v1/interests/${scope.id}/field-history` : `/api/v1/clients/${scope.id}/field-history`; const res = await apiFetch<{ data: FieldHistoryRow[] }>(url); return res.data; }, // Field history is small + read-mostly. 30s is plenty fresh for the // common case where the rep edits a field and wants to see the // history reflect immediately — react-query refetches on focus. staleTime: 30_000, }); const byPath = useMemo(() => { const map = new Map(); for (const row of data ?? []) { const arr = map.get(row.fieldPath) ?? []; arr.push(row); map.set(row.fieldPath, arr); } return map; }, [data]); return ( {children} ); } interface IconProps { fieldPath: string; className?: string; } /** * Renders nothing when the field has no history. When at least one * override row exists, renders a small clock button that opens a * popover with the diff timeline. */ export function FieldHistoryIcon({ fieldPath, className }: IconProps) { const { byPath } = useContext(FieldHistoryContext); const rows = byPath.get(fieldPath); if (!rows || rows.length === 0) return null; const meta = getBindableField(fieldPath); const label = meta?.label ?? fieldPath; return (
{label}
{rows.length} override{rows.length === 1 ? '' : 's'}
    {rows.map((r) => (
  • {formatSource(r.source)} {formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
    {formatValue(r.oldValue)} {formatValue(r.newValue)}
  • ))}
); } function formatValue(v: unknown): string { if (v === null || v === undefined) return '(empty)'; if (typeof v === 'string') return v.length > 80 ? `${v.slice(0, 77)}…` : v; return JSON.stringify(v); } function formatSource(source: string): string { if (source === 'supplemental_form') return 'Supplemental info form'; return source; }