This commit is contained in:
KhalimCK 2026-05-14 12:11:24 +01:00 committed by GitHub
commit e307f4ebab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 9468 additions and 3892 deletions

View file

@ -1,100 +0,0 @@
/**
* Live Tracking PIBI dates editor (issue #252)
*
* Verifies the write-role flow on the PIBI section of the property detail
* drawer: a `write` user can pick PIBI order and completion dates, hit
* Save, and the chosen dates are still reflected after the page is
* reloaded (i.e. the values were persisted server-side).
*
* The spec assumes an authenticated `write` session can be reused (or the
* test harness logs in via the same flow as the rest of the suite). The
* target portfolio slug + a deal id with PIBI fields editable for the
* write user are read from Cypress env vars so the spec stays portable.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_PIBI_DEAL_NAME");
const ORDER_DATE = "2025-03-12";
const COMPLETED_DATE = "2025-04-02";
describe("PIBI dates editor — write user flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerForTargetDeal() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Switch to the Measures tab — the easiest way into the drawer.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Navigate to the PIBI tab (drawer opens on Works tab from Measures row click).
cy.get("[data-testid=drawer-tab-pibi]").click();
cy.get("[data-testid=drawer-section-pibi]").should("exist");
}
it("lets a write user set PIBI order + completion dates and persists them across reload", () => {
openDrawerForTargetDeal();
// Both date inputs render for write+ users.
cy.get("[data-testid=pibi-order-date-input]").should("be.visible");
cy.get("[data-testid=pibi-completed-date-input]").should("be.visible");
cy.get("[data-testid=pibi-order-date-input]")
.clear()
.type(ORDER_DATE);
cy.get("[data-testid=pibi-completed-date-input]")
.clear()
.type(COMPLETED_DATE);
// Save button should be enabled once values change.
cy.get("[data-testid=pibi-save-button]")
.should("not.be.disabled")
.click();
// Saving completes — the button label flips back from "Saving…" to
// "Save PIBI Dates" and no error banner is shown.
cy.get("[data-testid=pibi-save-button]").should(
"contain.text",
"Save PIBI Dates",
);
cy.get("[data-testid=pibi-error]").should("not.exist");
// Optimistic update — the inputs already reflect the new values.
cy.get("[data-testid=pibi-order-date-input]").should(
"have.value",
ORDER_DATE,
);
cy.get("[data-testid=pibi-completed-date-input]").should(
"have.value",
COMPLETED_DATE,
);
// Reload the page and reopen the drawer — the persisted values must
// still be there.
cy.reload();
openDrawerForTargetDeal();
cy.get("[data-testid=pibi-order-date-input]").should(
"have.value",
ORDER_DATE,
);
cy.get("[data-testid=pibi-completed-date-input]").should(
"have.value",
COMPLETED_DATE,
);
});
});

View file

@ -1,162 +0,0 @@
/**
* Live Tracking PIBI measure selection flow (issue #254)
*
* Verifies the approver flow for selecting which measures on a deal go for
* PIBI:
* 1. the approver opens the property drawer at the PIBI section,
* 2. ticks and unticks measures in the multi-select,
* 3. saves the selection the drawer reflects the ticked state,
* 4. the POST hits the pibi-measures route which pushes
* `measures_for_pibi_ordered` back to HubSpot.
*
* Mirrors `instruct-measure.cy.js`. The spec uses `cy.intercept` so the
* HubSpot push side-effect is observable without a real CRM round-trip.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_PIBI_DEAL_NAME");
describe("PIBI measure selection — approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerAtPibiSection() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Open a property row to get the detail drawer.
cy.contains("button, [role=tab]", "Measures").click();
if (TARGET_DEAL_NAME) {
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
} else {
cy.get("[data-testid=measures-row]").first().click();
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Navigate to the PIBI tab (drawer opens on Works tab from Measures row click).
cy.get("[data-testid=drawer-tab-pibi]").click();
cy.get("[data-testid=drawer-section-pibi]").should("exist");
}
it("fetches the PIBI state and shows the multi-select for approvers", () => {
// Stub the GET so we control the initial state.
cy.intercept(
"GET",
`/api/portfolio/*/pibi-measures*`,
{
body: {
pibiMeasures: ["ASHP"],
approvedMeasures: ["ASHP", "Solar PV"],
instructedMeasures: [],
},
},
).as("getPibiMeasures");
openDrawerAtPibiSection();
cy.wait("@getPibiMeasures");
// The multi-select should be visible for approvers.
cy.get("[data-testid=pibi-measure-selector]").should("be.visible");
// ASHP should be checked (was in pibiMeasures).
cy.get("[data-testid=pibi-measure-checkbox-ASHP]").should(
"be.checked",
);
});
it("lets an approver tick/untick selections and POST to the route", () => {
// Stub GET to return a clean state.
cy.intercept(
"GET",
`/api/portfolio/*/pibi-measures*`,
{
body: {
pibiMeasures: [],
approvedMeasures: ["ASHP", "Solar PV"],
instructedMeasures: [],
},
},
).as("getPibiMeasures");
// Intercept the POST so we can assert the body.
cy.intercept(
"POST",
`/api/portfolio/*/pibi-measures`,
).as("savePibiMeasures");
openDrawerAtPibiSection();
cy.wait("@getPibiMeasures");
cy.get("[data-testid=pibi-measure-selector]").should("be.visible");
// Both approved measures (ASHP, Solar PV) should be pre-ticked since
// pibiMeasures was empty and approvedMeasures had values.
cy.get("[data-testid=pibi-measure-checkbox-ASHP]").should("be.checked");
cy.get("[data-testid=pibi-measure-checkbox-Solar PV]").should("be.checked");
// Untick ASHP.
cy.get("[data-testid=pibi-measure-option-ASHP]").click();
cy.get("[data-testid=pibi-measure-checkbox-ASHP]").should("not.be.checked");
// Save the selection.
cy.get("[data-testid=pibi-selector-save]").click();
cy.wait("@savePibiMeasures").then((intercepted) => {
// Body should reflect the new selection (Solar PV only).
expect(intercepted.request.body).to.have.property("dealId");
expect(intercepted.request.body.measureNames).to.include("Solar PV");
expect(intercepted.request.body.measureNames).not.to.include("ASHP");
// Route returns ok=true.
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
expect(intercepted.response.body).to.have.property("ok", true);
expect(intercepted.response.body).to.have.property(
"hubspotSync",
);
});
// No error banner visible.
cy.get("[data-testid=pibi-selector-error]").should("not.exist");
});
it("pushes measures_for_pibi_ordered to HubSpot on a successful save", () => {
cy.intercept(
"GET",
`/api/portfolio/*/pibi-measures*`,
{
body: {
pibiMeasures: ["CWI"],
approvedMeasures: ["CWI"],
instructedMeasures: [],
},
},
).as("getPibiMeasures");
// Stub the POST to confirm the property pushed.
cy.intercept("POST", `/api/portfolio/*/pibi-measures`, {
body: { ok: true, hubspotSync: "ok" },
}).as("savePibiMeasures");
openDrawerAtPibiSection();
cy.wait("@getPibiMeasures");
cy.get("[data-testid=pibi-selector-save]").click();
cy.wait("@savePibiMeasures").then((intercepted) => {
// Confirm the POST body contains the right measures.
expect(intercepted.request.body).to.have.property("measureNames");
// Response signals a successful HubSpot push.
expect(intercepted.response.body.hubspotSync).to.equal("ok");
});
});
});

View file

@ -0,0 +1,286 @@
/**
* Live Tracking PibiSection (flat TanStack Table redesign)
*
* Tests the approver flow for the per-measure PIBI request log rendered
* directly on the DealPage (pibi-surveys tab), not in a drawer.
*
* 1. Empty state renders with a "Log first PIBI" prompt
* 2. Flat table shows existing rows (no batch grouping)
* 3. Every row has always-editable cells (measure select, date inputs)
* 4. Save button is disabled when row is clean, enabled after editing
* 5. Save calls PATCH for existing rows
* 6. Delete calls DELETE
* 7. "+ Add row" appends a blank row; save calls POST
* 8. "Mark all complete" PATCHes all incomplete rows
* 9. Scope badges appear for approved / proposed measures
*
* Requires LIVE_PORTFOLIO_SLUG; skipped otherwise.
* All network calls are intercepted no real DB / HubSpot round-trips.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const PORTFOLIO_ID_GLOB = "*";
function stubGet(pibiRequests = []) {
cy.intercept(
"GET",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`,
{ body: { pibiRequests } },
).as("getPibiRequests");
}
function stubMeasures(
approvedMeasures = ["ASHP", "CWI"],
instructedMeasures = [],
) {
cy.intercept(
"GET",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-measures*`,
{ body: { pibiMeasures: approvedMeasures, approvedMeasures, instructedMeasures } },
).as("getPibiMeasures");
}
function stubPost(response = { ok: true, insertedCount: 1, hubspotSync: "ok" }) {
cy.intercept(
"POST",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests`,
{ body: response },
).as("postPibiRequest");
}
function stubPatch(id, response = { ok: true, hubspotSync: "ok" }) {
cy.intercept(
"PATCH",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests/${id}`,
{ body: response },
).as(`patchPibiRequest-${id}`);
}
function stubDelete(id, response = { ok: true, hubspotSync: "ok" }) {
cy.intercept(
"DELETE",
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests/${id}*`,
{ body: response },
).as(`deletePibiRequest-${id}`);
}
function openDealPageAtPibiTab() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Properties").click();
cy.get("[data-testid=property-row-link]").first().click();
cy.get("[data-testid=deal-page-tab-pibi-surveys]").click();
}
describe("PibiSection", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log("LIVE_PORTFOLIO_SLUG not set — skipping PibiSection specs");
this.skip();
}
});
beforeEach(() => {
stubMeasures();
});
// ── Cycle 1: Empty state ──────────────────────────────────────────────────
it("shows empty state with Log first PIBI prompt when no requests exist", () => {
stubGet([]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-empty-state]").should("be.visible");
cy.get("[data-testid=pibi-empty-state]").should(
"contain.text",
"No PIBIs logged yet",
);
cy.get("[data-testid=pibi-empty-add-row]").should("be.visible");
});
// ── Cycle 2: Flat table renders rows (no batch groups) ────────────────────
it("renders a flat table of rows with no batch grouping", () => {
stubGet([
{
id: "1",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
{
id: "2",
measureName: "CWI",
orderedAt: "2026-04-01T00:00:00.000Z",
completedAt: null,
},
]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-row-1]").should("be.visible");
cy.get("[data-testid=pibi-row-2]").should("be.visible");
cy.get("[data-testid=pibi-batch-group]").should("not.exist");
});
// ── Cycle 3: Always-editable cells ────────────────────────────────────────
it("renders measure select and date inputs as always-editable cells", () => {
stubGet([
{
id: "5",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-measure-select-5]").should("be.visible");
cy.get("[data-testid=pibi-ordered-date-5]").should("be.visible");
cy.get("[data-testid=pibi-completed-date-5]").should("be.visible");
});
// ── Cycle 4: Save disabled when clean, enabled after editing ──────────────
it("shows Save disabled on load, enabled after editing a cell", () => {
stubGet([
{
id: "6",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-save-6]").should("be.disabled");
cy.get("[data-testid=pibi-ordered-date-6]").clear().type("2026-06-01");
cy.get("[data-testid=pibi-save-6]").should("not.be.disabled");
});
// ── Cycle 5: Save calls PATCH ─────────────────────────────────────────────
it("calls PATCH with updated fields when approver clicks Save", () => {
stubGet([
{
id: "10",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubPatch("10");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-completed-date-10]").type("2026-05-15");
cy.get("[data-testid=pibi-save-10]").click();
cy.wait("@patchPibiRequest-10").then((interception) => {
expect(interception.request.body.dealId).to.be.a("string");
expect(interception.request.body.completedAt).to.include("2026-05-15");
});
});
// ── Cycle 6: Delete calls DELETE ──────────────────────────────────────────
it("calls DELETE when approver clicks Delete on a row", () => {
stubGet([
{
id: "20",
measureName: "EWI",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubDelete("20");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-delete-20]").click();
cy.wait("@deletePibiRequest-20");
});
// ── Cycle 7: Add row → POST ───────────────────────────────────────────────
it("appends a blank row on add-row click and POSTs on save", () => {
stubGet([]);
stubPost({ ok: true, insertedCount: 1, hubspotSync: "ok" });
cy.intercept("GET", `/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`, {
body: {
pibiRequests: [
{
id: "99",
measureName: "ASHP",
orderedAt: new Date().toISOString(),
completedAt: null,
},
],
},
}).as("getPibiRequestsAfter");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-empty-add-row]").click();
cy.get("[data-testid=pibi-section]").find("tr[data-testid^=pibi-row-new]").should("have.length", 1);
cy.get("[data-testid=pibi-section]")
.find("[data-testid^=pibi-save-new]")
.click();
cy.wait("@postPibiRequest").then((interception) => {
expect(interception.request.body.measureNames).to.be.an("array").with.length(1);
expect(interception.request.body.orderedAt).to.be.a("string");
});
});
// ── Cycle 8: Mark all complete ────────────────────────────────────────────
it("PATCHes all incomplete rows when Mark all complete is clicked", () => {
stubGet([
{
id: "30",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
{
id: "31",
measureName: "CWI",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubPatch("30");
stubPatch("31");
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-mark-all-complete]").click();
cy.wait("@patchPibiRequest-30").then((i) => {
expect(i.request.body.completedAt).to.be.a("string");
});
cy.wait("@patchPibiRequest-31");
});
// ── Cycle 9: Scope badges ─────────────────────────────────────────────────
it("shows Approved badge for a measure in approvedMeasures", () => {
stubGet([
{
id: "40",
measureName: "ASHP",
orderedAt: "2026-05-01T00:00:00.000Z",
completedAt: null,
},
]);
stubMeasures(["ASHP"], []);
openDealPageAtPibiTab();
cy.wait("@getPibiRequests");
cy.get("[data-testid=pibi-row-40]").should("contain.text", "Approved");
});
});

89
skills-lock.json Normal file
View file

@ -0,0 +1,89 @@
{
"version": 1,
"skills": {
"caveman": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/caveman/SKILL.md",
"computedHash": "934433479903febc585bf6deb5f0cebc63137e3f86b7babe0aab1ecb94d6d7a4"
},
"diagnose": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/diagnose/SKILL.md",
"computedHash": "15939a26f86edec2d4862042b8564e5a062cb81d04e047a0cea6305c8830b5f5"
},
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
},
"grill-with-docs": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
"computedHash": "31a5b1ae116558bf7d3f633f442835f54bd7645923d4f45c7823e52a97317666"
},
"improve-codebase-architecture": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
"computedHash": "c77b86b4332919499608f9af1880074e1fec65a59b95c70c27a9f39cd137865e"
},
"ralph-loop": {
"source": "Hestia-Homes/agentic-toolkit",
"sourceType": "github",
"skillPath": "skills/engineering/ralph-loop/SKILL.md",
"computedHash": "6d45d44d84abf566d0f298af6b7d710e5f6ebaecb5a06c31fedacd20085ae88d"
},
"setup-matt-pocock-skills": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md",
"computedHash": "3a32f8f1ed8160c9d286a2aabe88ee9b884c6f3f88a7a6c47b7d5d552c959587"
},
"tdd": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/tdd/SKILL.md",
"computedHash": "15a7b5e36383ebadb2dec5e586679e55e9663d292da418926b8da6fc0ef27d84"
},
"to-issues": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/to-issues/SKILL.md",
"computedHash": "73a91f30784523aa59ec9b02769576ebfc738e2cd5ad8f6441076031f0a5d5ac"
},
"to-prd": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/to-prd/SKILL.md",
"computedHash": "fd8c259f9c44eff08e29a1a2fc71a806a3568d279a55387a361f78620b10f2aa"
},
"to-project": {
"source": "Hestia-Homes/agentic-toolkit",
"sourceType": "github",
"skillPath": "skills/engineering/to-project/SKILL.md",
"computedHash": "59daf039ac699a44a9416f8ec403b83d4166e05489959e127746231ff8be4e12"
},
"triage": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/triage/SKILL.md",
"computedHash": "2b6efb6da12d92551772fcc04acf331f4e0e6f7bd9d4cb23ce0b301e0b128feb"
},
"write-a-skill": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/write-a-skill/SKILL.md",
"computedHash": "b44d8aab2ead83c716e01af4c9a24ccc4575ce70ad58ec4f1749fb88c9cc82ba"
},
"zoom-out": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/zoom-out/SKILL.md",
"computedHash": "8357aeaece3b709c442eab67e64b86844e05e2f1ea95b109565eba50b6def36e"
}
}
}

View file

@ -0,0 +1,289 @@
/**
* Unit tests for the approvals POST handler.
*
* Focuses on HubSpot sync behaviour: after approve/unapprove changes are
* persisted to the DB, the handler must push both the audit log
* (client_measures_approval_log) and the structured field (approved_measures)
* to HubSpot. Prior to this fix only the audit log was synced.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks (declared before vi.mock factories run) ─────────────────────
const {
mockGetServerSession,
syncMeasureApprovalsToHubSpotMock,
syncMeasuresFieldToHubSpotMock,
mockDbSelect,
mockDbInsert,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
syncMeasureApprovalsToHubSpotMock: vi.fn(),
syncMeasuresFieldToHubSpotMock: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
}));
// ── Auth ──────────────────────────────────────────────────────────────────────
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({
AuthOptions: {},
}));
// ── HubSpot syncs ─────────────────────────────────────────────────────────────
vi.mock("@/app/lib/hubspot/dealSync", () => ({
syncMeasureApprovalsToHubSpot: syncMeasureApprovalsToHubSpotMock,
syncMeasuresFieldToHubSpot: syncMeasuresFieldToHubSpotMock,
}));
vi.mock("@/app/lib/instructMeasure", () => ({
APPROVED_MEASURES_PROP: "approved_measures",
}));
// ── Drizzle ORM ───────────────────────────────────────────────────────────────
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })),
sql: vi.fn(),
}));
// ── DB schema stubs ───────────────────────────────────────────────────────────
vi.mock("@/app/db/schema/approvals", () => ({
dealMeasureApprovals: { hubspotDealId: {}, measureName: {}, isApproved: {}, approvedBy: {}, approvedAt: {} },
dealMeasureApprovalEvents: { hubspotDealId: {}, measureName: {}, action: {}, actedBy: {}, actedAt: {} },
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {}, id: {} },
}));
vi.mock("@/app/db/schema/users", () => ({
user: { id: {}, email: {}, firstName: {} },
}));
// ── DB mock ───────────────────────────────────────────────────────────────────
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
},
}));
// ── DB mock helpers ────────────────────────────────────────────────────────────
// Builds a thenable select chain where .limit() resolves to `limitResult`
// and awaiting the chain without .limit() resolves to `directResult`.
function makeSelectChain(
limitResult: unknown[],
directResult: unknown[] = [],
) {
const self: Record<string, unknown> = {};
// thenable so `await chain.where(...)` resolves to directResult
self["then"] = (
resolve: (v: unknown) => unknown,
reject: (e: unknown) => unknown,
) => Promise.resolve(directResult).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["leftJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
// Builds an insert chain. .values() is thenable (plain insert) and also
// exposes .onConflictDoUpdate() (upsert).
function makeInsertChain() {
const values: Record<string, unknown> = {};
values["then"] = (
resolve: (v: unknown) => unknown,
reject: (e: unknown) => unknown,
) => Promise.resolve(undefined).then(resolve, reject);
values["onConflictDoUpdate"] = vi.fn(() => Promise.resolve(undefined));
const insert = { values: vi.fn(() => values) };
return insert;
}
// ── Subject under test ────────────────────────────────────────────────────────
import { POST } from "./route";
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeRequest(body: unknown, portfolioId = "10") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/approvals`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
return { req, params: Promise.resolve({ portfolioId }) };
}
function setupHappyPath(approvalRowsAfterChange: Array<{ measureName: string; approvedByEmail: string }>) {
// 1. getServerSession
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
// 2. getUserId select
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([{ id: 1n }]),
);
// 3. hasApproverCapability select
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([{ id: 1n }]),
);
// 4. upsert dealMeasureApprovals (one per change)
mockDbInsert.mockImplementationOnce(() => makeInsertChain());
// 5. insert dealMeasureApprovalEvents (one per change)
mockDbInsert.mockImplementationOnce(() => makeInsertChain());
// 6. post-change approvalRows select (no .limit — awaited at .where())
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([], approvalRowsAfterChange),
);
// HubSpot syncs
syncMeasureApprovalsToHubSpotMock.mockResolvedValue(undefined);
syncMeasuresFieldToHubSpotMock.mockResolvedValue({ ok: true });
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("POST /approvals — approved_measures HubSpot sync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("syncs approved_measures field to HubSpot after an unapprove action", async () => {
setupHappyPath([
// Only one measure remains approved after the unapprove
{ measureName: "ASHP", approvedByEmail: "approver@test.com" },
]);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-1", measureName: "Solar PV", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(200);
// Allow fire-and-forget promises to settle
await vi.waitFor(() =>
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({
hubspotDealId: "deal-1",
propName: "approved_measures",
measureNames: ["ASHP"],
});
});
it("syncs approved_measures with empty list when all measures removed", async () => {
setupHappyPath([]); // nothing approved after removal
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-2", measureName: "ASHP", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(200);
await vi.waitFor(() =>
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({
hubspotDealId: "deal-2",
propName: "approved_measures",
measureNames: [],
});
});
it("syncs approved_measures when a new measure is approved", async () => {
setupHappyPath([
{ measureName: "ASHP", approvedByEmail: "approver@test.com" },
{ measureName: "Solar PV", approvedByEmail: "approver@test.com" },
]);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-3", measureName: "Solar PV", approved: true },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(200);
await vi.waitFor(() =>
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({
hubspotDealId: "deal-3",
propName: "approved_measures",
measureNames: ["ASHP", "Solar PV"],
});
});
it("also calls the audit-log sync (existing behaviour preserved)", async () => {
setupHappyPath([
{ measureName: "EWI", approvedByEmail: "approver@test.com" },
]);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-4", measureName: "EWI", approved: true },
],
});
await POST(req, { params });
await vi.waitFor(() =>
expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalled(),
);
expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalledWith(
expect.objectContaining({
hubspotDealId: "deal-4",
approvedMeasures: [{ measureName: "EWI", approvedByEmail: "approver@test.com" }],
}),
);
});
it("does not call HubSpot syncs when session is missing", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-5", measureName: "ASHP", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(401);
expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled();
});
it("does not call HubSpot syncs when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "writer@test.com" } });
// getUserId
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n }]));
// hasApproverCapability → empty → no capability
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
const { req, params } = makeRequest({
changes: [
{ hubspotDealId: "deal-6", measureName: "ASHP", approved: false },
],
});
const res = await POST(req, { params });
expect(res.status).toBe(403);
expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled();
});
});

View file

@ -6,11 +6,15 @@ import {
} from "@/app/db/schema/approvals";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
import {
syncMeasureApprovalsToHubSpot,
syncMeasuresFieldToHubSpot,
} from "@/app/lib/hubspot/dealSync";
import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure";
async function getRequestingUserId(email: string): Promise<bigint | null> {
const rows = await db
@ -102,7 +106,7 @@ export async function GET(
.from(dealMeasureApprovalEvents)
.leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
.where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
.orderBy(dealMeasureApprovalEvents.actedAt);
.orderBy(desc(dealMeasureApprovalEvents.actedAt));
const events = eventRows.map((e) => ({
id: e.id.toString(),
@ -230,6 +234,19 @@ export async function POST(
actedByEmail: session.user.email,
actedAt: now,
});
void syncMeasuresFieldToHubSpot({
hubspotDealId: dealId,
propName: APPROVED_MEASURES_PROP,
measureNames: approvalRows.map((r) => r.measureName),
}).then((result) => {
if (!result.ok) {
console.error("[HubSpot] approved_measures sync failed", {
dealId,
error: result.error,
});
}
});
}
return NextResponse.json({ success: true });

View file

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { bulkApprove } from "@/app/lib/bulkApprove";
const bodySchema = z.object({
changes: z
.array(
z.object({
hubspotDealId: z.string().min(1),
measureName: z.string().min(1),
approved: z.boolean(),
}),
)
.min(1, "changes must not be empty"),
});
/**
* POST /api/portfolio/[portfolioId]/bulk-approvals
*
* Approver-only. Applies all approve/unapprove changes in a single atomic
* DB transaction. If any change fails the entire batch is rolled back.
*
* Body: { changes: [{ hubspotDealId, measureName, approved }] }
* Response: 200 { ok: true, hubspotSync: "ok" | "failed" } | 400/401/403
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, session.user.email))
.limit(1);
if (!userRow[0]) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.some((r) => r.capability === "approver")) {
return NextResponse.json({ error: "Approver capability required" }, { status: 403 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const result = await bulkApprove({
changes: body.changes,
userId: userRow[0].id,
actedByEmail: session.user.email,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, hubspotError: result.hubspotError });
}

View file

@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { bulkInstructDeals } from "@/app/lib/bulkInstructDeals";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
const bodySchema = z.object({
deals: z
.array(
z.object({
dealId: z.string().min(1),
measureNames: z.array(z.string().min(1)).min(1),
}),
)
.min(1, "deals must not be empty"),
notes: z.string().optional(),
});
/**
* POST /api/portfolio/[portfolioId]/bulk-instructed-measures
*
* Approver-only. Instructs the given measures on each listed deal in a single
* atomic DB transaction. If any deal/measure fails the entire batch rolls back.
*
* Body: { deals: [{ dealId, measureNames }], notes? }
* Response: 200 { ok: true, hubspotSync } | 400/401/403
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, session.user.email))
.limit(1);
if (!userRow[0]) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.some((r) => r.capability === "approver")) {
return NextResponse.json({ error: "Approver capability required" }, { status: 403 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
for (const deal of body.deals) {
for (const name of deal.measureNames) {
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
return NextResponse.json({ error: `Unknown measure: ${name}` }, { status: 400 });
}
}
}
const result = await bulkInstructDeals({
deals: body.deals,
userId: userRow[0].id,
notes: body.notes,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({ ok: true, hubspotSync: result.hubspotSync, hubspotError: result.hubspotError });
}

View file

@ -10,12 +10,14 @@ import {
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { instructMeasure } from "@/app/lib/instructMeasure";
import { instructMeasures } from "@/app/lib/instructMeasure";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
const postSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureName: z.string().min(1, "measureName is required"),
measureNames: z
.array(z.string().min(1))
.min(1, "measureNames must not be empty"),
});
/**
@ -27,10 +29,10 @@ const postSchema = z.object({
* pushes back to HubSpot. See `instructMeasure` for the full contract.
*
* Body:
* { dealId: string, measureName: string }
* { dealId: string, measureNames: string[] }
*
* Response:
* 200 { ok: true, hubspotSync: "ok" | "failed", autoPopulatedProposed: boolean, hubspotError? }
* 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? }
* 400 { ok: false, error }
* 401 / 403 / 404 on auth/role/user errors.
*/
@ -60,15 +62,16 @@ export async function POST(
);
}
const { dealId, measureName } = parsed.data;
const { dealId, measureNames } = parsed.data;
// Validate against the canonical catalogue up-front so the route returns
// a clean 400 rather than relying on the service-level check.
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(measureName)) {
return NextResponse.json(
{ error: `Unknown measure: ${measureName}` },
{ status: 400 },
);
// Validate all names against the canonical catalogue up-front.
for (const name of measureNames) {
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
return NextResponse.json(
{ error: `Unknown measure: ${name}` },
{ status: 400 },
);
}
}
const userRow = await db
@ -119,9 +122,9 @@ export async function POST(
}
try {
const result = await instructMeasure({
const result = await instructMeasures({
dealId,
measureName,
measureNames,
userId: userRow[0].id,
});

View file

@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockDbSelect,
mockDbInsert,
mockDbDelete,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
mockDbDelete: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })),
}));
vi.mock("@/app/db/schema/portfolio_organisation", () => ({
portfolioOrganisation: {
portfolioId: {},
organisationId: {},
id: {},
},
}));
vi.mock("@/app/db/schema/organisation", () => ({
organisation: { id: {}, name: {}, hubspotCompanyId: {} },
}));
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
get delete() { return mockDbDelete; },
},
}));
// ── Chain builders ────────────────────────────────────────────────────────────
function makeSelectChain(rows: unknown[]) {
const self: Record<string, unknown> = {};
self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(rows).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["leftJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(rows));
return self;
}
function makeInsertChain() {
const self: Record<string, unknown> = {};
self["values"] = vi.fn(() => Promise.resolve([]));
return self;
}
function makeDeleteChain() {
const self: Record<string, unknown> = {};
self["where"] = vi.fn(() => Promise.resolve([]));
return self;
}
function makeParams(portfolioId = "42") {
return Promise.resolve({ portfolioId });
}
function makeRequest(method: string, body?: unknown, portfolioId = "42") {
return new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/organisation`,
{
method,
...(body ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}),
},
);
}
// ── Subject under test ────────────────────────────────────────────────────────
import { GET, POST, DELETE } from "./route";
describe("GET /portfolio/:id/organisation", () => {
beforeEach(() => vi.clearAllMocks());
it("returns empty array when no orgs linked", async () => {
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
const res = await GET(makeRequest("GET"), { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual([]);
});
it("returns all linked orgs as array", async () => {
const orgs = [
{ id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" },
{ id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" },
];
mockDbSelect.mockImplementationOnce(() => makeSelectChain(orgs));
const res = await GET(makeRequest("GET"), { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toHaveLength(2);
expect(json[0].name).toBe("Alpha Housing");
expect(json[1].name).toBe("Beta Council");
});
});
describe("POST /portfolio/:id/organisation", () => {
beforeEach(() => vi.clearAllMocks());
it("returns 403 for non-Domna user", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } });
const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(403);
});
it("returns 400 when organisationId missing", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
const res = await POST(makeRequest("POST", {}), { params: makeParams() });
expect(res.status).toBe(400);
});
it("returns 409 when org already linked to this portfolio", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
// existing link found
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: "link-1" }]));
const res = await POST(makeRequest("POST", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(409);
});
it("adds org without removing existing links", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
// no existing link for this org
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
const insertChain = makeInsertChain();
mockDbInsert.mockImplementationOnce(() => insertChain);
// return updated list
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([
{ id: "org-1", name: "Alpha Housing", hubspotCompanyId: "hs-1" },
{ id: "org-2", name: "Beta Council", hubspotCompanyId: "hs-2" },
]),
);
const res = await POST(makeRequest("POST", { organisationId: "org-2" }), { params: makeParams() });
expect(res.status).toBe(200);
// insert called — no delete called
expect(mockDbDelete).not.toHaveBeenCalled();
expect(insertChain.values).toHaveBeenCalled();
const json = await res.json();
expect(json).toHaveLength(2);
});
});
describe("DELETE /portfolio/:id/organisation", () => {
beforeEach(() => vi.clearAllMocks());
it("returns 403 for non-Domna user", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "outsider@other.com" } });
const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(403);
});
it("removes the specific org link", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
const deleteChain = makeDeleteChain();
mockDbDelete.mockImplementationOnce(() => deleteChain);
const res = await DELETE(makeRequest("DELETE", { organisationId: "org-1" }), { params: makeParams() });
expect(res.status).toBe(200);
expect(deleteChain.where).toHaveBeenCalled();
const json = await res.json();
expect(json.success).toBe(true);
});
it("returns 400 when organisationId missing", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "admin@domna.homes" } });
const res = await DELETE(makeRequest("DELETE", {}), { params: makeParams() });
expect(res.status).toBe(400);
});
});

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean {
return !!email?.endsWith("@domna.homes");
}
// GET — fetch the current linked organisation for this portfolio
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await params;
const rows = await db
function linkedOrgsQuery(portfolioId: string) {
return db
.select({
id: organisation.id,
name: organisation.name,
@ -25,13 +19,20 @@ export async function GET(
})
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
return NextResponse.json(rows[0] ?? null);
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
}
// POST — connect an organisation to this portfolio (Domna only)
// GET — fetch all linked organisations for this portfolio
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await params;
const rows = await linkedOrgsQuery(portfolioId);
return NextResponse.json(rows);
}
// POST — add an organisation link (Domna only, rejects duplicates)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
@ -43,40 +44,40 @@ export async function POST(
const { portfolioId } = await params;
const body = await req.json();
const { organisationId } = body as { organisationId: string };
const { organisationId } = body as { organisationId?: string };
if (!organisationId) {
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
}
// Upsert: delete any existing link then insert fresh
await db
.delete(portfolioOrganisation)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
// Reject if this org is already linked to this portfolio
const existing = await db
.select({ id: portfolioOrganisation.id })
.from(portfolioOrganisation)
.where(
and(
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
eq(portfolioOrganisation.organisationId, organisationId),
),
)
.limit(1);
if (existing.length > 0) {
return NextResponse.json({ error: "Organisation already linked" }, { status: 409 });
}
await db.insert(portfolioOrganisation).values({
portfolioId: BigInt(portfolioId),
organisationId,
});
// Return the newly linked org
const rows = await db
.select({
id: organisation.id,
name: organisation.name,
hubspotCompanyId: organisation.hubspotCompanyId,
})
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
return NextResponse.json(rows[0] ?? null);
const rows = await linkedOrgsQuery(portfolioId);
return NextResponse.json(rows);
}
// DELETE — disconnect the organisation from this portfolio (Domna only)
// DELETE — remove a specific organisation link (Domna only)
export async function DELETE(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
@ -85,10 +86,21 @@ export async function DELETE(
}
const { portfolioId } = await params;
const body = await req.json().catch(() => ({}));
const { organisationId } = body as { organisationId?: string };
if (!organisationId) {
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
}
await db
.delete(portfolioOrganisation)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
.where(
and(
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
eq(portfolioOrganisation.organisationId, organisationId),
),
);
return NextResponse.json({ success: true });
}

View file

@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockUpdatePibiRequest,
mockDeletePibiRequest,
mockDbSelect,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockUpdatePibiRequest: vi.fn(),
mockDeletePibiRequest: vi.fn(),
mockDbSelect: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/lib/updatePibiRequest", () => ({
updatePibiRequest: mockUpdatePibiRequest,
PIBI_ORDERED_TEXT_PROP: "measures_for_pibi_ordered_text",
}));
vi.mock("@/app/lib/deletePibiRequest", () => ({
deletePibiRequest: mockDeletePibiRequest,
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({ user: { id: {}, email: {} } }));
vi.mock("@/app/db/db", () => ({
db: { get select() { return mockDbSelect; } },
}));
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (_resolve: (v: unknown) => unknown, _reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(_resolve, _reject);
self["from"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function mockApproverAuth(userId = 2n) {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: userId }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
}
function makeParams(portfolioId = "5", id = "7") {
return Promise.resolve({ portfolioId, id });
}
import { PATCH, DELETE } from "./route";
describe("PATCH /pibi-requests/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUpdatePibiRequest.mockResolvedValue({ ok: true, hubspotSync: "ok" });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", {
method: "PATCH",
body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }),
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, { params: makeParams() });
expect(res.status).toBe(401);
});
it("returns 403 when not approver", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "user@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", {
method: "PATCH",
body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }),
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, { params: makeParams() });
expect(res.status).toBe(403);
});
it("updates PIBI and returns ok=true", async () => {
mockApproverAuth();
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7", {
method: "PATCH",
body: JSON.stringify({ dealId: "deal-1", completedAt: "2026-05-10T00:00:00Z" }),
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("ok");
expect(mockUpdatePibiRequest).toHaveBeenCalledWith(
expect.objectContaining({ id: 7n, dealId: "deal-1" }),
);
});
});
describe("DELETE /pibi-requests/[id]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDeletePibiRequest.mockResolvedValue({ ok: true, hubspotSync: "ok" });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7?dealId=deal-1", {
method: "DELETE",
});
const res = await DELETE(req, { params: makeParams() });
expect(res.status).toBe(401);
});
it("deletes PIBI and returns ok=true", async () => {
mockApproverAuth();
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests/7?dealId=deal-1", {
method: "DELETE",
});
const res = await DELETE(req, { params: makeParams() });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(mockDeletePibiRequest).toHaveBeenCalledWith(
expect.objectContaining({ id: 7n, dealId: "deal-1" }),
);
});
});

View file

@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { updatePibiRequest } from "@/app/lib/updatePibiRequest";
import { deletePibiRequest } from "@/app/lib/deletePibiRequest";
const patchSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureName: z.string().min(1).optional(),
orderedAt: z.string().datetime().optional(),
completedAt: z.string().datetime().nullable().optional(),
});
async function resolveApprover(
email: string,
portfolioId: string,
): Promise<
| { ok: true; userId: bigint }
| { ok: false; status: 401 | 403 | 404; error: string }
> {
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1);
if (!userRow[0]) return { ok: false, status: 404, error: "User not found" };
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return { ok: false, status: 403, error: "No portfolio access" };
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.map((r) => r.capability).includes("approver")) {
return { ok: false, status: 403, error: "Approver capability required" };
}
return { ok: true, userId: userRow[0].id };
}
/**
* PATCH /api/portfolio/[portfolioId]/pibi-requests/[id]
*
* Approver-only. Updates a PIBI request row.
* Body: { dealId: string, measureName?: string, orderedAt?: string, completedAt?: string | null }
*/
export async function PATCH(
req: NextRequest,
props: { params: Promise<{ portfolioId: string; id: string }> },
) {
const { portfolioId, id } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const auth = await resolveApprover(session.user.email, portfolioId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { dealId, measureName, orderedAt, completedAt } = parsed.data;
const result = await updatePibiRequest({
id: BigInt(id),
dealId,
updates: {
...(measureName !== undefined && { measureName }),
...(orderedAt !== undefined && { orderedAt: new Date(orderedAt) }),
...(completedAt !== undefined && { completedAt: completedAt ? new Date(completedAt) : null }),
},
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({
ok: true,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
}
/**
* DELETE /api/portfolio/[portfolioId]/pibi-requests/[id]?dealId=...
*
* Approver-only. Deletes a PIBI request row and re-syncs HubSpot.
*/
export async function DELETE(
req: NextRequest,
props: { params: Promise<{ portfolioId: string; id: string }> },
) {
const { portfolioId, id } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const dealId = req.nextUrl.searchParams.get("dealId");
if (!dealId) {
return NextResponse.json({ error: "dealId query param is required" }, { status: 400 });
}
const auth = await resolveApprover(session.user.email, portfolioId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const result = await deletePibiRequest({ id: BigInt(id), dealId });
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({
ok: true,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
}

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockCreatePibiRequests,
mockDbSelect,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockCreatePibiRequests: vi.fn(),
mockDbSelect: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/lib/createPibiRequests", () => ({
createPibiRequests: mockCreatePibiRequests,
PIBI_ORDERED_TEXT_PROP: "measures_for_pibi_ordered_text",
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
desc: vi.fn((col: unknown) => ({ $desc: col })),
}));
vi.mock("@/app/db/schema/pibi_requests", () => ({
pibiRequests: {
id: {}, hubspotDealId: {}, portfolioId: {}, measureName: {},
orderedAt: {}, completedAt: {}, createdByUserId: {}, pushedAt: {},
},
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({ user: { id: {}, email: {} } }));
vi.mock("@/app/db/db", () => ({
db: { get select() { return mockDbSelect; } },
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (_resolve: (v: unknown) => unknown, _reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(_resolve, _reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["orderBy"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function mockApproverAuth(userId = 2n) {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: userId, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
}
function makeRequest(body: unknown, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/pibi-requests`,
{ method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } },
);
return { req, params: Promise.resolve({ portfolioId }) };
}
function makeGetRequest(dealId: string, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/pibi-requests?dealId=${dealId}`,
);
return { req, params: Promise.resolve({ portfolioId }) };
}
import { GET, POST } from "./route";
describe("GET /pibi-requests", () => {
beforeEach(() => vi.clearAllMocks());
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeGetRequest("deal-1");
const res = await GET(req, { params });
expect(res.status).toBe(401);
});
it("returns 400 when dealId missing", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "x@test.com" } });
const req = new NextRequest("http://localhost/api/portfolio/5/pibi-requests");
const res = await GET(req, { params: Promise.resolve({ portfolioId: "5" }) });
expect(res.status).toBe(400);
});
it("returns pibi requests for deal", async () => {
const orderedAt = new Date("2026-05-06T10:00:00Z");
mockGetServerSession.mockResolvedValue({ user: { email: "x@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([], [
{ id: 1n, measureName: "CWI", orderedAt, completedAt: null },
{ id: 2n, measureName: "Loft insulation", orderedAt, completedAt: null },
])
);
const { req, params } = makeGetRequest("deal-1");
const res = await GET(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.pibiRequests).toHaveLength(2);
});
});
describe("POST /pibi-requests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreatePibiRequests.mockResolvedValue({ ok: true, insertedRowIds: [1n], hubspotSync: "ok" });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI"] });
const res = await POST(req, { params });
expect(res.status).toBe(401);
});
it("returns 403 when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI"] });
const res = await POST(req, { params });
expect(res.status).toBe(403);
});
it("returns 400 when measureNames is empty", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: [] });
const res = await POST(req, { params });
expect(res.status).toBe(400);
});
it("creates PIBIs and returns ok=true with insertedCount", async () => {
mockApproverAuth();
const { req, params } = makeRequest({ dealId: "deal-1", measureNames: ["CWI", "ASHP"] });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("ok");
expect(json.insertedCount).toBe(1);
});
});

View file

@ -0,0 +1,189 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { pibiRequests } from "@/app/db/schema/pibi_requests";
import { createPibiRequests } from "@/app/lib/createPibiRequests";
const postSchema = z.object({
dealId: z.string().min(1, "dealId is required"),
measureNames: z.array(z.string().min(1)).min(1, "at least one measure required"),
orderedAt: z.string().datetime().optional(),
});
async function resolveApprover(
email: string,
portfolioId: string,
): Promise<
| { ok: true; userId: bigint }
| { ok: false; status: 401 | 403 | 404; error: string }
> {
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1);
if (!userRow[0]) return { ok: false, status: 404, error: "User not found" };
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return { ok: false, status: 403, error: "No portfolio access" };
}
const capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.map((r) => r.capability).includes("approver")) {
return { ok: false, status: 403, error: "Approver capability required" };
}
return { ok: true, userId: userRow[0].id };
}
/**
* GET /api/portfolio/[portfolioId]/pibi-requests?dealId=...
*
* Returns all PIBI requests for a deal, ordered by orderedAt desc.
* Response: { pibiRequests: PibiRequestRow[] }
*/
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const dealId = req.nextUrl.searchParams.get("dealId");
if (!dealId) {
return NextResponse.json({ error: "dealId query param is required" }, { status: 400 });
}
const userRow = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, session.user.email))
.limit(1);
if (!userRow[0]) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const portfolioUserRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
if (!portfolioUserRow[0]?.role) {
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
}
const rows = await db
.select({
id: pibiRequests.id,
measureName: pibiRequests.measureName,
orderedAt: pibiRequests.orderedAt,
completedAt: pibiRequests.completedAt,
})
.from(pibiRequests)
.where(eq(pibiRequests.hubspotDealId, dealId))
.orderBy(desc(pibiRequests.orderedAt));
return NextResponse.json({
pibiRequests: rows.map((r) => ({
id: String(r.id),
measureName: r.measureName,
orderedAt: r.orderedAt,
completedAt: r.completedAt,
})),
});
}
/**
* POST /api/portfolio/[portfolioId]/pibi-requests
*
* Approver-only. Creates one pibi_request row per measure in the batch.
* Body: { dealId: string, measureNames: string[], orderedAt?: string (ISO) }
* Response: { ok: true, insertedCount: number, hubspotSync: "ok" | "failed" }
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = postSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const auth = await resolveApprover(session.user.email, portfolioId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { dealId, measureNames, orderedAt } = parsed.data;
const result = await createPibiRequests({
dealId,
portfolioId: BigInt(portfolioId),
measureNames,
orderedAt: orderedAt ? new Date(orderedAt) : undefined,
userId: auth.userId,
});
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
return NextResponse.json({
ok: true,
insertedCount: result.insertedRowIds.length,
hubspotSync: result.hubspotSync,
hubspotError: result.hubspotError,
});
}

View file

@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const {
mockGetServerSession,
mockSyncSurveyRequestToHubSpot,
mockDbSelect,
mockDbInsert,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockSyncSurveyRequestToHubSpot: vi.fn(),
mockDbSelect: vi.fn(),
mockDbInsert: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({
AuthOptions: {},
}));
vi.mock("@/app/lib/hubspot/dealSync", () => ({
syncSurveyRequestToHubSpot: mockSyncSurveyRequestToHubSpot,
}));
vi.mock("drizzle-orm", () => ({
and: vi.fn((...args: unknown[]) => ({ $and: args })),
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
desc: vi.fn((col: unknown) => ({ $desc: col })),
}));
vi.mock("@/app/db/schema/survey_requests", () => ({
surveyRequests: {
id: {}, hubspotDealId: {}, portfolioId: {}, notes: {},
surveyType: {}, status: {}, requestedBy: {}, requestedAt: {}, fulfilledAt: {},
},
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
}));
vi.mock("@/app/db/schema/users", () => ({
user: { id: {}, email: {} },
}));
vi.mock("@/app/db/db", () => ({
db: {
get select() { return mockDbSelect; },
get insert() { return mockDbInsert; },
},
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) {
const self: Record<string, unknown> = {};
self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(directResult).then(resolve, reject);
self["from"] = vi.fn(() => self);
self["innerJoin"] = vi.fn(() => self);
self["where"] = vi.fn(() => self);
self["orderBy"] = vi.fn(() => self);
self["limit"] = vi.fn(() => Promise.resolve(limitResult));
return self;
}
function makeInsertChain(returningResult: unknown[] = []) {
const returning = vi.fn(() => Promise.resolve(returningResult));
const values = vi.fn(() => ({ returning }));
return { values };
}
function makeRequest(body: unknown, portfolioId = "5") {
const req = new NextRequest(
`http://localhost/api/portfolio/${portfolioId}/survey-requests`,
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
return { req, params: Promise.resolve({ portfolioId }) };
}
// ── Subject under test ────────────────────────────────────────────────────────
import { POST } from "./route";
describe("POST /survey-requests", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: true });
});
it("returns 401 when unauthenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(401);
});
it("returns 403 when user lacks approver capability", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } });
// user lookup
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n, email: "write@test.com" }]));
// portfolio role check
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }]));
// capability check — no rows (directResult), so not an approver
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(403);
const json = await res.json();
expect(json.error).toMatch(/approver/i);
});
it("returns 409 when a pending request already exists for the deal", async () => {
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
// capability rows come back via directResult (no .limit() on that query)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
// pending check — returns a pending row
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n, status: "pending" }]));
const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(409);
const json = await res.json();
expect(json.error).toMatch(/pending/i);
});
it("creates the request with surveyType and syncs to HubSpot", async () => {
const insertedAt = new Date("2026-05-06T10:00:00.000Z");
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
// no pending request
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
// insert returning
mockDbInsert.mockImplementationOnce(() =>
makeInsertChain([{ id: 42n, requestedAt: insertedAt }])
);
const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.id).toBe("42");
expect(json.hubspotSync).toBe("ok");
expect(mockSyncSurveyRequestToHubSpot).toHaveBeenCalledWith({
hubspotDealId: "deal-abc",
surveyType: "technical_building_survey",
requestedAt: insertedAt,
});
});
it("returns hubspotSync: failed but still 200 when HubSpot fails", async () => {
const insertedAt = new Date();
mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } });
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }]));
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
mockDbInsert.mockImplementationOnce(() =>
makeInsertChain([{ id: 43n, requestedAt: insertedAt }])
);
mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: false, error: "HubSpot sync failed" });
const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" });
const res = await POST(req, { params });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.hubspotSync).toBe("failed");
expect(json.hubspotError).toBe("HubSpot sync failed");
});
});

