mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +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";
|
||||
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||
import {
|
||||
syncMeasureApprovalsToHubSpot,
|
||||
syncMeasuresFieldToHubSpot,
|
||||
} from "@/app/lib/hubspot/dealSync";
|
||||
import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure";
|
||||
|
||||
async function getRequestingUserId(email: string): Promise<bigint | null> {
|
||||
const rows = await db
|
||||
|
|
@ -102,7 +106,7 @@ export async function GET(
|
|||
.from(dealMeasureApprovalEvents)
|
||||
.leftJoin(user, eq(user.id, dealMeasureApprovalEvents.actedBy))
|
||||
.where(inArray(dealMeasureApprovalEvents.hubspotDealId, dealIds))
|
||||
.orderBy(dealMeasureApprovalEvents.actedAt);
|
||||
.orderBy(desc(dealMeasureApprovalEvents.actedAt));
|
||||
|
||||
const events = eventRows.map((e) => ({
|
||||
id: e.id.toString(),
|
||||
|
|
@ -230,6 +234,19 @@ export async function POST(
|
|||
actedByEmail: session.user.email,
|
||||
actedAt: now,
|
||||
});
|
||||
|
||||
void syncMeasuresFieldToHubSpot({
|
||||
hubspotDealId: dealId,
|
||||
propName: APPROVED_MEASURES_PROP,
|
||||
measureNames: approvalRows.map((r) => r.measureName),
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
console.error("[HubSpot] approved_measures sync failed", {
|
||||
dealId,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
|
|
|||
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,
|
||||
} from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { instructMeasure } from "@/app/lib/instructMeasure";
|
||||
import { instructMeasures } from "@/app/lib/instructMeasure";
|
||||
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
|
||||
|
||||
const postSchema = z.object({
|
||||
dealId: z.string().min(1, "dealId is required"),
|
||||
measureName: z.string().min(1, "measureName is required"),
|
||||
measureNames: z
|
||||
.array(z.string().min(1))
|
||||
.min(1, "measureNames must not be empty"),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -27,10 +29,10 @@ const postSchema = z.object({
|
|||
* pushes back to HubSpot. See `instructMeasure` for the full contract.
|
||||
*
|
||||
* Body:
|
||||
* { dealId: string, measureName: string }
|
||||
* { dealId: string, measureNames: string[] }
|
||||
*
|
||||
* Response:
|
||||
* 200 { ok: true, hubspotSync: "ok" | "failed", autoPopulatedProposed: boolean, hubspotError? }
|
||||
* 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? }
|
||||
* 400 { ok: false, error }
|
||||
* 401 / 403 / 404 on auth/role/user errors.
|
||||
*/
|
||||
|
|
@ -60,15 +62,16 @@ export async function POST(
|
|||
);
|
||||
}
|
||||
|
||||
const { dealId, measureName } = parsed.data;
|
||||
const { dealId, measureNames } = parsed.data;
|
||||
|
||||
// Validate against the canonical catalogue up-front so the route returns
|
||||
// a clean 400 rather than relying on the service-level check.
|
||||
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(measureName)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown measure: ${measureName}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
// Validate all names against the canonical catalogue up-front.
|
||||
for (const name of measureNames) {
|
||||
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown measure: ${name}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userRow = await db
|
||||
|
|
@ -119,9 +122,9 @@ export async function POST(
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await instructMeasure({
|
||||
const result = await instructMeasures({
|
||||
dealId,
|
||||
measureName,
|
||||
measureNames,
|
||||
userId: userRow[0].id,
|
||||
});
|
||||
|
||||
|
|
|
|||
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 { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/app/db/db";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
|
|
@ -10,14 +10,8 @@ function isDomnaUser(email: string | null | undefined): boolean {
|
|||
return !!email?.endsWith("@domna.homes");
|
||||
}
|
||||
|
||||
// GET — fetch the current linked organisation for this portfolio
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await params;
|
||||
|
||||
const rows = await db
|
||||
function linkedOrgsQuery(portfolioId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
|
|
@ -25,13 +19,20 @@ export async function GET(
|
|||
})
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json(rows[0] ?? null);
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
}
|
||||
|
||||
// POST — connect an organisation to this portfolio (Domna only)
|
||||
// GET — fetch all linked organisations for this portfolio
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await params;
|
||||
const rows = await linkedOrgsQuery(portfolioId);
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
// POST — add an organisation link (Domna only, rejects duplicates)
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
|
|
@ -43,40 +44,40 @@ export async function POST(
|
|||
|
||||
const { portfolioId } = await params;
|
||||
const body = await req.json();
|
||||
const { organisationId } = body as { organisationId: string };
|
||||
const { organisationId } = body as { organisationId?: string };
|
||||
|
||||
if (!organisationId) {
|
||||
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert: delete any existing link then insert fresh
|
||||
await db
|
||||
.delete(portfolioOrganisation)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
// Reject if this org is already linked to this portfolio
|
||||
const existing = await db
|
||||
.select({ id: portfolioOrganisation.id })
|
||||
.from(portfolioOrganisation)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioOrganisation.organisationId, organisationId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json({ error: "Organisation already linked" }, { status: 409 });
|
||||
}
|
||||
|
||||
await db.insert(portfolioOrganisation).values({
|
||||
portfolioId: BigInt(portfolioId),
|
||||
organisationId,
|
||||
});
|
||||
|
||||
// Return the newly linked org
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organisation.id,
|
||||
name: organisation.name,
|
||||
hubspotCompanyId: organisation.hubspotCompanyId,
|
||||
})
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json(rows[0] ?? null);
|
||||
const rows = await linkedOrgsQuery(portfolioId);
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
// DELETE — disconnect the organisation from this portfolio (Domna only)
|
||||
// DELETE — remove a specific organisation link (Domna only)
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
|
@ -85,10 +86,21 @@ export async function DELETE(
|
|||
}
|
||||
|
||||
const { portfolioId } = await params;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { organisationId } = body as { organisationId?: string };
|
||||
|
||||
if (!organisationId) {
|
||||
return NextResponse.json({ error: "organisationId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(portfolioOrganisation)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioOrganisation.organisationId, organisationId),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { NextRequest, NextResponse } from "next/server";
|
||||
import { surveyRequests } from "@/app/db/schema/survey_requests";
|
||||
import { portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import { portfolioUsers, portfolioCapabilities } from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { and, eq, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
|
@ -9,8 +9,6 @@ import { getServerSession } from "next-auth";
|
|||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||
|
||||
const WRITE_ROLES = ["creator", "admin", "write"] as const;
|
||||
|
||||
async function getRequestingUser(email: string) {
|
||||
const rows = await db
|
||||
.select({ id: user.id, email: user.email })
|
||||
|
|
@ -20,7 +18,7 @@ async function getRequestingUser(email: string) {
|
|||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function getUserRole(portfolioId: bigint, userId: bigint) {
|
||||
async function hasPortfolioRole(portfolioId: bigint, userId: bigint) {
|
||||
const rows = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
|
|
@ -31,11 +29,38 @@ async function getUserRole(portfolioId: bigint, userId: bigint) {
|
|||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.role ?? null;
|
||||
return !!rows[0]?.role;
|
||||
}
|
||||
|
||||
async function hasApproverCapability(portfolioId: bigint, userId: bigint) {
|
||||
const rows = await db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, portfolioId),
|
||||
eq(portfolioCapabilities.userId, userId),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => r.capability).includes("approver");
|
||||
}
|
||||
|
||||
async function getPendingRequest(hubspotDealId: string, portfolioId: bigint) {
|
||||
const rows = await db
|
||||
.select({ id: surveyRequests.id, status: surveyRequests.status })
|
||||
.from(surveyRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(surveyRequests.hubspotDealId, hubspotDealId),
|
||||
eq(surveyRequests.portfolioId, portfolioId),
|
||||
eq(surveyRequests.status, "pending"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
|
||||
// Returns all survey requests for a deal, most recent first.
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
|
|
@ -57,7 +82,7 @@ export async function GET(
|
|||
.select({
|
||||
id: surveyRequests.id,
|
||||
hubspotDealId: surveyRequests.hubspotDealId,
|
||||
notes: surveyRequests.notes,
|
||||
surveyType: surveyRequests.surveyType,
|
||||
status: surveyRequests.status,
|
||||
requestedAt: surveyRequests.requestedAt,
|
||||
fulfilledAt: surveyRequests.fulfilledAt,
|
||||
|
|
@ -77,7 +102,7 @@ export async function GET(
|
|||
const requests = rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
hubspotDealId: r.hubspotDealId,
|
||||
notes: r.notes,
|
||||
surveyType: r.surveyType ?? null,
|
||||
status: r.status,
|
||||
requestedByEmail: r.requestedByEmail,
|
||||
requestedAt: r.requestedAt?.toISOString() ?? null,
|
||||
|
|
@ -93,11 +118,11 @@ export async function GET(
|
|||
|
||||
const postSchema = z.object({
|
||||
hubspotDealId: z.string().min(1),
|
||||
notes: z.string().min(1, "Notes are required"),
|
||||
surveyType: z.string().min(1),
|
||||
});
|
||||
|
||||
// POST /api/portfolio/[portfolioId]/survey-requests
|
||||
// Submit a new survey request — requires write+ role.
|
||||
// Submit a new survey request — requires approver capability.
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
|
|
@ -114,10 +139,17 @@ export async function POST(
|
|||
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
|
||||
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
|
||||
const pid = BigInt(portfolioId);
|
||||
|
||||
const isMember = await hasPortfolioRole(pid, requestingUser.id);
|
||||
if (!isMember) {
|
||||
return NextResponse.json({ error: "No portfolio access" }, { status: 403 });
|
||||
}
|
||||
|
||||
const isApprover = await hasApproverCapability(pid, requestingUser.id);
|
||||
if (!isApprover) {
|
||||
return NextResponse.json(
|
||||
{ error: "Write access required to submit a survey request" },
|
||||
{ error: "Approver capability required to submit a survey request" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
|
@ -134,24 +166,33 @@ export async function POST(
|
|||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { hubspotDealId, notes } = parsed.data;
|
||||
const { hubspotDealId, surveyType } = parsed.data;
|
||||
|
||||
const existing = await getPendingRequest(hubspotDealId, pid);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "A pending survey request already exists for this deal" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [inserted] = await db
|
||||
.insert(surveyRequests)
|
||||
.values({
|
||||
hubspotDealId,
|
||||
portfolioId: BigInt(portfolioId),
|
||||
notes,
|
||||
portfolioId: pid,
|
||||
notes: "",
|
||||
surveyType,
|
||||
status: "pending",
|
||||
requestedBy: requestingUser.id,
|
||||
})
|
||||
.returning({ id: surveyRequests.id });
|
||||
.returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt });
|
||||
|
||||
const hubspotResult = await syncSurveyRequestToHubSpot({
|
||||
hubspotDealId,
|
||||
notes,
|
||||
requestedByEmail: requestingUser.email,
|
||||
surveyType,
|
||||
requestedAt: inserted.requestedAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export const surveyRequests = pgTable(
|
|||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
// Free-text notes from the requester describing what survey is needed.
|
||||
notes: text("notes").notNull(),
|
||||
surveyType: text("survey_type"),
|
||||
// 'pending' | 'fulfilled'
|
||||
|
|
|
|||
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",
|
||||
);
|
||||
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
|
||||
"domna_survey_type",
|
||||
"osmosis_survey_required",
|
||||
);
|
||||
expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe(
|
||||
"domna_survey_date",
|
||||
"osmosis_survey_date",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -369,8 +369,8 @@ describe("applyDealPropertyUpdate", () => {
|
|||
expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso);
|
||||
|
||||
const props = pushHubspot.mock.calls[0][0].properties;
|
||||
expect(props.domna_survey_type).toBe(surveyType);
|
||||
expect(props.domna_survey_date).toBe(
|
||||
expect(props.osmosis_survey_required).toBe(surveyType);
|
||||
expect(props.osmosis_survey_date).toBe(
|
||||
String(new Date(surveyIso).getTime()),
|
||||
);
|
||||
});
|
||||
|
|
@ -407,8 +407,8 @@ describe("applyDealPropertyUpdate", () => {
|
|||
expect(typeOnlyDb.domnaSurveyType).toBe("Standard");
|
||||
expect("domnaSurveyDate" in typeOnlyDb).toBe(false);
|
||||
const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties;
|
||||
expect(typeOnlyProps.domna_survey_type).toBe("Standard");
|
||||
expect("domna_survey_date" in typeOnlyProps).toBe(false);
|
||||
expect(typeOnlyProps.osmosis_survey_required).toBe("Standard");
|
||||
expect("osmosis_survey_date" in typeOnlyProps).toBe(false);
|
||||
|
||||
// Setting only the date — type column is untouched.
|
||||
const updateDbDate = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -427,10 +427,10 @@ describe("applyDealPropertyUpdate", () => {
|
|||
expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date);
|
||||
expect("domnaSurveyType" in dateOnlyDb).toBe(false);
|
||||
const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties;
|
||||
expect(dateOnlyProps.domna_survey_date).toBe(
|
||||
expect(dateOnlyProps.osmosis_survey_date).toBe(
|
||||
String(new Date(surveyIso).getTime()),
|
||||
);
|
||||
expect("domna_survey_type" in dateOnlyProps).toBe(false);
|
||||
expect("osmosis_survey_required" in dateOnlyProps).toBe(false);
|
||||
});
|
||||
|
||||
it("clears both domna fields to null when explicitly cleared", async () => {
|
||||
|
|
@ -453,8 +453,8 @@ describe("applyDealPropertyUpdate", () => {
|
|||
expect(dbValues.domnaSurveyType).toBeNull();
|
||||
expect(dbValues.domnaSurveyDate).toBeNull();
|
||||
const props = pushHubspot.mock.calls[0][0].properties;
|
||||
expect(props.domna_survey_type).toBe("");
|
||||
expect(props.domna_survey_date).toBe("");
|
||||
expect(props.osmosis_survey_required).toBe("");
|
||||
expect(props.osmosis_survey_date).toBe("");
|
||||
});
|
||||
|
||||
it("surfaces HubSpot push failures back to the caller", async () => {
|
||||
|
|
|
|||
|
|
@ -149,14 +149,14 @@ export const DEAL_PROPERTY_FIELDS = {
|
|||
domna_survey_type: {
|
||||
schema: stringOrNullSchema,
|
||||
allowedRoles: APPROVER_ROLES,
|
||||
hubspotProperty: "domna_survey_type",
|
||||
hubspotProperty: "osmosis_survey_required",
|
||||
dbColumn: hubspotDealData.domnaSurveyType,
|
||||
toHubspot: stringToHubspot,
|
||||
} satisfies DealPropertyFieldDef<string | null>,
|
||||
domna_survey_date: {
|
||||
schema: isoDateSchema,
|
||||
allowedRoles: APPROVER_ROLES,
|
||||
hubspotProperty: "domna_survey_date",
|
||||
hubspotProperty: "osmosis_survey_date",
|
||||
dbColumn: hubspotDealData.domnaSurveyDate,
|
||||
toHubspot: dateToHubspot,
|
||||
} satisfies DealPropertyFieldDef<Date | null>,
|
||||
|
|
|
|||
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", () => {
|
||||
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: {
|
||||
hubspotDealId: string;
|
||||
notes: string;
|
||||
requestedByEmail: string;
|
||||
surveyType: string;
|
||||
requestedAt: Date;
|
||||
}): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const client = getHubSpotClient();
|
||||
const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`;
|
||||
await client.crm.deals.basicApi.update(params.hubspotDealId, {
|
||||
properties: { survey_request_log: log },
|
||||
properties: {
|
||||
osmosis_survey_required: params.surveyType,
|
||||
osmosis_survey_date: params.requestedAt.toISOString().slice(0, 10),
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@ import {
|
|||
PROPOSED_MEASURES_PROP,
|
||||
APPROVED_MEASURES_PROP,
|
||||
instructMeasure,
|
||||
instructMeasures,
|
||||
} from "./instructMeasure";
|
||||
import type {
|
||||
InstructTxOutcome,
|
||||
InstructMeasuresTxOutcome,
|
||||
RunInstructTx,
|
||||
RunInstructMeasuresTx,
|
||||
ReadInstructedMeasureNames,
|
||||
StampPushedAt,
|
||||
SyncMeasuresField,
|
||||
|
|
@ -307,3 +310,180 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
|
|||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// instructMeasures (plural) — batch variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBatchDeps(overrides?: {
|
||||
txOutcome?: Partial<InstructMeasuresTxOutcome>;
|
||||
txError?: Error;
|
||||
instructedAfter?: string[];
|
||||
syncResults?: Array<{ ok: true } | { ok: false; error: string }>;
|
||||
stampError?: Error;
|
||||
}) {
|
||||
const txOutcome: InstructMeasuresTxOutcome = {
|
||||
instructedRowIds: [1n, 2n],
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: [],
|
||||
...overrides?.txOutcome,
|
||||
};
|
||||
const runInstructMeasuresTx: RunInstructMeasuresTx = vi.fn(async () => {
|
||||
if (overrides?.txError) throw overrides.txError;
|
||||
return txOutcome;
|
||||
});
|
||||
const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
|
||||
async () => overrides?.instructedAfter ?? ["ASHP", "Solar PV"],
|
||||
);
|
||||
const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
|
||||
overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
|
||||
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
|
||||
return syncQueue.shift() ?? ({ ok: true } as const);
|
||||
});
|
||||
const stampPushedAt: StampPushedAt = vi.fn(async () => {
|
||||
if (overrides?.stampError) throw overrides.stampError;
|
||||
});
|
||||
return {
|
||||
runInstructMeasuresTx,
|
||||
readInstructedMeasureNames,
|
||||
syncMeasuresField,
|
||||
stampPushedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("instructMeasures — input validation", () => {
|
||||
it("rejects when measureNames is empty", async () => {
|
||||
const deps = makeBatchDeps();
|
||||
const result = await instructMeasures({
|
||||
dealId: "deal-1",
|
||||
measureNames: [],
|
||||
userId: 1n,
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "measureNames must not be empty" });
|
||||
expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
|
||||
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects when any measureName is unknown", async () => {
|
||||
const deps = makeBatchDeps();
|
||||
const result = await instructMeasures({
|
||||
dealId: "deal-1",
|
||||
measureNames: ["ASHP", "Not a real measure"],
|
||||
userId: 1n,
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "Unknown measure: Not a real measure" });
|
||||
expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
|
||||
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("instructMeasures — happy path", () => {
|
||||
it("commits single tx, pushes instructed + proposed + approved, stamps all rowIds", async () => {
|
||||
const deps = makeBatchDeps({
|
||||
instructedAfter: ["ASHP", "Solar PV"],
|
||||
txOutcome: {
|
||||
instructedRowIds: [10n, 11n],
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: ["ASHP", "Solar PV"],
|
||||
},
|
||||
});
|
||||
const result = await instructMeasures({
|
||||
dealId: "deal-42",
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
userId: 7n,
|
||||
deps,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, instructedRowIds: [10n, 11n], hubspotSync: "ok" });
|
||||
expect(deps.runInstructMeasuresTx).toHaveBeenCalledOnce();
|
||||
expect(deps.runInstructMeasuresTx).toHaveBeenCalledWith({
|
||||
dealId: "deal-42",
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
userId: 7n,
|
||||
notes: null,
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
|
||||
hubspotDealId: "deal-42",
|
||||
propName: INSTRUCTED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
|
||||
hubspotDealId: "deal-42",
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
|
||||
hubspotDealId: "deal-42",
|
||||
propName: APPROVED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
});
|
||||
expect(deps.stampPushedAt).toHaveBeenCalledTimes(2);
|
||||
expect(deps.stampPushedAt).toHaveBeenCalledWith(10n);
|
||||
expect(deps.stampPushedAt).toHaveBeenCalledWith(11n);
|
||||
});
|
||||
|
||||
it("merges all new measures into existing proposed (deduped)", async () => {
|
||||
const deps = makeBatchDeps({
|
||||
instructedAfter: ["ASHP", "EWI", "Solar PV"],
|
||||
txOutcome: {
|
||||
instructedRowIds: [20n, 21n],
|
||||
existingProposedMeasures: ["ASHP", "Loft insulation"],
|
||||
allApprovedMeasureNames: ["ASHP", "EWI", "Solar PV"],
|
||||
},
|
||||
});
|
||||
await instructMeasures({
|
||||
dealId: "deal-merge",
|
||||
measureNames: ["EWI", "Solar PV"],
|
||||
userId: 3n,
|
||||
deps,
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
|
||||
hubspotDealId: "deal-merge",
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Loft insulation", "EWI", "Solar PV"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("instructMeasures — DB transaction failure", () => {
|
||||
it("returns error and skips HubSpot when tx throws", async () => {
|
||||
const deps = makeBatchDeps({ txError: new Error("batch insert failed") });
|
||||
const result = await instructMeasures({
|
||||
dealId: "deal-x",
|
||||
measureNames: ["ASHP", "EWI"],
|
||||
userId: 1n,
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "batch insert failed" });
|
||||
expect(deps.syncMeasuresField).not.toHaveBeenCalled();
|
||||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("instructMeasures — HubSpot push failure leaves DB committed", () => {
|
||||
it("returns ok=true with hubspotSync=failed when sync fails, does NOT stamp", async () => {
|
||||
const deps = makeBatchDeps({
|
||||
instructedAfter: ["ASHP", "EWI"],
|
||||
txOutcome: {
|
||||
instructedRowIds: [30n, 31n],
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: ["ASHP", "EWI"],
|
||||
},
|
||||
syncResults: [{ ok: false, error: "hubspot 500" }, { ok: true }, { ok: true }],
|
||||
});
|
||||
const result = await instructMeasures({
|
||||
dealId: "deal-h",
|
||||
measureNames: ["ASHP", "EWI"],
|
||||
userId: 1n,
|
||||
deps,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
hubspotSync: "failed",
|
||||
hubspotError: "hubspot 500",
|
||||
});
|
||||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -196,6 +196,224 @@ const defaultStampPushedAt: StampPushedAt = async (rowId) => {
|
|||
.where(eq(userDefinedDealMeasures.id, rowId));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch (plural) types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstructMeasuresTxOutcome {
|
||||
instructedRowIds: bigint[];
|
||||
existingProposedMeasures: string[];
|
||||
allApprovedMeasureNames: string[];
|
||||
}
|
||||
|
||||
export type RunInstructMeasuresTx = (params: {
|
||||
dealId: string;
|
||||
measureNames: MeasureName[];
|
||||
userId: bigint;
|
||||
notes: string | null;
|
||||
}) => Promise<InstructMeasuresTxOutcome>;
|
||||
|
||||
export type InstructMeasuresResult =
|
||||
| {
|
||||
ok: true;
|
||||
instructedRowIds: bigint[];
|
||||
hubspotSync: "ok" | "failed";
|
||||
hubspotError?: string;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface InstructMeasuresInput {
|
||||
dealId: string;
|
||||
measureNames: string[];
|
||||
userId: bigint;
|
||||
notes?: string;
|
||||
deps?: {
|
||||
runInstructMeasuresTx?: RunInstructMeasuresTx;
|
||||
readInstructedMeasureNames?: ReadInstructedMeasureNames;
|
||||
syncMeasuresField?: SyncMeasuresField;
|
||||
stampPushedAt?: StampPushedAt;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultRunInstructMeasuresTx: RunInstructMeasuresTx = async ({
|
||||
dealId,
|
||||
measureNames,
|
||||
userId,
|
||||
notes,
|
||||
}) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const instructedRowIds: bigint[] = [];
|
||||
|
||||
for (const measureName of measureNames) {
|
||||
const inserted = await tx
|
||||
.insert(userDefinedDealMeasures)
|
||||
.values({
|
||||
hubspotDealId: dealId,
|
||||
measureName,
|
||||
source: "instructed",
|
||||
createdByUserId: userId,
|
||||
notes,
|
||||
})
|
||||
.returning({ id: userDefinedDealMeasures.id });
|
||||
const rowId = inserted[0]?.id;
|
||||
if (rowId === undefined || rowId === null) {
|
||||
throw new Error("Failed to insert user_defined_deal_measures row");
|
||||
}
|
||||
instructedRowIds.push(rowId);
|
||||
|
||||
await tx
|
||||
.insert(dealMeasureApprovals)
|
||||
.values({
|
||||
hubspotDealId: dealId,
|
||||
measureName,
|
||||
isApproved: true,
|
||||
approvedBy: userId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
dealMeasureApprovals.hubspotDealId,
|
||||
dealMeasureApprovals.measureName,
|
||||
],
|
||||
set: {
|
||||
isApproved: true,
|
||||
approvedBy: userId,
|
||||
approvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.insert(dealMeasureApprovalEvents).values({
|
||||
hubspotDealId: dealId,
|
||||
measureName,
|
||||
action: "approved",
|
||||
actedBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const dealRows = await tx
|
||||
.select({ proposedMeasures: hubspotDealData.proposedMeasures })
|
||||
.from(hubspotDealData)
|
||||
.where(eq(hubspotDealData.dealId, dealId))
|
||||
.limit(1);
|
||||
const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null);
|
||||
|
||||
const approvedRows = await tx
|
||||
.select({ measureName: dealMeasureApprovals.measureName })
|
||||
.from(dealMeasureApprovals)
|
||||
.where(
|
||||
and(
|
||||
eq(dealMeasureApprovals.hubspotDealId, dealId),
|
||||
eq(dealMeasureApprovals.isApproved, true),
|
||||
),
|
||||
);
|
||||
const allApprovedMeasureNames = approvedRows.map((r) => r.measureName);
|
||||
|
||||
return { instructedRowIds, existingProposedMeasures, allApprovedMeasureNames };
|
||||
});
|
||||
};
|
||||
|
||||
export async function instructMeasures(
|
||||
input: InstructMeasuresInput,
|
||||
): Promise<InstructMeasuresResult> {
|
||||
if (input.measureNames.length === 0) {
|
||||
return { ok: false, error: "measureNames must not be empty" };
|
||||
}
|
||||
|
||||
const validatedNames: MeasureName[] = [];
|
||||
for (const name of input.measureNames) {
|
||||
const trimmed = name.trim();
|
||||
if (!isMeasureName(trimmed)) {
|
||||
return { ok: false, error: `Unknown measure: ${trimmed}` };
|
||||
}
|
||||
validatedNames.push(trimmed);
|
||||
}
|
||||
|
||||
const runInstructMeasuresTx =
|
||||
input.deps?.runInstructMeasuresTx ?? defaultRunInstructMeasuresTx;
|
||||
const readInstructed =
|
||||
input.deps?.readInstructedMeasureNames ?? defaultReadInstructedMeasureNames;
|
||||
const syncMeasuresField =
|
||||
input.deps?.syncMeasuresField ?? defaultSyncMeasuresField;
|
||||
const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt;
|
||||
|
||||
let txResult: InstructMeasuresTxOutcome;
|
||||
try {
|
||||
txResult = await runInstructMeasuresTx({
|
||||
dealId: input.dealId,
|
||||
measureNames: validatedNames,
|
||||
userId: input.userId,
|
||||
notes: input.notes ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to instruct measures";
|
||||
console.error("[instructMeasures] transaction failed", {
|
||||
dealId: input.dealId,
|
||||
measureNames: validatedNames,
|
||||
error: err,
|
||||
});
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
const allInstructed = await readInstructed(input.dealId);
|
||||
|
||||
const mergedProposed = Array.from(
|
||||
new Set([...txResult.existingProposedMeasures, ...validatedNames]),
|
||||
);
|
||||
|
||||
const instructedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: INSTRUCTED_MEASURES_PROP,
|
||||
measureNames: allInstructed,
|
||||
});
|
||||
|
||||
const proposedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: mergedProposed,
|
||||
});
|
||||
|
||||
const approvedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: APPROVED_MEASURES_PROP,
|
||||
measureNames: txResult.allApprovedMeasureNames,
|
||||
});
|
||||
|
||||
const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok;
|
||||
|
||||
if (overallOk) {
|
||||
for (const rowId of txResult.instructedRowIds) {
|
||||
try {
|
||||
await stampPushedAt(rowId);
|
||||
} catch (err) {
|
||||
console.error("[instructMeasures] failed to stamp pushed_at", {
|
||||
rowId: String(rowId),
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
instructedRowIds: txResult.instructedRowIds,
|
||||
hubspotSync: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
const hubspotError = !instructedSync.ok
|
||||
? instructedSync.error
|
||||
: !proposedSync.ok
|
||||
? proposedSync.error
|
||||
: !approvedSync.ok
|
||||
? approvedSync.error
|
||||
: "HubSpot sync failed";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
instructedRowIds: txResult.instructedRowIds,
|
||||
hubspotSync: "failed",
|
||||
hubspotError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function instructMeasure(
|
||||
input: InstructMeasureInput,
|
||||
): Promise<InstructMeasureResult> {
|
||||
|
|
|
|||
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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Link2, Link2Off, AlertTriangle, Search } from "lucide-react";
|
||||
import { Building2, Link2, Link2Off, AlertTriangle, Search, CheckCircle2, PlusCircle } from "lucide-react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import {
|
||||
|
|
@ -18,9 +18,9 @@ type OrgSummary = {
|
|||
hubspotCompanyId: string | null;
|
||||
};
|
||||
|
||||
async function fetchCurrentOrg(portfolioId: string): Promise<OrgSummary | null> {
|
||||
async function fetchLinkedOrgs(portfolioId: string): Promise<OrgSummary[]> {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`);
|
||||
if (!res.ok) throw new Error("Failed to fetch linked organisation");
|
||||
if (!res.ok) throw new Error("Failed to fetch linked organisations");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -33,25 +33,25 @@ async function fetchAllOrgs(): Promise<OrgSummary[]> {
|
|||
export default function OrganisationLinkCard({ portfolioId }: { portfolioId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [connectOpen, setConnectOpen] = useState(false);
|
||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [disconnectTarget, setDisconnectTarget] = useState<OrgSummary | null>(null);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
|
||||
// Current linked org
|
||||
const { data: currentOrg, isLoading: loadingCurrent } = useQuery({
|
||||
queryKey: ["portfolio-org", portfolioId],
|
||||
queryFn: () => fetchCurrentOrg(portfolioId),
|
||||
const { data: linkedOrgs = [], isLoading: loadingLinked } = useQuery({
|
||||
queryKey: ["portfolio-orgs", portfolioId],
|
||||
queryFn: () => fetchLinkedOrgs(portfolioId),
|
||||
});
|
||||
|
||||
// All orgs — only fetched when connect modal is open
|
||||
const { data: allOrgs = [], isLoading: loadingOrgs } = useQuery({
|
||||
queryKey: ["all-organisations"],
|
||||
queryFn: fetchAllOrgs,
|
||||
enabled: connectOpen,
|
||||
enabled: addOpen,
|
||||
});
|
||||
|
||||
const linkedOrgIds = useMemo(() => new Set(linkedOrgs.map((o) => o.id)), [linkedOrgs]);
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: async (organisationId: string) => {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
||||
|
|
@ -63,8 +63,8 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
|
||||
setConnectOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
|
||||
setAddOpen(false);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
|
|
@ -72,116 +72,102 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
});
|
||||
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (organisationId: string) => {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/organisation`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ organisationId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to disconnect organisation");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-org", portfolioId] });
|
||||
setDisconnectOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["portfolio-orgs", portfolioId] });
|
||||
setDisconnectTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
const filteredOrgs = useMemo(
|
||||
() =>
|
||||
allOrgs.filter((o) =>
|
||||
(o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
[allOrgs, searchQuery],
|
||||
const availableOrgs = useMemo(
|
||||
() => allOrgs.filter((o) => !linkedOrgIds.has(o.id) && (o.name ?? "").toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
[allOrgs, linkedOrgIds, searchQuery],
|
||||
);
|
||||
|
||||
const selectedOrg = allOrgs.find((o) => o.id === selectedOrgId) ?? null;
|
||||
|
||||
function openAdd() {
|
||||
setAddOpen(true);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-brandblue/15 bg-white shadow-sm mt-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
|
||||
<div className="p-2 rounded-lg bg-brandblue/10">
|
||||
<Building2 className="h-4 w-4 text-brandblue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brandblue">Organisation Link</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Connect this portfolio to an organisation to enable live project tracking
|
||||
</p>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 bg-brandlightblue/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-brandblue/10">
|
||||
<Building2 className="h-4 w-4 text-brandblue" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-brandblue">Organisation Links</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Connect this portfolio to one or more organisations to enable live project tracking
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<PlusCircle className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add Organisation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{loadingCurrent ? (
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{loadingLinked ? (
|
||||
<div className="h-10 bg-gray-100 rounded-lg animate-pulse w-48" />
|
||||
) : currentOrg ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{currentOrg.name ?? "Unnamed organisation"}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Connected · HubSpot ID: {currentOrg.hubspotCompanyId ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
) : linkedOrgs.length === 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs border-brandblue/20 text-brandblue hover:bg-brandlightblue/30"
|
||||
onClick={() => {
|
||||
setConnectOpen(true);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Change
|
||||
</Button>
|
||||
<p className="text-sm text-gray-500">No organisations linked</p>
|
||||
</div>
|
||||
) : (
|
||||
linkedOrgs.map((org) => (
|
||||
<div key={org.id} className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">{org.name ?? "Unnamed organisation"}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Connected · HubSpot ID: {org.hubspotCompanyId ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs border-red-200 text-red-600 hover:bg-red-50"
|
||||
onClick={() => setDisconnectOpen(true)}
|
||||
onClick={() => setDisconnectTarget(org)}
|
||||
>
|
||||
<Link2Off className="h-3.5 w-3.5 mr-1.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No organisation linked</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs bg-brandblue hover:bg-brandmidblue"
|
||||
onClick={() => {
|
||||
setConnectOpen(true);
|
||||
setSelectedOrgId(null);
|
||||
setConfirmed(false);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Connect Organisation
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Connect modal ─────────────────────────────────────────────── */}
|
||||
<Dialog open={connectOpen} onOpenChange={(v) => { setConnectOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
|
||||
{/* ── Add Organisation modal ─────────────────────────────────────────────── */}
|
||||
<Dialog open={addOpen} onOpenChange={(v) => { setAddOpen(v); if (!v) { setSelectedOrgId(null); setConfirmed(false); setSearchQuery(""); } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogTitle className="text-brandblue">Connect Organisation</DialogTitle>
|
||||
<DialogTitle className="text-brandblue">Add Organisation</DialogTitle>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mt-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
|
|
@ -192,14 +178,13 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Org list */}
|
||||
<div className="mt-2 max-h-56 overflow-y-auto rounded-lg border border-gray-200 divide-y divide-gray-100">
|
||||
{loadingOrgs ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">Loading…</div>
|
||||
) : filteredOrgs.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">No organisations found</div>
|
||||
) : availableOrgs.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400 text-center">No organisations available</div>
|
||||
) : (
|
||||
filteredOrgs.map((org) => (
|
||||
availableOrgs.map((org) => (
|
||||
<button
|
||||
key={org.id}
|
||||
onClick={() => setSelectedOrgId(org.id)}
|
||||
|
|
@ -218,7 +203,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-lg bg-amber-50 border border-amber-200 mt-1">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
|
|
@ -226,7 +210,6 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation checkbox */}
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -238,7 +221,7 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
</label>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="outline" onClick={() => setConnectOpen(false)} className="text-sm">
|
||||
<Button variant="outline" onClick={() => setAddOpen(false)} className="text-sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -252,21 +235,21 @@ export default function OrganisationLinkCard({ portfolioId }: { portfolioId: str
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Disconnect confirm dialog ──────────────────────────────────── */}
|
||||
<Dialog open={disconnectOpen} onOpenChange={setDisconnectOpen}>
|
||||
{/* ── Disconnect confirm dialog ──────────────────────────────────────────── */}
|
||||
<Dialog open={!!disconnectTarget} onOpenChange={(v) => { if (!v) setDisconnectTarget(null); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle className="text-gray-800">Disconnect organisation?</DialogTitle>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<strong>{currentOrg?.name ?? "this organisation"}</strong>?
|
||||
Live project tracking data will no longer be visible to portfolio viewers.
|
||||
<strong>{disconnectTarget?.name ?? "this organisation"}</strong>?
|
||||
Live project tracking data for this organisation will no longer be visible to portfolio viewers.
|
||||
</p>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="outline" onClick={() => setDisconnectOpen(false)} className="text-sm">
|
||||
<Button variant="outline" onClick={() => setDisconnectTarget(null)} className="text-sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => disconnectMutation.mutate()}
|
||||
onClick={() => disconnectTarget && disconnectMutation.mutate(disconnectTarget.id)}
|
||||
disabled={disconnectMutation.isPending}
|
||||
className="bg-red-600 hover:bg-red-700 text-white text-sm"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||
import {
|
||||
applyBulkDocType,
|
||||
selectAllUnclassified,
|
||||
getClassifiedCount,
|
||||
getUnclassifiedIds,
|
||||
uploadedCountForType,
|
||||
} from "./classifyPhase";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -272,7 +279,6 @@ function DocTypeButtonGrid({
|
|||
uploadedDocs: string[];
|
||||
}) {
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const uploadedSet = new Set(uploadedDocs);
|
||||
const requiredSet = new Set(requiredDocs);
|
||||
const isOtherSelected = value !== "" && !requiredSet.has(value);
|
||||
|
||||
|
|
@ -283,7 +289,7 @@ function DocTypeButtonGrid({
|
|||
{requiredDocs.map((docType) => {
|
||||
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
|
||||
const label = option?.label ?? docType;
|
||||
const alreadyUploaded = uploadedSet.has(docType);
|
||||
const uploadedCount = uploadedCountForType(uploadedDocs, docType);
|
||||
const isSelected = value === docType;
|
||||
|
||||
return (
|
||||
|
|
@ -291,21 +297,19 @@ function DocTypeButtonGrid({
|
|||
key={docType}
|
||||
type="button"
|
||||
onClick={() => { onChange(docType); setShowOther(false); }}
|
||||
title={alreadyUploaded ? `${label} — already uploaded` : label}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
|
||||
title={uploadedCount > 0 ? `${label} — ${uploadedCount} already uploaded` : label}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
|
||||
isSelected
|
||||
? "bg-brandblue text-white border-brandblue shadow-sm"
|
||||
: alreadyUploaded
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
|
||||
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
|
||||
}`}
|
||||
>
|
||||
{alreadyUploaded && !isSelected && (
|
||||
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
{uploadedCount > 0 && !isSelected && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 rounded-full bg-gray-100 text-gray-500 text-[10px] font-semibold leading-none">
|
||||
{uploadedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -432,6 +436,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// The measure selected in the measure-select phase (empty = "not measure-specific")
|
||||
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
|
||||
// Bulk classify state
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkDocType, setBulkDocType] = useState("");
|
||||
|
||||
// ── Fetch existing unclassified files on mount ───────────────────────
|
||||
|
||||
|
|
@ -581,14 +588,35 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
}
|
||||
|
||||
const classifiableEntries = queue.filter((f) => f.status === "done" && f.uploadedId);
|
||||
const allClassified = classifiableEntries.length > 0 && classifiableEntries.every((f) => f.docType !== "");
|
||||
const classifiedCount = getClassifiedCount(classifiableEntries);
|
||||
const unclassifiedCount = getUnclassifiedIds(classifiableEntries).length;
|
||||
|
||||
function handleBulkApply() {
|
||||
if (!bulkDocType) return;
|
||||
setQueue((prev) => applyBulkDocType(prev, selectedIds, bulkDocType));
|
||||
setSelectedIds(new Set());
|
||||
setBulkDocType("");
|
||||
}
|
||||
|
||||
function toggleSelectId(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectAllUnclassified() {
|
||||
setSelectedIds(selectAllUnclassified(classifiableEntries));
|
||||
}
|
||||
|
||||
async function handleSaveClassifications() {
|
||||
setSaveError(null);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const toSave = classifiableEntries.filter((f) => f.docType !== "");
|
||||
await saveClassifications(
|
||||
classifiableEntries.map((f) => ({
|
||||
toSave.map((f) => ({
|
||||
id: f.uploadedId!,
|
||||
fileType: f.docType,
|
||||
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
|
||||
|
|
@ -810,17 +838,43 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Select-all row */}
|
||||
{classifiableEntries.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="select-all-unclassified"
|
||||
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer"
|
||||
checked={selectedIds.size > 0 && selectedIds.size === selectAllUnclassified(classifiableEntries).size}
|
||||
onChange={() => {
|
||||
const allUnclassified = selectAllUnclassified(classifiableEntries);
|
||||
setSelectedIds(allUnclassified.size === selectedIds.size ? new Set() : allUnclassified);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="select-all-unclassified" className="text-xs text-gray-500 cursor-pointer select-none">
|
||||
Select all unclassified ({unclassifiedCount})
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list with classification */}
|
||||
<div className="space-y-3">
|
||||
{classifiableEntries.map((entry) => {
|
||||
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
|
||||
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
|
||||
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
|
||||
const isChecked = selectedIds.has(entry.id);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
|
||||
<div key={entry.id} className={`rounded-lg border bg-gray-50/50 p-3 space-y-2.5 transition-colors ${isChecked ? "border-brandblue/30 bg-brandlightblue/5" : "border-gray-100"}`}>
|
||||
{/* File info row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-brandblue accent-brandblue cursor-pointer shrink-0"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleSelectId(entry.id)}
|
||||
/>
|
||||
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
|
||||
|
|
@ -897,6 +951,51 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
})()}
|
||||
</div>
|
||||
|
||||
{/* ── Bulk classify toolbar — shown when files are selected ── */}
|
||||
{phase === "classify" && selectedIds.size > 0 && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-brandlightblue/10 border-t border-brandblue/20">
|
||||
<span className="text-xs font-semibold text-brandblue whitespace-nowrap">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
<span className="text-xs text-gray-600 whitespace-nowrap">Classify as:</span>
|
||||
<Select value={bulkDocType} onValueChange={setBulkDocType}>
|
||||
<SelectTrigger className="h-7 text-xs flex-1 max-w-56">
|
||||
<SelectValue placeholder="Choose type…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILE_TYPE_GROUPS.map((group) => {
|
||||
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<SelectGroup key={group}>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">{group}</SelectLabel>
|
||||
{items.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-brandblue text-white shrink-0"
|
||||
disabled={!bulkDocType}
|
||||
onClick={handleBulkApply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-gray-400 hover:text-gray-600 shrink-0 ml-1"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
|
||||
{phase === "loading" && (
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
|
|
@ -930,17 +1029,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
|
|||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveClassifications}
|
||||
disabled={!allClassified || isSaving}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
||||
) : (
|
||||
"Save Classifications →"
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<Button
|
||||
onClick={handleSaveClassifications}
|
||||
disabled={classifiedCount === 0 || isSaving}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Saving…</>
|
||||
) : (
|
||||
`Save ${classifiedCount} classified →`
|
||||
)}
|
||||
</Button>
|
||||
{unclassifiedCount > 0 && (
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{unclassifiedCount} unclassified — will stay in your queue
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type {
|
|||
DocumentDrawerState,
|
||||
DocStatusMap,
|
||||
RemovalStatusByDeal,
|
||||
InstructedMeasuresByDeal,
|
||||
} from "./types";
|
||||
|
||||
export default function LiveTracker({
|
||||
|
|
@ -39,6 +40,7 @@ export default function LiveTracker({
|
|||
docStatusMap,
|
||||
userCapability,
|
||||
approvalsByDeal,
|
||||
instructedMeasuresByDeal,
|
||||
removalStatusByDeal,
|
||||
portfolioId,
|
||||
userRole,
|
||||
|
|
@ -304,7 +306,9 @@ export default function LiveTracker({
|
|||
<MeasuresTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
instructedMeasuresByDeal={instructedMeasuresByDeal}
|
||||
portfolioId={portfolioId}
|
||||
isApprover={userCapability.includes("approver")}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -13,34 +12,28 @@ import {
|
|||
} from "@/app/shadcn_components/ui/table";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
||||
import { Search, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
|
||||
import { Search, CheckSquare, ListChecks, Loader2, X } from "lucide-react";
|
||||
import { STAGE_COLORS } from "./types";
|
||||
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
|
||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||
import { filterMeasureRows } from "./measureFilters";
|
||||
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
|
||||
|
||||
type AuditEvent = {
|
||||
id: string;
|
||||
hubspotDealId: string;
|
||||
measureName: string;
|
||||
action: string; // 'approved' | 'unapproved'
|
||||
actedByEmail: string;
|
||||
actedByName: string | null;
|
||||
actedAt: string; // ISO string
|
||||
};
|
||||
type Mode = "chip-click" | "instruct";
|
||||
|
||||
type Props = {
|
||||
data: ClassifiedDeal[];
|
||||
approvalsByDeal: ApprovalsByDeal;
|
||||
instructedMeasuresByDeal: Record<string, string[]>;
|
||||
portfolioId: string;
|
||||
isApprover: boolean;
|
||||
};
|
||||
|
||||
function ApprovalStatus({
|
||||
proposed,
|
||||
approved,
|
||||
}: {
|
||||
proposed: string[];
|
||||
approved: string[];
|
||||
}) {
|
||||
// ── Approval status badge ────────────────────────────────────────────────────
|
||||
|
||||
function ApprovalStatus({ proposed, approved }: { proposed: string[]; approved: string[] }) {
|
||||
if (proposed.length === 0) return null;
|
||||
const approvedSet = new Set(approved);
|
||||
const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
|
||||
|
|
@ -66,103 +59,295 @@ function ApprovalStatus({
|
|||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
// ── Bulk approve modal ───────────────────────────────────────────────────────
|
||||
|
||||
function ActivityLog({
|
||||
dealId,
|
||||
portfolioId,
|
||||
}: {
|
||||
dealId: string;
|
||||
type BulkApproveModalProps = {
|
||||
selected: Array<{ dealId: string; dealname: string | null; measureName: string }>;
|
||||
portfolioId: string;
|
||||
}) {
|
||||
const { data, isLoading } = useQuery<{ events: AuditEvent[] }>({
|
||||
queryKey: ["approvalEvents", portfolioId, dealId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/approvals?dealIds=${dealId}&include=events`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch events");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 py-2 pl-4">Loading activity…</p>
|
||||
);
|
||||
}
|
||||
function BulkApproveModal({ selected, portfolioId, onClose, onSuccess }: BulkApproveModalProps) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const events = data?.events ?? [];
|
||||
const groupedByDeal = useMemo(() => {
|
||||
const map = new Map<string, { dealname: string | null; measures: string[] }>();
|
||||
for (const item of selected) {
|
||||
const existing = map.get(item.dealId);
|
||||
if (existing) {
|
||||
existing.measures.push(item.measureName);
|
||||
} else {
|
||||
map.set(item.dealId, { dealname: item.dealname, measures: [item.measureName] });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [selected]);
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-gray-400 py-2 pl-4">No activity yet.</p>
|
||||
);
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-approvals`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
changes: selected.map((s) => ({
|
||||
hubspotDealId: s.dealId,
|
||||
measureName: s.measureName,
|
||||
approved: true,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
setError(data.error ?? "Failed to approve measures");
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
} catch {
|
||||
setError("Network error — please try again");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pl-4 pr-2 pb-3 space-y-1.5">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
e.action === "approved"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-red-50 text-red-600"
|
||||
}`}
|
||||
>
|
||||
{e.action === "approved" ? "Approved" : "Unapproved"}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">{e.measureName}</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-500">
|
||||
{e.actedByName ?? e.actedByEmail}
|
||||
</span>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-400">{formatDate(e.actedAt)}</span>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Confirm bulk approval</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Approving <span className="font-medium text-gray-800">{selected.length} measure{selected.length !== 1 ? "s" : ""}</span> across{" "}
|
||||
<span className="font-medium text-gray-800">{groupedByDeal.size} propert{groupedByDeal.size !== 1 ? "ies" : "y"}</span>.
|
||||
</p>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto space-y-2">
|
||||
{[...groupedByDeal.entries()].map(([dealId, { dealname, measures }]) => (
|
||||
<div key={dealId} className="rounded-lg border border-gray-100 p-3 space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-700">{dealname ?? dealId}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{measures.map((m) => (
|
||||
<span key={m} className="px-2 py-0.5 rounded-full text-xs bg-emerald-50 border border-emerald-200 text-emerald-700">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
|
||||
Approve {selected.length} measure{selected.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bulk instruct modal ──────────────────────────────────────────────────────
|
||||
|
||||
type BulkInstructModalProps = {
|
||||
selectedDealIds: string[];
|
||||
deals: ClassifiedDeal[];
|
||||
portfolioId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
function BulkInstructModal({ selectedDealIds, deals, portfolioId, onClose, onSuccess }: BulkInstructModalProps) {
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(new Set());
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedDeals = useMemo(
|
||||
() => deals.filter((d) => selectedDealIds.includes(d.dealId)),
|
||||
[deals, selectedDealIds],
|
||||
);
|
||||
|
||||
function toggleMeasure(name: string) {
|
||||
setSelectedMeasures((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit = selectedMeasures.size > 0 && confirmText.trim() === "confirm" && !submitting;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-instructed-measures`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
deals: selectedDealIds.map((dealId) => ({
|
||||
dealId,
|
||||
measureNames: [...selectedMeasures],
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
setError(data.error ?? "Failed to instruct measures");
|
||||
return;
|
||||
}
|
||||
onSuccess();
|
||||
} catch {
|
||||
setError("Network error — please try again");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Bulk instruct measures</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Instructing on <span className="font-medium text-gray-800">{selectedDeals.length} propert{selectedDeals.length !== 1 ? "ies" : "y"}</span>.
|
||||
Select measures to add to all selected deals.
|
||||
</p>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MEASURE_NAMES.map((name) => (
|
||||
<label
|
||||
key={name}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-xs transition-colors ${
|
||||
selectedMeasures.has(name)
|
||||
? "bg-indigo-50 border-indigo-300 text-indigo-700"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedMeasures.has(name)}
|
||||
onCheckedChange={() => toggleMeasure(name)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
{name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedMeasures.size > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-500">
|
||||
Type <span className="font-mono font-semibold">confirm</span> to proceed
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="confirm"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
|
||||
Instruct {selectedMeasures.size > 0 ? selectedMeasures.size : ""} measure{selectedMeasures.size !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function MeasuresTable({
|
||||
data,
|
||||
approvalsByDeal,
|
||||
instructedMeasuresByDeal,
|
||||
portfolioId,
|
||||
isApprover,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [mode, setMode] = useState<Mode>("chip-click");
|
||||
|
||||
// Chip-click mode: Set<"dealId::measureName">
|
||||
const [selectedChips, setSelectedChips] = useState<Set<string>>(new Set());
|
||||
|
||||
// Instruct mode: Set<dealId>
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||
const [showInstructModal, setShowInstructModal] = useState(false);
|
||||
|
||||
// Filter to only properties with proposed measures
|
||||
const dealsWithMeasures = useMemo(
|
||||
() => data.filter((d) => d.proposedMeasures),
|
||||
[data],
|
||||
() => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0),
|
||||
[data, instructedMeasuresByDeal],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
if (!q) return dealsWithMeasures;
|
||||
return dealsWithMeasures.filter(
|
||||
(d) =>
|
||||
d.dealname?.toLowerCase().includes(q) ||
|
||||
d.landlordPropertyId?.toLowerCase().includes(q) ||
|
||||
d.proposedMeasures?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [dealsWithMeasures, search]);
|
||||
const filtered = useMemo(
|
||||
() => filterMeasureRows(dealsWithMeasures, instructedMeasuresByDeal, search),
|
||||
[dealsWithMeasures, instructedMeasuresByDeal, search],
|
||||
);
|
||||
|
||||
function toggleRowExpand(dealId: string) {
|
||||
setExpandedRows((prev) => {
|
||||
function switchMode(next: Mode) {
|
||||
setMode(next);
|
||||
setSelectedChips(new Set());
|
||||
setSelectedRows(new Set());
|
||||
}
|
||||
|
||||
function toggleChip(dealId: string, measureName: string) {
|
||||
const key = `${dealId}::${measureName}`;
|
||||
setSelectedChips((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRow(dealId: string) {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(dealId)) next.delete(dealId);
|
||||
else next.add(dealId);
|
||||
|
|
@ -170,6 +355,45 @@ export default function MeasuresTable({
|
|||
});
|
||||
}
|
||||
|
||||
function toggleAllFiltered() {
|
||||
const allIds = filtered.map((d) => d.dealId);
|
||||
const allSelected = allIds.every((id) => selectedRows.has(id));
|
||||
if (allSelected) {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
allIds.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
allIds.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const selectedChipItems = useMemo(
|
||||
() =>
|
||||
[...selectedChips].map((key) => {
|
||||
const [dealId, measureName] = key.split("::");
|
||||
const deal = data.find((d) => d.dealId === dealId);
|
||||
return { dealId, dealname: deal?.dealname ?? null, measureName };
|
||||
}),
|
||||
[selectedChips, data],
|
||||
);
|
||||
|
||||
const allFilteredSelected =
|
||||
filtered.length > 0 && filtered.every((d) => selectedRows.has(d.dealId));
|
||||
|
||||
function handleActionSuccess() {
|
||||
setShowApproveModal(false);
|
||||
setShowInstructModal(false);
|
||||
setSelectedChips(new Set());
|
||||
setSelectedRows(new Set());
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
if (dealsWithMeasures.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
|
||||
|
|
@ -180,9 +404,11 @@ export default function MeasuresTable({
|
|||
);
|
||||
}
|
||||
|
||||
const colSpan = mode === "instruct" ? 7 : 6;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
{/* ── Toolbar ──────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
|
|
@ -193,22 +419,100 @@ export default function MeasuresTable({
|
|||
className="pl-9 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
{filtered.length} of {dealsWithMeasures.length} properties
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 hidden sm:inline">
|
||||
· Click a row to open property
|
||||
</span>
|
||||
|
||||
{isApprover && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === "chip-click" ? "default" : "outline"}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={() => switchMode("chip-click")}
|
||||
>
|
||||
<CheckSquare className="h-3.5 w-3.5" />
|
||||
Approve mode
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === "instruct" ? "default" : "outline"}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={() => switchMode("instruct")}
|
||||
>
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
Instruct mode
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
|
||||
{/* ── Action bar ───────────────────────────────────────────────── */}
|
||||
{isApprover && mode === "chip-click" && selectedChips.size > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5">
|
||||
<span className="text-sm text-emerald-800 font-medium">
|
||||
{selectedChips.size} measure{selectedChips.size !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
onClick={() => setShowApproveModal(true)}
|
||||
>
|
||||
Approve selected
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setSelectedChips(new Set())}
|
||||
className="ml-auto text-emerald-600 hover:text-emerald-800 text-xs"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isApprover && mode === "instruct" && selectedRows.size > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-2.5">
|
||||
<span className="text-sm text-indigo-800 font-medium">
|
||||
{selectedRows.size} propert{selectedRows.size !== 1 ? "ies" : "y"} selected
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
onClick={() => setShowInstructModal(true)}
|
||||
>
|
||||
Instruct measures
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setSelectedRows(new Set())}
|
||||
className="ml-auto text-indigo-600 hover:text-indigo-800 text-xs"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Table ────────────────────────────────────────────────────── */}
|
||||
<div className="relative rounded-xl border border-gray-100 overflow-hidden bg-white">
|
||||
{isPending && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brandblue" />
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 border-b border-gray-100">
|
||||
<TableHead className="w-6" />
|
||||
{mode === "instruct" && (
|
||||
<TableHead className="w-10 pl-3">
|
||||
<Checkbox
|
||||
checked={allFilteredSelected}
|
||||
onCheckedChange={toggleAllFiltered}
|
||||
aria-label="Select all filtered"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Address
|
||||
</TableHead>
|
||||
|
|
@ -216,7 +520,13 @@ export default function MeasuresTable({
|
|||
Stage
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Proposed Measures
|
||||
Proposed
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Instructed
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Tech Approved
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Status
|
||||
|
|
@ -226,121 +536,161 @@ export default function MeasuresTable({
|
|||
<TableBody>
|
||||
{filtered.map((deal) => {
|
||||
const proposed = parseMeasures(deal.proposedMeasures);
|
||||
const instructed = instructedMeasuresByDeal[deal.dealId] ?? [];
|
||||
const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall);
|
||||
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
|
||||
const approvedSet = new Set(approvedForDeal);
|
||||
const stageColor = STAGE_COLORS[deal.displayStage];
|
||||
const isExpanded = expandedRows.has(deal.dealId);
|
||||
const isRowSelected = selectedRows.has(deal.dealId);
|
||||
|
||||
const dealPageUrl = `/portfolio/${portfolioId}/your-projects/live/${deal.dealId}?tab=works`;
|
||||
|
||||
const handleRowClick = () => {
|
||||
const handleRowClick = (e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking a chip or checkbox
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-chip]") || target.closest("[data-checkbox]")) return;
|
||||
router.push(dealPageUrl);
|
||||
};
|
||||
const handleRowKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
router.push(dealPageUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment key={deal.dealId}>
|
||||
<TableRow
|
||||
data-testid="measures-row"
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Expand toggle */}
|
||||
<TableCell className="py-3 pl-3 pr-0 w-6">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleRowExpand(deal.dealId); }}
|
||||
className="text-gray-400 hover:text-brandblue transition-colors"
|
||||
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<TableRow
|
||||
key={deal.dealId}
|
||||
data-testid="measures-row"
|
||||
onClick={handleRowClick}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
router.push(dealPageUrl);
|
||||
}
|
||||
}}
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${
|
||||
isRowSelected ? "bg-indigo-50/40" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Row checkbox (instruct mode only) */}
|
||||
{mode === "instruct" && (
|
||||
<TableCell className="pl-3 py-3 w-10" data-checkbox>
|
||||
<Checkbox
|
||||
checked={isRowSelected}
|
||||
onCheckedChange={() => toggleRow(deal.dealId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4"
|
||||
aria-label={`Select ${deal.dealname ?? deal.dealId}`}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Address */}
|
||||
<TableCell className="py-3">
|
||||
<div className="font-medium text-sm text-gray-800">
|
||||
{deal.dealname ?? "—"}
|
||||
{/* Address */}
|
||||
<TableCell className="py-3">
|
||||
<div className="font-medium text-sm text-gray-800">
|
||||
{deal.dealname ?? "—"}
|
||||
</div>
|
||||
{deal.landlordPropertyId && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{deal.landlordPropertyId}
|
||||
</div>
|
||||
{deal.landlordPropertyId && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{deal.landlordPropertyId}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Stage */}
|
||||
<TableCell className="py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
|
||||
{deal.displayStage}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* Stage */}
|
||||
<TableCell className="py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
|
||||
{deal.displayStage}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Proposed measures — read-only; click the row to approve in the drawer */}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{proposed.map((measure) => {
|
||||
const isApproved = approvedSet.has(measure);
|
||||
return (
|
||||
<span
|
||||
key={measure}
|
||||
className={`px-2 py-1 rounded-full text-xs border ${
|
||||
isApproved
|
||||
{/* Proposed measures */}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{proposed.map((measure) => {
|
||||
const chipKey = `${deal.dealId}::${measure}`;
|
||||
const isSelected = selectedChips.has(chipKey);
|
||||
const isApproved = approvedSet.has(measure);
|
||||
const clickable = isApprover && mode === "chip-click";
|
||||
|
||||
return (
|
||||
<span
|
||||
key={measure}
|
||||
data-chip
|
||||
onClick={clickable ? (e) => { e.stopPropagation(); toggleChip(deal.dealId, measure); } : undefined}
|
||||
className={`px-2 py-1 rounded-full text-xs border transition-all ${
|
||||
isSelected
|
||||
? "bg-emerald-100 border-emerald-400 text-emerald-800 ring-2 ring-emerald-300"
|
||||
: isApproved
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: "bg-gray-50 border-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{measure}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
} ${clickable ? "cursor-pointer hover:border-emerald-400" : ""}`}
|
||||
>
|
||||
{measure}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Status */}
|
||||
<TableCell className="py-3">
|
||||
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
|
||||
</TableCell>
|
||||
{/* Instructed measures */}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{instructed.map((measure) => (
|
||||
<span
|
||||
key={measure}
|
||||
className="px-2 py-1 rounded-full text-xs border bg-indigo-50 border-indigo-200 text-indigo-700"
|
||||
>
|
||||
{measure}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
</TableRow>
|
||||
{/* Tech approved */}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{techApproved.map((measure) => (
|
||||
<span
|
||||
key={measure}
|
||||
className="px-2 py-1 rounded-full text-xs border bg-violet-50 border-violet-200 text-violet-700"
|
||||
>
|
||||
{measure}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Expandable activity log row */}
|
||||
{isExpanded && (
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="p-0"
|
||||
>
|
||||
<div className="border-t border-gray-100">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-2 pb-1">
|
||||
Activity log
|
||||
</p>
|
||||
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
{/* Approval status */}
|
||||
<TableCell className="py-3">
|
||||
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* ── Modals ───────────────────────────────────────────────────── */}
|
||||
{showApproveModal && (
|
||||
<BulkApproveModal
|
||||
selected={selectedChipItems}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setShowApproveModal(false)}
|
||||
onSuccess={handleActionSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInstructModal && (
|
||||
<BulkInstructModal
|
||||
selectedDealIds={[...selectedRows]}
|
||||
deals={data}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setShowInstructModal(false)}
|
||||
onSuccess={handleActionSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
FileDown,
|
||||
FileText,
|
||||
FileX,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
X,
|
||||
ExternalLink,
|
||||
HardHat,
|
||||
} from "lucide-react";
|
||||
import { FileDown, X } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
|
|
@ -22,138 +12,7 @@ import {
|
|||
DrawerDescription,
|
||||
} from "@/app/shadcn_components/ui/drawer";
|
||||
import type { PropertyDocument, DocStatus } from "./types";
|
||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
||||
|
||||
// Human-readable labels for all DB fileType enum values
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
// Survey / retrofit assessment docs
|
||||
photo_pack: "Photo Pack",
|
||||
site_note: "Site Note",
|
||||
rd_sap_site_note: "RdSAP Site Note",
|
||||
pas_2023_ventilation: "PAS 2023 Ventilation",
|
||||
pas_2023_condition: "PAS 2023 Condition Report",
|
||||
pas_significance: "PAS Significance",
|
||||
par_photo_pack: "PAR Photo Pack",
|
||||
pas_2023_property: "PAS 2023 Property Report",
|
||||
pas_2023_occupancy: "PAS 2023 Occupancy Report",
|
||||
ecmk_site_note: "ECMK Site Note",
|
||||
ecmk_rd_sap_site_note: "ECMK RdSAP Site Note",
|
||||
ecmk_survey_xml: "ECMK Survey XML",
|
||||
// Install docs — photos
|
||||
pre_photo: "Pre-Install Photos",
|
||||
mid_photo: "Mid-Install Photos",
|
||||
post_photo: "Post-Install Photos",
|
||||
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
|
||||
dmev_photos: "DMEV Photos (Wetrooms)",
|
||||
door_undercut_photos: "Door Undercut Photos",
|
||||
trickle_vent_photos: "Trickle Vent Photos",
|
||||
// Install docs — pre-installation
|
||||
pre_installation_building_inspection: "PIBI / Tech Survey",
|
||||
point_of_work_risk_assessment: "Point of Work Risk Assessment",
|
||||
// Install docs — compliance & lodgement
|
||||
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
|
||||
mcs_compliance_certificate: "MCS Compliance Certificate",
|
||||
certificate_of_conformity: "Certificate of Conformity",
|
||||
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
|
||||
trustmark_licence_numbers: "TrustMark Licence Numbers",
|
||||
operative_competency: "Operative Competency",
|
||||
// Install docs — ventilation
|
||||
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
|
||||
anemometer_readings: "Anemometer Readings",
|
||||
commissioning_records: "Commissioning Records",
|
||||
part_f_ventilation_document: "Approved Document Part F",
|
||||
// Install docs — handover & warranties
|
||||
handover_pack: "Handover Pack",
|
||||
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
|
||||
workmanship_warranty: "Workmanship Warranty",
|
||||
g98_notification: "G98 / G99 Notification",
|
||||
// Install docs — qualifications & other
|
||||
installer_qualifications: "Installer Qualifications",
|
||||
installer_feedback: "Installer Feedback",
|
||||
contractor_other: "Other",
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reusable download button — encapsulates the presigned URL mutation
|
||||
// -----------------------------------------------------------------------
|
||||
function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
|
||||
const { mutate: download, isPending: signing } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/sign-document-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: doc.s3FileKey, bucket: doc.s3FileBucket }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to get signed URL");
|
||||
const data = await res.json();
|
||||
return data.url as string;
|
||||
},
|
||||
onSuccess: (url) => {
|
||||
window.open(url, "_blank");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => download()}
|
||||
disabled={signing}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{signing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{signing ? "Preparing…" : "Download"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Individual document row — used in retrofit section and install fallback
|
||||
// -----------------------------------------------------------------------
|
||||
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
|
||||
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
|
||||
>
|
||||
{/* Left: icon + label + date stacked */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{showMeasure && doc.measureName
|
||||
? <><span className="text-brandblue/70 font-medium">{doc.measureName}</span> · {formatDate(doc.s3UploadTimestamp)}</>
|
||||
: formatDate(doc.s3UploadTimestamp)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadDocButton doc={doc} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
import PropertyDocumentsContent from "./PropertyDocumentsContent";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PropertyDrawer — main component
|
||||
|
|
@ -190,9 +49,7 @@ export default function PropertyDrawer({
|
|||
else if (uprn) params.set("uprn", uprn);
|
||||
else if (landlordPropertyId)
|
||||
params.set("landlordPropertyId", landlordPropertyId);
|
||||
const res = await fetch(
|
||||
`/api/live-tracking/property-documents?${params}`,
|
||||
);
|
||||
const res = await fetch(`/api/live-tracking/property-documents?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load documents");
|
||||
return res.json() as Promise<PropertyDocument[]>;
|
||||
},
|
||||
|
|
@ -200,31 +57,17 @@ export default function PropertyDrawer({
|
|||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Keep the last successfully fetched result so the closing animation doesn't
|
||||
// flash the empty state (the parent nulls out uprn/landlordPropertyId on close,
|
||||
// which disables the query and resets fetchedDocuments to [] mid-animation).
|
||||
// Preserve last fetched docs so the closing animation doesn't flash empty state
|
||||
// when the parent nulls identifiers mid-animation.
|
||||
const lastDocumentsRef = useRef<PropertyDocument[]>([]);
|
||||
if (open && !isFetching && !isError) {
|
||||
lastDocumentsRef.current = fetchedDocuments as PropertyDocument[];
|
||||
}
|
||||
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
|
||||
|
||||
// Split documents into the two sections
|
||||
const retrofitDocs = documents.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType));
|
||||
const installDocs = documents.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType));
|
||||
|
||||
const hasDocuments = documents.length > 0;
|
||||
|
||||
// Missing mandatory retrofit assessment docs (ecmk types are optional — not shown as missing)
|
||||
const presentRetrofitTypes = new Set(retrofitDocs.map((d) => d.docType));
|
||||
const missingRetrofitTypes = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter(
|
||||
(t) => !presentRetrofitTypes.has(t),
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
||||
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[40vw] min-w-80 rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl overflow-hidden">
|
||||
{/* Remove the default drag handle */}
|
||||
<div className="hidden" />
|
||||
|
||||
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
|
||||
|
|
@ -253,7 +96,7 @@ export default function PropertyDrawer({
|
|||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
{hasDocuments && !isFetching && (
|
||||
{documents.length > 0 && !isFetching && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-brandblue/10 border border-brandblue/20">
|
||||
<FileDown className="h-3.5 w-3.5 text-brandblue" />
|
||||
<span className="text-xs font-medium text-brandblue">
|
||||
|
|
@ -263,242 +106,18 @@ export default function PropertyDrawer({
|
|||
)}
|
||||
</DrawerHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Loading state */}
|
||||
{isFetching && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 rounded-lg bg-gray-100 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{isError && !isFetching && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
|
||||
<ExternalLink className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Could not load documents
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isFetching && !isError && !hasDocuments && (
|
||||
<div className="space-y-4 pt-1">
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center mb-3">
|
||||
<FolderOpen className="h-6 w-6 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
No documents available
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
|
||||
Missing Documents ({missingRetrofitTypes.length})
|
||||
</h3>
|
||||
{missingRetrofitTypes.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
|
||||
>
|
||||
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{DOC_TYPE_LABELS[t] ?? t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{!isFetching && !isError && hasDocuments && (
|
||||
<>
|
||||
{/* ── Retrofit Assessment Documents ── */}
|
||||
<motion.div
|
||||
key="retrofit"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
|
||||
Retrofit Assessment Documents
|
||||
</h3>
|
||||
{retrofitDocs.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{retrofitDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 px-0.5">None uploaded yet.</p>
|
||||
)}
|
||||
|
||||
{/* Missing mandatory retrofit assessment docs */}
|
||||
{missingRetrofitTypes.length > 0 && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
|
||||
Missing ({missingRetrofitTypes.length})
|
||||
</h4>
|
||||
{missingRetrofitTypes.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
|
||||
>
|
||||
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{DOC_TYPE_LABELS[t] ?? t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* ── Install Documents ── */}
|
||||
<motion.div
|
||||
key="install"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
|
||||
<HardHat className="h-3.5 w-3.5" />
|
||||
Install Documents
|
||||
</h3>
|
||||
|
||||
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
|
||||
// ── Per-measure checklist ──
|
||||
<div className="space-y-4">
|
||||
{docStatus.measureProgress.map((mp) => {
|
||||
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
|
||||
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
|
||||
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
|
||||
|
||||
return (
|
||||
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
|
||||
{/* Measure header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
|
||||
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
mp.isComplete
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: mp.uploadedCount > 0
|
||||
? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-gray-100 text-gray-500 border-gray-200"
|
||||
}`}>
|
||||
{mp.uploadedCount} / {mp.requiredCount} docs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5 space-y-1.5">
|
||||
{/* Uploaded required docs */}
|
||||
{mp.uploaded.map((docType) => {
|
||||
const doc = measureDocs.find((d) => d.docType === docType);
|
||||
if (!doc) return null;
|
||||
return (
|
||||
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
|
||||
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Missing required docs */}
|
||||
{missingTypes.map((docType) => (
|
||||
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
|
||||
<FileX className="h-2.5 w-2.5 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Extra docs uploaded for this measure (not in required list) */}
|
||||
{measureDocs
|
||||
.filter((d) => !mp.required.includes(d.docType))
|
||||
.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-3 w-3 text-sky-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Unassigned / no-measure install docs */}
|
||||
{(() => {
|
||||
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
|
||||
const unassigned = installDocs.filter(
|
||||
(d) => !d.measureName || !knownMeasures.has(d.measureName),
|
||||
);
|
||||
if (unassigned.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
|
||||
{unassigned.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : installDocs.length > 0 ? (
|
||||
// ── Fallback: flat list (no measure progress data) ──
|
||||
<div className="space-y-1.5">
|
||||
{installDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 px-0.5">No install documents uploaded yet.</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<PropertyDocumentsContent
|
||||
documents={documents}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
docStatus={docStatus ?? { presentSurveyTypes: [], hasSurveyDocs: false, isSurveyComplete: false, hasInstallDocs: false, installStatus: "none", measureProgress: [] }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
Download links expire after 30 minutes. Refresh to generate a new
|
||||
link.
|
||||
Download links expire after 30 minutes. Refresh to generate a new link.
|
||||
</p>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
|
|||
|
|
@ -3,23 +3,8 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { STAGE_COLORS } from "./types";
|
||||
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Stage badge — consistent pill rendering
|
||||
// -----------------------------------------------------------------------
|
||||
function StageBadge({ stage }: { stage: DisplayStage }) {
|
||||
const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap ${c.bg} ${c.text} ${c.border}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${c.dot}`} />
|
||||
{stage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
import type { ClassifiedDeal, DocStatusMap, RemovalStatusByDeal } from "./types";
|
||||
import { StageBadge } from "./ui";
|
||||
|
||||
// Sortable column header helper
|
||||
function SortableHeader({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -15,37 +16,31 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/app/shadcn_components/ui/tooltip";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import { sapToEpc, getEpcAccentClasses, parsePreSap } from "@/app/utils";
|
||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
|
||||
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState } from "../types";
|
||||
import { STAGE_COLORS } from "../types";
|
||||
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState, PropertyDocument } from "../types";
|
||||
import PropertyDocumentsContent from "../PropertyDocumentsContent";
|
||||
import { StageBadge } from "../ui";
|
||||
import {
|
||||
InfoRow,
|
||||
StageBadge,
|
||||
MilestoneTimeline,
|
||||
formatDate,
|
||||
MeasureApprovalEditor,
|
||||
InstructMeasureEditor,
|
||||
ApprovalLogSection,
|
||||
PibiDatesEditor,
|
||||
PibiMeasureSelector,
|
||||
DomnaEditor,
|
||||
HaltedEditor,
|
||||
SurveyRequestSection,
|
||||
RemovalRequestSection,
|
||||
SectionHeader,
|
||||
SECTION_TITLES,
|
||||
WRITE_ROLES,
|
||||
} from "../PropertyDetailDrawer";
|
||||
} from "../deal-detail/primitives";
|
||||
import { MeasureApprovalEditor } from "../deal-detail/MeasureApprovalEditor";
|
||||
import { InstructMeasureEditor } from "../deal-detail/pibi/InstructMeasureEditor";
|
||||
import { PibiSurveysTabContent } from "../deal-detail/PibiSurveysTabContent";
|
||||
import { ActivityLog } from "../ActivityLog";
|
||||
|
||||
type Tab = "works" | "pibi" | "survey-admin" | "documents";
|
||||
const VALID_TABS: Tab[] = ["works", "pibi", "survey-admin", "documents"];
|
||||
type Tab = "works" | "pibi-surveys" | "documents";
|
||||
const VALID_TABS: Tab[] = ["works", "pibi-surveys", "documents"];
|
||||
|
||||
const TAB_LABELS: Record<Tab, string> = {
|
||||
works: "Works",
|
||||
pibi: "PIBI",
|
||||
"survey-admin": "Survey & Admin",
|
||||
"pibi-surveys": "PIBIs & Surveys",
|
||||
documents: "Documents",
|
||||
};
|
||||
|
||||
|
|
@ -65,6 +60,7 @@ export default function DealPage({
|
|||
portfolioId,
|
||||
userRole,
|
||||
userCapability,
|
||||
approvedMeasures,
|
||||
docStatus,
|
||||
removalState,
|
||||
}: DealPageProps) {
|
||||
|
|
@ -82,16 +78,27 @@ export default function DealPage({
|
|||
router.replace(`?tab=${tab}`, { scroll: false });
|
||||
};
|
||||
|
||||
const epcCurrent = sapToEpc(deal.preSapScore != null ? Number(deal.preSapScore) : null);
|
||||
const { data: documents = [], isFetching: docsFetching, isError: docsError } = useQuery({
|
||||
queryKey: ["property-documents", deal.dealId, deal.uprn, deal.landlordPropertyId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (deal.dealId) params.set("dealId", deal.dealId);
|
||||
else if (deal.uprn) params.set("uprn", deal.uprn);
|
||||
else if (deal.landlordPropertyId) params.set("landlordPropertyId", deal.landlordPropertyId);
|
||||
const res = await fetch(`/api/live-tracking/property-documents?${params}`);
|
||||
if (!res.ok) throw new Error("Failed to load documents");
|
||||
return res.json() as Promise<PropertyDocument[]>;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const parsedPreSap = parsePreSap(deal.preSapScore);
|
||||
const epcPotential = sapToEpc(deal.epcSapScorePotential != null ? Number(deal.epcSapScorePotential) : null);
|
||||
const technicalApprovedMeasures = parseMeasures(
|
||||
deal.technicalApprovedMeasuresForInstall ?? null,
|
||||
);
|
||||
const pibiMeasures = parseMeasures(deal.measuresForPibiOrdered ?? null);
|
||||
|
||||
const isApprover = userCapability.includes("approver");
|
||||
const canWrite = WRITE_ROLES.includes(userRole);
|
||||
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
|
|
@ -140,14 +147,17 @@ export default function DealPage({
|
|||
Energy Performance
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-black text-brandblue">{epcCurrent}</span>
|
||||
{epcPotential !== "Unknown" && epcPotential !== epcCurrent && (
|
||||
{parsedPreSap ? (
|
||||
<span className={`text-2xl font-black ${getEpcAccentClasses(parsedPreSap.letter)}`}>
|
||||
{parsedPreSap.display}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xl font-black text-gray-400">—</span>
|
||||
)}
|
||||
{epcPotential !== "Unknown" && epcPotential !== (parsedPreSap?.letter ?? "Unknown") && (
|
||||
<span className="text-sm text-gray-400">→ {epcPotential}</span>
|
||||
)}
|
||||
</div>
|
||||
{deal.preSapScore !== null && deal.preSapScore !== undefined && (
|
||||
<p className="text-xs text-gray-500">SAP: {deal.preSapScore}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key details */}
|
||||
|
|
@ -163,24 +173,9 @@ export default function DealPage({
|
|||
<InfoRow label="Coordination" value={deal.coordinationStatus} />
|
||||
<InfoRow label="Design Status" value={deal.designStatus} />
|
||||
<InfoRow label="Design Type" value={deal.designType} />
|
||||
<InfoRow
|
||||
label="Pre-SAP"
|
||||
value={
|
||||
deal.preSapScore ? (
|
||||
<span
|
||||
className={`font-semibold px-1.5 py-0.5 rounded text-xs ${
|
||||
Number(deal.preSapScore) < 30
|
||||
? "text-red-600 bg-red-50"
|
||||
: Number(deal.preSapScore) < 50
|
||||
? "text-amber-700 bg-amber-50"
|
||||
: "text-emerald-700 bg-emerald-50"
|
||||
}`}
|
||||
>
|
||||
{deal.preSapScore}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
|
||||
<InfoRow label="Measures Lodged" value={formatDate(deal.measuresLodgementDate)} />
|
||||
<InfoRow label="Full Lodgement" value={formatDate(deal.fullLodgementDate)} />
|
||||
</div>
|
||||
|
||||
{/* Survey info */}
|
||||
|
|
@ -205,9 +200,6 @@ export default function DealPage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{deal.uprn && (
|
||||
<p className="text-xs text-gray-400 font-mono">UPRN: {deal.uprn}</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
@ -252,7 +244,6 @@ export default function DealPage({
|
|||
</div>
|
||||
<div className="divide-y divide-gray-50 mt-3">
|
||||
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
|
||||
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -297,163 +288,38 @@ export default function DealPage({
|
|||
</button>
|
||||
{isLogOpen && (
|
||||
<div className="mt-3">
|
||||
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
|
||||
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── PIBI ── */}
|
||||
{/* ── PIBIs & Surveys ── */}
|
||||
<div
|
||||
className={`p-5 space-y-6 ${activeTab === "pibi" ? "block" : "hidden"}`}
|
||||
className={`p-5 space-y-6 ${activeTab === "pibi-surveys" ? "block" : "hidden"}`}
|
||||
>
|
||||
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
|
||||
<div className="space-y-3">
|
||||
<PibiDatesEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialOrderDate={deal.pibiOrderDate}
|
||||
initialCompletedDate={deal.pibiCompletedDate}
|
||||
canEdit={canWrite}
|
||||
/>
|
||||
{isApprover ? (
|
||||
<PibiMeasureSelector
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
|
||||
canEdit
|
||||
/>
|
||||
) : (
|
||||
pibiMeasures.length > 0 && (
|
||||
<div className="divide-y divide-gray-50">
|
||||
<InfoRow
|
||||
label="Measures for PIBI"
|
||||
value={
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{pibiMeasures.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Survey & Admin ── */}
|
||||
<div
|
||||
className={`p-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
|
||||
>
|
||||
<div>
|
||||
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
|
||||
<DomnaEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialSurveyType={deal.domnaSurveyType ?? null}
|
||||
initialSurveyDate={deal.domnaSurveyDate}
|
||||
canEdit={isApprover}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
|
||||
<HaltedEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialHaltedDate={deal.propertyHaltedDate}
|
||||
initialHaltedReason={deal.propertyHaltedReason ?? null}
|
||||
canEdit={isApprover}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Survey Request
|
||||
</h3>
|
||||
<SurveyRequestSection
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Project Removal
|
||||
</h3>
|
||||
<RemovalRequestSection
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
<PibiSurveysTabContent
|
||||
deal={deal}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Documents ── */}
|
||||
<div
|
||||
className={`p-5 space-y-4 ${activeTab === "documents" ? "block" : "hidden"}`}
|
||||
className={`p-5 ${activeTab === "documents" ? "block" : "hidden"}`}
|
||||
>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
Documents
|
||||
</h3>
|
||||
{docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${docStatus.isSurveyComplete ? "bg-emerald-500" : docStatus.hasSurveyDocs ? "bg-amber-400" : "bg-gray-300"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Survey docs:{" "}
|
||||
<span className="font-medium">
|
||||
{docStatus.isSurveyComplete
|
||||
? "Complete"
|
||||
: docStatus.hasSurveyDocs
|
||||
? `${docStatus.presentSurveyTypes.length} uploaded`
|
||||
: "None"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${docStatus.installStatus === "all" ? "bg-emerald-500" : docStatus.installStatus === "partial" || docStatus.installStatus === "hasDocs" ? "bg-amber-400" : "bg-gray-300"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Install docs:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{docStatus.installStatus === "none"
|
||||
? "None"
|
||||
: docStatus.installStatus === "all"
|
||||
? "Complete"
|
||||
: "Partial"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{docStatus.measureProgress.length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{docStatus.measureProgress.map((mp) => (
|
||||
<div
|
||||
key={mp.measureName}
|
||||
className="flex items-center justify-between text-xs py-1.5 border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<span className="text-gray-700">{mp.measureName}</span>
|
||||
<span
|
||||
className={`font-medium ${mp.isComplete ? "text-emerald-600" : "text-amber-600"}`}
|
||||
>
|
||||
{mp.uploadedCount}/{mp.requiredCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No documents uploaded yet.</p>
|
||||
)}
|
||||
<PropertyDocumentsContent
|
||||
documents={documents}
|
||||
isFetching={docsFetching}
|
||||
isError={docsError}
|
||||
docStatus={docStatus}
|
||||
deal={deal}
|
||||
portfolioId={portfolioId}
|
||||
userCapability={userCapability}
|
||||
approvedMeasures={approvedMeasures}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -512,7 +378,7 @@ export default function DealPage({
|
|||
|
||||
{/* Request Survey */}
|
||||
<button
|
||||
onClick={() => switchTab("survey-admin")}
|
||||
onClick={() => switchTab("pibi-surveys")}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
|
||||
>
|
||||
<span>Request Survey</span>
|
||||
|
|
@ -521,7 +387,7 @@ export default function DealPage({
|
|||
|
||||
{/* Request Removal */}
|
||||
<button
|
||||
onClick={() => switchTab("survey-admin")}
|
||||
onClick={() => switchTab("pibi-surveys")}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
|
||||
>
|
||||
<span>Request Removal</span>
|
||||
|
|
@ -551,8 +417,10 @@ export default function DealPage({
|
|||
<InstructMeasureEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
|
||||
canEdit={isApprover}
|
||||
outOfOrderWarning={outOfOrderInstructionWarning(deal)}
|
||||
onSuccess={() => setInstructModalOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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 { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { eq, inArray, and, desc } from "drizzle-orm";
|
||||
import { eq, inArray, and, desc, sql } from "drizzle-orm";
|
||||
import { db } from "@/app/db/db";
|
||||
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
|
||||
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||
import { user as userTable } from "@/app/db/schema/users";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type {
|
||||
HubspotDeal,
|
||||
DocStatus,
|
||||
MeasureDocProgress,
|
||||
PortfolioCapabilityType,
|
||||
EffectiveRemovalState,
|
||||
} from "../types";
|
||||
import {
|
||||
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
|
||||
SURVEY_ALL_DOC_TYPES,
|
||||
} from "../types";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
import type { DocStatus, PortfolioCapabilityType } from "../types";
|
||||
import { deriveEffectiveRemovalState } from "../removalState";
|
||||
import { classifyDeals } from "../transforms";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { fetchDocsByDealId, computeDocStatusMap } from "../docStatus";
|
||||
import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "../dealQuery";
|
||||
import type { DealRow } from "../dealQuery";
|
||||
import DealPage from "./DealPage";
|
||||
import Link from "next/link";
|
||||
|
||||
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
|
||||
const designerUser = alias(hubspotUsers, "designer_user");
|
||||
|
||||
type DealRow = {
|
||||
deal: InferSelectModel<typeof hubspotDealData>;
|
||||
coordinator: string | null;
|
||||
designer: string | null;
|
||||
};
|
||||
|
||||
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
|
||||
const d = row.deal;
|
||||
return {
|
||||
id: d.id,
|
||||
dealId: d.dealId,
|
||||
dealname: d.dealname,
|
||||
dealstage: d.dealstage,
|
||||
companyId: d.companyId,
|
||||
projectCode: d.projectCode,
|
||||
landlordPropertyId: d.landlordPropertyId,
|
||||
uprn: d.uprn,
|
||||
outcome: d.outcome,
|
||||
outcomeNotes: d.outcomeNotes,
|
||||
majorConditionIssueDescription: d.majorConditionIssueDescription,
|
||||
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
|
||||
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
|
||||
coordinationStatus: d.coordinationStatus,
|
||||
designStatus: d.designStatus,
|
||||
pashubLink: d.pashubLink,
|
||||
sharepointLink: d.sharepointLink,
|
||||
dampMouldFlag: d.dampmouldGrowth,
|
||||
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
|
||||
preSapScore: d.preSap,
|
||||
coordinator: row.coordinator,
|
||||
ioeV1Date: d.mtpCompletionDate,
|
||||
ioeV2Date: d.mtpReModelCompletionDate,
|
||||
ioeV3Date: d.ioeV3CompletionDate,
|
||||
proposedMeasures: d.proposedMeasures,
|
||||
approvedPackage: d.approvedPackage,
|
||||
designer: row.designer,
|
||||
designDate: d.designCompletionDate,
|
||||
actualMeasuresInstalled: d.actualMeasuresInstalled,
|
||||
installer: d.installer,
|
||||
installerHandover: d.installerHandover,
|
||||
lodgementStatus: d.lodgementStatus,
|
||||
measuresLodgementDate: d.measuresLodgementDate,
|
||||
fullLodgementDate: d.lodgementDate,
|
||||
confirmedSurveyDate: d.confirmedSurveyDate,
|
||||
confirmedSurveyTime: d.confirmedSurveyTime,
|
||||
surveyedDate: d.surveyedDate,
|
||||
designType: d.dealType,
|
||||
eiScore: d.eiScore,
|
||||
eiScorePotential: d.eiScorePotential,
|
||||
epcSapScore: d.epcSapScore,
|
||||
epcSapScorePotential: d.epcSapScorePotential,
|
||||
surveyType: d.surveyType,
|
||||
measuresForPibiOrdered: d.measuresForPibiOrdered,
|
||||
pibiOrderDate: d.pibiOrderDate,
|
||||
pibiCompletedDate: d.pibiCompletedDate,
|
||||
propertyHaltedDate: d.propertyHaltedDate,
|
||||
propertyHaltedReason: d.propertyHaltedReason,
|
||||
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
|
||||
domnaSurveyType: d.domnaSurveyType,
|
||||
domnaSurveyDate: d.domnaSurveyDate,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DealDetailPage(props: {
|
||||
params: Promise<{ slug: string; dealId: string }>;
|
||||
}) {
|
||||
|
|
@ -109,22 +29,23 @@ export default async function DealDetailPage(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
const link = await db
|
||||
const links = await db
|
||||
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(
|
||||
organisation,
|
||||
eq(portfolioOrganisation.organisationId, organisation.id),
|
||||
)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
if (!link.length || !link[0].hubspotCompanyId) {
|
||||
const companyIds = links
|
||||
.map((l) => l.hubspotCompanyId)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
if (companyIds.length === 0) {
|
||||
redirect(`/portfolio/${portfolioId}/your-projects/live`);
|
||||
}
|
||||
|
||||
const companyId = link[0].hubspotCompanyId;
|
||||
|
||||
const rawDeals = await db
|
||||
.select({
|
||||
deal: hubspotDealData,
|
||||
|
|
@ -142,7 +63,7 @@ export default async function DealDetailPage(props: {
|
|||
)
|
||||
.where(
|
||||
and(
|
||||
eq(hubspotDealData.companyId, companyId),
|
||||
inArray(hubspotDealData.companyId, companyIds),
|
||||
eq(hubspotDealData.dealId, dealId),
|
||||
),
|
||||
)
|
||||
|
|
@ -211,7 +132,6 @@ export default async function DealDetailPage(props: {
|
|||
);
|
||||
approvedMeasures.push(...approvalRows.map((r) => r.measureName));
|
||||
|
||||
let removalState: EffectiveRemovalState = "none";
|
||||
const removalRows = await db
|
||||
.select({
|
||||
type: propertyRemovalRequests.type,
|
||||
|
|
@ -227,115 +147,23 @@ export default async function DealDetailPage(props: {
|
|||
.orderBy(desc(propertyRemovalRequests.requestedAt))
|
||||
.limit(1);
|
||||
|
||||
if (removalRows[0]) {
|
||||
const row = removalRows[0];
|
||||
if (row.status === "pending") {
|
||||
removalState =
|
||||
row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
} else if (row.type === "removal" && row.status === "approved") {
|
||||
removalState = "removed";
|
||||
} else if (row.type === "re_addition" && row.status === "declined") {
|
||||
removalState = "removed";
|
||||
}
|
||||
}
|
||||
const removalState = removalRows[0]
|
||||
? deriveEffectiveRemovalState(removalRows[0])
|
||||
: "none";
|
||||
|
||||
// Doc status — same two-phase strategy as live tracker
|
||||
const docFiles: Array<{ fileType: string; measureName: string | null }> = [];
|
||||
|
||||
const phase1Rows = await db
|
||||
.select({
|
||||
hubsotDealId: uploadedFiles.hubsotDealId,
|
||||
fileType: uploadedFiles.fileType,
|
||||
measureName: uploadedFiles.measureName,
|
||||
})
|
||||
.from(uploadedFiles)
|
||||
.where(eq(uploadedFiles.hubsotDealId, dealId));
|
||||
|
||||
for (const row of phase1Rows) {
|
||||
if (row.fileType !== null) {
|
||||
docFiles.push({ fileType: row.fileType, measureName: row.measureName });
|
||||
}
|
||||
}
|
||||
|
||||
if (docFiles.length === 0 && deal.uprn) {
|
||||
try {
|
||||
const uprnBig = BigInt(deal.uprn);
|
||||
const phase2Rows = await db
|
||||
.select({
|
||||
fileType: uploadedFiles.fileType,
|
||||
measureName: uploadedFiles.measureName,
|
||||
})
|
||||
.from(uploadedFiles)
|
||||
.where(eq(uploadedFiles.uprn, uprnBig));
|
||||
|
||||
for (const row of phase2Rows) {
|
||||
if (row.fileType !== null) {
|
||||
docFiles.push({
|
||||
fileType: row.fileType,
|
||||
measureName: row.measureName,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid UPRN — skip phase 2
|
||||
}
|
||||
}
|
||||
|
||||
const measures =
|
||||
approvedMeasures.length > 0
|
||||
? approvedMeasures
|
||||
: (deal.proposedMeasures ?? "")
|
||||
.split(",")
|
||||
.map((m: string) => m.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const surveyDocs = docFiles.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
|
||||
const installDocs = docFiles.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
|
||||
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
|
||||
|
||||
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
|
||||
const required = getRequiredDocs(measureName);
|
||||
const docsForMeasure = installDocs.filter(
|
||||
(d) => d.measureName === measureName,
|
||||
);
|
||||
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
|
||||
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
|
||||
return {
|
||||
measureName,
|
||||
required,
|
||||
uploaded,
|
||||
isComplete: uploaded.length === required.length,
|
||||
uploadedCount: uploaded.length,
|
||||
requiredCount: required.length,
|
||||
};
|
||||
});
|
||||
|
||||
let installStatus: DocStatus["installStatus"] = "none";
|
||||
if (installDocs.length > 0) {
|
||||
if (measures.length === 0) {
|
||||
installStatus = "hasDocs";
|
||||
} else {
|
||||
installStatus = measureProgress.every((m) => m.isComplete)
|
||||
? "all"
|
||||
: measureProgress.some((m) => m.uploadedCount > 0)
|
||||
? "partial"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
|
||||
const docStatus: DocStatus = {
|
||||
presentSurveyTypes: Array.from(surveyTypeSet),
|
||||
hasSurveyDocs: surveyDocs.length > 0,
|
||||
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) =>
|
||||
surveyTypeSet.has(t),
|
||||
),
|
||||
hasInstallDocs: installDocs.length > 0,
|
||||
installStatus,
|
||||
measureProgress,
|
||||
const docsByDealId = await fetchDocsByDealId([hubspotDeal], [dealId]);
|
||||
const docStatusMap = computeDocStatusMap([hubspotDeal], docsByDealId, { [dealId]: approvedMeasures });
|
||||
const docStatus: DocStatus = docStatusMap[dealId] ?? {
|
||||
presentSurveyTypes: [],
|
||||
hasSurveyDocs: false,
|
||||
isSurveyComplete: false,
|
||||
hasInstallDocs: false,
|
||||
installStatus: "none",
|
||||
measureProgress: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
<div className="max-w-screen-2xl mx-auto px-6 pb-10 space-y-4">
|
||||
<div className="mb-6">
|
||||
<nav className="flex items-center gap-1.5 text-sm text-gray-500 mb-3">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -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 LiveTracker from "./LiveTracker";
|
||||
import { computeLiveTrackerData } from "./transforms";
|
||||
import { fetchDocsByDealId, computeDocStatusMap } from "./docStatus";
|
||||
import { db } from "@/app/db/db";
|
||||
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
|
||||
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { coordinatorUser, designerUser, mapDbRowToHubspotDeal } from "./dealQuery";
|
||||
import type { DealRow } from "./dealQuery";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import {
|
||||
portfolioCapabilities,
|
||||
portfolioUsers,
|
||||
} from "@/app/db/schema/portfolio";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
|
||||
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||
import { user as userTable } from "@/app/db/schema/users";
|
||||
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
|
||||
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type {
|
||||
PortfolioCapabilityType,
|
||||
ApprovalsByDeal,
|
||||
InstructedMeasuresByDeal,
|
||||
RemovalStatusByDeal,
|
||||
} from "./types";
|
||||
import { computeRemovalStatusByDeal } from "./removalState";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
|
||||
const designerUser = alias(hubspotUsers, "designer_user");
|
||||
|
||||
type DealRow = {
|
||||
deal: InferSelectModel<typeof hubspotDealData>;
|
||||
coordinator: string | null;
|
||||
designer: string | null;
|
||||
};
|
||||
|
||||
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
|
||||
const d = row.deal;
|
||||
return {
|
||||
id: d.id,
|
||||
dealId: d.dealId,
|
||||
dealname: d.dealname,
|
||||
dealstage: d.dealstage,
|
||||
companyId: d.companyId,
|
||||
projectCode: d.projectCode,
|
||||
landlordPropertyId: d.landlordPropertyId,
|
||||
uprn: d.uprn,
|
||||
outcome: d.outcome,
|
||||
outcomeNotes: d.outcomeNotes,
|
||||
majorConditionIssueDescription: d.majorConditionIssueDescription,
|
||||
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
|
||||
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
|
||||
coordinationStatus: d.coordinationStatus,
|
||||
designStatus: d.designStatus,
|
||||
pashubLink: d.pashubLink,
|
||||
sharepointLink: d.sharepointLink,
|
||||
dampMouldFlag: d.dampmouldGrowth,
|
||||
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
|
||||
preSapScore: d.preSap,
|
||||
coordinator: row.coordinator,
|
||||
ioeV1Date: d.mtpCompletionDate,
|
||||
ioeV2Date: d.mtpReModelCompletionDate,
|
||||
ioeV3Date: d.ioeV3CompletionDate,
|
||||
proposedMeasures: d.proposedMeasures,
|
||||
approvedPackage: d.approvedPackage,
|
||||
designer: row.designer,
|
||||
designDate: d.designCompletionDate,
|
||||
actualMeasuresInstalled: d.actualMeasuresInstalled,
|
||||
installer: d.installer,
|
||||
installerHandover: d.installerHandover,
|
||||
lodgementStatus: d.lodgementStatus,
|
||||
measuresLodgementDate: d.measuresLodgementDate,
|
||||
fullLodgementDate: d.lodgementDate,
|
||||
confirmedSurveyDate: d.confirmedSurveyDate,
|
||||
confirmedSurveyTime: d.confirmedSurveyTime,
|
||||
surveyedDate: d.surveyedDate,
|
||||
designType: d.dealType,
|
||||
eiScore: d.eiScore,
|
||||
eiScorePotential: d.eiScorePotential,
|
||||
epcSapScore: d.epcSapScore,
|
||||
epcSapScorePotential: d.epcSapScorePotential,
|
||||
// New per-deal workflow fields
|
||||
surveyType: d.surveyType,
|
||||
measuresForPibiOrdered: d.measuresForPibiOrdered,
|
||||
pibiOrderDate: d.pibiOrderDate,
|
||||
pibiCompletedDate: d.pibiCompletedDate,
|
||||
propertyHaltedDate: d.propertyHaltedDate,
|
||||
propertyHaltedReason: d.propertyHaltedReason,
|
||||
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
|
||||
domnaSurveyType: d.domnaSurveyType,
|
||||
domnaSurveyDate: d.domnaSurveyDate,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LiveReportingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
|
|
@ -101,17 +39,21 @@ export default async function LiveReportingPage(props: {
|
|||
redirect("/");
|
||||
}
|
||||
|
||||
// Look up the linked organisation for this portfolio
|
||||
// Look up all linked organisations for this portfolio
|
||||
const link = await db
|
||||
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(organisation, eq(portfolioOrganisation.organisationId, organisation.id))
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
.innerJoin(
|
||||
organisation,
|
||||
eq(portfolioOrganisation.organisationId, organisation.id),
|
||||
)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
const pageHeader = (
|
||||
<div className="mb-6">
|
||||
<header className="text-3xl font-semibold text-brandblue">Live Projects</header>
|
||||
<header className="text-3xl font-semibold text-brandblue">
|
||||
Live Projects
|
||||
</header>
|
||||
<p className="text-sm text-gray-500">
|
||||
{`Check in on your projects' progress with real-time data updates.`}
|
||||
</p>
|
||||
|
|
@ -119,7 +61,11 @@ export default async function LiveReportingPage(props: {
|
|||
</div>
|
||||
);
|
||||
|
||||
if (!link.length || !link[0].hubspotCompanyId) {
|
||||
const companyIds = link
|
||||
.map((l) => l.hubspotCompanyId)
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
if (companyIds.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
{pageHeader}
|
||||
|
|
@ -129,10 +75,13 @@ export default async function LiveReportingPage(props: {
|
|||
<Building2 className="h-8 w-8 text-brandblue/50" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-gray-700">No organisation linked</p>
|
||||
<p className="text-base font-semibold text-gray-700">
|
||||
No organisation linked
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1 max-w-sm">
|
||||
A Domna administrator needs to connect this portfolio to an organisation in{" "}
|
||||
<strong>Portfolio Settings</strong> before live tracking data can be displayed.
|
||||
A Domna administrator needs to connect this portfolio to an
|
||||
organisation in <strong>Portfolio Settings</strong> before live
|
||||
tracking data can be displayed.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -141,70 +90,77 @@ export default async function LiveReportingPage(props: {
|
|||
);
|
||||
}
|
||||
|
||||
const companyId = link[0].hubspotCompanyId;
|
||||
|
||||
const rawDeals = await db
|
||||
.select({
|
||||
deal: hubspotDealData,
|
||||
coordinator: sql<string | null>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
|
||||
designer: sql<string | null>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
|
||||
coordinator: sql<
|
||||
string | null
|
||||
>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
|
||||
designer: sql<
|
||||
string | null
|
||||
>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
|
||||
})
|
||||
.from(hubspotDealData)
|
||||
.leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId))
|
||||
.leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId))
|
||||
.where(eq(hubspotDealData.companyId, companyId));
|
||||
.leftJoin(
|
||||
coordinatorUser,
|
||||
eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId),
|
||||
)
|
||||
.leftJoin(
|
||||
designerUser,
|
||||
eq(hubspotDealData.designer, designerUser.hubspotOwnerId),
|
||||
)
|
||||
.where(inArray(hubspotDealData.companyId, companyIds));
|
||||
|
||||
const deals = rawDeals.map(mapDbRowToHubspotDeal);
|
||||
const trackerData = computeLiveTrackerData(deals);
|
||||
|
||||
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
|
||||
let userCapability: PortfolioCapabilityType = [];
|
||||
const userEmail = user?.user?.email;
|
||||
|
||||
// Single user lookup shared by capability and role queries
|
||||
let userId: bigint | null = null;
|
||||
if (userEmail) {
|
||||
const userRow = await db
|
||||
.select({ id: userTable.id })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.email, userEmail))
|
||||
.limit(1);
|
||||
userId = userRow[0]?.id ?? null;
|
||||
}
|
||||
|
||||
if (userRow[0]) {
|
||||
const capRows = await db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioCapabilities.userId, userRow[0].id),
|
||||
),
|
||||
);
|
||||
userCapability = capRows
|
||||
.map((r) => r.capability)
|
||||
.filter((c): c is "approver" | "contractor" => c === "approver" || c === "contractor");
|
||||
}
|
||||
// Fetch current user's portfolio capabilities (approver / contractor — can have both)
|
||||
let userCapability: PortfolioCapabilityType = [];
|
||||
if (userId) {
|
||||
const capRows = await db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioCapabilities.userId, userId),
|
||||
),
|
||||
);
|
||||
userCapability = capRows
|
||||
.map((r) => r.capability)
|
||||
.filter(
|
||||
(c): c is "approver" | "contractor" =>
|
||||
c === "approver" || c === "contractor",
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch current user's portfolio role (creator / admin / write / read)
|
||||
let userRole = "read";
|
||||
if (userEmail) {
|
||||
const userRow = await db
|
||||
.select({ id: userTable.id })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.email, userEmail))
|
||||
if (userId) {
|
||||
const roleRow = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userRow[0]) {
|
||||
const roleRow = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userRow[0].id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
userRole = roleRow[0]?.role ?? "read";
|
||||
}
|
||||
userRole = roleRow[0]?.role ?? "read";
|
||||
}
|
||||
|
||||
// Fetch currently approved measures for all deals in scope
|
||||
|
|
@ -229,8 +185,27 @@ export default async function LiveReportingPage(props: {
|
|||
}
|
||||
}
|
||||
|
||||
// Compute effective removal state per deal
|
||||
const removalStatusByDeal: RemovalStatusByDeal = {};
|
||||
// Fetch instructed measures for all deals in scope
|
||||
const instructedMeasuresByDeal: InstructedMeasuresByDeal = {};
|
||||
if (dealIds.length > 0) {
|
||||
const instructedRows = await db
|
||||
.select({
|
||||
hubspotDealId: userDefinedDealMeasures.hubspotDealId,
|
||||
measureName: userDefinedDealMeasures.measureName,
|
||||
})
|
||||
.from(userDefinedDealMeasures)
|
||||
.where(
|
||||
and(
|
||||
inArray(userDefinedDealMeasures.hubspotDealId, dealIds),
|
||||
eq(userDefinedDealMeasures.source, "instructed"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of instructedRows) {
|
||||
(instructedMeasuresByDeal[row.hubspotDealId] ??= []).push(row.measureName);
|
||||
}
|
||||
}
|
||||
|
||||
const removalRows = await db
|
||||
.select({
|
||||
hubspotDealId: propertyRemovalRequests.hubspotDealId,
|
||||
|
|
@ -241,139 +216,10 @@ export default async function LiveReportingPage(props: {
|
|||
.where(eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)))
|
||||
.orderBy(desc(propertyRemovalRequests.requestedAt));
|
||||
|
||||
// Keep only the most recent row per deal, then derive effective state
|
||||
const seenDeals = new Set<string>();
|
||||
for (const row of removalRows) {
|
||||
if (seenDeals.has(row.hubspotDealId)) continue;
|
||||
seenDeals.add(row.hubspotDealId);
|
||||
let state: EffectiveRemovalState = "none";
|
||||
if (row.status === "pending") {
|
||||
state = row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
} else if (row.type === "removal" && row.status === "approved") {
|
||||
state = "removed";
|
||||
} else if (row.type === "re_addition" && row.status === "declined") {
|
||||
state = "removed";
|
||||
}
|
||||
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
|
||||
}
|
||||
const removalStatusByDeal: RemovalStatusByDeal = computeRemovalStatusByDeal(removalRows);
|
||||
|
||||
// Fetch document status for all deals — two-phase strategy:
|
||||
// Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data)
|
||||
// Phase 2: UPRN fallback only for deals that returned no results in phase 1
|
||||
const docsByDealId = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
|
||||
|
||||
if (dealIds.length > 0) {
|
||||
const phase1Rows = await db
|
||||
.select({
|
||||
hubsotDealId: uploadedFiles.hubsotDealId,
|
||||
fileType: uploadedFiles.fileType,
|
||||
measureName: uploadedFiles.measureName,
|
||||
})
|
||||
.from(uploadedFiles)
|
||||
.where(inArray(uploadedFiles.hubsotDealId, dealIds));
|
||||
|
||||
for (const row of phase1Rows) {
|
||||
if (!row.hubsotDealId || row.fileType === null) continue;
|
||||
if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []);
|
||||
docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: for deals with no docs from phase 1 that have a UPRN, try UPRN lookup
|
||||
const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId));
|
||||
const fallbackUprns = dealsWithoutDocs
|
||||
.map((d) => d.uprn)
|
||||
.filter((u): u is string => !!u)
|
||||
.map((u) => { try { return BigInt(u); } catch { return null; } })
|
||||
.filter((u): u is bigint => u !== null);
|
||||
|
||||
if (fallbackUprns.length > 0) {
|
||||
const phase2Rows = await db
|
||||
.select({
|
||||
uprn: uploadedFiles.uprn,
|
||||
fileType: uploadedFiles.fileType,
|
||||
measureName: uploadedFiles.measureName,
|
||||
})
|
||||
.from(uploadedFiles)
|
||||
.where(inArray(uploadedFiles.uprn, fallbackUprns));
|
||||
|
||||
// Map phase 2 UPRN results back to dealId
|
||||
const uprnToDealId = new Map<string, string>(
|
||||
dealsWithoutDocs
|
||||
.filter((d) => d.uprn)
|
||||
.map((d) => {
|
||||
try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; } catch { return null; }
|
||||
})
|
||||
.filter((e): e is [string, string] => e !== null),
|
||||
);
|
||||
|
||||
for (const row of phase2Rows) {
|
||||
if (row.uprn === null || row.fileType === null) continue;
|
||||
const dealId = uprnToDealId.get(String(row.uprn));
|
||||
if (!dealId) continue;
|
||||
if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []);
|
||||
docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName });
|
||||
}
|
||||
}
|
||||
|
||||
// Build measures lookup by dealId (approved measures, falling back to proposed)
|
||||
const measuresByDealId = new Map<string, string[]>();
|
||||
for (const deal of deals) {
|
||||
const approved = approvalsByDeal[deal.dealId] ?? [];
|
||||
const measures = approved.length > 0
|
||||
? approved
|
||||
: (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
|
||||
measuresByDealId.set(deal.dealId, measures);
|
||||
}
|
||||
|
||||
// Build docStatusMap keyed by dealId
|
||||
const docStatusMap: DocStatusMap = {};
|
||||
|
||||
for (const [dealId, docs] of docsByDealId) {
|
||||
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
|
||||
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
|
||||
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
|
||||
|
||||
const measures = measuresByDealId.get(dealId) ?? [];
|
||||
|
||||
// Compute per-measure document progress against the requirements matrix
|
||||
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
|
||||
const required = getRequiredDocs(measureName);
|
||||
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
|
||||
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
|
||||
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
|
||||
return {
|
||||
measureName,
|
||||
required,
|
||||
uploaded,
|
||||
isComplete: uploaded.length === required.length,
|
||||
uploadedCount: uploaded.length,
|
||||
requiredCount: required.length,
|
||||
};
|
||||
});
|
||||
|
||||
let installStatus: DocStatus["installStatus"] = "none";
|
||||
if (installDocs.length > 0) {
|
||||
if (measures.length === 0) {
|
||||
installStatus = "hasDocs";
|
||||
} else {
|
||||
installStatus = measureProgress.every((m) => m.isComplete)
|
||||
? "all"
|
||||
: measureProgress.some((m) => m.uploadedCount > 0)
|
||||
? "partial"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
|
||||
docStatusMap[dealId] = {
|
||||
presentSurveyTypes: Array.from(surveyTypeSet),
|
||||
hasSurveyDocs: surveyDocs.length > 0,
|
||||
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
|
||||
hasInstallDocs: installDocs.length > 0,
|
||||
installStatus,
|
||||
measureProgress,
|
||||
};
|
||||
}
|
||||
const docsByDealId = await fetchDocsByDealId(deals, dealIds);
|
||||
const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
|
|
@ -383,6 +229,7 @@ export default async function LiveReportingPage(props: {
|
|||
docStatusMap={docStatusMap}
|
||||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
instructedMeasuresByDeal={instructedMeasuresByDeal}
|
||||
removalStatusByDeal={removalStatusByDeal}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
OutcomeSlice,
|
||||
LiveTrackerProps,
|
||||
WorkPhaseStats,
|
||||
|
||||
DampMouldRiskData,
|
||||
FunnelStage,
|
||||
} from "./types";
|
||||
|
|
@ -224,106 +224,6 @@ export function computeProjectProgress(
|
|||
|
||||
const totalDeals = deals.length;
|
||||
|
||||
// Coordination phase:
|
||||
// completed = Design in Progress + Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete
|
||||
// in progress = Coordination in Progress
|
||||
const coordCompletedDeals = deals.filter((d) =>
|
||||
[
|
||||
"Design in Progress",
|
||||
"Installation in Progress",
|
||||
"Installation Complete",
|
||||
"At Lodgement",
|
||||
"At Post Survey",
|
||||
"Project Complete",
|
||||
].includes(d.displayStage)
|
||||
);
|
||||
const coordInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Coordination in Progress"
|
||||
);
|
||||
|
||||
const coordination: WorkPhaseStats = {
|
||||
completedDeals: coordCompletedDeals,
|
||||
inProgressDeals: coordInProgressDeals,
|
||||
completedCount: coordCompletedDeals.length,
|
||||
inProgressCount: coordInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (coordCompletedDeals.length / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0 ? (coordInProgressDeals.length / totalDeals) * 100 : 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
// Design phase:
|
||||
// completed = Installation in Progress + Installation Complete + At Lodgement + At Post Survey + Project Complete
|
||||
// in progress = Design in Progress
|
||||
const designCompletedDeals = deals.filter((d) =>
|
||||
[
|
||||
"Installation in Progress",
|
||||
"Installation Complete",
|
||||
"At Lodgement",
|
||||
"At Post Survey",
|
||||
"Project Complete",
|
||||
].includes(d.displayStage)
|
||||
);
|
||||
const designInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Design in Progress"
|
||||
);
|
||||
|
||||
const design: WorkPhaseStats = {
|
||||
completedDeals: designCompletedDeals,
|
||||
inProgressDeals: designInProgressDeals,
|
||||
completedCount: designCompletedDeals.length,
|
||||
inProgressCount: designInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (designCompletedDeals.length / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0 ? (designInProgressDeals.length / totalDeals) * 100 : 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
// Install phase:
|
||||
// completed = At Lodgement + At Post Survey + Project Complete
|
||||
// in progress = Installation Complete
|
||||
const installCompletedDeals = deals.filter((d) =>
|
||||
["At Lodgement", "At Post Survey", "Project Complete"].includes(d.displayStage)
|
||||
);
|
||||
const installInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "Installation Complete"
|
||||
);
|
||||
|
||||
const install: WorkPhaseStats = {
|
||||
completedDeals: installCompletedDeals,
|
||||
inProgressDeals: installInProgressDeals,
|
||||
completedCount: installCompletedDeals.length,
|
||||
inProgressCount: installInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (installCompletedDeals.length / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0 ? (installInProgressDeals.length / totalDeals) * 100 : 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
// Lodgement phase:
|
||||
// completed = At Post Survey + Project Complete
|
||||
// in progress = At Lodgement
|
||||
const lodgementInProgressDeals = deals.filter(
|
||||
(d) => d.displayStage === "At Lodgement"
|
||||
);
|
||||
|
||||
const lodgement: WorkPhaseStats = {
|
||||
completedDeals,
|
||||
inProgressDeals: lodgementInProgressDeals,
|
||||
completedCount,
|
||||
inProgressCount: lodgementInProgressDeals.length,
|
||||
completedPercentage:
|
||||
totalDeals > 0 ? (completedCount / totalDeals) * 100 : 0,
|
||||
inProgressPercentage:
|
||||
totalDeals > 0
|
||||
? (lodgementInProgressDeals.length / totalDeals) * 100
|
||||
: 0,
|
||||
total: totalDeals,
|
||||
};
|
||||
|
||||
return {
|
||||
stageProgress,
|
||||
queriesDeals,
|
||||
|
|
@ -332,10 +232,6 @@ export function computeProjectProgress(
|
|||
completedPercentage,
|
||||
nonQueryTotal,
|
||||
totalDeals,
|
||||
coordination,
|
||||
design,
|
||||
install,
|
||||
lodgement,
|
||||
dampMouldRisk: computeDampMouldRisk(deals),
|
||||
funnelStages: computeFunnelStages(deals),
|
||||
};
|
||||
|
|
@ -375,7 +271,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
|
|||
// -----------------------------------------------------------------------
|
||||
export function computeLiveTrackerData(
|
||||
rawDeals: HubspotDeal[]
|
||||
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
|
||||
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "instructedMeasuresByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
|
||||
// Classify all deals (add displayStage field)
|
||||
const classified = classifyDeals(rawDeals);
|
||||
|
||||
|
|
|
|||
|
|
@ -104,19 +104,6 @@ export type StageProgressItem = {
|
|||
deals: ClassifiedDeal[];
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Coordination/Design/Install/Lodgement summary card data
|
||||
// -----------------------------------------------------------------------
|
||||
export type WorkPhaseStats = {
|
||||
completedDeals: ClassifiedDeal[];
|
||||
inProgressDeals: ClassifiedDeal[];
|
||||
completedCount: number;
|
||||
inProgressCount: number;
|
||||
completedPercentage: number; // out of ALL deals in project
|
||||
inProgressPercentage: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Damp & mould risk comparison (survey-stage vs coordination-stage flags)
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -151,10 +138,6 @@ export type ProjectProgressData = {
|
|||
completedPercentage: number; // out of non-query total
|
||||
nonQueryTotal: number;
|
||||
totalDeals: number;
|
||||
coordination: WorkPhaseStats;
|
||||
design: WorkPhaseStats;
|
||||
install: WorkPhaseStats;
|
||||
lodgement: WorkPhaseStats;
|
||||
dampMouldRisk: DampMouldRiskData;
|
||||
funnelStages: FunnelStage[];
|
||||
};
|
||||
|
|
@ -207,6 +190,8 @@ export type RemovalRequest = {
|
|||
// -----------------------------------------------------------------------
|
||||
// Top-level props for LiveTracker (client root)
|
||||
// -----------------------------------------------------------------------
|
||||
export type InstructedMeasuresByDeal = Record<string, string[]>;
|
||||
|
||||
export type LiveTrackerProps = {
|
||||
projects: ProjectData[];
|
||||
totalDeals: number;
|
||||
|
|
@ -214,6 +199,7 @@ export type LiveTrackerProps = {
|
|||
docStatusMap: DocStatusMap;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
approvalsByDeal: ApprovalsByDeal;
|
||||
instructedMeasuresByDeal: InstructedMeasuresByDeal;
|
||||
removalStatusByDeal: RemovalStatusByDeal;
|
||||
portfolioId: string;
|
||||
userRole: string;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// Create a new Date object
|
||||
const dateTime = new Date(dateTimeString);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config";
|
|||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
jsx: "automatic",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue