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

@@ -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>
);
}