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

@@ -16,6 +16,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryIcon } from '@/components/shared/field-history';
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -199,32 +200,44 @@ function ContactRow({
<ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</ChannelPicker>
<div className="min-w-0 flex-1">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
<InlinePhoneField
e164={contact.valueE164 ?? null}
country={contact.valueCountry ?? null}
onEditingChange={setPhoneEditing}
onSave={async ({ e164, country }) => {
if (!e164) {
toast.error('Phone number is required');
return;
}
await onUpdate({ value: e164, valueE164: e164, valueCountry: country });
}}
/>
) : (
<InlineEditableField
value={contact.value}
onSave={async (v) => {
if (!v) {
toast.error('Value is required');
return;
}
await onUpdate({ value: v });
}}
/>
)}
<div className="min-w-0 flex-1 flex items-center gap-1">
<div className="min-w-0 flex-1">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
<InlinePhoneField
e164={contact.valueE164 ?? null}
country={contact.valueCountry ?? null}
onEditingChange={setPhoneEditing}
onSave={async ({ e164, country }) => {
if (!e164) {
toast.error('Phone number is required');
return;
}
await onUpdate({ value: e164, valueE164: e164, valueCountry: country });
}}
/>
) : (
<InlineEditableField
value={contact.value}
onSave={async (v) => {
if (!v) {
toast.error('Value is required');
return;
}
await onUpdate({ value: v });
}}
/>
)}
</div>
{/* Override history is only meaningful for the canonical "primary
email" / "primary phone" entries the supplemental form
overwrites — secondary contacts don't have a matching
bindable path. The icon renders nothing when no rows exist. */}
{contact.isPrimary && contact.channel === 'email' ? (
<FieldHistoryIcon fieldPath="client.primaryEmail" />
) : null}
{contact.isPrimary && (contact.channel === 'phone' || contact.channel === 'whatsapp') ? (
<FieldHistoryIcon fieldPath="client.primaryPhone" />
) : null}
</div>
</div>