View file

@ -1,7 +1,7 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { surveyRequests } from "@/app/db/schema/survey_requests";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { portfolioUsers, portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
@ -9,8 +9,6 @@ import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
const WRITE_ROLES = ["creator", "admin", "write"] as const;
async function getRequestingUser(email: string) {
const rows = await db
.select({ id: user.id, email: user.email })
@ -20,7 +18,7 @@ async function getRequestingUser(email: string) {
return rows[0] ?? null;
}
async function getUserRole(portfolioId: bigint, userId: bigint) {
async function hasPortfolioRole(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
@ -31,11 +29,38 @@ async function getUserRole(portfolioId: bigint, userId: bigint) {
),
)
.limit(1);
return rows[0]?.role ?? null;
return !!rows[0]?.role;
}
async function hasApproverCapability(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, portfolioId),
eq(portfolioCapabilities.userId, userId),
),
);
return rows.map((r) => r.capability).includes("approver");
}
async function getPendingRequest(hubspotDealId: string, portfolioId: bigint) {
const rows = await db
.select({ id: surveyRequests.id, status: surveyRequests.status })
.from(surveyRequests)
.where(
and(
eq(surveyRequests.hubspotDealId, hubspotDealId),
eq(surveyRequests.portfolioId, portfolioId),
eq(surveyRequests.status, "pending"),
),
)
.limit(1);
return rows[0] ?? null;
}
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
// Returns all survey requests for a deal, most recent first.
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
@ -57,7 +82,7 @@ export async function GET(
.select({
id: surveyRequests.id,
hubspotDealId: surveyRequests.hubspotDealId,
notes: surveyRequests.notes,
surveyType: surveyRequests.surveyType,
status: surveyRequests.status,
requestedAt: surveyRequests.requestedAt,
fulfilledAt: surveyRequests.fulfilledAt,
@ -77,7 +102,7 @@ export async function GET(
const requests = rows.map((r) => ({
id: String(r.id),
hubspotDealId: r.hubspotDealId,
notes: r.notes,
surveyType: r.surveyType ?? null,
status: r.status,
requestedByEmail: r.requestedByEmail,
requestedAt: r.requestedAt?.toISOString() ?? null,
@ -93,11 +118,11 @@ export async function GET(
const postSchema = z.object({
hubspotDealId: z.string().min(1),
notes: z.string().min(1, "Notes are required"),
surveyType: z.string().min(1),
});
// POST /api/portfolio/[portfolioId]/survey-requests
// Submit a new survey request — requires write+ role.
// Submit a new survey request — requires approver capability.
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
@ -114,10 +139,17 @@ export async function POST(
return NextResponse.json({ error: "User not found" }, { status: 401 });
}
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
const pid = BigInt(portfolioId);
const isMember = await hasPortfolioRole(pid, requestingUser.id);
if (!isMember) {
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
}
const isApprover = await hasApproverCapability(pid, requestingUser.id);
if (!isApprover) {
return NextResponse.json(
{ error: "Write access required to submit a survey request" },
{ error: "Approver capability required to submit a survey request" },
{ status: 403 },
);
}
@ -134,24 +166,33 @@ export async function POST(
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { hubspotDealId, notes } = parsed.data;
const { hubspotDealId, surveyType } = parsed.data;
const existing = await getPendingRequest(hubspotDealId, pid);
if (existing) {
return NextResponse.json(
{ error: "A pending survey request already exists for this deal" },
{ status: 409 },
);
}
try {
const [inserted] = await db
.insert(surveyRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
notes,
portfolioId: pid,
notes: "",
surveyType,
status: "pending",
requestedBy: requestingUser.id,
})
.returning({ id: surveyRequests.id });
.returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt });
const hubspotResult = await syncSurveyRequestToHubSpot({
hubspotDealId,
notes,
requestedByEmail: requestingUser.email,
surveyType,
requestedAt: inserted.requestedAt,
});
return NextResponse.json({

View file

@ -20,7 +20,6 @@ export const surveyRequests = pgTable(
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
// Free-text notes from the requester describing what survey is needed.
notes: text("notes").notNull(),
surveyType: text("survey_type"),
// 'pending' | 'fulfilled'

View file

@ -0,0 +1,86 @@
import { describe, it, expect, vi } from "vitest";
import { bulkApprove } from "./bulkApprove";
import type { RunBulkApproveTx, SyncApprovalsForDeals } from "./bulkApprove";
const noopSync: SyncApprovalsForDeals = async () => ({ ok: true });
function makeSuccessTx(approvedByDeal: Record<string, string[]> = {}): RunBulkApproveTx {
return async ({ changes }) => {
const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))];
const result: Record<string, string[]> = {};
for (const id of dealIds) {
result[id] = approvedByDeal[id] ?? [];
}
return { approvedMeasuresByDeal: result };
};
}
describe("bulkApprove", () => {
it("returns ok:false when changes array is empty", async () => {
const result = await bulkApprove({
changes: [],
userId: 1n,
deps: { runBulkApproveTx: makeSuccessTx(), syncApprovals: noopSync },
});
expect(result).toEqual({ ok: false, error: "changes must not be empty" });
});
it("calls tx with all changes and returns ok:true on success", async () => {
const txSpy = vi.fn(makeSuccessTx({ "deal-1": ["Loft insulation"] }));
const result = await bulkApprove({
changes: [{ hubspotDealId: "deal-1", measureName: "Loft insulation", approved: true }],
userId: 1n,
deps: { runBulkApproveTx: txSpy, syncApprovals: noopSync },
});
expect(result.ok).toBe(true);
expect(txSpy).toHaveBeenCalledOnce();
expect(txSpy.mock.calls[0][0].changes).toHaveLength(1);
});
it("returns ok:false when tx throws (atomic rollback)", async () => {
const failingTx: RunBulkApproveTx = async () => {
throw new Error("DB constraint violated");
};
const result = await bulkApprove({
changes: [{ hubspotDealId: "deal-1", measureName: "CWI", approved: true }],
userId: 1n,
deps: { runBulkApproveTx: failingTx, syncApprovals: noopSync },
});
expect(result).toMatchObject({ ok: false, error: "DB constraint violated" });
});
it("returns ok:true with hubspotSync:'failed' when HubSpot sync fails", async () => {
const failingSync: SyncApprovalsForDeals = async () => ({
ok: false,
error: "HubSpot timeout",
});
const result = await bulkApprove({
changes: [{ hubspotDealId: "deal-1", measureName: "CWI", approved: true }],
userId: 1n,
deps: { runBulkApproveTx: makeSuccessTx(), syncApprovals: failingSync },
});
expect(result).toMatchObject({ ok: true, hubspotSync: "failed", hubspotError: "HubSpot timeout" });
});
it("calls sync once per affected deal", async () => {
const syncSpy = vi.fn(noopSync);
await bulkApprove({
changes: [
{ hubspotDealId: "deal-1", measureName: "CWI", approved: true },
{ hubspotDealId: "deal-2", measureName: "ASHP", approved: true },
{ hubspotDealId: "deal-1", measureName: "EWI", approved: true },
],
userId: 1n,
deps: {
runBulkApproveTx: makeSuccessTx({
"deal-1": ["CWI", "EWI"],
"deal-2": ["ASHP"],
}),
syncApprovals: syncSpy,
},
});
expect(syncSpy).toHaveBeenCalledOnce();
const call = syncSpy.mock.calls[0][0];
expect(Object.keys(call.approvedMeasuresByDeal).sort()).toEqual(["deal-1", "deal-2"]);
});
});

156
src/app/lib/bulkApprove.ts Normal file
View file

@ -0,0 +1,156 @@
import { db } from "@/app/db/db";
import { and, eq, inArray } from "drizzle-orm";
import {
dealMeasureApprovals,
dealMeasureApprovalEvents,
} from "@/app/db/schema/approvals";
import { user } from "@/app/db/schema/users";
import {
syncMeasureApprovalsToHubSpot,
syncMeasuresFieldToHubSpot,
} from "@/app/lib/hubspot/dealSync";
import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure";
export interface BulkApproveChange {
hubspotDealId: string;
measureName: string;
approved: boolean;
}
export interface BulkApproveTxResult {
approvedMeasuresByDeal: Record<string, string[]>;
}
export type RunBulkApproveTx = (params: {
changes: BulkApproveChange[];
userId: bigint;
}) => Promise<BulkApproveTxResult>;
export type SyncApprovalsForDeals = (params: {
approvedMeasuresByDeal: Record<string, string[]>;
actedByEmail: string;
actedAt: Date;
}) => Promise<{ ok: true } | { ok: false; error: string }>;
export type BulkApproveResult =
| { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string }
| { ok: false; error: string };
export interface BulkApproveInput {
changes: BulkApproveChange[];
userId: bigint;
actedByEmail?: string;
deps?: {
runBulkApproveTx?: RunBulkApproveTx;
syncApprovals?: SyncApprovalsForDeals;
};
}
const defaultRunBulkApproveTx: RunBulkApproveTx = async ({ changes, userId }) => {
return db.transaction(async (tx) => {
const now = new Date();
for (const change of changes) {
await tx
.insert(dealMeasureApprovals)
.values({
hubspotDealId: change.hubspotDealId,
measureName: change.measureName,
isApproved: change.approved,
approvedBy: userId,
approvedAt: now,
})
.onConflictDoUpdate({
target: [dealMeasureApprovals.hubspotDealId, dealMeasureApprovals.measureName],
set: { isApproved: change.approved, approvedBy: userId, approvedAt: now },
});
await tx.insert(dealMeasureApprovalEvents).values({
hubspotDealId: change.hubspotDealId,
measureName: change.measureName,
action: change.approved ? "approved" : "unapproved",
actedBy: userId,
actedAt: now,
});
}
const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))];
const approvalRows = await tx
.select({
hubspotDealId: dealMeasureApprovals.hubspotDealId,
measureName: dealMeasureApprovals.measureName,
})
.from(dealMeasureApprovals)
.where(
and(
inArray(dealMeasureApprovals.hubspotDealId, dealIds),
eq(dealMeasureApprovals.isApproved, true),
),
);
const approvedMeasuresByDeal: Record<string, string[]> = {};
for (const row of approvalRows) {
(approvedMeasuresByDeal[row.hubspotDealId] ??= []).push(row.measureName);
}
return { approvedMeasuresByDeal };
});
};
const defaultSyncApprovals: SyncApprovalsForDeals = async ({
approvedMeasuresByDeal,
actedByEmail,
actedAt,
}) => {
const dealIds = Object.keys(approvedMeasuresByDeal);
for (const dealId of dealIds) {
const measures = approvedMeasuresByDeal[dealId] ?? [];
void syncMeasureApprovalsToHubSpot({
hubspotDealId: dealId,
approvedMeasures: measures.map((m) => ({ measureName: m, approvedByEmail: actedByEmail })),
actedByEmail,
actedAt,
});
const result = await syncMeasuresFieldToHubSpot({
hubspotDealId: dealId,
propName: APPROVED_MEASURES_PROP,
measureNames: measures,
});
if (!result.ok) {
return { ok: false, error: result.error };
}
}
return { ok: true };
};
export async function bulkApprove(input: BulkApproveInput): Promise<BulkApproveResult> {
if (input.changes.length === 0) {
return { ok: false, error: "changes must not be empty" };
}
const runTx = input.deps?.runBulkApproveTx ?? defaultRunBulkApproveTx;
const syncApprovals = input.deps?.syncApprovals ?? defaultSyncApprovals;
let txResult: BulkApproveTxResult;
try {
txResult = await runTx({ changes: input.changes, userId: input.userId });
} catch (err) {
const message = err instanceof Error ? err.message : "Bulk approve transaction failed";
console.error("[bulkApprove] transaction failed", { error: err });
return { ok: false, error: message };
}
const syncResult = await syncApprovals({
approvedMeasuresByDeal: txResult.approvedMeasuresByDeal,
actedByEmail: input.actedByEmail ?? "unknown",
actedAt: new Date(),
});
if (!syncResult.ok) {
return { ok: true, hubspotSync: "failed", hubspotError: syncResult.error };
}
return { ok: true, hubspotSync: "ok" };
}

View file

@ -0,0 +1,94 @@
import { describe, it, expect, vi } from "vitest";
import { bulkInstructDeals } from "./bulkInstructDeals";
import type { RunBulkInstructTx, SyncInstructedForDeals } from "./bulkInstructDeals";
const noopSync: SyncInstructedForDeals = async () => ({ ok: true });
function makeSuccessTx(): RunBulkInstructTx {
return async ({ deals }) => ({
instructedRowIds: deals.map((_, i) => BigInt(i + 1)),
approvedMeasuresByDeal: Object.fromEntries(
deals.map((d) => [d.dealId, d.measureNames]),
),
});
}
describe("bulkInstructDeals", () => {
it("returns ok:false when deals array is empty", async () => {
const result = await bulkInstructDeals({
deals: [],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toEqual({ ok: false, error: "deals must not be empty" });
});
it("returns ok:false when a deal has empty measureNames", async () => {
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: [] }],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: false });
});
it("returns ok:false for unknown measure name", async () => {
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: ["Not A Real Measure"] }],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("Unknown measure") });
});
it("returns ok:true when all deals succeed", async () => {
const result = await bulkInstructDeals({
deals: [
{ dealId: "deal-1", measureNames: ["ASHP", "CWI"] },
{ dealId: "deal-2", measureNames: ["EWI"] },
],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: true, hubspotSync: "ok" });
});
it("returns ok:false when tx throws (atomic rollback)", async () => {
const failingTx: RunBulkInstructTx = async () => {
throw new Error("FK violation");
};
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: ["ASHP"] }],
userId: 1n,
deps: { runBulkInstructTx: failingTx, syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: false, error: "FK violation" });
});
it("returns ok:true with hubspotSync:'failed' when sync fails", async () => {
const failSync: SyncInstructedForDeals = async () => ({
ok: false,
error: "HubSpot rate limit",
});
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: ["ASHP"] }],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: failSync },
});
expect(result).toMatchObject({ ok: true, hubspotSync: "failed", hubspotError: "HubSpot rate limit" });
});
it("passes all deals to the tx in one call", async () => {
const txSpy = vi.fn(makeSuccessTx());
await bulkInstructDeals({
deals: [
{ dealId: "deal-1", measureNames: ["ASHP"] },
{ dealId: "deal-2", measureNames: ["CWI"] },
],
userId: 1n,
deps: { runBulkInstructTx: txSpy, syncInstructed: noopSync },
});
expect(txSpy).toHaveBeenCalledOnce();
expect(txSpy.mock.calls[0][0].deals).toHaveLength(2);
});
});

View file

@ -0,0 +1,179 @@
import { db } from "@/app/db/db";
import { and, eq } from "drizzle-orm";
import {
dealMeasureApprovals,
dealMeasureApprovalEvents,
} from "@/app/db/schema/approvals";
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import { MEASURE_NAMES, type MeasureName } from "@/app/lib/measureDocumentRequirements";
import { syncMeasuresFieldToHubSpot } from "@/app/lib/hubspot/dealSync";
import {
INSTRUCTED_MEASURES_PROP,
PROPOSED_MEASURES_PROP,
APPROVED_MEASURES_PROP,
} from "@/app/lib/instructMeasure";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
export interface BulkInstructDeal {
dealId: string;
measureNames: string[];
}
export interface BulkInstructTxResult {
instructedRowIds: bigint[];
approvedMeasuresByDeal: Record<string, string[]>;
}
export type RunBulkInstructTx = (params: {
deals: Array<{ dealId: string; measureNames: MeasureName[] }>;
userId: bigint;
notes: string | null;
}) => Promise<BulkInstructTxResult>;
export type SyncInstructedForDeals = (params: {
approvedMeasuresByDeal: Record<string, string[]>;
}) => Promise<{ ok: true } | { ok: false; error: string }>;
export type BulkInstructDealsResult =
| { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string }
| { ok: false; error: string };
export interface BulkInstructDealsInput {
deals: BulkInstructDeal[];
userId: bigint;
notes?: string;
deps?: {
runBulkInstructTx?: RunBulkInstructTx;
syncInstructed?: SyncInstructedForDeals;
};
}
function isMeasureName(value: string): value is MeasureName {
return (MEASURE_NAMES as ReadonlyArray<string>).includes(value);
}
const defaultRunBulkInstructTx: RunBulkInstructTx = async ({ deals, userId, notes }) => {
return db.transaction(async (tx) => {
const instructedRowIds: bigint[] = [];
const approvedMeasuresByDeal: Record<string, string[]> = {};
for (const { dealId, measureNames } of deals) {
for (const measureName of measureNames) {
const inserted = await tx
.insert(userDefinedDealMeasures)
.values({ hubspotDealId: dealId, measureName, source: "instructed", createdByUserId: userId, notes })
.returning({ id: userDefinedDealMeasures.id });
const rowId = inserted[0]?.id;
if (!rowId) throw new Error(`Failed to insert instructed measure row for deal ${dealId}`);
instructedRowIds.push(rowId);
await tx
.insert(dealMeasureApprovals)
.values({ hubspotDealId: dealId, measureName, isApproved: true, approvedBy: userId })
.onConflictDoUpdate({
target: [dealMeasureApprovals.hubspotDealId, dealMeasureApprovals.measureName],
set: { isApproved: true, approvedBy: userId, approvedAt: new Date() },
});
await tx.insert(dealMeasureApprovalEvents).values({
hubspotDealId: dealId,
measureName,
action: "approved",
actedBy: userId,
});
}
const approvalRows = await tx
.select({ measureName: dealMeasureApprovals.measureName })
.from(dealMeasureApprovals)
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
approvedMeasuresByDeal[dealId] = approvalRows.map((r) => r.measureName);
}
return { instructedRowIds, approvedMeasuresByDeal };
});
};
const defaultSyncInstructed: SyncInstructedForDeals = async ({ approvedMeasuresByDeal }) => {
for (const [dealId, measures] of Object.entries(approvedMeasuresByDeal)) {
const instructedRows = await db
.select({ measureName: userDefinedDealMeasures.measureName })
.from(userDefinedDealMeasures)
.where(
and(
eq(userDefinedDealMeasures.hubspotDealId, dealId),
eq(userDefinedDealMeasures.source, "instructed"),
),
);
const allInstructed = instructedRows.map((r) => r.measureName);
const dealRow = await db
.select({ proposedMeasures: hubspotDealData.proposedMeasures })
.from(hubspotDealData)
.where(eq(hubspotDealData.dealId, dealId))
.limit(1);
const existing = parseMeasures(dealRow[0]?.proposedMeasures ?? null);
const mergedProposed = [...new Set([...existing, ...allInstructed])];
const r1 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: INSTRUCTED_MEASURES_PROP, measureNames: allInstructed });
if (!r1.ok) return { ok: false, error: r1.error };
const r2 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: PROPOSED_MEASURES_PROP, measureNames: mergedProposed });
if (!r2.ok) return { ok: false, error: r2.error };
const r3 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: APPROVED_MEASURES_PROP, measureNames: measures });
if (!r3.ok) return { ok: false, error: r3.error };
}
return { ok: true };
};
export async function bulkInstructDeals(
input: BulkInstructDealsInput,
): Promise<BulkInstructDealsResult> {
if (input.deals.length === 0) {
return { ok: false, error: "deals must not be empty" };
}
for (const { measureNames } of input.deals) {
if (measureNames.length === 0) {
return { ok: false, error: "each deal must have at least one measureName" };
}
for (const name of measureNames) {
if (!isMeasureName(name.trim())) {
return { ok: false, error: `Unknown measure: ${name}` };
}
}
}
const validatedDeals = input.deals.map((d) => ({
dealId: d.dealId,
measureNames: d.measureNames.map((m) => m.trim() as MeasureName),
}));
const runTx = input.deps?.runBulkInstructTx ?? defaultRunBulkInstructTx;
const syncInstructed = input.deps?.syncInstructed ?? defaultSyncInstructed;
let txResult: BulkInstructTxResult;
try {
txResult = await runTx({ deals: validatedDeals, userId: input.userId, notes: input.notes ?? null });
} catch (err) {
const message = err instanceof Error ? err.message : "Bulk instruct transaction failed";
console.error("[bulkInstructDeals] transaction failed", { error: err });
return { ok: false, error: message };
}
const syncResult = await syncInstructed({ approvedMeasuresByDeal: txResult.approvedMeasuresByDeal });
if (!syncResult.ok) {
return { ok: true, hubspotSync: "failed", hubspotError: syncResult.error };
}
return { ok: true, hubspotSync: "ok" };
}

View file

