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:
170
src/components/shared/field-history.tsx
Normal file
170
src/components/shared/field-history.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user