updating live tracking new features UI

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 20:43:19 +00:00
parent 72b58739ff
commit 4734eeed07
16 changed files with 1321 additions and 392 deletions

View file

@ -45,6 +45,8 @@ describe("Domna survey editor — approver flow", function () {
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click).
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-section-domna]").should("exist");
}

View file

@ -43,6 +43,8 @@ describe("Halted state editor — approver flow", function () {
}
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click).
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-section-halted]").should("exist");
}

View file

@ -0,0 +1,172 @@
/**
* Live Tracking Measure approval drawer (Works tab)
*
* Verifies the approver flow for approving/unapproving proposed measures
* directly from the Works tab of the PropertyDetailDrawer:
* 1. The approver opens the Works tab and sees measure chips.
* 2. The approver toggles a measure, clicks "Review & Save".
* 3. The ApprovalConfirmDialog appears the user types "approve".
* 4. POST fires to /api/portfolio/*/approvals with the correct payload.
*
* Mirrors the same structure as `pibi-measures.cy.js`.
* Uses `cy.intercept` to observe the API call without a real CRM round-trip.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_APPROVAL_DEAL_NAME");
describe("Measure approval drawer — Works tab approver flow", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawerAtWorksTab() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
// Open a property row from the Measures table 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");
// The drawer opens on the Works tab from a Measures row click — verify.
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
cy.get("[data-testid=drawer-section-measures]").should("exist");
}
it("fetches approved measures and shows chips in the Works tab for approvers", () => {
// Stub the GET so we control the initial state.
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
body: {
pibiMeasures: [],
approvedMeasures: ["ASHP", "Solar PV"],
instructedMeasures: [],
},
}).as("getMeasures");
openDrawerAtWorksTab();
cy.wait("@getMeasures");
// Chip container should be visible.
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
// ASHP and Solar PV should be shown as approved (checked).
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
cy.get("[data-testid='measure-approval-checkbox-Solar PV']").should(
"be.checked",
);
});
it("lets an approver toggle a measure and POST the approval change", () => {
// Stub GET to return ASHP as already approved.
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
body: {
pibiMeasures: [],
approvedMeasures: ["ASHP"],
instructedMeasures: [],
},
}).as("getMeasures");
// Intercept the POST so we can assert the payload.
cy.intercept("POST", `/api/portfolio/*/approvals`, {
body: { success: true },
}).as("postApprovals");
openDrawerAtWorksTab();
cy.wait("@getMeasures");
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
// ASHP should start approved.
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
// Toggle ASHP off (unapprove it).
cy.get("[data-testid=measure-approval-chip-ASHP]").click();
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should(
"not.be.checked",
);
// "Review & Save" button should now be active.
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
cy.get("[data-testid=measure-approval-save]").click();
// ApprovalConfirmDialog should be visible — type the confirm word.
cy.contains("Confirm approval changes").should("be.visible");
cy.get("input[placeholder*=\"approve\"]").type("approve");
cy.contains("button", "Confirm").click();
// Wait for the POST and assert the payload.
cy.wait("@postApprovals").then((intercepted) => {
expect(intercepted.request.body).to.have.property("changes");
const changes = intercepted.request.body.changes;
// Should contain one change: ASHP unapproved.
const ashpChange = changes.find((c) => c.measureName === "ASHP");
expect(ashpChange).to.exist;
expect(ashpChange.approved).to.equal(false);
});
// No error banner visible.
cy.get("[data-testid=measure-approval-error]").should("not.exist");
});
it("lets an approver approve a new measure and POST with correct payload", () => {
// Stub GET — no measures approved yet.
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
body: {
pibiMeasures: [],
approvedMeasures: [],
instructedMeasures: [],
},
}).as("getMeasures");
// Intercept POST.
cy.intercept("POST", `/api/portfolio/*/approvals`, {
body: { success: true },
}).as("postApprovals");
openDrawerAtWorksTab();
cy.wait("@getMeasures");
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
// Click the first chip to approve it.
cy.get("[data-testid=measure-approval-chips]")
.find("label")
.first()
.click();
// Save button should be active.
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
cy.get("[data-testid=measure-approval-save]").click();
// Confirm dialog — type the word.
cy.contains("Confirm approval changes").should("be.visible");
cy.get("input[placeholder*=\"approve\"]").type("approve");
cy.contains("button", "Confirm").click();
cy.wait("@postApprovals").then((intercepted) => {
expect(intercepted.request.body).to.have.property("changes");
const changes = intercepted.request.body.changes;
// Should have at least one approval.
expect(changes.length).to.be.greaterThan(0);
expect(changes[0]).to.have.property("approved", true);
expect(changes[0]).to.have.property("hubspotDealId");
});
// No error banner.
cy.get("[data-testid=measure-approval-error]").should("not.exist");
});
});

