mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
working on pibi ui
This commit is contained in:
parent
b919fb1646
commit
a046ed4a5c
18 changed files with 2420 additions and 300 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
228
cypress/e2e/live-tracking/pibi-section.cy.js
Normal file
228
cypress/e2e/live-tracking/pibi-section.cy.js
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Live Tracking — PibiSection (replaces pibi-dates.cy.js + pibi-measures.cy.js)
|
||||
*
|
||||
* Tests the approver flow for the new per-measure PIBI request log:
|
||||
* 1. Empty state renders with a "Log first PIBI" prompt
|
||||
* 2. Approver can open the log form, pick measures + date, and submit
|
||||
* 3. Submitted batch appears as a group with orderedAt header
|
||||
* 4. Approver can mark a row complete / undo it
|
||||
* 5. Approver can delete a row
|
||||
*
|
||||
* Requires LIVE_PORTFOLIO_SLUG env var; skipped otherwise.
|
||||
* All network calls are intercepted so no real DB / HubSpot round-trips occur.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_PIBI_DEAL_NAME");
|
||||
|
||||
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: { approvedMeasures, instructedMeasures } },
|
||||
).as("getPibiMeasures");
|
||||
}
|
||||
|
||||
function stubPost(response = { ok: true, insertedCount: 2, 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 openDrawerAtPibiSection() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
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");
|
||||
cy.get("[data-testid=drawer-tab-pibi-surveys]").click();
|
||||
cy.get("[data-testid=drawer-tab-panel-pibi-surveys]").should("be.visible");
|
||||
}
|
||||
|
||||
describe("PibiSection", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log("LIVE_PORTFOLIO_SLUG not set — skipping PibiSection specs");
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
stubMeasures();
|
||||
});
|
||||
|
||||
// ── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("shows empty state with Log first PIBI prompt when no requests exist", () => {
|
||||
stubGet([]);
|
||||
openDrawerAtPibiSection();
|
||||
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");
|
||||
});
|
||||
|
||||
// ── Log form ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it("opens log form when approver clicks Log PIBI button", () => {
|
||||
stubGet([]);
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=log-pibi-button]").click();
|
||||
cy.get("[data-testid=pibi-log-form]").should("be.visible");
|
||||
cy.get("[data-testid=pibi-order-date-input]").should("be.visible");
|
||||
});
|
||||
|
||||
it("submits selected measures and order date to POST endpoint", () => {
|
||||
stubGet([]);
|
||||
// After POST, return a batch so the list re-fetches populated
|
||||
const orderedAt = "2026-05-06T00:00:00.000Z";
|
||||
stubPost({ ok: true, insertedCount: 2, hubspotSync: "ok" });
|
||||
cy.intercept(
|
||||
"GET",
|
||||
`/api/portfolio/${PORTFOLIO_ID_GLOB}/pibi-requests*`,
|
||||
{ body: { pibiRequests: [
|
||||
{ id: "1", measureName: "ASHP", orderedAt, completedAt: null },
|
||||
{ id: "2", measureName: "CWI", orderedAt, completedAt: null },
|
||||
] } },
|
||||
).as("getPibiRequestsAfter");
|
||||
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=log-pibi-button]").click();
|
||||
|
||||
cy.get("[data-testid=pibi-measure-checkbox-ASHP]").check();
|
||||
cy.get("[data-testid=pibi-measure-checkbox-CWI]").check();
|
||||
cy.get("[data-testid=pibi-order-date-input]").clear().type("2026-05-06");
|
||||
|
||||
cy.get("[data-testid=pibi-submit-button]").click();
|
||||
|
||||
cy.wait("@postPibiRequest").then((interception) => {
|
||||
expect(interception.request.body.measureNames).to.include.members(["ASHP", "CWI"]);
|
||||
expect(interception.request.body.orderedAt).to.include("2026-05-06");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Batch display ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("renders a batch group with orderedAt header and measure rows", () => {
|
||||
const orderedAt = "2026-05-01T00:00:00.000Z";
|
||||
stubGet([
|
||||
{ id: "1", measureName: "ASHP", orderedAt, completedAt: null },
|
||||
{ id: "2", measureName: "CWI", orderedAt, completedAt: null },
|
||||
]);
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=pibi-batch-group]").should("have.length", 1);
|
||||
cy.get("[data-testid=pibi-batch-group]").should("contain.text", "01 May 2026");
|
||||
cy.get("[data-testid=pibi-row-1]").should("contain.text", "ASHP");
|
||||
cy.get("[data-testid=pibi-row-2]").should("contain.text", "CWI");
|
||||
});
|
||||
|
||||
it("renders two separate batch groups for different orderedAt values", () => {
|
||||
stubGet([
|
||||
{ id: "1", measureName: "ASHP", orderedAt: "2026-04-01T00:00:00.000Z", completedAt: null },
|
||||
{ id: "2", measureName: "CWI", orderedAt: "2026-05-01T00:00:00.000Z", completedAt: null },
|
||||
]);
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=pibi-batch-group]").should("have.length", 2);
|
||||
});
|
||||
|
||||
// ── Complete / undo ───────────────────────────────────────────────────────────
|
||||
|
||||
it("marks a row complete when approver clicks Complete", () => {
|
||||
const orderedAt = "2026-05-01T00:00:00.000Z";
|
||||
stubGet([{ id: "10", measureName: "ASHP", orderedAt, completedAt: null }]);
|
||||
stubPatch("10");
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=pibi-complete-button-10]").click();
|
||||
cy.wait("@patchPibiRequest-10").then((interception) => {
|
||||
expect(interception.request.body.completedAt).to.not.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
it("undoes completion when approver clicks Undo on a completed row", () => {
|
||||
const orderedAt = "2026-05-01T00:00:00.000Z";
|
||||
const completedAt = "2026-05-06T10:00:00.000Z";
|
||||
stubGet([{ id: "11", measureName: "CWI", orderedAt, completedAt }]);
|
||||
stubPatch("11");
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=pibi-complete-button-11]").should("contain.text", "Undo");
|
||||
cy.get("[data-testid=pibi-complete-button-11]").click();
|
||||
cy.wait("@patchPibiRequest-11").then((interception) => {
|
||||
expect(interception.request.body.completedAt).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("deletes a row when approver clicks Delete", () => {
|
||||
const orderedAt = "2026-05-01T00:00:00.000Z";
|
||||
stubGet([{ id: "20", measureName: "EWI", orderedAt, completedAt: null }]);
|
||||
stubDelete("20");
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=pibi-delete-button-20]").click();
|
||||
cy.wait("@deletePibiRequest-20");
|
||||
});
|
||||
|
||||
// ── Mark all complete ─────────────────────────────────────────────────────────
|
||||
|
||||
it("marks all rows in a batch complete via the batch header button", () => {
|
||||
const orderedAt = "2026-05-01T00:00:00.000Z";
|
||||
stubGet([
|
||||
{ id: "30", measureName: "ASHP", orderedAt, completedAt: null },
|
||||
{ id: "31", measureName: "CWI", orderedAt, completedAt: null },
|
||||
]);
|
||||
stubPatch("30");
|
||||
stubPatch("31");
|
||||
openDrawerAtPibiSection();
|
||||
cy.wait("@getPibiRequests");
|
||||
|
||||
cy.get("[data-testid=pibi-batch-complete-button]").click();
|
||||
cy.wait("@patchPibiRequest-30");
|
||||
cy.wait("@patchPibiRequest-31");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
37
src/app/db/schema/pibi_requests.ts
Normal file
37
src/app/db/schema/pibi_requests.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
bigserial,
|
||||
text,
|
||||
timestamp,
|
||||
pgTable,
|
||||
bigint,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { user } from "./users";
|
||||
import { portfolio } from "./portfolio";
|
||||
|
||||
export const pibiRequests = pgTable(
|
||||
"pibi_requests",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
hubspotDealId: text("hubspot_deal_id").notNull(),
|
||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
measureName: text("measure_name").notNull(),
|
||||
orderedAt: timestamp("ordered_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
createdByUserId: bigint("created_by_user_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
pushedAt: timestamp("pushed_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_pibi_requests_deal_id").on(table.hubspotDealId),
|
||||
index("idx_pibi_requests_portfolio_id").on(table.portfolioId),
|
||||
],
|
||||
);
|
||||
|
||||
export type PibiRequest = typeof pibiRequests.$inferSelect;
|
||||
export type NewPibiRequest = typeof pibiRequests.$inferInsert;
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,634 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
|
||||
import {
|
||||
groupByBatch,
|
||||
formatDate,
|
||||
toDateInputValue,
|
||||
dateInputToIso,
|
||||
} from "@/app/lib/pibiSectionHelpers";
|
||||
import type { PibiRow, PibiBatch } from "@/app/lib/pibiSectionHelpers";
|
||||
|
||||
// ── Measure badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
function MeasureScopeBadge({
|
||||
measure,
|
||||
approvedMeasures,
|
||||
proposedMeasures,
|
||||
}: {
|
||||
measure: string;
|
||||
approvedMeasures: string[];
|
||||
proposedMeasures: string[];
|
||||
}) {
|
||||
if (approvedMeasures.includes(measure)) {
|
||||
return (
|
||||
<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">
|
||||
Approved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (proposedMeasures.includes(measure)) {
|
||||
return (
|
||||
<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">
|
||||
Proposed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Inline row editor ─────────────────────────────────────────────────────────
|
||||
|
||||
function PibiRowEditor({
|
||||
row,
|
||||
portfolioId,
|
||||
dealId,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
row: PibiRow;
|
||||
portfolioId: string;
|
||||
dealId: string;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [measureName, setMeasureName] = useState(row.measureName);
|
||||
const [orderedAt, setOrderedAt] = useState(toDateInputValue(row.orderedAt));
|
||||
const [completedAt, setCompletedAt] = useState(toDateInputValue(row.completedAt));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/pibi-requests/${row.id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dealId,
|
||||
measureName,
|
||||
orderedAt: orderedAt ? dateInputToIso(orderedAt) : undefined,
|
||||
completedAt: completedAt ? dateInputToIso(completedAt) : null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof json.error === "string" ? json.error : "Save failed");
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="bg-blue-50/30">
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
value={measureName}
|
||||
onChange={(e) => setMeasureName(e.target.value)}
|
||||
className="w-full rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40"
|
||||
>
|
||||
{MEASURE_NAMES.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="date"
|
||||
value={orderedAt}
|
||||
onChange={(e) => setOrderedAt(e.target.value)}
|
||||
className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="date"
|
||||
value={completedAt}
|
||||
onChange={(e) => setCompletedAt(e.target.value)}
|
||||
className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="text-xs font-medium px-2.5 py-1 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="text-xs font-medium px-2.5 py-1 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-[10px] text-red-600 mt-1">{error}</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Batch group ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PibiBatchGroup({
|
||||
batch,
|
||||
portfolioId,
|
||||
dealId,
|
||||
approvedMeasures,
|
||||
proposedMeasures,
|
||||
canEdit,
|
||||
onMutated,
|
||||
}: {
|
||||
batch: PibiBatch;
|
||||
portfolioId: string;
|
||||
dealId: string;
|
||||
approvedMeasures: string[];
|
||||
proposedMeasures: string[];
|
||||
canEdit: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const allComplete = batch.rows.every((r) => r.completedAt !== null);
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
async function handleBatchComplete() {
|
||||
setCompleting(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
batch.rows
|
||||
.filter((r) => !r.completedAt)
|
||||
.map((r) =>
|
||||
fetch(`/api/portfolio/${portfolioId}/pibi-requests/${r.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dealId, completedAt }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
onMutated();
|
||||
} finally {
|
||||
setCompleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleteError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/pibi-requests/${id}?dealId=${encodeURIComponent(dealId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
onMutated();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRowComplete(row: PibiRow) {
|
||||
const newCompletedAt = row.completedAt ? null : completedAt;
|
||||
await fetch(`/api/portfolio/${portfolioId}/pibi-requests/${row.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dealId, completedAt: newCompletedAt }),
|
||||
});
|
||||
onMutated();
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="pibi-batch-group" className="rounded-xl border border-gray-100 overflow-hidden">
|
||||
{/* Batch header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-xs font-semibold text-gray-600">
|
||||
Ordered {formatDate(batch.orderedAt)}
|
||||
</span>
|
||||
{canEdit && !allComplete && (
|
||||
<button
|
||||
onClick={handleBatchComplete}
|
||||
disabled={completing}
|
||||
data-testid="pibi-batch-complete-button"
|
||||
className="text-xs font-medium px-2.5 py-1 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{completing ? "Marking…" : "Mark all complete"}
|
||||
</button>
|
||||
)}
|
||||
{allComplete && (
|
||||
<span className="text-xs font-semibold text-emerald-600 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block" />
|
||||
All complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-50">
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Measure
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Ordered
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Completed
|
||||
</th>
|
||||
{canEdit && (
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{batch.rows.map((row) =>
|
||||
editingId === row.id ? (
|
||||
<PibiRowEditor
|
||||
key={row.id}
|
||||
row={row}
|
||||
portfolioId={portfolioId}
|
||||
dealId={dealId}
|
||||
onSaved={() => { setEditingId(null); onMutated(); }}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-testid={`pibi-row-${row.id}`}
|
||||
className={row.completedAt ? "bg-emerald-50/30" : "bg-white"}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-medium ${row.completedAt ? "text-gray-400 line-through" : "text-gray-800"}`}>
|
||||
{row.measureName}
|
||||
</span>
|
||||
<MeasureScopeBadge
|
||||
measure={row.measureName}
|
||||
approvedMeasures={approvedMeasures}
|
||||
proposedMeasures={proposedMeasures}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500">
|
||||
{formatDate(row.orderedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{row.completedAt ? (
|
||||
<span className="text-emerald-600 font-medium">
|
||||
{formatDate(row.completedAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleRowComplete(row)}
|
||||
data-testid={`pibi-complete-button-${row.id}`}
|
||||
className={`text-[10px] font-medium px-2 py-0.5 rounded-full border transition-colors ${
|
||||
row.completedAt
|
||||
? "border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
: "border-emerald-200 text-emerald-600 hover:bg-emerald-50"
|
||||
}`}
|
||||
>
|
||||
{row.completedAt ? "Undo" : "Complete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(row.id)}
|
||||
className="text-[10px] font-medium px-2 py-0.5 rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(row.id)}
|
||||
data-testid={`pibi-delete-button-${row.id}`}
|
||||
className="text-[10px] font-medium px-2 py-0.5 rounded-full border border-red-100 text-red-400 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{deleteError && (
|
||||
<p className="text-[10px] text-red-600 px-3 py-1.5 border-t border-red-50 bg-red-50">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Log PIBI form ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LogPibiForm({
|
||||
portfolioId,
|
||||
dealId,
|
||||
approvedMeasures,
|
||||
proposedMeasures,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
dealId: string;
|
||||
approvedMeasures: string[];
|
||||
proposedMeasures: string[];
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const inScope = new Set([...approvedMeasures, ...proposedMeasures]);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [orderedAt, setOrderedAt] = useState(toDateInputValue(new Date().toISOString()));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggle(measure: string) {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(measure) ? next.delete(measure) : next.add(measure);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (checked.size === 0) {
|
||||
setError("Select at least one measure");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/portfolio/${portfolioId}/pibi-requests`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dealId,
|
||||
measureNames: Array.from(checked),
|
||||
orderedAt: orderedAt ? new Date(`${orderedAt}T00:00:00.000Z`).toISOString() : undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof json.error === "string" ? json.error : "Failed to log PIBI");
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to log PIBI");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="pibi-log-form" className="rounded-xl border border-brandblue/20 bg-brandlightblue/5 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-brandblue uppercase tracking-wider">
|
||||
Log PIBI
|
||||
</h4>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date picker */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600 shrink-0">
|
||||
Order date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orderedAt}
|
||||
onChange={(e) => setOrderedAt(e.target.value)}
|
||||
data-testid="pibi-order-date-input"
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Measure checkboxes */}
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{/* In-scope measures first */}
|
||||
{MEASURE_NAMES.filter((m) => inScope.has(m)).length > 0 && (
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider pb-1">
|
||||
In scope
|
||||
</p>
|
||||
)}
|
||||
{MEASURE_NAMES.filter((m) => inScope.has(m)).map((measure) => (
|
||||
<label
|
||||
key={measure}
|
||||
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-white/60 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(measure)}
|
||||
onChange={() => toggle(measure)}
|
||||
data-testid={`pibi-measure-checkbox-${measure}`}
|
||||
className="rounded accent-brandblue"
|
||||
/>
|
||||
<span className="text-xs text-gray-800">{measure}</span>
|
||||
<MeasureScopeBadge
|
||||
measure={measure}
|
||||
approvedMeasures={approvedMeasures}
|
||||
proposedMeasures={proposedMeasures}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Out-of-scope measures */}
|
||||
{MEASURE_NAMES.filter((m) => !inScope.has(m)).length > 0 && (
|
||||
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider pt-2 pb-1">
|
||||
Other measures
|
||||
</p>
|
||||
)}
|
||||
{MEASURE_NAMES.filter((m) => !inScope.has(m)).map((measure) => (
|
||||
<label
|
||||
key={measure}
|
||||
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-white/60 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(measure)}
|
||||
onChange={() => toggle(measure)}
|
||||
data-testid={`pibi-measure-checkbox-${measure}`}
|
||||
className="rounded accent-brandblue"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">{measure}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || checked.size === 0}
|
||||
data-testid="pibi-submit-button"
|
||||
className="text-xs font-medium px-4 py-2 rounded-lg bg-brandblue text-white hover:bg-brandmidblue disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? "Logging…" : `Log ${checked.size > 0 ? checked.size : ""} PIBI${checked.size !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="text-xs font-medium px-4 py-2 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main section ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface PibiSectionProps {
|
||||
dealId: string;
|
||||
portfolioId: string;
|
||||
proposedMeasures: string[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function PibiSection({
|
||||
dealId,
|
||||
portfolioId,
|
||||
proposedMeasures,
|
||||
canEdit,
|
||||
}: PibiSectionProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const batches = useMemo(
|
||||
() => groupByBatch(data?.pibiRequests ?? []),
|
||||
[data],
|
||||
);
|
||||
|
||||
function invalidate() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["pibiRequests", portfolioId, dealId] });
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<span className="text-xs text-gray-400">Loading PIBIs…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="pibi-section">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-700">PIBI Requests</span>
|
||||
{canEdit && !showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
data-testid="log-pibi-button"
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-brandblue text-white hover:bg-brandmidblue transition-colors"
|
||||
>
|
||||
+ Log PIBI
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log form */}
|
||||
{showForm && (
|
||||
<LogPibiForm
|
||||
portfolioId={portfolioId}
|
||||
dealId={dealId}
|
||||
approvedMeasures={approvedMeasures}
|
||||
proposedMeasures={proposedMeasures}
|
||||
onSuccess={() => { setShowForm(false); invalidate(); }}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{batches.length === 0 && !showForm && (
|
||||
<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={() => setShowForm(true)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* PIBI batch table(s) */}
|
||||
{batches.map((batch) => (
|
||||
<PibiBatchGroup
|
||||
key={batch.orderedAt}
|
||||
batch={batch}
|
||||
portfolioId={portfolioId}
|
||||
dealId={dealId}
|
||||
approvedMeasures={approvedMeasures}
|
||||
proposedMeasures={proposedMeasures}
|
||||
canEdit={canEdit}
|
||||
onMutated={invalidate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import { ApprovalConfirmDialog } from "./ApprovalConfirmDialog";
|
|||
import type { PendingDiff } from "./ApprovalConfirmDialog";
|
||||
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
|
||||
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
|
||||
import { PibiSection } from "./PibiSection";
|
||||
import { useToast } from "@/app/hooks/use-toast";
|
||||
|
||||
// Sections the caller can request focus on. Used by entry-points like the
|
||||
|
|
@ -1642,7 +1643,6 @@ export default function PropertyDetailDrawer({
|
|||
}, [focusSection, deal?.dealId]);
|
||||
|
||||
// Parsed measure lists.
|
||||
const pibiMeasures = parseMeasures(deal?.measuresForPibiOrdered ?? null);
|
||||
const technicalApprovedMeasures = parseMeasures(
|
||||
deal?.technicalApprovedMeasuresForInstall ?? null,
|
||||
);
|
||||
|
|
@ -1871,43 +1871,12 @@ export default function PropertyDetailDrawer({
|
|||
{/* PIBI */}
|
||||
<div>
|
||||
<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={WRITE_ROLES.includes(userRole)}
|
||||
/>
|
||||
{userCapability.includes("approver") ? (
|
||||
<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>
|
||||
<PibiSection
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
|
||||
canEdit={userCapability.includes("approver")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Survey request */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue