From 5ff99a636b1c04c642f3907f8235d6996a1247dd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 20:29:10 +0000 Subject: [PATCH] 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 b9acc29f..348a5a11 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 044acc04..b889d636 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 26281cd3..c8bf7a11 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 a9130874..0532ab36 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 b8482799..029e4113 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 faaf452a..9166e8b5 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 00000000..da315e1a --- /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 00000000..37de6463 --- /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 5d98e0b0..3d76862b 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 c97b5dba..1f4b9892 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;