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

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { and, desc, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { interestFieldHistory } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/clients/[id]/field-history
*
* Returns every supplemental-form override that touched the client (rolling
* up across all of their interests), newest first. Powers the inline clock
* icon + popover on Client detail. Mirrors /interests/[id]/field-history.
*/
export const GET = withAuth(
withPermission('clients', 'view', async (_req, ctx, params) => {
try {
const rows = await db
.select()
.from(interestFieldHistory)
.where(
and(
eq(interestFieldHistory.portId, ctx.portId),
eq(interestFieldHistory.clientId, params.id!),
),
)
.orderBy(desc(interestFieldHistory.createdAt))
.limit(100);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -13,14 +13,24 @@ import { Textarea } from '@/components/ui/textarea';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import type { FormField } from '@/lib/validators/form-templates'; 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 { interface FormTemplate {
id: string; id: string;
@@ -103,6 +113,41 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f))); 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() { function addField() {
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]); setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
} }
@@ -158,6 +203,40 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Button> </Button>
)} )}
</div> </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="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">Key (no spaces)</Label> <Label className="text-xs">Key (no spaces)</Label>
@@ -240,3 +319,18 @@ function FormTemplateFormBody({ open, onOpenChange, template, onSaved }: Props)
</Sheet> </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;
}

View File

