updated PIBI ui

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-07 11:26:22 +00:00
parent 2df592d1ac
commit 2fc87d4dbf
3 changed files with 702 additions and 669 deletions

View file

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

View file

@ -28,14 +28,13 @@ import {
MeasureApprovalEditor,
InstructMeasureEditor,
ApprovalLogSection,
PibiDatesEditor,
PibiMeasureSelector,
SurveyRequestSection,
RemovalRequestSection,
SectionHeader,
SECTION_TITLES,
WRITE_ROLES,
} from "../PropertyDetailDrawer";
import { PibiSection } from "../PibiSection";
type Tab = "works" | "pibi-surveys" | "documents";
const VALID_TABS: Tab[] = ["works", "pibi-surveys", "documents"];
@ -84,8 +83,6 @@ export default function DealPage({
const technicalApprovedMeasures = parseMeasures(
deal.technicalApprovedMeasuresForInstall ?? null,
);
const pibiMeasures = parseMeasures(deal.measuresForPibiOrdered ?? null);
const isApprover = userCapability.includes("approver");
const canWrite = WRITE_ROLES.includes(userRole);
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
@ -308,43 +305,12 @@ export default function DealPage({
>
<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={canWrite}
/>
{isApprover ? (
<PibiMeasureSelector
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit
/>
) : (
pibiMeasures.length > 0 && (
<div className="divide-y divide-gray-50">
<InfoRow
label="Measures for PIBI"
value={
<span className="flex flex-wrap gap-1.5">
{pibiMeasures.map((m) => (
<span
key={m}
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
>
{m}
</span>
))}
</span>
}
/>
</div>
)
)}
</div>
<PibiSection
dealId={deal.dealId}
portfolioId={portfolioId}
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
canEdit={isApprover}
/>
</div>
<div className="border-t border-gray-100 pt-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">