Adding bulk approve and instruct

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-09 08:39:20 +00:00
parent 1620a9a65f
commit d467a61c22
15 changed files with 1434 additions and 144 deletions

View file

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { db } from "@/app/db/db";
import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import { and, eq } from "drizzle-orm";
import { bulkApprove } from "@/app/lib/bulkApprove";
const bodySchema = z.object({
changes: z
.array(
z.object({
hubspotDealId: z.string().min(1),
measureName: z.string().min(1),
approved: z.boolean(),
}),
)
.min(1, "changes must not be empty"),
});
/**
* POST /api/portfolio/[portfolioId]/bulk-approvals
*
* Approver-only. Applies all approve/unapprove changes in a single atomic
* DB transaction. If any change fails the entire batch is rolled back.
*
* Body: { changes: [{ hubspotDealId, measureName, approved }] }
* Response: 200 { ok: true, hubspotSync: "ok" | "failed" } | 400/401/403
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
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 capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.some((r) => r.capability === "approver")) {
return NextResponse.json({ error: "Approver capability required" }, { status: 403 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const result = await bulkApprove({
changes: body.changes,
userId: userRow[0].id,
actedByEmail: session.user.email,
});
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,109 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
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 { and, eq } from "drizzle-orm";
import { bulkInstructDeals } from "@/app/lib/bulkInstructDeals";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
const bodySchema = z.object({
deals: z
.array(
z.object({
dealId: z.string().min(1),
measureNames: z.array(z.string().min(1)).min(1),
}),
)
.min(1, "deals must not be empty"),
notes: z.string().optional(),
});
/**
* POST /api/portfolio/[portfolioId]/bulk-instructed-measures
*
* Approver-only. Instructs the given measures on each listed deal in a single
* atomic DB transaction. If any deal/measure fails the entire batch rolls back.
*
* Body: { deals: [{ dealId, measureNames }], notes? }
* Response: 200 { ok: true, hubspotSync } | 400/401/403
*/
export async function POST(
req: NextRequest,
props: { params: Promise<{ portfolioId: string }> },
) {
const session = await getServerSession(AuthOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
}
const { portfolioId } = await props.params;
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 capabilityRows = await db
.select({ capability: portfolioCapabilities.capability })
.from(portfolioCapabilities)
.where(
and(
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
eq(portfolioCapabilities.userId, userRow[0].id),
),
);
if (!capabilityRows.some((r) => r.capability === "approver")) {
return NextResponse.json({ error: "Approver capability required" }, { status: 403 });
}
let body: z.infer<typeof bodySchema>;
try {
body = bodySchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
for (const deal of body.deals) {
for (const name of deal.measureNames) {
if (!(MEASURE_NAMES as ReadonlyArray<string>).includes(name)) {
return NextResponse.json({ error: `Unknown measure: ${name}` }, { status: 400 });
}
}
}
const result = await bulkInstructDeals({
deals: body.deals,
userId: userRow[0].id,
notes: body.notes,
});
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,86 @@
import { describe, it, expect, vi } from "vitest";
import { bulkApprove } from "./bulkApprove";
import type { RunBulkApproveTx, SyncApprovalsForDeals } from "./bulkApprove";
const noopSync: SyncApprovalsForDeals = async () => ({ ok: true });
function makeSuccessTx(approvedByDeal: Record<string, string[]> = {}): RunBulkApproveTx {
return async ({ changes }) => {
const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))];
const result: Record<string, string[]> = {};
for (const id of dealIds) {
result[id] = approvedByDeal[id] ?? [];
}
return { approvedMeasuresByDeal: result };
};
}
describe("bulkApprove", () => {
it("returns ok:false when changes array is empty", async () => {
const result = await bulkApprove({
changes: [],
userId: 1n,
deps: { runBulkApproveTx: makeSuccessTx(), syncApprovals: noopSync },
});
expect(result).toEqual({ ok: false, error: "changes must not be empty" });
});
it("calls tx with all changes and returns ok:true on success", async () => {
const txSpy = vi.fn(makeSuccessTx({ "deal-1": ["Loft insulation"] }));
const result = await bulkApprove({
changes: [{ hubspotDealId: "deal-1", measureName: "Loft insulation", approved: true }],
userId: 1n,
deps: { runBulkApproveTx: txSpy, syncApprovals: noopSync },
});
expect(result.ok).toBe(true);
expect(txSpy).toHaveBeenCalledOnce();
expect(txSpy.mock.calls[0][0].changes).toHaveLength(1);
});
it("returns ok:false when tx throws (atomic rollback)", async () => {
const failingTx: RunBulkApproveTx = async () => {
throw new Error("DB constraint violated");
};
const result = await bulkApprove({
changes: [{ hubspotDealId: "deal-1", measureName: "CWI", approved: true }],
userId: 1n,
deps: { runBulkApproveTx: failingTx, syncApprovals: noopSync },
});
expect(result).toMatchObject({ ok: false, error: "DB constraint violated" });
});
it("returns ok:true with hubspotSync:'failed' when HubSpot sync fails", async () => {
const failingSync: SyncApprovalsForDeals = async () => ({
ok: false,
error: "HubSpot timeout",
});
const result = await bulkApprove({
changes: [{ hubspotDealId: "deal-1", measureName: "CWI", approved: true }],
userId: 1n,
deps: { runBulkApproveTx: makeSuccessTx(), syncApprovals: failingSync },
});
expect(result).toMatchObject({ ok: true, hubspotSync: "failed", hubspotError: "HubSpot timeout" });
});
it("calls sync once per affected deal", async () => {
const syncSpy = vi.fn(noopSync);
await bulkApprove({
changes: [
{ hubspotDealId: "deal-1", measureName: "CWI", approved: true },
{ hubspotDealId: "deal-2", measureName: "ASHP", approved: true },
{ hubspotDealId: "deal-1", measureName: "EWI", approved: true },
],
userId: 1n,
deps: {
runBulkApproveTx: makeSuccessTx({
"deal-1": ["CWI", "EWI"],
"deal-2": ["ASHP"],
}),
syncApprovals: syncSpy,
},
});
expect(syncSpy).toHaveBeenCalledOnce();
const call = syncSpy.mock.calls[0][0];
expect(Object.keys(call.approvedMeasuresByDeal).sort()).toEqual(["deal-1", "deal-2"]);
});
});