@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; 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 { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { RemindersInline } from '@/components/reminders/reminders-inline'; 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 ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <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> <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> </div>
); );
} }
@@ -135,101 +150,103 @@ function OverviewTab({
}; };
return ( return (
<div className="space-y-6"> <FieldHistoryProvider scope={{ type: 'client', id: clientId }}>
<div className="rounded-xl border border-border bg-card p-4 shadow-sm"> <div className="space-y-6">
<ClientPipelineSummary clientId={clientId} variant="panel" /> <div className="rounded-xl border border-border bg-card p-4 shadow-sm">
</div> <ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */} {/* Personal Info */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3> <h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl> <dl>
<EditableRow label="Full Name"> <EditableRow label="Full Name" historyPath="client.fullName">
<InlineEditableField value={client.fullName} onSave={save('fullName')} /> <InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow> </EditableRow>
<EditableRow label="Country"> <EditableRow label="Country">
<InlineCountryField <InlineCountryField
value={client.nationalityIso ?? null} value={client.nationalityIso ?? null}
onSave={async (iso) => { onSave={async (iso) => {
// Auto-default the timezone to the country's primary // Auto-default the timezone to the country's primary
// zone when none is set yet — saves the rep a click // zone when none is set yet — saves the rep a click
// and matches what a marina actually wants for first // and matches what a marina actually wants for first
// contact (London for GB, NYC for US, etc.). Only // contact (London for GB, NYC for US, etc.). Only
// fires when timezone is empty so we never clobber a // fires when timezone is empty so we never clobber a
// value the rep deliberately picked. // value the rep deliberately picked.
const patch: { nationalityIso: string | null; timezone?: string | null } = { const patch: { nationalityIso: string | null; timezone?: string | null } = {
nationalityIso: iso, nationalityIso: iso,
}; };
if (iso && !client.timezone) { if (iso && !client.timezone) {
const defaultTz = primaryTimezoneFor(iso as CountryCode); const defaultTz = primaryTimezoneFor(iso as CountryCode);
if (defaultTz) patch.timezone = defaultTz; 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); countryHint={(client.nationalityIso as CountryCode | null) ?? null}
}} onSave={async (tz) => {
data-testid="client-country-inline" await mutation.mutateAsync({ timezone: tz });
/> }}
</EditableRow> data-testid="client-timezone-inline"
<EditableRow label="Timezone"> />
<InlineTimezoneField </EditableRow>
value={ <EditableRow label="Preferred Contact">
client.timezone ?? <InlineEditableField
(client.nationalityIso variant="select"
? primaryTimezoneFor(client.nationalityIso as CountryCode) options={CONTACT_METHOD_OPTIONS}
: null) value={client.preferredContactMethod}
} onSave={save('preferredContactMethod')}
countryHint={(client.nationalityIso as CountryCode | null) ?? null} />
onSave={async (tz) => { </EditableRow>
await mutation.mutateAsync({ timezone: tz }); </dl>
}} </div>
data-testid="client-timezone-inline"
/> {/* Contacts */}
</EditableRow> <div className="space-y-1">
<EditableRow label="Preferred Contact"> <h3 className="text-sm font-medium mb-2">Contact Details</h3>
<InlineEditableField <ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
variant="select" </div>
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod} {/* Source */}
onSave={save('preferredContactMethod')} <div className="space-y-1">
/> <h3 className="text-sm font-medium mb-2">Source</h3>
</EditableRow> <dl>
</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>
{/* 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>
</div> </FieldHistoryProvider>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryIcon } from '@/components/shared/field-history';
import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useConfirmation } from '@/hooks/use-confirmation'; import { useConfirmation } from '@/hooks/use-confirmation';
@@ -199,32 +200,44 @@ function ContactRow({
<ChannelPicker value={contact.channel} onChange={changeChannel}> <ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden /> <Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</ChannelPicker> </ChannelPicker>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 flex items-center gap-1">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( <div className="min-w-0 flex-1">
<InlinePhoneField {contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
e164={contact.valueE164 ?? null} <InlinePhoneField
country={contact.valueCountry ?? null} e164={contact.valueE164 ?? null}
onEditingChange={setPhoneEditing} country={contact.valueCountry ?? null}
onSave={async ({ e164, country }) => { onEditingChange={setPhoneEditing}
if (!e164) { onSave={async ({ e164, country }) => {
toast.error('Phone number is required'); if (!e164) {
return; toast.error('Phone number is required');
} return;
await onUpdate({ value: e164, valueE164: e164, valueCountry: country }); }
}} await onUpdate({ value: e164, valueE164: e164, valueCountry: country });
/> }}
) : ( />
<InlineEditableField ) : (
value={contact.value} <InlineEditableField
onSave={async (v) => { value={contact.value}
if (!v) { onSave={async (v) => {
toast.error('Value is required'); if (!v) {
return; toast.error('Value is required');
} return;
await onUpdate({ value: v }); }
}} 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>
</div> </div>

View File

@@ -19,6 +19,7 @@ import {
} from '@/components/ui/accordion'; } from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline'; import { RemindersInline } from '@/components/reminders/reminders-inline';
@@ -222,11 +223,26 @@ function useStageMutation(interestId: 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 (when at least one override row
* exists for this path on the surrounding FieldHistoryProvider scope)
* that opens the field-history popover. The icon renders nothing
* without history, so it's safe to pass on every row. */
historyPath?: string;
}) {
return ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt> <dt className="w-44 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> </div>
); );
} }
@@ -977,27 +993,28 @@ function OverviewTab({
const futureMilestones = milestones.filter((m) => m.phase === 'future'); const futureMilestones = milestones.filter((m) => m.phase === 'future');
return ( return (
<div className="space-y-6"> <FieldHistoryProvider scope={{ type: 'interest', id: interestId }}>
{/* Skip-ahead nudge - informational only; fires when the deal jumped <div className="space-y-6">
{/* Skip-ahead nudge - informational only; fires when the deal jumped
past a milestone without stamping the matching date. */} past a milestone without stamping the matching date. */}
<SkipAheadBanner interest={interest} /> <SkipAheadBanner interest={interest} />
{/* Conflict callout - fires when a linked berth is sold or already {/* Conflict callout - fires when a linked berth is sold or already
under offer to another active deal. Doesn't block the rep; just under offer to another active deal. Doesn't block the rep; just
surfaces the situation so they treat the deal as a backup. */} surfaces the situation so they treat the deal as a backup. */}
<InterestBerthStatusBanner <InterestBerthStatusBanner
interestId={interestId} interestId={interestId}
interestPipelineStage={interest.pipelineStage} interestPipelineStage={interest.pipelineStage}
interestOutcome={interest.outcome} interestOutcome={interest.outcome}
archivedAt={null} archivedAt={null}
/> />
{/* Qualification checklist - surfaces the port's per-port criteria so {/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */} of 'enquiry'. Hidden when the port has no enabled criteria. */}
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} /> <QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
{/* Payments - bank-issued invoices live elsewhere; this is the {/* Payments - bank-issued invoices live elsewhere; this is the
internal audit record of money received against the deal. The internal audit record of money received against the deal. The
running deposit total here drives the auto-advance into the running deposit total here drives the auto-advance into the
deposit_paid stage server-side. Hidden before the reservation deposit_paid stage server-side. Hidden before the reservation
@@ -1005,138 +1022,138 @@ function OverviewTab({
noise - the next-milestone card carries the actionable copy noise - the next-milestone card carries the actionable copy
instead. Render order: deprioritized below the milestone strip instead. Render order: deprioritized below the milestone strip
so the rep's eye lands on the active step first. */} so the rep's eye lands on the active step first. */}
{/* Pre-reservation: the dedicated "Next step" guidance card was {/* Pre-reservation: the dedicated "Next step" guidance card was
removed in favour of a brighter NEXT STEP pill on the active removed in favour of a brighter NEXT STEP pill on the active
MilestoneSection below (it already owns the workflow actions - MilestoneSection below (it already owns the workflow actions -
two surfaces was redundant). Nurturing keeps a slim helper two surfaces was redundant). Nurturing keeps a slim helper
since no milestone is naturally "current" while a deal is since no milestone is naturally "current" while a deal is
paused. */} paused. */}
{interest.pipelineStage === 'nurturing' ? ( {interest.pipelineStage === 'nurturing' ? (
<div className="rounded-xl border bg-card p-4 text-sm"> <div className="rounded-xl border bg-card p-4 text-sm">
<p className="font-medium text-foreground">Deal is on nurture</p> <p className="font-medium text-foreground">Deal is on nurture</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
them back to Qualified. them back to Qualified.
</p> </p>
</div> </div>
) : null} ) : null}
{/* Sales-process milestones - phase-aware so the user only sees {/* Sales-process milestones - phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future history strip; the current milestone gets the full card; future
milestones are hidden behind a toggle so reps can still milestones are hidden behind a toggle so reps can still
skip-ahead when reality calls for it (an override-confirm skip-ahead when reality calls for it (an override-confirm
gates the actual stage move). */} gates the actual stage move). */}
{pastMilestones.length > 0 && ( {pastMilestones.length > 0 && (
<div className="rounded-lg border bg-muted/20"> <div className="rounded-lg border bg-muted/20">
<div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"> <div className="flex items-center gap-2 border-b px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Past</span> <span>Past</span>
</div> </div>
<Accordion type="multiple" className="px-4"> <Accordion type="multiple" className="px-4">
{pastMilestones.map((m) => ( {pastMilestones.map((m) => (
<AccordionItem key={m.key} value={m.key} className="border-0"> <AccordionItem key={m.key} value={m.key} className="border-0">
<AccordionTrigger className="py-2 text-xs font-medium hover:no-underline"> <AccordionTrigger className="py-2 text-xs font-medium hover:no-underline">
<div className="flex flex-1 items-center gap-2 text-left text-muted-foreground"> <div className="flex flex-1 items-center gap-2 text-left text-muted-foreground">
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden /> <CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
<span className="font-medium text-foreground">{m.title}</span> <span className="font-medium text-foreground">{m.title}</span>
<span className="text-[10px]">·</span> <span className="text-[10px]">·</span>
<span className="truncate text-xs">{m.pastSummary}</span> <span className="truncate text-xs">{m.pastSummary}</span>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
{/* Reuse the same MilestoneSection layout used for the {/* Reuse the same MilestoneSection layout used for the
current milestone — the steps list, sub-status badge, current milestone — the steps list, sub-status badge,
and any inline doc actions all render the same way. and any inline doc actions all render the same way.
`isActive={false}` keeps the NEXT-STEP pill off. */} `isActive={false}` keeps the NEXT-STEP pill off. */}
<MilestoneSection <MilestoneSection
title={m.title} title={m.title}
icon={m.icon} icon={m.icon}
status={m.status} status={m.status}
isPending={stageMutation.isPending} isPending={stageMutation.isPending}
onAdvance={advance} onAdvance={advance}
currentStage={interest.pipelineStage} currentStage={interest.pipelineStage}
isActive={false} isActive={false}
steps={m.steps} steps={m.steps}
footer={m.footer} footer={m.footer}
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
))}
</Accordion>
</div>
)}
{currentMilestones.length > 0 && (
<div
className={cn(
'grid grid-cols-1 gap-4',
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2',
)}
>
{currentMilestones.map((m) => (
<MilestoneSection
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))} ))}
</Accordion> </div>
</div> )}
)}
{currentMilestones.length > 0 && ( {futureMilestones.length > 0 && (
<div <FutureMilestones
className={cn( milestones={futureMilestones}
'grid grid-cols-1 gap-4', stageMutation={stageMutation}
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2', advance={advance}
)} activeMilestone={activeMilestone}
> currentStage={interest.pipelineStage}
{currentMilestones.map((m) => ( />
<MilestoneSection )}
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))}
</div>
)}
{futureMilestones.length > 0 && ( {/* Payments section relocated below milestones (was above): the
<FutureMilestones
milestones={futureMilestones}
stageMutation={stageMutation}
advance={advance}
activeMilestone={activeMilestone}
currentStage={interest.pipelineStage}
/>
)}
{/* Payments section relocated below milestones (was above): the
deposit-tracking surface is reference/history, not the rep's deposit-tracking surface is reference/history, not the rep's
primary focus once they're at Reservation+. The active primary focus once they're at Reservation+. The active
milestone above carries the actionable copy. */} milestone above carries the actionable copy. */}
{showPaymentsSection ? ( {showPaymentsSection ? (
<PaymentsSection <PaymentsSection
interestId={interestId} interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null} depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null} depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/> />
) : null} ) : null}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */} {/* Lead & Source (editable) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Lead</h3> <h3 className="text-sm font-medium mb-2">Lead</h3>
<dl> <dl>
<EditableRow label="Lead Category"> <EditableRow label="Lead Category">
<InlineEditableField <InlineEditableField
variant="select" variant="select"
options={LEAD_CATEGORY_OPTIONS} options={LEAD_CATEGORY_OPTIONS}
value={interest.leadCategory} value={interest.leadCategory}
onSave={save('leadCategory')} onSave={save('leadCategory')}
/> />
</EditableRow> </EditableRow>
<EditableRow label="Source"> <EditableRow label="Source">
<InlineEditableField <InlineEditableField
variant="select" variant="select"
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))} options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
value={interest.source} value={interest.source}
onSave={save('source')} onSave={save('source')}
/> />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* Contact - client's primary email + phone (from the linked client {/* Contact - client's primary email + phone (from the linked client
record) AND the first/last-contact activity dates from the record) AND the first/last-contact activity dates from the
contact log. Phone is rendered via libphonenumber-js's contact log. Phone is rendered via libphonenumber-js's
international formatter so `+33633219796` reads as international formatter so `+33633219796` reads as
@@ -1144,255 +1161,260 @@ function OverviewTab({
Both email + phone are click-to-edit: the PATCH flows to the Both email + phone are click-to-edit: the PATCH flows to the
underlying client_contacts row (resolved via the underlying client_contacts row (resolved via the
`*ContactId` fields surfaced by the interest read). */} `*ContactId` fields surfaced by the interest read). */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3> <h3 className="text-sm font-medium mb-2">Contact</h3>
<dl> <dl>
<EditableRow label="Email"> <EditableRow label="Email" historyPath="client.primaryEmail">
{interest.clientId ? ( {interest.clientId ? (
<ClientChannelEditor <ClientChannelEditor
clientId={interest.clientId} clientId={interest.clientId}
channel="email" channel="email"
primaryContactId={interest.clientPrimaryEmailContactId ?? null} primaryContactId={interest.clientPrimaryEmailContactId ?? null}
primaryValue={interest.clientPrimaryEmail ?? null} primaryValue={interest.clientPrimaryEmail ?? null}
invalidateKeys={[['interest', interest.id]]} invalidateKeys={[['interest', interest.id]]}
/> />
) : (
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
<EditableRow label="Phone" historyPath="client.primaryPhone">
{interest.clientId ? (
<ClientChannelEditor
clientId={interest.clientId}
channel="phone"
primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
primaryValue={interest.clientPrimaryPhone ?? null}
primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
) : (
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
<>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
</>
) : ( ) : (
<span className="text-muted-foreground">-</span> <p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet - log a call, email, or meeting from the Contact
log tab to start tracking.
</p>
)} )}
</EditableRow> {interest.reservationStatus ? (
<EditableRow label="Phone"> <InfoRow label="Reservation" value={interest.reservationStatus} />
{interest.clientId ? ( ) : null}
<ClientChannelEditor </dl>
clientId={interest.clientId} </div>
channel="phone"
primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
primaryValue={interest.clientPrimaryPhone ?? null}
primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
) : (
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
<>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
</>
) : (
<p className="mt-1 text-xs text-muted-foreground italic">
No contact activity logged yet - log a call, email, or meeting from the Contact log
tab to start tracking.
</p>
)}
{interest.reservationStatus ? (
<InfoRow label="Reservation" value={interest.reservationStatus} />
) : null}
</dl>
</div>
{/* Berth requirements - desired length / width / draft. Editable {/* Berth requirements - desired length / width / draft. Editable
inline so reps can capture or correct a buyer's needs without inline so reps can capture or correct a buyer's needs without
leaving the Overview tab. These values drive the auto-tick on leaving the Overview tab. These values drive the auto-tick on
the "Dimensions confirmed" qualification row + the the "Dimensions confirmed" qualification row + the
BerthRecommenderPanel rankings below. */} BerthRecommenderPanel rankings below. */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Berth requirements</h3> <h3 className="text-sm font-medium mb-2">Berth requirements</h3>
{(() => { {(() => {
// Honour the interest's `desiredLengthUnit` so a deal whose rep // Honour the interest's `desiredLengthUnit` so a deal whose rep
// entered metric values doesn't render labelled "(ft)" with // entered metric values doesn't render labelled "(ft)" with
// empty inputs. On save we patch BOTH the chosen-unit column // empty inputs. On save we patch BOTH the chosen-unit column
// and the canonical counterpart so downstream surfaces // and the canonical counterpart so downstream surfaces
// (recommender, EOI merge fields) stay in lockstep. // (recommender, EOI merge fields) stay in lockstep.
const unitIsM = interest.desiredLengthUnit === 'm'; const unitIsM = interest.desiredLengthUnit === 'm';
const FT_PER_M = 3.28084; const FT_PER_M = 3.28084;
const toCounterpart = (v: string | null): string | null => { const toCounterpart = (v: string | null): string | null => {
if (!v) return null; if (!v) return null;
const n = Number(v); const n = Number(v);
if (!Number.isFinite(n)) return null; if (!Number.isFinite(n)) return null;
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4); return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4);
};
const onSavePair =
(
primary: InterestPatchField,
counterpart: InterestPatchField,
): ((next: string | null) => Promise<void>) =>
async (next: string | null) => {
await mutation.mutateAsync({
[primary]: next,
[counterpart]: toCounterpart(next),
});
}; };
const unitLabel = unitIsM ? 'm' : 'ft'; const onSavePair =
return ( (
<dl> primary: InterestPatchField,
<EditableRow label={`Desired length (${unitLabel})`}> counterpart: InterestPatchField,
<InlineEditableField ): ((next: string | null) => Promise<void>) =>
value={ async (next: string | null) => {
unitIsM await mutation.mutateAsync({
? (interest.desiredLengthM ?? null) [primary]: next,
: (interest.desiredLengthFt ?? null) [counterpart]: toCounterpart(next),
} });
onSave={onSavePair( };
unitIsM ? 'desiredLengthM' : 'desiredLengthFt', const unitLabel = unitIsM ? 'm' : 'ft';
unitIsM ? 'desiredLengthFt' : 'desiredLengthM', return (
)} <dl>
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'} <EditableRow label={`Desired length (${unitLabel})`}>
emptyText=" - " <InlineEditableField
/> value={
</EditableRow> unitIsM
<EditableRow label={`Desired width (${unitLabel})`}> ? (interest.desiredLengthM ?? null)
<InlineEditableField : (interest.desiredLengthFt ?? null)
value={ }
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null) onSave={onSavePair(
} unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
onSave={onSavePair( unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
unitIsM ? 'desiredWidthM' : 'desiredWidthFt', )}
unitIsM ? 'desiredWidthFt' : 'desiredWidthM', placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
)} emptyText=" - "
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'} />
emptyText=" - " </EditableRow>
/> <EditableRow label={`Desired width (${unitLabel})`}>
</EditableRow> <InlineEditableField
<EditableRow label={`Desired draft (${unitLabel})`}> value={
<InlineEditableField unitIsM
value={ ? (interest.desiredWidthM ?? null)
unitIsM ? (interest.desiredDraftM ?? null) : (interest.desiredDraftFt ?? null) : (interest.desiredWidthFt ?? null)
} }
onSave={onSavePair( onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt', unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM', unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
)} )}
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'} placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
emptyText=" - " emptyText=" - "
/> />
</EditableRow> </EditableRow>
</dl> <EditableRow label={`Desired draft (${unitLabel})`}>
); <InlineEditableField
})()} value={
</div> unitIsM
? (interest.desiredDraftM ?? null)
: (interest.desiredDraftFt ?? null)
}
onSave={onSavePair(
unitIsM ? 'desiredDraftM' : 'desiredDraftFt',
unitIsM ? 'desiredDraftFt' : 'desiredDraftM',
)}
placeholder={unitIsM ? 'e.g. 2' : 'e.g. 6'}
emptyText=" - "
/>
</EditableRow>
</dl>
);
})()}
</div>
{/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired` {/* Legacy `interest.reminderEnabled` / `reminderDays` / `reminderLastFired`
still drive the auto-follow-up worker (`processFollowUpReminders`), still drive the auto-follow-up worker (`processFollowUpReminders`),
but the Overview surface for them is hidden: the REMINDERS but the Overview surface for them is hidden: the REMINDERS
section below shows the full reminders table and the bell-in- section below shows the full reminders table and the bell-in-
header surfaces active counts. Removing the duplicate read-only header surfaces active counts. Removing the duplicate read-only
panel cleans Overview without affecting the backend job. */} panel cleans Overview without affecting the backend job. */}
{/* Most-recent threaded note teaser. Saves a click into the Notes {/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last." tab when the rep just wants to peek at "what was discussed last."
Always rendered now that the redundant `interests.notes` blob is Always rendered now that the redundant `interests.notes` blob is
gone - falls back to an empty-state prompt so reps still have an gone - falls back to an empty-state prompt so reps still have an
obvious entry point to the Notes tab from Overview. */} obvious entry point to the Notes tab from Overview. */}
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3> <h3 className="text-sm font-medium">Latest note</h3>
<Link <Link
href={`/${portSlug}/interests/${interestId}?tab=notes`} href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium text-primary hover:underline"
> >
{interest.recentNote {interest.recentNote
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}` ? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'} : 'Add note'}
</Link> </Link>
</div> </div>
{interest.recentNote ? ( {interest.recentNote ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm"> <div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90"> <p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content} {interest.recentNote.content}
</p> </p>
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground"> <p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{/* Stage pill = the deal's current stage. Source-of-truth {/* Stage pill = the deal's current stage. Source-of-truth
interpretation: the note is about the deal as it interpretation: the note is about the deal as it
stands today; reading it on Overview, "current stage" stands today; reading it on Overview, "current stage"
answers the implicit "where in the deal is this?". A answers the implicit "where in the deal is this?". A
historical "stage-at-note-time" lookup would need an historical "stage-at-note-time" lookup would need an
audit_logs read per teaser render — over-engineered for audit_logs read per teaser render — over-engineered for
a context hint. */} a context hint. */}
<span <span
className={cn( className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium', 'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
STAGE_BADGE[interest.pipelineStage as PipelineStage] ?? STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
'bg-muted text-muted-foreground', 'bg-muted text-muted-foreground',
)} )}
> >
{stageLabel(interest.pipelineStage)} {stageLabel(interest.pipelineStage)}
</span> </span>
<span> <span>
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), { {formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true, addSuffix: true,
})} })}
{interest.recentNote.authorId {interest.recentNote.authorId
? ` · ${ ? ` · ${
interest.recentNote.authorId === 'system' interest.recentNote.authorId === 'system'
? 'system' ? 'system'
: (interest.recentNote.authorName ?? 'Unknown') : (interest.recentNote.authorName ?? 'Unknown')
}` }`
: ''} : ''}
</span> </span>
</p> </p>
</div> </div>
) : ( ) : (
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
No notes yet. No notes yet.
</div> </div>
)} )}
</div>
<InlineTagEditor
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
<div className="md:col-span-2">
<RemindersInline interestId={interestId} />
</div>
</div> </div>
<InlineTagEditor {/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
heading="Tags"
wrapperClassName="md:col-span-2"
endpoint={`/api/v1/interests/${interestId}/tags`}
currentTags={interest.tags ?? []}
invalidateKey={['interests', interestId]}
/>
<div className="md:col-span-2">
<RemindersInline interestId={interestId} />
</div>
</div>
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
what's already linked before browsing more options. Each row exposes what's already linked before browsing more options. Each row exposes
per-berth role-flag toggles and the EOI bypass control (only visible per-berth role-flag toggles and the EOI bypass control (only visible
once the parent interest's primary EOI is signed). */} once the parent interest's primary EOI is signed). */}
{/* Won-status wrap-up checklist - only renders when this interest's {/* Won-status wrap-up checklist - only renders when this interest's
outcome is `won`. Surfaces upload slots for the manual paperwork outcome is `won`. Surfaces upload slots for the manual paperwork
that didn't flow through the EOI->Contract chain automatically. */} that didn't flow through the EOI->Contract chain automatically. */}
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} /> <WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
{/* Pre-EOI supplemental info request. Sends the client a one-time {/* Pre-EOI supplemental info request. Sends the client a one-time
public form pre-filled with what's on file so they can confirm / public form pre-filled with what's on file so they can confirm /
correct details before the EOI is drafted. Hides itself once correct details before the EOI is drafted. Hides itself once
the EOI is signed. */} the EOI is signed. */}
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} /> <SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
<LinkedBerthsList interestId={interestId} /> <LinkedBerthsList interestId={interestId} />
{/* Berth recommender (plan §5.3) - always-mounted card driven by the {/* Berth recommender (plan §5.3) - always-mounted card driven by the
interest's desired dimensions. Renders an inline guidance message interest's desired dimensions. Renders an inline guidance message
when dimensions aren't set yet. */} when dimensions aren't set yet. */}
<BerthRecommenderPanel <BerthRecommenderPanel
interestId={interestId} interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)} desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)} desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)} desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'} desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0} linkedBerthCount={interest.linkedBerthCount ?? 0}
/> />
{confirmDialog} {confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI" {/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same footer button can launch the dialog without leaving the tab. Same
dialog component the dedicated EOI tab uses - single source of dialog component the dedicated EOI tab uses - single source of
truth for the editing/confirmation flow. */} truth for the editing/confirmation flow. */}
<EoiGenerateDialog <EoiGenerateDialog
interestId={interestId} interestId={interestId}
clientId={clientId} clientId={clientId}
open={eoiGenerateOpen} open={eoiGenerateOpen}
onOpenChange={setEoiGenerateOpen} onOpenChange={setEoiGenerateOpen}
/> />
</div> </div>
</FieldHistoryProvider>
); );
} }

