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:
@@ -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'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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user