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",