Merge pull request #461 from Hestia-Homes/issue-460-design-doc-gates-installation

fix(live-projects): gate Installation in Progress on Retrofit Design Document presence (#460)
This commit is contained in:
Daniel Roth 2026-07-24 17:10:56 +01:00 committed by GitHub
commit 4859d7df55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 336 additions and 62 deletions

View file

@ -0,0 +1,96 @@
# 23. Installation in Progress is gated on the Retrofit Design Document, not the HubSpot designStatus flag
Date: 2026-07-24
## Status
Accepted
## Context
On the Live projects screen (`your-projects/live`), a Property's delivery stage
is derived from HubSpot deal fields by the stage classifier
(`resolveDisplayStage``classifyDeals``computeLiveTrackerData` in
`transforms.ts`). After coordination is complete, the classifier decided
*Design in Progress* vs *Installation in Progress* from the self-reported
`designStatus` text field: `designStatus === "UPLOADED"` meant "design is done,
installation can happen".
That flag is set in the CRM independently of the actual design work. Observed
values include `"UPLOADED"`, `"uploaded"` and `"IN PROGRESS"`, and it is
routinely set before — or without — the **Retrofit Design Document**
(`retrofit_design_doc`) ever being uploaded. The CRM flag and the document store
are two independent sources of truth that had silently diverged, so large
numbers of Properties with no design in place showed as *Installation in
Progress*. Managers could not trust the pipeline counts, the funnel, or the
stage badges, and Properties genuinely waiting on design were hidden inside a
later stage.
The domain owner confirmed there is only ever one design document per Property —
the Retrofit Design Document — and its presence is the correct trigger for
*Installation in Progress*.
## Decision
**Design-document presence, not the `designStatus` flag, drives the
Design-vs-Installation boundary.**
- The classifier stops reading `designStatus`. `designStatus` remains on the
deal record and may still be displayed (and still feeds the separate design
throughput trends chart), but it no longer influences the stage.
- Whether a `retrofit_design_doc` exists for a deal is passed **into** the pure
classifier as an explicit input — `resolveDisplayStage(deal, hasDesignDoc)`,
`classifyDeals(deals, designDocDealIds)`,
`computeLiveTrackerData(deals, designDocDealIds)`. The classification
functions perform no I/O; presence is derived by
`deriveDesignDocDealIds(docsByDealId)` from the documents already fetched
(direct deal-id match and UPRN fallback), using `DESIGN_DOC_TYPES` as the
definition of a design document. `page.tsx` and `[dealId]/page.tsx` now fetch
documents ahead of classification so the same fetch feeds both the presence
set and the document-status map.
- **After coordination, downstream evidence wins; document presence only
decides the Design/Installation boundary.** For a coordination-complete deal
(a `(V1|V2|V3) IOE/MTP COMPLETE` marker, not an RA issue) the stage resolves
in this priority:
```
full lodgement date present → Project Complete
measures lodgement date present → At Post Survey
lodgement status present → At Lodgement
measures installed OR installer handover→ Installation Complete
Retrofit Design Document present → Installation in Progress
otherwise → Design in Progress
```
Gating the whole post-coordination block on document presence would drag a
genuinely installed or lodged Property back to *Design in Progress* when its
design document is merely missing from the store. Document presence is the
weakest signal in the ladder, not a gate on the rest.
- The document-status map's install bucket is corrected: a `retrofit_design_doc`
is no longer mis-bucketed as an install document, so a Property holding only a
design document is not reported as having install documents.
Coordination and earlier stages are untouched — the RA-issue → Queries path,
the removed-Property short-circuits, the dealstage-id → raw-stage mapping and
the Coordination in Progress path all behave exactly as before.
## Consequences
- A Property shows *Installation in Progress* only once its Retrofit Design
Document exists; one whose coordination is complete but whose document is
missing stays at *Design in Progress*, where the outstanding design work is
visible. Funnel and stage-progress counts (and the "All Projects" roll-up)
follow the corrected per-deal classification, as does the Properties-tab stage
filter.
- A prematurely-set or stale `designStatus` flag can no longer push a Property
into *Installation in Progress*.
- `DESIGN_DOC_TYPES` (previously defined but unused) becomes load-bearing as the
single definition of what counts as a design document.
- Out of scope, deliberately: correcting the underlying `designStatus` data or
alerting on CRM-vs-document mismatch; surfacing a design-completeness badge on
the Documents tab; multi-document or per-measure design requirements (there is
only ever one design document per Property). The Live projects feature itself
remains superseded-in-principle by Ara Projects per ADR-0018; this fix keeps
it correct in the meantime.

View file

@ -13,7 +13,11 @@ import { user as userTable } from "@/app/db/schema/users";
import type { DocStatus, PortfolioCapabilityType } from "../types";
import { deriveEffectiveRemovalState } from "../removalState";
import { classifyDeals } from "../transforms";
import { fetchDocsByDealId, computeDocStatusMap } from "../docStatus";
import {
fetchDocsByDealId,
computeDocStatusMap,
deriveDesignDocDealIds,
} from "../docStatus";
import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "../dealQuery";
import type { DealRow } from "../dealQuery";
import DealPage from "./DealPage";
@ -74,7 +78,13 @@ export default async function DealDetailPage(props: {
}
const hubspotDeal = mapDbRowToHubspotDeal(rawDeals[0]);
const [deal] = classifyDeals([hubspotDeal]);
// Fetch this deal's documents before classifying so Retrofit Design Document
// presence drives the Design-vs-Installation boundary, consistent with the
// Live projects list. The same documents feed the document-status map below.
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
const [deal] = classifyDeals([hubspotDeal], designDocDealIds);
const userEmail = session.user.email;
let userCapability: PortfolioCapabilityType = [];
@ -151,7 +161,6 @@ export default async function DealDetailPage(props: {
? deriveEffectiveRemovalState(removalRows[0])
: "none";
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
const docStatusMap = computeDocStatusMap([hubspotDeal], docsByDealId, { [dealId]: approvedMeasures });
const docStatus: DocStatus = docStatusMap[dealId] ?? {
presentSurveyTypes: [],

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { computeDocStatusMap } from "./docStatus";
import { computeDocStatusMap, deriveDesignDocDealIds } from "./docStatus";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types";
import type { HubspotDeal, ApprovalsByDeal } from "./types";
@ -81,6 +81,40 @@ const allSurveyDocs = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.map((t) => ({
measureName: null,
}));
describe("deriveDesignDocDealIds", () => {
it("includes a deal that holds a Retrofit Design Document", () => {
// Arrange
const docsByDealId = new Map([
["deal-1", [{ fileType: "retrofit_design_doc", measureName: null }]],
]);
// Act
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
// Assert
expect(designDocDealIds.has("deal-1")).toBe(true);
});
it("excludes a deal whose documents contain no Retrofit Design Document", () => {
// Arrange — only survey/install docs, no design document
const docsByDealId = new Map([
[
"deal-1",
[
{ fileType: "photo_pack", measureName: null },
{ fileType: "pre_photo", measureName: "CWI" },
],
],
]);
// Act
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
// Assert
expect(designDocDealIds.has("deal-1")).toBe(false);
});
});
describe("computeDocStatusMap", () => {
it("marks survey as complete when all 9 mandatory doc types are present", () => {
const deal = makeDeal({ dealId: "deal-1" });
@ -123,6 +157,19 @@ describe("computeDocStatusMap", () => {
expect(measureNames).not.toContain("CWI");
});
it("does not report install documents for a deal holding only a Retrofit Design Document", () => {
// Arrange — a design document is not an install document
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: "CWI" });
const docs = new Map([["deal-1", [{ fileType: "retrofit_design_doc", measureName: null }]]]);
// Act
const result = computeDocStatusMap([deal], docs, {});
// Assert
expect(result["deal-1"].hasInstallDocs).toBe(false);
expect(result["deal-1"].installStatus).toBe("none");
});
describe("installStatus", () => {
// CWI requires only BASE_DOCS — simple to satisfy in tests
const cwi = "CWI";

View file

@ -5,6 +5,7 @@ import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import {
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
SURVEY_ALL_DOC_TYPES,
DESIGN_DOC_TYPES,
} from "./types";
import type {
HubspotDeal,
@ -81,6 +82,24 @@ export async function fetchDocsByDealId(
return docsByDealId;
}
/**
* The single source of truth for "which deals have a Retrofit Design Document".
* Derived from the already-fetched per-deal documents (which include both the
* direct deal-id match and the UPRN fallback), so no extra query is needed.
* `DESIGN_DOC_TYPES` defines what counts as a design document.
*/
export function deriveDesignDocDealIds(
docsByDealId: Map<string, DocEntry[]>,
): Set<string> {
const designDocDealIds = new Set<string>();
for (const [dealId, docs] of docsByDealId) {
if (docs.some((d) => DESIGN_DOC_TYPES.has(d.fileType))) {
designDocDealIds.add(dealId);
}
}
return designDocDealIds;
}
export function computeDocStatusMap(
deals: HubspotDeal[],
docsByDealId: Map<string, Array<{ fileType: string; measureName: string | null }>>,
@ -103,7 +122,12 @@ export function computeDocStatusMap(
for (const [dealId, docs] of docsByDealId) {
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
// Design documents are their own category — they are neither survey nor
// install docs. Without this carve-out a Retrofit Design Document (absent
// from the survey set) would be mis-bucketed as an install document.
const installDocs = docs.filter(
(d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType) && !DESIGN_DOC_TYPES.has(d.fileType),
);
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByDealId.get(dealId) ?? [];

View file

@ -4,7 +4,11 @@ import { redirect } from "next/navigation";
import { eq, inArray, and, desc, sql } from "drizzle-orm";
import LiveTracker from "./LiveTracker";
import { computeLiveTrackerData } from "./transforms";
import { fetchDocsByDealId, computeDocStatusMap } from "./docStatus";
import {
fetchDocsByDealId,
computeDocStatusMap,
deriveDesignDocDealIds,
} from "./docStatus";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
@ -112,7 +116,14 @@ export default async function LiveReportingPage(props: {
.where(inArray(hubspotDealData.companyId, companyIds));
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
// Fetch per-deal documents before classification so Retrofit Design Document
// presence can drive the Design-vs-Installation boundary. The same fetched
// documents feed the document-status map below.
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
const designDocDealIds = deriveDesignDocDealIds(docsByDealId);
const trackerData = computeLiveTrackerData(deals, designDocDealIds);
const userEmail = user?.user?.email;
@ -165,7 +176,6 @@ export default async function LiveReportingPage(props: {
// Fetch currently approved measures for all deals in scope
const approvalsByDeal: ApprovalsByDeal = {};
const dealIds = deals.map((d) => d.dealId).filter(Boolean);
if (dealIds.length > 0) {
const approvalRows = await db
.select({
@ -218,7 +228,6 @@ export default async function LiveReportingPage(props: {
const removalStatusByDeal: RemovalStatusByDeal = computeRemovalStatusByDeal(removalRows);
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal);
return (

View file

@ -300,81 +300,123 @@ describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => {
).toBe("Design in Progress");
});
it("is case-insensitive for coord/design status checks", () => {
it("is case-insensitive for the coordination-complete pattern", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(v1) ioe/mtp complete",
designStatus: "uploaded",
}))
).toBe("Installation in Progress"); // POST_DESIGN, no install fields set
resolveDisplayStage(
makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(v1) ioe/mtp complete",
}),
true // Retrofit Design Document present
)
).toBe("Installation in Progress");
});
});
describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => {
describe("resolveDisplayStage — after-coordination precedence (downstream evidence wins over document presence)", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
function makePostDesignDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
// A coordination-complete deal. designStatus is deliberately unset — it no
// longer influences classification; the Retrofit Design Document (passed via
// hasDesignDoc) is the source of truth for the Design/Installation boundary.
function makeCoordinationCompleteDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
return makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
designStatus: "UPLOADED",
...overrides,
});
}
it("returns Installation in Progress when no install fields are set", () => {
expect(resolveDisplayStage(makePostDesignDeal())).toBe("Installation in Progress");
it("returns Design in Progress when the design document is absent and there is no downstream evidence", () => {
expect(resolveDisplayStage(makeCoordinationCompleteDeal(), false)).toBe("Design in Progress");
});
it("returns Installation Complete when actualMeasuresInstalled is set", () => {
it("returns Installation Complete when actualMeasuresInstalled is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" }))
resolveDisplayStage(makeCoordinationCompleteDeal({ actualMeasuresInstalled: "Insulation" }), false)
).toBe("Installation Complete");
});
it("returns Installation Complete when installerHandover is set", () => {
it("returns Installation Complete when installerHandover is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" }))
resolveDisplayStage(makeCoordinationCompleteDeal({ installerHandover: "2024-01-01" }), false)
).toBe("Installation Complete");
});
it("returns At Lodgement when lodgementStatus is set", () => {
it("returns At Lodgement when lodgementStatus is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" }))
resolveDisplayStage(makeCoordinationCompleteDeal({ lodgementStatus: "Submitted" }), false)
).toBe("At Lodgement");
});
it("returns At Post Survey when measuresLodgementDate is set", () => {
it("returns At Post Survey when measuresLodgementDate is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() }))
resolveDisplayStage(makeCoordinationCompleteDeal({ measuresLodgementDate: new Date() }), false)
).toBe("At Post Survey");
});
it("returns Project Complete when fullLodgementDate is set", () => {
it("returns Project Complete when fullLodgementDate is set even though the design document is absent", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() }))
resolveDisplayStage(makeCoordinationCompleteDeal({ fullLodgementDate: new Date() }), false)
).toBe("Project Complete");
});
it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
fullLodgementDate: new Date(),
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
resolveDisplayStage(
makeCoordinationCompleteDeal({
fullLodgementDate: new Date(),
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}),
false
)
).toBe("Project Complete");
});
it("measuresLodgementDate takes precedence over lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
resolveDisplayStage(
makeCoordinationCompleteDeal({
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}),
false
)
).toBe("At Post Survey");
});
it("does NOT return Installation in Progress for designStatus UPLOADED when the design document is absent (regression for the reported bug)", () => {
// The HubSpot designStatus flag can be set before — or without — the
// Retrofit Design Document ever being uploaded. It must no longer push a
// Property into Installation in Progress.
expect(
resolveDisplayStage(makeCoordinationCompleteDeal({ designStatus: "UPLOADED" }), false)
).toBe("Design in Progress");
});
});
describe("resolveDisplayStage — design-document presence drives the Design/Installation boundary", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
function makeCoordinationCompleteDeal(overrides: Partial<HubspotDeal> = {}): HubspotDeal {
return makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
...overrides,
});
}
it("returns Installation in Progress when the Retrofit Design Document is present and there is no downstream evidence", () => {
// Arrange
const deal = makeCoordinationCompleteDeal();
// Act
const stage = resolveDisplayStage(deal, true);
// Assert
expect(stage).toBe("Installation in Progress");
});
});
// -----------------------------------------------------------------------
@ -402,6 +444,19 @@ describe("classifyDeals", () => {
expect(result.dealId).toBe("abc-123");
expect(result.dealname).toBe("Test");
});
it("classifies a coordination-complete deal as Installation in Progress only when its id is in the design-document set", () => {
// Arrange
const withDoc = makeDeal({ dealId: "has-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" });
const withoutDoc = makeDeal({ dealId: "no-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" });
// Act
const [a, b] = classifyDeals([withDoc, withoutDoc], new Set(["has-doc"]));
// Assert
expect(a.displayStage).toBe("Installation in Progress");
expect(b.displayStage).toBe("Design in Progress");
});
});
// -----------------------------------------------------------------------
@ -668,6 +723,25 @@ describe("computeLiveTrackerData", () => {
expect(result.projects).toHaveLength(0);
expect(result.totalDeals).toBe(0);
});
it("reflects design-document presence in the project roll-up stage counts", () => {
// Arrange — two coordination-complete deals in one project, one with a
// Retrofit Design Document and one without.
const deals = [
makeDeal({ projectCode: "PROJ-A", dealId: "has-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }),
makeDeal({ projectCode: "PROJ-A", dealId: "no-doc", coordinationStatus: "(V1) IOE/MTP COMPLETE" }),
];
// Act
const result = computeLiveTrackerData(deals, new Set(["has-doc"]));
// Assert
const stageProgress = result.projects[0].progress.stageProgress;
const design = stageProgress.find((s) => s.stage === "Design in Progress")!;
const install = stageProgress.find((s) => s.stage === "Installation in Progress")!;
expect(design.count).toBe(1);
expect(install.count).toBe(1);
});
});
describe("hasMajorConditionIssue", () => {

View file

@ -70,14 +70,16 @@ const STAGE_ID_MAP: Record<string, string> = {
// -----------------------------------------------------------------------
// After-assessment sub-classification
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus + designStatus
// Resolves AFTER_ASSESSMENT deals based on coordinationStatus alone.
// Whether coordination is complete is a CRM signal; what happens *after*
// coordination (design vs installation and beyond) is resolved separately
// by resolveAfterCoordinationStage, using document presence + downstream
// evidence rather than the self-reported designStatus flag.
// -----------------------------------------------------------------------
function resolveAfterAssessmentStage(
coordinationStatus: string | null,
designStatus: string | null
): "Coordination in Progress" | "Design in Progress" | "POST_DESIGN" | "Queries" {
coordinationStatus: string | null
): "Coordination in Progress" | "AFTER_COORDINATION" | "Queries" {
const coord = coordinationStatus?.toUpperCase() ?? "";
const design = designStatus?.toUpperCase() ?? "";
// RA ISSUE always -> Queries
if (coord === "RA ISSUE") return "Queries";
@ -88,7 +90,7 @@ function resolveAfterAssessmentStage(
coord.includes("(V2) IOE/MTP COMPLETE") ||
coord.includes("(V3) IOE/MTP COMPLETE")
) {
return design === "UPLOADED" ? "POST_DESIGN" : "Design in Progress";
return "AFTER_COORDINATION";
}
// Default for AFTER_ASSESSMENT
@ -96,22 +98,34 @@ function resolveAfterAssessmentStage(
}
// -----------------------------------------------------------------------
// Post-design sub-classification
// Called when design is UPLOADED — resolves install / lodgement / completed
// After-coordination sub-classification
// Resolves a coordination-complete deal to design / installation / lodgement
// / completed. Downstream evidence (measures installed, installer handover,
// lodgement, completion) always wins; document presence only decides the
// Design-vs-Installation boundary. Gating the whole block on document
// presence would drag a genuinely installed/lodged Property back to Design
// in Progress when its design document is missing from the store — see
// ADR-0023.
// -----------------------------------------------------------------------
function resolvePostDesignStage(deal: HubspotDeal): DisplayStage {
function resolveAfterCoordinationStage(
deal: HubspotDeal,
hasDesignDoc: boolean
): DisplayStage {
if (deal.fullLodgementDate) return "Project Complete";
if (deal.measuresLodgementDate) return "At Post Survey";
if (deal.lodgementStatus) return "At Lodgement";
if (deal.actualMeasuresInstalled || deal.installerHandover) return "Installation Complete";
return "Installation in Progress";
if (hasDesignDoc) return "Installation in Progress";
return "Design in Progress";
}
// -----------------------------------------------------------------------
// Resolve display stage for a single deal
// Maps dealstage ID + coordination/design/install status -> DisplayStage
// Maps dealstage ID + coordination/install status + Retrofit Design Document
// presence -> DisplayStage. Design-document presence is passed in explicitly
// (hasDesignDoc) so this function stays pure — it never reads the doc store.
// -----------------------------------------------------------------------
export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
export function resolveDisplayStage(deal: HubspotDeal, hasDesignDoc = false): DisplayStage {
if (deal.batch === "Removed from Program") {
return "Removed from Program";
}
@ -126,13 +140,10 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
const raw = STAGE_ID_MAP[deal.dealstage ?? ""] ?? "AFTER_ASSESSMENT";
if (raw === "AFTER_ASSESSMENT") {
const afterAssessment = resolveAfterAssessmentStage(
deal.coordinationStatus,
deal.designStatus
);
const afterAssessment = resolveAfterAssessmentStage(deal.coordinationStatus);
if (afterAssessment === "POST_DESIGN") {
return resolvePostDesignStage(deal);
if (afterAssessment === "AFTER_COORDINATION") {
return resolveAfterCoordinationStage(deal, hasDesignDoc);
}
return afterAssessment;
@ -152,10 +163,13 @@ export function resolveDisplayStage(deal: HubspotDeal): DisplayStage {
// Classify all deals in a list
// Adds displayStage to each deal
// -----------------------------------------------------------------------
export function classifyDeals(deals: HubspotDeal[]): ClassifiedDeal[] {
export function classifyDeals(
deals: HubspotDeal[],
designDocDealIds: ReadonlySet<string> = new Set()
): ClassifiedDeal[] {
return deals.map((deal) => ({
...deal,
displayStage: resolveDisplayStage(deal),
displayStage: resolveDisplayStage(deal, designDocDealIds.has(deal.dealId)),
}));
}
@ -280,10 +294,11 @@ export function hasMajorConditionIssue(deal: {
// Orchestrates all transformations: classify, group by project, compute stats
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
rawDeals: HubspotDeal[],
designDocDealIds: ReadonlySet<string> = new Set()
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "instructedMeasuresByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);
const classified = classifyDeals(rawDeals, designDocDealIds);
// Group deals by projectCode
const grouped: Record<string, ClassifiedDeal[]> = {};