Merge pull request #293 from Hestia-Homes/feature/non-booked-surveys
Some checks failed
Test Suite / unit-tests (push) Has been cancelled

Feature/non booked surveys
This commit is contained in:
Jun-te Kim 2026-05-29 13:37:30 +01:00 committed by GitHub
commit 546316eda9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 569 additions and 271 deletions

View file

@ -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) */}
<ExcludedFromPipelinePanel
deals={currentProject.allDeals}
onOpenTable={onOpenTable}
/>
</div>
);
}

View file

@ -47,6 +47,36 @@ function DampMouldBadgeCell({ value }: { value: unknown }) {
);
}
function RemovalFlagChip({ label, tooltip }: { label: string; tooltip: string }) {
return (
<span
title={tooltip}
className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 border border-slate-200 cursor-help"
>
{label}
</span>
);
}
function DealNameCell({ deal }: { deal: ClassifiedDeal }) {
const name = deal.dealname;
const isRemovedFromProgram = deal.batch === "Removed from Program";
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-800">
{name ?? <span className="text-gray-300"></span>}
</span>
{isRemovedFromProgram && (
<RemovalFlagChip
label="Removed from Program"
tooltip="This property has been removed from the project. If an outcome is set above, it is real history; no further action expected."
/>
)}
</div>
);
}
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 <DealNameCell deal={row.original} />;
}
if (key === "majorConditionIssuePhotosS3") {
return <PhotoDownloadCell value={value} />;
}

View file

@ -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<Record<keyof ClassifiedDeal, string>> = {
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<Record<keyof ClassifiedDeal, string>> = {
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<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => 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 (
<Card className="border border-slate-200 shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-1">
<CircleOff className="h-4 w-4 text-slate-500" />
<h3 className="text-base font-semibold text-slate-700">
Halted or Removed
</h3>
</div>
<p className="text-sm text-gray-500 mb-5">
Properties no longer being progressed either halted before a survey, or removed from the project entirely.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{removedFromBookings.length > 0 && (
<motion.button
whileHover={{ scale: 1.02 }}
onClick={() =>
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"
>
<p className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2 leading-tight">
Removed from Bookings
</p>
<p className="text-2xl font-bold text-slate-800">
{removedFromBookings.length}
</p>
<p className="text-xs text-slate-500 mt-0.5">
Removed from the bookings list before a survey took place
</p>
</motion.button>
)}
{removedFromProgram.length > 0 && (
<motion.button
whileHover={{ scale: 1.02 }}
onClick={() =>
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"
>
<p className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-2 leading-tight">
Removed from Program
</p>
<p className="text-2xl font-bold text-slate-800">
{removedFromProgram.length}
</p>
<p className="text-xs text-slate-500 mt-0.5">
Removed from the project entirely
</p>
</motion.button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -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;
@ -251,6 +215,8 @@ export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", sh
</SelectItem>
))}
<SelectItem value="Queries">Queries</SelectItem>
<SelectItem value="Removed from Bookings">Removed from Bookings</SelectItem>
<SelectItem value="Removed from Program">Removed from Program</SelectItem>
</SelectContent>
</Select>

View file

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

View file

@ -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;

View file

@ -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<string, ClassifiedDeal[]>;
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<string | null>(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 (
<Card className="flex flex-col items-center p-8 bg-gradient-to-br from-white to-brandlightblue/5 border border-brandblue/10">
{/* Header */}
<div className="text-center mb-8 pb-6 border-b border-brandblue/10 w-full">
<Title className="text-brandblue text-[16px] font-bold tracking-tight mb-2">
Survey Performance
</Title>
<p className="text-xs text-gray-500">
Click a segment or label to view filtered properties
</p>
</div>
{/* Donut Chart (Centered) */}
<div className="relative flex justify-center items-center mt-6">
<DonutChart
data={chartData}
category="amount"
index="name"
valueFormatter={(n) => `${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 (
<div
className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-lg shadow-lg
border border-brandblue/20 text-gray-800 text-sm font-medium"
>
<div className="flex flex-col items-center">
<span className="text-[0.95rem] font-bold text-brandblue">
{name}
</span>
<span className="text-gray-600 text-xs mt-1">
{amount.toLocaleString()}
</span>
</div>
</div>
);
}}
/>
{slices.length > 0 && (
<div className="absolute text-center">
<span className="text-4xl font-bold text-brandblue">
{slices.reduce((a, b) => a + b.amount, 0)}
</span>
</div>
)}
</div>
{/* Legend (Clean Grid Layout) */}
<div className="mt-10 flex flex-wrap justify-center gap-x-6 gap-y-3 max-w-[95%] border-t border-brandblue/10 pt-8">
{slices.map((slice, idx) => (
<button
key={slice.name}
onClick={() => handleClick(slice)}
onMouseEnter={() => setHovered(slice.name)}
onMouseLeave={() => setHovered(null)}
className="relative flex items-center space-x-2 text-sm text-gray-700 hover:text-brandblue cursor-pointer transition-colors px-3 py-2 rounded-lg hover:bg-brandlightblue/20"
>
<span
className={`inline-block w-3 h-3 rounded-full bg-${colors[idx]} border-2 border-${colors[idx]}/40 flex-shrink-0 transition-all`}
/>
<span className="font-medium truncate max-w-[100px]">
{slice.name}
</span>
<span className="text-xs text-gray-500 ml-1 whitespace-nowrap font-semibold">
{slice.percentage}%
</span>
{/* Tooltip on hover */}
{hovered === slice.name && (
<div
className="absolute -top-12 left-1/2 -translate-x-1/2 bg-white/95 backdrop-blur-md
px-4 py-3 rounded-lg shadow-lg border border-brandblue/20 text-gray-800
text-sm font-medium whitespace-nowrap z-20"
>
<div className="flex flex-col items-center">
<span className="text-[0.95rem] font-bold text-brandblue">
{slice.name}
</span>
<span className="text-gray-600 text-xs mt-1">
{slice.amount.toLocaleString()}
</span>
</div>
</div>
)}
</button>
))}
</div>
</Card>
);
}

View file

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

View file

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

View file

@ -20,6 +20,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
bookingStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
@ -47,6 +48,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
eiScorePotential: null,
epcSapScore: null,
epcSapScorePotential: null,
epcPrn: null,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,

View file

@ -19,6 +19,7 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
bookingStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
@ -46,6 +47,7 @@ function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
eiScorePotential: null,
epcSapScore: null,
epcSapScorePotential: null,
epcPrn: null,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,

View file

@ -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));
});
});

View file

@ -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;
}

View file

@ -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> = {}): HubspotDeal {
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
bookingStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
@ -54,6 +54,7 @@ function makeDeal(overrides: Partial<HubspotDeal> = {}): 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);
});
});

View file

@ -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<DisplayStage> = new Set<DisplayStage>([
"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<Record<string, number>> = {};
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<number>(
(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,
});
}

View file

@ -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<string> = 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",