View file

@ -41,6 +41,8 @@ describe("PIBI dates editor — write user flow", function () {
}
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");
}

View file

@ -40,8 +40,9 @@ describe("PIBI measure selection — approver flow", function () {
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
// Scroll to the PIBI section.
cy.get("[data-testid=drawer-section-pibi]").should("exist").scrollIntoView();
// 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", () => {

View file

@ -0,0 +1,83 @@
/**
* Live Tracking Survey request flow
*
* Verifies the client-facing "Request Survey" flow in the Survey & Admin tab:
* 1. User opens a property and navigates to Survey & Admin tab.
* 2. User fills in a free-text reason and submits the survey request.
* 3. The POST hits /api/portfolio/[id]/survey-requests.
* 4. The drawer reflects the pending request (badge shown).
* 5. On reload the pending request is still visible.
*
* Uses cy.intercept so the HubSpot side-effect is observable without a live
* CRM round-trip.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
const TARGET_DEAL_NAME = Cypress.env("LIVE_SURVEY_REQUEST_DEAL_NAME");
const SURVEY_NOTES = "Please arrange a full retrofit assessment — tenant has moved in.";
describe("Survey request — 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 openDrawerAtSurveyAdmin() {
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-survey-admin]").click();
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
}
it("shows the survey request form in the Survey & Admin tab", () => {
openDrawerAtSurveyAdmin();
cy.get("[data-testid=survey-request-form]").should("be.visible");
cy.get("[data-testid=survey-request-notes]").should("be.visible");
cy.get("[data-testid=survey-request-submit]").should("be.visible");
});
it("submits a survey request and shows a pending badge", () => {
cy.intercept(
"POST",
`/api/portfolio/*/survey-requests`,
).as("createSurveyRequest");
openDrawerAtSurveyAdmin();
cy.get("[data-testid=survey-request-notes]").type(SURVEY_NOTES);
cy.get("[data-testid=survey-request-submit]")
.should("not.be.disabled")
.click();
cy.wait("@createSurveyRequest").then((intercepted) => {
expect(intercepted.request.body).to.have.property("notes");
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
expect(intercepted.response.body).to.have.property("ok", true);
});
// Pending badge appears after submission.
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
});
it("persists the pending request across a page reload", () => {
openDrawerAtSurveyAdmin();
// If a pending request exists it should be visible without submitting again.
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
});
});

View file

@ -0,0 +1,91 @@
/**
* Live Tracking Tabbed property detail drawer
*
* Verifies the four-tab drawer structure introduced in the UI redesign:
* Overview | Works | PIBI | Survey & Admin
*
* The spec opens the drawer from the Properties table (first row) and asserts
* tab presence, default active state, and navigation between tabs.
*/
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
describe("Property detail drawer — tabbed layout", function () {
before(function () {
if (!PORTFOLIO_SLUG) {
cy.log(
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
);
this.skip();
}
});
function openDrawer() {
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Measures").click();
cy.get("[data-testid=measures-row]").first().click();
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
}
it("opens with Overview tab active by default", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-overview]")
.should("be.visible")
.and("have.attr", "aria-selected", "true");
cy.get("[data-testid=drawer-tab-panel-overview]").should("be.visible");
});
it("shows all four tabs", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-overview]").should("be.visible");
cy.get("[data-testid=drawer-tab-works]").should("be.visible");
cy.get("[data-testid=drawer-tab-pibi]").should("be.visible");
cy.get("[data-testid=drawer-tab-survey-admin]").should("be.visible");
});
it("navigates to Works tab and shows measures content", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-works]").click();
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
// Measures section lives in Works tab
cy.get("[data-testid=drawer-section-measures]").should("exist");
});
it("navigates to PIBI tab and shows PIBI content", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-pibi]").click();
cy.get("[data-testid=drawer-tab-panel-pibi]").should("be.visible");
cy.get("[data-testid=drawer-section-pibi]").should("exist");
});
it("navigates to Survey & Admin tab and shows admin content", () => {
openDrawer();
cy.get("[data-testid=drawer-tab-survey-admin]").click();
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
cy.get("[data-testid=drawer-section-domna]").should("exist");
});
it("focusSection=pibi opens PIBI tab directly", () => {
// This is exercised by the pibi-dates.cy.js helper that clicks the Measures
// tab row — after the redesign those rows pass focusSection="pibi" and the
// drawer should land on the PIBI tab, not Overview.
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
cy.contains("button, [role=tab]", "Measures").click();
cy.get("[data-testid=measures-row]").first().click();
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
cy.get("[data-testid=drawer-tab-works]")
.should("have.attr", "aria-selected", "true");
});
});

View file

@ -0,0 +1,167 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { surveyRequests } from "@/app/db/schema/survey_requests";
import { portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq, desc } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
const WRITE_ROLES = ["creator", "admin", "write"] as const;
async function getRequestingUser(email: string) {
const rows = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, email))
.limit(1);
return rows[0] ?? null;
}
async function getUserRole(portfolioId: bigint, userId: bigint) {
const rows = await db
.select({ role: portfolioUsers.role })
.from(portfolioUsers)
.where(
and(
eq(portfolioUsers.portfolioId, portfolioId),
eq(portfolioUsers.userId, userId),
),
)
.limit(1);
return rows[0]?.role ?? null;
}
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
// Returns all survey requests for a deal, most recent first.
export async function GET(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const { portfolioId } = await props.params;
const dealId = req.nextUrl.searchParams.get("dealId");
if (!dealId) {
return NextResponse.json({ error: "dealId required" }, { status: 400 });
}
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
try {
const rows = await db
.select({
id: surveyRequests.id,
hubspotDealId: surveyRequests.hubspotDealId,
notes: surveyRequests.notes,
status: surveyRequests.status,
requestedAt: surveyRequests.requestedAt,
fulfilledAt: surveyRequests.fulfilledAt,
requestedByEmail: user.email,
})
.from(surveyRequests)
.innerJoin(user, eq(user.id, surveyRequests.requestedBy))
.where(
and(
eq(surveyRequests.hubspotDealId, dealId),
eq(surveyRequests.portfolioId, BigInt(portfolioId)),
),
)
.orderBy(desc(surveyRequests.requestedAt))
.limit(20);
const requests = rows.map((r) => ({
id: String(r.id),
hubspotDealId: r.hubspotDealId,
notes: r.notes,
status: r.status,
requestedByEmail: r.requestedByEmail,
requestedAt: r.requestedAt?.toISOString() ?? null,
fulfilledAt: r.fulfilledAt?.toISOString() ?? null,
}));
return NextResponse.json({ requests });
} catch (err) {
console.error("[survey-requests GET]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
const postSchema = z.object({
hubspotDealId: z.string().min(1),
notes: z.string().min(1, "Notes are required"),
});
// POST /api/portfolio/[portfolioId]/survey-requests
// Submit a new survey request — requires write+ role.
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 });
}
const requestingUser = await getRequestingUser(session.user.email);
if (!requestingUser) {
return NextResponse.json({ error: "User not found" }, { status: 401 });
}
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
return NextResponse.json(
{ error: "Write access required to submit a survey request" },
{ status: 403 },
);
}
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 { hubspotDealId, notes } = parsed.data;
try {
const [inserted] = await db
.insert(surveyRequests)
.values({
hubspotDealId,
portfolioId: BigInt(portfolioId),
notes,
status: "pending",
requestedBy: requestingUser.id,
})
.returning({ id: surveyRequests.id });
const hubspotResult = await syncSurveyRequestToHubSpot({
hubspotDealId,
notes,
requestedByEmail: requestingUser.email,
});
return NextResponse.json({
ok: true,
id: String(inserted.id),
hubspotSync: hubspotResult.ok ? "ok" : "failed",
hubspotError: hubspotResult.error,
});
} catch (err) {
console.error("[survey-requests POST]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -1,2 +1 @@
ALTER TABLE "hubspot_deal_data" DROP COLUMN IF EXISTS "domna_survey_required";--> statement-breakpoint
ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_type" text;

View file

@ -0,0 +1,15 @@
CREATE TABLE "survey_requests" (
"id" bigserial PRIMARY KEY NOT NULL,
"hubspot_deal_id" text NOT NULL,
"portfolio_id" bigint NOT NULL,
"notes" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"requested_by" bigint NOT NULL,
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
"fulfilled_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_survey_requests_deal_id" ON "survey_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
CREATE INDEX "idx_survey_requests_portfolio_id" ON "survey_requests" USING btree ("portfolio_id");

View file

@ -1,4 +1,4 @@
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
export const hubspotDealData = pgTable("hubspot_deal_data", {
@ -66,6 +66,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", {
propertyHaltedReason: text("property_halted_reason"),
technicalApprovedMeasuresForInstall: text("technical_approved_measures_for_install"),
sentToInstallerForPricing: timestamp("sent_to_installer_for_pricing", { precision: 6, withTimezone: true }),
domnasurveyRequired: boolean("domna_survey_required"),
domnaSurveyType: text("domna_survey_type"),
domnaSurveyDate: timestamp("domna_survey_date", { precision: 6, withTimezone: true }),

View file

@ -0,0 +1,41 @@
import {
bigserial,
text,
timestamp,
pgTable,
bigint,
index,
} from "drizzle-orm/pg-core";
import { user } from "./users";
import { portfolio } from "./portfolio";
// One row per survey request from a portfolio user. A deal can accumulate
// multiple requests over time; query by hubspotDealId ordered by requestedAt
// desc to get the latest.
export const surveyRequests = pgTable(
"survey_requests",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
hubspotDealId: text("hubspot_deal_id").notNull(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
// Free-text notes from the requester describing what survey is needed.
notes: text("notes").notNull(),
// 'pending' | 'fulfilled'
status: text("status").notNull().default("pending"),
requestedBy: bigint("requested_by", { mode: "bigint" })
.notNull()
.references(() => user.id),
requestedAt: timestamp("requested_at", { withTimezone: true })
.defaultNow()
.notNull(),
fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
},
(table) => [
index("idx_survey_requests_deal_id").on(table.hubspotDealId),
index("idx_survey_requests_portfolio_id").on(table.portfolioId),
],
);
export type SurveyRequest = typeof surveyRequests.$inferSelect;

View file

@ -185,6 +185,27 @@ export async function syncMeasuresFieldToHubSpot(params: {
return { ok: false, error: "HubSpot sync failed" };
}
export async function syncSurveyRequestToHubSpot(params: {
hubspotDealId: string;
notes: string;
requestedByEmail: string;
}): Promise<{ ok: boolean; error?: string }> {
try {
const client = getHubSpotClient();
const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`;
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: { survey_request_log: log },
});
return { ok: true };
} catch (err) {
console.error("[HubSpot] syncSurveyRequestToHubSpot failed", {
dealId: params.hubspotDealId,
error: err,
});
return { ok: false, error: "HubSpot sync failed" };
}
}
export async function syncMeasureApprovalsToHubSpot(params: {
hubspotDealId: string;
approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>;

View file

@ -320,7 +320,6 @@ export default function LiveTracker({
)}
<MeasuresTable
data={currentProject?.allDeals ?? []}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
onOpenDetail={(deal) => openDetailDrawer(deal, "measures")}

View file

@ -1,7 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Table,
TableBody,
@ -11,13 +11,10 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Button } from "@/app/shadcn_components/ui/button";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Search, Save, ChevronDown, ChevronRight } from "lucide-react";
import { Search, ChevronDown, ChevronRight } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog";
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
import { parseMeasures } from "@/app/lib/parseMeasures";
type AuditEvent = {
@ -32,13 +29,11 @@ type AuditEvent = {
type Props = {
data: ClassifiedDeal[];
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
portfolioId: string;
/**
* Called when a measures row is clicked. The host (LiveTracker) opens the
* PropertyDetailDrawer focused on the Measures section. Optional so the
* table is still usable in isolation.
* Called when a row is clicked. Opens PropertyDetailDrawer focused on the
* Works tab so the user can approve measures there.
*/
onOpenDetail?: (deal: ClassifiedDeal) => void;
};
@ -144,33 +139,13 @@ function ActivityLog({
);
}
async function postApprovalChanges(
portfolioId: string,
changes: { hubspotDealId: string; measureName: string; approved: boolean }[],
) {
const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!res.ok) throw new Error("Failed to save approvals");
}
export default function MeasuresTable({
data,
userCapability,
approvalsByDeal,
portfolioId,
onOpenDetail,
}: Props) {
const [search, setSearch] = useState("");
// pendingChanges: dealId -> desired Set<measureName> (the full intended approved set)
const [pendingChanges, setPendingChanges] = useState<
Record<string, Set<string>>
>({});
const [savedApprovals, setSavedApprovals] =
useState<ApprovalsByDeal>(approvalsByDeal);
const [showConfirm, setShowConfirm] = useState(false);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
// Filter to only properties with proposed measures
@ -190,80 +165,6 @@ export default function MeasuresTable({
);
}, [dealsWithMeasures, search]);
const hasPendingChanges = Object.keys(pendingChanges).length > 0;
// Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved
const pendingDiffs = useMemo<Record<string, PendingDiff>>(() => {
const diffs: Record<string, PendingDiff> = {};
for (const [dealId, pending] of Object.entries(pendingChanges)) {
const saved = new Set(savedApprovals[dealId] ?? []);
const added = [...pending].filter((m) => !saved.has(m));
const removed = [...saved].filter((m) => !pending.has(m));
if (added.length > 0 || removed.length > 0) {
diffs[dealId] = { added, removed };
}
}
return diffs;
}, [pendingChanges, savedApprovals]);
const dealNames = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const d of dealsWithMeasures) {
map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId;
}
return map;
}, [dealsWithMeasures]);
const saveMutation = useMutation({
mutationFn: () => {
// Build flat list of explicit changes from diffs
const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = [];
for (const [dealId, diff] of Object.entries(pendingDiffs)) {
for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true });
for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false });
}
return postApprovalChanges(portfolioId, changes);
},
onSuccess: () => {
setSavedApprovals((prev) => {
const next = { ...prev };
for (const [dealId, pending] of Object.entries(pendingChanges)) {
next[dealId] = Array.from(pending);
}
return next;
});
setPendingChanges({});
setShowConfirm(false);
},
});
function toggleMeasure(dealId: string, measure: string) {
setPendingChanges((prev) => {
const base =
prev[dealId] !== undefined
? new Set(prev[dealId])
: new Set(savedApprovals[dealId] ?? []);
if (base.has(measure)) {
base.delete(measure);
} else {
base.add(measure);
}
// If pending equals saved, remove from tracking
const saved = new Set(savedApprovals[dealId] ?? []);
const equal = base.size === saved.size && [...base].every((m) => saved.has(m));
const next = { ...prev };
if (equal) {
delete next[dealId];
} else {
next[dealId] = base;
}
return next;
});
}
function toggleRowExpand(dealId: string) {
setExpandedRows((prev) => {
const next = new Set(prev);
@ -300,16 +201,9 @@ export default function MeasuresTable({
<span className="text-xs text-gray-400">
{filtered.length} of {dealsWithMeasures.length} properties
</span>
{userCapability.includes("approver") && hasPendingChanges && (
<Button
size="sm"
onClick={() => setShowConfirm(true)}
className="bg-brandblue text-white gap-1.5"
>
<Save className="h-3.5 w-3.5" />
Review changes ({Object.keys(pendingDiffs).length})
</Button>
)}
<span className="text-xs text-gray-400 hidden sm:inline">
· Click a row to approve measures
</span>
</div>
</div>
@ -336,13 +230,9 @@ export default function MeasuresTable({
<TableBody>
{filtered.map((deal) => {
const proposed = parseMeasures(deal.proposedMeasures);
const approvedForDeal =
pendingChanges[deal.dealId] !== undefined
? Array.from(pendingChanges[deal.dealId])
: (savedApprovals[deal.dealId] ?? []);
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
const approvedSet = new Set(approvedForDeal);
const stageColor = STAGE_COLORS[deal.displayStage];
const hasPending = pendingChanges[deal.dealId] !== undefined;
const isExpanded = expandedRows.has(deal.dealId);
const handleRowClick = () => {
@ -364,7 +254,7 @@ export default function MeasuresTable({
onKeyDown={onOpenDetail ? handleRowKeyDown : undefined}
tabIndex={onOpenDetail ? 0 : undefined}
role={onOpenDetail ? "button" : undefined}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${hasPending ? "bg-amber-50/30" : ""} ${onOpenDetail ? "cursor-pointer" : ""}`}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onOpenDetail ? "cursor-pointer" : ""}`}
>
{/* Expand toggle */}
<TableCell className="py-3 pl-3 pr-0 w-6">
@ -403,31 +293,11 @@ export default function MeasuresTable({
</span>
</TableCell>
{/* Proposed measures */}
{/* Proposed measures — read-only; click the row to approve in the drawer */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const isApproved = approvedSet.has(measure);
if (userCapability.includes("approver")) {
return (
<label
key={measure}
onClick={(e) => e.stopPropagation()}
className={`flex items-center gap-1.5 cursor-pointer px-2 py-1 rounded-full text-xs border transition-colors ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
}`}
>
<Checkbox
checked={isApproved}
onCheckedChange={() => toggleMeasure(deal.dealId, measure)}
className="h-3 w-3"
/>
{measure}
</label>
);
}
return (
<span
key={measure}
@ -474,16 +344,6 @@ export default function MeasuresTable({
</Table>
</div>
{/* Confirmation dialog */}
<ApprovalConfirmDialog
open={showConfirm}
pendingDiffs={pendingDiffs}
dealNames={dealNames}
onConfirm={() => saveMutation.mutate()}
onCancel={() => setShowConfirm(false)}
isPending={saveMutation.isPending}
/>
</div>
);
}