feat(uat-batch-9): milestone classifier + skip-ahead backfill controls

Two coordinated UX changes that finally make the rep's manual-stage-
jump workflow legible:

- Milestone phase classifier introduces a "stage-owning milestone"
  rule. When the rep manually advances the deal to Reservation+ but
  earlier sub-statuses are still un-signed, the current-stage
  milestone now stays marked `'current'` (no longer collapses into
  the past-strip / upcoming-accordion based on completion alone).
  Earlier-than-stage milestones bucket to `'past'` so the rep can
  backfill them; later slots stay `'future'`. The previous
  firstIncompleteKey-driven rule still applies in stages without an
  owning milestone (enquiry / qualified / nurturing).
- Skip-ahead backfill control `<MilestoneBackfillButton>` lands in
  the past-milestones strip whenever a milestone's date column is
  null. Opens a DatePicker popover (today default, accepts any past
  date) and PATCHes the relevant date_* column directly via
  useInterestPatch — no stage transition fires.
- `InterestPatchField` extended with the five milestone date keys;
  validator gains `dateDepositReceived` (was the only missing one).

Together this means: a deal manually-advanced from EOI Sent → Deposit
no longer hides Reservation under upcoming-milestones AND the rep can
record the EOI/reservation signing dates without re-triggering the
stage transition.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:54:33 +02:00
parent 535ff69fc4
commit d8da1f634d
2 changed files with 160 additions and 16 deletions

View File

