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:
35
src/app/api/v1/clients/[id]/field-history/route.ts
Normal file
35
src/app/api/v1/clients/[id]/field-history/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/components/ui/accordion';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
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 { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -977,27 +993,28 @@ function OverviewTab({
|
||||
const futureMilestones = milestones.filter((m) => m.phase === 'future');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip-ahead nudge - informational only; fires when the deal jumped
|
||||
<FieldHistoryProvider scope={{ type: 'interest', id: interestId }}>
|
||||
<div className="space-y-6">
|
||||
{/* Skip-ahead nudge - informational only; fires when the deal jumped
|
||||
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
|
||||
surfaces the situation so they treat the deal as a backup. */}
|
||||
<InterestBerthStatusBanner
|
||||
interestId={interestId}
|
||||
interestPipelineStage={interest.pipelineStage}
|
||||
interestOutcome={interest.outcome}
|
||||
archivedAt={null}
|
||||
/>
|
||||
<InterestBerthStatusBanner
|
||||
interestId={interestId}
|
||||
interestPipelineStage={interest.pipelineStage}
|
||||
interestOutcome={interest.outcome}
|
||||
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
|
||||
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
|
||||
running deposit total here drives the auto-advance into the
|
||||
deposit_paid stage server-side. Hidden before the reservation
|
||||
@@ -1005,138 +1022,138 @@ function OverviewTab({
|
||||
noise - the next-milestone card carries the actionable copy
|
||||
instead. Render order: deprioritized below the milestone strip
|
||||
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
|
||||
MilestoneSection below (it already owns the workflow actions -
|
||||
two surfaces was redundant). Nurturing keeps a slim helper
|
||||
since no milestone is naturally "current" while a deal is
|
||||
paused. */}
|
||||
{interest.pipelineStage === 'nurturing' ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm">
|
||||
<p className="font-medium text-foreground">Deal is on nurture</p>
|
||||
<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
|
||||
them back to Qualified.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{interest.pipelineStage === 'nurturing' ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm">
|
||||
<p className="font-medium text-foreground">Deal is on nurture</p>
|
||||
<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
|
||||
them back to Qualified.
|
||||
</p>
|
||||
</div>
|
||||
) : 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
|
||||
history strip; the current milestone gets the full card; future
|
||||
milestones are hidden behind a toggle so reps can still
|
||||
skip-ahead when reality calls for it (an override-confirm
|
||||
gates the actual stage move). */}
|
||||
{pastMilestones.length > 0 && (
|
||||
<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">
|
||||
<span>Past</span>
|
||||
</div>
|
||||
<Accordion type="multiple" className="px-4">
|
||||
{pastMilestones.map((m) => (
|
||||
<AccordionItem key={m.key} value={m.key} className="border-0">
|
||||
<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">
|
||||
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
|
||||
<span className="font-medium text-foreground">{m.title}</span>
|
||||
<span className="text-[10px]">·</span>
|
||||
<span className="truncate text-xs">{m.pastSummary}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{/* Reuse the same MilestoneSection layout used for the
|
||||
{pastMilestones.length > 0 && (
|
||||
<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">
|
||||
<span>Past</span>
|
||||
</div>
|
||||
<Accordion type="multiple" className="px-4">
|
||||
{pastMilestones.map((m) => (
|
||||
<AccordionItem key={m.key} value={m.key} className="border-0">
|
||||
<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">
|
||||
<CheckCircle2 className="size-3 shrink-0 text-emerald-600" aria-hidden />
|
||||
<span className="font-medium text-foreground">{m.title}</span>
|
||||
<span className="text-[10px]">·</span>
|
||||
<span className="truncate text-xs">{m.pastSummary}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{/* Reuse the same MilestoneSection layout used for the
|
||||
current milestone — the steps list, sub-status badge,
|
||||
and any inline doc actions all render the same way.
|
||||
`isActive={false}` keeps the NEXT-STEP pill off. */}
|
||||
<MilestoneSection
|
||||
title={m.title}
|
||||
icon={m.icon}
|
||||
status={m.status}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={false}
|
||||
steps={m.steps}
|
||||
footer={m.footer}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<MilestoneSection
|
||||
title={m.title}
|
||||
icon={m.icon}
|
||||
status={m.status}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={false}
|
||||
steps={m.steps}
|
||||
footer={m.footer}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</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 && (
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{futureMilestones.length > 0 && (
|
||||
<FutureMilestones
|
||||
milestones={futureMilestones}
|
||||
stageMutation={stageMutation}
|
||||
advance={advance}
|
||||
activeMilestone={activeMilestone}
|
||||
currentStage={interest.pipelineStage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{futureMilestones.length > 0 && (
|
||||
<FutureMilestones
|
||||
milestones={futureMilestones}
|
||||
stageMutation={stageMutation}
|
||||
advance={advance}
|
||||
activeMilestone={activeMilestone}
|
||||
currentStage={interest.pipelineStage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Payments section relocated below milestones (was above): the
|
||||
{/* Payments section relocated below milestones (was above): the
|
||||
deposit-tracking surface is reference/history, not the rep's
|
||||
primary focus once they're at Reservation+. The active
|
||||
milestone above carries the actionable copy. */}
|
||||
{showPaymentsSection ? (
|
||||
<PaymentsSection
|
||||
interestId={interestId}
|
||||
depositExpectedAmount={interest.depositExpectedAmount ?? null}
|
||||
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{showPaymentsSection ? (
|
||||
<PaymentsSection
|
||||
interestId={interestId}
|
||||
depositExpectedAmount={interest.depositExpectedAmount ?? null}
|
||||
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Lead & Source (editable) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Lead</h3>
|
||||
<dl>
|
||||
<EditableRow label="Lead Category">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={LEAD_CATEGORY_OPTIONS}
|
||||
value={interest.leadCategory}
|
||||
onSave={save('leadCategory')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
value={interest.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Lead & Source (editable) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Lead</h3>
|
||||
<dl>
|
||||
<EditableRow label="Lead Category">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={LEAD_CATEGORY_OPTIONS}
|
||||
value={interest.leadCategory}
|
||||
onSave={save('leadCategory')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
value={interest.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</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
|
||||
contact log. Phone is rendered via libphonenumber-js's
|
||||
international formatter so `+33633219796` reads as
|
||||
@@ -1144,255 +1161,260 @@ function OverviewTab({
|
||||
Both email + phone are click-to-edit: the PATCH flows to the
|
||||
underlying client_contacts row (resolved via the
|
||||
`*ContactId` fields surfaced by the interest read). */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||
<dl>
|
||||
<EditableRow label="Email">
|
||||
{interest.clientId ? (
|
||||
<ClientChannelEditor
|
||||
clientId={interest.clientId}
|
||||
channel="email"
|
||||
primaryContactId={interest.clientPrimaryEmailContactId ?? null}
|
||||
primaryValue={interest.clientPrimaryEmail ?? null}
|
||||
invalidateKeys={[['interest', interest.id]]}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
||||
<dl>
|
||||
<EditableRow label="Email" historyPath="client.primaryEmail">
|
||||
{interest.clientId ? (
|
||||
<ClientChannelEditor
|
||||
clientId={interest.clientId}
|
||||
channel="email"
|
||||
primaryContactId={interest.clientPrimaryEmailContactId ?? null}
|
||||
primaryValue={interest.clientPrimaryEmail ?? null}
|
||||
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>
|
||||
<EditableRow label="Phone">
|
||||
{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)} />
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
{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
|
||||
leaving the Overview tab. These values drive the auto-tick on
|
||||
the "Dimensions confirmed" qualification row + the
|
||||
BerthRecommenderPanel rankings below. */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
|
||||
{(() => {
|
||||
// Honour the interest's `desiredLengthUnit` so a deal whose rep
|
||||
// entered metric values doesn't render labelled "(ft)" with
|
||||
// empty inputs. On save we patch BOTH the chosen-unit column
|
||||
// and the canonical counterpart so downstream surfaces
|
||||
// (recommender, EOI merge fields) stay in lockstep.
|
||||
const unitIsM = interest.desiredLengthUnit === 'm';
|
||||
const FT_PER_M = 3.28084;
|
||||
const toCounterpart = (v: string | null): string | null => {
|
||||
if (!v) return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
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),
|
||||
});
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
|
||||
{(() => {
|
||||
// Honour the interest's `desiredLengthUnit` so a deal whose rep
|
||||
// entered metric values doesn't render labelled "(ft)" with
|
||||
// empty inputs. On save we patch BOTH the chosen-unit column
|
||||
// and the canonical counterpart so downstream surfaces
|
||||
// (recommender, EOI merge fields) stay in lockstep.
|
||||
const unitIsM = interest.desiredLengthUnit === 'm';
|
||||
const FT_PER_M = 3.28084;
|
||||
const toCounterpart = (v: string | null): string | null => {
|
||||
if (!v) return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return unitIsM ? (n * FT_PER_M).toFixed(4) : (n / FT_PER_M).toFixed(4);
|
||||
};
|
||||
const unitLabel = unitIsM ? 'm' : 'ft';
|
||||
return (
|
||||
<dl>
|
||||
<EditableRow label={`Desired length (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM
|
||||
? (interest.desiredLengthM ?? null)
|
||||
: (interest.desiredLengthFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
|
||||
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label={`Desired width (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM ? (interest.desiredWidthM ?? null) : (interest.desiredWidthFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
|
||||
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label={`Desired draft (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
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>
|
||||
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';
|
||||
return (
|
||||
<dl>
|
||||
<EditableRow label={`Desired length (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM
|
||||
? (interest.desiredLengthM ?? null)
|
||||
: (interest.desiredLengthFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredLengthM' : 'desiredLengthFt',
|
||||
unitIsM ? 'desiredLengthFt' : 'desiredLengthM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 18' : 'e.g. 60'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label={`Desired width (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
unitIsM
|
||||
? (interest.desiredWidthM ?? null)
|
||||
: (interest.desiredWidthFt ?? null)
|
||||
}
|
||||
onSave={onSavePair(
|
||||
unitIsM ? 'desiredWidthM' : 'desiredWidthFt',
|
||||
unitIsM ? 'desiredWidthFt' : 'desiredWidthM',
|
||||
)}
|
||||
placeholder={unitIsM ? 'e.g. 7.5' : 'e.g. 25'}
|
||||
emptyText=" - "
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label={`Desired draft (${unitLabel})`}>
|
||||
<InlineEditableField
|
||||
value={
|
||||
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`),
|
||||
but the Overview surface for them is hidden: the REMINDERS
|
||||
section below shows the full reminders table and the bell-in-
|
||||
header surfaces active counts. Removing the duplicate read-only
|
||||
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."
|
||||
Always rendered now that the redundant `interests.notes` blob is
|
||||
gone - falls back to an empty-state prompt so reps still have an
|
||||
obvious entry point to the Notes tab from Overview. */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Latest note</h3>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${interestId}?tab=notes`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{interest.recentNote
|
||||
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
|
||||
: 'Add note'}
|
||||
</Link>
|
||||
</div>
|
||||
{interest.recentNote ? (
|
||||
<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">
|
||||
{interest.recentNote.content}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{/* Stage pill = the deal's current stage. Source-of-truth
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Latest note</h3>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${interestId}?tab=notes`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{interest.recentNote
|
||||
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
|
||||
: 'Add note'}
|
||||
</Link>
|
||||
</div>
|
||||
{interest.recentNote ? (
|
||||
<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">
|
||||
{interest.recentNote.content}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{/* Stage pill = the deal's current stage. Source-of-truth
|
||||
interpretation: the note is about the deal as it
|
||||
stands today; reading it on Overview, "current stage"
|
||||
answers the implicit "where in the deal is this?". A
|
||||
historical "stage-at-note-time" lookup would need an
|
||||
audit_logs read per teaser render — over-engineered for
|
||||
a context hint. */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
|
||||
'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{stageLabel(interest.pipelineStage)}
|
||||
</span>
|
||||
<span>
|
||||
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
{interest.recentNote.authorId
|
||||
? ` · ${
|
||||
interest.recentNote.authorId === 'system'
|
||||
? 'system'
|
||||
: (interest.recentNote.authorName ?? 'Unknown')
|
||||
}`
|
||||
: ''}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
|
||||
No notes yet.
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
|
||||
'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{stageLabel(interest.pipelineStage)}
|
||||
</span>
|
||||
<span>
|
||||
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
{interest.recentNote.authorId
|
||||
? ` · ${
|
||||
interest.recentNote.authorId === 'system'
|
||||
? 'system'
|
||||
: (interest.recentNote.authorName ?? 'Unknown')
|
||||
}`
|
||||
: ''}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
|
||||
No notes yet.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
|
||||
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
|
||||
what's already linked before browsing more options. Each row exposes
|
||||
per-berth role-flag toggles and the EOI bypass control (only visible
|
||||
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
|
||||
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 /
|
||||
correct details before the EOI is drafted. Hides itself once
|
||||
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
|
||||
when dimensions aren't set yet. */}
|
||||
<BerthRecommenderPanel
|
||||
interestId={interestId}
|
||||
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
||||
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
||||
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
||||
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
||||
linkedBerthCount={interest.linkedBerthCount ?? 0}
|
||||
/>
|
||||
{confirmDialog}
|
||||
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
||||
<BerthRecommenderPanel
|
||||
interestId={interestId}
|
||||
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
||||
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
||||
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
||||
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
||||
linkedBerthCount={interest.linkedBerthCount ?? 0}
|
||||
/>
|
||||
{confirmDialog}
|
||||
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
||||
footer button can launch the dialog without leaving the tab. Same
|
||||
dialog component the dedicated EOI tab uses - single source of
|
||||
truth for the editing/confirmation flow. */}
|
||||
<EoiGenerateDialog
|
||||
interestId={interestId}
|
||||
clientId={clientId}
|
||||
open={eoiGenerateOpen}
|
||||
onOpenChange={setEoiGenerateOpen}
|
||||
/>
|
||||
</div>
|
||||
<EoiGenerateDialog
|
||||
interestId={interestId}
|
||||
clientId={clientId}
|
||||
open={eoiGenerateOpen}
|
||||
onOpenChange={setEoiGenerateOpen}
|
||||
/>
|
||||
</div>
|
||||
</FieldHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
170
src/components/shared/field-history.tsx
Normal file
170
src/components/shared/field-history.tsx
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
206
src/lib/templates/bindable-fields.ts
Normal file
206
src/lib/templates/bindable-fields.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BINDABLE_PATHS } from '@/lib/templates/bindable-fields';
|
||||
|
||||
export const formFieldSchema = z.object({
|
||||
key: z.string().min(1).max(80),
|
||||
label: z.string().min(1).max(200),
|
||||
@@ -7,6 +9,19 @@ export const formFieldSchema = z.object({
|
||||
required: z.boolean().optional().default(false),
|
||||
options: z.array(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({
|
||||
|
||||
Reference in New Issue
Block a user