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'
|
| 'source'
|
||||||
| 'desiredLengthFt'
|
| 'desiredLengthFt'
|
||||||
| 'desiredWidthFt'
|
| 'desiredWidthFt'
|
||||||
| 'desiredDraftFt';
|
| 'desiredDraftFt'
|
||||||
|
| 'dateEoiSent'
|
||||||
|
| 'dateEoiSigned'
|
||||||
|
| 'dateReservationSigned'
|
||||||
|
| 'dateDepositReceived'
|
||||||
|
| 'dateContractSent'
|
||||||
|
| 'dateContractSigned';
|
||||||
|
|
||||||
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
||||||
value: 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({
|
function MilestoneSection({
|
||||||
title,
|
title,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -616,10 +688,37 @@ function OverviewTab({
|
|||||||
} as const;
|
} as const;
|
||||||
const order = ['berth_interest', 'eoi', 'reservation', 'deposit', 'contract'] as const;
|
const order = ['berth_interest', 'eoi', 'reservation', 'deposit', 'contract'] as const;
|
||||||
const firstIncompleteKey = order.find((k) => !milestoneCompletion[k]) ?? null;
|
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 => {
|
const phaseFor = (k: (typeof order)[number]): Phase => {
|
||||||
if (milestoneCompletion[k]) return 'past';
|
// Stage owns this milestone → always current, never collapsed.
|
||||||
if (k === firstIncompleteKey) return 'current';
|
if (stageOwnedMilestone === k) return 'current';
|
||||||
return 'future';
|
// 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 berthInterestPhase: Phase = phaseFor('berth_interest');
|
||||||
const eoiPhase: Phase = phaseFor('eoi');
|
const eoiPhase: Phase = phaseFor('eoi');
|
||||||
@@ -730,9 +829,20 @@ function OverviewTab({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
pastSummary: interest.dateEoiSigned
|
pastSummary: interest.dateEoiSigned ? (
|
||||||
? `Signed ${formatDate(interest.dateEoiSigned)}`
|
`Signed ${formatDate(interest.dateEoiSigned)}`
|
||||||
: 'Completed',
|
) : (
|
||||||
|
<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',
|
key: 'reservation',
|
||||||
@@ -748,9 +858,20 @@ function OverviewTab({
|
|||||||
actionLabel: 'Mark reservation as signed',
|
actionLabel: 'Mark reservation as signed',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pastSummary: interest.dateReservationSigned
|
pastSummary: interest.dateReservationSigned ? (
|
||||||
? `Signed ${formatDate(interest.dateReservationSigned)}`
|
`Signed ${formatDate(interest.dateReservationSigned)}`
|
||||||
: 'Completed',
|
) : (
|
||||||
|
<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',
|
key: 'deposit',
|
||||||
@@ -780,9 +901,20 @@ function OverviewTab({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
pastSummary: interest.dateDepositReceived
|
pastSummary: interest.dateDepositReceived ? (
|
||||||
? `Received ${formatDate(interest.dateDepositReceived)}`
|
`Received ${formatDate(interest.dateDepositReceived)}`
|
||||||
: 'Recorded',
|
) : (
|
||||||
|
<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',
|
key: 'contract',
|
||||||
@@ -804,9 +936,20 @@ function OverviewTab({
|
|||||||
actionLabel: 'Mark contract as signed',
|
actionLabel: 'Mark contract as signed',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
pastSummary: interest.dateContractSigned
|
pastSummary: interest.dateContractSigned ? (
|
||||||
? `Signed ${formatDate(interest.dateContractSigned)}`
|
`Signed ${formatDate(interest.dateContractSigned)}`
|
||||||
: 'Completed',
|
) : (
|
||||||
|
<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(),
|
dateEoiSent: z.coerce.date().nullable().optional(),
|
||||||
dateEoiSigned: z.coerce.date().nullable().optional(),
|
dateEoiSigned: z.coerce.date().nullable().optional(),
|
||||||
dateReservationSigned: z.coerce.date().nullable().optional(),
|
dateReservationSigned: z.coerce.date().nullable().optional(),
|
||||||
|
dateDepositReceived: z.coerce.date().nullable().optional(),
|
||||||
dateContractSent: z.coerce.date().nullable().optional(),
|
dateContractSent: z.coerce.date().nullable().optional(),
|
||||||
dateContractSigned: z.coerce.date().nullable().optional(),
|
dateContractSigned: z.coerce.date().nullable().optional(),
|
||||||
pipelineStage: z.enum(PIPELINE_STAGES).default('enquiry'),
|
pipelineStage: z.enum(PIPELINE_STAGES).default('enquiry'),
|
||||||
|
|||||||
Reference in New Issue
Block a user