156
src/app/lib/bulkApprove.ts Normal file
View file

@ -0,0 +1,156 @@
import { db } from "@/app/db/db";
import { and, eq, inArray } from "drizzle-orm";
import {
dealMeasureApprovals,
dealMeasureApprovalEvents,
} from "@/app/db/schema/approvals";
import { user } from "@/app/db/schema/users";
import {
syncMeasureApprovalsToHubSpot,
syncMeasuresFieldToHubSpot,
} from "@/app/lib/hubspot/dealSync";
import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure";
export interface BulkApproveChange {
hubspotDealId: string;
measureName: string;
approved: boolean;
}
export interface BulkApproveTxResult {
approvedMeasuresByDeal: Record<string, string[]>;
}
export type RunBulkApproveTx = (params: {
changes: BulkApproveChange[];
userId: bigint;
}) => Promise<BulkApproveTxResult>;
export type SyncApprovalsForDeals = (params: {
approvedMeasuresByDeal: Record<string, string[]>;
actedByEmail: string;
actedAt: Date;
}) => Promise<{ ok: true } | { ok: false; error: string }>;
export type BulkApproveResult =
| { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string }
| { ok: false; error: string };
export interface BulkApproveInput {
changes: BulkApproveChange[];
userId: bigint;
actedByEmail?: string;
deps?: {
runBulkApproveTx?: RunBulkApproveTx;
syncApprovals?: SyncApprovalsForDeals;
};
}
const defaultRunBulkApproveTx: RunBulkApproveTx = async ({ changes, userId }) => {
return db.transaction(async (tx) => {
const now = new Date();
for (const change of changes) {
await tx
.insert(dealMeasureApprovals)
.values({
hubspotDealId: change.hubspotDealId,
measureName: change.measureName,
isApproved: change.approved,
approvedBy: userId,
approvedAt: now,
})
.onConflictDoUpdate({
target: [dealMeasureApprovals.hubspotDealId, dealMeasureApprovals.measureName],
set: { isApproved: change.approved, approvedBy: userId, approvedAt: now },
});
await tx.insert(dealMeasureApprovalEvents).values({
hubspotDealId: change.hubspotDealId,
measureName: change.measureName,
action: change.approved ? "approved" : "unapproved",
actedBy: userId,
actedAt: now,
});
}
const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))];
const approvalRows = await tx
.select({
hubspotDealId: dealMeasureApprovals.hubspotDealId,
measureName: dealMeasureApprovals.measureName,
})
.from(dealMeasureApprovals)
.where(
and(
inArray(dealMeasureApprovals.hubspotDealId, dealIds),
eq(dealMeasureApprovals.isApproved, true),
),
);
const approvedMeasuresByDeal: Record<string, string[]> = {};
for (const row of approvalRows) {
(approvedMeasuresByDeal[row.hubspotDealId] ??= []).push(row.measureName);
}
return { approvedMeasuresByDeal };
});
};
const defaultSyncApprovals: SyncApprovalsForDeals = async ({
approvedMeasuresByDeal,
actedByEmail,
actedAt,
}) => {
const dealIds = Object.keys(approvedMeasuresByDeal);
for (const dealId of dealIds) {
const measures = approvedMeasuresByDeal[dealId] ?? [];
void syncMeasureApprovalsToHubSpot({
hubspotDealId: dealId,
approvedMeasures: measures.map((m) => ({ measureName: m, approvedByEmail: actedByEmail })),
actedByEmail,
actedAt,
});
const result = await syncMeasuresFieldToHubSpot({
hubspotDealId: dealId,
propName: APPROVED_MEASURES_PROP,
measureNames: measures,
});
if (!result.ok) {
return { ok: false, error: result.error };
}
}
return { ok: true };
};
export async function bulkApprove(input: BulkApproveInput): Promise<BulkApproveResult> {
if (input.changes.length === 0) {
return { ok: false, error: "changes must not be empty" };
}
const runTx = input.deps?.runBulkApproveTx ?? defaultRunBulkApproveTx;
const syncApprovals = input.deps?.syncApprovals ?? defaultSyncApprovals;
let txResult: BulkApproveTxResult;
try {
txResult = await runTx({ changes: input.changes, userId: input.userId });
} catch (err) {
const message = err instanceof Error ? err.message : "Bulk approve transaction failed";
console.error("[bulkApprove] transaction failed", { error: err });
return { ok: false, error: message };
}
const syncResult = await syncApprovals({
approvedMeasuresByDeal: txResult.approvedMeasuresByDeal,
actedByEmail: input.actedByEmail ?? "unknown",
actedAt: new Date(),
});
if (!syncResult.ok) {
return { ok: true, hubspotSync: "failed", hubspotError: syncResult.error };
}
return { ok: true, hubspotSync: "ok" };
}

View file