View 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;
}

View File

@@ -337,6 +337,23 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
countryIso: input.country ?? null, countryIso: input.country ?? null,
isPrimary: true, 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 { } else {
const addrPatch: Record<string, unknown> = {}; const addrPatch: Record<string, unknown> = {};
if (input.address && input.address !== existingAddr.streetAddress) { if (input.address && input.address !== existingAddr.streetAddress) {
@@ -407,7 +424,17 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
valueCountry: input.phoneCountry, valueCountry: input.phoneCountry,
isPrimary: true, isPrimary: true,
}); });
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: null,
newValue: input.phoneE164,
});
} else if (existing.valueE164 !== input.phoneE164) { } else if (existing.valueE164 !== input.phoneE164) {
overrides.push({
fieldPath: 'client.primaryPhone',
oldValue: existing.valueE164 ?? existing.value,
newValue: input.phoneE164,
});
await tx await tx
.update(clientContacts) .update(clientContacts)
.set({ .set({
@@ -425,11 +452,42 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
where: eq(interests.id, row.interestId), where: eq(interests.id, row.interestId),
}); });
if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) { 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> = {}; const yachtPatch: Record<string, unknown> = {};
if (input.yachtName) yachtPatch.name = input.yachtName; if (input.yachtName && input.yachtName !== existingYacht?.name) {
if (input.yachtLengthFt !== null) yachtPatch.lengthFt = String(input.yachtLengthFt); yachtPatch.name = input.yachtName;
if (input.yachtWidthFt !== null) yachtPatch.widthFt = String(input.yachtWidthFt); overrides.push({
if (input.yachtDraftFt !== null) yachtPatch.draftFt = String(input.yachtDraftFt); 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) { if (Object.keys(yachtPatch).length > 0) {
await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId)); await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
} }

View File

@@ -0,0 +1,206 @@
/**
* Catalog of fields a form-template can bind to on Interest / Client / Yacht.
*
* Each entry maps a dot-path token (e.g. `client.email`) to:
* - the entity table whose row should be read/written
* - the column on that table
* - the form input type that best matches the column
* - a human label shown in the admin "Bind to" picker
*
* The form-template editor uses this as an allow-list (a field whose
* `bindTo` isn't in the catalog is rejected by the validator). The
* supplemental-form runtime uses it twice: at load time to prefill the
* public form with the entity's current value, and at submission time to
* route each posted answer back to the correct table+column with an
* `interest_field_history` row capturing the override.
*
* Entity scoping rules:
* - `interest.*` → write to `interests` row resolved from the token
* - `client.*` → write to `clients` row resolved from interest.clientId
* - `client_address.*` → write to first `client_addresses` row (or insert)
* - `yacht.*` → write to the interest's linked yacht (if present)
*
* Not in the catalog (intentional):
* - Polymorphic ownership columns (yacht.current_owner_*) — needs
* ownership-flow service, not a flat write.
* - Anything on companies (M43 first pass scopes to client+yacht).
*/
export type BindableType = 'text' | 'textarea' | 'email' | 'phone' | 'number';
export interface BindableField {
/** Stable dot-path. The form template stores this verbatim in `field.bindTo`. */
path: string;
/** Human label shown in the admin picker + the field-history popover. */
label: string;
/** Entity bucket — drives the picker grouping + write routing. */
entity: 'interest' | 'client' | 'client_address' | 'yacht';
/** Column on the entity's row. */
column: string;
/** Default form-input type when the binding is set (the editor still lets the admin override). */
inputType: BindableType;
}
/**
* Path naming convention:
* - `client.<column>` — top-level clients row
* - `client.primaryEmail|primaryPhone` — synthesised over client_contacts
* - `client.address.<column>` — the primary client_addresses row
* - `yacht.<column>` — interest's linked yachts row
* - `interest.<column>` — interests row resolved from the token
*
* `interest_field_history.field_path` stores these strings verbatim, so the
* detail-page history popover can `WHERE field_path = ?` to surface the
* inline clock icon for the matching InlineEditableField.
*/
export const BINDABLE_FIELDS: readonly BindableField[] = [
// ─── Client (top-level identity) ─────────────────────────────────
{
path: 'client.fullName',
label: 'Full name',
entity: 'client',
column: 'fullName',
inputType: 'text',
},
{
path: 'client.primaryEmail',
label: 'Primary email',
entity: 'client',
column: 'primaryEmail',
inputType: 'email',
},
{
path: 'client.primaryPhone',
label: 'Primary phone',
entity: 'client',
column: 'primaryPhone',
inputType: 'phone',
},
{
path: 'client.nationality',
label: 'Nationality',
entity: 'client',
column: 'nationality',
inputType: 'text',
},
// ─── Client address (single canonical row) ───────────────────────
{
path: 'client.address.streetAddress',
label: 'Street address',
entity: 'client_address',
column: 'streetAddress',
inputType: 'text',
},
{
path: 'client.address.city',
label: 'City',
entity: 'client_address',
column: 'city',
inputType: 'text',
},
{
path: 'client.address.postalCode',
label: 'Postal code',
entity: 'client_address',
column: 'postalCode',
inputType: 'text',
},
{
path: 'client.address.countryIso',
label: 'Country',
entity: 'client_address',
column: 'countryIso',
inputType: 'text',
},
// ─── Yacht ───────────────────────────────────────────────────────
{ path: 'yacht.name', label: 'Yacht name', entity: 'yacht', column: 'name', inputType: 'text' },
{
path: 'yacht.hullNumber',
label: 'Hull number',
entity: 'yacht',
column: 'hullNumber',
inputType: 'text',
},
{
path: 'yacht.registration',
label: 'Registration',
entity: 'yacht',
column: 'registration',
inputType: 'text',
},
{ path: 'yacht.flag', label: 'Flag', entity: 'yacht', column: 'flag', inputType: 'text' },
{
path: 'yacht.yearBuilt',
label: 'Year built',
entity: 'yacht',
column: 'yearBuilt',
inputType: 'number',
},
{
path: 'yacht.lengthFt',
label: 'Length (ft)',
entity: 'yacht',
column: 'lengthFt',
inputType: 'number',
},
{
path: 'yacht.widthFt',
label: 'Beam (ft)',
entity: 'yacht',
column: 'widthFt',
inputType: 'number',
},
{
path: 'yacht.draftFt',
label: 'Draft (ft)',
entity: 'yacht',
column: 'draftFt',
inputType: 'number',
},
// ─── Interest (deal-level free-form) ─────────────────────────────
{
path: 'interest.notes',
label: 'Additional notes',
entity: 'interest',
column: 'notes',
inputType: 'textarea',
},
] as const;
const BINDABLE_BY_PATH = new Map(BINDABLE_FIELDS.map((f) => [f.path, f]));
export function getBindableField(path: string | null | undefined): BindableField | null {
if (!path) return null;
return BINDABLE_BY_PATH.get(path) ?? null;
}
export function isBindablePath(path: string): boolean {
return BINDABLE_BY_PATH.has(path);
}
export const BINDABLE_PATHS: readonly string[] = BINDABLE_FIELDS.map((f) => f.path);
/**
* Grouped form for the admin picker. Returns entries in the order entities
* should appear in the dropdown (Client, then Yacht, then Interest, then
* address — most-frequent first).
*/
export function bindableFieldsByEntity(): Array<{
entity: BindableField['entity'];
label: string;
fields: readonly BindableField[];
}> {
const buckets: Array<{ entity: BindableField['entity']; label: string }> = [
{ entity: 'client', label: 'Client' },
{ entity: 'client_address', label: 'Client address' },
{ entity: 'yacht', label: 'Yacht' },
{ entity: 'interest', label: 'Interest' },
];
return buckets.map((b) => ({
...b,
fields: BINDABLE_FIELDS.filter((f) => f.entity === b.entity),
}));
}

View File

@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { BINDABLE_PATHS } from '@/lib/templates/bindable-fields';
export const formFieldSchema = z.object({ export const formFieldSchema = z.object({
key: z.string().min(1).max(80), key: z.string().min(1).max(80),
label: z.string().min(1).max(200), label: z.string().min(1).max(200),
@@ -7,6 +9,19 @@ export const formFieldSchema = z.object({
required: z.boolean().optional().default(false), required: z.boolean().optional().default(false),
options: z.array(z.string()).optional(), options: z.array(z.string()).optional(),
helpText: z.string().optional(), helpText: z.string().optional(),
/**
* Optional binding to an Interest/Client/Yacht column. When set, the
* supplemental-form runtime prefills the field from the linked entity
* AND writes the submitted value back to that column on apply (with an
* `interest_field_history` row capturing the override). Validated
* against `BINDABLE_FIELDS` so unknown paths can't sneak in.
*/
bindTo: z
.string()
.refine((v) => BINDABLE_PATHS.includes(v), {
message: 'Unknown bindable path',
})
.optional(),
}); });
export const createFormTemplateSchema = z.object({ export const createFormTemplateSchema = z.object({