diff --git a/cypress/e2e/live-tracking/domna-survey.cy.js b/cypress/e2e/live-tracking/domna-survey.cy.js index cf5ec53..554fa8c 100644 --- a/cypress/e2e/live-tracking/domna-survey.cy.js +++ b/cypress/e2e/live-tracking/domna-survey.cy.js @@ -45,6 +45,8 @@ describe("Domna survey editor — approver flow", function () { } cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + // Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click). + cy.get("[data-testid=drawer-tab-survey-admin]").click(); cy.get("[data-testid=drawer-section-domna]").should("exist"); } diff --git a/cypress/e2e/live-tracking/halted-state.cy.js b/cypress/e2e/live-tracking/halted-state.cy.js index 8249ebe..89066d2 100644 --- a/cypress/e2e/live-tracking/halted-state.cy.js +++ b/cypress/e2e/live-tracking/halted-state.cy.js @@ -43,6 +43,8 @@ describe("Halted state editor — approver flow", function () { } cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + // Navigate to Survey & Admin tab (drawer opens on Works tab from Measures row click). + cy.get("[data-testid=drawer-tab-survey-admin]").click(); cy.get("[data-testid=drawer-section-halted]").should("exist"); } diff --git a/cypress/e2e/live-tracking/measure-approval-drawer.cy.js b/cypress/e2e/live-tracking/measure-approval-drawer.cy.js new file mode 100644 index 0000000..0e25867 --- /dev/null +++ b/cypress/e2e/live-tracking/measure-approval-drawer.cy.js @@ -0,0 +1,172 @@ +/** + * Live Tracking — Measure approval drawer (Works tab) + * + * Verifies the approver flow for approving/unapproving proposed measures + * directly from the Works tab of the PropertyDetailDrawer: + * 1. The approver opens the Works tab and sees measure chips. + * 2. The approver toggles a measure, clicks "Review & Save". + * 3. The ApprovalConfirmDialog appears — the user types "approve". + * 4. POST fires to /api/portfolio/*/approvals with the correct payload. + * + * Mirrors the same structure as `pibi-measures.cy.js`. + * Uses `cy.intercept` to observe the API call without a real CRM round-trip. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const TARGET_DEAL_NAME = Cypress.env("LIVE_APPROVAL_DEAL_NAME"); + +describe("Measure approval drawer — Works tab approver flow", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + function openDrawerAtWorksTab() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + + // Open a property row from the Measures table to get the detail drawer. + cy.contains("button, [role=tab]", "Measures").click(); + + if (TARGET_DEAL_NAME) { + cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click(); + } else { + cy.get("[data-testid=measures-row]").first().click(); + } + + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + + // The drawer opens on the Works tab from a Measures row click — verify. + cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible"); + cy.get("[data-testid=drawer-section-measures]").should("exist"); + } + + it("fetches approved measures and shows chips in the Works tab for approvers", () => { + // Stub the GET so we control the initial state. + cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, { + body: { + pibiMeasures: [], + approvedMeasures: ["ASHP", "Solar PV"], + instructedMeasures: [], + }, + }).as("getMeasures"); + + openDrawerAtWorksTab(); + + cy.wait("@getMeasures"); + + // Chip container should be visible. + cy.get("[data-testid=measure-approval-chips]").should("be.visible"); + + // ASHP and Solar PV should be shown as approved (checked). + cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked"); + cy.get("[data-testid='measure-approval-checkbox-Solar PV']").should( + "be.checked", + ); + }); + + it("lets an approver toggle a measure and POST the approval change", () => { + // Stub GET to return ASHP as already approved. + cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, { + body: { + pibiMeasures: [], + approvedMeasures: ["ASHP"], + instructedMeasures: [], + }, + }).as("getMeasures"); + + // Intercept the POST so we can assert the payload. + cy.intercept("POST", `/api/portfolio/*/approvals`, { + body: { success: true }, + }).as("postApprovals"); + + openDrawerAtWorksTab(); + + cy.wait("@getMeasures"); + + cy.get("[data-testid=measure-approval-chips]").should("be.visible"); + + // ASHP should start approved. + cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked"); + + // Toggle ASHP off (unapprove it). + cy.get("[data-testid=measure-approval-chip-ASHP]").click(); + cy.get("[data-testid=measure-approval-checkbox-ASHP]").should( + "not.be.checked", + ); + + // "Review & Save" button should now be active. + cy.get("[data-testid=measure-approval-save]").should("not.be.disabled"); + cy.get("[data-testid=measure-approval-save]").click(); + + // ApprovalConfirmDialog should be visible — type the confirm word. + cy.contains("Confirm approval changes").should("be.visible"); + cy.get("input[placeholder*=\"approve\"]").type("approve"); + cy.contains("button", "Confirm").click(); + + // Wait for the POST and assert the payload. + cy.wait("@postApprovals").then((intercepted) => { + expect(intercepted.request.body).to.have.property("changes"); + const changes = intercepted.request.body.changes; + // Should contain one change: ASHP unapproved. + const ashpChange = changes.find((c) => c.measureName === "ASHP"); + expect(ashpChange).to.exist; + expect(ashpChange.approved).to.equal(false); + }); + + // No error banner visible. + cy.get("[data-testid=measure-approval-error]").should("not.exist"); + }); + + it("lets an approver approve a new measure and POST with correct payload", () => { + // Stub GET — no measures approved yet. + cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, { + body: { + pibiMeasures: [], + approvedMeasures: [], + instructedMeasures: [], + }, + }).as("getMeasures"); + + // Intercept POST. + cy.intercept("POST", `/api/portfolio/*/approvals`, { + body: { success: true }, + }).as("postApprovals"); + + openDrawerAtWorksTab(); + + cy.wait("@getMeasures"); + + cy.get("[data-testid=measure-approval-chips]").should("be.visible"); + + // Click the first chip to approve it. + cy.get("[data-testid=measure-approval-chips]") + .find("label") + .first() + .click(); + + // Save button should be active. + cy.get("[data-testid=measure-approval-save]").should("not.be.disabled"); + cy.get("[data-testid=measure-approval-save]").click(); + + // Confirm dialog — type the word. + cy.contains("Confirm approval changes").should("be.visible"); + cy.get("input[placeholder*=\"approve\"]").type("approve"); + cy.contains("button", "Confirm").click(); + + cy.wait("@postApprovals").then((intercepted) => { + expect(intercepted.request.body).to.have.property("changes"); + const changes = intercepted.request.body.changes; + // Should have at least one approval. + expect(changes.length).to.be.greaterThan(0); + expect(changes[0]).to.have.property("approved", true); + expect(changes[0]).to.have.property("hubspotDealId"); + }); + + // No error banner. + cy.get("[data-testid=measure-approval-error]").should("not.exist"); + }); +}); diff --git a/cypress/e2e/live-tracking/pibi-dates.cy.js b/cypress/e2e/live-tracking/pibi-dates.cy.js index 84098ab..d5cc717 100644 --- a/cypress/e2e/live-tracking/pibi-dates.cy.js +++ b/cypress/e2e/live-tracking/pibi-dates.cy.js @@ -41,6 +41,8 @@ describe("PIBI dates editor — write user flow", function () { } cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + // Navigate to the PIBI tab (drawer opens on Works tab from Measures row click). + cy.get("[data-testid=drawer-tab-pibi]").click(); cy.get("[data-testid=drawer-section-pibi]").should("exist"); } diff --git a/cypress/e2e/live-tracking/pibi-measures.cy.js b/cypress/e2e/live-tracking/pibi-measures.cy.js index 59afbe2..95ef81e 100644 --- a/cypress/e2e/live-tracking/pibi-measures.cy.js +++ b/cypress/e2e/live-tracking/pibi-measures.cy.js @@ -40,8 +40,9 @@ describe("PIBI measure selection — approver flow", function () { cy.get("[data-testid=property-detail-drawer]").should("be.visible"); - // Scroll to the PIBI section. - cy.get("[data-testid=drawer-section-pibi]").should("exist").scrollIntoView(); + // Navigate to the PIBI tab (drawer opens on Works tab from Measures row click). + cy.get("[data-testid=drawer-tab-pibi]").click(); + cy.get("[data-testid=drawer-section-pibi]").should("exist"); } it("fetches the PIBI state and shows the multi-select for approvers", () => { diff --git a/cypress/e2e/live-tracking/survey-request.cy.js b/cypress/e2e/live-tracking/survey-request.cy.js new file mode 100644 index 0000000..5c15a74 --- /dev/null +++ b/cypress/e2e/live-tracking/survey-request.cy.js @@ -0,0 +1,83 @@ +/** + * Live Tracking — Survey request flow + * + * Verifies the client-facing "Request Survey" flow in the Survey & Admin tab: + * 1. User opens a property and navigates to Survey & Admin tab. + * 2. User fills in a free-text reason and submits the survey request. + * 3. The POST hits /api/portfolio/[id]/survey-requests. + * 4. The drawer reflects the pending request (badge shown). + * 5. On reload the pending request is still visible. + * + * Uses cy.intercept so the HubSpot side-effect is observable without a live + * CRM round-trip. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const TARGET_DEAL_NAME = Cypress.env("LIVE_SURVEY_REQUEST_DEAL_NAME"); + +const SURVEY_NOTES = "Please arrange a full retrofit assessment — tenant has moved in."; + +describe("Survey request — write user flow", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + function openDrawerAtSurveyAdmin() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + + cy.contains("button, [role=tab]", "Measures").click(); + + if (TARGET_DEAL_NAME) { + cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click(); + } else { + cy.get("[data-testid=measures-row]").first().click(); + } + + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + cy.get("[data-testid=drawer-tab-survey-admin]").click(); + cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible"); + } + + it("shows the survey request form in the Survey & Admin tab", () => { + openDrawerAtSurveyAdmin(); + + cy.get("[data-testid=survey-request-form]").should("be.visible"); + cy.get("[data-testid=survey-request-notes]").should("be.visible"); + cy.get("[data-testid=survey-request-submit]").should("be.visible"); + }); + + it("submits a survey request and shows a pending badge", () => { + cy.intercept( + "POST", + `/api/portfolio/*/survey-requests`, + ).as("createSurveyRequest"); + + openDrawerAtSurveyAdmin(); + + cy.get("[data-testid=survey-request-notes]").type(SURVEY_NOTES); + cy.get("[data-testid=survey-request-submit]") + .should("not.be.disabled") + .click(); + + cy.wait("@createSurveyRequest").then((intercepted) => { + expect(intercepted.request.body).to.have.property("notes"); + expect(intercepted.response.statusCode).to.be.oneOf([200, 201]); + expect(intercepted.response.body).to.have.property("ok", true); + }); + + // Pending badge appears after submission. + cy.get("[data-testid=survey-request-pending-badge]").should("be.visible"); + }); + + it("persists the pending request across a page reload", () => { + openDrawerAtSurveyAdmin(); + + // If a pending request exists it should be visible without submitting again. + cy.get("[data-testid=survey-request-pending-badge]").should("be.visible"); + }); +}); diff --git a/cypress/e2e/live-tracking/tabbed-drawer.cy.js b/cypress/e2e/live-tracking/tabbed-drawer.cy.js new file mode 100644 index 0000000..e13eacb --- /dev/null +++ b/cypress/e2e/live-tracking/tabbed-drawer.cy.js @@ -0,0 +1,91 @@ +/** + * Live Tracking — Tabbed property detail drawer + * + * Verifies the four-tab drawer structure introduced in the UI redesign: + * Overview | Works | PIBI | Survey & Admin + * + * The spec opens the drawer from the Properties table (first row) and asserts + * tab presence, default active state, and navigation between tabs. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); + +describe("Property detail drawer — tabbed layout", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + function openDrawer() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + cy.contains("button, [role=tab]", "Measures").click(); + cy.get("[data-testid=measures-row]").first().click(); + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + } + + it("opens with Overview tab active by default", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-overview]") + .should("be.visible") + .and("have.attr", "aria-selected", "true"); + + cy.get("[data-testid=drawer-tab-panel-overview]").should("be.visible"); + }); + + it("shows all four tabs", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-overview]").should("be.visible"); + cy.get("[data-testid=drawer-tab-works]").should("be.visible"); + cy.get("[data-testid=drawer-tab-pibi]").should("be.visible"); + cy.get("[data-testid=drawer-tab-survey-admin]").should("be.visible"); + }); + + it("navigates to Works tab and shows measures content", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-works]").click(); + + cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible"); + // Measures section lives in Works tab + cy.get("[data-testid=drawer-section-measures]").should("exist"); + }); + + it("navigates to PIBI tab and shows PIBI content", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-pibi]").click(); + + cy.get("[data-testid=drawer-tab-panel-pibi]").should("be.visible"); + cy.get("[data-testid=drawer-section-pibi]").should("exist"); + }); + + it("navigates to Survey & Admin tab and shows admin content", () => { + openDrawer(); + + cy.get("[data-testid=drawer-tab-survey-admin]").click(); + + cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible"); + cy.get("[data-testid=drawer-section-domna]").should("exist"); + }); + + it("focusSection=pibi opens PIBI tab directly", () => { + // This is exercised by the pibi-dates.cy.js helper that clicks the Measures + // tab row — after the redesign those rows pass focusSection="pibi" and the + // drawer should land on the PIBI tab, not Overview. + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + + cy.contains("button, [role=tab]", "Measures").click(); + cy.get("[data-testid=measures-row]").first().click(); + + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + + cy.get("[data-testid=drawer-tab-works]") + .should("have.attr", "aria-selected", "true"); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts new file mode 100644 index 0000000..8190fa9 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts @@ -0,0 +1,167 @@ +import { db } from "@/app/db/db"; +import { NextRequest, NextResponse } from "next/server"; +import { surveyRequests } from "@/app/db/schema/survey_requests"; +import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { user } from "@/app/db/schema/users"; +import { and, eq, desc } from "drizzle-orm"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync"; + +const WRITE_ROLES = ["creator", "admin", "write"] as const; + +async function getRequestingUser(email: string) { + const rows = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + return rows[0] ?? null; +} + +async function getUserRole(portfolioId: bigint, userId: bigint) { + const rows = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, portfolioId), + eq(portfolioUsers.userId, userId), + ), + ) + .limit(1); + return rows[0]?.role ?? null; +} + +// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx +// Returns all survey requests for a deal, most recent first. +export async function GET( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + const dealId = req.nextUrl.searchParams.get("dealId"); + + if (!dealId) { + return NextResponse.json({ error: "dealId required" }, { status: 400 }); + } + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + try { + const rows = await db + .select({ + id: surveyRequests.id, + hubspotDealId: surveyRequests.hubspotDealId, + notes: surveyRequests.notes, + status: surveyRequests.status, + requestedAt: surveyRequests.requestedAt, + fulfilledAt: surveyRequests.fulfilledAt, + requestedByEmail: user.email, + }) + .from(surveyRequests) + .innerJoin(user, eq(user.id, surveyRequests.requestedBy)) + .where( + and( + eq(surveyRequests.hubspotDealId, dealId), + eq(surveyRequests.portfolioId, BigInt(portfolioId)), + ), + ) + .orderBy(desc(surveyRequests.requestedAt)) + .limit(20); + + const requests = rows.map((r) => ({ + id: String(r.id), + hubspotDealId: r.hubspotDealId, + notes: r.notes, + status: r.status, + requestedByEmail: r.requestedByEmail, + requestedAt: r.requestedAt?.toISOString() ?? null, + fulfilledAt: r.fulfilledAt?.toISOString() ?? null, + })); + + return NextResponse.json({ requests }); + } catch (err) { + console.error("[survey-requests GET]", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +const postSchema = z.object({ + hubspotDealId: z.string().min(1), + notes: z.string().min(1, "Notes are required"), +}); + +// POST /api/portfolio/[portfolioId]/survey-requests +// Submit a new survey request — requires write+ role. +export async function POST( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + const session = await getServerSession(AuthOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const requestingUser = await getRequestingUser(session.user.email); + if (!requestingUser) { + return NextResponse.json({ error: "User not found" }, { status: 401 }); + } + + const role = await getUserRole(BigInt(portfolioId), requestingUser.id); + if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) { + return NextResponse.json( + { error: "Write access required to submit a survey request" }, + { status: 403 }, + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = postSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const { hubspotDealId, notes } = parsed.data; + + try { + const [inserted] = await db + .insert(surveyRequests) + .values({ + hubspotDealId, + portfolioId: BigInt(portfolioId), + notes, + status: "pending", + requestedBy: requestingUser.id, + }) + .returning({ id: surveyRequests.id }); + + const hubspotResult = await syncSurveyRequestToHubSpot({ + hubspotDealId, + notes, + requestedByEmail: requestingUser.email, + }); + + return NextResponse.json({ + ok: true, + id: String(inserted.id), + hubspotSync: hubspotResult.ok ? "ok" : "failed", + hubspotError: hubspotResult.error, + }); + } catch (err) { + console.error("[survey-requests POST]", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/db/migrations/0193_domna_survey_type.sql b/src/app/db/migrations/0193_domna_survey_type.sql index d2604eb..e410bf2 100644 --- a/src/app/db/migrations/0193_domna_survey_type.sql +++ b/src/app/db/migrations/0193_domna_survey_type.sql @@ -1,2 +1 @@ -ALTER TABLE "hubspot_deal_data" DROP COLUMN IF EXISTS "domna_survey_required";--> statement-breakpoint ALTER TABLE "hubspot_deal_data" ADD COLUMN "domna_survey_type" text; diff --git a/src/app/db/migrations/0195_survey_requests.sql b/src/app/db/migrations/0195_survey_requests.sql new file mode 100644 index 0000000..c7f197b --- /dev/null +++ b/src/app/db/migrations/0195_survey_requests.sql @@ -0,0 +1,15 @@ +CREATE TABLE "survey_requests" ( + "id" bigserial PRIMARY KEY NOT NULL, + "hubspot_deal_id" text NOT NULL, + "portfolio_id" bigint NOT NULL, + "notes" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "requested_by" bigint NOT NULL, + "requested_at" timestamp with time zone DEFAULT now() NOT NULL, + "fulfilled_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_survey_requests_deal_id" ON "survey_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint +CREATE INDEX "idx_survey_requests_portfolio_id" ON "survey_requests" USING btree ("portfolio_id"); diff --git a/src/app/db/schema/crm/hubspot_deal_table.ts b/src/app/db/schema/crm/hubspot_deal_table.ts index 7bdf64f..4498dfd 100644 --- a/src/app/db/schema/crm/hubspot_deal_table.ts +++ b/src/app/db/schema/crm/hubspot_deal_table.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, boolean } from "drizzle-orm/pg-core"; import { InferModel } from "drizzle-orm"; export const hubspotDealData = pgTable("hubspot_deal_data", { @@ -66,6 +66,7 @@ export const hubspotDealData = pgTable("hubspot_deal_data", { propertyHaltedReason: text("property_halted_reason"), technicalApprovedMeasuresForInstall: text("technical_approved_measures_for_install"), sentToInstallerForPricing: timestamp("sent_to_installer_for_pricing", { precision: 6, withTimezone: true }), + domnasurveyRequired: boolean("domna_survey_required"), domnaSurveyType: text("domna_survey_type"), domnaSurveyDate: timestamp("domna_survey_date", { precision: 6, withTimezone: true }), diff --git a/src/app/db/schema/survey_requests.ts b/src/app/db/schema/survey_requests.ts new file mode 100644 index 0000000..815d18f --- /dev/null +++ b/src/app/db/schema/survey_requests.ts @@ -0,0 +1,41 @@ +import { + bigserial, + text, + timestamp, + pgTable, + bigint, + index, +} from "drizzle-orm/pg-core"; +import { user } from "./users"; +import { portfolio } from "./portfolio"; + +// One row per survey request from a portfolio user. A deal can accumulate +// multiple requests over time; query by hubspotDealId ordered by requestedAt +// desc to get the latest. +export const surveyRequests = pgTable( + "survey_requests", + { + id: bigserial("id", { mode: "bigint" }).primaryKey(), + hubspotDealId: text("hubspot_deal_id").notNull(), + portfolioId: bigint("portfolio_id", { mode: "bigint" }) + .notNull() + .references(() => portfolio.id), + // Free-text notes from the requester describing what survey is needed. + notes: text("notes").notNull(), + // 'pending' | 'fulfilled' + status: text("status").notNull().default("pending"), + requestedBy: bigint("requested_by", { mode: "bigint" }) + .notNull() + .references(() => user.id), + requestedAt: timestamp("requested_at", { withTimezone: true }) + .defaultNow() + .notNull(), + fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }), + }, + (table) => [ + index("idx_survey_requests_deal_id").on(table.hubspotDealId), + index("idx_survey_requests_portfolio_id").on(table.portfolioId), + ], +); + +export type SurveyRequest = typeof surveyRequests.$inferSelect; diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 05662ad..8b08c89 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -185,6 +185,27 @@ export async function syncMeasuresFieldToHubSpot(params: { return { ok: false, error: "HubSpot sync failed" }; } +export async function syncSurveyRequestToHubSpot(params: { + hubspotDealId: string; + notes: string; + requestedByEmail: string; +}): Promise<{ ok: boolean; error?: string }> { + try { + const client = getHubSpotClient(); + const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`; + await client.crm.deals.basicApi.update(params.hubspotDealId, { + properties: { survey_request_log: log }, + }); + return { ok: true }; + } catch (err) { + console.error("[HubSpot] syncSurveyRequestToHubSpot failed", { + dealId: params.hubspotDealId, + error: err, + }); + return { ok: false, error: "HubSpot sync failed" }; + } +} + export async function syncMeasureApprovalsToHubSpot(params: { hubspotDealId: string; approvedMeasures: Array<{ measureName: string; approvedByEmail: string }>; 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 2a6929b..a11828b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -320,7 +320,6 @@ export default function LiveTracker({ )} openDetailDrawer(deal, "measures")} 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 4ed29e9..eef8469 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useMemo, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Table, TableBody, @@ -11,13 +11,10 @@ import { TableRow, } from "@/app/shadcn_components/ui/table"; import { Input } from "@/app/shadcn_components/ui/input"; -import { Button } from "@/app/shadcn_components/ui/button"; import { Badge } from "@/app/shadcn_components/ui/badge"; -import { Checkbox } from "@/app/shadcn_components/ui/checkbox"; -import { Search, Save, ChevronDown, ChevronRight } from "lucide-react"; +import { Search, ChevronDown, ChevronRight } from "lucide-react"; import { STAGE_COLORS } from "./types"; -import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types"; -import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog"; +import type { ClassifiedDeal, ApprovalsByDeal } from "./types"; import { parseMeasures } from "@/app/lib/parseMeasures"; type AuditEvent = { @@ -32,13 +29,11 @@ type AuditEvent = { type Props = { data: ClassifiedDeal[]; - userCapability: PortfolioCapabilityType; approvalsByDeal: ApprovalsByDeal; portfolioId: string; /** - * Called when a measures row is clicked. The host (LiveTracker) opens the - * PropertyDetailDrawer focused on the Measures section. Optional so the - * table is still usable in isolation. + * Called when a row is clicked. Opens PropertyDetailDrawer focused on the + * Works tab so the user can approve measures there. */ onOpenDetail?: (deal: ClassifiedDeal) => void; }; @@ -144,33 +139,13 @@ function ActivityLog({ ); } -async function postApprovalChanges( - portfolioId: string, - changes: { hubspotDealId: string; measureName: string; approved: boolean }[], -) { - const res = await fetch(`/api/portfolio/${portfolioId}/approvals`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ changes }), - }); - if (!res.ok) throw new Error("Failed to save approvals"); -} - export default function MeasuresTable({ data, - userCapability, approvalsByDeal, portfolioId, onOpenDetail, }: Props) { const [search, setSearch] = useState(""); - // pendingChanges: dealId -> desired Set (the full intended approved set) - const [pendingChanges, setPendingChanges] = useState< - Record> - >({}); - const [savedApprovals, setSavedApprovals] = - useState(approvalsByDeal); - const [showConfirm, setShowConfirm] = useState(false); const [expandedRows, setExpandedRows] = useState>(new Set()); // Filter to only properties with proposed measures @@ -190,80 +165,6 @@ export default function MeasuresTable({ ); }, [dealsWithMeasures, search]); - const hasPendingChanges = Object.keys(pendingChanges).length > 0; - - // Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved - const pendingDiffs = useMemo>(() => { - const diffs: Record = {}; - for (const [dealId, pending] of Object.entries(pendingChanges)) { - const saved = new Set(savedApprovals[dealId] ?? []); - const added = [...pending].filter((m) => !saved.has(m)); - const removed = [...saved].filter((m) => !pending.has(m)); - if (added.length > 0 || removed.length > 0) { - diffs[dealId] = { added, removed }; - } - } - return diffs; - }, [pendingChanges, savedApprovals]); - - const dealNames = useMemo>(() => { - const map: Record = {}; - for (const d of dealsWithMeasures) { - map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId; - } - return map; - }, [dealsWithMeasures]); - - const saveMutation = useMutation({ - mutationFn: () => { - // Build flat list of explicit changes from diffs - const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = []; - for (const [dealId, diff] of Object.entries(pendingDiffs)) { - for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true }); - for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false }); - } - return postApprovalChanges(portfolioId, changes); - }, - onSuccess: () => { - setSavedApprovals((prev) => { - const next = { ...prev }; - for (const [dealId, pending] of Object.entries(pendingChanges)) { - next[dealId] = Array.from(pending); - } - return next; - }); - setPendingChanges({}); - setShowConfirm(false); - }, - }); - - function toggleMeasure(dealId: string, measure: string) { - setPendingChanges((prev) => { - const base = - prev[dealId] !== undefined - ? new Set(prev[dealId]) - : new Set(savedApprovals[dealId] ?? []); - - if (base.has(measure)) { - base.delete(measure); - } else { - base.add(measure); - } - - // If pending equals saved, remove from tracking - const saved = new Set(savedApprovals[dealId] ?? []); - const equal = base.size === saved.size && [...base].every((m) => saved.has(m)); - - const next = { ...prev }; - if (equal) { - delete next[dealId]; - } else { - next[dealId] = base; - } - return next; - }); - } - function toggleRowExpand(dealId: string) { setExpandedRows((prev) => { const next = new Set(prev); @@ -300,16 +201,9 @@ export default function MeasuresTable({ {filtered.length} of {dealsWithMeasures.length} properties - {userCapability.includes("approver") && hasPendingChanges && ( - - )} + + · Click a row to approve measures + @@ -336,13 +230,9 @@ export default function MeasuresTable({ {filtered.map((deal) => { const proposed = parseMeasures(deal.proposedMeasures); - const approvedForDeal = - pendingChanges[deal.dealId] !== undefined - ? Array.from(pendingChanges[deal.dealId]) - : (savedApprovals[deal.dealId] ?? []); + const approvedForDeal = approvalsByDeal[deal.dealId] ?? []; const approvedSet = new Set(approvedForDeal); const stageColor = STAGE_COLORS[deal.displayStage]; - const hasPending = pendingChanges[deal.dealId] !== undefined; const isExpanded = expandedRows.has(deal.dealId); const handleRowClick = () => { @@ -364,7 +254,7 @@ export default function MeasuresTable({ onKeyDown={onOpenDetail ? handleRowKeyDown : undefined} tabIndex={onOpenDetail ? 0 : undefined} role={onOpenDetail ? "button" : undefined} - className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${hasPending ? "bg-amber-50/30" : ""} ${onOpenDetail ? "cursor-pointer" : ""}`} + className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onOpenDetail ? "cursor-pointer" : ""}`} > {/* Expand toggle */} @@ -403,31 +293,11 @@ export default function MeasuresTable({ - {/* Proposed measures */} + {/* Proposed measures — read-only; click the row to approve in the drawer */}
{proposed.map((measure) => { const isApproved = approvedSet.has(measure); - if (userCapability.includes("approver")) { - return ( - - ); - } return (
- {/* Confirmation dialog */} - saveMutation.mutate()} - onCancel={() => setShowConfirm(false)} - isPending={saveMutation.isPending} - /> - ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index daeb4c6..fcacd3d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react"; import { @@ -26,11 +26,13 @@ import { import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types"; import { parseMeasures } from "@/app/lib/parseMeasures"; +import { ApprovalConfirmDialog } from "./ApprovalConfirmDialog"; +import type { PendingDiff } from "./ApprovalConfirmDialog"; import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings"; -// Sections that the drawer can scroll-focus on initial open. Keep the keys in -// stable, stage-ordered order so the layout remains predictable. +// Sections the caller can request focus on. Used by entry-points like the +// Measures table row click that should land the user on a specific tab. export type DrawerSection = | "survey" | "measures" @@ -39,6 +41,26 @@ export type DrawerSection = | "halted" | "technical"; +// The four tabs inside the drawer. +type DrawerTab = "overview" | "works" | "pibi" | "survey-admin"; + +// Maps each focusable section to the tab that contains it. +const SECTION_TO_TAB: Record = { + survey: "overview", + measures: "works", + technical: "works", + pibi: "pibi", + domna: "survey-admin", + halted: "survey-admin", +}; + +const TAB_LABELS: Record = { + overview: "Overview", + works: "Works", + pibi: "PIBI", + "survey-admin": "Survey & Admin", +}; + // ----------------------------------------------------------------------- // Removal request section // ----------------------------------------------------------------------- @@ -335,6 +357,152 @@ function RemovalRequestSection({ ); } +// ----------------------------------------------------------------------- +// Survey request section — write-role users can request a survey from Domna +// ----------------------------------------------------------------------- +type SurveyRequestRecord = { + id: string; + hubspotDealId: string; + notes: string; + status: string; + requestedByEmail: string; + requestedAt: string | null; + fulfilledAt: string | null; +}; + +function SurveyRequestSection({ + dealId, + portfolioId, + userRole, +}: { + dealId: string; + portfolioId: string; + userRole: string; +}) { + const queryClient = useQueryClient(); + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const canRequest = WRITE_ROLES.includes(userRole as (typeof WRITE_ROLES)[number]); + + const { data, isLoading } = useQuery<{ requests: SurveyRequestRecord[] }>({ + queryKey: ["surveyRequests", portfolioId, dealId], + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/survey-requests?dealId=${encodeURIComponent(dealId)}`, + ); + if (!res.ok) throw new Error("Failed to fetch survey requests"); + return res.json(); + }, + staleTime: 30_000, + }); + + const pending = data?.requests?.find((r) => r.status === "pending") ?? null; + + async function handleSubmit() { + if (!notes.trim() || submitting) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/survey-requests`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hubspotDealId: dealId, notes: notes.trim() }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setError(typeof json.error === "string" ? json.error : "Failed to submit request"); + return; + } + const json = (await res.json()) as { ok: boolean; hubspotSync?: string; hubspotError?: string }; + if (json.hubspotSync === "failed") { + setError(json.hubspotError ?? "Saved locally — HubSpot sync failed"); + } + setNotes(""); + queryClient.invalidateQueries({ queryKey: ["surveyRequests", portfolioId, dealId] }); + } finally { + setSubmitting(false); + } + } + + if (isLoading) { + return

Loading…

; + } + + return ( +
+ {error && ( +

{error}

+ )} + + {/* Pending badge */} + {pending && ( +
+ + Survey Requested + +

{pending.notes}

+

+ Requested by{" "} + {pending.requestedByEmail} + {pending.requestedAt && ` · ${formatDateTime(pending.requestedAt)}`} +

+
+ )} + + {/* Request form — only shown when no pending request */} + {canRequest && !pending && ( +
+

+ Request a survey from Domna. Notes will be sent to the coordination team. +

+