@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import { createPibiRequests, PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests";
import type { RunCreatePibiTx, SyncMeasuresField, StampPushedAt } from "./createPibiRequests";
function makeDeps(overrides?: {
txResult?: { insertedRowIds: bigint[]; allMeasureNames: string[] };
txError?: Error;
syncResult?: { ok: true } | { ok: false; error: string };
stampError?: Error;
}) {
const txResult = overrides?.txResult ?? {
insertedRowIds: [1n, 2n],
allMeasureNames: ["CWI", "Loft insulation"],
};
const runCreateTx: RunCreatePibiTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txResult;
});
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return overrides?.syncResult ?? ({ ok: true } as const);
});
const stampPushedAt: StampPushedAt = vi.fn(async () => {
if (overrides?.stampError) throw overrides.stampError;
});
return { runCreateTx, syncMeasuresField, stampPushedAt };
}
describe("createPibiRequests — happy path", () => {
it("inserts rows, syncs all deal measures to HubSpot, stamps pushed_at", async () => {
const orderedAt = new Date("2026-05-06T10:00:00Z");
const deps = makeDeps({
txResult: { insertedRowIds: [10n, 11n], allMeasureNames: ["CWI", "Loft insulation"] },
});
const result = await createPibiRequests({
dealId: "deal-1",
portfolioId: 42n,
measureNames: ["CWI", "Loft insulation"],
orderedAt,
userId: 5n,
deps,
});
expect(result).toMatchObject({
ok: true,
insertedRowIds: [10n, 11n],
hubspotSync: "ok",
});
expect(deps.runCreateTx).toHaveBeenCalledWith({
dealId: "deal-1",
portfolioId: 42n,
measureNames: ["CWI", "Loft insulation"],
orderedAt,
userId: 5n,
});
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
hubspotDealId: "deal-1",
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: ["CWI", "Loft insulation"],
});
expect(deps.stampPushedAt).toHaveBeenCalledWith([10n, 11n]);
});
it("uses now() when orderedAt is omitted", async () => {
const before = new Date();
const deps = makeDeps();
await createPibiRequests({
dealId: "deal-2",
portfolioId: 1n,
measureNames: ["ASHP"],
userId: 1n,
deps,
});
const callArg = (deps.runCreateTx as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
orderedAt: Date;
};
expect(callArg.orderedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
});
});
describe("createPibiRequests — validation", () => {
it("returns ok=false immediately when measureNames is empty", async () => {
const deps = makeDeps();
const result = await createPibiRequests({
dealId: "deal-3",
portfolioId: 1n,
measureNames: [],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "measureNames must not be empty" });
expect(deps.runCreateTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
});
describe("createPibiRequests — DB failure", () => {
it("returns ok=false, skips HubSpot when tx throws", async () => {
const deps = makeDeps({ txError: new Error("insert failed") });
const result = await createPibiRequests({
dealId: "deal-x",
portfolioId: 1n,
measureNames: ["EWI"],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "insert failed" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("createPibiRequests — HubSpot failure", () => {
it("returns ok=true with hubspotSync=failed, does NOT stamp pushed_at", async () => {
const deps = makeDeps({
txResult: { insertedRowIds: [20n], allMeasureNames: ["Solar PV"] },
syncResult: { ok: false, error: "hubspot 503" },
});
const result = await createPibiRequests({
dealId: "deal-h",
portfolioId: 1n,
measureNames: ["Solar PV"],
userId: 2n,
deps,
});
expect(result).toMatchObject({
ok: true,
insertedRowIds: [20n],
hubspotSync: "failed",
hubspotError: "hubspot 503",
});
expect(deps.runCreateTx).toHaveBeenCalledTimes(1);
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,160 @@
import { db } from "@/app/db/db";
import { pibiRequests } from "@/app/db/schema/pibi_requests";
import { eq } from "drizzle-orm";
import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync";
export const PIBI_ORDERED_TEXT_PROP = "measures_for_pibi_ordered_text";
// ---------------------------------------------------------------------------
// Injectable dep types
// ---------------------------------------------------------------------------
export type RunCreatePibiTx = (params: {
dealId: string;
portfolioId: bigint;
measureNames: string[];
orderedAt: Date;
userId: bigint;
}) => Promise<{ insertedRowIds: bigint[]; allMeasureNames: string[] }>;
export type SyncMeasuresField = typeof defaultSyncMeasuresField;
export type StampPushedAt = (rowIds: bigint[]) => Promise<void>;
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export type CreatePibiRequestsResult =
| {
ok: true;
insertedRowIds: bigint[];
hubspotSync: "ok" | "failed";
hubspotError?: string;
}
| { ok: false; error: string };
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
export interface CreatePibiRequestsInput {
dealId: string;
portfolioId: bigint;
measureNames: string[];
/** Defaults to now() when omitted. */
orderedAt?: Date;
userId: bigint;
deps?: {
runCreateTx?: RunCreatePibiTx;
syncMeasuresField?: SyncMeasuresField;
stampPushedAt?: StampPushedAt;
};
}
// ---------------------------------------------------------------------------
// Default DB-backed implementations
// ---------------------------------------------------------------------------
const defaultRunCreateTx: RunCreatePibiTx = async ({
dealId,
portfolioId,
measureNames,
orderedAt,
userId,
}) => {
return await db.transaction(async (tx) => {
const inserted = await tx
.insert(pibiRequests)
.values(
measureNames.map((measureName) => ({
hubspotDealId: dealId,
portfolioId,
measureName,
orderedAt,
createdByUserId: userId,
})),
)
.returning({ id: pibiRequests.id });
const allRows = await tx
.select({ measureName: pibiRequests.measureName })
.from(pibiRequests)
.where(eq(pibiRequests.hubspotDealId, dealId));
return {
insertedRowIds: inserted.map((r) => r.id),
allMeasureNames: allRows.map((r) => r.measureName),
};
});
};
const defaultStampPushedAt: StampPushedAt = async (rowIds) => {
if (rowIds.length === 0) return;
for (const rowId of rowIds) {
await db
.update(pibiRequests)
.set({ pushedAt: new Date() })
.where(eq(pibiRequests.id, rowId));
}
};
// ---------------------------------------------------------------------------
// Service entry-point
// ---------------------------------------------------------------------------
export async function createPibiRequests(
input: CreatePibiRequestsInput,
): Promise<CreatePibiRequestsResult> {
if (input.measureNames.length === 0) {
return { ok: false, error: "measureNames must not be empty" };
}
const runCreateTx = input.deps?.runCreateTx ?? defaultRunCreateTx;
const syncMeasuresField = input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt;
const orderedAt = input.orderedAt ?? new Date();
let txResult: { insertedRowIds: bigint[]; allMeasureNames: string[] };
try {
txResult = await runCreateTx({
dealId: input.dealId,
portfolioId: input.portfolioId,
measureNames: input.measureNames,
orderedAt,
userId: input.userId,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create PIBI requests";
return { ok: false, error: message };
}
const syncResult = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: txResult.allMeasureNames,
});
if (syncResult.ok) {
try {
await stampPushedAt(txResult.insertedRowIds);
} catch (err) {
console.error("[createPibiRequests] failed to stamp pushed_at", {
rowIds: txResult.insertedRowIds.map(String),
error: err,
});
}
return {
ok: true,
insertedRowIds: txResult.insertedRowIds,
hubspotSync: "ok",
};
}
return {
ok: true,
insertedRowIds: txResult.insertedRowIds,
hubspotSync: "failed",
hubspotError: syncResult.error,
};
}

View file

@ -75,10 +75,10 @@ describe("DEAL_PROPERTY_FIELDS registry", () => {
"property_halted_reason",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
"domna_survey_type",
"osmosis_survey_required",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe(
"domna_survey_date",
"osmosis_survey_date",
);
});
@ -369,8 +369,8 @@ describe("applyDealPropertyUpdate", () => {
expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso);
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.domna_survey_type).toBe(surveyType);
expect(props.domna_survey_date).toBe(
expect(props.osmosis_survey_required).toBe(surveyType);
expect(props.osmosis_survey_date).toBe(
String(new Date(surveyIso).getTime()),
);
});
@ -407,8 +407,8 @@ describe("applyDealPropertyUpdate", () => {
expect(typeOnlyDb.domnaSurveyType).toBe("Standard");
expect("domnaSurveyDate" in typeOnlyDb).toBe(false);
const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties;
expect(typeOnlyProps.domna_survey_type).toBe("Standard");
expect("domna_survey_date" in typeOnlyProps).toBe(false);
expect(typeOnlyProps.osmosis_survey_required).toBe("Standard");
expect("osmosis_survey_date" in typeOnlyProps).toBe(false);
// Setting only the date — type column is untouched.
const updateDbDate = vi.fn().mockResolvedValue(undefined);
@ -427,10 +427,10 @@ describe("applyDealPropertyUpdate", () => {
expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date);
expect("domnaSurveyType" in dateOnlyDb).toBe(false);
const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties;
expect(dateOnlyProps.domna_survey_date).toBe(
expect(dateOnlyProps.osmosis_survey_date).toBe(
String(new Date(surveyIso).getTime()),
);
expect("domna_survey_type" in dateOnlyProps).toBe(false);
expect("osmosis_survey_required" in dateOnlyProps).toBe(false);
});
it("clears both domna fields to null when explicitly cleared", async () => {
@ -453,8 +453,8 @@ describe("applyDealPropertyUpdate", () => {
expect(dbValues.domnaSurveyType).toBeNull();
expect(dbValues.domnaSurveyDate).toBeNull();
const props = pushHubspot.mock.calls[0][0].properties;
expect(props.domna_survey_type).toBe("");
expect(props.domna_survey_date).toBe("");
expect(props.osmosis_survey_required).toBe("");
expect(props.osmosis_survey_date).toBe("");
});
it("surfaces HubSpot push failures back to the caller", async () => {

View file

@ -149,14 +149,14 @@ export const DEAL_PROPERTY_FIELDS = {
domna_survey_type: {
schema: stringOrNullSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_type",
hubspotProperty: "osmosis_survey_required",
dbColumn: hubspotDealData.domnaSurveyType,
toHubspot: stringToHubspot,
} satisfies DealPropertyFieldDef<string | null>,
domna_survey_date: {
schema: isoDateSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_date",
hubspotProperty: "osmosis_survey_date",
dbColumn: hubspotDealData.domnaSurveyDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,

View file

@ -0,0 +1,86 @@
import { describe, expect, it, vi } from "vitest";
import { deletePibiRequest, PIBI_ORDERED_TEXT_PROP } from "./deletePibiRequest";
import type { RunDeletePibiTx, SyncMeasuresField } from "./deletePibiRequest";
function makeDeps(overrides?: {
txResult?: { remainingMeasureNames: string[] };
txError?: Error;
syncResult?: { ok: true } | { ok: false; error: string };
}) {
const txResult = overrides?.txResult ?? { remainingMeasureNames: ["ASHP"] };
const runDeleteTx: RunDeletePibiTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txResult;
});
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return overrides?.syncResult ?? ({ ok: true } as const);
});
return { runDeleteTx, syncMeasuresField };
}
describe("deletePibiRequest — happy path", () => {
it("deletes row, re-syncs remaining deal measures to HubSpot", async () => {
const deps = makeDeps({
txResult: { remainingMeasureNames: ["ASHP"] },
});
const result = await deletePibiRequest({
id: 7n,
dealId: "deal-1",
deps,
});
expect(result).toEqual({ ok: true, hubspotSync: "ok" });
expect(deps.runDeleteTx).toHaveBeenCalledWith({ id: 7n, dealId: "deal-1" });
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
hubspotDealId: "deal-1",
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: ["ASHP"],
});
});
it("syncs empty list when last PIBI is deleted", async () => {
const deps = makeDeps({ txResult: { remainingMeasureNames: [] } });
const result = await deletePibiRequest({ id: 1n, dealId: "deal-2", deps });
expect(result).toEqual({ ok: true, hubspotSync: "ok" });
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
hubspotDealId: "deal-2",
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: [],
});
});
});
describe("deletePibiRequest — DB failure", () => {
it("returns ok=false, skips HubSpot when tx throws", async () => {
const deps = makeDeps({ txError: new Error("row not found") });
const result = await deletePibiRequest({ id: 99n, dealId: "deal-x", deps });
expect(result).toEqual({ ok: false, error: "row not found" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
});
describe("deletePibiRequest — HubSpot failure", () => {
it("returns ok=true with hubspotSync=failed", async () => {
const deps = makeDeps({
syncResult: { ok: false, error: "hubspot down" },
});
const result = await deletePibiRequest({ id: 5n, dealId: "deal-h", deps });
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "hubspot down",
});
});
});

View file

@ -0,0 +1,93 @@
import { db } from "@/app/db/db";
import { pibiRequests } from "@/app/db/schema/pibi_requests";
import { eq, and } from "drizzle-orm";
import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync";
export { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests";
import { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests";
// ---------------------------------------------------------------------------
// Injectable dep types
// ---------------------------------------------------------------------------
export type RunDeletePibiTx = (params: {
id: bigint;
dealId: string;
}) => Promise<{ remainingMeasureNames: string[] }>;
export type SyncMeasuresField = typeof defaultSyncMeasuresField;
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
export type DeletePibiRequestResult =
| { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string }
| { ok: false; error: string };
export interface DeletePibiRequestInput {
id: bigint;
dealId: string;
deps?: {
runDeleteTx?: RunDeletePibiTx;
syncMeasuresField?: SyncMeasuresField;
};
}
// ---------------------------------------------------------------------------
// Default DB-backed implementation
// ---------------------------------------------------------------------------
const defaultRunDeleteTx: RunDeletePibiTx = async ({ id, dealId }) => {
return await db.transaction(async (tx) => {
const deleted = await tx
.delete(pibiRequests)
.where(and(eq(pibiRequests.id, id), eq(pibiRequests.hubspotDealId, dealId)))
.returning({ id: pibiRequests.id });
if (deleted.length === 0) {
throw new Error("PIBI request not found");
}
const remaining = await tx
.select({ measureName: pibiRequests.measureName })
.from(pibiRequests)
.where(eq(pibiRequests.hubspotDealId, dealId));
return { remainingMeasureNames: remaining.map((r) => r.measureName) };
});
};
// ---------------------------------------------------------------------------
// Service entry-point
// ---------------------------------------------------------------------------
export async function deletePibiRequest(
input: DeletePibiRequestInput,
): Promise<DeletePibiRequestResult> {
const runDeleteTx = input.deps?.runDeleteTx ?? defaultRunDeleteTx;
const syncMeasuresField = input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
let txResult: { remainingMeasureNames: string[] };
try {
txResult = await runDeleteTx({ id: input.id, dealId: input.dealId });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete PIBI request";
return { ok: false, error: message };
}
const syncResult = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: txResult.remainingMeasureNames,
});
if (syncResult.ok) {
return { ok: true, hubspotSync: "ok" };
}
return {
ok: true,
hubspotSync: "failed",
hubspotError: syncResult.error,
};
}

View file

@ -10,7 +10,7 @@ vi.mock("./client", () => ({
}),
}));
import { syncMeasuresFieldToHubSpot } from "./dealSync";
import { syncMeasuresFieldToHubSpot, syncSurveyRequestToHubSpot } from "./dealSync";
describe("syncMeasuresFieldToHubSpot", () => {
beforeEach(() => {
@ -122,3 +122,36 @@ describe("syncMeasuresFieldToHubSpot", () => {
});
});
});
describe("syncSurveyRequestToHubSpot", () => {
beforeEach(() => {
updateMock.mockReset();
});
it("writes survey type to osmosis_survey_required and date to osmosis_survey_date", async () => {
updateMock.mockResolvedValueOnce(undefined);
const requestedAt = new Date("2026-05-06T10:00:00.000Z");
const result = await syncSurveyRequestToHubSpot({
hubspotDealId: "deal-99",
surveyType: "technical_building_survey",
requestedAt,
});
expect(result).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalledWith("deal-99", {
properties: {
osmosis_survey_required: "technical_building_survey",
osmosis_survey_date: "2026-05-06",
},
});
});
it("returns ok: false with error message on HubSpot failure", async () => {
updateMock.mockRejectedValueOnce(new Error("HubSpot 400"));
const result = await syncSurveyRequestToHubSpot({
hubspotDealId: "deal-99",
surveyType: "technical_building_survey",
requestedAt: new Date(),
});
expect(result).toEqual({ ok: false, error: "HubSpot sync failed" });
});
});

View file

@ -187,14 +187,16 @@ export async function syncMeasuresFieldToHubSpot(params: {
export async function syncSurveyRequestToHubSpot(params: {
hubspotDealId: string;
notes: string;
requestedByEmail: string;
surveyType: string;
requestedAt: Date;
}): Promise<{ ok: boolean; error?: string }> {
try {
const client = getHubSpotClient();
const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`;
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: { survey_request_log: log },
properties: {
osmosis_survey_required: params.surveyType,
osmosis_survey_date: params.requestedAt.toISOString().slice(0, 10),
},
});
return { ok: true };
} catch (err) {

View file

@ -11,10 +11,13 @@ import {
PROPOSED_MEASURES_PROP,
APPROVED_MEASURES_PROP,
instructMeasure,
instructMeasures,
} from "./instructMeasure";
import type {
InstructTxOutcome,
InstructMeasuresTxOutcome,
RunInstructTx,
RunInstructMeasuresTx,
ReadInstructedMeasureNames,
StampPushedAt,
SyncMeasuresField,
@ -307,3 +310,180 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// instructMeasures (plural) — batch variant
// ---------------------------------------------------------------------------
function makeBatchDeps(overrides?: {
txOutcome?: Partial<InstructMeasuresTxOutcome>;
txError?: Error;
instructedAfter?: string[];
syncResults?: Array<{ ok: true } | { ok: false; error: string }>;
stampError?: Error;
}) {
const txOutcome: InstructMeasuresTxOutcome = {
instructedRowIds: [1n, 2n],
existingProposedMeasures: [],
allApprovedMeasureNames: [],
...overrides?.txOutcome,
};
const runInstructMeasuresTx: RunInstructMeasuresTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txOutcome;
});
const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
async () => overrides?.instructedAfter ?? ["ASHP", "Solar PV"],
);
const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return syncQueue.shift() ?? ({ ok: true } as const);
});
const stampPushedAt: StampPushedAt = vi.fn(async () => {
if (overrides?.stampError) throw overrides.stampError;
});
return {
runInstructMeasuresTx,
readInstructedMeasureNames,
syncMeasuresField,
stampPushedAt,
};
}
describe("instructMeasures — input validation", () => {
it("rejects when measureNames is empty", async () => {
const deps = makeBatchDeps();
const result = await instructMeasures({
dealId: "deal-1",
measureNames: [],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "measureNames must not be empty" });
expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
it("rejects when any measureName is unknown", async () => {
const deps = makeBatchDeps();
const result = await instructMeasures({
dealId: "deal-1",
measureNames: ["ASHP", "Not a real measure"],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "Unknown measure: Not a real measure" });
expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
});
describe("instructMeasures — happy path", () => {
it("commits single tx, pushes instructed + proposed + approved, stamps all rowIds", async () => {
const deps = makeBatchDeps({
instructedAfter: ["ASHP", "Solar PV"],
txOutcome: {
instructedRowIds: [10n, 11n],
existingProposedMeasures: [],
allApprovedMeasureNames: ["ASHP", "Solar PV"],
},
});
const result = await instructMeasures({
dealId: "deal-42",
measureNames: ["ASHP", "Solar PV"],
userId: 7n,
deps,
});
expect(result).toMatchObject({ ok: true, instructedRowIds: [10n, 11n], hubspotSync: "ok" });
expect(deps.runInstructMeasuresTx).toHaveBeenCalledOnce();
expect(deps.runInstructMeasuresTx).toHaveBeenCalledWith({
dealId: "deal-42",
measureNames: ["ASHP", "Solar PV"],
userId: 7n,
notes: null,
});
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
hubspotDealId: "deal-42",
propName: INSTRUCTED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-42",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
hubspotDealId: "deal-42",
propName: APPROVED_MEASURES_PROP,
measureNames: ["ASHP", "Solar PV"],
});
expect(deps.stampPushedAt).toHaveBeenCalledTimes(2);
expect(deps.stampPushedAt).toHaveBeenCalledWith(10n);
expect(deps.stampPushedAt).toHaveBeenCalledWith(11n);
});
it("merges all new measures into existing proposed (deduped)", async () => {
const deps = makeBatchDeps({
instructedAfter: ["ASHP", "EWI", "Solar PV"],
txOutcome: {
instructedRowIds: [20n, 21n],
existingProposedMeasures: ["ASHP", "Loft insulation"],
allApprovedMeasureNames: ["ASHP", "EWI", "Solar PV"],
},
});
await instructMeasures({
dealId: "deal-merge",
measureNames: ["EWI", "Solar PV"],
userId: 3n,
deps,
});
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
hubspotDealId: "deal-merge",
propName: PROPOSED_MEASURES_PROP,
measureNames: ["ASHP", "Loft insulation", "EWI", "Solar PV"],
});
});
});
describe("instructMeasures — DB transaction failure", () => {
it("returns error and skips HubSpot when tx throws", async () => {
const deps = makeBatchDeps({ txError: new Error("batch insert failed") });
const result = await instructMeasures({
dealId: "deal-x",
measureNames: ["ASHP", "EWI"],
userId: 1n,
deps,
});
expect(result).toEqual({ ok: false, error: "batch insert failed" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});
describe("instructMeasures — HubSpot push failure leaves DB committed", () => {
it("returns ok=true with hubspotSync=failed when sync fails, does NOT stamp", async () => {
const deps = makeBatchDeps({
instructedAfter: ["ASHP", "EWI"],
txOutcome: {
instructedRowIds: [30n, 31n],
existingProposedMeasures: [],
allApprovedMeasureNames: ["ASHP", "EWI"],
},
syncResults: [{ ok: false, error: "hubspot 500" }, { ok: true }, { ok: true }],
});
const result = await instructMeasures({
dealId: "deal-h",
measureNames: ["ASHP", "EWI"],
userId: 1n,
deps,
});
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "hubspot 500",
});
expect(deps.stampPushedAt).not.toHaveBeenCalled();
});
});

View file

@ -196,6 +196,224 @@ const defaultStampPushedAt: StampPushedAt = async (rowId) => {
.where(eq(userDefinedDealMeasures.id, rowId));
};
// ---------------------------------------------------------------------------
// Batch (plural) types
// ---------------------------------------------------------------------------
export interface InstructMeasuresTxOutcome {
instructedRowIds: bigint[];
existingProposedMeasures: string[];
allApprovedMeasureNames: string[];
}
export type RunInstructMeasuresTx = (params: {
dealId: string;
measureNames: MeasureName[];
userId: bigint;
notes: string | null;
}) => Promise<InstructMeasuresTxOutcome>;
export type InstructMeasuresResult =
| {
ok: true;
instructedRowIds: bigint[];
hubspotSync: "ok" | "failed";
hubspotError?: string;
}
| { ok: false; error: string };
export interface InstructMeasuresInput {
dealId: string;
measureNames: string[];
userId: bigint;
notes?: string;
deps?: {
runInstructMeasuresTx?: RunInstructMeasuresTx;
readInstructedMeasureNames?: ReadInstructedMeasureNames;
syncMeasuresField?: SyncMeasuresField;
stampPushedAt?: StampPushedAt;
};
}
const defaultRunInstructMeasuresTx: RunInstructMeasuresTx = async ({
dealId,
measureNames,
userId,
notes,
}) => {
return await db.transaction(async (tx) => {
const instructedRowIds: bigint[] = [];
for (const measureName of measureNames) {
const inserted = await tx
.insert(userDefinedDealMeasures)
.values({
hubspotDealId: dealId,
measureName,
source: "instructed",
createdByUserId: userId,
notes,
})
.returning({ id: userDefinedDealMeasures.id });
const rowId = inserted[0]?.id;
if (rowId === undefined || rowId === null) {
throw new Error("Failed to insert user_defined_deal_measures row");
}
instructedRowIds.push(rowId);
await tx
.insert(dealMeasureApprovals)
.values({
hubspotDealId: dealId,
measureName,
isApproved: true,
approvedBy: userId,
})
.onConflictDoUpdate({
target: [
dealMeasureApprovals.hubspotDealId,
dealMeasureApprovals.measureName,
],
set: {
isApproved: true,
approvedBy: userId,
approvedAt: new Date(),
},
});
await tx.insert(dealMeasureApprovalEvents).values({
hubspotDealId: dealId,
measureName,
action: "approved",
actedBy: userId,
});
}
const dealRows = await tx
.select({ proposedMeasures: hubspotDealData.proposedMeasures })
.from(hubspotDealData)
.where(eq(hubspotDealData.dealId, dealId))
.limit(1);
const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null);
const approvedRows = await tx
.select({ measureName: dealMeasureApprovals.measureName })
.from(dealMeasureApprovals)
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
const allApprovedMeasureNames = approvedRows.map((r) => r.measureName);
return { instructedRowIds, existingProposedMeasures, allApprovedMeasureNames };
});
};
export async function instructMeasures(
input: InstructMeasuresInput,
): Promise<InstructMeasuresResult> {
if (input.measureNames.length === 0) {
return { ok: false, error: "measureNames must not be empty" };
}
const validatedNames: MeasureName[] = [];
for (const name of input.measureNames) {
const trimmed = name.trim();
if (!isMeasureName(trimmed)) {
return { ok: false, error: `Unknown measure: ${trimmed}` };
}
validatedNames.push(trimmed);
}
const runInstructMeasuresTx =
input.deps?.runInstructMeasuresTx ?? defaultRunInstructMeasuresTx;
const readInstructed =
input.deps?.readInstructedMeasureNames ?? defaultReadInstructedMeasureNames;
const syncMeasuresField =
input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt;
let txResult: InstructMeasuresTxOutcome;
try {
txResult = await runInstructMeasuresTx({
dealId: input.dealId,
measureNames: validatedNames,
userId: input.userId,
notes: input.notes ?? null,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to instruct measures";
console.error("[instructMeasures] transaction failed", {
dealId: input.dealId,
measureNames: validatedNames,
error: err,
});
return { ok: false, error: message };
}
const allInstructed = await readInstructed(input.dealId);
const mergedProposed = Array.from(
new Set([...txResult.existingProposedMeasures, ...validatedNames]),
);
const instructedSync = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: INSTRUCTED_MEASURES_PROP,
measureNames: allInstructed,
});
const proposedSync = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: PROPOSED_MEASURES_PROP,
measureNames: mergedProposed,
});
const approvedSync = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: APPROVED_MEASURES_PROP,
measureNames: txResult.allApprovedMeasureNames,
});
const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok;
if (overallOk) {
for (const rowId of txResult.instructedRowIds) {
try {
await stampPushedAt(rowId);
} catch (err) {
console.error("[instructMeasures] failed to stamp pushed_at", {
rowId: String(rowId),
error: err,
});
}
}
return {
ok: true,
instructedRowIds: txResult.instructedRowIds,
hubspotSync: "ok",
};
}
const hubspotError = !instructedSync.ok
? instructedSync.error
: !proposedSync.ok
? proposedSync.error
: !approvedSync.ok
? approvedSync.error
: "HubSpot sync failed";
return {
ok: true,
instructedRowIds: txResult.instructedRowIds,
hubspotSync: "failed",
hubspotError,
};
}
export async function instructMeasure(
input: InstructMeasureInput,
): Promise<InstructMeasureResult> {

View file

@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
groupByBatch,
formatDate,
toDateInputValue,
dateInputToIso,
} from "./pibiSectionHelpers";
// ── groupByBatch ───────────────────────────────────────────────────────────────
describe("groupByBatch", () => {
it("returns empty array for no rows", () => {
expect(groupByBatch([])).toEqual([]);
});
it("groups rows with the same orderedAt into one batch", () => {
const orderedAt = "2026-05-01T00:00:00.000Z";
const rows = [
{ id: "1", measureName: "CWI", orderedAt, completedAt: null },
{ id: "2", measureName: "ASHP", orderedAt, completedAt: null },
];
const batches = groupByBatch(rows);
expect(batches).toHaveLength(1);
expect(batches[0].orderedAt).toBe(orderedAt);
expect(batches[0].rows).toHaveLength(2);
});
it("creates separate batches for different orderedAt values", () => {
const rows = [
{ id: "1", measureName: "CWI", orderedAt: "2026-05-01T00:00:00.000Z", completedAt: null },
{ id: "2", measureName: "ASHP", orderedAt: "2026-05-15T00:00:00.000Z", completedAt: null },
];
const batches = groupByBatch(rows);
expect(batches).toHaveLength(2);
});
it("preserves insertion order of first-seen orderedAt keys", () => {
const first = "2026-04-01T00:00:00.000Z";
const second = "2026-05-01T00:00:00.000Z";
const rows = [
{ id: "1", measureName: "CWI", orderedAt: first, completedAt: null },
{ id: "2", measureName: "ASHP", orderedAt: second, completedAt: null },
{ id: "3", measureName: "EWI", orderedAt: first, completedAt: null },
];
const batches = groupByBatch(rows);
expect(batches[0].orderedAt).toBe(first);
expect(batches[0].rows).toHaveLength(2);
expect(batches[1].orderedAt).toBe(second);
});
});
// ── formatDate ─────────────────────────────────────────────────────────────────
describe("formatDate", () => {
it("returns em-dash for null", () => {
expect(formatDate(null)).toBe("—");
});
it("returns em-dash for empty string", () => {
expect(formatDate("")).toBe("—");
});
it("formats a valid ISO date as dd Mon yyyy in en-GB locale", () => {
expect(formatDate("2026-05-06T00:00:00.000Z")).toBe("06 May 2026");
});
it("returns em-dash for an unparseable string", () => {
expect(formatDate("not-a-date")).toBe("—");
});
});
// ── toDateInputValue ───────────────────────────────────────────────────────────
describe("toDateInputValue", () => {
it("returns empty string for null", () => {
expect(toDateInputValue(null)).toBe("");
});
it("converts ISO to YYYY-MM-DD using UTC date parts", () => {
expect(toDateInputValue("2026-05-06T00:00:00.000Z")).toBe("2026-05-06");
});
it("zero-pads month and day", () => {
expect(toDateInputValue("2026-01-09T00:00:00.000Z")).toBe("2026-01-09");
});
it("returns empty string for unparseable input", () => {
expect(toDateInputValue("not-a-date")).toBe("");
});
});
// ── dateInputToIso ─────────────────────────────────────────────────────────────
describe("dateInputToIso", () => {
it("returns null for empty string", () => {
expect(dateInputToIso("")).toBeNull();
});
it("converts YYYY-MM-DD to midnight UTC ISO string", () => {
expect(dateInputToIso("2026-05-06")).toBe("2026-05-06T00:00:00.000Z");
});
it("round-trips with toDateInputValue", () => {
const iso = "2026-03-15T00:00:00.000Z";
expect(dateInputToIso(toDateInputValue(iso))).toBe(iso);
});
});

View file

@ -0,0 +1,53 @@
export type PibiRow = {
id: string;
measureName: string;
orderedAt: string;
completedAt: string | null;
};
export type PibiBatch = {
orderedAt: string;
rows: PibiRow[];
};
export function groupByBatch(rows: PibiRow[]): PibiBatch[] {
const map = new Map<string, PibiRow[]>();
for (const row of rows) {
const key = row.orderedAt;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
return Array.from(map.entries()).map(([orderedAt, rows]) => ({ orderedAt, rows }));
}
export function formatDate(iso: string | null): string {
if (!iso) return "—";
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-GB", {
day: "2-digit", month: "short", year: "numeric",
});
} catch {
return "—";
}
}
export function toDateInputValue(iso: string | null): string {
if (!iso) return "";
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
} catch {
return "";
}
}
export function dateInputToIso(value: string): string | null {
if (!value) return null;
return new Date(`${value}T00:00:00.000Z`).toISOString();
}

View file

@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { updatePibiRequest, PIBI_ORDERED_TEXT_PROP } from "./updatePibiRequest";
import type { RunUpdatePibiTx, SyncMeasuresField } from "./updatePibiRequest";
function makeDeps(overrides?: {
txResult?: { allMeasureNames: string[] };
txError?: Error;
syncResult?: { ok: true } | { ok: false; error: string };
}) {
const txResult = overrides?.txResult ?? { allMeasureNames: ["CWI", "ASHP"] };
const runUpdateTx: RunUpdatePibiTx = vi.fn(async () => {
if (overrides?.txError) throw overrides.txError;
return txResult;
});
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
return overrides?.syncResult ?? ({ ok: true } as const);
});
return { runUpdateTx, syncMeasuresField };
}
describe("updatePibiRequest — happy path", () => {
it("updates row, re-syncs all deal measures to HubSpot", async () => {
const completedAt = new Date("2026-05-10T12:00:00Z");
const deps = makeDeps({
txResult: { allMeasureNames: ["CWI", "ASHP"] },
});
const result = await updatePibiRequest({
id: 7n,
dealId: "deal-1",
updates: { completedAt },
deps,
});
expect(result).toEqual({ ok: true, hubspotSync: "ok" });
expect(deps.runUpdateTx).toHaveBeenCalledWith({
id: 7n,
dealId: "deal-1",
updates: { completedAt },
});
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
hubspotDealId: "deal-1",
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: ["CWI", "ASHP"],
});
});
it("can update measureName, orderedAt, and completedAt", async () => {
const orderedAt = new Date("2026-05-01T09:00:00Z");
const deps = makeDeps();
await updatePibiRequest({
id: 3n,
dealId: "deal-2",
updates: { measureName: "EWI", orderedAt },
deps,
});
expect(deps.runUpdateTx).toHaveBeenCalledWith({
id: 3n,
dealId: "deal-2",
updates: { measureName: "EWI", orderedAt },
});
});
});
describe("updatePibiRequest — DB failure", () => {
it("returns ok=false, skips HubSpot when tx throws", async () => {
const deps = makeDeps({ txError: new Error("row not found") });
const result = await updatePibiRequest({
id: 99n,
dealId: "deal-x",
updates: { completedAt: new Date() },
deps,
});
expect(result).toEqual({ ok: false, error: "row not found" });
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
});
});
describe("updatePibiRequest — HubSpot failure", () => {
it("returns ok=true with hubspotSync=failed", async () => {
const deps = makeDeps({
syncResult: { ok: false, error: "hubspot timeout" },
});
const result = await updatePibiRequest({
id: 5n,
dealId: "deal-h",
updates: { completedAt: new Date() },
deps,
});
expect(result).toMatchObject({
ok: true,
hubspotSync: "failed",
hubspotError: "hubspot timeout",
});
});
});

View file

@ -0,0 +1,111 @@
import { db } from "@/app/db/db";
import { pibiRequests } from "@/app/db/schema/pibi_requests";
import { eq, and } from "drizzle-orm";
import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync";
export { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests";
import { PIBI_ORDERED_TEXT_PROP } from "./createPibiRequests";
// ---------------------------------------------------------------------------
// Injectable dep types
// ---------------------------------------------------------------------------
export type RunUpdatePibiTx = (params: {
id: bigint;
dealId: string;
updates: PibiRequestUpdates;
}) => Promise<{ allMeasureNames: string[] }>;
export type SyncMeasuresField = typeof defaultSyncMeasuresField;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PibiRequestUpdates {
measureName?: string;
orderedAt?: Date;
completedAt?: Date | null;
}
export type UpdatePibiRequestResult =
| { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string }
| { ok: false; error: string };
export interface UpdatePibiRequestInput {
id: bigint;
dealId: string;
updates: PibiRequestUpdates;
deps?: {
runUpdateTx?: RunUpdatePibiTx;
syncMeasuresField?: SyncMeasuresField;
};
}
// ---------------------------------------------------------------------------
// Default DB-backed implementation
// ---------------------------------------------------------------------------
const defaultRunUpdateTx: RunUpdatePibiTx = async ({ id, dealId, updates }) => {
return await db.transaction(async (tx) => {
const result = await tx
.update(pibiRequests)
.set({
...(updates.measureName !== undefined && { measureName: updates.measureName }),
...(updates.orderedAt !== undefined && { orderedAt: updates.orderedAt }),
...(updates.completedAt !== undefined && { completedAt: updates.completedAt }),
})
.where(and(eq(pibiRequests.id, id), eq(pibiRequests.hubspotDealId, dealId)))
.returning({ id: pibiRequests.id });
if (result.length === 0) {
throw new Error("PIBI request not found");
}
const allRows = await tx
.select({ measureName: pibiRequests.measureName })
.from(pibiRequests)
.where(eq(pibiRequests.hubspotDealId, dealId));
return { allMeasureNames: allRows.map((r) => r.measureName) };
});
};
// ---------------------------------------------------------------------------
// Service entry-point
// ---------------------------------------------------------------------------
export async function updatePibiRequest(
input: UpdatePibiRequestInput,
): Promise<UpdatePibiRequestResult> {
const runUpdateTx = input.deps?.runUpdateTx ?? defaultRunUpdateTx;
const syncMeasuresField = input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
let txResult: { allMeasureNames: string[] };
try {
txResult = await runUpdateTx({
id: input.id,
dealId: input.dealId,
updates: input.updates,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update PIBI request";
return { ok: false, error: message };
}
const syncResult = await syncMeasuresField({
hubspotDealId: input.dealId,
propName: PIBI_ORDERED_TEXT_PROP,
measureNames: txResult.allMeasureNames,
});
if (syncResult.ok) {
return { ok: true, hubspotSync: "ok" };
}
return {
ok: true,
hubspotSync: "failed",
hubspotError: syncResult.error,
};
}

View file

@ -2,7 +2,7 @@
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Link2, Link2Off, AlertTriangle, Search } from "lucide-react";
import { Building2, Link2, Link2Off, AlertTriangle, Search, CheckCircle2, PlusCircle } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
import {
@ -18,9 +18,9 @@ type OrgSummary = {
hubspotCompanyId: string | null;
};
async function fetchCurrentOrg(portfolioId: string): Promise<OrgSummary | null> {
async function fetchLinkedOrgs(portfolioId: string): Promise<OrgSummary[]> {
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`);
if (!res.ok) throw new Error("Failed to fetch linked organisation");
if (!res.ok) throw new Error("Failed to fetch linked organisations");
return res.json();
}
@ -33,25 +33,25 @@ async function fetchAllOrgs(): Promise<OrgSummary[]> {
export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) {
const queryClient = useQueryClient();
const [connectOpen, setConnectOpen] = useState(false);
const [disconnectOpen, setDisconnectOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [disconnectTarget, setDisconnectTarget] = useState<OrgSummary | null>(null);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [confirmed, setConfirmed] = useState(false);
// Current linked org
const { data: currentOrg, isLoading: loadingCurrent } = useQuery({
queryKey: ["portfolio-org", portfolioId],
queryFn: () => fetchCurrentOrg(portfolioId),
const { data: linkedOrgs = [], isLoading: loadingLinked } = useQuery({
queryKey: ["portfolio-orgs", portfolioId],
queryFn: () => fetchLinkedOrgs(portfolioId),
});
// All orgs — only fetched when connect modal is open
const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({
queryKey: ["all-organisations"],
queryFn: fetchAllOrgs,
enabled: connectOpen,
enabled: addOpen,
});
const linkedOrgIds = useMemo(() => new Set(linkedOrgs.map((o) => o.id)), [linkedOrgs]);
const connectMutation = useMutation({
mutationFn: async (organisationId: string) => {
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
@ -63,8 +63,8 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
setConnectOpen(false);
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
setAddOpen(false);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
@ -72,116 +72,102 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
});
const disconnectMutation = useMutation({
mutationFn: async () => {
mutationFn: async (organisationId: string) => {
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ organisationId }),
});
if (!res.ok) throw new Error("Failed to disconnect organisation");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
setDisconnectOpen(false);
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
setDisconnectTarget(null);
},
});
const filteredOrgs = useMemo(
() =>
allOrgs.filter((o) =>
(o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()),
),
[allOrgs, searchQuery],
const availableOrgs = useMemo(
() => allOrgs.filter((o) => !linkedOrgIds.has(o.id) && (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())),
[allOrgs, linkedOrgIds, searchQuery],
);
const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null;
function openAdd() {
setAddOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}
return (
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
<div className="p-2 rounded-lg bg-brandblue/10">
<Building2 className="h-4 w-4 text-brandblue" />
</div>
<div>
<p className="text-sm font-semibold text-brandblue">Organisation Link</p>
<p className="text-xs text-gray-500 mt-0.5">
Connect this portfolio to an organisation to enable live project tracking
</p>
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brandblue/10">
<Building2 className="h-4 w-4 text-brandblue" />
</div>
<div>
<p className="text-sm font-semibold text-brandblue">Organisation Links</p>
<p className="text-xs text-gray-500 mt-0.5">
Connect this portfolio to one or more organisations to enable live project tracking
</p>
</div>
</div>
<Button
size="sm"
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
onClick={openAdd}
>
<PlusCircle className="h-3.5 w-3.5 mr-1.5" />
Add Organisation
</Button>
</div>
{/* Body */}
<div className="px-5 py-4">
{loadingCurrent ? (
<div className="px-5 py-4 space-y-2">
{loadingLinked ? (
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
) : currentOrg ? (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-gray-800">{currentOrg.name ?? "Unnamed organisation"}</p>
<p className="text-xs text-gray-400 mt-0.5">
Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"}
</p>
</div>
) : linkedOrgs.length === 0 ? (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<Building2 className="h-4 w-4 text-gray-400" />
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 text-xs border-brandblue/20 text-brandblue hover:bg-brandlightblue/30"
onClick={() => {
setConnectOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Change
</Button>
<p className="text-sm text-gray-500">No organisations linked</p>
</div>
) : (
linkedOrgs.map((org) => (
<div key={org.id} className="flex items-center justify-between gap-4 py-1">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-gray-800">{org.name ?? "Unnamed organisation"}</p>
<p className="text-xs text-gray-400 mt-0.5">
Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-xs border-red-200 text-red-600 hover:bg-red-50"
onClick={() => setDisconnectOpen(true)}
onClick={() => setDisconnectTarget(org)}
>
<Link2Off className="h-3.5 w-3.5 mr-1.5" />
Disconnect
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
<Building2 className="h-4 w-4 text-gray-400" />
</div>
<p className="text-sm text-gray-500">No organisation linked</p>
</div>
<Button
size="sm"
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
onClick={() => {
setConnectOpen(true);
setSelectedOrgId(null);
setConfirmed(false);
setSearchQuery("");
}}
>
<Link2 className="h-3.5 w-3.5 mr-1.5" />
Connect Organisation
</Button>
</div>
))
)}
</div>
{/* ── Connect modal ─────────────────────────────────────────────── */}
<Dialog open={connectOpen} onOpenChange={(v) => { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
{/* ── Add Organisation modal ─────────────────────────────────────────────── */}
<Dialog open={addOpen} onOpenChange={(v) => { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
<DialogContent className="max-w-md">
<DialogTitle className="text-brandblue">Connect Organisation</DialogTitle>
<DialogTitle className="text-brandblue">Add Organisation</DialogTitle>
{/* Search */}
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
@ -192,14 +178,13 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
/>
</div>
{/* Org list */}
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
{loadingOrgs ? (
<div className="p-4 text-sm text-gray-400 text-center">Loading</div>
) : filteredOrgs.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">No organisations found</div>
) : availableOrgs.length === 0 ? (
<div className="p-4 text-sm text-gray-400 text-center">No organisations available</div>
) : (
filteredOrgs.map((org) => (
availableOrgs.map((org) => (
<button
key={org.id}
onClick={() => setSelectedOrgId(org.id)}
@ -218,7 +203,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
)}
</div>
{/* Warning */}
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-amber-50 border border-amber-200 mt-1">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-xs text-amber-700 leading-relaxed">
@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
</p>
</div>
{/* Confirmation checkbox */}
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
<input
type="checkbox"
@ -238,7 +221,7 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
</label>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setConnectOpen(false)} className="text-sm">
<Button variant="outline" onClick={() => setAddOpen(false)} className="text-sm">
Cancel
</Button>
<Button
@ -252,21 +235,21 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
</DialogContent>
</Dialog>
{/* ── Disconnect confirm dialog ──────────────────────────────────── */}
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
{/* ── Disconnect confirm dialog ──────────────────────────────────────────── */}
<Dialog open={!!disconnectTarget} onOpenChange={(v) => { if (!v) setDisconnectTarget(null); }}>
<DialogContent className="max-w-sm">
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
<p className="text-sm text-gray-600 leading-relaxed">
Are you sure you want to disconnect{" "}
<strong>{currentOrg?.name ?? "this organisation"}</strong>?
Live project tracking data will no longer be visible to portfolio viewers.
<strong>{disconnectTarget?.name ?? "this organisation"}</strong>?
Live project tracking data for this organisation will no longer be visible to portfolio viewers.
</p>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={() => setDisconnectOpen(false)} className="text-sm">
<Button variant="outline" onClick={() => setDisconnectTarget(null)} className="text-sm">
Cancel
</Button>
<Button
onClick={() => disconnectMutation.mutate()}
onClick={() => disconnectTarget && disconnectMutation.mutate(disconnectTarget.id)}
disabled={disconnectMutation.isPending}
className="bg-red-600 hover:bg-red-700 text-white text-sm"
>

View file

@ -0,0 +1,87 @@
"use client";
import { useQuery } from "@tanstack/react-query";
type AuditEvent = {
id: string;
hubspotDealId: string;
measureName: string;
action: string;
actedByEmail: string;
actedByName: string | null;
actedAt: string;
};
function formatDate(iso: string) {
return new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function ActivityLog({
dealId,
portfolioId,
}: {
dealId: string;
portfolioId: string;
}) {
const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
queryKey: ["approvalEvents", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`,
);
if (!res.ok) throw new Error("Failed to fetch events");
return res.json();
},
staleTime: 30_000,
});
if (isLoading) {
return (
<p className="text-xs text-gray-400 py-2 pl-4">Loading activity</p>
);
}
const events = data?.events ?? [];
if (events.length === 0) {
return (
<p className="text-xs text-gray-400 py-2 pl-4">No activity yet.</p>
);
}
return (
<div className="relative">
<div className="max-h-48 overflow-y-auto pl-4 pr-2 pb-3 space-y-1.5">
{events.map((e) => (
<div key={e.id} className="flex items-center gap-2 text-xs">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
e.action === "approved"
? "bg-emerald-50 text-emerald-700"
: "bg-red-50 text-red-600"
}`}
>
{e.action === "approved" ? "Approved" : "Unapproved"}
</span>
<span className="font-medium text-gray-700">{e.measureName}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">
{e.actedByName ?? e.actedByEmail}
</span>
<span className="text-gray-400">·</span>
<span className="text-gray-400">{formatDate(e.actedAt)}</span>
</div>
))}
</div>
{events.length > 4 && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-white to-transparent" />
)}
</div>
);
}

View file

@ -24,6 +24,13 @@ import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import { parseMeasures } from "@/app/lib/parseMeasures";
import {
applyBulkDocType,
selectAllUnclassified,
getClassifiedCount,
getUnclassifiedIds,
uploadedCountForType,
} from "./classifyPhase";
// ── Types ─────────────────────────────────────────────────────────────────
@ -272,7 +279,6 @@ function DocTypeButtonGrid({
uploadedDocs: string[];
}) {
const [showOther, setShowOther] = useState(false);
const uploadedSet = new Set(uploadedDocs);
const requiredSet = new Set(requiredDocs);
const isOtherSelected = value !== "" && !requiredSet.has(value);
@ -283,7 +289,7 @@ function DocTypeButtonGrid({
{requiredDocs.map((docType) => {
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
const label = option?.label ?? docType;
const alreadyUploaded = uploadedSet.has(docType);
const uploadedCount = uploadedCountForType(uploadedDocs, docType);
const isSelected = value === docType;
return (
@ -291,21 +297,19 @@ function DocTypeButtonGrid({
key={docType}
type="button"
onClick={() => { onChange(docType); setShowOther(false); }}
title={alreadyUploaded ? `${label} already uploaded` : label}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
title={uploadedCount > 0 ? `${label} ${uploadedCount} already uploaded` : label}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isSelected
? "bg-brandblue text-white border-brandblue shadow-sm"
: alreadyUploaded
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
}`}
>
{alreadyUploaded && !isSelected && (
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{label}
{uploadedCount > 0 && !isSelected && (
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-gray-100 text-gray-500 text-[10px] font-semibold leading-none">
{uploadedCount}
</span>
)}
</button>
);
})}
@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
const [saveError, setSaveError] = useState<string | null>(null);
// The measure selected in the measure-select phase (empty = "not measure-specific")
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
// Bulk classify state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDocType, setBulkDocType] = useState("");
// ── Fetch existing unclassified files on mount ───────────────────────
@ -581,14 +588,35 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
}
const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
const classifiedCount = getClassifiedCount(classifiableEntries);
const unclassifiedCount = getUnclassifiedIds(classifiableEntries).length;
function handleBulkApply() {
if (!bulkDocType) return;
setQueue((prev) => applyBulkDocType(prev, selectedIds, bulkDocType));
setSelectedIds(new Set());
setBulkDocType("");
}
function toggleSelectId(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function handleSelectAllUnclassified() {
setSelectedIds(selectAllUnclassified(classifiableEntries));
}
async function handleSaveClassifications() {
setSaveError(null);
setIsSaving(true);
try {
const toSave = classifiableEntries.filter((f) => f.docType !== "");
await saveClassifications(
classifiableEntries.map((f) => ({
toSave.map((f) => ({
id: f.uploadedId!,
fileType: f.docType,
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
@ -810,17 +838,43 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
</div>
)}
{/* Select-all row */}
{classifiableEntries.length > 0 && (
<div className="flex items-center gap-2 px-1">
<input
type="checkbox"
id="select-all-unclassified"
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer"
checked={selectedIds.size > 0 && selectedIds.size === selectAllUnclassified(classifiableEntries).size}
onChange={() => {
const allUnclassified = selectAllUnclassified(classifiableEntries);
setSelectedIds(allUnclassified.size === selectedIds.size ? new Set() : allUnclassified);
}}
/>
<label htmlFor="select-all-unclassified" className="text-xs text-gray-500 cursor-pointer select-none">
Select all unclassified ({unclassifiedCount})
</label>
</div>
)}
{/* File list with classification */}
<div className="space-y-3">
{classifiableEntries.map((entry) => {
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
const isChecked = selectedIds.has(entry.id);
return (
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
<div key={entry.id} className={`rounded-lg border bg-gray-50/50 p-3 space-y-2.5 transition-colors ${isChecked ? "border-brandblue/30 bg-brandlightblue/5" : "border-gray-100"}`}>
{/* File info row */}
<div className="flex items-center gap-2 min-w-0">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer shrink-0"
checked={isChecked}
onChange={() => toggleSelectId(entry.id)}
/>
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
@ -897,6 +951,51 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
})()}
</div>
{/* ── Bulk classify toolbar — shown when files are selected ── */}
{phase === "classify" && selectedIds.size > 0 && (
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-brandlightblue/10 border-t border-brandblue/20">
<span className="text-xs font-semibold text-brandblue whitespace-nowrap">
{selectedIds.size} selected
</span>
<span className="text-xs text-gray-400"></span>
<span className="text-xs text-gray-600 whitespace-nowrap">Classify as:</span>
<Select value={bulkDocType} onValueChange={setBulkDocType}>
<SelectTrigger className="h-7 text-xs flex-1 max-w-56">
<SelectValue placeholder="Choose type…" />
</SelectTrigger>
<SelectContent>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">{group}</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
<Button
size="sm"
className="h-7 text-xs bg-brandblue text-white shrink-0"
disabled={!bulkDocType}
onClick={handleBulkApply}
>
Apply
</Button>
<button
type="button"
className="text-xs text-gray-400 hover:text-gray-600 shrink-0 ml-1"
onClick={() => setSelectedIds(new Set())}
>
Clear
</button>
</div>
)}
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
{phase === "loading" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
@ -930,17 +1029,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
Skip for now
</Button>
<Button
onClick={handleSaveClassifications}
disabled={!allClassified || isSaving}
className="bg-brandblue text-white gap-1.5"
>
{isSaving ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving</>
) : (
"Save Classifications →"
<div className="flex flex-col items-end gap-0.5">
<Button
onClick={handleSaveClassifications}
disabled={classifiedCount === 0 || isSaving}
className="bg-brandblue text-white gap-1.5"
>
{isSaving ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving</>
) : (
`Save ${classifiedCount} classified →`
)}
</Button>
{unclassifiedCount > 0 && (
<p className="text-[10px] text-gray-400">
{unclassifiedCount} unclassified will stay in your queue
</p>
)}
</Button>
</div>
</>
)}
</DialogFooter>

View file

@ -30,6 +30,7 @@ import type {
DocumentDrawerState,
DocStatusMap,
RemovalStatusByDeal,
InstructedMeasuresByDeal,
} from "./types";
export default function LiveTracker({
@ -39,6 +40,7 @@ export default function LiveTracker({
docStatusMap,
userCapability,
approvalsByDeal,
instructedMeasuresByDeal,
removalStatusByDeal,
portfolioId,
userRole,
@ -304,7 +306,9 @@ export default function LiveTracker({
<MeasuresTable
data={currentProject?.allDeals ?? []}
approvalsByDeal={approvalsByDeal}
instructedMeasuresByDeal={instructedMeasuresByDeal}
portfolioId={portfolioId}
isApprover={userCapability.includes("approver")}
/>
</div>
</TabsContent>

View file

@ -1,8 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
Table,
TableBody,
@ -13,34 +12,28 @@ import {
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { Search, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Search, CheckSquare, ListChecks, Loader2, X } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { filterMeasureRows } from "./measureFilters";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
type AuditEvent = {
id: string;
hubspotDealId: string;
measureName: string;
action: string; // 'approved' | 'unapproved'
actedByEmail: string;
actedByName: string | null;
actedAt: string; // ISO string
};
type Mode = "chip-click" | "instruct";
type Props = {
data: ClassifiedDeal[];
approvalsByDeal: ApprovalsByDeal;
instructedMeasuresByDeal: Record<string, string[]>;
portfolioId: string;
isApprover: boolean;
};
function ApprovalStatus({
proposed,
approved,
}: {
proposed: string[];
approved: string[];
}) {
// ── Approval status badge ────────────────────────────────────────────────────
function ApprovalStatus({ proposed, approved }: { proposed: string[]; approved: string[] }) {
if (proposed.length === 0) return null;
const approvedSet = new Set(approved);
const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
@ -66,103 +59,295 @@ function ApprovalStatus({
);
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ── Bulk approve modal ───────────────────────────────────────────────────────
function ActivityLog({
dealId,
portfolioId,
}: {
dealId: string;
type BulkApproveModalProps = {
selected: Array<{ dealId: string; dealname: string | null; measureName: string }>;
portfolioId: string;
}) {
const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
queryKey: ["approvalEvents", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`,
);
if (!res.ok) throw new Error("Failed to fetch events");
return res.json();
},
staleTime: 30_000,
});
onClose: () => void;
onSuccess: () => void;
};
if (isLoading) {
return (
<p className="text-xs text-gray-400 py-2 pl-4">Loading activity</p>
);
}
function BulkApproveModal({ selected, portfolioId, onClose, onSuccess }: BulkApproveModalProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const events = data?.events ?? [];
const groupedByDeal = useMemo(() => {
const map = new Map<string, { dealname: string | null; measures: string[] }>();
for (const item of selected) {
const existing = map.get(item.dealId);
if (existing) {
existing.measures.push(item.measureName);
} else {
map.set(item.dealId, { dealname: item.dealname, measures: [item.measureName] });
}
}
return map;
}, [selected]);
if (events.length === 0) {
return (
<p className="text-xs text-gray-400 py-2 pl-4">No activity yet.</p>
);
async function handleConfirm() {
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-approvals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
changes: selected.map((s) => ({
hubspotDealId: s.dealId,
measureName: s.measureName,
approved: true,
})),
}),
});
const data = await res.json();
if (!res.ok || !data.ok) {
setError(data.error ?? "Failed to approve measures");
return;
}
onSuccess();
} catch {
setError("Network error — please try again");
} finally {
setSubmitting(false);
}
}
return (
<div className="pl-4 pr-2 pb-3 space-y-1.5">
{events.map((e) => (
<div key={e.id} className="flex items-center gap-2 text-xs">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
e.action === "approved"
? "bg-emerald-50 text-emerald-700"
: "bg-red-50 text-red-600"
}`}
>
{e.action === "approved" ? "Approved" : "Unapproved"}
</span>
<span className="font-medium text-gray-700">{e.measureName}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">
{e.actedByName ?? e.actedByEmail}
</span>
<span className="text-gray-400">·</span>
<span className="text-gray-400">{formatDate(e.actedAt)}</span>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Confirm bulk approval</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
))}
<p className="text-sm text-gray-500">
Approving <span className="font-medium text-gray-800">{selected.length} measure{selected.length !== 1 ? "s" : ""}</span> across{" "}
<span className="font-medium text-gray-800">{groupedByDeal.size} propert{groupedByDeal.size !== 1 ? "ies" : "y"}</span>.
</p>
<div className="max-h-56 overflow-y-auto space-y-2">
{[...groupedByDeal.entries()].map(([dealId, { dealname, measures }]) => (
<div key={dealId} className="rounded-lg border border-gray-100 p-3 space-y-1.5">
<p className="text-xs font-medium text-gray-700">{dealname ?? dealId}</p>
<div className="flex flex-wrap gap-1">
{measures.map((m) => (
<span key={m} className="px-2 py-0.5 rounded-full text-xs bg-emerald-50 border border-emerald-200 text-emerald-700">
{m}
</span>
))}
</div>
</div>
))}
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
size="sm"
onClick={handleConfirm}
disabled={submitting}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Approve {selected.length} measure{selected.length !== 1 ? "s" : ""}
</Button>
</div>
</div>
</div>
);
}
// ── Bulk instruct modal ──────────────────────────────────────────────────────
type BulkInstructModalProps = {
selectedDealIds: string[];
deals: ClassifiedDeal[];
portfolioId: string;
onClose: () => void;
onSuccess: () => void;
};
function BulkInstructModal({ selectedDealIds, deals, portfolioId, onClose, onSuccess }: BulkInstructModalProps) {
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(new Set());
const [confirmText, setConfirmText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedDeals = useMemo(
() => deals.filter((d) => selectedDealIds.includes(d.dealId)),
[deals, selectedDealIds],
);
function toggleMeasure(name: string) {
setSelectedMeasures((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
const canSubmit = selectedMeasures.size > 0 && confirmText.trim() === "confirm" && !submitting;
async function handleSubmit() {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-instructed-measures`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deals: selectedDealIds.map((dealId) => ({
dealId,
measureNames: [...selectedMeasures],
})),
}),
});
const data = await res.json();
if (!res.ok || !data.ok) {
setError(data.error ?? "Failed to instruct measures");
return;
}
onSuccess();
} catch {
setError("Network error — please try again");
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Bulk instruct measures</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-500">
Instructing on <span className="font-medium text-gray-800">{selectedDeals.length} propert{selectedDeals.length !== 1 ? "ies" : "y"}</span>.
Select measures to add to all selected deals.
</p>
<div className="max-h-48 overflow-y-auto">
<div className="grid grid-cols-2 gap-1.5">
{MEASURE_NAMES.map((name) => (
<label
key={name}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-xs transition-colors ${
selectedMeasures.has(name)
? "bg-indigo-50 border-indigo-300 text-indigo-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
<Checkbox
checked={selectedMeasures.has(name)}
onCheckedChange={() => toggleMeasure(name)}
className="h-3.5 w-3.5"
/>
{name}
</label>
))}
</div>
</div>
{selectedMeasures.size > 0 && (
<div className="space-y-1">
<p className="text-xs text-gray-500">
Type <span className="font-mono font-semibold">confirm</span> to proceed
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="confirm"
className="h-8 text-sm"
/>
</div>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Instruct {selectedMeasures.size > 0 ? selectedMeasures.size : ""} measure{selectedMeasures.size !== 1 ? "s" : ""}
</Button>
</div>
</div>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export default function MeasuresTable({
data,
approvalsByDeal,
instructedMeasuresByDeal,
portfolioId,
isApprover,
}: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [mode, setMode] = useState<Mode>("chip-click");
// Chip-click mode: Set<"dealId::measureName">
const [selectedChips, setSelectedChips] = useState<Set<string>>(new Set());
// Instruct mode: Set<dealId>
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showApproveModal, setShowApproveModal] = useState(false);
const [showInstructModal, setShowInstructModal] = useState(false);
// Filter to only properties with proposed measures
const dealsWithMeasures = useMemo(
() => data.filter((d) => d.proposedMeasures),
[data],
() => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0),
[data, instructedMeasuresByDeal],
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return dealsWithMeasures;
return dealsWithMeasures.filter(
(d) =>
d.dealname?.toLowerCase().includes(q) ||
d.landlordPropertyId?.toLowerCase().includes(q) ||
d.proposedMeasures?.toLowerCase().includes(q),
);
}, [dealsWithMeasures, search]);
const filtered = useMemo(
() => filterMeasureRows(dealsWithMeasures, instructedMeasuresByDeal, search),
[dealsWithMeasures, instructedMeasuresByDeal, search],
);
function toggleRowExpand(dealId: string) {
setExpandedRows((prev) => {
function switchMode(next: Mode) {
setMode(next);
setSelectedChips(new Set());
setSelectedRows(new Set());
}
function toggleChip(dealId: string, measureName: string) {
const key = `${dealId}::${measureName}`;
setSelectedChips((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function toggleRow(dealId: string) {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(dealId)) next.delete(dealId);
else next.add(dealId);
@ -170,6 +355,45 @@ export default function MeasuresTable({
});
}
function toggleAllFiltered() {
const allIds = filtered.map((d) => d.dealId);
const allSelected = allIds.every((id) => selectedRows.has(id));
if (allSelected) {
setSelectedRows((prev) => {
const next = new Set(prev);
allIds.forEach((id) => next.delete(id));
return next;
});
} else {
setSelectedRows((prev) => {
const next = new Set(prev);
allIds.forEach((id) => next.add(id));
return next;
});
}
}
const selectedChipItems = useMemo(
() =>
[...selectedChips].map((key) => {
const [dealId, measureName] = key.split("::");
const deal = data.find((d) => d.dealId === dealId);
return { dealId, dealname: deal?.dealname ?? null, measureName };
}),
[selectedChips, data],
);
const allFilteredSelected =
filtered.length > 0 && filtered.every((d) => selectedRows.has(d.dealId));
function handleActionSuccess() {
setShowApproveModal(false);
setShowInstructModal(false);
setSelectedChips(new Set());
setSelectedRows(new Set());
startTransition(() => router.refresh());
}
if (dealsWithMeasures.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
@ -180,9 +404,11 @@ export default function MeasuresTable({
);
}
const colSpan = mode === "instruct" ? 7 : 6;
return (
<div className="space-y-4">
{/* Toolbar */}
{/* ── Toolbar ──────────────────────────────────────────────────── */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
@ -193,22 +419,100 @@ export default function MeasuresTable({
className="pl-9 h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">
{filtered.length} of {dealsWithMeasures.length} properties
</span>
<span className="text-xs text-gray-400 hidden sm:inline">
· Click a row to open property
</span>
{isApprover && (
<>
<Button
size="sm"
variant={mode === "chip-click" ? "default" : "outline"}
className="h-8 text-xs gap-1.5"
onClick={() => switchMode("chip-click")}
>
<CheckSquare className="h-3.5 w-3.5" />
Approve mode
</Button>
<Button
size="sm"
variant={mode === "instruct" ? "default" : "outline"}
className="h-8 text-xs gap-1.5"
onClick={() => switchMode("instruct")}
>
<ListChecks className="h-3.5 w-3.5" />
Instruct mode
</Button>
</>
)}
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
{/* ── Action bar ───────────────────────────────────────────────── */}
{isApprover && mode === "chip-click" && selectedChips.size > 0 && (
<div className="flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5">
<span className="text-sm text-emerald-800 font-medium">
{selectedChips.size} measure{selectedChips.size !== 1 ? "s" : ""} selected
</span>
<Button
size="sm"
className="h-7 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => setShowApproveModal(true)}
>
Approve selected
</Button>
<button
onClick={() => setSelectedChips(new Set())}
className="ml-auto text-emerald-600 hover:text-emerald-800 text-xs"
>
Clear
</button>
</div>
)}
{isApprover && mode === "instruct" && selectedRows.size > 0 && (
<div className="flex items-center gap-3 rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-2.5">
<span className="text-sm text-indigo-800 font-medium">
{selectedRows.size} propert{selectedRows.size !== 1 ? "ies" : "y"} selected
</span>
<Button
size="sm"
className="h-7 text-xs bg-indigo-600 hover:bg-indigo-700 text-white"
onClick={() => setShowInstructModal(true)}
>
Instruct measures
</Button>
<button
onClick={() => setSelectedRows(new Set())}
className="ml-auto text-indigo-600 hover:text-indigo-800 text-xs"
>
Clear
</button>
</div>
)}
{/* ── Table ────────────────────────────────────────────────────── */}
<div className="relative rounded-xl border border-gray-100 overflow-hidden bg-white">
{isPending && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm">
<Loader2 className="h-6 w-6 animate-spin text-brandblue" />
</div>
)}
<Table>
<TableHeader>
<TableRow className="bg-gray-50 border-b border-gray-100">
<TableHead className="w-6" />
{mode === "instruct" && (
<TableHead className="w-10 pl-3">
<Checkbox
checked={allFilteredSelected}
onCheckedChange={toggleAllFiltered}
aria-label="Select all filtered"
className="h-4 w-4"
/>
</TableHead>
)}
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Address
</TableHead>
@ -216,7 +520,13 @@ export default function MeasuresTable({
Stage
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Proposed Measures
Proposed
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Instructed
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Tech Approved
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Status
@ -226,121 +536,161 @@ export default function MeasuresTable({
<TableBody>
{filtered.map((deal) => {
const proposed = parseMeasures(deal.proposedMeasures);
const instructed = instructedMeasuresByDeal[deal.dealId] ?? [];
const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall);
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
const approvedSet = new Set(approvedForDeal);
const stageColor = STAGE_COLORS[deal.displayStage];
const isExpanded = expandedRows.has(deal.dealId);
const isRowSelected = selectedRows.has(deal.dealId);
const dealPageUrl = `/portfolio/${portfolioId}/your-projects/live/${deal.dealId}?tab=works`;
const handleRowClick = () => {
const handleRowClick = (e: React.MouseEvent) => {
// Don't navigate if clicking a chip or checkbox
const target = e.target as HTMLElement;
if (target.closest("[data-chip]") || target.closest("[data-checkbox]")) return;
router.push(dealPageUrl);
};
const handleRowKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(dealPageUrl);
}
};
return (
<React.Fragment key={deal.dealId}>
<TableRow
data-testid="measures-row"
onClick={handleRowClick}
onKeyDown={handleRowKeyDown}
tabIndex={0}
role="button"
className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer"
>
{/* Expand toggle */}
<TableCell className="py-3 pl-3 pr-0 w-6">
<button
onClick={(e) => { e.stopPropagation(); toggleRowExpand(deal.dealId); }}
className="text-gray-400 hover:text-brandblue transition-colors"
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<TableRow
key={deal.dealId}
data-testid="measures-row"
onClick={handleRowClick}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(dealPageUrl);
}
}}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${
isRowSelected ? "bg-indigo-50/40" : ""
}`}
>
{/* Row checkbox (instruct mode only) */}
{mode === "instruct" && (
<TableCell className="pl-3 py-3 w-10" data-checkbox>
<Checkbox
checked={isRowSelected}
onCheckedChange={() => toggleRow(deal.dealId)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
aria-label={`Select ${deal.dealname ?? deal.dealId}`}
/>
</TableCell>
)}
{/* Address */}
<TableCell className="py-3">
<div className="font-medium text-sm text-gray-800">
{deal.dealname ?? "—"}
{/* Address */}
<TableCell className="py-3">
<div className="font-medium text-sm text-gray-800">
{deal.dealname ?? "—"}
</div>
{deal.landlordPropertyId && (
<div className="text-xs text-gray-400 mt-0.5">
{deal.landlordPropertyId}
</div>
{deal.landlordPropertyId && (
<div className="text-xs text-gray-400 mt-0.5">
{deal.landlordPropertyId}
</div>
)}
</TableCell>
)}
</TableCell>
{/* Stage */}
<TableCell className="py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
{deal.displayStage}
</span>
</TableCell>
{/* Stage */}
<TableCell className="py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
{deal.displayStage}
</span>
</TableCell>
{/* Proposed measures — read-only; click the row to approve in the drawer */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const isApproved = approvedSet.has(measure);
return (
<span
key={measure}
className={`px-2 py-1 rounded-full text-xs border ${
isApproved
{/* Proposed measures */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const chipKey = `${deal.dealId}::${measure}`;
const isSelected = selectedChips.has(chipKey);
const isApproved = approvedSet.has(measure);
const clickable = isApprover && mode === "chip-click";
return (
<span
key={measure}
data-chip
onClick={clickable ? (e) => { e.stopPropagation(); toggleChip(deal.dealId, measure); } : undefined}
className={`px-2 py-1 rounded-full text-xs border transition-all ${
isSelected
? "bg-emerald-100 border-emerald-400 text-emerald-800 ring-2 ring-emerald-300"
: isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600"
}`}
>
{measure}
</span>
);
})}
</div>
</TableCell>
} ${clickable ? "cursor-pointer hover:border-emerald-400" : ""}`}
>
{measure}
</span>
);
})}
</div>
</TableCell>
{/* Status */}
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
{/* Instructed measures */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{instructed.map((measure) => (
<span
key={measure}
className="px-2 py-1 rounded-full text-xs border bg-indigo-50 border-indigo-200 text-indigo-700"
>
{measure}
</span>
))}
</div>
</TableCell>
</TableRow>
{/* Tech approved */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{techApproved.map((measure) => (
<span
key={measure}
className="px-2 py-1 rounded-full text-xs border bg-violet-50 border-violet-200 text-violet-700"
>
{measure}
</span>
))}
</div>
</TableCell>
{/* Expandable activity log row */}
{isExpanded && (
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={5}
className="p-0"
>
<div className="border-t border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-2 pb-1">
Activity log
</p>
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
{/* Approval status */}
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* ── Modals ───────────────────────────────────────────────────── */}
{showApproveModal && (
<BulkApproveModal
selected={selectedChipItems}
portfolioId={portfolioId}
onClose={() => setShowApproveModal(false)}
onSuccess={handleActionSuccess}
/>
)}
{showInstructModal && (
<BulkInstructModal
selectedDealIds={[...selectedRows]}
deals={data}
portfolioId={portfolioId}
onClose={() => setShowInstructModal(false)}
onSuccess={handleActionSuccess}
/>
)}
</div>
);
}

View file

@ -0,0 +1,699 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
createColumnHelper,
flexRender,
type RowData,
} from "@tanstack/react-table";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
import {
toDateInputValue,
dateInputToIso,
formatDate,
} from "@/app/lib/pibiSectionHelpers";
import type { PibiRow } from "@/app/lib/pibiSectionHelpers";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
// ── TableMeta augmentation ────────────────────────────────────────────────────
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
pibi?: PibiTableMeta;
}
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface EditableRow {
id: string;
isNew: boolean;
measureName: string;
orderedAt: string; // yyyy-mm-dd or ""
completedAt: string; // yyyy-mm-dd or ""
isDirty: boolean;
isSaving: boolean;
error: string | null;
}
interface NewRowData {
id: string;
measureName: string;
orderedAt: string;
completedAt: string;
}
interface PibiTableMeta {
canEdit: boolean;
approvedMeasures: string[];
proposedMeasures: string[];
updateField(
id: string,
field: "measureName" | "orderedAt" | "completedAt",
value: string,
): void;
saveRow(id: string): void;
deleteRow(id: string): void;
}
// ── New row ID counter ────────────────────────────────────────────────────────
let newRowSeq = 0;
function nextNewId() {
return `new-${++newRowSeq}`;
}
// ── Scope badge ───────────────────────────────────────────────────────────────
function ScopeBadge({
measure,
approved,
proposed,
}: {
measure: string;
approved: string[];
proposed: string[];
}) {
if (approved.includes(measure)) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-50 border border-emerald-200 text-emerald-700 cursor-default">
Approved
</span>
</TooltipTrigger>
<TooltipContent>This measure has been technically approved for installation</TooltipContent>
</Tooltip>
);
}
if (proposed.includes(measure)) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-blue-50 border border-blue-200 text-blue-700 cursor-default">
Proposed
</span>
</TooltipTrigger>
<TooltipContent>This measure has been proposed but not yet approved</TooltipContent>
</Tooltip>
);
}
return null;
}
// ── Column definitions ────────────────────────────────────────────────────────
const columnHelper = createColumnHelper<EditableRow>();
const COLUMNS = [
columnHelper.accessor("measureName", {
header: "Measure",
cell: ({ row, table }) => {
const meta = table.options.meta!.pibi!;
const r = row.original;
return (
<div className="flex items-center gap-1.5">
{meta.canEdit ? (
<select
value={r.measureName}
onChange={(e) =>
meta.updateField(r.id, "measureName", e.target.value)
}
disabled={r.isSaving}
data-testid={`pibi-measure-select-${r.id}`}
className="rounded border border-gray-200 px-1.5 py-0.5 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40 w-[130px]"
>
{(() => {
const approved = MEASURE_NAMES.filter((m) =>
meta.approvedMeasures.includes(m),
);
const proposed = MEASURE_NAMES.filter(
(m) =>
meta.proposedMeasures.includes(m) &&
!meta.approvedMeasures.includes(m),
);
const other = MEASURE_NAMES.filter(
(m) =>
!meta.approvedMeasures.includes(m) &&
!meta.proposedMeasures.includes(m),
);
return (
<>
{approved.length > 0 && (
<optgroup label="Approved">
{approved.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
{proposed.length > 0 && (
<optgroup label="Proposed">
{proposed.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
{other.length > 0 && (
<optgroup label="Other">
{other.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
</>
);
})()}
</select>
) : (
<span className="text-xs text-gray-800">{r.measureName}</span>
)}
<ScopeBadge
measure={r.measureName}
approved={meta.approvedMeasures}
proposed={meta.proposedMeasures}
/>
</div>
);
},
}),
columnHelper.accessor("orderedAt", {
header: "Ordered",
cell: ({ row, table }) => {
const meta = table.options.meta!.pibi!;
const r = row.original;
if (!meta.canEdit) {
return (
<span className="text-xs text-gray-500">
{formatDate(r.orderedAt)}
</span>
);
}
return (
<input
type="date"
value={r.orderedAt}
onChange={(e) =>
meta.updateField(r.id, "orderedAt", e.target.value)
}
disabled={r.isSaving}
data-testid={`pibi-ordered-date-${r.id}`}
className="rounded border border-gray-200 px-1.5 py-0.5 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40 w-[110px]"
/>
);
},
}),
columnHelper.accessor("completedAt", {
header: "Completed",
cell: ({ row, table }) => {
const meta = table.options.meta!.pibi!;
const r = row.original;
if (!meta.canEdit) {
return r.completedAt ? (
<span className="text-xs text-emerald-600 font-medium">
{formatDate(r.completedAt)}
</span>
) : (
<span className="text-xs text-gray-300"></span>
);
}
return (
<input
type="date"
value={r.completedAt}
onChange={(e) =>
meta.updateField(r.id, "completedAt", e.target.value)
}
disabled={r.isSaving}
data-testid={`pibi-completed-date-${r.id}`}
className="rounded border border-gray-200 px-1.5 py-0.5 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40 w-[110px]"
/>
);
},
}),
columnHelper.display({
id: "actions",
header: "",
cell: ({ row, table }) => {
const meta = table.options.meta!.pibi!;
if (!meta.canEdit) return null;
const r = row.original;
return (
<div className="flex items-center gap-1.5">
<button
onClick={() => meta.saveRow(r.id)}
disabled={!r.isDirty || r.isSaving}
data-testid={`pibi-save-${r.id}`}
className="text-[10px] font-medium px-2.5 py-1 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-30 transition-colors"
>
{r.isSaving ? "Saving…" : "Save"}
</button>
<button
onClick={() => meta.deleteRow(r.id)}
disabled={r.isSaving}
data-testid={`pibi-delete-${r.id}`}
className="text-[10px] font-medium px-2.5 py-1 rounded-lg border border-red-100 text-red-400 hover:bg-red-50 disabled:opacity-30 transition-colors"
>
Delete
</button>
{r.error && (
<span className="text-[10px] text-red-600">{r.error}</span>
)}
</div>
);
},
}),
];
// ── Main component ────────────────────────────────────────────────────────────
export interface PibiSectionProps {
dealId: string;
portfolioId: string;
proposedMeasures: string[];
canEdit: boolean;
}
export function PibiSection({
dealId,
portfolioId,
proposedMeasures,
canEdit,
}: PibiSectionProps) {
const queryClient = useQueryClient();
// Local edit state for existing rows
const [localEdits, setLocalEdits] = useState<
Record<
string,
Partial<{ measureName: string; orderedAt: string; completedAt: string }>
>
>({});
// Unsaved new rows
const [newRows, setNewRows] = useState<NewRowData[]>([]);
// Row IDs currently being saved or deleted
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
// Per-row error messages
const [rowErrors, setRowErrors] = useState<Record<string, string>>({});
// ── Server data ───────────────────────────────────────────────────────────
const { data, isLoading } = useQuery<{ pibiRequests: PibiRow[] }>({
queryKey: ["pibiRequests", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-requests?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch PIBI requests");
return res.json();
},
staleTime: 30_000,
});
const { data: measureData } = useQuery<{
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch measures");
return res.json();
},
staleTime: 60_000,
});
const approvedMeasures = useMemo(
() => [
...(measureData?.approvedMeasures ?? []),
...(measureData?.instructedMeasures ?? []),
],
[measureData],
);
// ── Derived table rows ────────────────────────────────────────────────────
const tableRows: EditableRow[] = useMemo(() => {
const serverRows = (data?.pibiRequests ?? []).map(
(row): EditableRow => ({
id: row.id,
isNew: false,
measureName: localEdits[row.id]?.measureName ?? row.measureName,
orderedAt:
localEdits[row.id]?.orderedAt ?? toDateInputValue(row.orderedAt),
completedAt:
localEdits[row.id]?.completedAt ??
toDateInputValue(row.completedAt),
isDirty: !!localEdits[row.id],
isSaving: savingIds.has(row.id),
error: rowErrors[row.id] ?? null,
}),
);
const pendingRows = newRows.map(
(row): EditableRow => ({
id: row.id,
isNew: true,
measureName: row.measureName,
orderedAt: row.orderedAt,
completedAt: row.completedAt,
isDirty: true,
isSaving: savingIds.has(row.id),
error: rowErrors[row.id] ?? null,
}),
);
return [...serverRows, ...pendingRows];
}, [data, localEdits, newRows, savingIds, rowErrors]);
// ── Handlers ──────────────────────────────────────────────────────────────
const updateField = useCallback(
(
id: string,
field: "measureName" | "orderedAt" | "completedAt",
value: string,
) => {
if (id.startsWith("new-")) {
setNewRows((prev) =>
prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)),
);
} else {
setLocalEdits((prev) => ({
...prev,
[id]: { ...prev[id], [field]: value },
}));
}
},
[],
);
const saveRow = useCallback(
async (id: string) => {
const row = tableRows.find((r) => r.id === id);
if (!row) return;
setSavingIds((prev) => new Set(prev).add(id));
setRowErrors((prev) => {
const n = { ...prev };
delete n[id];
return n;
});
try {
let res: Response;
if (row.isNew) {
res = await fetch(
`/api/portfolio/${portfolioId}/pibi-requests`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dealId,
measureNames: [row.measureName],
orderedAt: row.orderedAt
? new Date(`${row.orderedAt}T00:00:00.000Z`).toISOString()
: undefined,
}),
},
);
} else {
res = await fetch(
`/api/portfolio/${portfolioId}/pibi-requests/${id}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dealId,
measureName: row.measureName,
...(row.orderedAt && {
orderedAt: dateInputToIso(row.orderedAt),
}),
completedAt: row.completedAt
? dateInputToIso(row.completedAt)
: null,
}),
},
);
}
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(
typeof json.error === "string" ? json.error : "Save failed",
);
}
if (row.isNew) {
setNewRows((prev) => prev.filter((r) => r.id !== id));
} else {
setLocalEdits((prev) => {
const n = { ...prev };
delete n[id];
return n;
});
}
await queryClient.invalidateQueries({
queryKey: ["pibiRequests", portfolioId, dealId],
});
} catch (err) {
setRowErrors((prev) => ({
...prev,
[id]: err instanceof Error ? err.message : "Save failed",
}));
} finally {
setSavingIds((prev) => {
const n = new Set(prev);
n.delete(id);
return n;
});
}
},
[tableRows, dealId, portfolioId, queryClient],
);
const deleteRow = useCallback(
async (id: string) => {
const row = tableRows.find((r) => r.id === id);
if (!row) return;
if (row.isNew) {
setNewRows((prev) => prev.filter((r) => r.id !== id));
return;
}
setSavingIds((prev) => new Set(prev).add(id));
setRowErrors((prev) => {
const n = { ...prev };
delete n[id];
return n;
});
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-requests/${id}?dealId=${encodeURIComponent(dealId)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("Delete failed");
await queryClient.invalidateQueries({
queryKey: ["pibiRequests", portfolioId, dealId],
});
} catch (err) {
setRowErrors((prev) => ({
...prev,
[id]: err instanceof Error ? err.message : "Delete failed",
}));
} finally {
setSavingIds((prev) => {
const n = new Set(prev);
n.delete(id);
return n;
});
}
},
[tableRows, dealId, portfolioId, queryClient],
);
function addRow() {
const today = new Date().toISOString().slice(0, 10);
setNewRows((prev) => [
...prev,
{
id: nextNewId(),
measureName: MEASURE_NAMES[0],
orderedAt: today,
completedAt: "",
},
]);
}
async function markAllComplete() {
const now = new Date().toISOString();
const incomplete = tableRows.filter((r) => !r.isNew && !r.completedAt);
await Promise.all(
incomplete.map((r) =>
fetch(`/api/portfolio/${portfolioId}/pibi-requests/${r.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, completedAt: now }),
}),
),
);
await queryClient.invalidateQueries({
queryKey: ["pibiRequests", portfolioId, dealId],
});
}
// ── Table instance ────────────────────────────────────────────────────────
const table = useReactTable({
data: tableRows,
columns: COLUMNS,
getCoreRowModel: getCoreRowModel(),
meta: {
pibi: {
canEdit,
approvedMeasures,
proposedMeasures,
updateField,
saveRow,
deleteRow,
},
},
});
// ── Render ────────────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="flex items-center justify-center py-6">
<span className="text-xs text-gray-400">Loading PIBIs</span>
</div>
);
}
const hasIncomplete = tableRows.some((r) => !r.isNew && !r.completedAt);
return (
<TooltipProvider>
<div className="space-y-3" data-testid="pibi-section">
{/* Header actions */}
<div className="flex items-center gap-2">
{canEdit && hasIncomplete && (
<button
onClick={markAllComplete}
data-testid="pibi-mark-all-complete"
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition-colors"
>
Mark all complete
</button>
)}
{canEdit && (
<button
onClick={addRow}
data-testid="pibi-add-row"
className="ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue transition-colors"
>
+ Add row
</button>
)}
</div>
{/* Empty state */}
{tableRows.length === 0 && (
<div
data-testid="pibi-empty-state"
className="flex flex-col items-center justify-center py-8 rounded-xl border border-dashed border-gray-200 bg-gray-50/50 space-y-2"
>
<p className="text-xs text-gray-400 font-medium">
No PIBIs logged yet
</p>
{canEdit && (
<button
onClick={addRow}
data-testid="pibi-empty-add-row"
className="text-xs font-medium px-3 py-1.5 rounded-lg border border-brandblue/20 text-brandblue hover:bg-brandlightblue/20 transition-colors"
>
Log first PIBI
</button>
)}
</div>
)}
{/* Table */}
{tableRows.length > 0 && (
<div className="rounded-xl border border-gray-100 overflow-x-auto">
<table className="w-full text-xs min-w-[560px]">
<thead>
{table.getHeaderGroups().map((hg) => (
<tr
key={hg.id}
className="border-b border-gray-100 bg-gray-50"
>
{hg.headers.map((h) => (
<th
key={h.id}
className="px-2 py-1.5 text-left text-[10px] font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap"
>
{flexRender(
h.column.columnDef.header,
h.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-50">
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
data-testid={`pibi-row-${row.original.id}`}
className={
row.original.completedAt
? "bg-emerald-50/30"
: "bg-white"
}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-2 py-1.5">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</TooltipProvider>
);
}

View file

@ -0,0 +1,428 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronRight,
FileDown,
FileText,
FileX,
Loader2,
FolderOpen,
ExternalLink,
HardHat,
Upload,
} from "lucide-react";
import type { PropertyDocument, DocStatus } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types";
import { splitDocumentsByType, getMissingRetrofitTypes, getUnassignedInstallDocs } from "./propertyDocuments";
import ContractorUploadModal from "./ContractorUploadModal";
import type { ClassifiedDeal, PortfolioCapabilityType } from "./types";
export const DOC_TYPE_LABELS: Record<string, string> = {
photo_pack: "Photo Pack",
site_note: "Site Note",
rd_sap_site_note: "RdSAP Site Note",
pas_2023_ventilation: "PAS 2023 Ventilation",
pas_2023_condition: "PAS 2023 Condition Report",
pas_significance: "PAS Significance",
par_photo_pack: "PAR Photo Pack",
pas_2023_property: "PAS 2023 Property Report",
pas_2023_occupancy: "PAS 2023 Occupancy Report",
ecmk_site_note: "ECMK Site Note",
ecmk_rd_sap_site_note: "ECMK RdSAP Site Note",
ecmk_survey_xml: "ECMK Survey XML",
pre_photo: "Pre-Install Photos",
mid_photo: "Mid-Install Photos",
post_photo: "Post-Install Photos",
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
dmev_photos: "DMEV Photos (Wetrooms)",
door_undercut_photos: "Door Undercut Photos",
trickle_vent_photos: "Trickle Vent Photos",
pre_installation_building_inspection: "PIBI / Tech Survey",
point_of_work_risk_assessment: "Point of Work Risk Assessment",
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
mcs_compliance_certificate: "MCS Compliance Certificate",
certificate_of_conformity: "Certificate of Conformity",
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
trustmark_licence_numbers: "TrustMark Licence Numbers",
operative_competency: "Operative Competency",
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
anemometer_readings: "Anemometer Readings",
commissioning_records: "Commissioning Records",
part_f_ventilation_document: "Approved Document Part F",
handover_pack: "Handover Pack",
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
workmanship_warranty: "Workmanship Warranty",
g98_notification: "G98 / G99 Notification",
installer_qualifications: "Installer Qualifications",
installer_feedback: "Installer Feedback",
contractor_other: "Other",
};
function formatDocDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return iso;
}
}
export function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: doc.s3FileKey, bucket: doc.s3FileBucket }),
});
if (!res.ok) throw new Error("Failed to get signed URL");
const data = await res.json();
return data.url as string;
},
onSuccess: (url) => {
window.open(url, "_blank");
},
});
return (
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
);
}
export function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
return (
<motion.div
layout
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
>
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0 w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-4 w-4 text-sky-600" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
<p className="text-xs text-gray-400 mt-0.5">
{showMeasure && doc.measureName ? (
<>
<span className="text-brandblue/70 font-medium">{doc.measureName}</span>{" "}
· {formatDocDate(doc.s3UploadTimestamp)}
</>
) : (
formatDocDate(doc.s3UploadTimestamp)
)}
</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</motion.div>
);
}
interface PropertyDocumentsContentProps {
documents: PropertyDocument[];
isFetching: boolean;
isError: boolean;
docStatus: DocStatus;
// Upload (contractor-only, all required to show button)
deal?: ClassifiedDeal;
portfolioId?: string;
userCapability?: PortfolioCapabilityType;
approvedMeasures?: string[];
}
export default function PropertyDocumentsContent({
documents,
isFetching,
isError,
docStatus,
deal,
portfolioId,
userCapability,
approvedMeasures,
}: PropertyDocumentsContentProps) {
const [uploadOpen, setUploadOpen] = useState(false);
const [openMeasures, setOpenMeasures] = useState<Set<string>>(new Set());
const [otherOpen, setOtherOpen] = useState(false);
const toggleMeasure = (measureName: string) =>
setOpenMeasures((prev) => {
const next = new Set(prev);
next.has(measureName) ? next.delete(measureName) : next.add(measureName);
return next;
});
const { retrofitDocs, installDocs } = splitDocumentsByType(documents);
const missingRetrofitTypes = getMissingRetrofitTypes(retrofitDocs);
const hasDocuments = documents.length > 0;
const isContractor = userCapability?.includes("contractor") ?? false;
const canUpload = isContractor && !!deal && !!portfolioId;
const docStatusMap = deal
? { [deal.dealId]: docStatus }
: undefined;
return (
<div className="space-y-4">
{/* Upload button */}
{canUpload && (
<div className="flex justify-end">
<button
onClick={() => setUploadOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brandblue text-white text-sm font-semibold hover:bg-brandmidblue transition-colors"
>
<Upload className="h-4 w-4" />
Upload Docs
</button>
</div>
)}
{/* Loading */}
{isFetching && (
<div className="space-y-3 pt-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 rounded-lg bg-gray-100 animate-pulse" />
))}
</div>
)}
{/* Error */}
{isError && !isFetching && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
<ExternalLink className="h-5 w-5 text-red-400" />
</div>
<p className="text-sm font-medium text-gray-700">Could not load documents</p>
<p className="text-xs text-gray-500 mt-1">Please try again later.</p>
</div>
)}
{/* Empty */}
{!isFetching && !isError && !hasDocuments && (
<div className="space-y-4 pt-1">
<div className="flex flex-col items-center py-6 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center mb-3">
<FolderOpen className="h-6 w-6 text-amber-400" />
</div>
<p className="text-sm font-medium text-gray-700">No documents available</p>
<p className="text-xs text-gray-400 mt-1">
All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
</p>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingRetrofitTypes.length})
</h3>
{missingRetrofitTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
</div>
)}
{/* Documents */}
<AnimatePresence>
{!isFetching && !isError && hasDocuments && (
<>
{/* Retrofit Assessment */}
<motion.div key="retrofit" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
Retrofit Assessment Documents
</h3>
{retrofitDocs.length > 0 ? (
<div className="space-y-1.5">
{retrofitDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
) : (
<p className="text-xs text-gray-400 px-0.5">None uploaded yet.</p>
)}
{missingRetrofitTypes.length > 0 && (
<div className="space-y-1.5 pt-1">
<h4 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing ({missingRetrofitTypes.length})
</h4>
{missingRetrofitTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
)}
</motion.div>
{/* Install Documents */}
<motion.div key="install" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
<HardHat className="h-3.5 w-3.5" />
Install Documents
</h3>
{docStatus.measureProgress.length > 0 ? (
<div className="space-y-4">
{docStatus.measureProgress.map((mp) => {
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
const isOpen = openMeasures.has(mp.measureName);
return (
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
<button
onClick={() => toggleMeasure(mp.measureName)}
className="w-full flex items-center justify-between px-3 py-2.5 bg-white hover:bg-gray-50 transition-colors text-left"
>
<span className="flex items-center gap-2">
<ChevronRight className={`h-3.5 w-3.5 text-gray-400 transition-transform duration-150 ${isOpen ? "rotate-90" : ""}`} />
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
mp.isComplete
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: mp.uploadedCount > 0
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}>
{mp.uploadedCount} / {mp.requiredCount} docs
</span>
</button>
{isOpen && <div className="px-3 py-2.5 space-y-1.5 border-t border-gray-100">
{mp.uploaded.map((docType) => {
const doc = measureDocs.find((d) => d.docType === docType);
if (!doc) return null;
return (
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
<p className="text-[10px] text-gray-400">{formatDocDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
);
})}
{missingTypes.map((docType) => (
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
<FileX className="h-2.5 w-2.5 text-amber-400" />
</div>
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
</div>
))}
{measureDocs
.filter((d) => !mp.required.includes(d.docType))
.map((doc) => (
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-3 w-3 text-sky-500" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
<p className="text-[10px] text-gray-400">{formatDocDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
))}
</div>}
</div>
);
})}
{/* Unassigned install docs */}
{(() => {
const unassigned = getUnassignedInstallDocs(installDocs, docStatus.measureProgress);
if (unassigned.length === 0) return null;
return (
<div className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
<button
onClick={() => setOtherOpen((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2.5 bg-white hover:bg-gray-50 transition-colors text-left"
>
<span className="flex items-center gap-2">
<ChevronRight className={`h-3.5 w-3.5 text-gray-400 transition-transform duration-150 ${otherOpen ? "rotate-90" : ""}`} />
<span className="text-[10px] font-semibold uppercase tracking-wide text-gray-500">Other</span>
</span>
<span className="text-[10px] text-gray-400">{unassigned.length} doc{unassigned.length !== 1 ? "s" : ""}</span>
</button>
{otherOpen && (
<div className="px-3 py-2.5 space-y-1.5 border-t border-gray-100">
{unassigned.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
)}
</div>
);
})()}
</div>
) : installDocs.length > 0 ? (
<div className="space-y-1.5">
{installDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
) : (
<p className="text-xs text-gray-400 px-0.5">No install documents uploaded yet.</p>
)}
</motion.div>
</>
)}
</AnimatePresence>
{/* Upload modal */}
{canUpload && uploadOpen && deal && portfolioId && (
<ContractorUploadModal
deal={deal}
portfolioId={portfolioId}
onClose={() => setUploadOpen(false)}
docStatusMap={docStatusMap}
approvedMeasures={approvedMeasures}
/>
)}
</div>
);
}

View file

@ -1,18 +1,8 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
FileDown,
FileText,
FileX,
Loader2,
FolderOpen,
X,
ExternalLink,
HardHat,
} from "lucide-react";
import { FileDown, X } from "lucide-react";
import {
Drawer,
DrawerClose,
@ -22,138 +12,7 @@ import {
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument, DocStatus } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
// Human-readable labels for all DB fileType enum values
const DOC_TYPE_LABELS: Record<string, string> = {
// Survey / retrofit assessment docs
photo_pack: "Photo Pack",
site_note: "Site Note",
rd_sap_site_note: "RdSAP Site Note",
pas_2023_ventilation: "PAS 2023 Ventilation",
pas_2023_condition: "PAS 2023 Condition Report",
pas_significance: "PAS Significance",
par_photo_pack: "PAR Photo Pack",
pas_2023_property: "PAS 2023 Property Report",
pas_2023_occupancy: "PAS 2023 Occupancy Report",
ecmk_site_note: "ECMK Site Note",
ecmk_rd_sap_site_note: "ECMK RdSAP Site Note",
ecmk_survey_xml: "ECMK Survey XML",
// Install docs — photos
pre_photo: "Pre-Install Photos",
mid_photo: "Mid-Install Photos",
post_photo: "Post-Install Photos",
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
dmev_photos: "DMEV Photos (Wetrooms)",
door_undercut_photos: "Door Undercut Photos",
trickle_vent_photos: "Trickle Vent Photos",
// Install docs — pre-installation
pre_installation_building_inspection: "PIBI / Tech Survey",
point_of_work_risk_assessment: "Point of Work Risk Assessment",
// Install docs — compliance & lodgement
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
mcs_compliance_certificate: "MCS Compliance Certificate",
certificate_of_conformity: "Certificate of Conformity",
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
trustmark_licence_numbers: "TrustMark Licence Numbers",
operative_competency: "Operative Competency",
// Install docs — ventilation
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
anemometer_readings: "Anemometer Readings",
commissioning_records: "Commissioning Records",
part_f_ventilation_document: "Approved Document Part F",
// Install docs — handover & warranties
handover_pack: "Handover Pack",
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
workmanship_warranty: "Workmanship Warranty",
g98_notification: "G98 / G99 Notification",
// Install docs — qualifications & other
installer_qualifications: "Installer Qualifications",
installer_feedback: "Installer Feedback",
contractor_other: "Other",
};
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return iso;
}
}
// -----------------------------------------------------------------------
// Reusable download button — encapsulates the presigned URL mutation
// -----------------------------------------------------------------------
function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: doc.s3FileKey, bucket: doc.s3FileBucket }),
});
if (!res.ok) throw new Error("Failed to get signed URL");
const data = await res.json();
return data.url as string;
},
onSuccess: (url) => {
window.open(url, "_blank");
},
});
return (
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
);
}
// -----------------------------------------------------------------------
// Individual document row — used in retrofit section and install fallback
// -----------------------------------------------------------------------
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
return (
<motion.div
layout
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
>
{/* Left: icon + label + date stacked */}
<div className="flex items-center gap-3 min-w-0">
<div className="shrink-0 w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-4 w-4 text-sky-600" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
<p className="text-xs text-gray-400 mt-0.5">
{showMeasure && doc.measureName
? <><span className="text-brandblue/70 font-medium">{doc.measureName}</span> · {formatDate(doc.s3UploadTimestamp)}</>
: formatDate(doc.s3UploadTimestamp)
}
</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</motion.div>
);
}
import PropertyDocumentsContent from "./PropertyDocumentsContent";
// -----------------------------------------------------------------------
// PropertyDrawer — main component
@ -190,9 +49,7 @@ export default function PropertyDrawer({
else if (uprn) params.set("uprn", uprn);
else if (landlordPropertyId)
params.set("landlordPropertyId", landlordPropertyId);
const res = await fetch(
`/api/live-tracking/property-documents?${params}`,
);
const res = await fetch(`/api/live-tracking/property-documents?${params}`);
if (!res.ok) throw new Error("Failed to load documents");
return res.json() as Promise<PropertyDocument[]>;
},
@ -200,31 +57,17 @@ export default function PropertyDrawer({
staleTime: 30_000,
});
// Keep the last successfully fetched result so the closing animation doesn't
// flash the empty state (the parent nulls out uprn/landlordPropertyId on close,
// which disables the query and resets fetchedDocuments to [] mid-animation).
// Preserve last fetched docs so the closing animation doesn't flash empty state
// when the parent nulls identifiers mid-animation.
const lastDocumentsRef = useRef<PropertyDocument[]>([]);
if (open && !isFetching && !isError) {
lastDocumentsRef.current = fetchedDocuments as PropertyDocument[];
}
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
// Split documents into the two sections
const retrofitDocs = documents.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType));
const installDocs = documents.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType));
const hasDocuments = documents.length > 0;
// Missing mandatory retrofit assessment docs (ecmk types are optional — not shown as missing)
const presentRetrofitTypes = new Set(retrofitDocs.map((d) => d.docType));
const missingRetrofitTypes = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter(
(t) => !presentRetrofitTypes.has(t),
);
return (
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[40vw] min-w-80 rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl overflow-hidden">
{/* Remove the default drag handle */}
<div className="hidden" />
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
@ -253,7 +96,7 @@ export default function PropertyDrawer({
</DrawerClose>
</div>
{hasDocuments && !isFetching && (
{documents.length > 0 && !isFetching && (
<div className="mt-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-brandblue/10 border border-brandblue/20">
<FileDown className="h-3.5 w-3.5 text-brandblue" />
<span className="text-xs font-medium text-brandblue">
@ -263,242 +106,18 @@ export default function PropertyDrawer({
)}
</DrawerHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Loading state */}
{isFetching && (
<div className="space-y-3 pt-2">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-14 rounded-lg bg-gray-100 animate-pulse"
/>
))}
</div>
)}
{/* Error state */}
{isError && !isFetching && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
<ExternalLink className="h-5 w-5 text-red-400" />
</div>
<p className="text-sm font-medium text-gray-700">
Could not load documents
</p>
<p className="text-xs text-gray-500 mt-1">
Please try again later.
</p>
</div>
)}
{/* Empty state */}
{!isFetching && !isError && !hasDocuments && (
<div className="space-y-4 pt-1">
<div className="flex flex-col items-center py-6 text-center">
<div className="w-12 h-12 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center mb-3">
<FolderOpen className="h-6 w-6 text-amber-400" />
</div>
<p className="text-sm font-medium text-gray-700">
No documents available
</p>
<p className="text-xs text-gray-400 mt-1">
All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
</p>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingRetrofitTypes.length})
</h3>
{missingRetrofitTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
</div>
)}
<AnimatePresence>
{!isFetching && !isError && hasDocuments && (
<>
{/* ── Retrofit Assessment Documents ── */}
<motion.div
key="retrofit"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
Retrofit Assessment Documents
</h3>
{retrofitDocs.length > 0 ? (
<div className="space-y-1.5">
{retrofitDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
) : (
<p className="text-xs text-gray-400 px-0.5">None uploaded yet.</p>
)}
{/* Missing mandatory retrofit assessment docs */}
{missingRetrofitTypes.length > 0 && (
<div className="space-y-1.5 pt-1">
<h4 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing ({missingRetrofitTypes.length})
</h4>
{missingRetrofitTypes.map((t) => (
<div
key={t}
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
>
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
<span className="text-xs text-amber-600 font-medium">
{DOC_TYPE_LABELS[t] ?? t}
</span>
</div>
))}
</div>
)}
</motion.div>
{/* ── Install Documents ── */}
<motion.div
key="install"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
<HardHat className="h-3.5 w-3.5" />
Install Documents
</h3>
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
// ── Per-measure checklist ──
<div className="space-y-4">
{docStatus.measureProgress.map((mp) => {
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
return (
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
{/* Measure header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
mp.isComplete
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: mp.uploadedCount > 0
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}>
{mp.uploadedCount} / {mp.requiredCount} docs
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5">
{/* Uploaded required docs */}
{mp.uploaded.map((docType) => {
const doc = measureDocs.find((d) => d.docType === docType);
if (!doc) return null;
return (
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
);
})}
{/* Missing required docs */}
{missingTypes.map((docType) => (
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
<FileX className="h-2.5 w-2.5 text-amber-400" />
</div>
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
</div>
))}
{/* Extra docs uploaded for this measure (not in required list) */}
{measureDocs
.filter((d) => !mp.required.includes(d.docType))
.map((doc) => (
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-3 w-3 text-sky-500" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
))
}
</div>
</div>
);
})}
{/* Unassigned / no-measure install docs */}
{(() => {
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
const unassigned = installDocs.filter(
(d) => !d.measureName || !knownMeasures.has(d.measureName),
);
if (unassigned.length === 0) return null;
return (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
{unassigned.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
);
})()}
</div>
) : installDocs.length > 0 ? (
// ── Fallback: flat list (no measure progress data) ──
<div className="space-y-1.5">
{installDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
) : (
<p className="text-xs text-gray-400 px-0.5">No install documents uploaded yet.</p>
)}
</motion.div>
</>
)}
</AnimatePresence>
<div className="flex-1 overflow-y-auto px-6 py-4">
<PropertyDocumentsContent
documents={documents}
isFetching={isFetching}
isError={isError}
docStatus={docStatus ?? { presentSurveyTypes: [], hasSurveyDocs: false, isSurveyComplete: false, hasInstallDocs: false, installStatus: "none", measureProgress: [] }}
/>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
<p className="text-xs text-gray-400">
Download links expire after 30 minutes. Refresh to generate a new
link.
Download links expire after 30 minutes. Refresh to generate a new link.
</p>
</div>
</DrawerContent>

View file

@ -3,23 +3,8 @@
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
import Link from "next/link";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
// -----------------------------------------------------------------------
// Stage badge — consistent pill rendering
// -----------------------------------------------------------------------
function StageBadge({ stage }: { stage: DisplayStage }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
{stage}
</span>
);
}
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal } from "./types";
import { StageBadge } from "./ui";
// Sortable column header helper
function SortableHeader({

View file

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams, useRouter } from "next/navigation";
import {
Dialog,
@ -15,37 +16,31 @@ import {
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import { AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
import { sapToEpc } from "@/app/utils";
import { sapToEpc, getEpcAccentClasses, parsePreSap } from "@/app/utils";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState } from "../types";
import { STAGE_COLORS } from "../types";
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState, PropertyDocument } from "../types";
import PropertyDocumentsContent from "../PropertyDocumentsContent";
import { StageBadge } from "../ui";
import {
InfoRow,
StageBadge,
MilestoneTimeline,
formatDate,
MeasureApprovalEditor,
InstructMeasureEditor,
ApprovalLogSection,
PibiDatesEditor,
PibiMeasureSelector,
DomnaEditor,
HaltedEditor,
SurveyRequestSection,
RemovalRequestSection,
SectionHeader,
SECTION_TITLES,
WRITE_ROLES,
} from "../PropertyDetailDrawer";
} from "../deal-detail/primitives";
import { MeasureApprovalEditor } from "../deal-detail/MeasureApprovalEditor";
import { InstructMeasureEditor } from "../deal-detail/pibi/InstructMeasureEditor";
import { PibiSurveysTabContent } from "../deal-detail/PibiSurveysTabContent";
import { ActivityLog } from "../ActivityLog";
type Tab = "works" | "pibi" | "survey-admin" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi", "survey-admin", "documents"];
type Tab = "works" | "pibi-surveys" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi-surveys", "documents"];
const TAB_LABELS: Record<Tab, string> = {
works: "Works",
pibi: "PIBI",
"survey-admin": "Survey & Admin",
"pibi-surveys": "PIBIs & Surveys",
documents: "Documents",
};
@ -65,6 +60,7 @@ export default function DealPage({
portfolioId,
userRole,
userCapability,
approvedMeasures,
docStatus,
removalState,
}: DealPageProps) {
@ -82,16 +78,27 @@ export default function DealPage({
router.replace(`?tab=${tab}`, { scroll: false });
};
const epcCurrent = sapToEpc(deal.preSapScore != null ? Number(deal.preSapScore) : null);
const { data: documents = [], isFetching: docsFetching, isError: docsError } = useQuery({
queryKey: ["property-documents", deal.dealId, deal.uprn, deal.landlordPropertyId],
queryFn: async () => {
const params = new URLSearchParams();
if (deal.dealId) params.set("dealId", deal.dealId);
else if (deal.uprn) params.set("uprn", deal.uprn);
else if (deal.landlordPropertyId) params.set("landlordPropertyId", deal.landlordPropertyId);
const res = await fetch(`/api/live-tracking/property-documents?${params}`);
if (!res.ok) throw new Error("Failed to load documents");
return res.json() as Promise<PropertyDocument[]>;
},
staleTime: 30_000,
});
const parsedPreSap = parsePreSap(deal.preSapScore);
const epcPotential = sapToEpc(deal.epcSapScorePotential != null ? Number(deal.epcSapScorePotential) : null);
const technicalApprovedMeasures = parseMeasures(
deal.technicalApprovedMeasuresForInstall ?? null,
);
const pibiMeasures = parseMeasures(deal.measuresForPibiOrdered ?? null);
const isApprover = userCapability.includes("approver");
const canWrite = WRITE_ROLES.includes(userRole);
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
return (
<TooltipProvider>
@ -140,14 +147,17 @@ export default function DealPage({
Energy Performance
</p>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-black text-brandblue">{epcCurrent}</span>
{epcPotential !== "Unknown" && epcPotential !== epcCurrent && (
{parsedPreSap ? (
<span className={`text-2xl font-black ${getEpcAccentClasses(parsedPreSap.letter)}`}>
{parsedPreSap.display}
</span>
) : (
<span className="text-2xl font-black text-gray-400"></span>
)}
{epcPotential !== "Unknown" && epcPotential !== (parsedPreSap?.letter ?? "Unknown") && (
<span className="text-sm text-gray-400"> {epcPotential}</span>
)}
</div>
{deal.preSapScore !== null && deal.preSapScore !== undefined && (
<p className="text-xs text-gray-500">SAP: {deal.preSapScore}</p>
)}
</div>
{/* Key details */}
@ -163,24 +173,9 @@ export default function DealPage({
<InfoRow label="Coordination" value={deal.coordinationStatus} />
<InfoRow label="Design Status" value={deal.designStatus} />
<InfoRow label="Design Type" value={deal.designType} />
<InfoRow
label="Pre-SAP"
value={
deal.preSapScore ? (
<span
className={`font-semibold px-1.5 py-0.5 rounded text-xs ${
Number(deal.preSapScore) < 30
? "text-red-600 bg-red-50"
: Number(deal.preSapScore) < 50
? "text-amber-700 bg-amber-50"
: "text-emerald-700 bg-emerald-50"
}`}
>
{deal.preSapScore}
</span>
) : null
}
/>
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
<InfoRow label="Measures Lodged" value={formatDate(deal.measuresLodgementDate)} />
<InfoRow label="Full Lodgement" value={formatDate(deal.fullLodgementDate)} />
</div>
{/* Survey info */}
@ -205,9 +200,6 @@ export default function DealPage({
</div>
</div>
{deal.uprn && (
<p className="text-xs text-gray-400 font-mono">UPRN: {deal.uprn}</p>
)}
</div>
</aside>
@ -252,7 +244,6 @@ export default function DealPage({
</div>
<div className="divide-y divide-gray-50 mt-3">
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
</div>
</div>
@ -297,163 +288,38 @@ export default function DealPage({
</button>
{isLogOpen && (
<div className="mt-3">
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
</div>
)}
</div>
</div>
{/* ── PIBI ── */}
{/* ── PIBIs & Surveys ── */}
<div
className={`p-5 space-y-6 ${activeTab === "pibi" ? "block" : "hidden"}`}
className={`p-5 space-y-6 ${activeTab === "pibi-surveys" ? "block" : "hidden"}`}
>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<div className="space-y-3">
<PibiDatesEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialOrderDate={deal.pibiOrderDate}
initialCompletedDate={deal.pibiCompletedDate}
canEdit={canWrite}
/>
{isApprover ? (
<PibiMeasureSelector
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit
/>
) : (
pibiMeasures.length > 0 && (
<div className="divide-y divide-gray-50">
<InfoRow
label="Measures for PIBI"
value={
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
}
/>
</div>
)
)}
</div>
</div>
{/* ── Survey & Admin ── */}
<div
className={`p-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
>
<div>
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
<DomnaEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialSurveyType={deal.domnaSurveyType ?? null}
initialSurveyDate={deal.domnaSurveyDate}
canEdit={isApprover}
/>
</div>
<div>
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
<HaltedEditor
dealId={deal.dealId}
portfolioId={portfolioId}
initialHaltedDate={deal.propertyHaltedDate}
initialHaltedReason={deal.propertyHaltedReason ?? null}
canEdit={isApprover}
/>
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Survey Request
</h3>
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Removal
</h3>
<RemovalRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
<PibiSurveysTabContent
deal={deal}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
{/* ── Documents ── */}
<div
className={`p-5 space-y-4 ${activeTab === "documents" ? "block" : "hidden"}`}
className={`p-5 ${activeTab === "documents" ? "block" : "hidden"}`}
>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
Documents
</h3>
{docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${docStatus.isSurveyComplete ? "bg-emerald-500" : docStatus.hasSurveyDocs ? "bg-amber-400" : "bg-gray-300"}`}
/>
<span className="text-sm text-gray-700">
Survey docs:{" "}
<span className="font-medium">
{docStatus.isSurveyComplete
? "Complete"
: docStatus.hasSurveyDocs
? `${docStatus.presentSurveyTypes.length} uploaded`
: "None"}
</span>
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${docStatus.installStatus === "all" ? "bg-emerald-500" : docStatus.installStatus === "partial" || docStatus.installStatus === "hasDocs" ? "bg-amber-400" : "bg-gray-300"}`}
/>
<span className="text-sm text-gray-700">
Install docs:{" "}
<span className="font-medium capitalize">
{docStatus.installStatus === "none"
? "None"
: docStatus.installStatus === "all"
? "Complete"
: "Partial"}
</span>
</span>
</div>
{docStatus.measureProgress.length > 0 && (
<div className="space-y-2 mt-2">
{docStatus.measureProgress.map((mp) => (
<div
key={mp.measureName}
className="flex items-center justify-between text-xs py-1.5 border-b border-gray-50 last:border-0"
>
<span className="text-gray-700">{mp.measureName}</span>
<span
className={`font-medium ${mp.isComplete ? "text-emerald-600" : "text-amber-600"}`}
>
{mp.uploadedCount}/{mp.requiredCount}
</span>
</div>
))}
</div>
)}
</div>
) : (
<p className="text-sm text-gray-400">No documents uploaded yet.</p>
)}
<PropertyDocumentsContent
documents={documents}
isFetching={docsFetching}
isError={docsError}
docStatus={docStatus}
deal={deal}
portfolioId={portfolioId}
userCapability={userCapability}
approvedMeasures={approvedMeasures}
/>
</div>
</div>
@ -512,7 +378,7 @@ export default function DealPage({
{/* Request Survey */}
<button
onClick={() => switchTab("survey-admin")}
onClick={() => switchTab("pibi-surveys")}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
>
<span>Request Survey</span>
@ -521,7 +387,7 @@ export default function DealPage({
{/* Request Removal */}
<button
onClick={() => switchTab("survey-admin")}
onClick={() => switchTab("pibi-surveys")}
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
>
<span>Request Removal</span>
@ -551,8 +417,10 @@ export default function DealPage({
<InstructMeasureEditor
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit={isApprover}
outOfOrderWarning={outOfOrderInstructionWarning(deal)}
onSuccess={() => setInstructModalOpen(false)}
/>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,265 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Hoisted mocks ─────────────────────────────────────────────────────────────
const { mockGetServerSession, mockDbSelect, mockNotFound, mockRedirect } =
vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockDbSelect: vi.fn(),
mockNotFound: vi.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
mockRedirect: vi.fn(() => {
throw new Error("NEXT_REDIRECT");
}),
}));
// ── Auth ──────────────────────────────────────────────────────────────────────
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
// ── Navigation ────────────────────────────────────────────────────────────────
vi.mock("next/navigation", () => ({
redirect: mockRedirect,
notFound: mockNotFound,
}));
vi.mock("next/link", () => ({ default: vi.fn(() => null) }));
// ── Drizzle ORM ───────────────────────────────────────────────────────────────
vi.mock("drizzle-orm", () => ({
eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })),
inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })),
and: vi.fn((...args: unknown[]) => ({ $and: args })),
desc: vi.fn((col: unknown) => ({ $desc: col })),
sql: vi.fn(),
}));
vi.mock("drizzle-orm/pg-core", () => ({
alias: vi.fn((table: unknown, _name: string) => table),
}));
// ── DB ────────────────────────────────────────────────────────────────────────
vi.mock("@/app/db/db", () => ({
db: { get select() { return mockDbSelect; } },
}));
// ── Schemas ───────────────────────────────────────────────────────────────────
vi.mock("@/app/db/schema/crm/hubspot_deal_table", () => ({
hubspotDealData: { dealId: {}, companyId: {}, coordinator: {}, designer: {} },
}));
vi.mock("@/app/db/schema/crm/hubspot_user_table", () => ({
hubspotUsers: { hubspotOwnerId: {}, firstName: {}, lastName: {} },
}));
vi.mock("@/app/db/schema/uploaded_files", () => ({
uploadedFiles: { hubsotDealId: {}, fileType: {}, measureName: {}, uprn: {} },
}));
vi.mock("@/app/db/schema/portfolio_organisation", () => ({
portfolioOrganisation: { portfolioId: {}, organisationId: {} },
}));
vi.mock("@/app/db/schema/organisation", () => ({
organisation: { id: {}, hubspotCompanyId: {} },
}));
vi.mock("@/app/db/schema/portfolio", () => ({
portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} },
portfolioUsers: { portfolioId: {}, userId: {}, role: {} },
}));
vi.mock("@/app/db/schema/approvals", () => ({
dealMeasureApprovals: { hubspotDealId: {}, isApproved: {}, measureName: {} },
}));
vi.mock("@/app/db/schema/removal_requests", () => ({
propertyRemovalRequests: {
portfolioId: {},
hubspotDealId: {},
type: {},
status: {},
requestedAt: {},
},
}));
vi.mock("@/app/db/schema/users", () => ({
user: { id: {}, email: {} },
}));
// ── Transforms & lib ──────────────────────────────────────────────────────────
vi.mock("../transforms", () => ({
classifyDeals: vi.fn((deals: unknown[]) =>
deals.map((d) => ({ ...(d as object), displayStage: "Test Stage" })),
),
}));
vi.mock("@/app/lib/measureDocumentRequirements", () => ({
getRequiredDocs: vi.fn(() => []),
}));
// ── DealPage component ────────────────────────────────────────────────────────
vi.mock("./DealPage", () => ({ default: vi.fn(() => null) }));
// ── Subject under test ────────────────────────────────────────────────────────
import DealDetailPage from "./page";
// ── Chain helpers ─────────────────────────────────────────────────────────────
// Generic chain: .limit(n) resolves to limitResult; awaiting without .limit
// resolves to directResult (thenable).
function makeSelectChain(
limitResult: unknown[] = [],
directResult: unknown[] = [],
) {
const self: Record<string, unknown> = {};
self.then = (
resolve: (v: unknown) => unknown,
reject: (e: unknown) => unknown,
) => Promise.resolve(directResult).then(resolve, reject);
self.from = vi.fn(() => self);
self.innerJoin = vi.fn(() => self);
self.leftJoin = vi.fn(() => self);
self.where = vi.fn(() => self);
self.orderBy = vi.fn(() => self);
self.limit = vi.fn(() => Promise.resolve(limitResult));
return self;
}
// Deal chain: inspects the WHERE argument's JSON to decide what to return.
// Returns dealRow only when the condition includes $inArray, i.e. after fix.
// Before fix, eq() produces $eq and the deal is not found (empty result).
function makeDealChain(dealRow: Record<string, unknown>) {
let conditionUsesInArray = false;
const self: Record<string, unknown> = {};
self.then = (
resolve: (v: unknown) => unknown,
reject: (e: unknown) => unknown,
) => Promise.resolve([]).then(resolve, reject);
self.from = vi.fn(() => self);
self.leftJoin = vi.fn(() => self);
self.where = vi.fn((condition: unknown) => {
conditionUsesInArray = JSON.stringify(condition).includes('"$inArray"');
return self;
});
self.orderBy = vi.fn(() => self);
self.limit = vi.fn(() =>
Promise.resolve(conditionUsesInArray ? [dealRow] : []),
);
return self;
}
// ── Fixtures ──────────────────────────────────────────────────────────────────
const DEAL_COMPANY = "86970043613";
const OTHER_COMPANIES = ["262091354351", "86797211838"];
const DEAL_ID = "489569618109";
const PORTFOLIO_ID = "737";
const mockDealRow = {
deal: {
id: "1",
dealId: DEAL_ID,
dealname: "Test Property",
dealstage: "test",
companyId: DEAL_COMPANY,
projectCode: null,
landlordPropertyId: null,
uprn: null,
outcome: null,
outcomeNotes: null,
majorConditionIssueDescription: null,
majorConditionIssuePhotos: null,
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
pashubLink: null,
sharepointLink: null,
dampmouldGrowth: null,
damnpMouldAndRepairComments: null,
preSap: null,
mtpCompletionDate: null,
mtpReModelCompletionDate: null,
ioeV3CompletionDate: null,
proposedMeasures: null,
approvedPackage: null,
designCompletionDate: null,
actualMeasuresInstalled: null,
installer: null,
installerHandover: null,
lodgementStatus: null,
measuresLodgementDate: null,
lodgementDate: null,
confirmedSurveyDate: null,
confirmedSurveyTime: null,
surveyedDate: null,
dealType: null,
eiScore: null,
eiScorePotential: null,
epcSapScore: null,
epcSapScorePotential: null,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
pibiCompletedDate: null,
propertyHaltedDate: null,
propertyHaltedReason: null,
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
createdAt: new Date(),
updatedAt: new Date(),
},
coordinator: null,
designer: null,
};
function setupDb() {
mockGetServerSession.mockResolvedValue({
user: { email: "test@domna.homes" },
});
// 1. Org lookup
// limitResult = [wrong company] — what .limit(1) returns (broken path)
// directResult = all three companies — what await-without-limit returns (fixed path)
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain(
[{ hubspotCompanyId: OTHER_COMPANIES[0] }],
[
{ hubspotCompanyId: DEAL_COMPANY },
{ hubspotCompanyId: OTHER_COMPANIES[0] },
{ hubspotCompanyId: OTHER_COMPANIES[1] },
],
),
);
// 2. Deal lookup — returns deal only when inArray appears in the where clause
mockDbSelect.mockImplementationOnce(() => makeDealChain(mockDealRow));
// 3. User lookup
mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n }]));
// 4. Cap rows (no .limit — awaited via thenable)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
// 5. Role row
mockDbSelect.mockImplementationOnce(() =>
makeSelectChain([{ role: "read" }]),
);
// 6. Approval rows (no .limit)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
// 7. Removal rows (has .orderBy + .limit)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([]));
// 8. Phase-1 docs (no .limit)
mockDbSelect.mockImplementationOnce(() => makeSelectChain([], []));
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("DealDetailPage — multi-organisation portfolio", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders deal when its company is not the first linked organisation", async () => {
setupDb();
await expect(
DealDetailPage({
params: Promise.resolve({ slug: PORTFOLIO_ID, dealId: DEAL_ID }),
}),
).resolves.toBeDefined();
expect(mockNotFound).not.toHaveBeenCalled();
});
});

View file

@ -1,104 +1,24 @@
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { redirect, notFound } from "next/navigation";
import { eq, inArray, and, desc } from "drizzle-orm";
import { eq, inArray, and, desc, sql } from "drizzle-orm";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { alias } from "drizzle-orm/pg-core";
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { organisation } from "@/app/db/schema/organisation";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import { sql } from "drizzle-orm";
import type {
HubspotDeal,
DocStatus,
MeasureDocProgress,
PortfolioCapabilityType,
EffectiveRemovalState,
} from "../types";
import {
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
SURVEY_ALL_DOC_TYPES,
} from "../types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import type { DocStatus, PortfolioCapabilityType } from "../types";
import { deriveEffectiveRemovalState } from "../removalState";
import { classifyDeals } from "../transforms";
import type { InferSelectModel } from "drizzle-orm";
import { fetchDocsByDealId, computeDocStatusMap } from "../docStatus";
import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "../dealQuery";
import type { DealRow } from "../dealQuery";
import DealPage from "./DealPage";
import Link from "next/link";
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
const designerUser = alias(hubspotUsers, "designer_user");
type DealRow = {
deal: InferSelectModel<typeof hubspotDealData>;
coordinator: string | null;
designer: string | null;
};
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
const d = row.deal;
return {
id: d.id,
dealId: d.dealId,
dealname: d.dealname,
dealstage: d.dealstage,
companyId: d.companyId,
projectCode: d.projectCode,
landlordPropertyId: d.landlordPropertyId,
uprn: d.uprn,
outcome: d.outcome,
outcomeNotes: d.outcomeNotes,
majorConditionIssueDescription: d.majorConditionIssueDescription,
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
coordinationStatus: d.coordinationStatus,
designStatus: d.designStatus,
pashubLink: d.pashubLink,
sharepointLink: d.sharepointLink,
dampMouldFlag: d.dampmouldGrowth,
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
preSapScore: d.preSap,
coordinator: row.coordinator,
ioeV1Date: d.mtpCompletionDate,
ioeV2Date: d.mtpReModelCompletionDate,
ioeV3Date: d.ioeV3CompletionDate,
proposedMeasures: d.proposedMeasures,
approvedPackage: d.approvedPackage,
designer: row.designer,
designDate: d.designCompletionDate,
actualMeasuresInstalled: d.actualMeasuresInstalled,
installer: d.installer,
installerHandover: d.installerHandover,
lodgementStatus: d.lodgementStatus,
measuresLodgementDate: d.measuresLodgementDate,
fullLodgementDate: d.lodgementDate,
confirmedSurveyDate: d.confirmedSurveyDate,
confirmedSurveyTime: d.confirmedSurveyTime,
surveyedDate: d.surveyedDate,
designType: d.dealType,
eiScore: d.eiScore,
eiScorePotential: d.eiScorePotential,
epcSapScore: d.epcSapScore,
epcSapScorePotential: d.epcSapScorePotential,
surveyType: d.surveyType,
measuresForPibiOrdered: d.measuresForPibiOrdered,
pibiOrderDate: d.pibiOrderDate,
pibiCompletedDate: d.pibiCompletedDate,
propertyHaltedDate: d.propertyHaltedDate,
propertyHaltedReason: d.propertyHaltedReason,
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};
}
export default async function DealDetailPage(props: {
params: Promise<{ slug: string; dealId: string }>;
}) {
@ -109,22 +29,23 @@ export default async function DealDetailPage(props: {
redirect("/");
}
const link = await db
const links = await db
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
.from(portfolioOrganisation)
.innerJoin(
organisation,
eq(portfolioOrganisation.organisationId, organisation.id),
)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
if (!link.length || !link[0].hubspotCompanyId) {
const companyIds = links
.map((l) => l.hubspotCompanyId)
.filter((id): id is string => !!id);
if (companyIds.length === 0) {
redirect(`/portfolio/${portfolioId}/your-projects/live`);
}
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select({
deal: hubspotDealData,
@ -142,7 +63,7 @@ export default async function DealDetailPage(props: {
)
.where(
and(
eq(hubspotDealData.companyId, companyId),
inArray(hubspotDealData.companyId, companyIds),
eq(hubspotDealData.dealId, dealId),
),
)
@ -211,7 +132,6 @@ export default async function DealDetailPage(props: {
);
approvedMeasures.push(...approvalRows.map((r) => r.measureName));
let removalState: EffectiveRemovalState = "none";
const removalRows = await db
.select({
type: propertyRemovalRequests.type,
@ -227,115 +147,23 @@ export default async function DealDetailPage(props: {
.orderBy(desc(propertyRemovalRequests.requestedAt))
.limit(1);
if (removalRows[0]) {
const row = removalRows[0];
if (row.status === "pending") {
removalState =
row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
} else if (row.type === "removal" && row.status === "approved") {
removalState = "removed";
} else if (row.type === "re_addition" && row.status === "declined") {
removalState = "removed";
}
}
const removalState = removalRows[0]
? deriveEffectiveRemovalState(removalRows[0])
: "none";
// Doc status — same two-phase strategy as live tracker
const docFiles: Array<{ fileType: string; measureName: string | null }> = [];
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(eq(uploadedFiles.hubsotDealId, dealId));
for (const row of phase1Rows) {
if (row.fileType !== null) {
docFiles.push({ fileType: row.fileType, measureName: row.measureName });
}
}
if (docFiles.length === 0 && deal.uprn) {
try {
const uprnBig = BigInt(deal.uprn);
const phase2Rows = await db
.select({
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(eq(uploadedFiles.uprn, uprnBig));
for (const row of phase2Rows) {
if (row.fileType !== null) {
docFiles.push({
fileType: row.fileType,
measureName: row.measureName,
});
}
}
} catch {
// Invalid UPRN — skip phase 2
}
}
const measures =
approvedMeasures.length > 0
? approvedMeasures
: (deal.proposedMeasures ?? "")
.split(",")
.map((m: string) => m.trim())
.filter(Boolean);
const surveyDocs = docFiles.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docFiles.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter(
(d) => d.measureName === measureName,
);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
const docStatus: DocStatus = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) =>
surveyTypeSet.has(t),
),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
const docStatusMap = computeDocStatusMap([hubspotDeal], docsByDealId, { [dealId]: approvedMeasures });
const docStatus: DocStatus = docStatusMap[dealId] ?? {
presentSurveyTypes: [],
hasSurveyDocs: false,
isSurveyComplete: false,
hasInstallDocs: false,
installStatus: "none",
measureProgress: [],
};
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
<div className="max-w-screen-2xl mx-auto px-6 pb-10 space-y-4">
<div className="mb-6">
<nav className="flex items-center gap-1.5 text-sm text-gray-500 mb-3">
<Link

View file

@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import {
applyBulkDocType,
selectAllUnclassified,
getClassifiedCount,
getUnclassifiedIds,
uploadedCountForType,
} from "./classifyPhase";
import type { ClassifyEntry } from "./classifyPhase";
function makeEntry(overrides: Partial<ClassifyEntry> = {}): ClassifyEntry {
return {
id: "1",
docType: "",
status: "done",
...overrides,
};
}
// ── applyBulkDocType ─────────────────────────────────────────────────────────
describe("applyBulkDocType", () => {
it("applies docType to selected entries", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "a")?.docType).toBe("pre_photo");
});
it("leaves unselected entries unchanged", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b", docType: "post_photo" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "b")?.docType).toBe("post_photo");
});
it("overwrites an existing docType on selected entry", () => {
const entries = [makeEntry({ id: "a", docType: "mid_photo" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "a")?.docType).toBe("pre_photo");
});
it("skips non-done entries even if selected", () => {
const entries = [makeEntry({ id: "a", status: "error" })];
const result = applyBulkDocType(entries, new Set(["a"]), "pre_photo");
expect(result.find((e) => e.id === "a")?.docType).toBe("");
});
it("returns same length array", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
const result = applyBulkDocType(entries, new Set(["a", "b"]), "pre_photo");
expect(result).toHaveLength(2);
});
});
// ── selectAllUnclassified ────────────────────────────────────────────────────
describe("selectAllUnclassified", () => {
it("returns IDs of done entries with no docType", () => {
const entries = [makeEntry({ id: "a" }), makeEntry({ id: "b" })];
const ids = selectAllUnclassified(entries);
expect(ids).toEqual(new Set(["a", "b"]));
});
it("excludes already-classified entries", () => {
const entries = [
makeEntry({ id: "a", docType: "pre_photo" }),
makeEntry({ id: "b" }),
];
const ids = selectAllUnclassified(entries);
expect(ids).toEqual(new Set(["b"]));
});
it("excludes non-done entries", () => {
const entries = [
makeEntry({ id: "a", status: "error" }),
makeEntry({ id: "b" }),
];
const ids = selectAllUnclassified(entries);
expect(ids).toEqual(new Set(["b"]));
});
it("returns empty set when all classified", () => {
const entries = [makeEntry({ id: "a", docType: "pre_photo" })];
expect(selectAllUnclassified(entries)).toEqual(new Set());
});
});
// ── getClassifiedCount ───────────────────────────────────────────────────────
describe("getClassifiedCount", () => {
it("counts done entries with a docType", () => {
const entries = [
makeEntry({ id: "a", docType: "pre_photo" }),
makeEntry({ id: "b" }),
];
expect(getClassifiedCount(entries)).toBe(1);
});
it("returns 0 when none classified", () => {
expect(getClassifiedCount([makeEntry(), makeEntry({ id: "2" })])).toBe(0);
});
it("excludes non-done entries from count", () => {
const entries = [makeEntry({ id: "a", status: "error", docType: "pre_photo" })];
expect(getClassifiedCount(entries)).toBe(0);
});
});
// ── getUnclassifiedIds ───────────────────────────────────────────────────────
describe("getUnclassifiedIds", () => {
it("returns IDs of done entries with empty docType", () => {
const entries = [
makeEntry({ id: "a" }),
makeEntry({ id: "b", docType: "pre_photo" }),
];
expect(getUnclassifiedIds(entries)).toEqual(["a"]);
});
it("returns empty array when all classified", () => {
const entries = [makeEntry({ id: "a", docType: "pre_photo" })];
expect(getUnclassifiedIds(entries)).toEqual([]);
});
});
// ── uploadedCountForType ─────────────────────────────────────────────────────
describe("uploadedCountForType", () => {
it("returns 0 when docType not in uploaded list", () => {
expect(uploadedCountForType(["post_photo"], "pre_photo")).toBe(0);
});
it("counts occurrences of docType", () => {
expect(uploadedCountForType(["pre_photo", "pre_photo", "post_photo"], "pre_photo")).toBe(2);
});
it("returns 0 for empty list", () => {
expect(uploadedCountForType([], "pre_photo")).toBe(0);
});
it("exact match only — partial strings don't count", () => {
expect(uploadedCountForType(["pre_photo_extra"], "pre_photo")).toBe(0);
});
});

View file

@ -0,0 +1,33 @@
export type ClassifyEntry = {
id: string;
docType: string;
status: "queued" | "uploading" | "done" | "error";
};
export function applyBulkDocType<T extends ClassifyEntry>(
entries: T[],
selectedIds: Set<string>,
docType: string,
): T[] {
return entries.map((e) =>
selectedIds.has(e.id) && e.status === "done" ? { ...e, docType } : e,
);
}
export function selectAllUnclassified(entries: ClassifyEntry[]): Set<string> {
return new Set(
entries.filter((e) => e.status === "done" && e.docType === "").map((e) => e.id),
);
}
export function getClassifiedCount(entries: ClassifyEntry[]): number {
return entries.filter((e) => e.status === "done" && e.docType !== "").length;
}
export function getUnclassifiedIds(entries: ClassifyEntry[]): string[] {
return entries.filter((e) => e.status === "done" && e.docType === "").map((e) => e.id);
}
export function uploadedCountForType(uploadedDocs: string[], docType: string): number {
return uploadedDocs.filter((d) => d === docType).length;
}

View file

@ -0,0 +1,243 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApprovalConfirmDialog } from "../ApprovalConfirmDialog";
import type { PendingDiff } from "../ApprovalConfirmDialog";
interface MeasureApprovalEditorProps {
dealId: string;
dealName: string | null;
portfolioId: string;
proposedMeasures: string[];
isApprover: boolean;
}
export function MeasureApprovalEditor({
dealId,
dealName,
portfolioId,
proposedMeasures,
isApprover,
}: MeasureApprovalEditorProps) {
const queryClient = useQueryClient();
// Reuse the pibi-measures query — it already returns approvedMeasures and
// instructedMeasures alongside the PIBI selection.
const { data, isLoading } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch measures");
return res.json();
},
staleTime: 30_000,
});
const allMeasures = useMemo(() => {
const instructed = data?.instructedMeasures ?? [];
const all = [...proposedMeasures];
for (const m of instructed) {
if (!all.includes(m)) all.push(m);
}
return all;
}, [proposedMeasures, data?.instructedMeasures]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [savedSelected, setSavedSelected] = useState<Set<string>>(new Set());
const [initialised, setInitialised] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!data) return;
const initial = new Set(data.approvedMeasures);
setSelected(new Set(initial));
setSavedSelected(new Set(initial));
setInitialised(true);
setError(null);
}, [dealId, data]);
function toggleMeasure(measure: string) {
if (!isApprover) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(measure)) next.delete(measure);
else next.add(measure);
return next;
});
}
const pendingDiff: PendingDiff = useMemo(() => {
const added = allMeasures.filter((m) => selected.has(m) && !savedSelected.has(m));
const removed = allMeasures.filter((m) => !selected.has(m) && savedSelected.has(m));
return { added, removed };
}, [allMeasures, selected, savedSelected]);
const isDirty = pendingDiff.added.length > 0 || pendingDiff.removed.length > 0;
async function handleConfirm() {
setSubmitting(true);
setError(null);
const changes = [
...pendingDiff.added.map((measureName) => ({
hubspotDealId: dealId,
measureName,
approved: true,
})),
...pendingDiff.removed.map((measureName) => ({
hubspotDealId: dealId,
measureName,
approved: false,
})),
];
try {
const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string" ? json.error : "Failed to save approvals",
);
setConfirmOpen(false);
return;
}
setSavedSelected(new Set(selected));
setConfirmOpen(false);
queryClient.invalidateQueries({
queryKey: ["pibiMeasures", portfolioId, dealId],
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save approvals");
setConfirmOpen(false);
} finally {
setSubmitting(false);
}
}
if (isLoading || !initialised) {
if (allMeasures.length === 0 && isLoading) {
return (
<p
data-testid="measure-approval-loading"
className="text-xs text-gray-400 py-2"
>
Loading
</p>
);
}
}
if (allMeasures.length === 0) {
return (
<p className="text-xs text-gray-400 py-2">
No proposed measures for this property yet.
</p>
);
}
return (
<div className="space-y-3">
<div
data-testid="measure-approval-chips"
className="flex flex-wrap gap-2"
>
{allMeasures.map((measure) => {
const isApproved = selected.has(measure);
return (
<label
key={measure}
data-testid={`measure-approval-chip-${measure}`}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-colors ${
isApprover ? "cursor-pointer" : "cursor-default"
} ${
isApproved
? "bg-brandblue/10 border-brandblue/40 text-brandblue font-medium"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
{isApprover && (
<input
type="checkbox"
className="sr-only"
checked={isApproved}
onChange={() => toggleMeasure(measure)}
disabled={submitting}
data-testid={`measure-approval-checkbox-${measure}`}
/>
)}
<span
className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
isApproved
? "bg-brandblue border-brandblue"
: "bg-white border-gray-300"
}`}
>
{isApproved && (
<svg viewBox="0 0 10 8" className="w-2 h-2 fill-white">
<path
d="M1 4l3 3 5-6"
stroke="white"
strokeWidth="1.5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</span>
{measure}
</label>
);
})}
</div>
{isApprover && (
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Tick measures to approve. Changes require confirmation before saving.
</p>
<button
type="button"
data-testid="measure-approval-save"
onClick={() => setConfirmOpen(true)}
disabled={!isDirty || submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Saving…" : "Review & Save"}
</button>
</div>
)}
{error && (
<p
data-testid="measure-approval-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
<ApprovalConfirmDialog
open={confirmOpen}
pendingDiffs={{ [dealId]: pendingDiff }}
dealNames={{ [dealId]: dealName ?? dealId }}
onConfirm={handleConfirm}
onCancel={() => setConfirmOpen(false)}
isPending={submitting}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { parseMeasures } from "@/app/lib/parseMeasures";
import type { ClassifiedDeal, PortfolioCapabilityType } from "../types";
import { PibiSection } from "../PibiSection";
import { SectionHeader, SECTION_TITLES } from "./primitives";
import { SurveyRequestSection } from "./SurveyRequestSection";
import { RemovalRequestSection } from "./RemovalRequestSection";
interface PibiSurveysTabContentProps {
deal: ClassifiedDeal;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
}
export function PibiSurveysTabContent({
deal,
portfolioId,
userRole,
userCapability,
}: PibiSurveysTabContentProps) {
const isApprover = userCapability.includes("approver");
return (
<>
<div>
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
<PibiSection
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit={isApprover}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Survey Request
</h3>
<SurveyRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
canEdit={isApprover}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Removal
</h3>
<RemovalRequestSection
dealId={deal.dealId}
portfolioId={portfolioId}
userRole={userRole}
userCapability={userCapability}
/>
</div>
</>
);
}

View file

@ -0,0 +1,306 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2, RotateCcw } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/app/shadcn_components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import type { PortfolioCapabilityType, RemovalRequest } from "../types";
import { WRITE_ROLES, formatDateTime } from "./primitives";
export function RemovalRequestSection({
dealId,
portfolioId,
userRole,
userCapability,
}: {
dealId: string;
portfolioId: string;
userRole: string;
userCapability: PortfolioCapabilityType;
}) {
const queryClient = useQueryClient();
const [dialogType, setDialogType] = useState<"removal" | "re_addition" | null>(null);
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const canRequest = WRITE_ROLES.includes(userRole);
const isApprover = userCapability.includes("approver");
const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({
queryKey: ["removalRequests", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`,
);
if (!res.ok) throw new Error("Failed to fetch removal requests");
return res.json();
},
staleTime: 30_000,
});
const latest = data?.requests?.[0] ?? null;
type EffectiveState = "active" | "pending_removal" | "removed" | "pending_re_addition";
const effectiveState: EffectiveState = (() => {
if (!latest) return "active";
if (latest.status === "pending") {
return latest.type === "re_addition" ? "pending_re_addition" : "pending_removal";
}
if (latest.type === "removal" && latest.status === "approved") return "removed";
if (latest.type === "re_addition" && latest.status === "declined") return "removed";
return "active";
})();
const pendingRequest = latest?.status === "pending" ? latest : null;
const latestResolvedRequest = latest?.status !== "pending" ? latest : null;
function closeDialog() {
setDialogType(null);
setReason("");
setError(null);
}
async function handleSubmit() {
if (!reason.trim() || !dialogType) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim(), type: dialogType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to submit request");
return;
}
closeDialog();
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
}
}
async function handleReview(requestId: string, action: "approved" | "declined") {
setReviewing(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId: Number(requestId), action }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(json.error ?? "Failed to review request");
return;
}
queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] });
} finally {
setReviewing(false);
}
}
function resolvedLabel(req: RemovalRequest): string {
if (req.type === "re_addition") {
return req.status === "approved" ? "Re-addition Approved" : "Re-addition Declined";
}
return req.status === "approved" ? "Removal Approved" : "Removal Declined";
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
return (
<div className="space-y-3">
{error && (
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
{pendingRequest && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
{pendingRequest.type === "re_addition" ? "Pending Re-addition Request" : "Pending Removal Request"}
</span>
</div>
<p className="text-xs text-gray-700 leading-relaxed">{pendingRequest.reason}</p>
<p className="text-[11px] text-gray-400">
Requested by <span className="font-medium text-gray-600">{pendingRequest.requestedByEmail}</span>
{" · "}
{formatDateTime(pendingRequest.requestedAt)}
</p>
{isApprover && (
<div className="flex gap-2 pt-1">
<button
onClick={() => handleReview(pendingRequest.id, "approved")}
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{pendingRequest.type === "re_addition" ? "Approve Re-addition" : "Approve Removal"}
</button>
<button
onClick={() => handleReview(pendingRequest.id, "declined")}
disabled={reviewing}
className="flex-1 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-50 transition-colors"
>
Decline
</button>
</div>
)}
</div>
)}
{latestResolvedRequest && (
<div className={`rounded-xl border p-3.5 space-y-1.5 ${
latestResolvedRequest.status === "approved"
? "border-emerald-200 bg-emerald-50"
: "border-gray-200 bg-gray-50"
}`}>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full border ${
latestResolvedRequest.status === "approved"
? "text-emerald-700 bg-emerald-100 border-emerald-200"
: "text-gray-600 bg-gray-100 border-gray-200"
}`}>
{resolvedLabel(latestResolvedRequest)}
</span>
</div>
<p className="text-xs text-gray-600 leading-relaxed">{latestResolvedRequest.reason}</p>
<p className="text-[11px] text-gray-400">
Requested by <span className="font-medium text-gray-600">{latestResolvedRequest.requestedByEmail}</span>
{" · "}
{formatDateTime(latestResolvedRequest.requestedAt)}
</p>
{latestResolvedRequest.reviewedByEmail && (
<p className="text-[11px] text-gray-400">
{latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "}
<span className="font-medium text-gray-600">{latestResolvedRequest.reviewedByEmail}</span>
{latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`}
</p>
)}
</div>
)}
{!pendingRequest && (
<>
{effectiveState === "active" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("removal"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-red-200 text-red-600 hover:bg-red-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
Request Removal from Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
{effectiveState === "removed" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block w-full">
<button
onClick={() => { if (canRequest) setDialogType("re_addition"); }}
disabled={!canRequest}
className={`w-full flex items-center justify-center gap-2 text-xs font-medium px-3 py-2 rounded-lg border transition-colors ${
canRequest
? "border-blue-200 text-blue-600 hover:bg-blue-50 bg-white"
: "border-gray-100 text-gray-300 bg-gray-50 cursor-not-allowed"
}`}
>
<RotateCcw className="h-3.5 w-3.5" />
Request Re-addition to Project
</button>
</span>
</TooltipTrigger>
{!canRequest && (
<TooltipContent side="top" className="text-xs">
Not available with read-only permissions
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</>
)}
<Dialog open={dialogType !== null} onOpenChange={(v) => { if (!v) closeDialog(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold text-gray-800">
{dialogType === "re_addition" ? "Request Re-addition to Project" : "Request Removal from Project"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-xs text-gray-500 leading-relaxed">
{dialogType === "re_addition"
? "Please provide a reason why this property should be re-added to the project. This will be recorded for audit purposes."
: "Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes."}
</p>
<textarea
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 resize-none"
placeholder={dialogType === "re_addition" ? "Reason for re-addition…" : "Reason for removal…"}
rows={4}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
<DialogFooter className="gap-2">
<button
onClick={closeDialog}
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!reason.trim() || submitting}
className={`text-xs font-medium px-4 py-2 rounded-lg text-white disabled:opacity-50 transition-colors ${
dialogType === "re_addition"
? "bg-blue-600 hover:bg-blue-700"
: "bg-red-600 hover:bg-red-700"
}`}
>
{submitting ? "Submitting…" : "Submit Request"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { formatDateTime } from "./primitives";
const SURVEY_TYPES = [
{ value: "technical_building_survey", label: "Technical Building Survey" },
] as const;
type SurveyRequestRecord = {
id: string;
hubspotDealId: string;
surveyType: string | null;
status: string;
requestedByEmail: string;
requestedAt: string | null;
fulfilledAt: string | null;
};
export function SurveyRequestSection({
dealId,
portfolioId,
canEdit,
}: {
dealId: string;
portfolioId: string;
canEdit: boolean;
}) {
const queryClient = useQueryClient();
const [selectedType, setSelectedType] = useState<string>(SURVEY_TYPES[0].value);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data, isLoading } = useQuery<{ requests: SurveyRequestRecord[] }>({
queryKey: ["surveyRequests", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/survey-requests?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch survey requests");
return res.json();
},
staleTime: 30_000,
});
const pending = data?.requests?.find((r) => r.status === "pending") ?? null;
const fulfilled = (data?.requests ?? []).filter((r) => r.status === "fulfilled");
async function handleSubmit() {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/survey-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hubspotDealId: dealId, surveyType: selectedType }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(typeof json.error === "string" ? json.error : "Failed to submit request");
return;
}
const json = (await res.json()) as { ok: boolean; hubspotSync?: string; hubspotError?: string };
if (json.hubspotSync === "failed") {
setError(json.hubspotError ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` : "Saved locally — HubSpot sync failed");
}
queryClient.invalidateQueries({ queryKey: ["surveyRequests", portfolioId, dealId] });
} finally {
setSubmitting(false);
}
}
function surveyTypeLabel(value: string | null) {
if (!value) return value;
return SURVEY_TYPES.find((t) => t.value === value)?.label ?? value;
}
if (isLoading) {
return <p className="text-xs text-gray-400 py-2">Loading</p>;
}
return (
<div data-testid="survey-request-form" className="space-y-3">
{error && (
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
{!pending && fulfilled.length === 0 && (
<p className="text-xs text-gray-400 py-1">No surveys requested</p>
)}
{pending && (
<div
data-testid="survey-request-pending-badge"
className="rounded-xl border border-amber-200 bg-amber-50 p-3.5 space-y-1.5"
>
<span className="text-xs font-semibold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-full border border-amber-200">
Survey Requested
</span>
<p className="text-xs text-gray-700 font-medium">{surveyTypeLabel(pending.surveyType)}</p>
<p className="text-[11px] text-gray-400">
Requested by{" "}
<span className="font-medium text-gray-600">{pending.requestedByEmail}</span>
{pending.requestedAt && ` · ${formatDateTime(pending.requestedAt)}`}
</p>
</div>
)}
{canEdit && !pending && (
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">Survey type</span>
<select
data-testid="survey-request-type-select"
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
disabled={submitting}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 bg-white"
>
{SURVEY_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
<div className="flex justify-end">
<button
data-testid="survey-request-submit"
type="button"
onClick={handleSubmit}
disabled={submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Submitting…" : "Request Survey"}
</button>
</div>
</div>
)}
{fulfilled.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-semibold uppercase tracking-wide text-gray-400">Past Requests</p>
{fulfilled.map((r) => (
<div key={r.id} className="rounded-lg border border-emerald-100 bg-emerald-50/50 px-3 py-2.5 space-y-1">
<span className="text-[10px] font-semibold text-emerald-700">Fulfilled</span>
<p className="text-xs text-gray-700 font-medium">{surveyTypeLabel(r.surveyType)}</p>
<p className="text-[11px] text-gray-400">
By <span className="font-medium text-gray-600">{r.requestedByEmail}</span>
{r.requestedAt && ` · ${formatDateTime(r.requestedAt)}`}
</p>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,16 @@
export type { DrawerSection } from "./primitives";
export {
SECTION_TITLES,
WRITE_ROLES,
formatDate,
InfoRow,
SectionHeader,
MilestoneTimeline,
} from "./primitives";
export { RemovalRequestSection } from "./RemovalRequestSection";
export { SurveyRequestSection } from "./SurveyRequestSection";
export { MeasureApprovalEditor } from "./MeasureApprovalEditor";
export { PibiSurveysTabContent } from "./PibiSurveysTabContent";
export { PibiDatesEditor } from "./pibi/PibiDatesEditor";
export { PibiMeasureSelector } from "./pibi/PibiMeasureSelector";
export { InstructMeasureEditor } from "./pibi/InstructMeasureEditor";

View file

@ -0,0 +1,226 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
import { useToast } from "@/app/hooks/use-toast";
interface InstructMeasureEditorProps {
dealId: string;
portfolioId: string;
proposedMeasures: string[];
canEdit: boolean;
outOfOrderWarning: string | null;
onSuccess?: () => void;
}
export function InstructMeasureEditor({
dealId,
portfolioId,
proposedMeasures,
canEdit,
outOfOrderWarning,
onSuccess,
}: InstructMeasureEditorProps) {
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: pibiData } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch measures");
return res.json();
},
staleTime: 30_000,
enabled: canEdit,
});
const excluded = useMemo(() => {
const set = new Set<string>(proposedMeasures);
for (const m of pibiData?.approvedMeasures ?? []) set.add(m);
for (const m of pibiData?.instructedMeasures ?? []) set.add(m);
return set;
}, [proposedMeasures, pibiData]);
const eligible = useMemo(
() => MEASURE_NAMES.filter((m) => !excluded.has(m)),
[excluded],
);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [confirmText, setConfirmText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setChecked(new Set());
setConfirmText("");
setError(null);
}, [dealId]);
if (!canEdit) return null;
function toggleMeasure(m: string) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(m)) next.delete(m);
else next.add(m);
return next;
});
}
async function handleSubmit() {
const measureNames = Array.from(checked);
if (measureNames.length === 0 || confirmText !== "confirm") return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/instructed-measures`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, measureNames }),
},
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to instruct measures",
);
return;
}
const json = (await res.json()) as {
ok: boolean;
hubspotSync: "ok" | "failed";
hubspotError?: string;
};
setChecked(new Set());
setConfirmText("");
void queryClient.invalidateQueries({ queryKey: ["pibiMeasures", portfolioId, dealId] });
onSuccess?.();
if (json.hubspotSync === "failed") {
toast({
title: "Measures instructed",
description: json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
variant: "destructive",
});
} else {
toast({
title: "Measures instructed",
description: `${measureNames.join(", ")} ${measureNames.length === 1 ? "has" : "have"} been instructed.`,
});
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to instruct measures",
);
} finally {
setSubmitting(false);
}
}
const hasSelection = checked.size > 0;
const canSubmit = hasSelection && confirmText === "confirm" && !submitting;
return (
<div className="space-y-3">
{outOfOrderWarning && (
<div
data-testid="instruct-measure-warning"
className="flex items-start gap-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 px-3 py-2 rounded-lg"
>
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{outOfOrderWarning}</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">Instruct measures</span>
{eligible.length === 0 ? (
<p className="text-xs text-gray-400 italic">All measures already proposed or approved.</p>
) : (
<div
data-testid="instruct-measure-checklist"
className="flex flex-col gap-0.5 max-h-48 overflow-y-auto rounded-lg border border-gray-200 p-2"
>
{eligible.map((m) => (
<label
key={m}
className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-gray-50 text-xs text-gray-800"
>
<input
type="checkbox"
checked={checked.has(m)}
onChange={() => toggleMeasure(m)}
disabled={submitting}
className="accent-brandblue"
/>
{m}
</label>
))}
</div>
)}
</div>
{hasSelection && (
<div className="space-y-2 border-t border-gray-100 pt-3">
<div className="flex flex-wrap gap-1">
{Array.from(checked).map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-blue-50 border border-blue-200 text-blue-700"
>
{m}
</span>
))}
</div>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500">
Type <span className="font-mono font-semibold">confirm</span> to instruct
</span>
<input
data-testid="instruct-measure-confirm-input"
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
disabled={submitting}
placeholder="confirm"
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300 w-full"
/>
</label>
<button
type="button"
data-testid="instruct-measure-submit"
onClick={handleSubmit}
disabled={!canSubmit}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{submitting ? "Instructing…" : `Instruct ${checked.size} measure${checked.size === 1 ? "" : "s"}`}
</button>
</div>
)}
{error && (
<p
data-testid="instruct-measure-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,200 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { InfoRow, formatDate } from "../primitives";
function toDateInputValue(d: Date | string | null | undefined): string {
if (!d) return "";
try {
const date = typeof d === "string" ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return "";
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
} catch {
return "";
}
}
function dateInputToIso(value: string): string | null {
if (!value) return null;
return new Date(`${value}T00:00:00.000Z`).toISOString();
}
interface PibiDatesEditorProps {
dealId: string;
portfolioId: string;
initialOrderDate: Date | string | null;
initialCompletedDate: Date | string | null;
canEdit: boolean;
}
export function PibiDatesEditor({
dealId,
portfolioId,
initialOrderDate,
initialCompletedDate,
canEdit,
}: PibiDatesEditorProps) {
const initialOrder = useMemo(
() => toDateInputValue(initialOrderDate),
[initialOrderDate],
);
const initialCompleted = useMemo(
() => toDateInputValue(initialCompletedDate),
[initialCompletedDate],
);
const [orderValue, setOrderValue] = useState(initialOrder);
const [completedValue, setCompletedValue] = useState(initialCompleted);
const [savedOrder, setSavedOrder] = useState(initialOrder);
const [savedCompleted, setSavedCompleted] = useState(initialCompleted);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setOrderValue(initialOrder);
setSavedOrder(initialOrder);
}, [initialOrder]);
useEffect(() => {
setCompletedValue(initialCompleted);
setSavedCompleted(initialCompleted);
}, [initialCompleted]);
const dirty =
orderValue !== savedOrder || completedValue !== savedCompleted;
async function handleSave() {
if (!dirty) return;
setSubmitting(true);
setError(null);
const fields: Record<string, string | null> = {};
if (orderValue !== savedOrder) {
fields.pibi_order_date = dateInputToIso(orderValue);
}
if (completedValue !== savedCompleted) {
fields.pibi_completed_date = dateInputToIso(completedValue);
}
const prevOrder = savedOrder;
const prevCompleted = savedCompleted;
setSavedOrder(orderValue);
setSavedCompleted(completedValue);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/deal-properties`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, fields }),
},
);
if (!res.ok) {
setSavedOrder(prevOrder);
setSavedCompleted(prevCompleted);
setOrderValue(prevOrder);
setCompletedValue(prevCompleted);
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to update PIBI dates",
);
return;
}
const json = (await res.json()) as {
results: Record<string, { ok: boolean; error?: string }>;
hubspotSync: "ok" | "failed" | "skipped";
hubspotError?: string;
};
const fieldErrors = Object.entries(json.results ?? {})
.filter(([, r]) => !r.ok)
.map(([k, r]) => `${k}: ${r.error ?? "rejected"}`);
if (fieldErrors.length > 0) {
setError(fieldErrors.join("; "));
} else if (json.hubspotSync === "failed") {
setError(
json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
);
}
} catch (err) {
setSavedOrder(prevOrder);
setSavedCompleted(prevCompleted);
setOrderValue(prevOrder);
setCompletedValue(prevCompleted);
setError(err instanceof Error ? err.message : "Failed to update PIBI dates");
} finally {
setSubmitting(false);
}
}
if (!canEdit) {
return (
<div className="divide-y divide-gray-50">
<InfoRow label="PIBI Order Date" value={formatDate(initialOrderDate)} />
<InfoRow
label="PIBI Completed Date"
value={formatDate(initialCompletedDate)}
/>
</div>
);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
PIBI Order Date
</span>
<input
type="date"
data-testid="pibi-order-date-input"
value={orderValue}
onChange={(e) => setOrderValue(e.target.value)}
disabled={submitting}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-gray-500 font-medium">
PIBI Completed Date
</span>
<input
type="date"
data-testid="pibi-completed-date-input"
value={completedValue}
onChange={(e) => setCompletedValue(e.target.value)}
disabled={submitting}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
</label>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Pick the actual date leave blank to clear. Changes sync to HubSpot.
</p>
<button
type="button"
data-testid="pibi-save-button"
onClick={handleSave}
disabled={!dirty || submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Saving…" : "Save PIBI Dates"}
</button>
</div>
{error && (
<p
data-testid="pibi-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,216 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
interface PibiMeasureSelectorProps {
dealId: string;
portfolioId: string;
proposedMeasures: string[];
canEdit: boolean;
}
export function PibiMeasureSelector({
dealId,
portfolioId,
proposedMeasures,
canEdit,
}: PibiMeasureSelectorProps) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{
pibiMeasures: string[];
approvedMeasures: string[];
instructedMeasures: string[];
}>({
queryKey: ["pibiMeasures", portfolioId, dealId],
queryFn: async () => {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`,
);
if (!res.ok) throw new Error("Failed to fetch PIBI measures");
return res.json();
},
staleTime: 30_000,
});
const allMeasures = useMemo(() => {
const instructed = data?.instructedMeasures ?? [];
const all = [...proposedMeasures];
for (const m of instructed) {
if (!all.includes(m)) all.push(m);
}
return all;
}, [proposedMeasures, data?.instructedMeasures]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [initialised, setInitialised] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!data) return;
const initial =
data.pibiMeasures.length > 0
? data.pibiMeasures
: data.approvedMeasures;
setSelected(new Set(initial));
setInitialised(true);
setError(null);
}, [dealId, data]);
function toggleMeasure(measure: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(measure)) {
next.delete(measure);
} else {
next.add(measure);
}
return next;
});
}
async function handleSave() {
setSubmitting(true);
setError(null);
const measureNames = Array.from(selected);
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/pibi-measures`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dealId, measureNames }),
},
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
setError(
typeof json.error === "string"
? json.error
: "Failed to save PIBI selections",
);
return;
}
const json = (await res.json()) as {
ok: boolean;
hubspotSync: "ok" | "failed";
hubspotError?: string;
};
if (json.hubspotSync === "failed") {
setError(
json.hubspotError
? `Saved locally — HubSpot sync failed: ${json.hubspotError}`
: "Saved locally — HubSpot sync failed",
);
}
queryClient.invalidateQueries({
queryKey: ["pibiMeasures", portfolioId, dealId],
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to save PIBI selections",
);
} finally {
setSubmitting(false);
}
}
if (!canEdit) return null;
if (isLoading || !initialised) {
return (
<p
data-testid="pibi-selector-loading"
className="text-xs text-gray-400 py-2"
>
Loading
</p>
);
}
if (allMeasures.length === 0) {
return (
<p className="text-xs text-gray-400 py-2">
No measures associated with this deal yet.
</p>
);
}
return (
<div className="space-y-3">
<p className="text-xs text-gray-500 font-medium">PIBI Measure Selection</p>
<div
data-testid="pibi-measure-selector"
className="flex flex-wrap gap-2"
>
{allMeasures.map((measure) => {
const checked = selected.has(measure);
const isApproved = (data?.approvedMeasures ?? []).includes(measure);
return (
<label
key={measure}
data-testid={`pibi-measure-option-${measure}`}
className={`flex items-center gap-1.5 cursor-pointer px-2.5 py-1 rounded-full text-xs border transition-colors ${
checked
? "bg-brandblue/10 border-brandblue/40 text-brandblue font-medium"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggleMeasure(measure)}
disabled={submitting}
data-testid={`pibi-measure-checkbox-${measure}`}
/>
<span
className={`w-3 h-3 rounded-sm border flex items-center justify-center shrink-0 ${
checked
? "bg-brandblue border-brandblue"
: "bg-white border-gray-300"
}`}
>
{checked && (
<svg viewBox="0 0 10 8" className="w-2 h-2 fill-white">
<path d="M1 4l3 3 5-6" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{measure}
{isApproved && (
<span className="text-[10px] text-emerald-600 font-semibold ml-0.5">
</span>
)}
</label>
);
})}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-gray-400">
Tick measures going for PIBI. Approved measures are pre-ticked ().
</p>
<button
type="button"
data-testid="pibi-selector-save"
onClick={handleSave}
disabled={submitting}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
>
{submitting ? "Saving…" : "Save PIBI Selections"}
</button>
</div>
{error && (
<p
data-testid="pibi-selector-error"
className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { CheckCircle2, Circle } from "lucide-react";
import type { ClassifiedDeal } from "../types";
export type DrawerSection = "survey" | "measures" | "pibi" | "domna" | "technical";
export const SECTION_TITLES: Record<DrawerSection, string> = {
survey: "Survey",
measures: "Measures",
pibi: "PIBI",
domna: "Domna Survey",
technical: "Technical Approved",
};
export const WRITE_ROLES = ["creator", "admin", "write"];
export function formatDate(d: Date | string | null | undefined): string | null {
if (!d) return null;
try {
return new Date(d).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
} catch {
return null;
}
}
export function formatDateTime(d: string | Date | null | undefined): string {
if (!d) return "";
try {
return new Date(d).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
export function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value) return null;
return (
<div className="flex items-start gap-3 py-2 border-b border-gray-50 last:border-0">
<span className="text-xs text-gray-400 font-medium w-32 shrink-0 pt-0.5">{label}</span>
<span className="text-xs text-gray-700 flex-1 leading-relaxed">{value}</span>
</div>
);
}
export function SectionHeader({ id, label }: { id: string; label: string }) {
return (
<h3
data-testid={`drawer-section-${id}`}
className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3"
>
{label}
</h3>
);
}
const MILESTONES: { label: string; field: keyof ClassifiedDeal; sublabel?: string }[] = [
{ label: "Booking Confirmed", field: "confirmedSurveyDate" },
{ label: "Assessment Completed", field: "surveyedDate" },
{ label: "Coordination (V1)", field: "ioeV1Date", sublabel: "IOE/MTP V1" },
{ label: "Coordination (V2)", field: "ioeV2Date", sublabel: "IOE/MTP V2" },
{ label: "Design Completed", field: "designDate" },
{ label: "Measures Lodged", field: "measuresLodgementDate" },
{ label: "Stage 1 Lodgement", field: "fullLodgementDate" },
];
export function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) {
const milestones = MILESTONES.map((m) => ({
...m,
date: formatDate(deal[m.field] as Date | string | null),
}));
const lastCompletedIdx = milestones.reduce((acc, m, i) => (m.date ? i : acc), -1);
return (
<div className="relative">
{milestones.map((m, i) => {
const completed = !!m.date;
const isLast = i === milestones.length - 1;
return (
<div key={m.field} className="flex items-stretch gap-3">
<div className="flex flex-col items-center w-5 shrink-0">
<div className={`relative z-10 flex items-center justify-center w-5 h-5 rounded-full border-2 mt-0.5 transition-all duration-300 ${
completed
? "bg-brandmidblue border-brandmidblue"
: i <= lastCompletedIdx + 1
? "bg-white border-brandblue/30"
: "bg-white border-gray-200"
}`}>
{completed ? (
<CheckCircle2 className="h-3 w-3 text-white" />
) : (
<Circle className={`h-2 w-2 ${i <= lastCompletedIdx + 1 ? "text-brandblue/40" : "text-gray-300"}`} />
)}
</div>
{!isLast && (
<div className={`w-0.5 flex-1 my-0.5 ${
completed && milestones[i + 1]?.date ? "bg-brandmidblue/40" : "bg-gray-100"
}`} />
)}
</div>
<div className={`pb-4 flex-1 min-w-0 ${isLast ? "pb-0" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={`text-xs font-semibold leading-tight ${
completed ? "text-gray-800" : "text-gray-400"
}`}>
{m.label}
</p>
{m.sublabel && (
<p className="text-[10px] text-gray-400 mt-0.5">{m.sublabel}</p>
)}
</div>
{m.date ? (
<span className="text-[11px] font-medium text-brandmidblue bg-brandlightblue/60 px-2 py-0.5 rounded-full shrink-0 whitespace-nowrap">
{m.date}
</span>
) : (
<span className="text-[11px] text-gray-300 shrink-0">Pending</span>
)}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,73 @@
import { alias } from "drizzle-orm/pg-core";
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import type { HubspotDeal } from "./types";
import type { InferSelectModel } from "drizzle-orm";
export const coordinatorUser = alias(hubspotUsers, "coordinator_user");
export const designerUser = alias(hubspotUsers, "designer_user");
export type DealRow = {
deal: InferSelectModel<typeof hubspotDealData>;
coordinator: string | null;
designer: string | null;
};
export function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
const d = row.deal;
return {
id: d.id,
dealId: d.dealId,
dealname: d.dealname,
dealstage: d.dealstage,
companyId: d.companyId,
projectCode: d.projectCode,
landlordPropertyId: d.landlordPropertyId,
uprn: d.uprn,
outcome: d.outcome,
outcomeNotes: d.outcomeNotes,
majorConditionIssueDescription: d.majorConditionIssueDescription,
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
coordinationStatus: d.coordinationStatus,
designStatus: d.designStatus,
pashubLink: d.pashubLink,
sharepointLink: d.sharepointLink,
dampMouldFlag: d.dampmouldGrowth,
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
preSapScore: d.preSap,
coordinator: row.coordinator,
ioeV1Date: d.mtpCompletionDate,
ioeV2Date: d.mtpReModelCompletionDate,
ioeV3Date: d.ioeV3CompletionDate,
proposedMeasures: d.proposedMeasures,
approvedPackage: d.approvedPackage,
designer: row.designer,
designDate: d.designCompletionDate,
actualMeasuresInstalled: d.actualMeasuresInstalled,
installer: d.installer,
installerHandover: d.installerHandover,
lodgementStatus: d.lodgementStatus,
measuresLodgementDate: d.measuresLodgementDate,
fullLodgementDate: d.lodgementDate,
confirmedSurveyDate: d.confirmedSurveyDate,
confirmedSurveyTime: d.confirmedSurveyTime,
surveyedDate: d.surveyedDate,
designType: d.dealType,
eiScore: d.eiScore,
eiScorePotential: d.eiScorePotential,
epcSapScore: d.epcSapScore,
epcSapScorePotential: d.epcSapScorePotential,
surveyType: d.surveyType,
measuresForPibiOrdered: d.measuresForPibiOrdered,
pibiOrderDate: d.pibiOrderDate,
pibiCompletedDate: d.pibiCompletedDate,
propertyHaltedDate: d.propertyHaltedDate,
propertyHaltedReason: d.propertyHaltedReason,
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};
}

View file

@ -0,0 +1,168 @@
import { describe, it, expect } from "vitest";
import { computeDocStatusMap } from "./docStatus";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types";
import type { HubspotDeal, ApprovalsByDeal } 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,
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,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
pibiCompletedDate: null,
propertyHaltedDate: null,
propertyHaltedReason: null,
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
const allSurveyDocs = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.map((t) => ({
fileType: t,
measureName: null,
}));
describe("computeDocStatusMap", () => {
it("marks survey as complete when all 9 mandatory doc types are present", () => {
const deal = makeDeal({ dealId: "deal-1" });
const docs = new Map([["deal-1", allSurveyDocs]]);
const result = computeDocStatusMap([deal], docs, {});
expect(result["deal-1"].isSurveyComplete).toBe(true);
expect(result["deal-1"].hasSurveyDocs).toBe(true);
});
it("marks survey as incomplete when only some mandatory doc types are present", () => {
const deal = makeDeal({ dealId: "deal-1" });
const partialSurveyDocs = [{ fileType: "photo_pack", measureName: null }];
const docs = new Map([["deal-1", partialSurveyDocs]]);
const result = computeDocStatusMap([deal], docs, {});
expect(result["deal-1"].isSurveyComplete).toBe(false);
expect(result["deal-1"].hasSurveyDocs).toBe(true);
});
it("omits a deal from the map when it has no uploaded documents", () => {
const deal = makeDeal({ dealId: "deal-1" });
const result = computeDocStatusMap([deal], new Map(), {});
expect(result["deal-1"]).toBeUndefined();
});
it("uses approved measures in preference to proposed measures", () => {
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: "CWI" });
const approvalsByDeal: ApprovalsByDeal = { "deal-1": ["ASHP"] };
const docs = new Map([["deal-1", allSurveyDocs]]);
const result = computeDocStatusMap([deal], docs, approvalsByDeal);
const measureNames = result["deal-1"].measureProgress.map((m) => m.measureName);
expect(measureNames).toEqual(["ASHP"]);
expect(measureNames).not.toContain("CWI");
});
describe("installStatus", () => {
// CWI requires only BASE_DOCS — simple to satisfy in tests
const cwi = "CWI";
const cwiRequiredDocs = [
"pre_photo",
"mid_photo",
"post_photo",
"pre_installation_building_inspection",
"claim_of_compliance",
"insurance_guarantee",
"workmanship_warranty",
];
it('is "all" when every measure has all required install docs uploaded', () => {
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi });
const installDocs = cwiRequiredDocs.map((ft) => ({
fileType: ft,
measureName: cwi,
}));
const docs = new Map([["deal-1", installDocs]]);
const result = computeDocStatusMap([deal], docs, {});
expect(result["deal-1"].installStatus).toBe("all");
});
it('is "partial" when some (but not all) measures have docs uploaded', () => {
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi });
const partialInstallDocs = [{ fileType: "pre_photo", measureName: cwi }];
const docs = new Map([["deal-1", partialInstallDocs]]);
const result = computeDocStatusMap([deal], docs, {});
expect(result["deal-1"].installStatus).toBe("partial");
});
it('is "hasDocs" when install docs exist but no measures are defined on the deal', () => {
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: null });
const installDocs = [{ fileType: "pre_photo", measureName: null }];
const docs = new Map([["deal-1", installDocs]]);
const result = computeDocStatusMap([deal], docs, {});
expect(result["deal-1"].installStatus).toBe("hasDocs");
});
it('is "none" when measures are defined but no install docs have been uploaded', () => {
const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi });
// Only survey docs — no install docs
const docs = new Map([["deal-1", allSurveyDocs]]);
const result = computeDocStatusMap([deal], docs, {});
expect(result["deal-1"].installStatus).toBe("none");
});
});
});

View file

@ -0,0 +1,152 @@
import { inArray } from "drizzle-orm";
import { db } from "@/app/db/db";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import {
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
SURVEY_ALL_DOC_TYPES,
} from "./types";
import type {
HubspotDeal,
DocStatus,
DocStatusMap,
MeasureDocProgress,
ApprovalsByDeal,
} from "./types";
type DocEntry = { fileType: string; measureName: string | null };
export async function fetchDocsByDealId(
deals: HubspotDeal[],
dealIds: string[],
): Promise<Map<string, DocEntry[]>> {
const docsByDealId = new Map<string, DocEntry[]>();
if (dealIds.length === 0) return docsByDealId;
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.hubsotDealId, dealIds));
for (const row of phase1Rows) {
if (!row.hubsotDealId || row.fileType === null) continue;
if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []);
docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
// Phase 2: UPRN fallback for deals that returned no results in phase 1
const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId));
const fallbackUprns = dealsWithoutDocs
.map((d) => d.uprn)
.filter((u): u is string => !!u)
.map((u) => {
try { return BigInt(u); } catch { return null; }
})
.filter((u): u is bigint => u !== null);
if (fallbackUprns.length > 0) {
const phase2Rows = await db
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, fallbackUprns));
const uprnToDealId = new Map<string, string>(
dealsWithoutDocs
.filter((d) => d.uprn)
.map((d) => {
try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; }
catch { return null; }
})
.filter((e): e is [string, string] => e !== null),
);
for (const row of phase2Rows) {
if (row.uprn === null || row.fileType === null) continue;
const dealId = uprnToDealId.get(String(row.uprn));
if (!dealId) continue;
if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []);
docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
return docsByDealId;
}
export function computeDocStatusMap(
deals: HubspotDeal[],
docsByDealId: Map<string, Array<{ fileType: string; measureName: string | null }>>,
approvalsByDeal: ApprovalsByDeal,
): DocStatusMap {
const measuresByDealId = new Map<string, string[]>();
for (const deal of deals) {
const approved = approvalsByDeal[deal.dealId] ?? [];
const measures =
approved.length > 0
? approved
: (deal.proposedMeasures ?? "")
.split(",")
.map((m) => m.trim())
.filter(Boolean);
measuresByDealId.set(deal.dealId, measures);
}
const docStatusMap: DocStatusMap = {};
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));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByDealId.get(dealId) ?? [];
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
docStatusMap[dealId] = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) =>
surveyTypeSet.has(t),
),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
};
}
return docStatusMap;
}

View file

@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { filterMeasureRows } from "./measureFilters";
import type { ClassifiedDeal } from "./types";
function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
return {
id: "1",
dealId: "deal-1",
dealname: "1 Test Street",
dealstage: null,
companyId: null,
projectCode: null,
landlordPropertyId: "LP001",
uprn: null,
outcome: null,
outcomeNotes: null,
majorConditionIssueDescription: null,
majorConditionIssuePhotos: null,
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
dampMouldAndRepairComments: null,
preSapScore: null,
coordinator: null,
ioeV1Date: null,
ioeV2Date: null,
ioeV3Date: null,
proposedMeasures: "Loft insulation;CWI",
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,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
pibiCompletedDate: null,
propertyHaltedDate: null,
propertyHaltedReason: null,
technicalApprovedMeasuresForInstall: "Solar PV",
domnaSurveyType: null,
domnaSurveyDate: null,
createdAt: new Date(),
updatedAt: new Date(),
displayStage: "Coordination in Progress",
...overrides,
};
}
describe("filterMeasureRows", () => {
it("returns all rows when search is empty", () => {
const deals = [makeDeal(), makeDeal({ dealId: "deal-2" })];
expect(filterMeasureRows(deals, {}, "")).toEqual(deals);
});
it("matches by address (dealname)", () => {
const match = makeDeal({ dealname: "12 Oak Lane" });
const noMatch = makeDeal({ dealId: "deal-2", dealname: "5 Elm Street" });
const result = filterMeasureRows([match, noMatch], {}, "oak");
expect(result).toEqual([match]);
});
it("matches by landlordPropertyId", () => {
const match = makeDeal({ landlordPropertyId: "LP999" });
const noMatch = makeDeal({ dealId: "deal-2", landlordPropertyId: "LP001" });
const result = filterMeasureRows([match, noMatch], {}, "lp999");
expect(result).toEqual([match]);
});
it("matches by proposed measure name", () => {
const match = makeDeal({ proposedMeasures: "ASHP;Loft insulation" });
const noMatch = makeDeal({ dealId: "deal-2", proposedMeasures: "CWI" });
const result = filterMeasureRows([match, noMatch], {}, "ashp");
expect(result).toEqual([match]);
});
it("matches by instructed measure name", () => {
const match = makeDeal({ dealId: "deal-1" });
const noMatch = makeDeal({ dealId: "deal-2", proposedMeasures: "CWI" });
const instructedByDeal = { "deal-1": ["EWI"] };
const result = filterMeasureRows([match, noMatch], instructedByDeal, "ewi");
expect(result).toEqual([match]);
});
it("matches by technically approved measure name", () => {
const match = makeDeal({ technicalApprovedMeasuresForInstall: "Battery" });
const noMatch = makeDeal({ dealId: "deal-2", technicalApprovedMeasuresForInstall: null });
const result = filterMeasureRows([match, noMatch], {}, "battery");
expect(result).toEqual([match]);
});
it("is case-insensitive", () => {
const deal = makeDeal({ proposedMeasures: "Loft insulation" });
expect(filterMeasureRows([deal], {}, "LOFT")).toEqual([deal]);
expect(filterMeasureRows([deal], {}, "loft insulation")).toEqual([deal]);
});
it("returns empty when nothing matches", () => {
const deal = makeDeal({ dealname: "1 Test St", proposedMeasures: "CWI" });
expect(filterMeasureRows([deal], {}, "ASHP")).toEqual([]);
});
});

View file

@ -0,0 +1,27 @@
import { parseMeasures } from "@/app/lib/parseMeasures";
import type { ClassifiedDeal } from "./types";
export function filterMeasureRows(
deals: ClassifiedDeal[],
instructedByDeal: Record<string, string[]>,
search: string,
): ClassifiedDeal[] {
const q = search.toLowerCase().trim();
if (!q) return deals;
return deals.filter((deal) => {
if (deal.dealname?.toLowerCase().includes(q)) return true;
if (deal.landlordPropertyId?.toLowerCase().includes(q)) return true;
const proposed = parseMeasures(deal.proposedMeasures);
if (proposed.some((m) => m.toLowerCase().includes(q))) return true;
const instructed = instructedByDeal[deal.dealId] ?? [];
if (instructed.some((m) => m.toLowerCase().includes(q))) return true;
const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall);
if (techApproved.some((m) => m.toLowerCase().includes(q))) return true;
return false;
});
}

View file

@ -4,93 +4,31 @@ 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 { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { alias } from "drizzle-orm/pg-core";
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "./dealQuery";
import type { DealRow } from "./dealQuery";
import { organisation } from "@/app/db/schema/organisation";
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
import {
portfolioCapabilities,
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import type { InferSelectModel } from "drizzle-orm";
import type {
PortfolioCapabilityType,
ApprovalsByDeal,
InstructedMeasuresByDeal,
RemovalStatusByDeal,
} from "./types";
import { computeRemovalStatusByDeal } from "./removalState";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
const designerUser = alias(hubspotUsers, "designer_user");
type DealRow = {
deal: InferSelectModel<typeof hubspotDealData>;
coordinator: string | null;
designer: string | null;
};
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
const d = row.deal;
return {
id: d.id,
dealId: d.dealId,
dealname: d.dealname,
dealstage: d.dealstage,
companyId: d.companyId,
projectCode: d.projectCode,
landlordPropertyId: d.landlordPropertyId,
uprn: d.uprn,
outcome: d.outcome,
outcomeNotes: d.outcomeNotes,
majorConditionIssueDescription: d.majorConditionIssueDescription,
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
coordinationStatus: d.coordinationStatus,
designStatus: d.designStatus,
pashubLink: d.pashubLink,
sharepointLink: d.sharepointLink,
dampMouldFlag: d.dampmouldGrowth,
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
preSapScore: d.preSap,
coordinator: row.coordinator,
ioeV1Date: d.mtpCompletionDate,
ioeV2Date: d.mtpReModelCompletionDate,
ioeV3Date: d.ioeV3CompletionDate,
proposedMeasures: d.proposedMeasures,
approvedPackage: d.approvedPackage,
designer: row.designer,
designDate: d.designCompletionDate,
actualMeasuresInstalled: d.actualMeasuresInstalled,
installer: d.installer,
installerHandover: d.installerHandover,
lodgementStatus: d.lodgementStatus,
measuresLodgementDate: d.measuresLodgementDate,
fullLodgementDate: d.lodgementDate,
confirmedSurveyDate: d.confirmedSurveyDate,
confirmedSurveyTime: d.confirmedSurveyTime,
surveyedDate: d.surveyedDate,
designType: d.dealType,
eiScore: d.eiScore,
eiScorePotential: d.eiScorePotential,
epcSapScore: d.epcSapScore,
epcSapScorePotential: d.epcSapScorePotential,
// New per-deal workflow fields
surveyType: d.surveyType,
measuresForPibiOrdered: d.measuresForPibiOrdered,
pibiOrderDate: d.pibiOrderDate,
pibiCompletedDate: d.pibiCompletedDate,
propertyHaltedDate: d.propertyHaltedDate,
propertyHaltedReason: d.propertyHaltedReason,
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
domnaSurveyType: d.domnaSurveyType,
domnaSurveyDate: d.domnaSurveyDate,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
};
}
export default async function LiveReportingPage(props: {
params: Promise<{ slug: string }>;
}) {
@ -101,17 +39,21 @@ export default async function LiveReportingPage(props: {
redirect("/");
}
// Look up the linked organisation for this portfolio
// Look up all linked organisations for this portfolio
const link = await db
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
.from(portfolioOrganisation)
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
.limit(1);
.innerJoin(
organisation,
eq(portfolioOrganisation.organisationId, organisation.id),
)
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
const pageHeader = (
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">Live Projects</header>
<header className="text-3xl font-semibold text-brandblue">
Live Projects
</header>
<p className="text-sm text-gray-500">
{`Check in on your projects' progress with real-time data updates.`}
</p>
@ -119,7 +61,11 @@ export default async function LiveReportingPage(props: {
</div>
);
if (!link.length || !link[0].hubspotCompanyId) {
const companyIds = link
.map((l) => l.hubspotCompanyId)
.filter((id): id is string => !!id);
if (companyIds.length === 0) {
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
{pageHeader}
@ -129,10 +75,13 @@ export default async function LiveReportingPage(props: {
<Building2 className="h-8 w-8 text-brandblue/50" />
</div>
<div>
<p className="text-base font-semibold text-gray-700">No organisation linked</p>
<p className="text-base font-semibold text-gray-700">
No organisation linked
</p>
<p className="text-sm text-gray-400 mt-1 max-w-sm">
A Domna administrator needs to connect this portfolio to an organisation in{" "}
<strong>Portfolio Settings</strong> before live tracking data can be displayed.
A Domna administrator needs to connect this portfolio to an
organisation in <strong>Portfolio Settings</strong> before live
tracking data can be displayed.
</p>
</div>
</CardContent>
@ -141,70 +90,77 @@ export default async function LiveReportingPage(props: {
);
}
const companyId = link[0].hubspotCompanyId;
const rawDeals = await db
.select({
deal: hubspotDealData,
coordinator: sql<string | null>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
designer: sql<string | null>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
coordinator: sql<
string | null
>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
designer: sql<
string | null
>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
})
.from(hubspotDealData)
.leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId))
.leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId))
.where(eq(hubspotDealData.companyId, companyId));
.leftJoin(
coordinatorUser,
eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId),
)
.leftJoin(
designerUser,
eq(hubspotDealData.designer, designerUser.hubspotOwnerId),
)
.where(inArray(hubspotDealData.companyId, companyIds));
const deals = rawDeals.map(mapDbRowToHubspotDeal);
const trackerData = computeLiveTrackerData(deals);
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
let userCapability: PortfolioCapabilityType = [];
const userEmail = user?.user?.email;
// Single user lookup shared by capability and role queries
let userId: bigint | null = null;
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
.limit(1);
userId = userRow[0]?.id ?? null;
}
if (userRow[0]) {
const capRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
userCapability = capRows
.map((r) => r.capability)
.filter((c): c is "approver" | "contractor" => c === "approver" || c === "contractor");
}
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
let userCapability: PortfolioCapabilityType = [];
if (userId) {
const capRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userId),
),
);
userCapability = capRows
.map((r) => r.capability)
.filter(
(c): c is "approver" | "contractor" =>
c === "approver" || c === "contractor",
);
}
// Fetch current user's portfolio role (creator / admin / write / read)
let userRole = "read";
if (userEmail) {
const userRow = await db
.select({ id: userTable.id })
.from(userTable)
.where(eq(userTable.email, userEmail))
if (userId) {
const roleRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userId),
),
)
.limit(1);
if (userRow[0]) {
const roleRow = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
eq(portfolioUsers.userId, userRow[0].id),
),
)
.limit(1);
userRole = roleRow[0]?.role ?? "read";
}
userRole = roleRow[0]?.role ?? "read";
}
// Fetch currently approved measures for all deals in scope
@ -229,8 +185,27 @@ export default async function LiveReportingPage(props: {
}
}
// Compute effective removal state per deal
const removalStatusByDeal: RemovalStatusByDeal = {};
// Fetch instructed measures for all deals in scope
const instructedMeasuresByDeal: InstructedMeasuresByDeal = {};
if (dealIds.length > 0) {
const instructedRows = await db
.select({
hubspotDealId: userDefinedDealMeasures.hubspotDealId,
measureName: userDefinedDealMeasures.measureName,
})
.from(userDefinedDealMeasures)
.where(
and(
inArray(userDefinedDealMeasures.hubspotDealId, dealIds),
eq(userDefinedDealMeasures.source, "instructed"),
),
);
for (const row of instructedRows) {
(instructedMeasuresByDeal[row.hubspotDealId] ??= []).push(row.measureName);
}
}
const removalRows = await db
.select({
hubspotDealId: propertyRemovalRequests.hubspotDealId,
@ -241,139 +216,10 @@ export default async function LiveReportingPage(props: {
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
.orderBy(desc(propertyRemovalRequests.requestedAt));
// Keep only the most recent row per deal, then derive effective state
const seenDeals = new Set<string>();
for (const row of removalRows) {
if (seenDeals.has(row.hubspotDealId)) continue;
seenDeals.add(row.hubspotDealId);
let state: EffectiveRemovalState = "none";
if (row.status === "pending") {
state = row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
} else if (row.type === "removal" && row.status === "approved") {
state = "removed";
} else if (row.type === "re_addition" && row.status === "declined") {
state = "removed";
}
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
}
const removalStatusByDeal: RemovalStatusByDeal = computeRemovalStatusByDeal(removalRows);
// Fetch document status for all deals — two-phase strategy:
// Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data)
// Phase 2: UPRN fallback only for deals that returned no results in phase 1
const docsByDealId = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
if (dealIds.length > 0) {
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.hubsotDealId, dealIds));
for (const row of phase1Rows) {
if (!row.hubsotDealId || row.fileType === null) continue;
if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []);
docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Phase 2: for deals with no docs from phase 1 that have a UPRN, try UPRN lookup
const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId));
const fallbackUprns = dealsWithoutDocs
.map((d) => d.uprn)
.filter((u): u is string => !!u)
.map((u) => { try { return BigInt(u); } catch { return null; } })
.filter((u): u is bigint => u !== null);
if (fallbackUprns.length > 0) {
const phase2Rows = await db
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, fallbackUprns));
// Map phase 2 UPRN results back to dealId
const uprnToDealId = new Map<string, string>(
dealsWithoutDocs
.filter((d) => d.uprn)
.map((d) => {
try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; } catch { return null; }
})
.filter((e): e is [string, string] => e !== null),
);
for (const row of phase2Rows) {
if (row.uprn === null || row.fileType === null) continue;
const dealId = uprnToDealId.get(String(row.uprn));
if (!dealId) continue;
if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []);
docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Build measures lookup by dealId (approved measures, falling back to proposed)
const measuresByDealId = new Map<string, string[]>();
for (const deal of deals) {
const approved = approvalsByDeal[deal.dealId] ?? [];
const measures = approved.length > 0
? approved
: (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByDealId.set(deal.dealId, measures);
}
// Build docStatusMap keyed by dealId
const docStatusMap: DocStatusMap = {};
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));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByDealId.get(dealId) ?? [];
// Compute per-measure document progress against the requirements matrix
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
docStatusMap[dealId] = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
};
}
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal);
return (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
@ -383,6 +229,7 @@ export default async function LiveReportingPage(props: {
docStatusMap={docStatusMap}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
instructedMeasuresByDeal={instructedMeasuresByDeal}
removalStatusByDeal={removalStatusByDeal}
portfolioId={portfolioId}
userRole={userRole}

View file

@ -0,0 +1,137 @@
import { describe, it, expect } from "vitest";
import {
splitDocumentsByType,
getMissingRetrofitTypes,
getUnassignedInstallDocs,
} from "./propertyDocuments";
import type { PropertyDocument, MeasureDocProgress } from "./types";
function makeDoc(overrides: Partial<PropertyDocument> = {}): PropertyDocument {
return {
id: "1",
s3FileKey: "key",
s3FileBucket: "bucket",
docType: "photo_pack",
s3UploadTimestamp: "2024-01-01T00:00:00Z",
uprn: null,
landlordPropertyId: null,
measureName: null,
...overrides,
};
}
function makeMeasureProgress(overrides: Partial<MeasureDocProgress> = {}): MeasureDocProgress {
return {
measureName: "ASHP",
required: ["pre_photo", "post_photo"],
uploaded: [],
isComplete: false,
uploadedCount: 0,
requiredCount: 2,
...overrides,
};
}
describe("splitDocumentsByType", () => {
it("puts survey doc types in retrofitDocs", () => {
const doc = makeDoc({ docType: "photo_pack" });
const { retrofitDocs, installDocs } = splitDocumentsByType([doc]);
expect(retrofitDocs).toHaveLength(1);
expect(installDocs).toHaveLength(0);
});
it("puts install doc types in installDocs", () => {
const doc = makeDoc({ docType: "pre_photo" });
const { retrofitDocs, installDocs } = splitDocumentsByType([doc]);
expect(retrofitDocs).toHaveLength(0);
expect(installDocs).toHaveLength(1);
});
it("includes optional ecmk types in retrofitDocs", () => {
const doc = makeDoc({ docType: "ecmk_site_note" });
const { retrofitDocs } = splitDocumentsByType([doc]);
expect(retrofitDocs).toHaveLength(1);
});
it("splits a mixed list correctly", () => {
const docs = [
makeDoc({ id: "1", docType: "photo_pack" }),
makeDoc({ id: "2", docType: "pre_photo" }),
makeDoc({ id: "3", docType: "site_note" }),
makeDoc({ id: "4", docType: "post_photo" }),
];
const { retrofitDocs, installDocs } = splitDocumentsByType(docs);
expect(retrofitDocs).toHaveLength(2);
expect(installDocs).toHaveLength(2);
});
it("returns empty arrays for empty input", () => {
const { retrofitDocs, installDocs } = splitDocumentsByType([]);
expect(retrofitDocs).toHaveLength(0);
expect(installDocs).toHaveLength(0);
});
});
describe("getMissingRetrofitTypes", () => {
it("returns all mandatory types when no docs uploaded", () => {
const missing = getMissingRetrofitTypes([]);
expect(missing).toHaveLength(9);
});
it("excludes types that have been uploaded", () => {
const uploaded = [makeDoc({ docType: "photo_pack" })];
const missing = getMissingRetrofitTypes(uploaded);
expect(missing).not.toContain("photo_pack");
expect(missing).toHaveLength(8);
});
it("returns empty array when all mandatory types uploaded", () => {
const uploaded = [
"photo_pack", "site_note", "rd_sap_site_note", "pas_2023_ventilation",
"pas_2023_condition", "pas_significance", "par_photo_pack",
"pas_2023_property", "pas_2023_occupancy",
].map((docType, i) => makeDoc({ id: String(i), docType }));
expect(getMissingRetrofitTypes(uploaded)).toHaveLength(0);
});
it("does not count ecmk types as mandatory", () => {
const uploaded = [makeDoc({ docType: "ecmk_site_note" })];
const missing = getMissingRetrofitTypes(uploaded);
expect(missing).not.toContain("ecmk_site_note");
expect(missing).toHaveLength(9);
});
});
describe("getUnassignedInstallDocs", () => {
it("returns docs with no measureName", () => {
const doc = makeDoc({ docType: "pre_photo", measureName: null });
const result = getUnassignedInstallDocs([doc], [makeMeasureProgress()]);
expect(result).toContain(doc);
});
it("returns docs whose measureName is not in measureProgress", () => {
const doc = makeDoc({ docType: "pre_photo", measureName: "SolarPV" });
const progress = [makeMeasureProgress({ measureName: "ASHP" })];
const result = getUnassignedInstallDocs([doc], progress);
expect(result).toContain(doc);
});
it("excludes docs assigned to a known measure", () => {
const doc = makeDoc({ docType: "pre_photo", measureName: "ASHP" });
const progress = [makeMeasureProgress({ measureName: "ASHP" })];
const result = getUnassignedInstallDocs([doc], progress);
expect(result).toHaveLength(0);
});
it("returns empty array when all docs are assigned", () => {
const docs = [
makeDoc({ id: "1", docType: "pre_photo", measureName: "ASHP" }),
makeDoc({ id: "2", docType: "post_photo", measureName: "CWI" }),
];
const progress = [
makeMeasureProgress({ measureName: "ASHP" }),
makeMeasureProgress({ measureName: "CWI" }),
];
expect(getUnassignedInstallDocs(docs, progress)).toHaveLength(0);
});
});

View file

@ -0,0 +1,28 @@
import {
SURVEY_ALL_DOC_TYPES,
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
} from "./types";
import type { PropertyDocument, MeasureDocProgress } from "./types";
export function splitDocumentsByType(docs: PropertyDocument[]): {
retrofitDocs: PropertyDocument[];
installDocs: PropertyDocument[];
} {
return {
retrofitDocs: docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType)),
installDocs: docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType)),
};
}
export function getMissingRetrofitTypes(retrofitDocs: PropertyDocument[]): string[] {
const present = new Set(retrofitDocs.map((d) => d.docType));
return EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter((t) => !present.has(t));
}
export function getUnassignedInstallDocs(
installDocs: PropertyDocument[],
measureProgress: MeasureDocProgress[],
): PropertyDocument[] {
const knownMeasures = new Set(measureProgress.map((m) => m.measureName));
return installDocs.filter((d) => !d.measureName || !knownMeasures.has(d.measureName));
}

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { deriveEffectiveRemovalState, computeRemovalStatusByDeal } from "./removalState";
describe("deriveEffectiveRemovalState", () => {
it("pending removal → pending_removal", () => {
expect(deriveEffectiveRemovalState({ type: "removal", status: "pending" })).toBe("pending_removal");
});
it("pending re_addition → pending_re_addition", () => {
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "pending" })).toBe("pending_re_addition");
});
it("approved removal → removed", () => {
expect(deriveEffectiveRemovalState({ type: "removal", status: "approved" })).toBe("removed");
});
it("declined re_addition → removed (re-addition refused means still out)", () => {
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "declined" })).toBe("removed");
});
it("approved re_addition → none (back in, no active removal state)", () => {
expect(deriveEffectiveRemovalState({ type: "re_addition", status: "approved" })).toBe("none");
});
it("declined removal → none (removal refused, property stays in)", () => {
expect(deriveEffectiveRemovalState({ type: "removal", status: "declined" })).toBe("none");
});
});
describe("computeRemovalStatusByDeal", () => {
it("returns empty map for no rows", () => {
expect(computeRemovalStatusByDeal([])).toEqual({});
});
it("maps a single active removal row", () => {
const rows = [{ hubspotDealId: "d1", type: "removal", status: "pending" }];
expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "pending_removal" });
});
it("omits deals whose most recent state is none", () => {
const rows = [{ hubspotDealId: "d1", type: "removal", status: "declined" }];
expect(computeRemovalStatusByDeal(rows)).toEqual({});
});
it("deduplicates by deal — first row wins (most recent, caller provides DESC order)", () => {
const rows = [
{ hubspotDealId: "d1", type: "removal", status: "approved" }, // most recent
{ hubspotDealId: "d1", type: "removal", status: "pending" }, // older, ignored
];
expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "removed" });
});
it("handles multiple deals independently", () => {
const rows = [
{ hubspotDealId: "d1", type: "removal", status: "pending" },
{ hubspotDealId: "d2", type: "re_addition", status: "pending" },
{ hubspotDealId: "d3", type: "re_addition", status: "approved" },
];
expect(computeRemovalStatusByDeal(rows)).toEqual({
d1: "pending_removal",
d2: "pending_re_addition",
});
});
});

