mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
9f552ad649
commit
5ff99a636b
10 changed files with 208 additions and 43 deletions
|
|
@ -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<string, string> = {
|
|||
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<string>("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;
|
||||
|
|
|
|||
|
|
@ -290,6 +290,21 @@ export function createPropertyTableColumns(
|
|||
),
|
||||
},
|
||||
|
||||
// ── Surveyed date ────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "surveyedDate",
|
||||
id: "surveyedDate",
|
||||
header: ({ column }) => <SortableHeader label="Surveyed Date" column={column as any} />,
|
||||
cell: ({ row }) => {
|
||||
const d = row.original.surveyedDate;
|
||||
return (
|
||||
<span className="text-xs text-gray-500">
|
||||
{d ? new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) : <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// ── Design date ──────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "designDate",
|
||||
|
|
@ -320,6 +335,18 @@ export function createPropertyTableColumns(
|
|||
},
|
||||
},
|
||||
|
||||
// ── EPC certificate number ───────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "epcPrn",
|
||||
id: "epcPrn",
|
||||
header: ({ column }) => <SortableHeader label="EPC Certificate Number" column={column as any} />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs font-mono text-gray-600">
|
||||
{row.original.epcPrn ?? <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
// ── Group ────────────────────────────────────────────────────────────
|
||||
{
|
||||
accessorKey: "batch",
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ const mockDealRow = {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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> = {}): 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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
|
|||
eiScorePotential: null,
|
||||
epcSapScore: null,
|
||||
epcSapScorePotential: null,
|
||||
epcPrn: null,
|
||||
surveyType: null,
|
||||
measuresForPibiOrdered: null,
|
||||
pibiOrderDate: null,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue