Only let surveyed outcomes override a Do Not Book status

Previously, any HubSpot outcome would override bookingStatus="Do Not Book"
and keep the property in the normal pipeline. That was too permissive —
outcomes like "Tenant Refusal" or "Not Viable" combined with Do Not Book
should classify the property as Removed from Bookings, not lurk in Queries
or the survey-issues bucket.

Now only completed survey outcomes (Surveyed, Surveyed - Pending Upload,
EPC Completed) override Do Not Book. Any other outcome + Do Not Book
falls through to Removed from Bookings, surfaces in the Halted or Removed
panel, and gets the matching stage badge in the Properties tab. The
redundant "Removed from Bookings" chip in the drill-down table is gone
since the stage classification now carries that signal cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-29 12:05:53 +00:00
parent 5ff99a636b
commit 56fdfa06e4
5 changed files with 77 additions and 13 deletions

View file

@ -60,7 +60,6 @@ function RemovalFlagChip({ label, tooltip }: { label: string; tooltip: string })
function DealNameCell({ deal }: { deal: ClassifiedDeal }) {
const name = deal.dealname;
const isDoNotBook = deal.bookingStatus === "Do Not Book";
const isRemovedFromProgram = deal.batch === "Removed from Program";
return (
@ -68,12 +67,6 @@ function DealNameCell({ deal }: { deal: ClassifiedDeal }) {
<span className="text-sm text-gray-800">
{name ?? <span className="text-gray-300"></span>}
</span>
{isDoNotBook && (
<RemovalFlagChip
label="Removed from Bookings"
tooltip="bookingStatus is Do Not Book in HubSpot. If an outcome is set above, it is real history; no further action expected."
/>
)}
{isRemovedFromProgram && (
<RemovalFlagChip
label="Removed from Program"

View file

@ -4,8 +4,7 @@ import { motion } from "framer-motion";
import { AlertCircle } from "lucide-react";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import type { ClassifiedDeal } from "./types";
const SUCCESSFUL_OUTCOMES = new Set(["Surveyed", "Surveyed - Pending Upload", "EPC Completed"]);
import { SUCCESSFUL_SURVEY_OUTCOMES } from "./types";
const COLUMNS: (keyof ClassifiedDeal)[] = [
"dealname",
@ -35,9 +34,14 @@ export default function SurveyIssuesPanel({
deals,
onOpenTable,
}: SurveyIssuesPanelProps) {
// Filter to deals with a populated outcome that is not a success
// Deals with a non-successful outcome that are still in the active pipeline.
// DNB-overridden rows are classified as "Removed from Bookings" and surfaced
// in the Halted or Removed panel instead — exclude them here to avoid double-counting.
const issueDeals = deals.filter(
(d) => d.outcome && !SUCCESSFUL_OUTCOMES.has(d.outcome),
(d) =>
d.outcome &&
!SUCCESSFUL_SURVEY_OUTCOMES.has(d.outcome) &&
d.displayStage !== "Removed from Bookings",
);
if (issueDeals.length === 0) return null;

View file

@ -144,13 +144,68 @@ describe("resolveDisplayStage — Removed from Bookings", () => {
).toBe("Removed from Bookings");
});
it("falls back to normal stage resolution when bookingStatus is Do Not Book but an outcome is set", () => {
it("classifies as Removed from Bookings when bookingStatus is Do Not Book and outcome is a non-successful survey outcome", () => {
// Tenant Refusal is a non-successful outcome; with DNB the property is removed from bookings,
// not lingering in the normal pipeline.
expect(
resolveDisplayStage(makeDeal({
dealstage: "1617223910",
bookingStatus: "Do Not Book",
outcome: "Tenant Refusal",
}))
).toBe("Removed from Bookings");
});
it("falls back to normal stage resolution when bookingStatus is Do Not Book but outcome is Surveyed", () => {
// Real survey history overrides DNB — the property stays in the normal pipeline.
expect(
resolveDisplayStage(makeDeal({
dealstage: "1617223910",
bookingStatus: "Do Not Book",
outcome: "Surveyed",
}))
).toBe("Scope & Planning");
});
it("falls back to normal stage resolution when bookingStatus is Do Not Book but outcome is Surveyed - Pending Upload", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: "1617223910",
bookingStatus: "Do Not Book",
outcome: "Surveyed - Pending Upload",
}))
).toBe("Scope & Planning");
});
it("falls back to normal stage resolution when bookingStatus is Do Not Book but outcome is EPC Completed", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: "1617223910",
bookingStatus: "Do Not Book",
outcome: "EPC Completed",
}))
).toBe("Scope & Planning");
});
it("classifies as Removed from Bookings for Not Viable + Do Not Book even when the dealstage maps to Queries", () => {
// "1887735998" is the Not Viable dealstage, which normally maps to "Queries".
// With DNB + a non-successful outcome, Removed from Bookings takes precedence over the Queries mapping.
expect(
resolveDisplayStage(makeDeal({
dealstage: "1887735998",
bookingStatus: "Do Not Book",
outcome: "Not Viable",
}))
).toBe("Removed from Bookings");
});
it("does not classify as Removed from Bookings when an outcome is set but bookingStatus is null", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: "1617223910",
bookingStatus: null,
outcome: "Surveyed",
}))
).toBe("Scope & Planning");
});
});

View file

@ -18,6 +18,7 @@ import type {
import {
STAGE_ORDER,
MAJOR_CONDITION_STAGE_ID,
SUCCESSFUL_SURVEY_OUTCOMES,
} from "./types";
// Terminal stages that exit the pipeline by design — excluded from funnel
@ -116,7 +117,10 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
return "Removed from Program";
}
if (deal.bookingStatus === "Do Not Book" && !deal.outcome) {
if (
deal.bookingStatus === "Do Not Book" &&
!(deal.outcome && SUCCESSFUL_SURVEY_OUTCOMES.has(deal.outcome))
) {
return "Removed from Bookings";
}

View file

@ -316,6 +316,14 @@ export const SURVEYOR_OUTCOMES = [
export type SurveyorOutcome = (typeof SURVEYOR_OUTCOMES)[number];
// Outcomes that represent real completed survey history. Take precedence over
// a "Do Not Book" booking status — the property stays in the normal pipeline.
export const SUCCESSFUL_SURVEY_OUTCOMES: ReadonlySet<string> = new Set([
"Surveyed",
"Surveyed - Pending Upload",
"EPC Completed",
]);
export const MAJOR_CONDITION_STAGE_ID = "3061261536" as const;
// Order of stages for grouping/display (queries excluded from this list)