View file

@ -0,0 +1,31 @@
import type { EffectiveRemovalState, RemovalStatusByDeal } from "./types";
export function deriveEffectiveRemovalState(row: {
type: string;
status: string;
}): EffectiveRemovalState {
if (row.status === "pending") {
return row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
}
if (row.type === "removal" && row.status === "approved") return "removed";
if (row.type === "re_addition" && row.status === "declined") return "removed";
return "none";
}
// Rows must be ordered by requestedAt DESC so the first row per deal is the most recent.
export function computeRemovalStatusByDeal(
rows: { hubspotDealId: string; type: string; status: string }[],
): RemovalStatusByDeal {
const result: RemovalStatusByDeal = {};
const seen = new Set<string>();
for (const row of rows) {
if (seen.has(row.hubspotDealId)) continue;
seen.add(row.hubspotDealId);
const state = deriveEffectiveRemovalState(row);
if (state !== "none") result[row.hubspotDealId] = state;
}
return result;
}

View file

@ -0,0 +1,525 @@
import { describe, it, expect } from "vitest";
import {
resolveDisplayStage,
classifyDeals,
computeDampMouldRisk,
computeFunnelStages,
computeProjectProgress,
computeOutcomeSlices,
computeLiveTrackerData,
} from "./transforms";
import type { HubspotDeal, ClassifiedDeal } 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,
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,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
pibiCompletedDate: null,
propertyHaltedDate: null,
propertyHaltedReason: null,
technicalApprovedMeasuresForInstall: null,
domnaSurveyType: null,
domnaSurveyDate: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeClassified(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
return { ...makeDeal(overrides), displayStage: "Scope & Planning", ...overrides };
}
// -----------------------------------------------------------------------
// resolveDisplayStage
// -----------------------------------------------------------------------
describe("resolveDisplayStage — direct STAGE_ID_MAP lookups", () => {
it("returns Scope & Planning for a backlog stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "1617223910" }))).toBe("Scope & Planning");
});
it("returns Booking in Progress for a bookings stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "3589581001" }))).toBe("Booking in Progress");
});
it("returns Assessment in Progress for a survey-in-progress stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "1617223913" }))).toBe("Assessment in Progress");
});
it("returns Queries for a known queries stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "2663668937" }))).toBe("Queries");
});
it("falls through to AFTER_ASSESSMENT path when dealstage is null", () => {
// null dealstage → AFTER_ASSESSMENT → no coord/design status → Coordination in Progress
expect(resolveDisplayStage(makeDeal({ dealstage: null }))).toBe("Coordination in Progress");
});
it("falls through to AFTER_ASSESSMENT path for an unrecognised stage ID", () => {
expect(resolveDisplayStage(makeDeal({ dealstage: "9999999999" }))).toBe("Coordination in Progress");
});
});
describe("resolveDisplayStage — RA ISSUE override on non-AFTER_ASSESSMENT stages", () => {
it("overrides Scope & Planning to Queries when coordinationStatus is RA ISSUE", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "1617223910", coordinationStatus: "RA ISSUE" }))
).toBe("Queries");
});
it("overrides Assessment in Progress to Queries when coordinationStatus is RA ISSUE", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "RA ISSUE" }))
).toBe("Queries");
});
it("is case-insensitive for RA ISSUE check", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "ra issue" }))
).toBe("Queries");
});
it("does NOT override Booking in Progress with RA ISSUE (only Scope & Planning and Assessment in Progress)", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: "3589581001", coordinationStatus: "RA ISSUE" }))
).toBe("Booking in Progress");
});
});
describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
it("returns Queries when coordinationStatus is RA ISSUE", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: "RA ISSUE" }))
).toBe("Queries");
});
it("returns Coordination in Progress by default (no special status)", () => {
expect(
resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: null, designStatus: null }))
).toBe("Coordination in Progress");
});
it("returns Design in Progress when coord is IOE/MTP COMPLETE but design not yet uploaded", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V1) IOE/MTP COMPLETE",
designStatus: "IN PROGRESS",
}))
).toBe("Design in Progress");
});
it("returns Design in Progress for V2 IOE/MTP COMPLETE variant", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V2) IOE/MTP COMPLETE",
designStatus: null,
}))
).toBe("Design in Progress");
});
it("returns Design in Progress for V3 IOE/MTP COMPLETE variant", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(V3) IOE/MTP COMPLETE",
designStatus: null,
}))
).toBe("Design in Progress");
});
it("is case-insensitive for coord/design status checks", () => {
expect(
resolveDisplayStage(makeDeal({
dealstage: AFTER_ASSESSMENT_STAGE,
coordinationStatus: "(v1) ioe/mtp complete",
designStatus: "uploaded",
}))
).toBe("Installation in Progress"); // POST_DESIGN, no install fields set
});
});
describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => {
const AFTER_ASSESSMENT_STAGE = "3948185842";
function makePostDesignDeal(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 Installation Complete when actualMeasuresInstalled is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" }))
).toBe("Installation Complete");
});
it("returns Installation Complete when installerHandover is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" }))
).toBe("Installation Complete");
});
it("returns At Lodgement when lodgementStatus is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" }))
).toBe("At Lodgement");
});
it("returns At Post Survey when measuresLodgementDate is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() }))
).toBe("At Post Survey");
});
it("returns Project Complete when fullLodgementDate is set", () => {
expect(
resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() }))
).toBe("Project Complete");
});
it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
fullLodgementDate: new Date(),
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
).toBe("Project Complete");
});
it("measuresLodgementDate takes precedence over lodgementStatus", () => {
expect(
resolveDisplayStage(makePostDesignDeal({
measuresLodgementDate: new Date(),
lodgementStatus: "Submitted",
}))
).toBe("At Post Survey");
});
});
// -----------------------------------------------------------------------
// classifyDeals
// -----------------------------------------------------------------------
describe("classifyDeals", () => {
it("adds displayStage to each deal", () => {
const deals = [
makeDeal({ dealstage: "1617223910" }),
makeDeal({ dealstage: "1617223913" }),
];
const result = classifyDeals(deals);
expect(result[0].displayStage).toBe("Scope & Planning");
expect(result[1].displayStage).toBe("Assessment in Progress");
});
it("returns an empty array for no deals", () => {
expect(classifyDeals([])).toEqual([]);
});
it("preserves all original deal fields", () => {
const deal = makeDeal({ dealId: "abc-123", dealname: "Test" });
const [result] = classifyDeals([deal]);
expect(result.dealId).toBe("abc-123");
expect(result.dealname).toBe("Test");
});
});
// -----------------------------------------------------------------------
// computeDampMouldRisk
// -----------------------------------------------------------------------
describe("computeDampMouldRisk", () => {
it("counts survey flags from majorConditionIssuePhotosS3", () => {
const deals = [
makeClassified({ majorConditionIssuePhotosS3: "s3://bucket/file.jpg" }),
makeClassified({ majorConditionIssuePhotosS3: null }),
];
const result = computeDampMouldRisk(deals);
expect(result.surveyFlagCount).toBe(1);
});
it("counts coordinator flags from dampMouldFlag", () => {
const deals = [
makeClassified({ dampMouldFlag: "Yes" }),
makeClassified({ dampMouldFlag: null }),
makeClassified({ dampMouldFlag: "Yes" }),
];
const result = computeDampMouldRisk(deals);
expect(result.coordinatorFlagCount).toBe(2);
});
it("counts deals flagged at both stages independently", () => {
const deals = [
makeClassified({ majorConditionIssuePhotosS3: "s3://x", dampMouldFlag: "Yes" }),
makeClassified({ majorConditionIssuePhotosS3: "s3://y", dampMouldFlag: null }),
makeClassified({ majorConditionIssuePhotosS3: null, dampMouldFlag: "Yes" }),
];
const result = computeDampMouldRisk(deals);
expect(result.surveyFlagCount).toBe(2);
expect(result.coordinatorFlagCount).toBe(2);
expect(result.bothFlaggedCount).toBe(1);
});
it("returns zero counts for empty deals", () => {
const result = computeDampMouldRisk([]);
expect(result.surveyFlagCount).toBe(0);
expect(result.coordinatorFlagCount).toBe(0);
expect(result.bothFlaggedCount).toBe(0);
expect(result.totalDeals).toBe(0);
});
});
// -----------------------------------------------------------------------
// computeFunnelStages
// -----------------------------------------------------------------------
describe("computeFunnelStages", () => {
it("excludes Queries deals from counts and percentages", () => {
const deals = [
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Queries" }),
];
const funnel = computeFunnelStages(deals);
const scopeEntry = funnel.find((f) => f.stage === "Scope & Planning")!;
expect(scopeEntry.currentPct).toBe(100); // 1 out of 1 non-query
});
it("returns 0 percentages when all deals are Queries", () => {
const deals = [makeClassified({ displayStage: "Queries" })];
const funnel = computeFunnelStages(deals);
funnel.forEach((f) => {
expect(f.currentPct).toBe(0);
expect(f.cumulativePct).toBe(0);
});
});
it("cumulative count includes all deals at or beyond a given stage", () => {
const deals = [
makeClassified({ displayStage: "Installation in Progress" }),
makeClassified({ displayStage: "Installation Complete" }),
makeClassified({ displayStage: "Project Complete" }),
];
const funnel = computeFunnelStages(deals);
const installEntry = funnel.find((f) => f.stage === "Installation in Progress")!;
// All 3 deals are at or beyond "Installation in Progress"
expect(installEntry.cumulativeCount).toBe(3);
expect(installEntry.currentCount).toBe(1);
});
it("returns an entry for every stage in STAGE_ORDER", () => {
const funnel = computeFunnelStages([]);
expect(funnel.length).toBe(10); // STAGE_ORDER has 10 entries
});
});
// -----------------------------------------------------------------------
// computeProjectProgress
// -----------------------------------------------------------------------
describe("computeProjectProgress", () => {
it("excludes Queries from nonQueryTotal but includes them in totalDeals", () => {
const deals = [
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Queries" }),
];
const result = computeProjectProgress(deals);
expect(result.totalDeals).toBe(2);
expect(result.nonQueryTotal).toBe(1);
expect(result.queriesDeals).toHaveLength(1);
});
it("computes completedPercentage as fraction of non-query total", () => {
const deals = [
makeClassified({ displayStage: "Project Complete" }),
makeClassified({ displayStage: "Project Complete" }),
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Queries" }), // excluded from denominator
];
const result = computeProjectProgress(deals);
expect(result.completedCount).toBe(2);
expect(result.completedPercentage).toBeCloseTo(66.67, 1);
expect(result.nonQueryTotal).toBe(3);
});
it("returns 0 percentages when there are no non-query deals", () => {
const deals = [makeClassified({ displayStage: "Queries" })];
const result = computeProjectProgress(deals);
expect(result.completedPercentage).toBe(0);
result.stageProgress.forEach((s) => expect(s.percentage).toBe(0));
});
it("returns correct stage counts in stageProgress", () => {
const deals = [
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Scope & Planning" }),
makeClassified({ displayStage: "Assessment in Progress" }),
];
const result = computeProjectProgress(deals);
const scopeItem = result.stageProgress.find((s) => s.stage === "Scope & Planning")!;
const assessItem = result.stageProgress.find((s) => s.stage === "Assessment in Progress")!;
expect(scopeItem.count).toBe(2);
expect(assessItem.count).toBe(1);
});
it("stageProgress does not include a Queries entry", () => {
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", () => {
const deals = [
makeClassified({ outcome: "Surveyed" }),
makeClassified({ outcome: "Surveyed" }),
makeClassified({ outcome: "UNKNOWN_VALUE" }),
makeClassified({ outcome: null }),
];
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([]);
});
});
// -----------------------------------------------------------------------
// computeLiveTrackerData
// -----------------------------------------------------------------------
describe("computeLiveTrackerData", () => {
it("returns a single project entry and no __ALL__ when all deals share one project code", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223913" }),
];
const result = computeLiveTrackerData(deals);
expect(result.projects).toHaveLength(1);
expect(result.projects[0].projectCode).toBe("PROJ-A");
});
it("prepends a synthetic __ALL__ entry when deals span multiple project codes", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }),
];
const result = computeLiveTrackerData(deals);
expect(result.projects[0].projectCode).toBe("__ALL__");
expect(result.projects).toHaveLength(3); // __ALL__ + PROJ-A + PROJ-B
});
it("__ALL__ progress covers all deals across project codes", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }),
makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }),
];
const result = computeLiveTrackerData(deals);
expect(result.projects[0].progress.totalDeals).toBe(2);
});
it("totalDeals is the count of all classified deals", () => {
const deals = [
makeDeal({ projectCode: "PROJ-A" }),
makeDeal({ projectCode: "PROJ-A" }),
makeDeal({ projectCode: "PROJ-A" }),
];
expect(computeLiveTrackerData(deals).totalDeals).toBe(3);
});
it("filters majorConditionDeals by the major condition stage ID", () => {
const deals = [
makeDeal({ dealstage: "3061261536" }), // MAJOR_CONDITION_STAGE_ID
makeDeal({ dealstage: "1617223910" }),
];
const result = computeLiveTrackerData(deals);
expect(result.majorConditionDeals).toHaveLength(1);
expect(result.majorConditionDeals[0].dealstage).toBe("3061261536");
});
it("groups deals without a projectCode under Unknown Project", () => {
const deals = [makeDeal({ projectCode: null })];
const result = computeLiveTrackerData(deals);
expect(result.projects[0].projectCode).toBe("Unknown Project");
});
it("returns empty projects and zero counts for no input deals", () => {
const result = computeLiveTrackerData([]);
expect(result.projects).toHaveLength(0);
expect(result.totalDeals).toBe(0);
expect(result.majorConditionDeals).toHaveLength(0);
});
});

