mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
Merge 1345f36d8d into 707df2dea9
This commit is contained in:
commit
e307f4ebab
77 changed files with 9468 additions and 3892 deletions
|
|
@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
286
cypress/e2e/live-tracking/pibi-section.cy.js
Normal file
286
cypress/e2e/live-tracking/pibi-section.cy.js
Normal 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
89
skills-lock.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/app/api/portfolio/[portfolioId]/approvals/route.test.ts
Normal file
289
src/app/api/portfolio/[portfolioId]/approvals/route.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,11 +6,15 @@ import {
|
||||||
} from "@/app/db/schema/approvals";
|
} from "@/app/db/schema/approvals";
|
||||||
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
|
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
|
||||||
import { user } from "@/app/db/schema/users";
|
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 { z } from "zod";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
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> {
|
async function getRequestingUserId(email: string): Promise<bigint | null> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|
@ -102,7 +106,7 @@ export async function GET(
|
||||||
.from(dealMeasureApprovalEvents)
|
.from(dealMeasureApprovalEvents)
|
||||||
.leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
|
.leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
|
||||||
.where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
|
.where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
|
||||||
.orderBy(dealMeasureApprovalEvents.actedAt);
|
.orderBy(desc(dealMeasureApprovalEvents.actedAt));
|
||||||
|
|
||||||
const events = eventRows.map((e) => ({
|
const events = eventRows.map((e) => ({
|
||||||
id: e.id.toString(),
|
id: e.id.toString(),
|
||||||
|
|
@ -230,6 +234,19 @@ export async function POST(
|
||||||
actedByEmail: session.user.email,
|
actedByEmail: session.user.email,
|
||||||
actedAt: now,
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
|
|
|
||||||
85
src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts
Normal file
85
src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -10,12 +10,14 @@ import {
|
||||||
portfolioUsers,
|
portfolioUsers,
|
||||||
} from "@/app/db/schema/portfolio";
|
} from "@/app/db/schema/portfolio";
|
||||||
import { user } from "@/app/db/schema/users";
|
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";
|
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
|
||||||
|
|
||||||
const postSchema = z.object({
|
const postSchema = z.object({
|
||||||
dealId: z.string().min(1, "dealId is required"),
|
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.
|
* pushes back to HubSpot. See `instructMeasure` for the full contract.
|
||||||
*
|
*
|
||||||
* Body:
|
* Body:
|
||||||
* { dealId: string, measureName: string }
|
* { dealId: string, measureNames: string[] }
|
||||||
*
|
*
|
||||||
* Response:
|
* Response:
|
||||||
* 200 { ok: true, hubspotSync: "ok" | "failed", autoPopulatedProposed: boolean, hubspotError? }
|
* 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? }
|
||||||
* 400 { ok: false, error }
|
* 400 { ok: false, error }
|
||||||
* 401 / 403 / 404 on auth/role/user errors.
|
* 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
|
// Validate all names against the canonical catalogue up-front.
|
||||||
// a clean 400 rather than relying on the service-level check.
|
for (const name of measureNames) {
|
||||||
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(measureName)) {
|
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Unknown measure: ${measureName}` },
|
{ error: `Unknown measure: ${name}` },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRow = await db
|
const userRow = await db
|
||||||
|
|
@ -119,9 +122,9 @@ export async function POST(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await instructMeasure({
|
const result = await instructMeasures({
|
||||||
dealId,
|
dealId,
|
||||||
measureName,
|
measureNames,
|
||||||
userId: userRow[0].id,
|
userId: userRow[0].id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
181
src/app/api/portfolio/[portfolioId]/organisation/route.test.ts
Normal file
181
src/app/api/portfolio/[portfolioId]/organisation/route.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
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 { db } from "@/app/db/db";
|
||||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||||
import { organisation } from "@/app/db/schema/organisation";
|
import { organisation } from "@/app/db/schema/organisation";
|
||||||
|
|
@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean {
|
||||||
return !!email?.endsWith("@domna.homes");
|
return !!email?.endsWith("@domna.homes");
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET — fetch the current linked organisation for this portfolio
|
function linkedOrgsQuery(portfolioId: string) {
|
||||||
export async function GET(
|
return db
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
|
||||||
) {
|
|
||||||
const { portfolioId } = await params;
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
.select({
|
||||||
id: organisation.id,
|
id: organisation.id,
|
||||||
name: organisation.name,
|
name: organisation.name,
|
||||||
|
|
@ -25,13 +19,20 @@ export async function GET(
|
||||||
})
|
})
|
||||||
.from(portfolioOrganisation)
|
.from(portfolioOrganisation)
|
||||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return NextResponse.json(rows[0] ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||||
|
|
@ -43,40 +44,40 @@ export async function POST(
|
||||||
|
|
||||||
const { portfolioId } = await params;
|
const { portfolioId } = await params;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { organisationId } = body as { organisationId: string };
|
const { organisationId } = body as { organisationId?: string };
|
||||||
|
|
||||||
if (!organisationId) {
|
if (!organisationId) {
|
||||||
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert: delete any existing link then insert fresh
|
// Reject if this org is already linked to this portfolio
|
||||||
await db
|
const existing = await db
|
||||||
.delete(portfolioOrganisation)
|
.select({ id: portfolioOrganisation.id })
|
||||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
.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({
|
await db.insert(portfolioOrganisation).values({
|
||||||
portfolioId: BigInt(portfolioId),
|
portfolioId: BigInt(portfolioId),
|
||||||
organisationId,
|
organisationId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the newly linked org
|
const rows = await linkedOrgsQuery(portfolioId);
|
||||||
const rows = await db
|
return NextResponse.json(rows);
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE — disconnect the organisation from this portfolio (Domna only)
|
// DELETE — remove a specific organisation link (Domna only)
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(AuthOptions);
|
const session = await getServerSession(AuthOptions);
|
||||||
|
|
@ -85,10 +86,21 @@ export async function DELETE(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { portfolioId } = await params;
|
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
|
await db
|
||||||
.delete(portfolioOrganisation)
|
.delete(portfolioOrganisation)
|
||||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
|
||||||
|
eq(portfolioOrganisation.organisationId, organisationId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
161
src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts
Normal file
161
src/app/api/portfolio/[portfolioId]/pibi-requests/[id]/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
157
src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts
Normal file
157
src/app/api/portfolio/[portfolioId]/pibi-requests/route.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
189
src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts
Normal file
189
src/app/api/portfolio/[portfolioId]/pibi-requests/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { db } from "@/app/db/db";
|
import { db } from "@/app/db/db";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { surveyRequests } from "@/app/db/schema/survey_requests";
|
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 { user } from "@/app/db/schema/users";
|
||||||
import { and, eq, desc } from "drizzle-orm";
|
import { and, eq, desc } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -9,8 +9,6 @@ import { getServerSession } from "next-auth";
|
||||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||||
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
|
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||||
|
|
||||||
const WRITE_ROLES = ["creator", "admin", "write"] as const;
|
|
||||||
|
|
||||||
async function getRequestingUser(email: string) {
|
async function getRequestingUser(email: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: user.id, email: user.email })
|
.select({ id: user.id, email: user.email })
|
||||||
|
|
@ -20,7 +18,7 @@ async function getRequestingUser(email: string) {
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserRole(portfolioId: bigint, userId: bigint) {
|
async function hasPortfolioRole(portfolioId: bigint, userId: bigint) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ role: portfolioUsers.role })
|
.select({ role: portfolioUsers.role })
|
||||||
.from(portfolioUsers)
|
.from(portfolioUsers)
|
||||||
|
|
@ -31,11 +29,38 @@ async function getUserRole(portfolioId: bigint, userId: bigint) {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.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
|
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
|
||||||
// Returns all survey requests for a deal, most recent first.
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
props: { params: Promise<{ portfolioId: string }> },
|
props: { params: Promise<{ portfolioId: string }> },
|
||||||
|
|
@ -57,7 +82,7 @@ export async function GET(
|
||||||
.select({
|
.select({
|
||||||
id: surveyRequests.id,
|
id: surveyRequests.id,
|
||||||
hubspotDealId: surveyRequests.hubspotDealId,
|
hubspotDealId: surveyRequests.hubspotDealId,
|
||||||
notes: surveyRequests.notes,
|
surveyType: surveyRequests.surveyType,
|
||||||
status: surveyRequests.status,
|
status: surveyRequests.status,
|
||||||
requestedAt: surveyRequests.requestedAt,
|
requestedAt: surveyRequests.requestedAt,
|
||||||
fulfilledAt: surveyRequests.fulfilledAt,
|
fulfilledAt: surveyRequests.fulfilledAt,
|
||||||
|
|
@ -77,7 +102,7 @@ export async function GET(
|
||||||
const requests = rows.map((r) => ({
|
const requests = rows.map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
hubspotDealId: r.hubspotDealId,
|
hubspotDealId: r.hubspotDealId,
|
||||||
notes: r.notes,
|
surveyType: r.surveyType ?? null,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
requestedByEmail: r.requestedByEmail,
|
requestedByEmail: r.requestedByEmail,
|
||||||
requestedAt: r.requestedAt?.toISOString() ?? null,
|
requestedAt: r.requestedAt?.toISOString() ?? null,
|
||||||
|
|
@ -93,11 +118,11 @@ export async function GET(
|
||||||
|
|
||||||
const postSchema = z.object({
|
const postSchema = z.object({
|
||||||
hubspotDealId: z.string().min(1),
|
hubspotDealId: z.string().min(1),
|
||||||
notes: z.string().min(1, "Notes are required"),
|
surveyType: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/portfolio/[portfolioId]/survey-requests
|
// 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(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
props: { params: Promise<{ portfolioId: string }> },
|
props: { params: Promise<{ portfolioId: string }> },
|
||||||
|
|
@ -114,10 +139,17 @@ export async function POST(
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
|
const pid = BigInt(portfolioId);
|
||||||
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
|
|
||||||
|
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(
|
return NextResponse.json(
|
||||||
{ error: "Write access required to submit a survey request" },
|
{ error: "Approver capability required to submit a survey request" },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -134,24 +166,33 @@ export async function POST(
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
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 {
|
try {
|
||||||
const [inserted] = await db
|
const [inserted] = await db
|
||||||
.insert(surveyRequests)
|
.insert(surveyRequests)
|
||||||
.values({
|
.values({
|
||||||
hubspotDealId,
|
hubspotDealId,
|
||||||
portfolioId: BigInt(portfolioId),
|
portfolioId: pid,
|
||||||
notes,
|
notes: "",
|
||||||
|
surveyType,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
requestedBy: requestingUser.id,
|
requestedBy: requestingUser.id,
|
||||||
})
|
})
|
||||||
.returning({ id: surveyRequests.id });
|
.returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt });
|
||||||
|
|
||||||
const hubspotResult = await syncSurveyRequestToHubSpot({
|
const hubspotResult = await syncSurveyRequestToHubSpot({
|
||||||
hubspotDealId,
|
hubspotDealId,
|
||||||
notes,
|
surveyType,
|
||||||
requestedByEmail: requestingUser.email,
|
requestedAt: inserted.requestedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export const surveyRequests = pgTable(
|
||||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => portfolio.id),
|
.references(() => portfolio.id),
|
||||||
// Free-text notes from the requester describing what survey is needed.
|
|
||||||
notes: text("notes").notNull(),
|
notes: text("notes").notNull(),
|
||||||
surveyType: text("survey_type"),
|
surveyType: text("survey_type"),
|
||||||
// 'pending' | 'fulfilled'
|
// 'pending' | 'fulfilled'
|
||||||
|
|
|
||||||
86
src/app/lib/bulkApprove.test.ts
Normal file
86
src/app/lib/bulkApprove.test.ts
Normal 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
156
src/app/lib/bulkApprove.ts
Normal 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" };
|
||||||
|
}
|
||||||
94
src/app/lib/bulkInstructDeals.test.ts
Normal file
94
src/app/lib/bulkInstructDeals.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
179
src/app/lib/bulkInstructDeals.ts
Normal file
179
src/app/lib/bulkInstructDeals.ts
Normal 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" };
|
||||||
|
}
|
||||||
151
src/app/lib/createPibiRequests.test.ts
Normal file
151
src/app/lib/createPibiRequests.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
160
src/app/lib/createPibiRequests.ts
Normal file
160
src/app/lib/createPibiRequests.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -75,10 +75,10 @@ describe("DEAL_PROPERTY_FIELDS registry", () => {
|
||||||
"property_halted_reason",
|
"property_halted_reason",
|
||||||
);
|
);
|
||||||
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
|
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
|
||||||
"domna_survey_type",
|
"osmosis_survey_required",
|
||||||
);
|
);
|
||||||
expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe(
|
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);
|
expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso);
|
||||||
|
|
||||||
const props = pushHubspot.mock.calls[0][0].properties;
|
const props = pushHubspot.mock.calls[0][0].properties;
|
||||||
expect(props.domna_survey_type).toBe(surveyType);
|
expect(props.osmosis_survey_required).toBe(surveyType);
|
||||||
expect(props.domna_survey_date).toBe(
|
expect(props.osmosis_survey_date).toBe(
|
||||||
String(new Date(surveyIso).getTime()),
|
String(new Date(surveyIso).getTime()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -407,8 +407,8 @@ describe("applyDealPropertyUpdate", () => {
|
||||||
expect(typeOnlyDb.domnaSurveyType).toBe("Standard");
|
expect(typeOnlyDb.domnaSurveyType).toBe("Standard");
|
||||||
expect("domnaSurveyDate" in typeOnlyDb).toBe(false);
|
expect("domnaSurveyDate" in typeOnlyDb).toBe(false);
|
||||||
const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties;
|
const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties;
|
||||||
expect(typeOnlyProps.domna_survey_type).toBe("Standard");
|
expect(typeOnlyProps.osmosis_survey_required).toBe("Standard");
|
||||||
expect("domna_survey_date" in typeOnlyProps).toBe(false);
|
expect("osmosis_survey_date" in typeOnlyProps).toBe(false);
|
||||||
|
|
||||||
// Setting only the date — type column is untouched.
|
// Setting only the date — type column is untouched.
|
||||||
const updateDbDate = vi.fn().mockResolvedValue(undefined);
|
const updateDbDate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
@ -427,10 +427,10 @@ describe("applyDealPropertyUpdate", () => {
|
||||||
expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date);
|
expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date);
|
||||||
expect("domnaSurveyType" in dateOnlyDb).toBe(false);
|
expect("domnaSurveyType" in dateOnlyDb).toBe(false);
|
||||||
const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties;
|
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()),
|
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 () => {
|
it("clears both domna fields to null when explicitly cleared", async () => {
|
||||||
|
|
@ -453,8 +453,8 @@ describe("applyDealPropertyUpdate", () => {
|
||||||
expect(dbValues.domnaSurveyType).toBeNull();
|
expect(dbValues.domnaSurveyType).toBeNull();
|
||||||
expect(dbValues.domnaSurveyDate).toBeNull();
|
expect(dbValues.domnaSurveyDate).toBeNull();
|
||||||
const props = pushHubspot.mock.calls[0][0].properties;
|
const props = pushHubspot.mock.calls[0][0].properties;
|
||||||
expect(props.domna_survey_type).toBe("");
|
expect(props.osmosis_survey_required).toBe("");
|
||||||
expect(props.domna_survey_date).toBe("");
|
expect(props.osmosis_survey_date).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces HubSpot push failures back to the caller", async () => {
|
it("surfaces HubSpot push failures back to the caller", async () => {
|
||||||
|
|
|
||||||
|
|
@ -149,14 +149,14 @@ export const DEAL_PROPERTY_FIELDS = {
|
||||||
domna_survey_type: {
|
domna_survey_type: {
|
||||||
schema: stringOrNullSchema,
|
schema: stringOrNullSchema,
|
||||||
allowedRoles: APPROVER_ROLES,
|
allowedRoles: APPROVER_ROLES,
|
||||||
hubspotProperty: "domna_survey_type",
|
hubspotProperty: "osmosis_survey_required",
|
||||||
dbColumn: hubspotDealData.domnaSurveyType,
|
dbColumn: hubspotDealData.domnaSurveyType,
|
||||||
toHubspot: stringToHubspot,
|
toHubspot: stringToHubspot,
|
||||||
} satisfies DealPropertyFieldDef<string | null>,
|
} satisfies DealPropertyFieldDef<string | null>,
|
||||||
domna_survey_date: {
|
domna_survey_date: {
|
||||||
schema: isoDateSchema,
|
schema: isoDateSchema,
|
||||||
allowedRoles: APPROVER_ROLES,
|
allowedRoles: APPROVER_ROLES,
|
||||||
hubspotProperty: "domna_survey_date",
|
hubspotProperty: "osmosis_survey_date",
|
||||||
dbColumn: hubspotDealData.domnaSurveyDate,
|
dbColumn: hubspotDealData.domnaSurveyDate,
|
||||||
toHubspot: dateToHubspot,
|
toHubspot: dateToHubspot,
|
||||||
} satisfies DealPropertyFieldDef<Date | null>,
|
} satisfies DealPropertyFieldDef<Date | null>,
|
||||||
|
|
|
||||||
86
src/app/lib/deletePibiRequest.test.ts
Normal file
86
src/app/lib/deletePibiRequest.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
93
src/app/lib/deletePibiRequest.ts
Normal file
93
src/app/lib/deletePibiRequest.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ vi.mock("./client", () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { syncMeasuresFieldToHubSpot } from "./dealSync";
|
import { syncMeasuresFieldToHubSpot, syncSurveyRequestToHubSpot } from "./dealSync";
|
||||||
|
|
||||||
describe("syncMeasuresFieldToHubSpot", () => {
|
describe("syncMeasuresFieldToHubSpot", () => {
|
||||||
beforeEach(() => {
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -187,14 +187,16 @@ export async function syncMeasuresFieldToHubSpot(params: {
|
||||||
|
|
||||||
export async function syncSurveyRequestToHubSpot(params: {
|
export async function syncSurveyRequestToHubSpot(params: {
|
||||||
hubspotDealId: string;
|
hubspotDealId: string;
|
||||||
notes: string;
|
surveyType: string;
|
||||||
requestedByEmail: string;
|
requestedAt: Date;
|
||||||
}): Promise<{ ok: boolean; error?: string }> {
|
}): Promise<{ ok: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const client = getHubSpotClient();
|
const client = getHubSpotClient();
|
||||||
const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`;
|
|
||||||
await client.crm.deals.basicApi.update(params.hubspotDealId, {
|
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 };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,13 @@ import {
|
||||||
PROPOSED_MEASURES_PROP,
|
PROPOSED_MEASURES_PROP,
|
||||||
APPROVED_MEASURES_PROP,
|
APPROVED_MEASURES_PROP,
|
||||||
instructMeasure,
|
instructMeasure,
|
||||||
|
instructMeasures,
|
||||||
} from "./instructMeasure";
|
} from "./instructMeasure";
|
||||||
import type {
|
import type {
|
||||||
InstructTxOutcome,
|
InstructTxOutcome,
|
||||||
|
InstructMeasuresTxOutcome,
|
||||||
RunInstructTx,
|
RunInstructTx,
|
||||||
|
RunInstructMeasuresTx,
|
||||||
ReadInstructedMeasureNames,
|
ReadInstructedMeasureNames,
|
||||||
StampPushedAt,
|
StampPushedAt,
|
||||||
SyncMeasuresField,
|
SyncMeasuresField,
|
||||||
|
|
@ -307,3 +310,180 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
|
||||||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,224 @@ const defaultStampPushedAt: StampPushedAt = async (rowId) => {
|
||||||
.where(eq(userDefinedDealMeasures.id, 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(
|
export async function instructMeasure(
|
||||||
input: InstructMeasureInput,
|
input: InstructMeasureInput,
|
||||||
): Promise<InstructMeasureResult> {
|
): Promise<InstructMeasureResult> {
|
||||||
|
|
|
||||||
107
src/app/lib/pibiSectionHelpers.test.ts
Normal file
107
src/app/lib/pibiSectionHelpers.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/app/lib/pibiSectionHelpers.ts
Normal file
53
src/app/lib/pibiSectionHelpers.ts
Normal 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();
|
||||||
|
}
|
||||||
107
src/app/lib/updatePibiRequest.test.ts
Normal file
107
src/app/lib/updatePibiRequest.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
111
src/app/lib/updatePibiRequest.ts
Normal file
111
src/app/lib/updatePibiRequest.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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 { Button } from "@/app/shadcn_components/ui/button";
|
||||||
import { Input } from "@/app/shadcn_components/ui/input";
|
import { Input } from "@/app/shadcn_components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,9 +18,9 @@ type OrgSummary = {
|
||||||
hubspotCompanyId: string | null;
|
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`);
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,25 +33,25 @@ async function fetchAllOrgs(): Promise<OrgSummary[]> {
|
||||||
export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) {
|
export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [connectOpen, setConnectOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
const [disconnectTarget, setDisconnectTarget] = useState<OrgSummary | null>(null);
|
||||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
|
||||||
// Current linked org
|
const { data: linkedOrgs = [], isLoading: loadingLinked } = useQuery({
|
||||||
const { data: currentOrg, isLoading: loadingCurrent } = useQuery({
|
queryKey: ["portfolio-orgs", portfolioId],
|
||||||
queryKey: ["portfolio-org", portfolioId],
|
queryFn: () => fetchLinkedOrgs(portfolioId),
|
||||||
queryFn: () => fetchCurrentOrg(portfolioId),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// All orgs — only fetched when connect modal is open
|
|
||||||
const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({
|
const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({
|
||||||
queryKey: ["all-organisations"],
|
queryKey: ["all-organisations"],
|
||||||
queryFn: fetchAllOrgs,
|
queryFn: fetchAllOrgs,
|
||||||
enabled: connectOpen,
|
enabled: addOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const linkedOrgIds = useMemo(() => new Set(linkedOrgs.map((o) => o.id)), [linkedOrgs]);
|
||||||
|
|
||||||
const connectMutation = useMutation({
|
const connectMutation = useMutation({
|
||||||
mutationFn: async (organisationId: string) => {
|
mutationFn: async (organisationId: string) => {
|
||||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
||||||
|
|
@ -63,8 +63,8 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
|
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
|
||||||
setConnectOpen(false);
|
setAddOpen(false);
|
||||||
setSelectedOrgId(null);
|
setSelectedOrgId(null);
|
||||||
setConfirmed(false);
|
setConfirmed(false);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
@ -72,116 +72,102 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
});
|
});
|
||||||
|
|
||||||
const disconnectMutation = useMutation({
|
const disconnectMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (organisationId: string) => {
|
||||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ organisationId }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to disconnect organisation");
|
if (!res.ok) throw new Error("Failed to disconnect organisation");
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
|
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
|
||||||
setDisconnectOpen(false);
|
setDisconnectTarget(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredOrgs = useMemo(
|
const availableOrgs = useMemo(
|
||||||
() =>
|
() => allOrgs.filter((o) => !linkedOrgIds.has(o.id) && (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())),
|
||||||
allOrgs.filter((o) =>
|
[allOrgs, linkedOrgIds, searchQuery],
|
||||||
(o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
),
|
|
||||||
[allOrgs, searchQuery],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null;
|
const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null;
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setAddOpen(true);
|
||||||
|
setSelectedOrgId(null);
|
||||||
|
setConfirmed(false);
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
|
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
|
||||||
<div className="p-2 rounded-lg bg-brandblue/10">
|
<div className="flex items-center gap-3">
|
||||||
<Building2 className="h-4 w-4 text-brandblue" />
|
<div className="p-2 rounded-lg bg-brandblue/10">
|
||||||
</div>
|
<Building2 className="h-4 w-4 text-brandblue" />
|
||||||
<div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-brandblue">Organisation Link</p>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-sm font-semibold text-brandblue">Organisation Links</p>
|
||||||
Connect this portfolio to an organisation to enable live project tracking
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
</p>
|
Connect this portfolio to one or more organisations to enable live project tracking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{loadingCurrent ? (
|
{loadingLinked ? (
|
||||||
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
|
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
|
||||||
) : currentOrg ? (
|
) : linkedOrgs.length === 0 ? (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
|
<Building2 className="h-4 w-4 text-gray-400" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-sm text-gray-500">No organisations linked</p>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
) : (
|
||||||
variant="outline"
|
linkedOrgs.map((org) => (
|
||||||
className="h-8 text-xs border-brandblue/20 text-brandblue hover:bg-brandlightblue/30"
|
<div key={org.id} className="flex items-center justify-between gap-4 py-1">
|
||||||
onClick={() => {
|
<div className="flex items-center gap-3">
|
||||||
setConnectOpen(true);
|
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||||
setSelectedOrgId(null);
|
<div>
|
||||||
setConfirmed(false);
|
<p className="text-sm font-semibold text-gray-800">{org.name ?? "Unnamed organisation"}</p>
|
||||||
setSearchQuery("");
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
}}
|
Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"}
|
||||||
>
|
</p>
|
||||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
</div>
|
||||||
Change
|
</div>
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 text-xs border-red-200 text-red-600 hover:bg-red-50"
|
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" />
|
<Link2Off className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ── Connect modal ─────────────────────────────────────────────── */}
|
{/* ── Add Organisation modal ─────────────────────────────────────────────── */}
|
||||||
<Dialog open={connectOpen} onOpenChange={(v) => { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
|
<Dialog open={addOpen} onOpenChange={(v) => { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
|
||||||
<DialogContent className="max-w-md">
|
<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">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -192,14 +178,13 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Org list */}
|
|
||||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
|
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
|
||||||
{loadingOrgs ? (
|
{loadingOrgs ? (
|
||||||
<div className="p-4 text-sm text-gray-400 text-center">Loading…</div>
|
<div className="p-4 text-sm text-gray-400 text-center">Loading…</div>
|
||||||
) : filteredOrgs.length === 0 ? (
|
) : availableOrgs.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-gray-400 text-center">No organisations found</div>
|
<div className="p-4 text-sm text-gray-400 text-center">No organisations available</div>
|
||||||
) : (
|
) : (
|
||||||
filteredOrgs.map((org) => (
|
availableOrgs.map((org) => (
|
||||||
<button
|
<button
|
||||||
key={org.id}
|
key={org.id}
|
||||||
onClick={() => setSelectedOrgId(org.id)}
|
onClick={() => setSelectedOrgId(org.id)}
|
||||||
|
|
@ -218,7 +203,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning */}
|
|
||||||
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-amber-50 border border-amber-200 mt-1">
|
<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" />
|
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
||||||
<p className="text-xs text-amber-700 leading-relaxed">
|
<p className="text-xs text-amber-700 leading-relaxed">
|
||||||
|
|
@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirmation checkbox */}
|
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
|
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -238,7 +221,7 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
<DialogFooter className="mt-2">
|
||||||
<Button variant="outline" onClick={() => setConnectOpen(false)} className="text-sm">
|
<Button variant="outline" onClick={() => setAddOpen(false)} className="text-sm">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -252,21 +235,21 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── Disconnect confirm dialog ──────────────────────────────────── */}
|
{/* ── Disconnect confirm dialog ──────────────────────────────────────────── */}
|
||||||
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
<Dialog open={!!disconnectTarget} onOpenChange={(v) => { if (!v) setDisconnectTarget(null); }}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
|
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
Are you sure you want to disconnect{" "}
|
Are you sure you want to disconnect{" "}
|
||||||
<strong>{currentOrg?.name ?? "this organisation"}</strong>?
|
<strong>{disconnectTarget?.name ?? "this organisation"}</strong>?
|
||||||
Live project tracking data will no longer be visible to portfolio viewers.
|
Live project tracking data for this organisation will no longer be visible to portfolio viewers.
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter className="mt-2">
|
<DialogFooter className="mt-2">
|
||||||
<Button variant="outline" onClick={() => setDisconnectOpen(false)} className="text-sm">
|
<Button variant="outline" onClick={() => setDisconnectTarget(null)} className="text-sm">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => disconnectMutation.mutate()}
|
onClick={() => disconnectTarget && disconnectMutation.mutate(disconnectTarget.id)}
|
||||||
disabled={disconnectMutation.isPending}
|
disabled={disconnectMutation.isPending}
|
||||||
className="bg-red-600 hover:bg-red-700 text-white text-sm"
|
className="bg-red-600 hover:bg-red-700 text-white text-sm"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,13 @@ import { uploadFileToS3 } from "@/app/utils/s3";
|
||||||
import type { ClassifiedDeal, DocStatusMap } from "./types";
|
import type { ClassifiedDeal, DocStatusMap } from "./types";
|
||||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||||
|
import {
|
||||||
|
applyBulkDocType,
|
||||||
|
selectAllUnclassified,
|
||||||
|
getClassifiedCount,
|
||||||
|
getUnclassifiedIds,
|
||||||
|
uploadedCountForType,
|
||||||
|
} from "./classifyPhase";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -272,7 +279,6 @@ function DocTypeButtonGrid({
|
||||||
uploadedDocs: string[];
|
uploadedDocs: string[];
|
||||||
}) {
|
}) {
|
||||||
const [showOther, setShowOther] = useState(false);
|
const [showOther, setShowOther] = useState(false);
|
||||||
const uploadedSet = new Set(uploadedDocs);
|
|
||||||
const requiredSet = new Set(requiredDocs);
|
const requiredSet = new Set(requiredDocs);
|
||||||
const isOtherSelected = value !== "" && !requiredSet.has(value);
|
const isOtherSelected = value !== "" && !requiredSet.has(value);
|
||||||
|
|
||||||
|
|
@ -283,7 +289,7 @@ function DocTypeButtonGrid({
|
||||||
{requiredDocs.map((docType) => {
|
{requiredDocs.map((docType) => {
|
||||||
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
|
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
|
||||||
const label = option?.label ?? docType;
|
const label = option?.label ?? docType;
|
||||||
const alreadyUploaded = uploadedSet.has(docType);
|
const uploadedCount = uploadedCountForType(uploadedDocs, docType);
|
||||||
const isSelected = value === docType;
|
const isSelected = value === docType;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -291,21 +297,19 @@ function DocTypeButtonGrid({
|
||||||
key={docType}
|
key={docType}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onChange(docType); setShowOther(false); }}
|
onClick={() => { onChange(docType); setShowOther(false); }}
|
||||||
title={alreadyUploaded ? `${label} — already uploaded` : label}
|
title={uploadedCount > 0 ? `${label} — ${uploadedCount} 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 ${
|
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
|
isSelected
|
||||||
? "bg-brandblue text-white border-brandblue shadow-sm"
|
? "bg-brandblue text-white border-brandblue shadow-sm"
|
||||||
: alreadyUploaded
|
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
|
||||||
? "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"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{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}
|
{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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
// The measure selected in the measure-select phase (empty = "not measure-specific")
|
// The measure selected in the measure-select phase (empty = "not measure-specific")
|
||||||
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
|
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 ───────────────────────
|
// ── 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 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() {
|
async function handleSaveClassifications() {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const toSave = classifiableEntries.filter((f) => f.docType !== "");
|
||||||
await saveClassifications(
|
await saveClassifications(
|
||||||
classifiableEntries.map((f) => ({
|
toSave.map((f) => ({
|
||||||
id: f.uploadedId!,
|
id: f.uploadedId!,
|
||||||
fileType: f.docType,
|
fileType: f.docType,
|
||||||
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
|
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
|
||||||
|
|
@ -810,17 +838,43 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
||||||
</div>
|
</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 */}
|
{/* File list with classification */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{classifiableEntries.map((entry) => {
|
{classifiableEntries.map((entry) => {
|
||||||
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
|
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
|
||||||
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
|
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
|
||||||
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
|
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
|
||||||
|
const isChecked = selectedIds.has(entry.id);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* File info row */}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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} />
|
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
|
<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>
|
</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">
|
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
|
||||||
{phase === "loading" && (
|
{phase === "loading" && (
|
||||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
<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}>
|
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||||
Skip for now
|
Skip for now
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
onClick={handleSaveClassifications}
|
<Button
|
||||||
disabled={!allClassified || isSaving}
|
onClick={handleSaveClassifications}
|
||||||
className="bg-brandblue text-white gap-1.5"
|
disabled={classifiedCount === 0 || isSaving}
|
||||||
>
|
className="bg-brandblue text-white gap-1.5"
|
||||||
{isSaving ? (
|
>
|
||||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
{isSaving ? (
|
||||||
) : (
|
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
||||||
"Save Classifications →"
|
) : (
|
||||||
|
`Save ${classifiedCount} classified →`
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{unclassifiedCount > 0 && (
|
||||||
|
<p className="text-[10px] text-gray-400">
|
||||||
|
{unclassifiedCount} unclassified — will stay in your queue
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type {
|
||||||
DocumentDrawerState,
|
DocumentDrawerState,
|
||||||
DocStatusMap,
|
DocStatusMap,
|
||||||
RemovalStatusByDeal,
|
RemovalStatusByDeal,
|
||||||
|
InstructedMeasuresByDeal,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export default function LiveTracker({
|
export default function LiveTracker({
|
||||||
|
|
@ -39,6 +40,7 @@ export default function LiveTracker({
|
||||||
docStatusMap,
|
docStatusMap,
|
||||||
userCapability,
|
userCapability,
|
||||||
approvalsByDeal,
|
approvalsByDeal,
|
||||||
|
instructedMeasuresByDeal,
|
||||||
removalStatusByDeal,
|
removalStatusByDeal,
|
||||||
portfolioId,
|
portfolioId,
|
||||||
userRole,
|
userRole,
|
||||||
|
|
@ -304,7 +306,9 @@ export default function LiveTracker({
|
||||||
<MeasuresTable
|
<MeasuresTable
|
||||||
data={currentProject?.allDeals ?? []}
|
data={currentProject?.allDeals ?? []}
|
||||||
approvalsByDeal={approvalsByDeal}
|
approvalsByDeal={approvalsByDeal}
|
||||||
|
instructedMeasuresByDeal={instructedMeasuresByDeal}
|
||||||
portfolioId={portfolioId}
|
portfolioId={portfolioId}
|
||||||
|
isApprover={userCapability.includes("approver")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -13,34 +12,28 @@ import {
|
||||||
} from "@/app/shadcn_components/ui/table";
|
} from "@/app/shadcn_components/ui/table";
|
||||||
import { Input } from "@/app/shadcn_components/ui/input";
|
import { Input } from "@/app/shadcn_components/ui/input";
|
||||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
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 { STAGE_COLORS } from "./types";
|
||||||
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
|
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
|
||||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||||
|
import { filterMeasureRows } from "./measureFilters";
|
||||||
|
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
|
||||||
|
|
||||||
type AuditEvent = {
|
type Mode = "chip-click" | "instruct";
|
||||||
id: string;
|
|
||||||
hubspotDealId: string;
|
|
||||||
measureName: string;
|
|
||||||
action: string; // 'approved' | 'unapproved'
|
|
||||||
actedByEmail: string;
|
|
||||||
actedByName: string | null;
|
|
||||||
actedAt: string; // ISO string
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: ClassifiedDeal[];
|
data: ClassifiedDeal[];
|
||||||
approvalsByDeal: ApprovalsByDeal;
|
approvalsByDeal: ApprovalsByDeal;
|
||||||
|
instructedMeasuresByDeal: Record<string, string[]>;
|
||||||
portfolioId: string;
|
portfolioId: string;
|
||||||
|
isApprover: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ApprovalStatus({
|
// ── Approval status badge ────────────────────────────────────────────────────
|
||||||
proposed,
|
|
||||||
approved,
|
function ApprovalStatus({ proposed, approved }: { proposed: string[]; approved: string[] }) {
|
||||||
}: {
|
|
||||||
proposed: string[];
|
|
||||||
approved: string[];
|
|
||||||
}) {
|
|
||||||
if (proposed.length === 0) return null;
|
if (proposed.length === 0) return null;
|
||||||
const approvedSet = new Set(approved);
|
const approvedSet = new Set(approved);
|
||||||
const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
|
const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
|
||||||
|
|
@ -66,103 +59,295 @@ function ApprovalStatus({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
// ── Bulk approve modal ───────────────────────────────────────────────────────
|
||||||
return new Date(iso).toLocaleString("en-GB", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivityLog({
|
type BulkApproveModalProps = {
|
||||||
dealId,
|
selected: Array<{ dealId: string; dealname: string | null; measureName: string }>;
|
||||||
portfolioId,
|
|
||||||
}: {
|
|
||||||
dealId: string;
|
|
||||||
portfolioId: string;
|
portfolioId: string;
|
||||||
}) {
|
onClose: () => void;
|
||||||
const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
|
onSuccess: () => void;
|
||||||
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) {
|
function BulkApproveModal({ selected, portfolioId, onClose, onSuccess }: BulkApproveModalProps) {
|
||||||
return (
|
const [submitting, setSubmitting] = useState(false);
|
||||||
<p className="text-xs text-gray-400 py-2 pl-4">Loading activity…</p>
|
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) {
|
async function handleConfirm() {
|
||||||
return (
|
setSubmitting(true);
|
||||||
<p className="text-xs text-gray-400 py-2 pl-4">No activity yet.</p>
|
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 (
|
return (
|
||||||
<div className="pl-4 pr-2 pb-3 space-y-1.5">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
{events.map((e) => (
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
|
||||||
<div key={e.id} className="flex items-center gap-2 text-xs">
|
<div className="flex items-center justify-between">
|
||||||
<span
|
<h2 className="text-base font-semibold text-gray-800">Confirm bulk approval</h2>
|
||||||
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
e.action === "approved"
|
<X className="h-4 w-4" />
|
||||||
? "bg-emerald-50 text-emerald-700"
|
</button>
|
||||||
: "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>
|
||||||
))}
|
|
||||||
|
<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>
|
</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({
|
export default function MeasuresTable({
|
||||||
data,
|
data,
|
||||||
approvalsByDeal,
|
approvalsByDeal,
|
||||||
|
instructedMeasuresByDeal,
|
||||||
portfolioId,
|
portfolioId,
|
||||||
|
isApprover,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [search, setSearch] = useState("");
|
const [isPending, startTransition] = useTransition();
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
||||||
|
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(
|
const dealsWithMeasures = useMemo(
|
||||||
() => data.filter((d) => d.proposedMeasures),
|
() => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0),
|
||||||
[data],
|
[data, instructedMeasuresByDeal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(
|
||||||
const q = search.toLowerCase();
|
() => filterMeasureRows(dealsWithMeasures, instructedMeasuresByDeal, search),
|
||||||
if (!q) return dealsWithMeasures;
|
[dealsWithMeasures, instructedMeasuresByDeal, search],
|
||||||
return dealsWithMeasures.filter(
|
);
|
||||||
(d) =>
|
|
||||||
d.dealname?.toLowerCase().includes(q) ||
|
|
||||||
d.landlordPropertyId?.toLowerCase().includes(q) ||
|
|
||||||
d.proposedMeasures?.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [dealsWithMeasures, search]);
|
|
||||||
|
|
||||||
function toggleRowExpand(dealId: string) {
|
function switchMode(next: Mode) {
|
||||||
setExpandedRows((prev) => {
|
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);
|
const next = new Set(prev);
|
||||||
if (next.has(dealId)) next.delete(dealId);
|
if (next.has(dealId)) next.delete(dealId);
|
||||||
else next.add(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) {
|
if (dealsWithMeasures.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
|
<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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Toolbar */}
|
{/* ── Toolbar ──────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div className="relative flex-1 min-w-48 max-w-sm">
|
<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" />
|
<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"
|
className="pl-9 h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{filtered.length} of {dealsWithMeasures.length} properties
|
{filtered.length} of {dealsWithMeasures.length} properties
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400 hidden sm:inline">
|
|
||||||
· Click a row to open property
|
{isApprover && (
|
||||||
</span>
|
<>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* ── Action bar ───────────────────────────────────────────────── */}
|
||||||
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
|
{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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50 border-b border-gray-100">
|
<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">
|
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
Address
|
Address
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -216,7 +520,13 @@ export default function MeasuresTable({
|
||||||
Stage
|
Stage
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<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>
|
||||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
Status
|
Status
|
||||||
|
|
@ -226,121 +536,161 @@ export default function MeasuresTable({
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((deal) => {
|
{filtered.map((deal) => {
|
||||||
const proposed = parseMeasures(deal.proposedMeasures);
|
const proposed = parseMeasures(deal.proposedMeasures);
|
||||||
|
const instructed = instructedMeasuresByDeal[deal.dealId] ?? [];
|
||||||
|
const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall);
|
||||||
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
|
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
|
||||||
const approvedSet = new Set(approvedForDeal);
|
const approvedSet = new Set(approvedForDeal);
|
||||||
const stageColor = STAGE_COLORS[deal.displayStage];
|
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 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);
|
router.push(dealPageUrl);
|
||||||
};
|
};
|
||||||
const handleRowKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
router.push(dealPageUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={deal.dealId}>
|
<TableRow
|
||||||
<TableRow
|
key={deal.dealId}
|
||||||
data-testid="measures-row"
|
data-testid="measures-row"
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
onKeyDown={handleRowKeyDown}
|
tabIndex={0}
|
||||||
tabIndex={0}
|
role="button"
|
||||||
role="button"
|
onKeyDown={(e) => {
|
||||||
className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer"
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
>
|
e.preventDefault();
|
||||||
{/* Expand toggle */}
|
router.push(dealPageUrl);
|
||||||
<TableCell className="py-3 pl-3 pr-0 w-6">
|
}
|
||||||
<button
|
}}
|
||||||
onClick={(e) => { e.stopPropagation(); toggleRowExpand(deal.dealId); }}
|
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${
|
||||||
className="text-gray-400 hover:text-brandblue transition-colors"
|
isRowSelected ? "bg-indigo-50/40" : ""
|
||||||
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
|
}`}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{/* Row checkbox (instruct mode only) */}
|
||||||
<ChevronDown className="h-4 w-4" />
|
{mode === "instruct" && (
|
||||||
) : (
|
<TableCell className="pl-3 py-3 w-10" data-checkbox>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<Checkbox
|
||||||
)}
|
checked={isRowSelected}
|
||||||
</button>
|
onCheckedChange={() => toggleRow(deal.dealId)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-4 w-4"
|
||||||
|
aria-label={`Select ${deal.dealname ?? deal.dealId}`}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Address */}
|
{/* Address */}
|
||||||
<TableCell className="py-3">
|
<TableCell className="py-3">
|
||||||
<div className="font-medium text-sm text-gray-800">
|
<div className="font-medium text-sm text-gray-800">
|
||||||
{deal.dealname ?? "—"}
|
{deal.dealname ?? "—"}
|
||||||
|
</div>
|
||||||
|
{deal.landlordPropertyId && (
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{deal.landlordPropertyId}
|
||||||
</div>
|
</div>
|
||||||
{deal.landlordPropertyId && (
|
)}
|
||||||
<div className="text-xs text-gray-400 mt-0.5">
|
</TableCell>
|
||||||
{deal.landlordPropertyId}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Stage */}
|
{/* Stage */}
|
||||||
<TableCell className="py-3">
|
<TableCell className="py-3">
|
||||||
<span
|
<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}`}
|
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}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
|
||||||
{deal.displayStage}
|
{deal.displayStage}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Proposed measures — read-only; click the row to approve in the drawer */}
|
{/* Proposed measures */}
|
||||||
<TableCell className="py-3">
|
<TableCell className="py-3">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{proposed.map((measure) => {
|
{proposed.map((measure) => {
|
||||||
const isApproved = approvedSet.has(measure);
|
const chipKey = `${deal.dealId}::${measure}`;
|
||||||
return (
|
const isSelected = selectedChips.has(chipKey);
|
||||||
<span
|
const isApproved = approvedSet.has(measure);
|
||||||
key={measure}
|
const clickable = isApprover && mode === "chip-click";
|
||||||
className={`px-2 py-1 rounded-full text-xs border ${
|
|
||||||
isApproved
|
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-emerald-50 border-emerald-200 text-emerald-700"
|
||||||
: "bg-gray-50 border-gray-200 text-gray-600"
|
: "bg-gray-50 border-gray-200 text-gray-600"
|
||||||
}`}
|
} ${clickable ? "cursor-pointer hover:border-emerald-400" : ""}`}
|
||||||
>
|
>
|
||||||
{measure}
|
{measure}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Instructed measures */}
|
||||||
<TableCell className="py-3">
|
<TableCell className="py-3">
|
||||||
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
|
<div className="flex flex-wrap gap-1.5">
|
||||||
</TableCell>
|
{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 */}
|
{/* Approval status */}
|
||||||
{isExpanded && (
|
<TableCell className="py-3">
|
||||||
<TableRow className="bg-gray-50/50">
|
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
|
||||||
<TableCell
|
</TableCell>
|
||||||
colSpan={5}
|
</TableRow>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { FileDown, X } from "lucide-react";
|
||||||
import {
|
|
||||||
FileDown,
|
|
||||||
FileText,
|
|
||||||
FileX,
|
|
||||||
Loader2,
|
|
||||||
FolderOpen,
|
|
||||||
X,
|
|
||||||
ExternalLink,
|
|
||||||
HardHat,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
|
|
@ -22,138 +12,7 @@ import {
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
} from "@/app/shadcn_components/ui/drawer";
|
} from "@/app/shadcn_components/ui/drawer";
|
||||||
import type { PropertyDocument, DocStatus } from "./types";
|
import type { PropertyDocument, DocStatus } from "./types";
|
||||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
import PropertyDocumentsContent from "./PropertyDocumentsContent";
|
||||||
|
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// PropertyDrawer — main component
|
// PropertyDrawer — main component
|
||||||
|
|
@ -190,9 +49,7 @@ export default function PropertyDrawer({
|
||||||
else if (uprn) params.set("uprn", uprn);
|
else if (uprn) params.set("uprn", uprn);
|
||||||
else if (landlordPropertyId)
|
else if (landlordPropertyId)
|
||||||
params.set("landlordPropertyId", landlordPropertyId);
|
params.set("landlordPropertyId", landlordPropertyId);
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/live-tracking/property-documents?${params}`);
|
||||||
`/api/live-tracking/property-documents?${params}`,
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error("Failed to load documents");
|
if (!res.ok) throw new Error("Failed to load documents");
|
||||||
return res.json() as Promise<PropertyDocument[]>;
|
return res.json() as Promise<PropertyDocument[]>;
|
||||||
},
|
},
|
||||||
|
|
@ -200,31 +57,17 @@ export default function PropertyDrawer({
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the last successfully fetched result so the closing animation doesn't
|
// Preserve last fetched docs so the closing animation doesn't flash empty state
|
||||||
// flash the empty state (the parent nulls out uprn/landlordPropertyId on close,
|
// when the parent nulls identifiers mid-animation.
|
||||||
// which disables the query and resets fetchedDocuments to [] mid-animation).
|
|
||||||
const lastDocumentsRef = useRef<PropertyDocument[]>([]);
|
const lastDocumentsRef = useRef<PropertyDocument[]>([]);
|
||||||
if (open && !isFetching && !isError) {
|
if (open && !isFetching && !isError) {
|
||||||
lastDocumentsRef.current = fetchedDocuments as PropertyDocument[];
|
lastDocumentsRef.current = fetchedDocuments as PropertyDocument[];
|
||||||
}
|
}
|
||||||
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
|
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 (
|
return (
|
||||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
<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">
|
<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" />
|
<div className="hidden" />
|
||||||
|
|
||||||
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
|
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
|
||||||
|
|
@ -253,7 +96,7 @@ export default function PropertyDrawer({
|
||||||
</DrawerClose>
|
</DrawerClose>
|
||||||
</div>
|
</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">
|
<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" />
|
<FileDown className="h-3.5 w-3.5 text-brandblue" />
|
||||||
<span className="text-xs font-medium text-brandblue">
|
<span className="text-xs font-medium text-brandblue">
|
||||||
|
|
@ -263,242 +106,18 @@ export default function PropertyDrawer({
|
||||||
)}
|
)}
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
{/* Body */}
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
<PropertyDocumentsContent
|
||||||
{/* Loading state */}
|
documents={documents}
|
||||||
{isFetching && (
|
isFetching={isFetching}
|
||||||
<div className="space-y-3 pt-2">
|
isError={isError}
|
||||||
{[1, 2, 3].map((i) => (
|
docStatus={docStatus ?? { presentSurveyTypes: [], hasSurveyDocs: false, isSurveyComplete: false, hasInstallDocs: false, installStatus: "none", measureProgress: [] }}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
|
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Download links expire after 30 minutes. Refresh to generate a new
|
Download links expire after 30 minutes. Refresh to generate a new link.
|
||||||
link.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,8 @@
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
|
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { STAGE_COLORS } from "./types";
|
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal } from "./types";
|
||||||
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
|
import { StageBadge } from "./ui";
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortable column header helper
|
// Sortable column header helper
|
||||||
function SortableHeader({
|
function SortableHeader({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -15,37 +16,31 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/app/shadcn_components/ui/tooltip";
|
} from "@/app/shadcn_components/ui/tooltip";
|
||||||
import { AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
|
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 { parseMeasures } from "@/app/lib/parseMeasures";
|
||||||
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
|
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
|
||||||
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState } from "../types";
|
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState, PropertyDocument } from "../types";
|
||||||
import { STAGE_COLORS } from "../types";
|
import PropertyDocumentsContent from "../PropertyDocumentsContent";
|
||||||
|
import { StageBadge } from "../ui";
|
||||||
import {
|
import {
|
||||||
InfoRow,
|
InfoRow,
|
||||||
StageBadge,
|
|
||||||
MilestoneTimeline,
|
MilestoneTimeline,
|
||||||
formatDate,
|
formatDate,
|
||||||
MeasureApprovalEditor,
|
|
||||||
InstructMeasureEditor,
|
|
||||||
ApprovalLogSection,
|
|
||||||
PibiDatesEditor,
|
|
||||||
PibiMeasureSelector,
|
|
||||||
DomnaEditor,
|
|
||||||
HaltedEditor,
|
|
||||||
SurveyRequestSection,
|
|
||||||
RemovalRequestSection,
|
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SECTION_TITLES,
|
SECTION_TITLES,
|
||||||
WRITE_ROLES,
|
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";
|
type Tab = "works" | "pibi-surveys" | "documents";
|
||||||
const VALID_TABS: Tab[] = ["works", "pibi", "survey-admin", "documents"];
|
const VALID_TABS: Tab[] = ["works", "pibi-surveys", "documents"];
|
||||||
|
|
||||||
const TAB_LABELS: Record<Tab, string> = {
|
const TAB_LABELS: Record<Tab, string> = {
|
||||||
works: "Works",
|
works: "Works",
|
||||||
pibi: "PIBI",
|
"pibi-surveys": "PIBIs & Surveys",
|
||||||
"survey-admin": "Survey & Admin",
|
|
||||||
documents: "Documents",
|
documents: "Documents",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,6 +60,7 @@ export default function DealPage({
|
||||||
portfolioId,
|
portfolioId,
|
||||||
userRole,
|
userRole,
|
||||||
userCapability,
|
userCapability,
|
||||||
|
approvedMeasures,
|
||||||
docStatus,
|
docStatus,
|
||||||
removalState,
|
removalState,
|
||||||
}: DealPageProps) {
|
}: DealPageProps) {
|
||||||
|
|
@ -82,16 +78,27 @@ export default function DealPage({
|
||||||
router.replace(`?tab=${tab}`, { scroll: false });
|
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 epcPotential = sapToEpc(deal.epcSapScorePotential != null ? Number(deal.epcSapScorePotential) : null);
|
||||||
const technicalApprovedMeasures = parseMeasures(
|
const technicalApprovedMeasures = parseMeasures(
|
||||||
deal.technicalApprovedMeasuresForInstall ?? null,
|
deal.technicalApprovedMeasuresForInstall ?? null,
|
||||||
);
|
);
|
||||||
const pibiMeasures = parseMeasures(deal.measuresForPibiOrdered ?? null);
|
|
||||||
|
|
||||||
const isApprover = userCapability.includes("approver");
|
const isApprover = userCapability.includes("approver");
|
||||||
const canWrite = WRITE_ROLES.includes(userRole);
|
const canWrite = WRITE_ROLES.includes(userRole);
|
||||||
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|
@ -140,14 +147,17 @@ export default function DealPage({
|
||||||
Energy Performance
|
Energy Performance
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-2xl font-black text-brandblue">{epcCurrent}</span>
|
{parsedPreSap ? (
|
||||||
{epcPotential !== "Unknown" && epcPotential !== epcCurrent && (
|
<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>
|
<span className="text-sm text-gray-400">→ {epcPotential}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{deal.preSapScore !== null && deal.preSapScore !== undefined && (
|
|
||||||
<p className="text-xs text-gray-500">SAP: {deal.preSapScore}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key details */}
|
{/* Key details */}
|
||||||
|
|
@ -163,24 +173,9 @@ export default function DealPage({
|
||||||
<InfoRow label="Coordination" value={deal.coordinationStatus} />
|
<InfoRow label="Coordination" value={deal.coordinationStatus} />
|
||||||
<InfoRow label="Design Status" value={deal.designStatus} />
|
<InfoRow label="Design Status" value={deal.designStatus} />
|
||||||
<InfoRow label="Design Type" value={deal.designType} />
|
<InfoRow label="Design Type" value={deal.designType} />
|
||||||
<InfoRow
|
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
|
||||||
label="Pre-SAP"
|
<InfoRow label="Measures Lodged" value={formatDate(deal.measuresLodgementDate)} />
|
||||||
value={
|
<InfoRow label="Full Lodgement" value={formatDate(deal.fullLodgementDate)} />
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Survey info */}
|
{/* Survey info */}
|
||||||
|
|
@ -205,9 +200,6 @@ export default function DealPage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deal.uprn && (
|
|
||||||
<p className="text-xs text-gray-400 font-mono">UPRN: {deal.uprn}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -252,7 +244,6 @@ export default function DealPage({
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-50 mt-3">
|
<div className="divide-y divide-gray-50 mt-3">
|
||||||
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
|
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
|
||||||
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -297,163 +288,38 @@ export default function DealPage({
|
||||||
</button>
|
</button>
|
||||||
{isLogOpen && (
|
{isLogOpen && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
|
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── PIBI ── */}
|
{/* ── PIBIs & Surveys ── */}
|
||||||
<div
|
<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} />
|
<PibiSurveysTabContent
|
||||||
<div className="space-y-3">
|
deal={deal}
|
||||||
<PibiDatesEditor
|
portfolioId={portfolioId}
|
||||||
dealId={deal.dealId}
|
userRole={userRole}
|
||||||
portfolioId={portfolioId}
|
userCapability={userCapability}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Documents ── */}
|
{/* ── Documents ── */}
|
||||||
<div
|
<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">
|
<PropertyDocumentsContent
|
||||||
Documents
|
documents={documents}
|
||||||
</h3>
|
isFetching={docsFetching}
|
||||||
{docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? (
|
isError={docsError}
|
||||||
<div className="space-y-3">
|
docStatus={docStatus}
|
||||||
<div className="flex items-center gap-2">
|
deal={deal}
|
||||||
<span
|
portfolioId={portfolioId}
|
||||||
className={`w-2 h-2 rounded-full ${docStatus.isSurveyComplete ? "bg-emerald-500" : docStatus.hasSurveyDocs ? "bg-amber-400" : "bg-gray-300"}`}
|
userCapability={userCapability}
|
||||||
/>
|
approvedMeasures={approvedMeasures}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -512,7 +378,7 @@ export default function DealPage({
|
||||||
|
|
||||||
{/* Request Survey */}
|
{/* Request Survey */}
|
||||||
<button
|
<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"
|
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>
|
<span>Request Survey</span>
|
||||||
|
|
@ -521,7 +387,7 @@ export default function DealPage({
|
||||||
|
|
||||||
{/* Request Removal */}
|
{/* Request Removal */}
|
||||||
<button
|
<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"
|
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>
|
<span>Request Removal</span>
|
||||||
|
|
@ -551,8 +417,10 @@ export default function DealPage({
|
||||||
<InstructMeasureEditor
|
<InstructMeasureEditor
|
||||||
dealId={deal.dealId}
|
dealId={deal.dealId}
|
||||||
portfolioId={portfolioId}
|
portfolioId={portfolioId}
|
||||||
|
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
|
||||||
canEdit={isApprover}
|
canEdit={isApprover}
|
||||||
outOfOrderWarning={outOfOrderInstructionWarning(deal)}
|
outOfOrderWarning={outOfOrderInstructionWarning(deal)}
|
||||||
|
onSuccess={() => setInstructModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,104 +1,24 @@
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||||
import { redirect, notFound } from "next/navigation";
|
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 { db } from "@/app/db/db";
|
||||||
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
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 { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||||
import { organisation } from "@/app/db/schema/organisation";
|
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 { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||||
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||||
import { user as userTable } from "@/app/db/schema/users";
|
import { user as userTable } from "@/app/db/schema/users";
|
||||||
import { sql } from "drizzle-orm";
|
import type { DocStatus, PortfolioCapabilityType } from "../types";
|
||||||
import type {
|
import { deriveEffectiveRemovalState } from "../removalState";
|
||||||
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 { classifyDeals } from "../transforms";
|
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 DealPage from "./DealPage";
|
||||||
import Link from "next/link";
|
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: {
|
export default async function DealDetailPage(props: {
|
||||||
params: Promise<{ slug: string; dealId: string }>;
|
params: Promise<{ slug: string; dealId: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -109,22 +29,23 @@ export default async function DealDetailPage(props: {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = await db
|
const links = await db
|
||||||
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
||||||
.from(portfolioOrganisation)
|
.from(portfolioOrganisation)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
organisation,
|
organisation,
|
||||||
eq(portfolioOrganisation.organisationId, organisation.id),
|
eq(portfolioOrganisation.organisationId, organisation.id),
|
||||||
)
|
)
|
||||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
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`);
|
redirect(`/portfolio/${portfolioId}/your-projects/live`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyId = link[0].hubspotCompanyId;
|
|
||||||
|
|
||||||
const rawDeals = await db
|
const rawDeals = await db
|
||||||
.select({
|
.select({
|
||||||
deal: hubspotDealData,
|
deal: hubspotDealData,
|
||||||
|
|
@ -142,7 +63,7 @@ export default async function DealDetailPage(props: {
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(hubspotDealData.companyId, companyId),
|
inArray(hubspotDealData.companyId, companyIds),
|
||||||
eq(hubspotDealData.dealId, dealId),
|
eq(hubspotDealData.dealId, dealId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -211,7 +132,6 @@ export default async function DealDetailPage(props: {
|
||||||
);
|
);
|
||||||
approvedMeasures.push(...approvalRows.map((r) => r.measureName));
|
approvedMeasures.push(...approvalRows.map((r) => r.measureName));
|
||||||
|
|
||||||
let removalState: EffectiveRemovalState = "none";
|
|
||||||
const removalRows = await db
|
const removalRows = await db
|
||||||
.select({
|
.select({
|
||||||
type: propertyRemovalRequests.type,
|
type: propertyRemovalRequests.type,
|
||||||
|
|
@ -227,115 +147,23 @@ export default async function DealDetailPage(props: {
|
||||||
.orderBy(desc(propertyRemovalRequests.requestedAt))
|
.orderBy(desc(propertyRemovalRequests.requestedAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (removalRows[0]) {
|
const removalState = removalRows[0]
|
||||||
const row = removalRows[0];
|
? deriveEffectiveRemovalState(removalRows[0])
|
||||||
if (row.status === "pending") {
|
: "none";
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doc status — same two-phase strategy as live tracker
|
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
|
||||||
const docFiles: Array<{ fileType: string; measureName: string | null }> = [];
|
const docStatusMap = computeDocStatusMap([hubspotDeal], docsByDealId, { [dealId]: approvedMeasures });
|
||||||
|
const docStatus: DocStatus = docStatusMap[dealId] ?? {
|
||||||
const phase1Rows = await db
|
presentSurveyTypes: [],
|
||||||
.select({
|
hasSurveyDocs: false,
|
||||||
hubsotDealId: uploadedFiles.hubsotDealId,
|
isSurveyComplete: false,
|
||||||
fileType: uploadedFiles.fileType,
|
hasInstallDocs: false,
|
||||||
measureName: uploadedFiles.measureName,
|
installStatus: "none",
|
||||||
})
|
measureProgress: [],
|
||||||
.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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mb-6">
|
||||||
<nav className="flex items-center gap-1.5 text-sm text-gray-500 mb-3">
|
<nav className="flex items-center gap-1.5 text-sm text-gray-500 mb-3">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,93 +4,31 @@ import { redirect } from "next/navigation";
|
||||||
import { eq, inArray, and, desc, sql } from "drizzle-orm";
|
import { eq, inArray, and, desc, sql } from "drizzle-orm";
|
||||||
import LiveTracker from "./LiveTracker";
|
import LiveTracker from "./LiveTracker";
|
||||||
import { computeLiveTrackerData } from "./transforms";
|
import { computeLiveTrackerData } from "./transforms";
|
||||||
|
import { fetchDocsByDealId, computeDocStatusMap } from "./docStatus";
|
||||||
import { db } from "@/app/db/db";
|
import { db } from "@/app/db/db";
|
||||||
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
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 { 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 { 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 { 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 { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||||
import { user as userTable } from "@/app/db/schema/users";
|
import { user as userTable } from "@/app/db/schema/users";
|
||||||
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
import type {
|
||||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
PortfolioCapabilityType,
|
||||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
ApprovalsByDeal,
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
InstructedMeasuresByDeal,
|
||||||
|
RemovalStatusByDeal,
|
||||||
|
} from "./types";
|
||||||
|
import { computeRemovalStatusByDeal } from "./removalState";
|
||||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||||
import { Building2 } from "lucide-react";
|
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: {
|
export default async function LiveReportingPage(props: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -101,17 +39,21 @@ export default async function LiveReportingPage(props: {
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the linked organisation for this portfolio
|
// Look up all linked organisations for this portfolio
|
||||||
const link = await db
|
const link = await db
|
||||||
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
||||||
.from(portfolioOrganisation)
|
.from(portfolioOrganisation)
|
||||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
.innerJoin(
|
||||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
organisation,
|
||||||
.limit(1);
|
eq(portfolioOrganisation.organisationId, organisation.id),
|
||||||
|
)
|
||||||
|
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||||
|
|
||||||
const pageHeader = (
|
const pageHeader = (
|
||||||
<div className="mb-6">
|
<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">
|
<p className="text-sm text-gray-500">
|
||||||
{`Check in on your projects' progress with real-time data updates.`}
|
{`Check in on your projects' progress with real-time data updates.`}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -119,7 +61,11 @@ export default async function LiveReportingPage(props: {
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||||
{pageHeader}
|
{pageHeader}
|
||||||
|
|
@ -129,10 +75,13 @@ export default async function LiveReportingPage(props: {
|
||||||
<Building2 className="h-8 w-8 text-brandblue/50" />
|
<Building2 className="h-8 w-8 text-brandblue/50" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-sm text-gray-400 mt-1 max-w-sm">
|
||||||
A Domna administrator needs to connect this portfolio to an organisation in{" "}
|
A Domna administrator needs to connect this portfolio to an
|
||||||
<strong>Portfolio Settings</strong> before live tracking data can be displayed.
|
organisation in <strong>Portfolio Settings</strong> before live
|
||||||
|
tracking data can be displayed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -141,70 +90,77 @@ export default async function LiveReportingPage(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyId = link[0].hubspotCompanyId;
|
|
||||||
|
|
||||||
const rawDeals = await db
|
const rawDeals = await db
|
||||||
.select({
|
.select({
|
||||||
deal: hubspotDealData,
|
deal: hubspotDealData,
|
||||||
coordinator: sql<string | null>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
|
coordinator: sql<
|
||||||
designer: sql<string | null>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
|
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)
|
.from(hubspotDealData)
|
||||||
.leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId))
|
.leftJoin(
|
||||||
.leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId))
|
coordinatorUser,
|
||||||
.where(eq(hubspotDealData.companyId, companyId));
|
eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
designerUser,
|
||||||
|
eq(hubspotDealData.designer, designerUser.hubspotOwnerId),
|
||||||
|
)
|
||||||
|
.where(inArray(hubspotDealData.companyId, companyIds));
|
||||||
|
|
||||||
const deals = rawDeals.map(mapDbRowToHubspotDeal);
|
const deals = rawDeals.map(mapDbRowToHubspotDeal);
|
||||||
const trackerData = computeLiveTrackerData(deals);
|
const trackerData = computeLiveTrackerData(deals);
|
||||||
|
|
||||||
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
|
|
||||||
let userCapability: PortfolioCapabilityType = [];
|
|
||||||
const userEmail = user?.user?.email;
|
const userEmail = user?.user?.email;
|
||||||
|
|
||||||
|
// Single user lookup shared by capability and role queries
|
||||||
|
let userId: bigint | null = null;
|
||||||
if (userEmail) {
|
if (userEmail) {
|
||||||
const userRow = await db
|
const userRow = await db
|
||||||
.select({ id: userTable.id })
|
.select({ id: userTable.id })
|
||||||
.from(userTable)
|
.from(userTable)
|
||||||
.where(eq(userTable.email, userEmail))
|
.where(eq(userTable.email, userEmail))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
userId = userRow[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if (userRow[0]) {
|
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
|
||||||
const capRows = await db
|
let userCapability: PortfolioCapabilityType = [];
|
||||||
.select({ capability: portfolioCapabilities.capability })
|
if (userId) {
|
||||||
.from(portfolioCapabilities)
|
const capRows = await db
|
||||||
.where(
|
.select({ capability: portfolioCapabilities.capability })
|
||||||
and(
|
.from(portfolioCapabilities)
|
||||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
.where(
|
||||||
eq(portfolioCapabilities.userId, userRow[0].id),
|
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");
|
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)
|
// Fetch current user's portfolio role (creator / admin / write / read)
|
||||||
let userRole = "read";
|
let userRole = "read";
|
||||||
if (userEmail) {
|
if (userId) {
|
||||||
const userRow = await db
|
const roleRow = await db
|
||||||
.select({ id: userTable.id })
|
.select({ role: portfolioUsers.role })
|
||||||
.from(userTable)
|
.from(portfolioUsers)
|
||||||
.where(eq(userTable.email, userEmail))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||||
|
eq(portfolioUsers.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
userRole = roleRow[0]?.role ?? "read";
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch currently approved measures for all deals in scope
|
// 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
|
// Fetch instructed measures for all deals in scope
|
||||||
const removalStatusByDeal: RemovalStatusByDeal = {};
|
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
|
const removalRows = await db
|
||||||
.select({
|
.select({
|
||||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||||
|
|
@ -241,139 +216,10 @@ export default async function LiveReportingPage(props: {
|
||||||
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
|
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
|
||||||
.orderBy(desc(propertyRemovalRequests.requestedAt));
|
.orderBy(desc(propertyRemovalRequests.requestedAt));
|
||||||
|
|
||||||
// Keep only the most recent row per deal, then derive effective state
|
const removalStatusByDeal: RemovalStatusByDeal = computeRemovalStatusByDeal(removalRows);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch document status for all deals — two-phase strategy:
|
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
|
||||||
// Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data)
|
const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal);
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
<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}
|
docStatusMap={docStatusMap}
|
||||||
userCapability={userCapability}
|
userCapability={userCapability}
|
||||||
approvalsByDeal={approvalsByDeal}
|
approvalsByDeal={approvalsByDeal}
|
||||||
|
instructedMeasuresByDeal={instructedMeasuresByDeal}
|
||||||
removalStatusByDeal={removalStatusByDeal}
|
removalStatusByDeal={removalStatusByDeal}
|
||||||
portfolioId={portfolioId}
|
portfolioId={portfolioId}
|
||||||
userRole={userRole}
|
userRole={userRole}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,7 +11,7 @@ import type {
|
||||||
ProjectData,
|
ProjectData,
|
||||||
OutcomeSlice,
|
OutcomeSlice,
|
||||||
LiveTrackerProps,
|
LiveTrackerProps,
|
||||||
WorkPhaseStats,
|
|
||||||
DampMouldRiskData,
|
DampMouldRiskData,
|
||||||
FunnelStage,
|
FunnelStage,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
@ -224,106 +224,6 @@ export function computeProjectProgress(
|
||||||
|
|
||||||
const totalDeals = deals.length;
|
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 {
|
return {
|
||||||
stageProgress,
|
stageProgress,
|
||||||
queriesDeals,
|
queriesDeals,
|
||||||
|
|
@ -332,10 +232,6 @@ export function computeProjectProgress(
|
||||||
completedPercentage,
|
completedPercentage,
|
||||||
nonQueryTotal,
|
nonQueryTotal,
|
||||||
totalDeals,
|
totalDeals,
|
||||||
coordination,
|
|
||||||
design,
|
|
||||||
install,
|
|
||||||
lodgement,
|
|
||||||
dampMouldRisk: computeDampMouldRisk(deals),
|
dampMouldRisk: computeDampMouldRisk(deals),
|
||||||
funnelStages: computeFunnelStages(deals),
|
funnelStages: computeFunnelStages(deals),
|
||||||
};
|
};
|
||||||
|
|
@ -375,7 +271,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
export function computeLiveTrackerData(
|
export function computeLiveTrackerData(
|
||||||
rawDeals: HubspotDeal[]
|
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)
|
// Classify all deals (add displayStage field)
|
||||||
const classified = classifyDeals(rawDeals);
|
const classified = classifyDeals(rawDeals);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,19 +104,6 @@ export type StageProgressItem = {
|
||||||
deals: ClassifiedDeal[];
|
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)
|
// Damp & mould risk comparison (survey-stage vs coordination-stage flags)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -151,10 +138,6 @@ export type ProjectProgressData = {
|
||||||
completedPercentage: number; // out of non-query total
|
completedPercentage: number; // out of non-query total
|
||||||
nonQueryTotal: number;
|
nonQueryTotal: number;
|
||||||
totalDeals: number;
|
totalDeals: number;
|
||||||
coordination: WorkPhaseStats;
|
|
||||||
design: WorkPhaseStats;
|
|
||||||
install: WorkPhaseStats;
|
|
||||||
lodgement: WorkPhaseStats;
|
|
||||||
dampMouldRisk: DampMouldRiskData;
|
dampMouldRisk: DampMouldRiskData;
|
||||||
funnelStages: FunnelStage[];
|
funnelStages: FunnelStage[];
|
||||||
};
|
};
|
||||||
|
|
@ -207,6 +190,8 @@ export type RemovalRequest = {
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Top-level props for LiveTracker (client root)
|
// Top-level props for LiveTracker (client root)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
export type InstructedMeasuresByDeal = Record<string, string[]>;
|
||||||
|
|
||||||
export type LiveTrackerProps = {
|
export type LiveTrackerProps = {
|
||||||
projects: ProjectData[];
|
projects: ProjectData[];
|
||||||
totalDeals: number;
|
totalDeals: number;
|
||||||
|
|
@ -214,6 +199,7 @@ export type LiveTrackerProps = {
|
||||||
docStatusMap: DocStatusMap;
|
docStatusMap: DocStatusMap;
|
||||||
userCapability: PortfolioCapabilityType;
|
userCapability: PortfolioCapabilityType;
|
||||||
approvalsByDeal: ApprovalsByDeal;
|
approvalsByDeal: ApprovalsByDeal;
|
||||||
|
instructedMeasuresByDeal: InstructedMeasuresByDeal;
|
||||||
removalStatusByDeal: RemovalStatusByDeal;
|
removalStatusByDeal: RemovalStatusByDeal;
|
||||||
portfolioId: string;
|
portfolioId: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
|
|
|
||||||
|
|
@ -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
37
src/app/utils.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
export function formatDateTime(dateTimeString: string | Date): string {
|
||||||
// Create a new Date object
|
// Create a new Date object
|
||||||
const dateTime = new Date(dateTimeString);
|
const dateTime = new Date(dateTimeString);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
esbuild: {
|
||||||
|
jsx: "automatic",
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue