feat(uat-batch): M43 — form-template bindings + inline field history

Closes plan item 43 (Form-template fields bind to Interest/Client data —
autofill, override-preservation history, dual-surface audit trail).

Phase 1 — Editor:
- New bindable-fields catalog (src/lib/templates/bindable-fields.ts):
  client/yacht/interest paths, each tagged with the entity, column, and
  default input type. Source of truth for what can bind + what
  interest_field_history.field_path strings the writers should use.
- formFieldSchema gains optional bindTo, validated against the catalog
  as an allow-list (no arbitrary paths sneak through).
- form-template-form admin sheet: per-field "Bind to" dropdown grouped
  by entity, auto-derives label/key/type when a binding is picked,
  shows "Autofills from + writes back to {label} . {path}" badge.

Phase 2 — Runtime + history writes:
- supplemental-forms.service.applySubmission already wrote
  interest_field_history rows for client name/email/address from the
  earlier 0081 migration session. Extended to also capture phone +
  yacht (name, length, width, draft) diffs that were silently going
  to the entity without an audit row, and to push insert-path
  overrides for the no-existing-address case.
- Field paths aligned with the bindable-fields catalog so detail-page
  lookups work via exact-match WHERE field_path = ?.

Phase 3 — Inline history surface:
- New /api/v1/clients/[id]/field-history (mirror of the existing
  interests endpoint).
- shared/field-history: FieldHistoryProvider wraps a detail tab and
  fires a single keyed GET; FieldHistoryIcon consumes the context and
  renders a small clock affordance only when at least one override
  exists, opening a popover with the reverse-chrono diff list.
- Client + Interest detail Overview tabs wrapped in the provider;
  EditableRow gains an optional historyPath prop; ContactsEditor
  renders the icon next to the canonical primary email/phone.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 12:51:39 +02:00
parent be261f3f90
commit 91be0f9136
9 changed files with 1084 additions and 454 deletions

View File

@@ -0,0 +1,170 @@
'use client';
/**
* Inline field-history surface for detail pages.
*
* The pattern:
*
* 1. Wrap the detail page (or just the section containing editable
* fields) in <FieldHistoryProvider scope={{ type, id }} />. The
* provider fires a single GET for every recorded override, keyed
* by entity, and exposes a Map<fieldPath, HistoryRow[]> 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<string, FieldHistoryRow[]>;
isLoading: boolean;
}
const FieldHistoryContext = createContext<ContextValue>({
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<string, FieldHistoryRow[]>();
for (const row of data ?? []) {
const arr = map.get(row.fieldPath) ?? [];
arr.push(row);
map.set(row.fieldPath, arr);
}
return map;
}, [data]);
return (
<FieldHistoryContext.Provider value={{ byPath, isLoading }}>
{children}
</FieldHistoryContext.Provider>
);
}
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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-5 w-5 text-muted-foreground hover:text-foreground', className)}
aria-label={`Field history for ${label}`}
title={`${rows.length} override${rows.length === 1 ? '' : 's'}`}
>
<Clock className="h-3 w-3" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0">
<div className="border-b px-3 py-2">
<div className="text-xs font-medium">{label}</div>
<div className="text-[10px] text-muted-foreground">
{rows.length} override{rows.length === 1 ? '' : 's'}
</div>
</div>
<ScrollArea className="max-h-72">
<ul className="divide-y text-xs">
{rows.map((r) => (
<li key={r.id} className="px-3 py-2 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">{formatSource(r.source)}</span>
<span className="text-[10px] text-muted-foreground">
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
</span>
</div>
<div className="text-muted-foreground">
<span className="line-through">{formatValue(r.oldValue)}</span>
<span className="mx-1"></span>
<span className="text-foreground">{formatValue(r.newValue)}</span>
</div>
</li>
))}
</ul>
</ScrollArea>
</PopoverContent>
</Popover>
);
}
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;
}