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..38a05ce 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,36 @@ 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 isRemovedFromProgram = deal.batch === "Removed from Program";
+
+ return (
+
+
+ {name ?? —}
+
+ {isRemovedFromProgram && (
+
+ )}
+
+ );
+}
+
function DampMouldCommentCell({ value }: { value: unknown }) {
const comment = typeof value === "string" ? value.trim() : "";
@@ -183,6 +213,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..8acf838
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ExcludedFromPipelinePanel.tsx
@@ -0,0 +1,130 @@
+"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",
+ "outcomeNotes",
+ "propertyHaltedDate",
+ "propertyHaltedReason",
+];
+const REMOVED_FROM_BOOKINGS_LABELS: Partial> = {
+ dealname: "Address",
+ landlordPropertyId: "Ref",
+ outcomeNotes: "Outcome Notes",
+ 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 (
+
+
+
+
+
+ Halted or Removed
+
+
+
+ Properties no longer being progressed — either halted before a survey, or removed from the project entirely.
+
+
+
+ {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 dd8b3bf..b8a67a3 100644
Binary files a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx and b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx differ
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..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;
@@ -251,6 +215,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/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/SurveyIssuesPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx
index 65cc8ab..9186f1a 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/SurveyIssuesPanel.tsx
@@ -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;
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..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
@@ -162,6 +162,7 @@ const mockDealRow = {
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
+ bookingStatus: null,
pashubLink: null,
sharepointLink: null,
dampmouldGrowth: null,
@@ -187,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 8f65919..0532ab3 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,
@@ -58,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 38c1490..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
@@ -20,6 +20,7 @@ function makeDeal(overrides: Partial = {}): HubspotDeal {
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
+ bookingStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
@@ -47,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 9398b9a..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
@@ -19,6 +19,7 @@ function makeDeal(overrides: Partial = {}): ClassifiedDeal {
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
+ bookingStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
@@ -46,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 9145af8..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
@@ -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,
@@ -54,6 +54,7 @@ function makeDeal(overrides: Partial = {}): HubspotDeal {
eiScorePotential: null,
epcSapScore: null,
epcSapScorePotential: null,
+ epcPrn: null,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
@@ -132,6 +133,117 @@ 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("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");
+ });
+});
+
+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 +511,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 +577,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..d36cda5 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,18 @@ import type {
import {
STAGE_ORDER,
- SURVEYOR_OUTCOMES,
MAJOR_CONDITION_STAGE_ID,
+ SUCCESSFUL_SURVEY_OUTCOMES,
} 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 +113,17 @@ 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 && SUCCESSFUL_SURVEY_OUTCOMES.has(deal.outcome))
+ ) {
+ return "Removed from Bookings";
+ }
+
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
if (raw === "AFTER_ASSESSMENT") {
@@ -170,7 +188,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 +219,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 +228,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 +261,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 +283,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 +297,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..f53d71a 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;
@@ -52,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;
@@ -88,6 +90,8 @@ export type DisplayStage =
| "At Post Survey"
| "Project Complete"
| "Queries"
+ | "Removed from Bookings"
+ | "Removed from Program"
| "Unknown Stage";
// -----------------------------------------------------------------------
@@ -145,22 +149,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
};
@@ -322,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)
@@ -411,6 +413,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",