mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Adding bulk approve and instruct
This commit is contained in:
parent
1620a9a65f
commit
d467a61c22
15 changed files with 1434 additions and 144 deletions
85
src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts
Normal file
85
src/app/api/portfolio/[portfolioId]/bulk-approvals/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
86
src/app/lib/bulkApprove.test.ts
Normal file
86
src/app/lib/bulkApprove.test.ts
Normal 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
156
src/app/lib/bulkApprove.ts
Normal 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" };
|
||||
}
|
||||
94
src/app/lib/bulkInstructDeals.test.ts
Normal file
94
src/app/lib/bulkInstructDeals.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
179
src/app/lib/bulkInstructDeals.ts
Normal file
179
src/app/lib/bulkInstructDeals.ts
Normal 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" };
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue