diff --git a/cypress/e2e/live-tracking/pibi-section.cy.js b/cypress/e2e/live-tracking/pibi-section.cy.js index 4557e04..cbcb5db 100644 --- a/cypress/e2e/live-tracking/pibi-section.cy.js +++ b/cypress/e2e/live-tracking/pibi-section.cy.js @@ -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"); + }); }); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx index 631bda4..1dac39b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PibiSection.tsx @@ -1,35 +1,90 @@ "use client"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useReactTable, + getCoreRowModel, + createColumnHelper, + flexRender, + type RowData, +} from "@tanstack/react-table"; import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; import { - groupByBatch, - formatDate, toDateInputValue, dateInputToIso, + formatDate, } from "@/app/lib/pibiSectionHelpers"; -import type { PibiRow, PibiBatch } from "@/app/lib/pibiSectionHelpers"; +import type { PibiRow } from "@/app/lib/pibiSectionHelpers"; -// ── Measure badge ───────────────────────────────────────────────────────────── +// ── TableMeta augmentation ──────────────────────────────────────────────────── -function MeasureScopeBadge({ - measure, - approvedMeasures, - proposedMeasures, -}: { - measure: string; +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface TableMeta { + pibi?: PibiTableMeta; + } +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface EditableRow { + id: string; + isNew: boolean; + measureName: string; + orderedAt: string; // yyyy-mm-dd or "" + completedAt: string; // yyyy-mm-dd or "" + isDirty: boolean; + isSaving: boolean; + error: string | null; +} + +interface NewRowData { + id: string; + measureName: string; + orderedAt: string; + completedAt: string; +} + +interface PibiTableMeta { + canEdit: boolean; approvedMeasures: string[]; proposedMeasures: string[]; + updateField( + id: string, + field: "measureName" | "orderedAt" | "completedAt", + value: string, + ): void; + saveRow(id: string): void; + deleteRow(id: string): void; +} + +// ── New row ID counter ──────────────────────────────────────────────────────── + +let newRowSeq = 0; +function nextNewId() { + return `new-${++newRowSeq}`; +} + +// ── Scope badge ─────────────────────────────────────────────────────────────── + +function ScopeBadge({ + measure, + approved, + proposed, +}: { + measure: string; + approved: string[]; + proposed: string[]; }) { - if (approvedMeasures.includes(measure)) { + if (approved.includes(measure)) { return ( Approved ); } - if (proposedMeasures.includes(measure)) { + if (proposed.includes(measure)) { return ( Proposed @@ -39,476 +94,140 @@ function MeasureScopeBadge({ return null; } -// ── Inline row editor ───────────────────────────────────────────────────────── +// ── Column definitions ──────────────────────────────────────────────────────── -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(null); +const columnHelper = createColumnHelper(); - 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, - }), - }, +const COLUMNS = [ + columnHelper.accessor("measureName", { + header: "Measure", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + const r = row.original; + return ( +
+ {meta.canEdit ? ( + + ) : ( + {r.measureName} + )} + +
); - 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 ( - - - - - + columnHelper.accessor("orderedAt", { + header: "Ordered", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + const r = row.original; + if (!meta.canEdit) { + return ( + + {formatDate(r.orderedAt)} + + ); + } + return ( setOrderedAt(e.target.value)} + value={r.orderedAt} + onChange={(e) => + meta.updateField(r.id, "orderedAt", e.target.value) + } + disabled={r.isSaving} + data-testid={`pibi-ordered-date-${r.id}`} className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40" /> - - + ); + }, + }), + + columnHelper.accessor("completedAt", { + header: "Completed", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + const r = row.original; + if (!meta.canEdit) { + return r.completedAt ? ( + + {formatDate(r.completedAt)} + + ) : ( + + ); + } + return ( setCompletedAt(e.target.value)} + value={r.completedAt} + onChange={(e) => + meta.updateField(r.id, "completedAt", e.target.value) + } + disabled={r.isSaving} + data-testid={`pibi-completed-date-${r.id}`} className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-800 focus:outline-none focus:ring-1 focus:ring-brandblue/40" /> - - + ); + }, + }), + + columnHelper.display({ + id: "actions", + header: "", + cell: ({ row, table }) => { + const meta = table.options.meta!.pibi!; + if (!meta.canEdit) return null; + const r = row.original; + return (
-
- {error && ( -

{error}

- )} - - - ); -} - -// ── 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(null); - const [completing, setCompleting] = useState(false); - const [deleteError, setDeleteError] = useState(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 ( -
- {/* Batch header */} -
- - Ordered {formatDate(batch.orderedAt)} - - {canEdit && !allComplete && ( - - )} - {allComplete && ( - - - All complete - - )} -
- - {/* Rows */} - - - - - - - {canEdit && ( - - )} - - - - {batch.rows.map((row) => - editingId === row.id ? ( - { setEditingId(null); onMutated(); }} - onCancel={() => setEditingId(null)} - /> - ) : ( - - - - - {canEdit && ( - - )} - - ), + {r.error && ( + {r.error} )} - -
- Measure - - Ordered - - Completed - - Actions -
-
- - {row.measureName} - - -
-
- {formatDate(row.orderedAt)} - - {row.completedAt ? ( - - {formatDate(row.completedAt)} - - ) : ( - - )} - -
- - - -
-
- {deleteError && ( -

{deleteError}

- )} -
- ); -} + + ); + }, + }), +]; -// ── Log PIBI form ───────────────────────────────────────────────────────────── +// ── Main component ──────────────────────────────────────────────────────────── -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>(new Set()); - const [orderedAt, setOrderedAt] = useState(toDateInputValue(new Date().toISOString())); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(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 ( -
-
-