@@ -63,7 +63,13 @@ type InterestPatchField =
| 'source'
| 'desiredLengthFt'
| 'desiredWidthFt'
| 'desiredDraftFt';
| 'desiredDraftFt'
| 'dateEoiSent'
| 'dateEoiSigned'
| 'dateReservationSigned'
| 'dateDepositReceived'
| 'dateContractSent'
| 'dateContractSigned';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
@@ -347,6 +353,72 @@ function MilestoneAdvanceButton({
);
}
/**
* Skip-ahead backfill control: shown next to past milestones whose
* date column is null. Opens the same date popover as
* MilestoneAdvanceButton but PATCHes the date column directly without
* triggering a stage transition — the stage was already advanced
* manually upstream.
*/
function MilestoneBackfillButton({
label,
disabled,
onConfirm,
}: {
label: string;
disabled?: boolean;
onConfirm: (milestoneDate: string) => void | Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState<string>(() => new Date().toISOString().slice(0, 10));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{label}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 space-y-2 p-3">
<div className="space-y-1">
<label className="text-xs font-medium" htmlFor="backfill-date">
Date completed
</label>
<DatePicker
id="backfill-date"
value={date}
toDate={new Date()}
onChange={setDate}
placeholder="Pick a date"
/>
<p className="text-[11px] text-muted-foreground">
Records the date the milestone happened. Does not change the deal&apos;s pipeline stage.
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={!date || disabled}
onClick={async () => {
await onConfirm(date);
setOpen(false);
}}
>
Save date
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function MilestoneSection({
title,
icon: Icon,
@@ -616,10 +688,37 @@ function OverviewTab({
} as const;
const order = ['berth_interest', 'eoi', 'reservation', 'deposit', 'contract'] as const;
const firstIncompleteKey = order.find((k) => !milestoneCompletion[k]) ?? null;
// 2026-05-21: a "stage-owning" milestone is the one that maps 1:1 with
// the deal's current pipelineStage column. When the rep manually
// jumps the stage forward (Reservation+) but earlier sub-statuses
// are still un-signed, we need the current-stage milestone to stay
// marked `'current'` regardless of completion — otherwise EOI gets
// flagged as NEXT STEP and the actual current stage hides under
// "Upcoming milestones". Earlier-than-stage milestones go to
// `'past'` so the rep can render backfill controls against them.
const STAGE_TO_MILESTONE: Partial<Record<PipelineStage, (typeof order)[number]>> = {
eoi: 'eoi',
reservation: 'reservation',
deposit_paid: 'deposit',
contract: 'contract',
};
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => {
if (milestoneCompletion[k]) return 'past';
if (k === firstIncompleteKey) return 'current';
return 'future';
// Stage owns this milestone → always current, never collapsed.
if (stageOwnedMilestone === k) return 'current';
// Otherwise: completion drives past/future ordering when no stage
// owns a milestone (enquiry/qualified/nurturing stages).
if (!stageOwnedMilestone) {
if (milestoneCompletion[k]) return 'past';
if (k === firstIncompleteKey) return 'current';
return 'future';
}
// A stage DOES own a different milestone — bucket by position
// relative to it. Earlier slots go to `past` even if incomplete
// (the backfill controls live there); later slots go to `future`.
const idx = order.indexOf(k);
return idx < stageOwnedIdx ? 'past' : 'future';
};
const berthInterestPhase: Phase = phaseFor('berth_interest');
const eoiPhase: Phase = phaseFor('eoi');
@@ -730,9 +829,20 @@ function OverviewTab({
</Button>
</div>
) : null,
pastSummary: interest.dateEoiSigned
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
pastSummary: interest.dateEoiSigned ? (
`Signed ${formatDate(interest.dateEoiSigned)}`
) : (
<span className="inline-flex items-center gap-1.5">
<span>Completed</span>
<MilestoneBackfillButton
label="Set date"
disabled={mutation.isPending}
onConfirm={async (d) => {
await mutation.mutateAsync({ dateEoiSigned: d });
}}
/>
</span>
),
},
{
key: 'reservation',
@@ -748,9 +858,20 @@ function OverviewTab({
actionLabel: 'Mark reservation as signed',
},
],
pastSummary: interest.dateReservationSigned
? `Signed ${formatDate(interest.dateReservationSigned)}`
: 'Completed',
pastSummary: interest.dateReservationSigned ? (
`Signed ${formatDate(interest.dateReservationSigned)}`
) : (
<span className="inline-flex items-center gap-1.5">
<span>Completed</span>
<MilestoneBackfillButton
label="Set date"
disabled={mutation.isPending}
onConfirm={async (d) => {
await mutation.mutateAsync({ dateReservationSigned: d });
}}
/>
</span>
),
},
{
key: 'deposit',
@@ -780,9 +901,20 @@ function OverviewTab({
</span>
</div>
) : null,
pastSummary: interest.dateDepositReceived
? `Received ${formatDate(interest.dateDepositReceived)}`
: 'Recorded',
pastSummary: interest.dateDepositReceived ? (
`Received ${formatDate(interest.dateDepositReceived)}`
) : (
<span className="inline-flex items-center gap-1.5">
<span>Recorded</span>
<MilestoneBackfillButton
label="Set date"
disabled={mutation.isPending}
onConfirm={async (d) => {
await mutation.mutateAsync({ dateDepositReceived: d });
}}
/>
</span>
),
},
{
key: 'contract',
@@ -804,9 +936,20 @@ function OverviewTab({
actionLabel: 'Mark contract as signed',
},
],
pastSummary: interest.dateContractSigned
? `Signed ${formatDate(interest.dateContractSigned)}`
: 'Completed',
pastSummary: interest.dateContractSigned ? (
`Signed ${formatDate(interest.dateContractSigned)}`
) : (
<span className="inline-flex items-center gap-1.5">
<span>Completed</span>
<MilestoneBackfillButton
label="Set date"
disabled={mutation.isPending}
onConfirm={async (d) => {
await mutation.mutateAsync({ dateContractSigned: d });
}}
/>
</span>
),
},
];

View File

@@ -63,6 +63,7 @@ export const createInterestSchema = z.object({
dateEoiSent: z.coerce.date().nullable().optional(),
dateEoiSigned: z.coerce.date().nullable().optional(),
dateReservationSigned: z.coerce.date().nullable().optional(),
dateDepositReceived: z.coerce.date().nullable().optional(),
dateContractSent: z.coerce.date().nullable().optional(),
dateContractSigned: z.coerce.date().nullable().optional(),
pipelineStage: z.enum(PIPELINE_STAGES).default('enquiry'),