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;