From 2edfb11469348e6456b35f34e40401fe235914e9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 17:10:43 +0000 Subject: [PATCH 1/5] Surface "Removed from Bookings" and "Removed from Program" as distinct stages Properties that are intentionally not progressing (bookingStatus = "Do Not Book" with no outcome, or batch = "Removed from Program") were landing in the "Queries" bucket, inflating it with non-actionable rows. Two new terminal DisplayStage values now classify these explicitly, with precedence Removed from Program > Removed from Bookings > Queries. Both are excluded from pipeline funnel and stage-progress denominators (sibling to Queries) and surface as their own cards under "Excluded from Pipeline" on the analytics tab. Drill-down rows in Survey Issues get slate chips when a deal carries either flag, preserving outcome history for properties surveyed before being de-scoped. Also removes the unused SurveyedResultsPieChart chain (component, computeOutcomeSlices, OutcomeSlice, outcomePieSlices field). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../your-projects/live/AnalyticsView.tsx | 8 +- .../your-projects/live/DrillDownTable.tsx | 40 +++++ .../live/ExcludedFromPipelinePanel.tsx | 132 ++++++++++++++++ .../your-projects/live/LiveTracker.tsx | Bin 22565 -> 22482 bytes .../your-projects/live/PropertyTable.tsx | 2 + .../live/SurveyedResultsPieChart.tsx | 145 ------------------ .../your-projects/live/[dealId]/page.test.ts | 1 + .../your-projects/live/dealQuery.ts | 1 + .../your-projects/live/docStatus.test.ts | 1 + .../your-projects/live/measureFilters.test.ts | 1 + .../your-projects/live/transforms.test.ts | 107 +++++++++---- .../your-projects/live/transforms.ts | 56 +++---- .../(portfolio)/your-projects/live/types.ts | 25 +-- 13 files changed, 295 insertions(+), 224 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx delete mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx index 6479501..b41971b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -4,10 +4,10 @@ import { useState } from "react"; import { motion } from "framer-motion"; import { Home, AlertTriangle, ToggleLeft, ToggleRight } from "lucide-react"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; -import SurveyedResultsPieChart from "./SurveyedResultsPieChart"; import DampMouldRiskPanel from "./DampMouldRiskPanel"; import CompletionTrendsChart from "./CompletionTrendsChart"; import SurveyIssuesPanel from "./SurveyIssuesPanel"; +import ExcludedFromPipelinePanel from "./ExcludedFromPipelinePanel"; import GroupFilter, { type GroupNode } from "./GroupFilter"; import { STAGE_COLORS, STAGE_ORDER } from "./types"; import type { @@ -429,6 +429,12 @@ export default function AnalyticsView({ deals={currentProject.allDeals} onOpenTable={onOpenTable} /> + + {/* Row 7: Excluded from Pipeline (Removed from Bookings / Removed from Program) */} + ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx index c5b314d..a20476a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx @@ -47,6 +47,43 @@ function DampMouldBadgeCell({ value }: { value: unknown }) { ); } +function RemovalFlagChip({ label, tooltip }: { label: string; tooltip: string }) { + return ( + + {label} + + ); +} + +function DealNameCell({ deal }: { deal: ClassifiedDeal }) { + const name = deal.dealname; + const isDoNotBook = deal.bookingStatus === "Do Not Book"; + const isRemovedFromProgram = deal.batch === "Removed from Program"; + + return ( +
+ + {name ?? } + + {isDoNotBook && ( + + )} + {isRemovedFromProgram && ( + + )} +
+ ); +} + function DampMouldCommentCell({ value }: { value: unknown }) { const comment = typeof value === "string" ? value.trim() : ""; @@ -183,6 +220,9 @@ export default function DrillDownTable({ ), cell: ({ row }) => { const value = row.original[key as keyof ClassifiedDeal]; + if (key === "dealname") { + return ; + } if (key === "majorConditionIssuePhotosS3") { return ; } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx new file mode 100644 index 0000000..5ca62fc --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { motion } from "framer-motion"; +import { CircleOff } from "lucide-react"; +import { Card, CardContent } from "@/app/shadcn_components/ui/card"; +import type { ClassifiedDeal } from "./types"; + +const REMOVED_FROM_BOOKINGS_COLUMNS: (keyof ClassifiedDeal)[] = [ + "dealname", + "landlordPropertyId", + "coordinator", + "confirmedSurveyDate", + "propertyHaltedDate", + "propertyHaltedReason", +]; +const REMOVED_FROM_BOOKINGS_LABELS: Partial> = { + dealname: "Address", + landlordPropertyId: "Ref", + coordinator: "Coordinator", + confirmedSurveyDate: "Confirmed Survey Date", + propertyHaltedDate: "Halted Date", + propertyHaltedReason: "Halted Reason", +}; + +const REMOVED_FROM_PROGRAM_COLUMNS: (keyof ClassifiedDeal)[] = [ + "dealname", + "landlordPropertyId", + "batchDescription", + "coordinator", + "propertyHaltedDate", + "propertyHaltedReason", +]; +const REMOVED_FROM_PROGRAM_LABELS: Partial> = { + dealname: "Address", + landlordPropertyId: "Ref", + batchDescription: "Group", + coordinator: "Coordinator", + propertyHaltedDate: "Halted Date", + propertyHaltedReason: "Halted Reason", +}; + +interface ExcludedFromPipelinePanelProps { + deals: ClassifiedDeal[]; + onOpenTable: ( + stage: string, + deals: ClassifiedDeal[], + columns?: (keyof ClassifiedDeal)[], + columnLabels?: Partial>, + breakdown?: Record, + ) => void; +} + +export default function ExcludedFromPipelinePanel({ + deals, + onOpenTable, +}: ExcludedFromPipelinePanelProps) { + const removedFromBookings = deals.filter( + (d) => d.displayStage === "Removed from Bookings", + ); + const removedFromProgram = deals.filter( + (d) => d.displayStage === "Removed from Program", + ); + + if (removedFromBookings.length === 0 && removedFromProgram.length === 0) return null; + + return ( + + +
+ +

+ Excluded from Pipeline +

+
+

+ Terminal states — properties intentionally not progressing through the pipeline. +

+ +
+ {removedFromBookings.length > 0 && ( + + onOpenTable( + "Removed from Bookings", + removedFromBookings, + REMOVED_FROM_BOOKINGS_COLUMNS, + REMOVED_FROM_BOOKINGS_LABELS, + ) + } + className="group text-left rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-4 hover:border-slate-300 hover:shadow-md transition-all duration-200" + > +

+ Removed from Bookings +

+

+ {removedFromBookings.length} +

+

+ Removed from the bookings list before a survey took place +

+
+ )} + {removedFromProgram.length > 0 && ( + + onOpenTable( + "Removed from Program", + removedFromProgram, + REMOVED_FROM_PROGRAM_COLUMNS, + REMOVED_FROM_PROGRAM_LABELS, + ) + } + className="group text-left rounded-xl border border-slate-200 bg-gradient-to-br from-slate-50 to-white p-4 hover:border-slate-300 hover:shadow-md transition-all duration-200" + > +

+ Removed from Program +

+

+ {removedFromProgram.length} +

+

+ Removed from the project entirely +

+
+ )} +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index dd8b3bf4e046d27e068de2fb21edd7c57d5d80d5..b8a67a3a34f6ff54fdfa00cadedde3d0dbb06dde 100644 GIT binary patch delta 19 bcmZ3wf$`FM#tjFUHeX|M5#JmxbuT*X!MAtC{p5W!+AtV-WV HKC=Y?=dT~- diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index dd90dc4..b9acc29 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -251,6 +251,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh ))} Queries + Removed from Bookings + Removed from Program diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx deleted file mode 100644 index f1e46f3..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyedResultsPieChart.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { DonutChart, Card, Title } from "@tremor/react"; -import { useState } from "react"; -import type { OutcomeSlice, ClassifiedDeal } from "./types"; - -interface SurveyedPieChartProps { - slices: OutcomeSlice[]; - dealsByOutcome: Record; - onOpenTable?: (outcome: string, filteredDeals: ClassifiedDeal[]) => void; -} - -export default function SurveyedResultsPieChart({ - slices, - dealsByOutcome, - onOpenTable, -}: SurveyedPieChartProps) { - const colors = [ - "indigo-600", - "indigo-400", - "blue-300", - "amber-400", - "amber-200", - "slate-400", - "gray-300", - "gray-100", - "gray-200", - ]; - - const [hovered, setHovered] = useState(null); - - const handleClick = (slice: OutcomeSlice) => { - if (!slice) return; - const filteredDeals = dealsByOutcome[slice.name] ?? []; - onOpenTable?.(slice.name, filteredDeals); - }; - - // Don't show the chart if there's no data - if (slices.length === 0) { - return null; - } - - // Convert OutcomeSlice to chart data format - const chartData = slices.map((slice) => ({ - name: slice.name, - amount: slice.amount, - percentage: slice.percentage, - })); - - return ( - - {/* Header */} -
- - Survey Performance - -

- Click a segment or label to view filtered properties -

-
- - {/* Donut Chart (Centered) */} -
- `${n.toLocaleString()}`} - colors={colors} - onValueChange={handleClick} - showLabel={false} - className="w-64 h-64 cursor-pointer" - customTooltip={({ payload }) => { - const item = payload?.[0]?.payload; - if (!item) return null; - const { name, amount } = item; - return ( -
-
- - {name} - - - {amount.toLocaleString()} - -
-
- ); - }} - /> - {slices.length > 0 && ( -
- - {slices.reduce((a, b) => a + b.amount, 0)} - -
- )} -
- - {/* Legend (Clean Grid Layout) */} -
- {slices.map((slice, idx) => ( - - ))} -
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts index 0b2f2d7..26281cd 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts @@ -162,6 +162,7 @@ const mockDealRow = { majorConditionIssuePhotosS3: null, coordinationStatus: null, designStatus: null, + bookingStatus: null, pashubLink: null, sharepointLink: null, dampmouldGrowth: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts index 8f65919..a913087 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts @@ -31,6 +31,7 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal { majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3, coordinationStatus: d.coordinationStatus, designStatus: d.designStatus, + bookingStatus: d.bookingStatus, pashubLink: d.pashubLink, sharepointLink: d.sharepointLink, dampMouldFlag: d.dampmouldGrowth, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts index 38c1490..b848279 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts @@ -20,6 +20,7 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { majorConditionIssuePhotosS3: null, coordinationStatus: null, designStatus: null, + bookingStatus: null, pashubLink: null, sharepointLink: null, dampMouldFlag: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts index 9398b9a..faaf452 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts @@ -19,6 +19,7 @@ function makeDeal(overrides: Partial = {}): ClassifiedDeal { majorConditionIssuePhotosS3: null, coordinationStatus: null, designStatus: null, + bookingStatus: null, pashubLink: null, sharepointLink: null, dampMouldFlag: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index 9145af8..5d98e0b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -5,7 +5,6 @@ import { computeDampMouldRisk, computeFunnelStages, computeProjectProgress, - computeOutcomeSlices, computeLiveTrackerData, } from "./transforms"; import type { HubspotDeal, ClassifiedDeal } from "./types"; @@ -27,6 +26,7 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { majorConditionIssuePhotosS3: null, coordinationStatus: null, designStatus: null, + bookingStatus: null, pashubLink: null, sharepointLink: null, dampMouldFlag: null, @@ -132,6 +132,62 @@ describe("resolveDisplayStage — RA ISSUE override on non-AFTER_ASSESSMENT stag }); }); +describe("resolveDisplayStage — Removed from Bookings", () => { + it("classifies as Removed from Bookings when bookingStatus is Do Not Book and no outcome", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: "1617223910", + bookingStatus: "Do Not Book", + outcome: null, + })) + ).toBe("Removed from Bookings"); + }); + + it("falls back to normal stage resolution when bookingStatus is Do Not Book but an outcome is set", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: "1617223910", + bookingStatus: "Do Not Book", + outcome: "Tenant Refusal", + })) + ).toBe("Scope & Planning"); + }); +}); + +describe("resolveDisplayStage — Removed from Program", () => { + it("classifies as Removed from Program when batch is 'Removed from Program' regardless of dealstage", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: "1617223913", + batch: "Removed from Program", + })) + ).toBe("Removed from Program"); + }); + + it("Removed from Program takes precedence over Removed from Bookings", () => { + expect( + resolveDisplayStage(makeDeal({ + batch: "Removed from Program", + bookingStatus: "Do Not Book", + outcome: null, + })) + ).toBe("Removed from Program"); + }); +}); + +describe("resolveDisplayStage — precedence between new stages and existing classification", () => { + it("Removed from Bookings takes precedence over a Queries dealstage", () => { + // "2663668937" is a Queries-mapped dealstage; without Do Not Book it'd resolve to "Queries" + expect( + resolveDisplayStage(makeDeal({ + dealstage: "2663668937", + bookingStatus: "Do Not Book", + outcome: null, + })) + ).toBe("Removed from Bookings"); + }); +}); + describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => { const AFTER_ASSESSMENT_STAGE = "3948185842"; @@ -399,6 +455,17 @@ describe("computeFunnelStages", () => { const funnel = computeFunnelStages([]); expect(funnel.length).toBe(10); // STAGE_ORDER has 10 entries }); + + it("excludes Removed from Bookings and Removed from Program from counts and percentages", () => { + const deals = [ + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Removed from Bookings" }), + makeClassified({ displayStage: "Removed from Program" }), + ]; + const funnel = computeFunnelStages(deals); + const scopeEntry = funnel.find((f) => f.stage === "Scope & Planning")!; + expect(scopeEntry.currentPct).toBe(100); // 1 out of 1 in-pipeline deal + }); }); // ----------------------------------------------------------------------- @@ -454,40 +521,16 @@ describe("computeProjectProgress", () => { const result = computeProjectProgress([makeClassified({ displayStage: "Queries" })]); expect(result.stageProgress.find((s) => s.stage === "Queries")).toBeUndefined(); }); -}); -// ----------------------------------------------------------------------- -// computeOutcomeSlices -// ----------------------------------------------------------------------- - -describe("computeOutcomeSlices", () => { - it("counts only known SURVEYOR_OUTCOMES values", () => { + it("excludes Removed from Bookings and Removed from Program from nonQueryTotal", () => { const deals = [ - makeClassified({ outcome: "Surveyed" }), - makeClassified({ outcome: "Surveyed" }), - makeClassified({ outcome: "UNKNOWN_VALUE" }), - makeClassified({ outcome: null }), + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Removed from Bookings" }), + makeClassified({ displayStage: "Removed from Program" }), ]; - const slices = computeOutcomeSlices(deals); - expect(slices).toHaveLength(1); - expect(slices[0].name).toBe("Surveyed"); - expect(slices[0].amount).toBe(2); - }); - - it("computes percentage as a formatted string with one decimal place", () => { - const deals = [ - makeClassified({ outcome: "Surveyed" }), - makeClassified({ outcome: "Surveyed" }), - makeClassified({ outcome: "Other" }), - makeClassified({ outcome: "Other" }), - ]; - const slices = computeOutcomeSlices(deals); - const surveyed = slices.find((s) => s.name === "Surveyed")!; - expect(surveyed.percentage).toBe("50.0"); - }); - - it("returns empty array when no deals have matching outcomes", () => { - expect(computeOutcomeSlices([makeClassified({ outcome: null })])).toEqual([]); + const result = computeProjectProgress(deals); + expect(result.totalDeals).toBe(3); + expect(result.nonQueryTotal).toBe(1); }); }); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index 5d78839..6171f20 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -9,7 +9,6 @@ import type { DisplayStage, ProjectProgressData, ProjectData, - OutcomeSlice, LiveTrackerProps, DampMouldRiskData, @@ -18,10 +17,17 @@ import type { import { STAGE_ORDER, - SURVEYOR_OUTCOMES, MAJOR_CONDITION_STAGE_ID, } from "./types"; +// Terminal stages that exit the pipeline by design — excluded from funnel +// counts and stage-progress denominators. +const STAGES_EXCLUDED_FROM_PIPELINE: ReadonlySet = new Set([ + "Queries", + "Removed from Bookings", + "Removed from Program", +]); + // ----------------------------------------------------------------------- // Stage ID -> raw label mapping // ----------------------------------------------------------------------- @@ -106,6 +112,14 @@ function resolvePostDesignStage(deal: HubspotDeal): DisplayStage { // Maps dealstage ID + coordination/design/install status -> DisplayStage // ----------------------------------------------------------------------- export function resolveDisplayStage(deal: HubspotDeal): DisplayStage { + if (deal.batch === "Removed from Program") { + return "Removed from Program"; + } + + if (deal.bookingStatus === "Do Not Book" && !deal.outcome) { + return "Removed from Bookings"; + } + const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT"; if (raw === "AFTER_ASSESSMENT") { @@ -170,7 +184,7 @@ export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData // Compute pipeline funnel — dual counts (current snapshot + cumulative) // ----------------------------------------------------------------------- export function computeFunnelStages(deals: ClassifiedDeal[]): FunnelStage[] { - const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries"); + const nonQueryDeals = deals.filter((d) => !STAGES_EXCLUDED_FROM_PIPELINE.has(d.displayStage)); const total = nonQueryDeals.length; return STAGE_ORDER.map((stage) => { @@ -201,7 +215,7 @@ export function computeProjectProgress( deals: ClassifiedDeal[] ): ProjectProgressData { const queriesDeals = deals.filter((d) => d.displayStage === "Queries"); - const nonQueryDeals = deals.filter((d) => d.displayStage !== "Queries"); + const nonQueryDeals = deals.filter((d) => !STAGES_EXCLUDED_FROM_PIPELINE.has(d.displayStage)); const nonQueryTotal = nonQueryDeals.length; // Stage counts/percentages (queries excluded from percentage calculation) @@ -210,7 +224,7 @@ export function computeProjectProgress( (stageBuckets[deal.displayStage] ??= []).push(deal); } - const stageProgress = STAGE_ORDER.filter((s) => s !== "Queries").map( + const stageProgress = STAGE_ORDER.filter((s) => !STAGES_EXCLUDED_FROM_PIPELINE.has(s)).map( (stage) => { const stageDeals = stageBuckets[stage] ?? []; return { @@ -243,34 +257,6 @@ export function computeProjectProgress( }; } -// ----------------------------------------------------------------------- -// Compute outcome pie slices for the surveyed pie chart -// ----------------------------------------------------------------------- -export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] { - const counts: Partial> = {}; - - for (const deal of deals) { - if ( - deal.outcome && - (SURVEYOR_OUTCOMES as readonly string[]).includes(deal.outcome) - ) { - counts[deal.outcome] = (counts[deal.outcome] ?? 0) + 1; - } - } - - const total = Object.values(counts).reduce( - (a, b) => a + (b ?? 0), - 0 - ); - - return Object.entries(counts).map(([name, amount]) => ({ - name, - amount: amount ?? 0, - percentage: - total > 0 ? (((amount ?? 0) / total) * 100).toFixed(1) : "0.0", - })); -} - // ----------------------------------------------------------------------- // Top-level function called by page.tsx // Orchestrates all transformations: classify, group by project, compute stats @@ -293,12 +279,11 @@ export function computeLiveTrackerData( (grouped[key] ??= []).push(deal); } - // For each project group, compute progress data and outcome slices + // For each project group, compute progress data const projects: ProjectData[] = Object.entries(grouped).map( ([projectCode, deals]) => ({ projectCode, progress: computeProjectProgress(deals), - outcomePieSlices: computeOutcomeSlices(deals), allDeals: deals, }) ); @@ -308,7 +293,6 @@ export function computeLiveTrackerData( projects.unshift({ projectCode: "__ALL__", progress: computeProjectProgress(classified), - outcomePieSlices: computeOutcomeSlices(classified), allDeals: classified, }); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index ed65920..c97b5db 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -23,6 +23,7 @@ export type HubspotDeal = { majorConditionIssuePhotosS3: string | null; coordinationStatus: string | null; designStatus: string | null; + bookingStatus: string | null; // ── CRM-synced additions ────────────────────────────────────────────── pashubLink: string | null; @@ -88,6 +89,8 @@ export type DisplayStage = | "At Post Survey" | "Project Complete" | "Queries" + | "Removed from Bookings" + | "Removed from Program" | "Unknown Stage"; // ----------------------------------------------------------------------- @@ -145,22 +148,12 @@ export type ProjectProgressData = { funnelStages: FunnelStage[]; }; -// ----------------------------------------------------------------------- -// Surveyed outcome entry (for pie chart) -// ----------------------------------------------------------------------- -export type OutcomeSlice = { - name: string; // outcome label - amount: number; - percentage: string; // pre-formatted "12.3" -}; - // ----------------------------------------------------------------------- // What LiveTracker receives from page.tsx for one project // ----------------------------------------------------------------------- export type ProjectData = { projectCode: string; progress: ProjectProgressData; - outcomePieSlices: OutcomeSlice[]; // empty array = hide pie chart allDeals: ClassifiedDeal[]; // for table drill-downs within project }; @@ -411,6 +404,18 @@ export const STAGE_COLORS: Record< border: "border-red-200", dot: "bg-red-400", }, + "Removed from Bookings": { + bg: "bg-slate-50", + text: "text-slate-600", + border: "border-slate-200", + dot: "bg-slate-400", + }, + "Removed from Program": { + bg: "bg-slate-100", + text: "text-slate-700", + border: "border-slate-300", + dot: "bg-slate-500", + }, "Unknown Stage": { bg: "bg-gray-50", text: "text-gray-500", From 9f552ad6496490365776ccc01d368fb1f757272e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 18:03:45 +0000 Subject: [PATCH 2/5] Reword "Excluded from Pipeline" card to "Halted or Removed" Softer, less technical language for landlord-facing copy. The subtitle now spells out the two states (halted before a survey, or removed from the project entirely) so the relationship between the section header and the two cards is explicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../your-projects/live/ExcludedFromPipelinePanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx index 5ca62fc..918d92e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx @@ -69,11 +69,11 @@ export default function ExcludedFromPipelinePanel({

- Excluded from Pipeline + Halted or Removed

- Terminal states — properties intentionally not progressing through the pipeline. + Properties no longer being progressed — either halted before a survey, or removed from the project entirely.

From 5ff99a636b1c04c642f3907f8235d6996a1247dd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 20:29:10 +0000 Subject: [PATCH 3/5] Add Surveyed Date and EPC Certificate Number as optional property columns Both surface in the column-toggle dropdown and the CSV export, hidden by default like the other optional columns. surveyedDate sits next to designDate (chronological survey -> design order); epcPrn sits after the lodgement date since the PRN is the output of lodgement. epcPrn was already in the DB schema but absent from the HubspotDeal type and mapper, so the plumbing is added alongside. Extracts the CSV-formatting logic out of PropertyTable into a pure propertyCsv module with tests, locking in the export contract (header order, en-GB date formatting, null cells empty) so future column drift is caught. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../your-projects/live/PropertyTable.tsx | 50 ++------ .../live/PropertyTableColumns.tsx | 27 ++++ .../your-projects/live/[dealId]/page.test.ts | 1 + .../your-projects/live/dealQuery.ts | 1 + .../your-projects/live/docStatus.test.ts | 1 + .../your-projects/live/measureFilters.test.ts | 1 + .../your-projects/live/propertyCsv.test.ts | 116 ++++++++++++++++++ .../your-projects/live/propertyCsv.ts | 52 ++++++++ .../your-projects/live/transforms.test.ts | 1 + .../(portfolio)/your-projects/live/types.ts | 1 + 10 files changed, 208 insertions(+), 43 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index b9acc29..348a5a1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -37,6 +37,7 @@ import { } from "@/app/shadcn_components/ui/select"; import { Search, SlidersHorizontal, ChevronLeft, ChevronRight, Download } from "lucide-react"; import { createPropertyTableColumns } from "./PropertyTableColumns"; +import { formatPropertyCsv } from "./propertyCsv"; import { STAGE_ORDER } from "./types"; import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal, EffectiveRemovalState } from "./types"; @@ -57,8 +58,10 @@ const COLUMN_LABELS: Record = { epcSapScore: "EPC SAP Score", epcSapScorePotential: "EPC SAP (Potential)", lodgementStatus: "Lodgement Status", + surveyedDate: "Surveyed Date", designDate: "Design Date", fullLodgementDate: "Lodgement Date", + epcPrn: "EPC Certificate Number", batch: "Group", batchDescription: "Group Description", }; @@ -75,41 +78,6 @@ interface PropertyTableProps { removalStatusByDeal?: RemovalStatusByDeal; } -const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [ - { key: "dealname", label: "Address" }, - { key: "landlordPropertyId", label: "Property Ref" }, - { key: "uprn", label: "UPRN" }, - { key: "displayStage", label: "Stage" }, - { key: "projectCode", label: "Project" }, - { key: "coordinator", label: "Coordinator" }, - { key: "designer", label: "Designer" }, - { key: "installer", label: "Installer" }, - { key: "proposedMeasures", label: "Proposed Measures" }, - { key: "approvedPackage", label: "Approved Package" }, - { key: "actualMeasuresInstalled", label: "Installed Measures" }, - { key: "preSapScore", label: "Pre-SAP" }, - { key: "eiScore", label: "EI Score" }, - { key: "eiScorePotential", label: "EI Score (Potential)" }, - { key: "epcSapScore", label: "EPC SAP Score" }, - { key: "epcSapScorePotential", label: "EPC SAP (Potential)" }, - { key: "lodgementStatus", label: "Lodgement Status" }, - { key: "designDate", label: "Design Date" }, - { key: "fullLodgementDate", label: "Lodgement Date" }, - { key: "batch", label: "Group" }, - { key: "batchDescription", label: "Group Description" }, -]; - -function escapeCell(value: unknown): string { - if (value === null || value === undefined) return ""; - const str = - value instanceof Date - ? value.toLocaleDateString("en-GB") - : String(value); - return str.includes(",") || str.includes('"') || str.includes("\n") - ? `"${str.replace(/"/g, '""')}"` - : str; -} - export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) { const [globalFilter, setGlobalFilter] = useState(""); const [stageFilter, setStageFilter] = useState("all"); @@ -132,8 +100,10 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh epcSapScore: false, epcSapScorePotential: false, lodgementStatus: false, + surveyedDate: false, designDate: false, fullLodgementDate: false, + epcPrn: false, batch: false, batchDescription: false, }); @@ -188,14 +158,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh }); const downloadCsv = () => { - const rows = table.getFilteredRowModel().rows; - const header = CSV_FIELDS.map((f) => f.label).join(","); - const body = rows - .map((row) => - CSV_FIELDS.map((f) => escapeCell(row.original[f.key])).join(",") - ) - .join("\n"); - const blob = new Blob([header + "\n" + body], { type: "text/csv;charset=utf-8;" }); + const rows = table.getFilteredRowModel().rows.map((r) => r.original); + const blob = new Blob([formatPropertyCsv(rows)], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx index 044acc0..b889d63 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx @@ -290,6 +290,21 @@ export function createPropertyTableColumns( ), }, + // ── Surveyed date ──────────────────────────────────────────────────── + { + accessorKey: "surveyedDate", + id: "surveyedDate", + header: ({ column }) => , + cell: ({ row }) => { + const d = row.original.surveyedDate; + return ( + + {d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : } + + ); + }, + }, + // ── Design date ────────────────────────────────────────────────────── { accessorKey: "designDate", @@ -320,6 +335,18 @@ export function createPropertyTableColumns( }, }, + // ── EPC certificate number ─────────────────────────────────────────── + { + accessorKey: "epcPrn", + id: "epcPrn", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.epcPrn ?? } + + ), + }, + // ── Group ──────────────────────────────────────────────────────────── { accessorKey: "batch", diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts index 26281cd..c8bf7a1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.test.ts @@ -188,6 +188,7 @@ const mockDealRow = { eiScorePotential: null, epcSapScore: null, epcSapScorePotential: null, + epcPrn: null, surveyType: null, measuresForPibiOrdered: null, pibiOrderDate: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts index a913087..0532ab3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/dealQuery.ts @@ -59,6 +59,7 @@ export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal { eiScorePotential: d.eiScorePotential, epcSapScore: d.epcSapScore, epcSapScorePotential: d.epcSapScorePotential, + epcPrn: d.epcPrn, surveyType: d.surveyType, measuresForPibiOrdered: d.measuresForPibiOrdered, pibiOrderDate: d.pibiOrderDate, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts index b848279..029e411 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts @@ -48,6 +48,7 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { eiScorePotential: null, epcSapScore: null, epcSapScorePotential: null, + epcPrn: null, surveyType: null, measuresForPibiOrdered: null, pibiOrderDate: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts index faaf452..9166e8b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts @@ -47,6 +47,7 @@ function makeDeal(overrides: Partial = {}): ClassifiedDeal { eiScorePotential: null, epcSapScore: null, epcSapScorePotential: null, + epcPrn: null, surveyType: null, measuresForPibiOrdered: null, pibiOrderDate: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts new file mode 100644 index 0000000..da315e1 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { formatPropertyCsv, PROPERTY_CSV_FIELDS } from "./propertyCsv"; +import type { ClassifiedDeal, HubspotDeal } from "./types"; + +function makeDeal(overrides: Partial = {}): HubspotDeal { + return { + id: "1", + dealId: "deal-1", + dealname: "Test Property", + dealstage: null, + companyId: null, + projectCode: null, + landlordPropertyId: null, + uprn: null, + outcome: null, + outcomeNotes: null, + majorConditionIssueDescription: null, + majorConditionIssuePhotos: null, + majorConditionIssuePhotosS3: null, + coordinationStatus: null, + designStatus: null, + bookingStatus: null, + pashubLink: null, + sharepointLink: null, + dampMouldFlag: null, + dampMouldAndRepairComments: null, + preSapScore: null, + coordinator: null, + ioeV1Date: null, + ioeV2Date: null, + ioeV3Date: null, + proposedMeasures: null, + approvedPackage: null, + designer: null, + designDate: null, + actualMeasuresInstalled: null, + installer: null, + installerHandover: null, + lodgementStatus: null, + measuresLodgementDate: null, + fullLodgementDate: null, + confirmedSurveyDate: null, + confirmedSurveyTime: null, + surveyedDate: null, + designType: null, + eiScore: null, + eiScorePotential: null, + epcSapScore: null, + epcSapScorePotential: null, + epcPrn: null, + surveyType: null, + measuresForPibiOrdered: null, + pibiOrderDate: null, + pibiCompletedDate: null, + propertyHaltedDate: null, + propertyHaltedReason: null, + technicalApprovedMeasuresForInstall: null, + domnaSurveyType: null, + domnaSurveyDate: null, + batch: null, + batchDescription: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeClassified(overrides: Partial = {}): ClassifiedDeal { + return { ...makeDeal(overrides), displayStage: "Scope & Planning", ...overrides }; +} + +function headerCells(csv: string): string[] { + return csv.split("\n")[0].split(","); +} + +function rowCells(csv: string, rowIndex: number): string[] { + return csv.split("\n")[rowIndex + 1].split(","); +} + +describe("formatPropertyCsv", () => { + it("includes Surveyed Date and EPC Certificate Number headers, with values for a populated deal", () => { + const deal = makeClassified({ + surveyedDate: new Date("2026-03-15T00:00:00Z"), + epcPrn: "1234-5678-9012-3456-7890", + }); + + const csv = formatPropertyCsv([deal]); + const headers = headerCells(csv); + const cells = rowCells(csv, 0); + + const surveyedIdx = headers.indexOf("Surveyed Date"); + const prnIdx = headers.indexOf("EPC Certificate Number"); + + expect(surveyedIdx).toBeGreaterThan(-1); + expect(prnIdx).toBeGreaterThan(-1); + expect(cells[surveyedIdx]).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + expect(cells[prnIdx]).toBe("1234-5678-9012-3456-7890"); + }); + + it("renders null fields as empty cells", () => { + const deal = makeClassified({ surveyedDate: null, epcPrn: null }); + + const csv = formatPropertyCsv([deal]); + const headers = headerCells(csv); + const cells = rowCells(csv, 0); + + expect(cells[headers.indexOf("Surveyed Date")]).toBe(""); + expect(cells[headers.indexOf("EPC Certificate Number")]).toBe(""); + }); + + it("emits header row alone when given no rows", () => { + const csv = formatPropertyCsv([]); + expect(csv.split("\n")).toHaveLength(1); + expect(headerCells(csv)).toEqual(PROPERTY_CSV_FIELDS.map((f) => f.label)); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts new file mode 100644 index 0000000..37de646 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyCsv.ts @@ -0,0 +1,52 @@ +import type { ClassifiedDeal } from "./types"; + +export type PropertyCsvField = { key: keyof ClassifiedDeal; label: string }; + +export const PROPERTY_CSV_FIELDS: PropertyCsvField[] = [ + { key: "dealname", label: "Address" }, + { key: "landlordPropertyId", label: "Property Ref" }, + { key: "uprn", label: "UPRN" }, + { key: "displayStage", label: "Stage" }, + { key: "projectCode", label: "Project" }, + { key: "coordinator", label: "Coordinator" }, + { key: "designer", label: "Designer" }, + { key: "installer", label: "Installer" }, + { key: "proposedMeasures", label: "Proposed Measures" }, + { key: "approvedPackage", label: "Approved Package" }, + { key: "actualMeasuresInstalled", label: "Installed Measures" }, + { key: "preSapScore", label: "Pre-SAP" }, + { key: "eiScore", label: "EI Score" }, + { key: "eiScorePotential", label: "EI Score (Potential)" }, + { key: "epcSapScore", label: "EPC SAP Score" }, + { key: "epcSapScorePotential", label: "EPC SAP (Potential)" }, + { key: "lodgementStatus", label: "Lodgement Status" }, + { key: "surveyedDate", label: "Surveyed Date" }, + { key: "designDate", label: "Design Date" }, + { key: "fullLodgementDate", label: "Lodgement Date" }, + { key: "epcPrn", label: "EPC Certificate Number" }, + { key: "batch", label: "Group" }, + { key: "batchDescription", label: "Group Description" }, +]; + +export function escapeCsvCell(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = + value instanceof Date + ? value.toLocaleDateString("en-GB") + : String(value); + return str.includes(",") || str.includes('"') || str.includes("\n") + ? `"${str.replace(/"/g, '""')}"` + : str; +} + +export function formatPropertyCsv( + rows: ClassifiedDeal[], + fields: PropertyCsvField[] = PROPERTY_CSV_FIELDS, +): string { + const header = fields.map((f) => f.label).join(","); + if (rows.length === 0) return header; + const body = rows + .map((row) => fields.map((f) => escapeCsvCell(row[f.key])).join(",")) + .join("\n"); + return header + "\n" + body; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index 5d98e0b..3d76862 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -54,6 +54,7 @@ function makeDeal(overrides: Partial = {}): HubspotDeal { eiScorePotential: null, epcSapScore: null, epcSapScorePotential: null, + epcPrn: null, surveyType: null, measuresForPibiOrdered: null, pibiOrderDate: null, diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index c97b5db..1f4b989 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -53,6 +53,7 @@ export type HubspotDeal = { eiScorePotential: string | null; epcSapScore: string | null; epcSapScorePotential: string | null; + epcPrn: string | null; // ── New per-deal workflow fields (issue #249 slice) ──────────────────── surveyType: string | null; From 56fdfa06e4e047abb25b8303f83eb2717c1e00ca Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 12:05:53 +0000 Subject: [PATCH 4/5] Only let surveyed outcomes override a Do Not Book status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../your-projects/live/DrillDownTable.tsx | 7 --- .../your-projects/live/SurveyIssuesPanel.tsx | 12 ++-- .../your-projects/live/transforms.test.ts | 57 ++++++++++++++++++- .../your-projects/live/transforms.ts | 6 +- .../(portfolio)/your-projects/live/types.ts | 8 +++ 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx index a20476a..38a05ce 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DrillDownTable.tsx @@ -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 }) { {name ?? } - {isDoNotBook && ( - - )} {isRemovedFromProgram && ( 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; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index 3d76862..a60db64 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -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"); }); }); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index 6171f20..d36cda5 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -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"; } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index 1f4b989..f53d71a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -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 = 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) From f7b14027900f4abb78800e19302b1342e054c897 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 29 May 2026 12:17:38 +0000 Subject: [PATCH 5/5] Swap coordinator/survey-date columns for outcome notes in Removed from Bookings Removed properties have no coordinator or confirmed survey date by definition, so those columns were always blank. Outcome notes are the field that actually explains why the property dropped out of bookings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../your-projects/live/ExcludedFromPipelinePanel.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx index 918d92e..8acf838 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx @@ -8,16 +8,14 @@ import type { ClassifiedDeal } from "./types"; const REMOVED_FROM_BOOKINGS_COLUMNS: (keyof ClassifiedDeal)[] = [ "dealname", "landlordPropertyId", - "coordinator", - "confirmedSurveyDate", + "outcomeNotes", "propertyHaltedDate", "propertyHaltedReason", ]; const REMOVED_FROM_BOOKINGS_LABELS: Partial> = { dealname: "Address", landlordPropertyId: "Ref", - coordinator: "Coordinator", - confirmedSurveyDate: "Confirmed Survey Date", + outcomeNotes: "Outcome Notes", propertyHaltedDate: "Halted Date", propertyHaltedReason: "Halted Reason", };