From d467a61c221d129f5564ad49380279b2934faef6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 9 May 2026 08:39:20 +0000 Subject: [PATCH] Adding bulk approve and instruct --- .../[portfolioId]/bulk-approvals/route.ts | 85 +++ .../bulk-instructed-measures/route.ts | 109 +++ src/app/lib/bulkApprove.test.ts | 86 +++ src/app/lib/bulkApprove.ts | 156 ++++ src/app/lib/bulkInstructDeals.test.ts | 94 +++ src/app/lib/bulkInstructDeals.ts | 179 +++++ src/app/lib/hubspot/dealSync.test.ts | 2 +- src/app/lib/hubspot/dealSync.ts | 8 +- .../your-projects/live/LiveTracker.tsx | 4 + .../your-projects/live/MeasuresTable.tsx | 682 ++++++++++++++---- .../your-projects/live/measureFilters.test.ts | 117 +++ .../your-projects/live/measureFilters.ts | 27 + .../(portfolio)/your-projects/live/page.tsx | 24 + .../your-projects/live/transforms.ts | 2 +- .../(portfolio)/your-projects/live/types.ts | 3 + 15 files changed, 1434 insertions(+), 144 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts create mode 100644 src/app/lib/bulkApprove.test.ts create mode 100644 src/app/lib/bulkApprove.ts create mode 100644 src/app/lib/bulkInstructDeals.test.ts create mode 100644 src/app/lib/bulkInstructDeals.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.ts diff --git a/src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts new file mode 100644 index 00000000..c2f80cff --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts @@ -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; + 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 }); +} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts new file mode 100644 index 00000000..3b02b660 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-instructed-measures/route.ts @@ -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; + 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).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 }); +} diff --git a/src/app/lib/bulkApprove.test.ts b/src/app/lib/bulkApprove.test.ts new file mode 100644 index 00000000..81f034bd --- /dev/null +++ b/src/app/lib/bulkApprove.test.ts @@ -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 = {}): RunBulkApproveTx { + return async ({ changes }) => { + const dealIds = [...new Set(changes.map((c) => c.hubspotDealId))]; + const result: Record = {}; + 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"]); + }); +}); diff --git a/src/app/lib/bulkApprove.ts b/src/app/lib/bulkApprove.ts new file mode 100644 index 00000000..7d925c50 --- /dev/null +++ b/src/app/lib/bulkApprove.ts @@ -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; +} + +export type RunBulkApproveTx = (params: { + changes: BulkApproveChange[]; + userId: bigint; +}) => Promise; + +export type SyncApprovalsForDeals = (params: { + approvedMeasuresByDeal: Record; + 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 = {}; + 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 { + 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" }; +} diff --git a/src/app/lib/bulkInstructDeals.test.ts b/src/app/lib/bulkInstructDeals.test.ts new file mode 100644 index 00000000..db33c2f2 --- /dev/null +++ b/src/app/lib/bulkInstructDeals.test.ts @@ -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); + }); +}); diff --git a/src/app/lib/bulkInstructDeals.ts b/src/app/lib/bulkInstructDeals.ts new file mode 100644 index 00000000..27bf2ea4 --- /dev/null +++ b/src/app/lib/bulkInstructDeals.ts @@ -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; +} + +export type RunBulkInstructTx = (params: { + deals: Array<{ dealId: string; measureNames: MeasureName[] }>; + userId: bigint; + notes: string | null; +}) => Promise; + +export type SyncInstructedForDeals = (params: { + approvedMeasuresByDeal: Record; +}) => 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).includes(value); +} + +const defaultRunBulkInstructTx: RunBulkInstructTx = async ({ deals, userId, notes }) => { + return db.transaction(async (tx) => { + const instructedRowIds: bigint[] = []; + const approvedMeasuresByDeal: Record = {}; + + 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 { + 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" }; +} diff --git a/src/app/lib/hubspot/dealSync.test.ts b/src/app/lib/hubspot/dealSync.test.ts index 4d5aa472..47acf69f 100644 --- a/src/app/lib/hubspot/dealSync.test.ts +++ b/src/app/lib/hubspot/dealSync.test.ts @@ -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", }, }); }); diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index dbf03962..6f111baa 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -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 }; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index e73316fa..8296d3ba 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -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({ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index 89fc2103..8c8d4ce8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -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; 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(null); + + const groupedByDeal = useMemo(() => { + const map = new Map(); + 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 ( +
+
+
+

Confirm bulk approval

+ +
+ +

+ Approving {selected.length} measure{selected.length !== 1 ? "s" : ""} across{" "} + {groupedByDeal.size} propert{groupedByDeal.size !== 1 ? "ies" : "y"}. +

+ +
+ {[...groupedByDeal.entries()].map(([dealId, { dealname, measures }]) => ( +
+

{dealname ?? dealId}

+
+ {measures.map((m) => ( + + {m} + + ))} +
+
+ ))} +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} + +// ── 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>(new Set()); + const [confirmText, setConfirmText] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+

Bulk instruct measures

+ +
+ +

+ Instructing on {selectedDeals.length} propert{selectedDeals.length !== 1 ? "ies" : "y"}. + Select measures to add to all selected deals. +

+ +
+
+ {MEASURE_NAMES.map((name) => ( + + ))} +
+
+ + {selectedMeasures.size > 0 && ( +
+

+ Type confirm to proceed +

+ setConfirmText(e.target.value)} + placeholder="confirm" + className="h-8 text-sm" + /> +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} + +// ── Main component ─────────────────────────────────────────────────────────── + export default function MeasuresTable({ data, approvalsByDeal, + instructedMeasuresByDeal, portfolioId, + isApprover, }: Props) { const router = useRouter(); - const [search, setSearch] = useState(""); - const [expandedRows, setExpandedRows] = useState>(new Set()); + const [isPending, startTransition] = useTransition(); + + const [search, setSearch] = useState(""); + const [mode, setMode] = useState("chip-click"); + + // Chip-click mode: Set<"dealId::measureName"> + const [selectedChips, setSelectedChips] = useState>(new Set()); + + // Instruct mode: Set + const [selectedRows, setSelectedRows] = useState>(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 (
@@ -101,9 +404,11 @@ export default function MeasuresTable({ ); } + const colSpan = mode === "instruct" ? 7 : 6; + return (
- {/* Toolbar */} + {/* ── Toolbar ──────────────────────────────────────────────────── */}
@@ -114,22 +419,100 @@ export default function MeasuresTable({ className="pl-9 h-9 text-sm" />
+
{filtered.length} of {dealsWithMeasures.length} properties - - · Click a row to open property - + + {isApprover && ( + <> + + + + )}
- {/* Table */} -
+ {/* ── Action bar ───────────────────────────────────────────────── */} + {isApprover && mode === "chip-click" && selectedChips.size > 0 && ( +
+ + {selectedChips.size} measure{selectedChips.size !== 1 ? "s" : ""} selected + + + +
+ )} + + {isApprover && mode === "instruct" && selectedRows.size > 0 && ( +
+ + {selectedRows.size} propert{selectedRows.size !== 1 ? "ies" : "y"} selected + + + +
+ )} + + {/* ── Table ────────────────────────────────────────────────────── */} +
+ {isPending && ( +
+ +
+ )} - + {mode === "instruct" && ( + + + + )} Address @@ -137,10 +520,13 @@ export default function MeasuresTable({ Stage - Proposed Measures + Proposed - Technical Approved + Instructed + + + Tech Approved Status @@ -150,135 +536,161 @@ export default function MeasuresTable({ {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) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - router.push(dealPageUrl); - } - }; return ( - - - {/* Expand toggle */} - - + { + 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" && ( + + toggleRow(deal.dealId)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4" + aria-label={`Select ${deal.dealname ?? deal.dealId}`} + /> + )} - {/* Address */} - -
- {deal.dealname ?? "—"} + {/* Address */} + +
+ {deal.dealname ?? "—"} +
+ {deal.landlordPropertyId && ( +
+ {deal.landlordPropertyId}
- {deal.landlordPropertyId && ( -
- {deal.landlordPropertyId} -
- )} -
+ )} + - {/* Stage */} - - - - {deal.displayStage} - - + {/* Stage */} + + + + {deal.displayStage} + + - {/* Proposed measures — read-only; click the row to approve in the drawer */} - -
- {proposed.map((measure) => { - const isApproved = approvedSet.has(measure); - return ( - - {measure} - - ); - })} -
-
+ {/* Proposed measures */} + +
+ {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 */} - -
- {parseMeasures(deal.technicalApprovedMeasuresForInstall).map((measure) => ( + return ( { 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} - ))} -
-
+ ); + })} +
+
- {/* Status */} - - - + {/* Instructed measures */} + +
+ {instructed.map((measure) => ( + + {measure} + + ))} +
+
- + {/* Tech approved */} + +
+ {techApproved.map((measure) => ( + + {measure} + + ))} +
+
- {/* Expandable activity log row */} - {isExpanded && ( - - -
-

- Activity log -

- -
-
-
- )} - + {/* Approval status */} + + + + ); })}
+ {/* ── Modals ───────────────────────────────────────────────────── */} + {showApproveModal && ( + setShowApproveModal(false)} + onSuccess={handleActionSuccess} + /> + )} + + {showInstructModal && ( + setShowInstructModal(false)} + onSuccess={handleActionSuccess} + /> + )}
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts new file mode 100644 index 00000000..31b77342 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { filterMeasureRows } from "./measureFilters"; +import type { ClassifiedDeal } from "./types"; + +function makeDeal(overrides: Partial = {}): 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([]); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.ts new file mode 100644 index 00000000..2b928ee4 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/measureFilters.ts @@ -0,0 +1,27 @@ +import { parseMeasures } from "@/app/lib/parseMeasures"; +import type { ClassifiedDeal } from "./types"; + +export function filterMeasureRows( + deals: ClassifiedDeal[], + instructedByDeal: Record, + 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; + }); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 56b63efa..06cdd8a0 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -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} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index 2b89cb54..9e45e67e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -375,7 +375,7 @@ export function computeOutcomeSlices(deals: ClassifiedDeal[]): OutcomeSlice[] { // ----------------------------------------------------------------------- export function computeLiveTrackerData( rawDeals: HubspotDeal[] -): Omit { +): Omit { // Classify all deals (add displayStage field) const classified = classifyDeals(rawDeals); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index c03caa97..da7d78e3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -207,6 +207,8 @@ export type RemovalRequest = { // ----------------------------------------------------------------------- // Top-level props for LiveTracker (client root) // ----------------------------------------------------------------------- +export type InstructedMeasuresByDeal = Record; + 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;