- Log PIBI -

- -
- - {/* Date picker */} -
- - 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" - /> -
- - {/* Measure checkboxes */} -
- {/* In-scope measures first */} - {MEASURE_NAMES.filter((m) => inScope.has(m)).length > 0 && ( -

- In scope -

- )} - {MEASURE_NAMES.filter((m) => inScope.has(m)).map((measure) => ( - - ))} - - {/* Out-of-scope measures */} - {MEASURE_NAMES.filter((m) => !inScope.has(m)).length > 0 && ( -

- Other measures -

- )} - {MEASURE_NAMES.filter((m) => !inScope.has(m)).map((measure) => ( - - ))} -
- - {error && ( -

{error}

- )} - -
- - -
-
- ); -} - -// ── Main section ────────────────────────────────────────────────────────────── - -interface PibiSectionProps { +export interface PibiSectionProps { dealId: string; portfolioId: string; proposedMeasures: string[]; @@ -522,7 +241,25 @@ export function PibiSection({ canEdit, }: PibiSectionProps) { const queryClient = useQueryClient(); - const [showForm, setShowForm] = useState(false); + + // Local edit state for existing rows + const [localEdits, setLocalEdits] = useState< + Record< + string, + Partial<{ measureName: string; orderedAt: string; completedAt: string }> + > + >({}); + + // Unsaved new rows + const [newRows, setNewRows] = useState([]); + + // Row IDs currently being saved or deleted + const [savingIds, setSavingIds] = useState>(new Set()); + + // Per-row error messages + const [rowErrors, setRowErrors] = useState>({}); + + // ── Server data ─────────────────────────────────────────────────────────── const { data, isLoading } = useQuery<{ pibiRequests: PibiRow[] }>({ queryKey: ["pibiRequests", portfolioId, dealId], @@ -536,7 +273,10 @@ export function PibiSection({ staleTime: 30_000, }); - const { data: measureData } = useQuery<{ approvedMeasures: string[]; instructedMeasures: string[] }>({ + const { data: measureData } = useQuery<{ + approvedMeasures: string[]; + instructedMeasures: string[]; + }>({ queryKey: ["pibiMeasures", portfolioId, dealId], queryFn: async () => { const res = await fetch( @@ -549,19 +289,251 @@ export function PibiSection({ }); const approvedMeasures = useMemo( - () => [...(measureData?.approvedMeasures ?? []), ...(measureData?.instructedMeasures ?? [])], + () => [ + ...(measureData?.approvedMeasures ?? []), + ...(measureData?.instructedMeasures ?? []), + ], [measureData], ); - const batches = useMemo( - () => groupByBatch(data?.pibiRequests ?? []), - [data], + // ── Derived table rows ──────────────────────────────────────────────────── + + const tableRows: EditableRow[] = useMemo(() => { + const serverRows = (data?.pibiRequests ?? []).map( + (row): EditableRow => ({ + id: row.id, + isNew: false, + measureName: localEdits[row.id]?.measureName ?? row.measureName, + orderedAt: + localEdits[row.id]?.orderedAt ?? toDateInputValue(row.orderedAt), + completedAt: + localEdits[row.id]?.completedAt ?? + toDateInputValue(row.completedAt), + isDirty: !!localEdits[row.id], + isSaving: savingIds.has(row.id), + error: rowErrors[row.id] ?? null, + }), + ); + + const pendingRows = newRows.map( + (row): EditableRow => ({ + id: row.id, + isNew: true, + measureName: row.measureName, + orderedAt: row.orderedAt, + completedAt: row.completedAt, + isDirty: true, + isSaving: savingIds.has(row.id), + error: rowErrors[row.id] ?? null, + }), + ); + + return [...serverRows, ...pendingRows]; + }, [data, localEdits, newRows, savingIds, rowErrors]); + + // ── Handlers ────────────────────────────────────────────────────────────── + + const updateField = useCallback( + ( + id: string, + field: "measureName" | "orderedAt" | "completedAt", + value: string, + ) => { + if (id.startsWith("new-")) { + setNewRows((prev) => + prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)), + ); + } else { + setLocalEdits((prev) => ({ + ...prev, + [id]: { ...prev[id], [field]: value }, + })); + } + }, + [], ); - function invalidate() { - void queryClient.invalidateQueries({ queryKey: ["pibiRequests", portfolioId, dealId] }); + const saveRow = useCallback( + async (id: string) => { + const row = tableRows.find((r) => r.id === id); + if (!row) return; + + setSavingIds((prev) => new Set(prev).add(id)); + setRowErrors((prev) => { + const n = { ...prev }; + delete n[id]; + return n; + }); + + try { + let res: Response; + + if (row.isNew) { + res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dealId, + measureNames: [row.measureName], + orderedAt: row.orderedAt + ? new Date(`${row.orderedAt}T00:00:00.000Z`).toISOString() + : undefined, + }), + }, + ); + } else { + res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests/${id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dealId, + measureName: row.measureName, + ...(row.orderedAt && { + orderedAt: dateInputToIso(row.orderedAt), + }), + completedAt: row.completedAt + ? dateInputToIso(row.completedAt) + : null, + }), + }, + ); + } + + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error( + typeof json.error === "string" ? json.error : "Save failed", + ); + } + + if (row.isNew) { + setNewRows((prev) => prev.filter((r) => r.id !== id)); + } else { + setLocalEdits((prev) => { + const n = { ...prev }; + delete n[id]; + return n; + }); + } + + await queryClient.invalidateQueries({ + queryKey: ["pibiRequests", portfolioId, dealId], + }); + } catch (err) { + setRowErrors((prev) => ({ + ...prev, + [id]: err instanceof Error ? err.message : "Save failed", + })); + } finally { + setSavingIds((prev) => { + const n = new Set(prev); + n.delete(id); + return n; + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [tableRows, dealId, portfolioId, queryClient], + ); + + const deleteRow = useCallback( + async (id: string) => { + const row = tableRows.find((r) => r.id === id); + if (!row) return; + + if (row.isNew) { + setNewRows((prev) => prev.filter((r) => r.id !== id)); + return; + } + + setSavingIds((prev) => new Set(prev).add(id)); + setRowErrors((prev) => { + const n = { ...prev }; + delete n[id]; + return n; + }); + + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-requests/${id}?dealId=${encodeURIComponent(dealId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Delete failed"); + + await queryClient.invalidateQueries({ + queryKey: ["pibiRequests", portfolioId, dealId], + }); + } catch (err) { + setRowErrors((prev) => ({ + ...prev, + [id]: err instanceof Error ? err.message : "Delete failed", + })); + } finally { + setSavingIds((prev) => { + const n = new Set(prev); + n.delete(id); + return n; + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [tableRows, dealId, portfolioId, queryClient], + ); + + function addRow() { + const today = new Date().toISOString().slice(0, 10); + setNewRows((prev) => [ + ...prev, + { + id: nextNewId(), + measureName: MEASURE_NAMES[0], + orderedAt: today, + completedAt: "", + }, + ]); } + async function markAllComplete() { + const now = new Date().toISOString(); + const incomplete = tableRows.filter((r) => !r.isNew && !r.completedAt); + await Promise.all( + incomplete.map((r) => + fetch(`/api/portfolio/${portfolioId}/pibi-requests/${r.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dealId, completedAt: now }), + }), + ), + ); + await queryClient.invalidateQueries({ + queryKey: ["pibiRequests", portfolioId, dealId], + }); + } + + // ── Table instance ──────────────────────────────────────────────────────── + + const table = useReactTable({ + data: tableRows, + columns: COLUMNS, + getCoreRowModel: getCoreRowModel(), + meta: { + pibi: { + canEdit, + approvedMeasures, + proposedMeasures, + updateField, + saveRow, + deleteRow, + }, + }, + }); + + // ── Render ──────────────────────────────────────────────────────────────── + if (isLoading) { return (
@@ -570,44 +542,45 @@ export function PibiSection({ ); } + const hasIncomplete = tableRows.some((r) => !r.isNew && !r.completedAt); + return (
- {/* Header row */} -
- PIBI Requests - {canEdit && !showForm && ( + {/* Header actions */} +
+ {canEdit && hasIncomplete && ( + )} + {canEdit && ( + )}
- {/* Log form */} - {showForm && ( - { setShowForm(false); invalidate(); }} - onCancel={() => setShowForm(false)} - /> - )} - {/* Empty state */} - {batches.length === 0 && !showForm && ( + {tableRows.length === 0 && (
-

No PIBIs logged yet

+

+ No PIBIs logged yet +

{canEdit && (
)} - {/* PIBI batch table(s) */} - {batches.map((batch) => ( - - ))} + {/* Table */} + {tableRows.length > 0 && ( +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {flexRender( + h.column.columnDef.header, + h.getContext(), + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+
+ )}
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx index 7e0fc39..7bc9396 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx @@ -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({ >
-
- - {isApprover ? ( - - ) : ( - pibiMeasures.length > 0 && ( -
- - {pibiMeasures.map((m) => ( - - {m} - - ))} - - } - /> -
- ) - )} -
+