View file

@ -11,7 +11,7 @@ import type {
ProjectData,
OutcomeSlice,
LiveTrackerProps,
WorkPhaseStats,
DampMouldRiskData,
FunnelStage,
} from "./types";
@ -224,106 +224,6 @@ export function computeProjectProgress(
const totalDeals = deals.length;
// Coordination phase:
// completed = Design in Progress + Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete
// in progress = Coordination in Progress
const coordCompletedDeals = deals.filter((d) =>
[
"Design in Progress",
"Installation in Progress",
"Installation Complete",
"At Lodgement",
"At Post Survey",
"Project Complete",
].includes(d.displayStage)
);
const coordInProgressDeals = deals.filter(
(d) => d.displayStage === "Coordination in Progress"
);
const coordination: WorkPhaseStats = {
completedDeals: coordCompletedDeals,
inProgressDeals: coordInProgressDeals,
completedCount: coordCompletedDeals.length,
inProgressCount: coordInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (coordCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0 ? (coordInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Design phase:
// completed = Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete
// in progress = Design in Progress
const designCompletedDeals = deals.filter((d) =>
[
"Installation in Progress",
"Installation Complete",
"At Lodgement",
"At Post Survey",
"Project Complete",
].includes(d.displayStage)
);
const designInProgressDeals = deals.filter(
(d) => d.displayStage === "Design in Progress"
);
const design: WorkPhaseStats = {
completedDeals: designCompletedDeals,
inProgressDeals: designInProgressDeals,
completedCount: designCompletedDeals.length,
inProgressCount: designInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (designCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0 ? (designInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Install phase:
// completed = At Lodgement + At Post Survey + Project Complete
// in progress = Installation Complete
const installCompletedDeals = deals.filter((d) =>
["At Lodgement", "At Post Survey", "Project Complete"].includes(d.displayStage)
);
const installInProgressDeals = deals.filter(
(d) => d.displayStage === "Installation Complete"
);
const install: WorkPhaseStats = {
completedDeals: installCompletedDeals,
inProgressDeals: installInProgressDeals,
completedCount: installCompletedDeals.length,
inProgressCount: installInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (installCompletedDeals.length / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0 ? (installInProgressDeals.length / totalDeals) * 100 : 0,
total: totalDeals,
};
// Lodgement phase:
// completed = At Post Survey + Project Complete
// in progress = At Lodgement
const lodgementInProgressDeals = deals.filter(
(d) => d.displayStage === "At Lodgement"
);
const lodgement: WorkPhaseStats = {
completedDeals,
inProgressDeals: lodgementInProgressDeals,
completedCount,
inProgressCount: lodgementInProgressDeals.length,
completedPercentage:
totalDeals > 0 ? (completedCount / totalDeals) * 100 : 0,
inProgressPercentage:
totalDeals > 0
? (lodgementInProgressDeals.length / totalDeals) * 100
: 0,
total: totalDeals,
};
return {
stageProgress,
queriesDeals,
@ -332,10 +232,6 @@ export function computeProjectProgress(
completedPercentage,
nonQueryTotal,
totalDeals,
coordination,
design,
install,
lodgement,
dampMouldRisk: computeDampMouldRisk(deals),
funnelStages: computeFunnelStages(deals),
};
@ -375,7 +271,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "instructedMeasuresByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -104,19 +104,6 @@ export type StageProgressItem = {
deals: ClassifiedDeal[];
};
// -----------------------------------------------------------------------
// Coordination/Design/Install/Lodgement summary card data
// -----------------------------------------------------------------------
export type WorkPhaseStats = {
completedDeals: ClassifiedDeal[];
inProgressDeals: ClassifiedDeal[];
completedCount: number;
inProgressCount: number;
completedPercentage: number; // out of ALL deals in project
inProgressPercentage: number;
total: number;
};
// -----------------------------------------------------------------------
// Damp & mould risk comparison (survey-stage vs coordination-stage flags)
// -----------------------------------------------------------------------
@ -151,10 +138,6 @@ export type ProjectProgressData = {
completedPercentage: number; // out of non-query total
nonQueryTotal: number;
totalDeals: number;
coordination: WorkPhaseStats;
design: WorkPhaseStats;
install: WorkPhaseStats;
lodgement: WorkPhaseStats;
dampMouldRisk: DampMouldRiskData;
funnelStages: FunnelStage[];
};
@ -207,6 +190,8 @@ export type RemovalRequest = {
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
export type InstructedMeasuresByDeal = Record<string, string[]>;
export type LiveTrackerProps = {
projects: ProjectData[];
totalDeals: number;
@ -214,6 +199,7 @@ export type LiveTrackerProps = {
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
instructedMeasuresByDeal: InstructedMeasuresByDeal;
removalStatusByDeal: RemovalStatusByDeal;
portfolioId: string;
userRole: string;

View file

@ -0,0 +1,16 @@
"use client";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal } from "./types";
export function StageBadge({ stage }: { stage: ClassifiedDeal["displayStage"] }) {
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
{stage}
</span>
);
}

37
src/app/utils.test.ts Normal file
View file

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { parsePreSap } from "./utils";
describe("parsePreSap", () => {
it("returns null for null", () => {
expect(parsePreSap(null)).toBeNull();
});
it("returns null for undefined", () => {
expect(parsePreSap(undefined)).toBeNull();
});
it("returns null for empty string", () => {
expect(parsePreSap("")).toBeNull();
});
it("parses new format with letter prefix", () => {
expect(parsePreSap("D55")).toEqual({ letter: "D", display: "D55" });
});
it("derives letter for legacy numeric-only format", () => {
expect(parsePreSap("56")).toEqual({ letter: "D", display: "D56" });
});
it("handles other EPC bands correctly", () => {
expect(parsePreSap("G10")).toEqual({ letter: "G", display: "G10" });
expect(parsePreSap("A95")).toEqual({ letter: "A", display: "A95" });
});
it("trims surrounding whitespace", () => {
expect(parsePreSap(" D55 ")).toEqual({ letter: "D", display: "D55" });
});
it("normalises letter to uppercase", () => {
expect(parsePreSap("d55")).toEqual({ letter: "D", display: "D55" });
});
});

View file

@ -132,6 +132,17 @@ export function sapToEpc(sapPoints: number | null): string {
}
}
// Handles "D55" (new format) and "56" (legacy numeric-only format)
export function parsePreSap(raw: string | null | undefined): { letter: string; display: string } | null {
if (!raw) return null;
const match = raw.trim().match(/^([A-Za-z]?)(\d+)$/);
if (!match) return null;
const num = parseInt(match[2], 10);
const letter = match[1] ? match[1].toUpperCase() : sapToEpc(num);
if (letter === "Unknown") return null;
return { letter, display: `${letter}${num}` };
}
export function formatDateTime(dateTimeString: string | Date): string {
// Create a new Date object
const dateTime = new Date(dateTimeString);

View file

@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
esbuild: {
jsx: "automatic",
},
test: {
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],