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:
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { InlineCountryField } from '@/components/shared/inline-country-field';
|
||||
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
||||
import { RemindersInline } from '@/components/reminders/reminders-inline';
|
||||
@@ -56,11 +57,25 @@ function useClientPatch(clientId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function EditableRow({
|
||||
label,
|
||||
children,
|
||||
historyPath,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** When set, renders a clock icon (if any override rows exist) that
|
||||
* opens the field-history popover. The icon component renders nothing
|
||||
* when the field has no history, so it's safe to pass on every row. */
|
||||
historyPath?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
<dd className="flex-1 min-w-0 flex items-center gap-1">
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,101 +150,103 @@ function OverviewTab({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
<FieldHistoryProvider scope={{ type: 'client', id: clientId }}>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<EditableRow label="Full Name">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Country">
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso ?? null}
|
||||
onSave={async (iso) => {
|
||||
// Auto-default the timezone to the country's primary
|
||||
// zone when none is set yet — saves the rep a click
|
||||
// and matches what a marina actually wants for first
|
||||
// contact (London for GB, NYC for US, etc.). Only
|
||||
// fires when timezone is empty so we never clobber a
|
||||
// value the rep deliberately picked.
|
||||
const patch: { nationalityIso: string | null; timezone?: string | null } = {
|
||||
nationalityIso: iso,
|
||||
};
|
||||
if (iso && !client.timezone) {
|
||||
const defaultTz = primaryTimezoneFor(iso as CountryCode);
|
||||
if (defaultTz) patch.timezone = defaultTz;
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<EditableRow label="Full Name" historyPath="client.fullName">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Country">
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso ?? null}
|
||||
onSave={async (iso) => {
|
||||
// Auto-default the timezone to the country's primary
|
||||
// zone when none is set yet — saves the rep a click
|
||||
// and matches what a marina actually wants for first
|
||||
// contact (London for GB, NYC for US, etc.). Only
|
||||
// fires when timezone is empty so we never clobber a
|
||||
// value the rep deliberately picked.
|
||||
const patch: { nationalityIso: string | null; timezone?: string | null } = {
|
||||
nationalityIso: iso,
|
||||
};
|
||||
if (iso && !client.timezone) {
|
||||
const defaultTz = primaryTimezoneFor(iso as CountryCode);
|
||||
if (defaultTz) patch.timezone = defaultTz;
|
||||
}
|
||||
await mutation.mutateAsync(patch);
|
||||
}}
|
||||
data-testid="client-country-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={
|
||||
client.timezone ??
|
||||
(client.nationalityIso
|
||||
? primaryTimezoneFor(client.nationalityIso as CountryCode)
|
||||
: null)
|
||||
}
|
||||
await mutation.mutateAsync(patch);
|
||||
}}
|
||||
data-testid="client-country-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={
|
||||
client.timezone ??
|
||||
(client.nationalityIso
|
||||
? primaryTimezoneFor(client.nationalityIso as CountryCode)
|
||||
: null)
|
||||
}
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await mutation.mutateAsync({ timezone: tz });
|
||||
}}
|
||||
data-testid="client-timezone-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Contact">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={CONTACT_METHOD_OPTIONS}
|
||||
value={client.preferredContactMethod}
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await mutation.mutateAsync({ timezone: tz });
|
||||
}}
|
||||
data-testid="client-timezone-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Contact">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={CONTACT_METHOD_OPTIONS}
|
||||
value={client.preferredContactMethod}
|
||||
onSave={save('preferredContactMethod')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={client.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source Details">
|
||||
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
|
||||
<RemindersInline clientId={clientId} />
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={client.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source Details">
|
||||
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
|
||||
<RemindersInline clientId={clientId} />
|
||||
</div>
|
||||
</div>
|
||||
</FieldHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user