@ -0,0 +1,94 @@
import { describe, it, expect, vi } from "vitest";
import { bulkInstructDeals } from "./bulkInstructDeals";
import type { RunBulkInstructTx, SyncInstructedForDeals } from "./bulkInstructDeals";
const noopSync: SyncInstructedForDeals = async () => ({ ok: true });
function makeSuccessTx(): RunBulkInstructTx {
return async ({ deals }) => ({
instructedRowIds: deals.map((_, i) => BigInt(i + 1)),
approvedMeasuresByDeal: Object.fromEntries(
deals.map((d) => [d.dealId, d.measureNames]),
),
});
}
describe("bulkInstructDeals", () => {
it("returns ok:false when deals array is empty", async () => {
const result = await bulkInstructDeals({
deals: [],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toEqual({ ok: false, error: "deals must not be empty" });
});
it("returns ok:false when a deal has empty measureNames", async () => {
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: [] }],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: false });
});
it("returns ok:false for unknown measure name", async () => {
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: ["Not A Real Measure"] }],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: false, error: expect.stringContaining("Unknown measure") });
});
it("returns ok:true when all deals succeed", async () => {
const result = await bulkInstructDeals({
deals: [
{ dealId: "deal-1", measureNames: ["ASHP", "CWI"] },
{ dealId: "deal-2", measureNames: ["EWI"] },
],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: true, hubspotSync: "ok" });
});
it("returns ok:false when tx throws (atomic rollback)", async () => {
const failingTx: RunBulkInstructTx = async () => {
throw new Error("FK violation");
};
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: ["ASHP"] }],
userId: 1n,
deps: { runBulkInstructTx: failingTx, syncInstructed: noopSync },
});
expect(result).toMatchObject({ ok: false, error: "FK violation" });
});
it("returns ok:true with hubspotSync:'failed' when sync fails", async () => {
const failSync: SyncInstructedForDeals = async () => ({
ok: false,
error: "HubSpot rate limit",
});
const result = await bulkInstructDeals({
deals: [{ dealId: "deal-1", measureNames: ["ASHP"] }],
userId: 1n,
deps: { runBulkInstructTx: makeSuccessTx(), syncInstructed: failSync },
});
expect(result).toMatchObject({ ok: true, hubspotSync: "failed", hubspotError: "HubSpot rate limit" });
});
it("passes all deals to the tx in one call", async () => {
const txSpy = vi.fn(makeSuccessTx());
await bulkInstructDeals({
deals: [
{ dealId: "deal-1", measureNames: ["ASHP"] },
{ dealId: "deal-2", measureNames: ["CWI"] },
],
userId: 1n,
deps: { runBulkInstructTx: txSpy, syncInstructed: noopSync },
});
expect(txSpy).toHaveBeenCalledOnce();
expect(txSpy.mock.calls[0][0].deals).toHaveLength(2);
});
});

View file

@ -0,0 +1,179 @@
import { db } from "@/app/db/db";
import { and, eq } from "drizzle-orm";
import {
dealMeasureApprovals,
dealMeasureApprovalEvents,
} from "@/app/db/schema/approvals";
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import { MEASURE_NAMES, type MeasureName } from "@/app/lib/measureDocumentRequirements";
import { syncMeasuresFieldToHubSpot } from "@/app/lib/hubspot/dealSync";
import {
INSTRUCTED_MEASURES_PROP,
PROPOSED_MEASURES_PROP,
APPROVED_MEASURES_PROP,
} from "@/app/lib/instructMeasure";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
export interface BulkInstructDeal {
dealId: string;
measureNames: string[];
}
export interface BulkInstructTxResult {
instructedRowIds: bigint[];
approvedMeasuresByDeal: Record<string, string[]>;
}
export type RunBulkInstructTx = (params: {
deals: Array<{ dealId: string; measureNames: MeasureName[] }>;
userId: bigint;
notes: string | null;
}) => Promise<BulkInstructTxResult>;
export type SyncInstructedForDeals = (params: {
approvedMeasuresByDeal: Record<string, string[]>;
}) => Promise<{ ok: true } | { ok: false; error: string }>;
export type BulkInstructDealsResult =
| { ok: true; hubspotSync: "ok" | "failed"; hubspotError?: string }
| { ok: false; error: string };
export interface BulkInstructDealsInput {
deals: BulkInstructDeal[];
userId: bigint;
notes?: string;
deps?: {
runBulkInstructTx?: RunBulkInstructTx;
syncInstructed?: SyncInstructedForDeals;
};
}
function isMeasureName(value: string): value is MeasureName {
return (MEASURE_NAMES as ReadonlyArray<string>).includes(value);
}
const defaultRunBulkInstructTx: RunBulkInstructTx = async ({ deals, userId, notes }) => {
return db.transaction(async (tx) => {
const instructedRowIds: bigint[] = [];
const approvedMeasuresByDeal: Record<string, string[]> = {};
for (const { dealId, measureNames } of deals) {
for (const measureName of measureNames) {
const inserted = await tx
.insert(userDefinedDealMeasures)
.values({ hubspotDealId: dealId, measureName, source: "instructed", createdByUserId: userId, notes })
.returning({ id: userDefinedDealMeasures.id });
const rowId = inserted[0]?.id;
if (!rowId) throw new Error(`Failed to insert instructed measure row for deal ${dealId}`);
instructedRowIds.push(rowId);
await tx
.insert(dealMeasureApprovals)
.values({ hubspotDealId: dealId, measureName, isApproved: true, approvedBy: userId })
.onConflictDoUpdate({
target: [dealMeasureApprovals.hubspotDealId, dealMeasureApprovals.measureName],
set: { isApproved: true, approvedBy: userId, approvedAt: new Date() },
});
await tx.insert(dealMeasureApprovalEvents).values({
hubspotDealId: dealId,
measureName,
action: "approved",
actedBy: userId,
});
}
const approvalRows = await tx
.select({ measureName: dealMeasureApprovals.measureName })
.from(dealMeasureApprovals)
.where(
and(
eq(dealMeasureApprovals.hubspotDealId, dealId),
eq(dealMeasureApprovals.isApproved, true),
),
);
approvedMeasuresByDeal[dealId] = approvalRows.map((r) => r.measureName);
}
return { instructedRowIds, approvedMeasuresByDeal };
});
};
const defaultSyncInstructed: SyncInstructedForDeals = async ({ approvedMeasuresByDeal }) => {
for (const [dealId, measures] of Object.entries(approvedMeasuresByDeal)) {
const instructedRows = await db
.select({ measureName: userDefinedDealMeasures.measureName })
.from(userDefinedDealMeasures)
.where(
and(
eq(userDefinedDealMeasures.hubspotDealId, dealId),
eq(userDefinedDealMeasures.source, "instructed"),
),
);
const allInstructed = instructedRows.map((r) => r.measureName);
const dealRow = await db
.select({ proposedMeasures: hubspotDealData.proposedMeasures })
.from(hubspotDealData)
.where(eq(hubspotDealData.dealId, dealId))
.limit(1);
const existing = parseMeasures(dealRow[0]?.proposedMeasures ?? null);
const mergedProposed = [...new Set([...existing, ...allInstructed])];
const r1 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: INSTRUCTED_MEASURES_PROP, measureNames: allInstructed });
if (!r1.ok) return { ok: false, error: r1.error };
const r2 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: PROPOSED_MEASURES_PROP, measureNames: mergedProposed });
if (!r2.ok) return { ok: false, error: r2.error };
const r3 = await syncMeasuresFieldToHubSpot({ hubspotDealId: dealId, propName: APPROVED_MEASURES_PROP, measureNames: measures });
if (!r3.ok) return { ok: false, error: r3.error };
}
return { ok: true };
};
export async function bulkInstructDeals(
input: BulkInstructDealsInput,
): Promise<BulkInstructDealsResult> {
if (input.deals.length === 0) {
return { ok: false, error: "deals must not be empty" };
}
for (const { measureNames } of input.deals) {
if (measureNames.length === 0) {
return { ok: false, error: "each deal must have at least one measureName" };
}
for (const name of measureNames) {
if (!isMeasureName(name.trim())) {
return { ok: false, error: `Unknown measure: ${name}` };
}
}
}
const validatedDeals = input.deals.map((d) => ({
dealId: d.dealId,
measureNames: d.measureNames.map((m) => m.trim() as MeasureName),
}));
const runTx = input.deps?.runBulkInstructTx ?? defaultRunBulkInstructTx;
const syncInstructed = input.deps?.syncInstructed ?? defaultSyncInstructed;
let txResult: BulkInstructTxResult;
try {
txResult = await runTx({ deals: validatedDeals, userId: input.userId, notes: input.notes ?? null });
} catch (err) {
const message = err instanceof Error ? err.message : "Bulk instruct transaction failed";
console.error("[bulkInstructDeals] transaction failed", { error: err });
return { ok: false, error: message };
}
const syncResult = await syncInstructed({ approvedMeasuresByDeal: txResult.approvedMeasuresByDeal });
if (!syncResult.ok) {
return { ok: true, hubspotSync: "failed", hubspotError: syncResult.error };
}
return { ok: true, hubspotSync: "ok" };
}

