working on pibi ui

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-06 23:04:45 +00:00
parent b919fb1646
commit a046ed4a5c
18 changed files with 2420 additions and 300 deletions

View file

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

View file

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

View file

@ -0,0 +1,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");
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
);
}

View file

@ -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 */}