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

@@ -13,14 +13,24 @@ import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { FormField } from '@/lib/validators/form-templates';
import {
bindableFieldsByEntity,
getBindableField,
type BindableType,
} from '@/lib/templates/bindable-fields';
const BIND_TO_NONE = '__none__';
interface FormTemplate {
id: string;
@@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
}
function changeBinding(idx: number, raw: string) {
if (raw === BIND_TO_NONE) {
// Clear the binding but leave the rest of the field untouched —
// admins may want to keep a custom field that no longer autofills.
setFields((prev) =>
prev.map((f, i) => {
if (i !== idx) return f;
const { bindTo, ...rest } = f;
void bindTo;
return rest;
}),
);
return;
}
const meta = getBindableField(raw);
if (!meta) return;
// Adopt the binding + auto-derive input type and label when the admin
// hasn't typed one yet (saves repetitive data entry on the common
// "bind to client email" flow). Pre-existing labels are preserved so
// admins can override the friendly name per template.
setFields((prev) =>
prev.map((f, i) =>
i === idx
? {
...f,
bindTo: raw,
type: coerceFieldType(meta.inputType, f.type),
label: f.label.trim() ? f.label : meta.label,
key: f.key.trim() ? f.key : meta.path.split('.').pop()!,
}
: f,
),
);
}
function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
}
@@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Button>
)}
</div>
<div className="space-y-1">
<Label className="text-xs">Bind to (autofill + write-back)</Label>
<Select
value={f.bindTo ?? BIND_TO_NONE}
onValueChange={(v) => changeBinding(i, v)}
>
<SelectTrigger>
<SelectValue placeholder="No binding (free-form)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BIND_TO_NONE}>
No binding - store in submission only
</SelectItem>
{bindableFieldsByEntity().map((group) => (
<SelectGroup key={group.entity}>
<SelectLabel>{group.label}</SelectLabel>
{group.fields.map((bf) => (
<SelectItem key={bf.path} value={bf.path}>
{bf.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
{f.bindTo ? (
<Badge variant="secondary" className="text-[10px] font-normal">
Autofills from + writes back to {getBindableField(f.bindTo)?.label} ·{' '}
{f.bindTo}
</Badge>
) : null}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label>
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Sheet>
);
}
/**
* Map a bindable column's natural input type onto the form-field types we
* actually render. When binding to a `number` column we still let the
* admin keep `select` if they'd already chosen it (e.g. they want to
* constrain to specific values) — same for `textarea`.
*/
function coerceFieldType(
bindableType: BindableType,
currentType: FormField['type'],
): FormField['type'] {
if (currentType === 'select' || currentType === 'checkbox') return currentType;
if (currentType === 'textarea' && bindableType === 'text') return 'textarea';
return bindableType;
}