View file

@ -140,7 +140,7 @@ describe("syncSurveyRequestToHubSpot", () => {
expect(updateMock).toHaveBeenCalledWith("deal-99", {
properties: {
osmosis_survey_required: "technical_building_survey",
osmosis_survey_date: "2026-05-06T10:00:00.000Z",
osmosis_survey_date: "2026-05-06",
},
});
});

View file

@ -195,13 +195,7 @@ export async function syncSurveyRequestToHubSpot(params: {
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
osmosis_survey_required: params.surveyType,
osmosis_survey_date: String(
Date.UTC(
params.requestedAt.getUTCFullYear(),
params.requestedAt.getUTCMonth(),
params.requestedAt.getUTCDate()
)
),
osmosis_survey_date: params.requestedAt.toISOString().slice(0, 10),
},
});
return { ok: true };

View file

@ -30,6 +30,7 @@ import type {
DocumentDrawerState,
DocStatusMap,
RemovalStatusByDeal,
InstructedMeasuresByDeal,
} from "./types";
export default function LiveTracker({
@ -39,6 +40,7 @@ export default function LiveTracker({
docStatusMap,
userCapability,
approvalsByDeal,
instructedMeasuresByDeal,
removalStatusByDeal,
portfolioId,
userRole,
@ -304,7 +306,9 @@ export default function LiveTracker({
<MeasuresTable
data={currentProject?.allDeals ?? []}
approvalsByDeal={approvalsByDeal}
instructedMeasuresByDeal={instructedMeasuresByDeal}
portfolioId={portfolioId}
isApprover={userCapability.includes("approver")}
/>
</div>
</TabsContent>

View file

@ -1,6 +1,6 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
Table,
@ -12,25 +12,28 @@ import {
} from "@/app/shadcn_components/ui/table";
import { Input } from "@/app/shadcn_components/ui/input";
import { Badge } from "@/app/shadcn_components/ui/badge";
import { Search, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { Search, CheckSquare, ListChecks, Loader2, X } from "lucide-react";
import { STAGE_COLORS } from "./types";
import type { ClassifiedDeal, ApprovalsByDeal } from "./types";
import { parseMeasures } from "@/app/lib/parseMeasures";
import { ActivityLog } from "./ActivityLog";
import { filterMeasureRows } from "./measureFilters";
import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements";
type Mode = "chip-click" | "instruct";
type Props = {
data: ClassifiedDeal[];
approvalsByDeal: ApprovalsByDeal;
instructedMeasuresByDeal: Record<string, string[]>;
portfolioId: string;
isApprover: boolean;
};
function ApprovalStatus({
proposed,
approved,
}: {
proposed: string[];
approved: string[];
}) {
// ── Approval status badge ────────────────────────────────────────────────────
function ApprovalStatus({ proposed, approved }: { proposed: string[]; approved: string[] }) {
if (proposed.length === 0) return null;
const approvedSet = new Set(approved);
const approvedCount = proposed.filter((m) => approvedSet.has(m)).length;
@ -56,34 +59,295 @@ function ApprovalStatus({
);
}
// ── Bulk approve modal ───────────────────────────────────────────────────────
type BulkApproveModalProps = {
selected: Array<{ dealId: string; dealname: string | null; measureName: string }>;
portfolioId: string;
onClose: () => void;
onSuccess: () => void;
};
function BulkApproveModal({ selected, portfolioId, onClose, onSuccess }: BulkApproveModalProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const groupedByDeal = useMemo(() => {
const map = new Map<string, { dealname: string | null; measures: string[] }>();
for (const item of selected) {
const existing = map.get(item.dealId);
if (existing) {
existing.measures.push(item.measureName);
} else {
map.set(item.dealId, { dealname: item.dealname, measures: [item.measureName] });
}
}
return map;
}, [selected]);
async function handleConfirm() {
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-approvals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
changes: selected.map((s) => ({
hubspotDealId: s.dealId,
measureName: s.measureName,
approved: true,
})),
}),
});
const data = await res.json();
if (!res.ok || !data.ok) {
setError(data.error ?? "Failed to approve measures");
return;
}
onSuccess();
} catch {
setError("Network error — please try again");
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Confirm bulk approval</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-500">
Approving <span className="font-medium text-gray-800">{selected.length} measure{selected.length !== 1 ? "s" : ""}</span> across{" "}
<span className="font-medium text-gray-800">{groupedByDeal.size} propert{groupedByDeal.size !== 1 ? "ies" : "y"}</span>.
</p>
<div className="max-h-56 overflow-y-auto space-y-2">
{[...groupedByDeal.entries()].map(([dealId, { dealname, measures }]) => (
<div key={dealId} className="rounded-lg border border-gray-100 p-3 space-y-1.5">
<p className="text-xs font-medium text-gray-700">{dealname ?? dealId}</p>
<div className="flex flex-wrap gap-1">
{measures.map((m) => (
<span key={m} className="px-2 py-0.5 rounded-full text-xs bg-emerald-50 border border-emerald-200 text-emerald-700">
{m}
</span>
))}
</div>
</div>
))}
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
size="sm"
onClick={handleConfirm}
disabled={submitting}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Approve {selected.length} measure{selected.length !== 1 ? "s" : ""}
</Button>
</div>
</div>
</div>
);
}
// ── Bulk instruct modal ──────────────────────────────────────────────────────
type BulkInstructModalProps = {
selectedDealIds: string[];
deals: ClassifiedDeal[];
portfolioId: string;
onClose: () => void;
onSuccess: () => void;
};
function BulkInstructModal({ selectedDealIds, deals, portfolioId, onClose, onSuccess }: BulkInstructModalProps) {
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(new Set());
const [confirmText, setConfirmText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedDeals = useMemo(
() => deals.filter((d) => selectedDealIds.includes(d.dealId)),
[deals, selectedDealIds],
);
function toggleMeasure(name: string) {
setSelectedMeasures((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
const canSubmit = selectedMeasures.size > 0 && confirmText.trim() === "confirm" && !submitting;
async function handleSubmit() {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch(`/api/portfolio/${portfolioId}/bulk-instructed-measures`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
deals: selectedDealIds.map((dealId) => ({
dealId,
measureNames: [...selectedMeasures],
})),
}),
});
const data = await res.json();
if (!res.ok || !data.ok) {
setError(data.error ?? "Failed to instruct measures");
return;
}
onSuccess();
} catch {
setError("Network error — please try again");
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800">Bulk instruct measures</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-500">
Instructing on <span className="font-medium text-gray-800">{selectedDeals.length} propert{selectedDeals.length !== 1 ? "ies" : "y"}</span>.
Select measures to add to all selected deals.
</p>
<div className="max-h-48 overflow-y-auto">
<div className="grid grid-cols-2 gap-1.5">
{MEASURE_NAMES.map((name) => (
<label
key={name}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer text-xs transition-colors ${
selectedMeasures.has(name)
? "bg-indigo-50 border-indigo-300 text-indigo-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50"
}`}
>
<Checkbox
checked={selectedMeasures.has(name)}
onCheckedChange={() => toggleMeasure(name)}
className="h-3.5 w-3.5"
/>
{name}
</label>
))}
</div>
</div>
{selectedMeasures.size > 0 && (
<div className="space-y-1">
<p className="text-xs text-gray-500">
Type <span className="font-mono font-semibold">confirm</span> to proceed
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="confirm"
className="h-8 text-sm"
/>
</div>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Instruct {selectedMeasures.size > 0 ? selectedMeasures.size : ""} measure{selectedMeasures.size !== 1 ? "s" : ""}
</Button>
</div>
</div>
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export default function MeasuresTable({
data,
approvalsByDeal,
instructedMeasuresByDeal,
portfolioId,
isApprover,
}: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [mode, setMode] = useState<Mode>("chip-click");
// Chip-click mode: Set<"dealId::measureName">
const [selectedChips, setSelectedChips] = useState<Set<string>>(new Set());
// Instruct mode: Set<dealId>
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [showApproveModal, setShowApproveModal] = useState(false);
const [showInstructModal, setShowInstructModal] = useState(false);
// Filter to only properties with proposed measures
const dealsWithMeasures = useMemo(
() => data.filter((d) => d.proposedMeasures),
[data],
() => data.filter((d) => d.proposedMeasures || (instructedMeasuresByDeal[d.dealId]?.length ?? 0) > 0),
[data, instructedMeasuresByDeal],
);
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return dealsWithMeasures;
return dealsWithMeasures.filter(
(d) =>
d.dealname?.toLowerCase().includes(q) ||
d.landlordPropertyId?.toLowerCase().includes(q) ||
d.proposedMeasures?.toLowerCase().includes(q),
);
}, [dealsWithMeasures, search]);
const filtered = useMemo(
() => filterMeasureRows(dealsWithMeasures, instructedMeasuresByDeal, search),
[dealsWithMeasures, instructedMeasuresByDeal, search],
);
function toggleRowExpand(dealId: string) {
setExpandedRows((prev) => {
function switchMode(next: Mode) {
setMode(next);
setSelectedChips(new Set());
setSelectedRows(new Set());
}
function toggleChip(dealId: string, measureName: string) {
const key = `${dealId}::${measureName}`;
setSelectedChips((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
function toggleRow(dealId: string) {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(dealId)) next.delete(dealId);
else next.add(dealId);
@ -91,6 +355,45 @@ export default function MeasuresTable({
});
}
function toggleAllFiltered() {
const allIds = filtered.map((d) => d.dealId);
const allSelected = allIds.every((id) => selectedRows.has(id));
if (allSelected) {
setSelectedRows((prev) => {
const next = new Set(prev);
allIds.forEach((id) => next.delete(id));
return next;
});
} else {
setSelectedRows((prev) => {
const next = new Set(prev);
allIds.forEach((id) => next.add(id));
return next;
});
}
}
const selectedChipItems = useMemo(
() =>
[...selectedChips].map((key) => {
const [dealId, measureName] = key.split("::");
const deal = data.find((d) => d.dealId === dealId);
return { dealId, dealname: deal?.dealname ?? null, measureName };
}),
[selectedChips, data],
);
const allFilteredSelected =
filtered.length > 0 && filtered.every((d) => selectedRows.has(d.dealId));
function handleActionSuccess() {
setShowApproveModal(false);
setShowInstructModal(false);
setSelectedChips(new Set());
setSelectedRows(new Set());
startTransition(() => router.refresh());
}
if (dealsWithMeasures.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-white p-12 text-center">
@ -101,9 +404,11 @@ export default function MeasuresTable({
);
}
const colSpan = mode === "instruct" ? 7 : 6;
return (
<div className="space-y-4">
{/* Toolbar */}
{/* ── Toolbar ──────────────────────────────────────────────────── */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
@ -114,22 +419,100 @@ export default function MeasuresTable({
className="pl-9 h-9 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">
{filtered.length} of {dealsWithMeasures.length} properties
</span>
<span className="text-xs text-gray-400 hidden sm:inline">
· Click a row to open property
</span>
{isApprover && (
<>
<Button
size="sm"
variant={mode === "chip-click" ? "default" : "outline"}
className="h-8 text-xs gap-1.5"
onClick={() => switchMode("chip-click")}
>
<CheckSquare className="h-3.5 w-3.5" />
Approve mode
</Button>
<Button
size="sm"
variant={mode === "instruct" ? "default" : "outline"}
className="h-8 text-xs gap-1.5"
onClick={() => switchMode("instruct")}
>
<ListChecks className="h-3.5 w-3.5" />
Instruct mode
</Button>
</>
)}
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-gray-100 overflow-hidden bg-white">
{/* ── Action bar ───────────────────────────────────────────────── */}
{isApprover && mode === "chip-click" && selectedChips.size > 0 && (
<div className="flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5">
<span className="text-sm text-emerald-800 font-medium">
{selectedChips.size} measure{selectedChips.size !== 1 ? "s" : ""} selected
</span>
<Button
size="sm"
className="h-7 text-xs bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => setShowApproveModal(true)}
>
Approve selected
</Button>
<button
onClick={() => setSelectedChips(new Set())}
className="ml-auto text-emerald-600 hover:text-emerald-800 text-xs"
>
Clear
</button>
</div>
)}
{isApprover && mode === "instruct" && selectedRows.size > 0 && (
<div className="flex items-center gap-3 rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-2.5">
<span className="text-sm text-indigo-800 font-medium">
{selectedRows.size} propert{selectedRows.size !== 1 ? "ies" : "y"} selected
</span>
<Button
size="sm"
className="h-7 text-xs bg-indigo-600 hover:bg-indigo-700 text-white"
onClick={() => setShowInstructModal(true)}
>
Instruct measures
</Button>
<button
onClick={() => setSelectedRows(new Set())}
className="ml-auto text-indigo-600 hover:text-indigo-800 text-xs"
>
Clear
</button>
</div>
)}
{/* ── Table ────────────────────────────────────────────────────── */}
<div className="relative rounded-xl border border-gray-100 overflow-hidden bg-white">
{isPending && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-sm">
<Loader2 className="h-6 w-6 animate-spin text-brandblue" />
</div>
)}
<Table>
<TableHeader>
<TableRow className="bg-gray-50 border-b border-gray-100">
<TableHead className="w-6" />
{mode === "instruct" && (
<TableHead className="w-10 pl-3">
<Checkbox
checked={allFilteredSelected}
onCheckedChange={toggleAllFiltered}
aria-label="Select all filtered"
className="h-4 w-4"
/>
</TableHead>
)}
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Address
</TableHead>
@ -137,10 +520,13 @@ export default function MeasuresTable({
Stage
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Proposed Measures
Proposed
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Technical Approved
Instructed
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Tech Approved
</TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Status
@ -150,135 +536,161 @@ export default function MeasuresTable({
<TableBody>
{filtered.map((deal) => {
const proposed = parseMeasures(deal.proposedMeasures);
const instructed = instructedMeasuresByDeal[deal.dealId] ?? [];
const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall);
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
const approvedSet = new Set(approvedForDeal);
const stageColor = STAGE_COLORS[deal.displayStage];
const isExpanded = expandedRows.has(deal.dealId);
const isRowSelected = selectedRows.has(deal.dealId);
const dealPageUrl = `/portfolio/${portfolioId}/your-projects/live/${deal.dealId}?tab=works`;
const handleRowClick = () => {
const handleRowClick = (e: React.MouseEvent) => {
// Don't navigate if clicking a chip or checkbox
const target = e.target as HTMLElement;
if (target.closest("[data-chip]") || target.closest("[data-checkbox]")) return;
router.push(dealPageUrl);
};
const handleRowKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(dealPageUrl);
}
};
return (
<React.Fragment key={deal.dealId}>
<TableRow
data-testid="measures-row"
onClick={handleRowClick}
onKeyDown={handleRowKeyDown}
tabIndex={0}
role="button"
className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer"
>
{/* Expand toggle */}
<TableCell className="py-3 pl-3 pr-0 w-6">
<button
onClick={(e) => { e.stopPropagation(); toggleRowExpand(deal.dealId); }}
className="text-gray-400 hover:text-brandblue transition-colors"
aria-label={isExpanded ? "Collapse activity" : "Expand activity"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<TableRow
key={deal.dealId}
data-testid="measures-row"
onClick={handleRowClick}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(dealPageUrl);
}
}}
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors cursor-pointer ${
isRowSelected ? "bg-indigo-50/40" : ""
}`}
>
{/* Row checkbox (instruct mode only) */}
{mode === "instruct" && (
<TableCell className="pl-3 py-3 w-10" data-checkbox>
<Checkbox
checked={isRowSelected}
onCheckedChange={() => toggleRow(deal.dealId)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
aria-label={`Select ${deal.dealname ?? deal.dealId}`}
/>
</TableCell>
)}
{/* Address */}
<TableCell className="py-3">
<div className="font-medium text-sm text-gray-800">
{deal.dealname ?? "—"}
{/* Address */}
<TableCell className="py-3">
<div className="font-medium text-sm text-gray-800">
{deal.dealname ?? "—"}
</div>
{deal.landlordPropertyId && (
<div className="text-xs text-gray-400 mt-0.5">
{deal.landlordPropertyId}
</div>
{deal.landlordPropertyId && (
<div className="text-xs text-gray-400 mt-0.5">
{deal.landlordPropertyId}
</div>
)}
</TableCell>
)}
</TableCell>
{/* Stage */}
<TableCell className="py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
{deal.displayStage}
</span>
</TableCell>
{/* Stage */}
<TableCell className="py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${stageColor.dot}`} />
{deal.displayStage}
</span>
</TableCell>
{/* 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);
return (
<span
key={measure}
className={`px-2 py-1 rounded-full text-xs border ${
isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600"
}`}
>
{measure}
</span>
);
})}
</div>
</TableCell>
{/* Proposed measures */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{proposed.map((measure) => {
const chipKey = `${deal.dealId}::${measure}`;
const isSelected = selectedChips.has(chipKey);
const isApproved = approvedSet.has(measure);
const clickable = isApprover && mode === "chip-click";
{/* Technical Approved */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{parseMeasures(deal.technicalApprovedMeasuresForInstall).map((measure) => (
return (
<span
key={measure}
className="px-2 py-1 rounded-full text-xs border bg-violet-50 border-violet-200 text-violet-700"
data-chip
onClick={clickable ? (e) => { e.stopPropagation(); toggleChip(deal.dealId, measure); } : undefined}
className={`px-2 py-1 rounded-full text-xs border transition-all ${
isSelected
? "bg-emerald-100 border-emerald-400 text-emerald-800 ring-2 ring-emerald-300"
: isApproved
? "bg-emerald-50 border-emerald-200 text-emerald-700"
: "bg-gray-50 border-gray-200 text-gray-600"
} ${clickable ? "cursor-pointer hover:border-emerald-400" : ""}`}
>
{measure}
</span>
))}
</div>
</TableCell>
);
})}
</div>
</TableCell>
{/* Status */}
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
{/* Instructed measures */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{instructed.map((measure) => (
<span
key={measure}
className="px-2 py-1 rounded-full text-xs border bg-indigo-50 border-indigo-200 text-indigo-700"
>
{measure}
</span>
))}
</div>
</TableCell>
</TableRow>
{/* Tech approved */}
<TableCell className="py-3">
<div className="flex flex-wrap gap-1.5">
{techApproved.map((measure) => (
<span
key={measure}
className="px-2 py-1 rounded-full text-xs border bg-violet-50 border-violet-200 text-violet-700"
>
{measure}
</span>
))}
</div>
</TableCell>
{/* Expandable activity log row */}
{isExpanded && (
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={6}
className="p-0"
>
<div className="border-t border-gray-100">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-2 pb-1">
Activity log
</p>
<ActivityLog dealId={deal.dealId} portfolioId={portfolioId} />
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
{/* Approval status */}
<TableCell className="py-3">
<ApprovalStatus proposed={proposed} approved={approvedForDeal} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* ── Modals ───────────────────────────────────────────────────── */}
{showApproveModal && (
<BulkApproveModal
selected={selectedChipItems}
portfolioId={portfolioId}
onClose={() => setShowApproveModal(false)}
onSuccess={handleActionSuccess}
/>
)}
{showInstructModal && (
<BulkInstructModal
selectedDealIds={[...selectedRows]}
deals={data}
portfolioId={portfolioId}
onClose={() => setShowInstructModal(false)}
onSuccess={handleActionSuccess}
/>
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { filterMeasureRows } from "./measureFilters";
import type { ClassifiedDeal } from "./types";
function makeDeal(overrides: Partial<ClassifiedDeal> = {}): ClassifiedDeal {
return {
id: "1",
dealId: "deal-1",
dealname: "1 Test Street",
dealstage: null,
companyId: null,
projectCode: null,
landlordPropertyId: "LP001",
uprn: null,
outcome: null,
outcomeNotes: null,
majorConditionIssueDescription: null,
majorConditionIssuePhotos: null,
majorConditionIssuePhotosS3: null,
coordinationStatus: null,
designStatus: null,
pashubLink: null,
sharepointLink: null,
dampMouldFlag: null,
dampMouldAndRepairComments: null,
preSapScore: null,
coordinator: null,
ioeV1Date: null,
ioeV2Date: null,
ioeV3Date: null,
proposedMeasures: "Loft insulation;CWI",
approvedPackage: null,
designer: null,
designDate: null,
actualMeasuresInstalled: null,
installer: null,
installerHandover: null,
lodgementStatus: null,
measuresLodgementDate: null,
fullLodgementDate: null,
confirmedSurveyDate: null,
confirmedSurveyTime: null,
surveyedDate: null,
designType: null,
eiScore: null,
eiScorePotential: null,
epcSapScore: null,
epcSapScorePotential: null,
surveyType: null,
measuresForPibiOrdered: null,
pibiOrderDate: null,
pibiCompletedDate: null,
propertyHaltedDate: null,
propertyHaltedReason: null,
technicalApprovedMeasuresForInstall: "Solar PV",
domnaSurveyType: null,
domnaSurveyDate: null,
createdAt: new Date(),
updatedAt: new Date(),
displayStage: "Coordination in Progress",
...overrides,
};
}
describe("filterMeasureRows", () => {
it("returns all rows when search is empty", () => {
const deals = [makeDeal(), makeDeal({ dealId: "deal-2" })];
expect(filterMeasureRows(deals, {}, "")).toEqual(deals);
});
it("matches by address (dealname)", () => {
const match = makeDeal({ dealname: "12 Oak Lane" });
const noMatch = makeDeal({ dealId: "deal-2", dealname: "5 Elm Street" });
const result = filterMeasureRows([match, noMatch], {}, "oak");
expect(result).toEqual([match]);
});
it("matches by landlordPropertyId", () => {
const match = makeDeal({ landlordPropertyId: "LP999" });
const noMatch = makeDeal({ dealId: "deal-2", landlordPropertyId: "LP001" });
const result = filterMeasureRows([match, noMatch], {}, "lp999");
expect(result).toEqual([match]);
});
it("matches by proposed measure name", () => {
const match = makeDeal({ proposedMeasures: "ASHP;Loft insulation" });
const noMatch = makeDeal({ dealId: "deal-2", proposedMeasures: "CWI" });
const result = filterMeasureRows([match, noMatch], {}, "ashp");
expect(result).toEqual([match]);
});
it("matches by instructed measure name", () => {
const match = makeDeal({ dealId: "deal-1" });
const noMatch = makeDeal({ dealId: "deal-2", proposedMeasures: "CWI" });
const instructedByDeal = { "deal-1": ["EWI"] };
const result = filterMeasureRows([match, noMatch], instructedByDeal, "ewi");
expect(result).toEqual([match]);
});
it("matches by technically approved measure name", () => {
const match = makeDeal({ technicalApprovedMeasuresForInstall: "Battery" });
const noMatch = makeDeal({ dealId: "deal-2", technicalApprovedMeasuresForInstall: null });
const result = filterMeasureRows([match, noMatch], {}, "battery");
expect(result).toEqual([match]);
});
it("is case-insensitive", () => {
const deal = makeDeal({ proposedMeasures: "Loft insulation" });
expect(filterMeasureRows([deal], {}, "LOFT")).toEqual([deal]);
expect(filterMeasureRows([deal], {}, "loft insulation")).toEqual([deal]);
});
it("returns empty when nothing matches", () => {
const deal = makeDeal({ dealname: "1 Test St", proposedMeasures: "CWI" });
expect(filterMeasureRows([deal], {}, "ASHP")).toEqual([]);
});
});

View file

@ -0,0 +1,27 @@
import { parseMeasures } from "@/app/lib/parseMeasures";
import type { ClassifiedDeal } from "./types";
export function filterMeasureRows(
deals: ClassifiedDeal[],
instructedByDeal: Record<string, string[]>,
search: string,
): ClassifiedDeal[] {
const q = search.toLowerCase().trim();
if (!q) return deals;
return deals.filter((deal) => {
if (deal.dealname?.toLowerCase().includes(q)) return true;
if (deal.landlordPropertyId?.toLowerCase().includes(q)) return true;
const proposed = parseMeasures(deal.proposedMeasures);
if (proposed.some((m) => m.toLowerCase().includes(q))) return true;
const instructed = instructedByDeal[deal.dealId] ?? [];
if (instructed.some((m) => m.toLowerCase().includes(q))) return true;
const techApproved = parseMeasures(deal.technicalApprovedMeasuresForInstall);
if (techApproved.some((m) => m.toLowerCase().includes(q))) return true;
return false;
});
}

View file

@ -16,6 +16,7 @@ import {
portfolioUsers,
} from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import type {
@ -25,6 +26,7 @@ import type {
MeasureDocProgress,
PortfolioCapabilityType,
ApprovalsByDeal,
InstructedMeasuresByDeal,
RemovalStatusByDeal,
EffectiveRemovalState,
} from "./types";
@ -266,6 +268,27 @@ export default async function LiveReportingPage(props: {
}
}
// Fetch instructed measures for all deals in scope
const instructedMeasuresByDeal: InstructedMeasuresByDeal = {};
if (dealIds.length > 0) {
const instructedRows = await db
.select({
hubspotDealId: userDefinedDealMeasures.hubspotDealId,
measureName: userDefinedDealMeasures.measureName,
})
.from(userDefinedDealMeasures)
.where(
and(
inArray(userDefinedDealMeasures.hubspotDealId, dealIds),
eq(userDefinedDealMeasures.source, "instructed"),
),
);
for (const row of instructedRows) {
(instructedMeasuresByDeal[row.hubspotDealId] ??= []).push(row.measureName);
}
}
// Compute effective removal state per deal
const removalStatusByDeal: RemovalStatusByDeal = {};
const removalRows = await db
@ -451,6 +474,7 @@ export default async function LiveReportingPage(props: {
docStatusMap={docStatusMap}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
instructedMeasuresByDeal={instructedMeasuresByDeal}
removalStatusByDeal={removalStatusByDeal}
portfolioId={portfolioId}
userRole={userRole}

View file

@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] {
// -----------------------------------------------------------------------
export function computeLiveTrackerData(
rawDeals: HubspotDeal[]
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
): Omit<LiveTrackerProps, "docStatusMap" | "userCapability" | "approvalsByDeal" | "instructedMeasuresByDeal" | "removalStatusByDeal" | "portfolioId" | "userRole" | "userEmail"> {
// Classify all deals (add displayStage field)
const classified = classifyDeals(rawDeals);

View file

@ -207,6 +207,8 @@ export type RemovalRequest = {
// -----------------------------------------------------------------------
// Top-level props for LiveTracker (client root)
// -----------------------------------------------------------------------
export type InstructedMeasuresByDeal = Record<string, string[]>;
export type LiveTrackerProps = {
projects: ProjectData[];
totalDeals: number;
@ -214,6 +216,7 @@ export type LiveTrackerProps = {
docStatusMap: DocStatusMap;
userCapability: PortfolioCapabilityType;
approvalsByDeal: ApprovalsByDeal;
instructedMeasuresByDeal: InstructedMeasuresByDeal;
removalStatusByDeal: RemovalStatusByDeal;
portfolioId: string;
userRole: string;