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:
@@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
countryIso: input.country ?? null,
|
||||
isPrimary: true,
|
||||
});
|
||||
// Insert-path: every populated field is a "from null → value"
|
||||
// override so the history panel surfaces the initial population
|
||||
// the same way it surfaces later edits.
|
||||
if (input.address) {
|
||||
overrides.push({
|
||||
fieldPath: 'client.address.streetAddress',
|
||||
oldValue: null,
|
||||
newValue: input.address,
|
||||
});
|
||||
}
|
||||
if (input.country) {
|
||||
overrides.push({
|
||||
fieldPath: 'client.address.countryIso',
|
||||
oldValue: null,
|
||||
newValue: input.country,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const addrPatch: Record<string, unknown> = {};
|
||||
if (input.address && input.address !== existingAddr.streetAddress) {
|
||||
@@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
valueCountry: input.phoneCountry,
|
||||
isPrimary: true,
|
||||
});
|
||||
overrides.push({
|
||||
fieldPath: 'client.primaryPhone',
|
||||
oldValue: null,
|
||||
newValue: input.phoneE164,
|
||||
});
|
||||
} else if (existing.valueE164 !== input.phoneE164) {
|
||||
overrides.push({
|
||||
fieldPath: 'client.primaryPhone',
|
||||
oldValue: existing.valueE164 ?? existing.value,
|
||||
newValue: input.phoneE164,
|
||||
});
|
||||
await tx
|
||||
.update(clientContacts)
|
||||
.set({
|
||||
@@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
where: eq(interests.id, row.interestId),
|
||||
});
|
||||
if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) {
|
||||
const existingYacht = await tx.query.yachts.findFirst({
|
||||
where: eq(yachts.id, interest.yachtId),
|
||||
});
|
||||
const yachtPatch: Record<string, unknown> = {};
|
||||
if (input.yachtName) yachtPatch.name = input.yachtName;
|
||||
if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt);
|
||||
if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt);
|
||||
if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt);
|
||||
if (input.yachtName && input.yachtName !== existingYacht?.name) {
|
||||
yachtPatch.name = input.yachtName;
|
||||
overrides.push({
|
||||
fieldPath: 'yacht.name',
|
||||
oldValue: existingYacht?.name ?? null,
|
||||
newValue: input.yachtName,
|
||||
});
|
||||
}
|
||||
if (input.yachtLengthFt !== null && String(input.yachtLengthFt) !== existingYacht?.lengthFt) {
|
||||
yachtPatch.lengthFt = String(input.yachtLengthFt);
|
||||
overrides.push({
|
||||
fieldPath: 'yacht.lengthFt',
|
||||
oldValue: existingYacht?.lengthFt ?? null,
|
||||
newValue: String(input.yachtLengthFt),
|
||||
});
|
||||
}
|
||||
if (input.yachtWidthFt !== null && String(input.yachtWidthFt) !== existingYacht?.widthFt) {
|
||||
yachtPatch.widthFt = String(input.yachtWidthFt);
|
||||
overrides.push({
|
||||
fieldPath: 'yacht.widthFt',
|
||||
oldValue: existingYacht?.widthFt ?? null,
|
||||
newValue: String(input.yachtWidthFt),
|
||||
});
|
||||
}
|
||||
if (input.yachtDraftFt !== null && String(input.yachtDraftFt) !== existingYacht?.draftFt) {
|
||||
yachtPatch.draftFt = String(input.yachtDraftFt);
|
||||
overrides.push({
|
||||
fieldPath: 'yacht.draftFt',
|
||||
oldValue: existingYacht?.draftFt ?? null,
|
||||
newValue: String(input.yachtDraftFt),
|
||||
});
|
||||
}
|
||||
if (Object.keys(yachtPatch).length > 0) {
|
||||
await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user