mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #260 from Hestia-Homes/claude/assessment-model-p2
Claude/assessment model p2
This commit is contained in:
commit
4cff53336a
26 changed files with 2489 additions and 624 deletions
11
.github/workflows/nextjs-build.yml
vendored
11
.github/workflows/nextjs-build.yml
vendored
|
|
@ -1,11 +1,12 @@
|
|||
name: Next.js Build Check
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**" # all branches
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
|
@ -21,5 +22,5 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Next.js app
|
||||
run: npm run build
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
172
cypress/e2e/live-tracking/measure-approval-drawer.cy.js
Normal file
172
cypress/e2e/live-tracking/measure-approval-drawer.cy.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Live Tracking — Measure approval drawer (Works tab)
|
||||
*
|
||||
* Verifies the approver flow for approving/unapproving proposed measures
|
||||
* directly from the Works tab of the PropertyDetailDrawer:
|
||||
* 1. The approver opens the Works tab and sees measure chips.
|
||||
* 2. The approver toggles a measure, clicks "Review & Save".
|
||||
* 3. The ApprovalConfirmDialog appears — the user types "approve".
|
||||
* 4. POST fires to /api/portfolio/*/approvals with the correct payload.
|
||||
*
|
||||
* Mirrors the same structure as `pibi-measures.cy.js`.
|
||||
* Uses `cy.intercept` to observe the API call without a real CRM round-trip.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_APPROVAL_DEAL_NAME");
|
||||
|
||||
describe("Measure approval drawer — Works tab approver flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerAtWorksTab() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Open a property row from the Measures table to get the detail drawer.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
|
||||
// The drawer opens on the Works tab from a Measures row click — verify.
|
||||
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-measures]").should("exist");
|
||||
}
|
||||
|
||||
it("fetches approved measures and shows chips in the Works tab for approvers", () => {
|
||||
// Stub the GET so we control the initial state.
|
||||
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
|
||||
body: {
|
||||
pibiMeasures: [],
|
||||
approvedMeasures: ["ASHP", "Solar PV"],
|
||||
instructedMeasures: [],
|
||||
},
|
||||
}).as("getMeasures");
|
||||
|
||||
openDrawerAtWorksTab();
|
||||
|
||||
cy.wait("@getMeasures");
|
||||
|
||||
// Chip container should be visible.
|
||||
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
|
||||
|
||||
// ASHP and Solar PV should be shown as approved (checked).
|
||||
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
|
||||
cy.get("[data-testid='measure-approval-checkbox-Solar PV']").should(
|
||||
"be.checked",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets an approver toggle a measure and POST the approval change", () => {
|
||||
// Stub GET to return ASHP as already approved.
|
||||
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
|
||||
body: {
|
||||
pibiMeasures: [],
|
||||
approvedMeasures: ["ASHP"],
|
||||
instructedMeasures: [],
|
||||
},
|
||||
}).as("getMeasures");
|
||||
|
||||
// Intercept the POST so we can assert the payload.
|
||||
cy.intercept("POST", `/api/portfolio/*/approvals`, {
|
||||
body: { success: true },
|
||||
}).as("postApprovals");
|
||||
|
||||
openDrawerAtWorksTab();
|
||||
|
||||
cy.wait("@getMeasures");
|
||||
|
||||
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
|
||||
|
||||
// ASHP should start approved.
|
||||
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should("be.checked");
|
||||
|
||||
// Toggle ASHP off (unapprove it).
|
||||
cy.get("[data-testid=measure-approval-chip-ASHP]").click();
|
||||
cy.get("[data-testid=measure-approval-checkbox-ASHP]").should(
|
||||
"not.be.checked",
|
||||
);
|
||||
|
||||
// "Review & Save" button should now be active.
|
||||
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
|
||||
cy.get("[data-testid=measure-approval-save]").click();
|
||||
|
||||
// ApprovalConfirmDialog should be visible — type the confirm word.
|
||||
cy.contains("Confirm approval changes").should("be.visible");
|
||||
cy.get("input[placeholder*=\"approve\"]").type("approve");
|
||||
cy.contains("button", "Confirm").click();
|
||||
|
||||
// Wait for the POST and assert the payload.
|
||||
cy.wait("@postApprovals").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.have.property("changes");
|
||||
const changes = intercepted.request.body.changes;
|
||||
// Should contain one change: ASHP unapproved.
|
||||
const ashpChange = changes.find((c) => c.measureName === "ASHP");
|
||||
expect(ashpChange).to.exist;
|
||||
expect(ashpChange.approved).to.equal(false);
|
||||
});
|
||||
|
||||
// No error banner visible.
|
||||
cy.get("[data-testid=measure-approval-error]").should("not.exist");
|
||||
});
|
||||
|
||||
it("lets an approver approve a new measure and POST with correct payload", () => {
|
||||
// Stub GET — no measures approved yet.
|
||||
cy.intercept("GET", `/api/portfolio/*/pibi-measures*`, {
|
||||
body: {
|
||||
pibiMeasures: [],
|
||||
approvedMeasures: [],
|
||||
instructedMeasures: [],
|
||||
},
|
||||
}).as("getMeasures");
|
||||
|
||||
// Intercept POST.
|
||||
cy.intercept("POST", `/api/portfolio/*/approvals`, {
|
||||
body: { success: true },
|
||||
}).as("postApprovals");
|
||||
|
||||
openDrawerAtWorksTab();
|
||||
|
||||
cy.wait("@getMeasures");
|
||||
|
||||
cy.get("[data-testid=measure-approval-chips]").should("be.visible");
|
||||
|
||||
// Click the first chip to approve it.
|
||||
cy.get("[data-testid=measure-approval-chips]")
|
||||
.find("label")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Save button should be active.
|
||||
cy.get("[data-testid=measure-approval-save]").should("not.be.disabled");
|
||||
cy.get("[data-testid=measure-approval-save]").click();
|
||||
|
||||
// Confirm dialog — type the word.
|
||||
cy.contains("Confirm approval changes").should("be.visible");
|
||||
cy.get("input[placeholder*=\"approve\"]").type("approve");
|
||||
cy.contains("button", "Confirm").click();
|
||||
|
||||
cy.wait("@postApprovals").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.have.property("changes");
|
||||
const changes = intercepted.request.body.changes;
|
||||
// Should have at least one approval.
|
||||
expect(changes.length).to.be.greaterThan(0);
|
||||
expect(changes[0]).to.have.property("approved", true);
|
||||
expect(changes[0]).to.have.property("hubspotDealId");
|
||||
});
|
||||
|
||||
// No error banner.
|
||||
cy.get("[data-testid=measure-approval-error]").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
50
cypress/e2e/live-tracking/property-deal-page.cy.js
Normal file
50
cypress/e2e/live-tracking/property-deal-page.cy.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Live Tracking — Property Deal Page (replaces property-detail-drawer)
|
||||
*
|
||||
* Verifies the two core navigation behaviors after moving from a right-side
|
||||
* drawer to a dedicated CRM-style deal page at /live/[dealId]:
|
||||
*
|
||||
* 1. Property table rows link to the correct deal page URL.
|
||||
* 2. The deal page loads with the Works tab active by default.
|
||||
*
|
||||
* Reads LIVE_PORTFOLIO_SLUG from Cypress env so it runs against any seeded
|
||||
* environment without hard-coding an ID.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
|
||||
describe("Property deal page", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("property table row has link to deal page URL", () => {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
cy.contains("button, [role=tab]", "Properties").click();
|
||||
cy.get("[data-testid=property-row-link]")
|
||||
.first()
|
||||
.should("have.attr", "href")
|
||||
.and(
|
||||
"match",
|
||||
new RegExp(
|
||||
`/portfolio/${PORTFOLIO_SLUG}/your-projects/live/[^/]+$`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("deal page shows Works tab active by default", () => {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
cy.contains("button, [role=tab]", "Properties").click();
|
||||
cy.get("[data-testid=property-row-link]").first().click();
|
||||
cy.get("[data-testid=deal-page-tab-works]").should(
|
||||
"have.attr",
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
/**
|
||||
* Live Tracking — Property Detail Drawer (issue #251)
|
||||
*
|
||||
* Verifies the row-click flow on the Measures tab: clicking a row in the
|
||||
* MeasuresTable opens the PropertyDetailDrawer and all six stage-ordered
|
||||
* sections are visible in the body.
|
||||
*
|
||||
* The spec assumes an authenticated session can be reused (or skipped) the
|
||||
* same way the rest of the suite handles it. Because live tracking is a
|
||||
* portfolio-scoped page, the test reads the target portfolio slug from the
|
||||
* `LIVE_PORTFOLIO_SLUG` Cypress env var so it can run against any seeded
|
||||
* environment without hard-coding an ID.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
|
||||
const EXPECTED_SECTIONS = [
|
||||
"survey",
|
||||
"measures",
|
||||
"pibi",
|
||||
"domna",
|
||||
"halted",
|
||||
"technical",
|
||||
];
|
||||
|
||||
describe("Property detail drawer — measures row click", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
// Skip the suite entirely when the env var is absent. The spec still
|
||||
// compiles so CI pipelines that lint Cypress files stay green.
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the drawer focused on Measures and shows all six sections", () => {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
// Switch to the Measures tab.
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
// Click the first measures row.
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
|
||||
// Drawer is open.
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
|
||||
// All six sections rendered inside the drawer.
|
||||
EXPECTED_SECTIONS.forEach((section) => {
|
||||
cy.get(`[data-testid=drawer-section-${section}]`).should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
83
cypress/e2e/live-tracking/survey-request.cy.js
Normal file
83
cypress/e2e/live-tracking/survey-request.cy.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Live Tracking — Survey request flow
|
||||
*
|
||||
* Verifies the client-facing "Request Survey" flow in the Survey & Admin tab:
|
||||
* 1. User opens a property and navigates to Survey & Admin tab.
|
||||
* 2. User fills in a free-text reason and submits the survey request.
|
||||
* 3. The POST hits /api/portfolio/[id]/survey-requests.
|
||||
* 4. The drawer reflects the pending request (badge shown).
|
||||
* 5. On reload the pending request is still visible.
|
||||
*
|
||||
* Uses cy.intercept so the HubSpot side-effect is observable without a live
|
||||
* CRM round-trip.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
const TARGET_DEAL_NAME = Cypress.env("LIVE_SURVEY_REQUEST_DEAL_NAME");
|
||||
|
||||
const SURVEY_NOTES = "Please arrange a full retrofit assessment — tenant has moved in.";
|
||||
|
||||
describe("Survey request — write user flow", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawerAtSurveyAdmin() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
|
||||
if (TARGET_DEAL_NAME) {
|
||||
cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click();
|
||||
} else {
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
}
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").click();
|
||||
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
|
||||
}
|
||||
|
||||
it("shows the survey request form in the Survey & Admin tab", () => {
|
||||
openDrawerAtSurveyAdmin();
|
||||
|
||||
cy.get("[data-testid=survey-request-form]").should("be.visible");
|
||||
cy.get("[data-testid=survey-request-notes]").should("be.visible");
|
||||
cy.get("[data-testid=survey-request-submit]").should("be.visible");
|
||||
});
|
||||
|
||||
it("submits a survey request and shows a pending badge", () => {
|
||||
cy.intercept(
|
||||
"POST",
|
||||
`/api/portfolio/*/survey-requests`,
|
||||
).as("createSurveyRequest");
|
||||
|
||||
openDrawerAtSurveyAdmin();
|
||||
|
||||
cy.get("[data-testid=survey-request-notes]").type(SURVEY_NOTES);
|
||||
cy.get("[data-testid=survey-request-submit]")
|
||||
.should("not.be.disabled")
|
||||
.click();
|
||||
|
||||
cy.wait("@createSurveyRequest").then((intercepted) => {
|
||||
expect(intercepted.request.body).to.have.property("notes");
|
||||
expect(intercepted.response.statusCode).to.be.oneOf([200, 201]);
|
||||
expect(intercepted.response.body).to.have.property("ok", true);
|
||||
});
|
||||
|
||||
// Pending badge appears after submission.
|
||||
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
|
||||
});
|
||||
|
||||
it("persists the pending request across a page reload", () => {
|
||||
openDrawerAtSurveyAdmin();
|
||||
|
||||
// If a pending request exists it should be visible without submitting again.
|
||||
cy.get("[data-testid=survey-request-pending-badge]").should("be.visible");
|
||||
});
|
||||
});
|
||||
91
cypress/e2e/live-tracking/tabbed-drawer.cy.js
Normal file
91
cypress/e2e/live-tracking/tabbed-drawer.cy.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Live Tracking — Tabbed property detail drawer
|
||||
*
|
||||
* Verifies the four-tab drawer structure introduced in the UI redesign:
|
||||
* Overview | Works | PIBI | Survey & Admin
|
||||
*
|
||||
* The spec opens the drawer from the Properties table (first row) and asserts
|
||||
* tab presence, default active state, and navigation between tabs.
|
||||
*/
|
||||
|
||||
const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG");
|
||||
|
||||
describe("Property detail drawer — tabbed layout", function () {
|
||||
before(function () {
|
||||
if (!PORTFOLIO_SLUG) {
|
||||
cy.log(
|
||||
"LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs",
|
||||
);
|
||||
this.skip();
|
||||
}
|
||||
});
|
||||
|
||||
function openDrawer() {
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
}
|
||||
|
||||
it("opens with Overview tab active by default", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-overview]")
|
||||
.should("be.visible")
|
||||
.and("have.attr", "aria-selected", "true");
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-overview]").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows all four tabs", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-overview]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-works]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-pibi]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").should("be.visible");
|
||||
});
|
||||
|
||||
it("navigates to Works tab and shows measures content", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-works]").click();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-works]").should("be.visible");
|
||||
// Measures section lives in Works tab
|
||||
cy.get("[data-testid=drawer-section-measures]").should("exist");
|
||||
});
|
||||
|
||||
it("navigates to PIBI tab and shows PIBI content", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-pibi]").click();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-pibi]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-pibi]").should("exist");
|
||||
});
|
||||
|
||||
it("navigates to Survey & Admin tab and shows admin content", () => {
|
||||
openDrawer();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-survey-admin]").click();
|
||||
|
||||
cy.get("[data-testid=drawer-tab-panel-survey-admin]").should("be.visible");
|
||||
cy.get("[data-testid=drawer-section-domna]").should("exist");
|
||||
});
|
||||
|
||||
it("focusSection=pibi opens PIBI tab directly", () => {
|
||||
// This is exercised by the pibi-dates.cy.js helper that clicks the Measures
|
||||
// tab row — after the redesign those rows pass focusSection="pibi" and the
|
||||
// drawer should land on the PIBI tab, not Overview.
|
||||
cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`);
|
||||
|
||||
cy.contains("button, [role=tab]", "Measures").click();
|
||||
cy.get("[data-testid=measures-row]").first().click();
|
||||
|
||||
cy.get("[data-testid=property-detail-drawer]").should("be.visible");
|
||||
|
||||
cy.get("[data-testid=drawer-tab-works]")
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
|
@ -133,7 +133,6 @@ export async function POST(
|
|||
return NextResponse.json({
|
||||
ok: true,
|
||||
hubspotSync: result.hubspotSync,
|
||||
autoPopulatedProposed: result.autoPopulatedProposed,
|
||||
hubspotError: result.hubspotError,
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
167
src/app/api/portfolio/[portfolioId]/survey-requests/route.ts
Normal file
167
src/app/api/portfolio/[portfolioId]/survey-requests/route.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { surveyRequests } from "@/app/db/schema/survey_requests";
|
||||
import { portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { and, eq, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync";
|
||||
|
||||
const WRITE_ROLES = ["creator", "admin", "write"] as const;
|
||||
|
||||
async function getRequestingUser(email: string) {
|
||||
const rows = await db
|
||||
.select({ id: user.id, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.email, email))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function getUserRole(portfolioId: bigint, userId: bigint) {
|
||||
const rows = await db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, portfolioId),
|
||||
eq(portfolioUsers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0]?.role ?? null;
|
||||
}
|
||||
|
||||
// GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx
|
||||
// Returns all survey requests for a deal, most recent first.
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await props.params;
|
||||
const dealId = req.nextUrl.searchParams.get("dealId");
|
||||
|
||||
if (!dealId) {
|
||||
return NextResponse.json({ error: "dealId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: surveyRequests.id,
|
||||
hubspotDealId: surveyRequests.hubspotDealId,
|
||||
notes: surveyRequests.notes,
|
||||
status: surveyRequests.status,
|
||||
requestedAt: surveyRequests.requestedAt,
|
||||
fulfilledAt: surveyRequests.fulfilledAt,
|
||||
requestedByEmail: user.email,
|
||||
})
|
||||
.from(surveyRequests)
|
||||
.innerJoin(user, eq(user.id, surveyRequests.requestedBy))
|
||||
.where(
|
||||
and(
|
||||
eq(surveyRequests.hubspotDealId, dealId),
|
||||
eq(surveyRequests.portfolioId, BigInt(portfolioId)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(surveyRequests.requestedAt))
|
||||
.limit(20);
|
||||
|
||||
const requests = rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
hubspotDealId: r.hubspotDealId,
|
||||
notes: r.notes,
|
||||
status: r.status,
|
||||
requestedByEmail: r.requestedByEmail,
|
||||
requestedAt: r.requestedAt?.toISOString() ?? null,
|
||||
fulfilledAt: r.fulfilledAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ requests });
|
||||
} catch (err) {
|
||||
console.error("[survey-requests GET]", err);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const postSchema = z.object({
|
||||
hubspotDealId: z.string().min(1),
|
||||
notes: z.string().min(1, "Notes are required"),
|
||||
});
|
||||
|
||||
// POST /api/portfolio/[portfolioId]/survey-requests
|
||||
// Submit a new survey request — requires write+ role.
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string }> },
|
||||
) {
|
||||
const { portfolioId } = await props.params;
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
const requestingUser = await getRequestingUser(session.user.email);
|
||||
if (!requestingUser) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = await getUserRole(BigInt(portfolioId), requestingUser.id);
|
||||
if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) {
|
||||
return NextResponse.json(
|
||||
{ error: "Write access required to submit a survey request" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = postSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { hubspotDealId, notes } = parsed.data;
|
||||
|
||||
try {
|
||||
const [inserted] = await db
|
||||
.insert(surveyRequests)
|
||||
.values({
|
||||
hubspotDealId,
|
||||
portfolioId: BigInt(portfolioId),
|
||||
notes,
|
||||
status: "pending",
|
||||
requestedBy: requestingUser.id,
|
||||
})
|
||||
.returning({ id: surveyRequests.id });
|
||||
|
||||
const hubspotResult = await syncSurveyRequestToHubSpot({
|
||||
hubspotDealId,
|
||||
notes,
|
||||
requestedByEmail: requestingUser.email,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
id: String(inserted.id),
|
||||
hubspotSync: hubspotResult.ok ? "ok" : "failed",
|
||||
hubspotError: hubspotResult.error,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[survey-requests POST]", err);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
15
src/app/db/migrations/0195_survey_requests.sql
Normal file
15
src/app/db/migrations/0195_survey_requests.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
CREATE TABLE "survey_requests" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"hubspot_deal_id" text NOT NULL,
|
||||
"portfolio_id" bigint NOT NULL,
|
||||
"notes" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"requested_by" bigint NOT NULL,
|
||||
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"fulfilled_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "survey_requests" ADD CONSTRAINT "survey_requests_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_survey_requests_deal_id" ON "survey_requests" USING btree ("hubspot_deal_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_survey_requests_portfolio_id" ON "survey_requests" USING btree ("portfolio_id");
|
||||
|
|
@ -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 }),
|
||||
|
||||
|
|
|
|||
41
src/app/db/schema/survey_requests.ts
Normal file
41
src/app/db/schema/survey_requests.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
bigserial,
|
||||
text,
|
||||
timestamp,
|
||||
pgTable,
|
||||
bigint,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { user } from "./users";
|
||||
import { portfolio } from "./portfolio";
|
||||
|
||||
// One row per survey request from a portfolio user. A deal can accumulate
|
||||
// multiple requests over time; query by hubspotDealId ordered by requestedAt
|
||||
// desc to get the latest.
|
||||
export const surveyRequests = pgTable(
|
||||
"survey_requests",
|
||||
{
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
hubspotDealId: text("hubspot_deal_id").notNull(),
|
||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
// Free-text notes from the requester describing what survey is needed.
|
||||
notes: text("notes").notNull(),
|
||||
// 'pending' | 'fulfilled'
|
||||
status: text("status").notNull().default("pending"),
|
||||
requestedBy: bigint("requested_by", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
requestedAt: timestamp("requested_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_survey_requests_deal_id").on(table.hubspotDealId),
|
||||
index("idx_survey_requests_portfolio_id").on(table.portfolioId),
|
||||
],
|
||||
);
|
||||
|
||||
export type SurveyRequest = typeof surveyRequests.$inferSelect;
|
||||
|
|
@ -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 }>;
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
* Unit tests for the instruct-measure service.
|
||||
*
|
||||
* These tests exercise the orchestration logic — DB transaction +
|
||||
* post-commit HubSpot push + auto-populate-proposed branch — by injecting
|
||||
* lightweight fakes for the DB hooks. The tests never touch the real DB
|
||||
* or HubSpot client.
|
||||
* post-commit HubSpot push — by injecting lightweight fakes for the DB hooks.
|
||||
* The tests never touch the real DB or HubSpot client.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
INSTRUCTED_MEASURES_PROP,
|
||||
PROPOSED_MEASURES_PROP,
|
||||
APPROVED_MEASURES_PROP,
|
||||
instructMeasure,
|
||||
} from "./instructMeasure";
|
||||
import type {
|
||||
|
|
@ -31,8 +31,8 @@ function makeDeps(overrides?: {
|
|||
}) {
|
||||
const txOutcome: InstructTxOutcome = {
|
||||
instructedRowId: 42n,
|
||||
proposedWasEmpty: false,
|
||||
hadNoApprovals: false,
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: [],
|
||||
...overrides?.txOutcome,
|
||||
};
|
||||
const runInstructTx: RunInstructTx = vi.fn(async () => {
|
||||
|
|
@ -43,7 +43,7 @@ function makeDeps(overrides?: {
|
|||
async () => overrides?.instructedAfter ?? ["ASHP"],
|
||||
);
|
||||
const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
|
||||
overrides?.syncResults ?? [{ ok: true }];
|
||||
overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
|
||||
const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
|
||||
return syncQueue.shift() ?? ({ ok: true } as const);
|
||||
});
|
||||
|
|
@ -90,13 +90,13 @@ describe("instructMeasure — input validation", () => {
|
|||
});
|
||||
|
||||
describe("instructMeasure — happy path", () => {
|
||||
it("commits the tx, pushes the full instructed list, stamps pushed_at", async () => {
|
||||
it("commits tx, pushes instructed + proposed + approved, stamps pushed_at", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["ASHP", "Solar PV"],
|
||||
txOutcome: {
|
||||
instructedRowId: 99n,
|
||||
proposedWasEmpty: false,
|
||||
hadNoApprovals: false,
|
||||
existingProposedMeasures: ["ASHP"],
|
||||
allApprovedMeasureNames: ["ASHP", "Solar PV"],
|
||||
},
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
|
|
@ -109,7 +109,6 @@ describe("instructMeasure — happy path", () => {
|
|||
ok: true,
|
||||
instructedRowId: 99n,
|
||||
hubspotSync: "ok",
|
||||
autoPopulatedProposed: false,
|
||||
});
|
||||
expect(deps.runInstructTx).toHaveBeenCalledWith({
|
||||
dealId: "deal-42",
|
||||
|
|
@ -117,80 +116,54 @@ describe("instructMeasure — happy path", () => {
|
|||
userId: 7n,
|
||||
notes: null,
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1);
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledWith({
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
|
||||
hubspotDealId: "deal-42",
|
||||
propName: INSTRUCTED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
|
||||
hubspotDealId: "deal-42",
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
|
||||
hubspotDealId: "deal-42",
|
||||
propName: APPROVED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV"],
|
||||
});
|
||||
expect(deps.stampPushedAt).toHaveBeenCalledWith(99n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instructMeasure — auto-populate proposed branch", () => {
|
||||
it("also pushes proposed_measures when proposed was empty AND no prior approvals", async () => {
|
||||
it("merges new measure into existing proposed (deduped)", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["EWI"],
|
||||
instructedAfter: ["ASHP", "EWI"],
|
||||
txOutcome: {
|
||||
instructedRowId: 1n,
|
||||
proposedWasEmpty: true,
|
||||
hadNoApprovals: true,
|
||||
existingProposedMeasures: ["ASHP", "Solar PV"],
|
||||
allApprovedMeasureNames: ["ASHP", "EWI"],
|
||||
},
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
dealId: "deal-blank",
|
||||
await instructMeasure({
|
||||
dealId: "deal-merge",
|
||||
measureName: "EWI",
|
||||
userId: 3n,
|
||||
deps,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.autoPopulatedProposed).toBe(true);
|
||||
expect(result.hubspotSync).toBe("ok");
|
||||
}
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(2);
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
|
||||
hubspotDealId: "deal-blank",
|
||||
propName: INSTRUCTED_MEASURES_PROP,
|
||||
measureNames: ["EWI"],
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
|
||||
hubspotDealId: "deal-blank",
|
||||
hubspotDealId: "deal-merge",
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: ["EWI"],
|
||||
measureNames: ["ASHP", "Solar PV", "EWI"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT auto-populate proposed when proposed was empty but approvals already exist", async () => {
|
||||
it("adds to proposed even when deal already had proposed measures", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["EWI"],
|
||||
txOutcome: {
|
||||
instructedRowId: 1n,
|
||||
proposedWasEmpty: true,
|
||||
hadNoApprovals: false, // an approval already existed
|
||||
},
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
dealId: "deal-mid-flow",
|
||||
measureName: "EWI",
|
||||
userId: 3n,
|
||||
deps,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.autoPopulatedProposed).toBe(false);
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1);
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ propName: INSTRUCTED_MEASURES_PROP }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT auto-populate proposed when proposed already has measures", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["EWI"],
|
||||
txOutcome: {
|
||||
instructedRowId: 1n,
|
||||
proposedWasEmpty: false,
|
||||
hadNoApprovals: true,
|
||||
existingProposedMeasures: ["ASHP", "Solar PV"],
|
||||
allApprovedMeasureNames: ["EWI"],
|
||||
},
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
|
|
@ -200,8 +173,35 @@ describe("instructMeasure — auto-populate proposed branch", () => {
|
|||
deps,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.autoPopulatedProposed).toBe(false);
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(1);
|
||||
expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2,
|
||||
expect.objectContaining({
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: ["ASHP", "Solar PV", "EWI"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds to proposed when deal has no existing proposed measures", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["EWI"],
|
||||
txOutcome: {
|
||||
instructedRowId: 1n,
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: ["EWI"],
|
||||
},
|
||||
});
|
||||
await instructMeasure({
|
||||
dealId: "deal-blank",
|
||||
measureName: "EWI",
|
||||
userId: 3n,
|
||||
deps,
|
||||
});
|
||||
expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
|
||||
hubspotDealId: "deal-blank",
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: ["EWI"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -222,15 +222,19 @@ describe("instructMeasure — DB transaction failure", () => {
|
|||
});
|
||||
|
||||
describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
|
||||
it("returns ok=true with hubspotSync=failed and does NOT stamp pushed_at", async () => {
|
||||
it("returns ok=true with hubspotSync=failed when instructed push fails, does NOT stamp", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["ASHP"],
|
||||
txOutcome: {
|
||||
instructedRowId: 11n,
|
||||
proposedWasEmpty: false,
|
||||
hadNoApprovals: false,
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: ["ASHP"],
|
||||
},
|
||||
syncResults: [{ ok: false, error: "hubspot 500" }],
|
||||
syncResults: [
|
||||
{ ok: false, error: "hubspot 500" },
|
||||
{ ok: true },
|
||||
{ ok: true },
|
||||
],
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
dealId: "deal-h",
|
||||
|
|
@ -244,21 +248,21 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
|
|||
hubspotSync: "failed",
|
||||
hubspotError: "hubspot 500",
|
||||
});
|
||||
expect(deps.runInstructTx).toHaveBeenCalledTimes(1);
|
||||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a failed proposed-measures push as overall failure (still no stamp)", async () => {
|
||||
it("returns hubspotSync=failed when proposed push fails", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["EWI"],
|
||||
txOutcome: {
|
||||
instructedRowId: 12n,
|
||||
proposedWasEmpty: true,
|
||||
hadNoApprovals: true,
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: ["EWI"],
|
||||
},
|
||||
syncResults: [
|
||||
{ ok: true },
|
||||
{ ok: false, error: "proposed push failed" },
|
||||
{ ok: true },
|
||||
],
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
|
|
@ -271,7 +275,34 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
|
|||
ok: true,
|
||||
hubspotSync: "failed",
|
||||
hubspotError: "proposed push failed",
|
||||
autoPopulatedProposed: true,
|
||||
});
|
||||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns hubspotSync=failed when approved push fails", async () => {
|
||||
const deps = makeDeps({
|
||||
instructedAfter: ["EWI"],
|
||||
txOutcome: {
|
||||
instructedRowId: 13n,
|
||||
existingProposedMeasures: [],
|
||||
allApprovedMeasureNames: ["EWI"],
|
||||
},
|
||||
syncResults: [
|
||||
{ ok: true },
|
||||
{ ok: true },
|
||||
{ ok: false, error: "approved push failed" },
|
||||
],
|
||||
});
|
||||
const result = await instructMeasure({
|
||||
dealId: "deal-blank",
|
||||
measureName: "EWI",
|
||||
userId: 3n,
|
||||
deps,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
hubspotSync: "failed",
|
||||
hubspotError: "approved push failed",
|
||||
});
|
||||
expect(deps.stampPushedAt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,18 +10,19 @@
|
|||
* 1. insert a `user_defined_deal_measures` row with `source = "instructed"`
|
||||
* 2. insert a `deal_measure_approvals` row with `is_approved = true`
|
||||
* 3. insert a corresponding `deal_measure_approval_events` row
|
||||
* 4. read the deal's current `proposed_measures` + total approval count
|
||||
* 4. read the deal's current `proposed_measures` list
|
||||
* 5. read all currently approved measure names for the deal
|
||||
*
|
||||
* After the transaction commits, push the instructed-measures list to
|
||||
* HubSpot under `instructed_measures`. If `proposed_measures` was empty
|
||||
* AND the deal had no prior approvals, ALSO push the new measure as
|
||||
* `proposed_measures` so the deal has a coherent measures starting point.
|
||||
* After the transaction commits, push three HubSpot fields:
|
||||
* - `instructed_measures`: full list of all instructed measures (audit trail)
|
||||
* - `proposed_measures`: existing list merged with the new measure (always)
|
||||
* - `approved_measures`: all currently approved measures for the deal
|
||||
*
|
||||
* HubSpot push failures DO NOT roll back the DB. Successful pushes stamp
|
||||
* `pushed_at` on the new local row; failures leave it null so a follow-up
|
||||
* reconcile job can retry.
|
||||
*/
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/app/db/db";
|
||||
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
||||
import {
|
||||
|
|
@ -38,12 +39,15 @@ import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/li
|
|||
|
||||
export const INSTRUCTED_MEASURES_PROP = "instructed_measures";
|
||||
export const PROPOSED_MEASURES_PROP = "proposed_measures";
|
||||
export const APPROVED_MEASURES_PROP = "approved_measures";
|
||||
|
||||
/** Snapshot returned by the transactional step of the service. */
|
||||
export interface InstructTxOutcome {
|
||||
instructedRowId: bigint;
|
||||
proposedWasEmpty: boolean;
|
||||
hadNoApprovals: boolean;
|
||||
/** Existing proposed measures from hubspot_deal_data before this instruction. */
|
||||
existingProposedMeasures: string[];
|
||||
/** All approved measure names for the deal after this instruction's approval row is upserted. */
|
||||
allApprovedMeasureNames: string[];
|
||||
}
|
||||
|
||||
export type SyncMeasuresField = typeof defaultSyncMeasuresField;
|
||||
|
|
@ -66,7 +70,6 @@ export type InstructMeasureResult =
|
|||
ok: true;
|
||||
instructedRowId: bigint;
|
||||
hubspotSync: "ok" | "failed";
|
||||
autoPopulatedProposed: boolean;
|
||||
hubspotError?: string;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
|
@ -154,19 +157,20 @@ const defaultRunInstructTx: RunInstructTx = async ({
|
|||
.from(hubspotDealData)
|
||||
.where(eq(hubspotDealData.dealId, dealId))
|
||||
.limit(1);
|
||||
const proposedRaw = dealRows[0]?.proposedMeasures ?? null;
|
||||
const proposedWasEmpty = parseMeasures(proposedRaw).length === 0;
|
||||
const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null);
|
||||
|
||||
const approvalCountRows = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
const approvedRows = await tx
|
||||
.select({ measureName: dealMeasureApprovals.measureName })
|
||||
.from(dealMeasureApprovals)
|
||||
.where(eq(dealMeasureApprovals.hubspotDealId, dealId));
|
||||
const approvalCount = Number(approvalCountRows[0]?.count ?? 0);
|
||||
// We just inserted (or upserted) one approval row in this tx, so any
|
||||
// count > 1 means the deal already had a prior approval row before us.
|
||||
const hadNoApprovals = approvalCount <= 1;
|
||||
.where(
|
||||
and(
|
||||
eq(dealMeasureApprovals.hubspotDealId, dealId),
|
||||
eq(dealMeasureApprovals.isApproved, true),
|
||||
),
|
||||
);
|
||||
const allApprovedMeasureNames = approvedRows.map((r) => r.measureName);
|
||||
|
||||
return { instructedRowId, proposedWasEmpty, hadNoApprovals };
|
||||
return { instructedRowId, existingProposedMeasures, allApprovedMeasureNames };
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -240,25 +244,29 @@ export async function instructMeasure(
|
|||
// ---------------------------------------------------------------------
|
||||
const allInstructed = await readInstructed(input.dealId);
|
||||
|
||||
const mergedProposed = Array.from(
|
||||
new Set([...txResult.existingProposedMeasures, measureName]),
|
||||
);
|
||||
|
||||
const instructedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: INSTRUCTED_MEASURES_PROP,
|
||||
measureNames: allInstructed,
|
||||
});
|
||||
|
||||
let proposedSync: { ok: true } | { ok: false; error: string } | null = null;
|
||||
const shouldAutoPopulateProposed =
|
||||
txResult.proposedWasEmpty && txResult.hadNoApprovals;
|
||||
if (shouldAutoPopulateProposed) {
|
||||
proposedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: [measureName],
|
||||
});
|
||||
}
|
||||
const proposedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: PROPOSED_MEASURES_PROP,
|
||||
measureNames: mergedProposed,
|
||||
});
|
||||
|
||||
const overallOk =
|
||||
instructedSync.ok && (proposedSync === null || proposedSync.ok);
|
||||
const approvedSync = await syncMeasuresField({
|
||||
hubspotDealId: input.dealId,
|
||||
propName: APPROVED_MEASURES_PROP,
|
||||
measureNames: txResult.allApprovedMeasureNames,
|
||||
});
|
||||
|
||||
const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok;
|
||||
|
||||
if (overallOk) {
|
||||
try {
|
||||
|
|
@ -273,21 +281,21 @@ export async function instructMeasure(
|
|||
ok: true,
|
||||
instructedRowId: txResult.instructedRowId,
|
||||
hubspotSync: "ok",
|
||||
autoPopulatedProposed: shouldAutoPopulateProposed,
|
||||
};
|
||||
}
|
||||
|
||||
const hubspotError = !instructedSync.ok
|
||||
? instructedSync.error
|
||||
: proposedSync && !proposedSync.ok
|
||||
: !proposedSync.ok
|
||||
? proposedSync.error
|
||||
: "HubSpot sync failed";
|
||||
: !approvedSync.ok
|
||||
? approvedSync.error
|
||||
: "HubSpot sync failed";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
instructedRowId: txResult.instructedRowId,
|
||||
hubspotSync: "failed",
|
||||
autoPopulatedProposed: shouldAutoPopulateProposed,
|
||||
hubspotError,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import DocumentTable from "./DocumentTable";
|
|||
import MeasuresTable from "./MeasuresTable";
|
||||
import type { HubspotDeal } from "./types";
|
||||
import PropertyDrawer from "./PropertyDrawer";
|
||||
import PropertyDetailDrawer, { type DrawerSection } from "./PropertyDetailDrawer";
|
||||
import AnalyticsView from "./AnalyticsView";
|
||||
import type {
|
||||
LiveTrackerProps,
|
||||
|
|
@ -75,22 +74,6 @@ export default function LiveTracker({
|
|||
dealname: null,
|
||||
});
|
||||
|
||||
// ── Property detail drawer ───────────────────────────────────────────
|
||||
const [detailDeal, setDetailDeal] = useState<ClassifiedDeal | null>(null);
|
||||
const [detailFocusSection, setDetailFocusSection] = useState<
|
||||
DrawerSection | undefined
|
||||
>(undefined);
|
||||
|
||||
const openDetailDrawer = (deal: ClassifiedDeal, section?: DrawerSection) => {
|
||||
setDetailFocusSection(section);
|
||||
setDetailDeal(deal);
|
||||
};
|
||||
|
||||
const closeDetailDrawer = () => {
|
||||
setDetailDeal(null);
|
||||
setDetailFocusSection(undefined);
|
||||
};
|
||||
|
||||
const handleOpenTable = (
|
||||
stage: string,
|
||||
filteredDeals: ClassifiedDeal[],
|
||||
|
|
@ -243,7 +226,7 @@ export default function LiveTracker({
|
|||
<PropertyTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
onOpenDrawer={handleOpenDrawer}
|
||||
onOpenDetail={(deal) => openDetailDrawer(deal)}
|
||||
portfolioId={portfolioId}
|
||||
docStatusMap={docStatusMap}
|
||||
removalStatusByDeal={removalStatusByDeal}
|
||||
/>
|
||||
|
|
@ -320,10 +303,8 @@ export default function LiveTracker({
|
|||
)}
|
||||
<MeasuresTable
|
||||
data={currentProject?.allDeals ?? []}
|
||||
userCapability={userCapability}
|
||||
approvalsByDeal={approvalsByDeal}
|
||||
portfolioId={portfolioId}
|
||||
onOpenDetail={(deal) => openDetailDrawer(deal, "measures")}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
@ -438,16 +419,6 @@ export default function LiveTracker({
|
|||
}
|
||||
/>
|
||||
|
||||
{/* ── Property detail drawer ─────────────────────────────────────── */}
|
||||
<PropertyDetailDrawer
|
||||
deal={detailDeal}
|
||||
onClose={closeDetailDrawer}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
userEmail={userEmail}
|
||||
focusSection={detailFocusSection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -11,13 +12,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,15 +30,8 @@ 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.
|
||||
*/
|
||||
onOpenDetail?: (deal: ClassifiedDeal) => void;
|
||||
};
|
||||
|
||||
function ApprovalStatus({
|
||||
|
|
@ -144,33 +135,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 router = useRouter();
|
||||
const [search, setSearch] = useState("");
|
||||
// pendingChanges: dealId -> desired Set<measureName> (the full intended approved set)
|
||||
const [pendingChanges, setPendingChanges] = useState<
|
||||
Record<string, Set<string>>
|
||||
>({});
|
||||
const [savedApprovals, setSavedApprovals] =
|
||||
useState<ApprovalsByDeal>(approvalsByDeal);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
// Filter to only properties with proposed measures
|
||||
|
|
@ -190,80 +161,6 @@ export default function MeasuresTable({
|
|||
);
|
||||
}, [dealsWithMeasures, search]);
|
||||
|
||||
const hasPendingChanges = Object.keys(pendingChanges).length > 0;
|
||||
|
||||
// Compute diffs: for each deal in pendingChanges, what's added vs removed vs saved
|
||||
const pendingDiffs = useMemo<Record<string, PendingDiff>>(() => {
|
||||
const diffs: Record<string, PendingDiff> = {};
|
||||
for (const [dealId, pending] of Object.entries(pendingChanges)) {
|
||||
const saved = new Set(savedApprovals[dealId] ?? []);
|
||||
const added = [...pending].filter((m) => !saved.has(m));
|
||||
const removed = [...saved].filter((m) => !pending.has(m));
|
||||
if (added.length > 0 || removed.length > 0) {
|
||||
diffs[dealId] = { added, removed };
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}, [pendingChanges, savedApprovals]);
|
||||
|
||||
const dealNames = useMemo<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const d of dealsWithMeasures) {
|
||||
map[d.dealId] = d.dealname ?? d.landlordPropertyId ?? d.dealId;
|
||||
}
|
||||
return map;
|
||||
}, [dealsWithMeasures]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
// Build flat list of explicit changes from diffs
|
||||
const changes: { hubspotDealId: string; measureName: string; approved: boolean }[] = [];
|
||||
for (const [dealId, diff] of Object.entries(pendingDiffs)) {
|
||||
for (const m of diff.added) changes.push({ hubspotDealId: dealId, measureName: m, approved: true });
|
||||
for (const m of diff.removed) changes.push({ hubspotDealId: dealId, measureName: m, approved: false });
|
||||
}
|
||||
return postApprovalChanges(portfolioId, changes);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSavedApprovals((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [dealId, pending] of Object.entries(pendingChanges)) {
|
||||
next[dealId] = Array.from(pending);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPendingChanges({});
|
||||
setShowConfirm(false);
|
||||
},
|
||||
});
|
||||
|
||||
function toggleMeasure(dealId: string, measure: string) {
|
||||
setPendingChanges((prev) => {
|
||||
const base =
|
||||
prev[dealId] !== undefined
|
||||
? new Set(prev[dealId])
|
||||
: new Set(savedApprovals[dealId] ?? []);
|
||||
|
||||
if (base.has(measure)) {
|
||||
base.delete(measure);
|
||||
} else {
|
||||
base.add(measure);
|
||||
}
|
||||
|
||||
// If pending equals saved, remove from tracking
|
||||
const saved = new Set(savedApprovals[dealId] ?? []);
|
||||
const equal = base.size === saved.size && [...base].every((m) => saved.has(m));
|
||||
|
||||
const next = { ...prev };
|
||||
if (equal) {
|
||||
delete next[dealId];
|
||||
} else {
|
||||
next[dealId] = base;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRowExpand(dealId: string) {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
|
@ -300,16 +197,9 @@ export default function MeasuresTable({
|
|||
<span className="text-xs text-gray-400">
|
||||
{filtered.length} of {dealsWithMeasures.length} properties
|
||||
</span>
|
||||
{userCapability.includes("approver") && hasPendingChanges && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="bg-brandblue text-white gap-1.5"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Review changes ({Object.keys(pendingDiffs).length})
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 hidden sm:inline">
|
||||
· Click a row to open property
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -336,23 +226,20 @@ export default function MeasuresTable({
|
|||
<TableBody>
|
||||
{filtered.map((deal) => {
|
||||
const proposed = parseMeasures(deal.proposedMeasures);
|
||||
const approvedForDeal =
|
||||
pendingChanges[deal.dealId] !== undefined
|
||||
? Array.from(pendingChanges[deal.dealId])
|
||||
: (savedApprovals[deal.dealId] ?? []);
|
||||
const approvedForDeal = approvalsByDeal[deal.dealId] ?? [];
|
||||
const approvedSet = new Set(approvedForDeal);
|
||||
const stageColor = STAGE_COLORS[deal.displayStage];
|
||||
const hasPending = pendingChanges[deal.dealId] !== undefined;
|
||||
const isExpanded = expandedRows.has(deal.dealId);
|
||||
|
||||
const dealPageUrl = `/portfolio/${portfolioId}/your-projects/live/${deal.dealId}?tab=works`;
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (onOpenDetail) onOpenDetail(deal);
|
||||
router.push(dealPageUrl);
|
||||
};
|
||||
const handleRowKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>) => {
|
||||
if (!onOpenDetail) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenDetail(deal);
|
||||
router.push(dealPageUrl);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -360,11 +247,11 @@ export default function MeasuresTable({
|
|||
<React.Fragment key={deal.dealId}>
|
||||
<TableRow
|
||||
data-testid="measures-row"
|
||||
onClick={onOpenDetail ? handleRowClick : undefined}
|
||||
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" : ""}`}
|
||||
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">
|
||||
|
|
@ -403,31 +290,11 @@ export default function MeasuresTable({
|
|||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Proposed measures */}
|
||||
{/* Proposed measures — read-only; click the row to approve in the drawer */}
|
||||
<TableCell className="py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{proposed.map((measure) => {
|
||||
const isApproved = approvedSet.has(measure);
|
||||
if (userCapability.includes("approver")) {
|
||||
return (
|
||||
<label
|
||||
key={measure}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex items-center gap-1.5 cursor-pointer px-2 py-1 rounded-full text-xs border transition-colors ${
|
||||
isApproved
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: "bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isApproved}
|
||||
onCheckedChange={() => toggleMeasure(deal.dealId, measure)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{measure}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
key={measure}
|
||||
|
|
@ -474,16 +341,6 @@ export default function MeasuresTable({
|
|||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<ApprovalConfirmDialog
|
||||
open={showConfirm}
|
||||
pendingDiffs={pendingDiffs}
|
||||
dealNames={dealNames}
|
||||
onConfirm={() => saveMutation.mutate()}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
isPending={saveMutation.isPending}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -67,7 +67,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio
|
|||
interface PropertyTableProps {
|
||||
data: ClassifiedDeal[];
|
||||
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
|
||||
onOpenDetail?: (deal: ClassifiedDeal) => void;
|
||||
portfolioId?: string;
|
||||
showDocuments?: boolean;
|
||||
docStatusMap?: DocStatusMap;
|
||||
removalStatusByDeal?: RemovalStatusByDeal;
|
||||
|
|
@ -106,7 +106,7 @@ function escapeCell(value: unknown): string {
|
|||
: str;
|
||||
}
|
||||
|
||||
export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
|
||||
export default function PropertyTable({ data, onOpenDrawer, portfolioId = "", showDocuments = false, docStatusMap = {}, removalStatusByDeal = {} }: PropertyTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState<string>("all");
|
||||
const [docFilter, setDocFilter] = useState<DocFilter>("all");
|
||||
|
|
@ -157,8 +157,8 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
|
|||
}, [data, stageFilter, docFilter, docStatusMap, removalFilter, removalStatusByDeal]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal),
|
||||
[onOpenDrawer, showDocuments, docStatusMap, onOpenDetail, removalStatusByDeal]
|
||||
() => createPropertyTableColumns(onOpenDrawer, showDocuments, docStatusMap, portfolioId, removalStatusByDeal),
|
||||
[onOpenDrawer, showDocuments, docStatusMap, portfolioId, removalStatusByDeal]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { STAGE_COLORS } from "./types";
|
||||
import type { ClassifiedDeal, DisplayStage, DocStatusMap, RemovalStatusByDeal } from "./types";
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ export function createPropertyTableColumns(
|
|||
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
|
||||
showDocuments: boolean = false,
|
||||
docStatusMap: DocStatusMap = {},
|
||||
onOpenDetail?: (deal: ClassifiedDeal) => void,
|
||||
portfolioId: string = "",
|
||||
removalStatusByDeal: RemovalStatusByDeal = {},
|
||||
): ColumnDef<ClassifiedDeal>[] {
|
||||
const columns: ColumnDef<ClassifiedDeal>[] = [
|
||||
|
|
@ -60,18 +61,22 @@ export function createPropertyTableColumns(
|
|||
cell: ({ row }) => {
|
||||
const removalState = row.original.dealId ? removalStatusByDeal[row.original.dealId] : undefined;
|
||||
const hasPending = removalState === "pending_removal" || removalState === "pending_re_addition";
|
||||
const href = portfolioId
|
||||
? `/portfolio/${portfolioId}/your-projects/live/${row.original.dealId}`
|
||||
: undefined;
|
||||
return (
|
||||
<div className="max-w-[220px] flex items-center gap-1.5">
|
||||
{hasPending && (
|
||||
<span className="shrink-0 w-2 h-2 rounded-full bg-amber-400" title="Outstanding removal request" />
|
||||
)}
|
||||
{onOpenDetail ? (
|
||||
<button
|
||||
onClick={() => onOpenDetail(row.original)}
|
||||
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight text-left truncate transition-colors"
|
||||
{href ? (
|
||||
<Link
|
||||
href={href}
|
||||
data-testid="property-row-link"
|
||||
className="text-sm font-medium text-brandblue hover:text-brandmidblue hover:underline underline-offset-2 leading-tight truncate transition-colors"
|
||||
>
|
||||
{row.original.dealname ?? "—"}
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-gray-900 leading-tight truncate">
|
||||
{row.original.dealname ?? "—"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,562 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/app/shadcn_components/ui/tooltip";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import { parseMeasures } from "@/app/lib/parseMeasures";
|
||||
import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings";
|
||||
import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState } from "../types";
|
||||
import { STAGE_COLORS } from "../types";
|
||||
import {
|
||||
InfoRow,
|
||||
StageBadge,
|
||||
MilestoneTimeline,
|
||||
formatDate,
|
||||
MeasureApprovalEditor,
|
||||
InstructMeasureEditor,
|
||||
ApprovalLogSection,
|
||||
PibiDatesEditor,
|
||||
PibiMeasureSelector,
|
||||
DomnaEditor,
|
||||
HaltedEditor,
|
||||
SurveyRequestSection,
|
||||
RemovalRequestSection,
|
||||
SectionHeader,
|
||||
SECTION_TITLES,
|
||||
WRITE_ROLES,
|
||||
} from "../PropertyDetailDrawer";
|
||||
|
||||
type Tab = "works" | "pibi" | "survey-admin" | "documents";
|
||||
const VALID_TABS: Tab[] = ["works", "pibi", "survey-admin", "documents"];
|
||||
|
||||
const TAB_LABELS: Record<Tab, string> = {
|
||||
works: "Works",
|
||||
pibi: "PIBI",
|
||||
"survey-admin": "Survey & Admin",
|
||||
documents: "Documents",
|
||||
};
|
||||
|
||||
interface DealPageProps {
|
||||
deal: ClassifiedDeal;
|
||||
portfolioId: string;
|
||||
userRole: string;
|
||||
userCapability: PortfolioCapabilityType;
|
||||
approvedMeasures: string[];
|
||||
docStatus: DocStatus;
|
||||
removalState: EffectiveRemovalState;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
export default function DealPage({
|
||||
deal,
|
||||
portfolioId,
|
||||
userRole,
|
||||
userCapability,
|
||||
docStatus,
|
||||
removalState,
|
||||
}: DealPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const rawTab = searchParams.get("tab");
|
||||
const [activeTab, setActiveTab] = useState<Tab>(
|
||||
VALID_TABS.includes(rawTab as Tab) ? (rawTab as Tab) : "works",
|
||||
);
|
||||
const [instructModalOpen, setInstructModalOpen] = useState(false);
|
||||
const [isLogOpen, setIsLogOpen] = useState(false);
|
||||
|
||||
const switchTab = (tab: Tab) => {
|
||||
setActiveTab(tab);
|
||||
router.replace(`?tab=${tab}`, { scroll: false });
|
||||
};
|
||||
|
||||
const epcCurrent = sapToEpc(deal.preSapScore != null ? Number(deal.preSapScore) : null);
|
||||
const epcPotential = sapToEpc(deal.epcSapScorePotential != null ? Number(deal.epcSapScorePotential) : null);
|
||||
const technicalApprovedMeasures = parseMeasures(
|
||||
deal.technicalApprovedMeasuresForInstall ?? null,
|
||||
);
|
||||
const pibiMeasures = parseMeasures(deal.measuresForPibiOrdered ?? null);
|
||||
|
||||
const isApprover = userCapability.includes("approver");
|
||||
const canWrite = WRITE_ROLES.includes(userRole);
|
||||
const stageColors = STAGE_COLORS[deal.displayStage] ?? STAGE_COLORS["Unknown Stage"];
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
|
||||
{/* ── Left Sidebar: Property Info ─────────────────────────── */}
|
||||
<aside className="col-span-12 lg:col-span-3 space-y-5">
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5 space-y-5">
|
||||
|
||||
{/* Header info */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 leading-snug mb-2">
|
||||
{deal.dealname ?? "Property"}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StageBadge stage={deal.displayStage} />
|
||||
{deal.landlordPropertyId && (
|
||||
<span className="text-xs font-mono text-gray-400 bg-gray-50 px-2 py-0.5 rounded border border-gray-200">
|
||||
{deal.landlordPropertyId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Damp & mould flag */}
|
||||
{(deal.dampMouldFlag || deal.majorConditionIssuePhotosS3) && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 border border-red-200">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-red-700">Damp & Mould Flag</p>
|
||||
{deal.dampMouldFlag && (
|
||||
<p className="text-xs text-red-600 mt-0.5">{deal.dampMouldFlag}</p>
|
||||
)}
|
||||
{deal.majorConditionIssueDescription && (
|
||||
<p className="text-xs text-red-600 mt-0.5 italic">
|
||||
{deal.majorConditionIssueDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EPC */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
Energy Performance
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-black text-brandblue">{epcCurrent}</span>
|
||||
{epcPotential !== "Unknown" && epcPotential !== epcCurrent && (
|
||||
<span className="text-sm text-gray-400">→ {epcPotential}</span>
|
||||
)}
|
||||
</div>
|
||||
{deal.preSapScore !== null && deal.preSapScore !== undefined && (
|
||||
<p className="text-xs text-gray-500">SAP: {deal.preSapScore}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key details */}
|
||||
<div className="space-y-0.5 divide-y divide-gray-50">
|
||||
<InfoRow label="Project" value={deal.projectCode} />
|
||||
<InfoRow label="Coordinator" value={deal.coordinator} />
|
||||
<InfoRow label="Designer" value={deal.designer} />
|
||||
<InfoRow label="Installer" value={deal.installer} />
|
||||
<InfoRow label="Outcome" value={deal.outcome} />
|
||||
{deal.outcomeNotes && (
|
||||
<InfoRow label="Outcome Notes" value={deal.outcomeNotes} />
|
||||
)}
|
||||
<InfoRow label="Coordination" value={deal.coordinationStatus} />
|
||||
<InfoRow label="Design Status" value={deal.designStatus} />
|
||||
<InfoRow label="Design Type" value={deal.designType} />
|
||||
<InfoRow
|
||||
label="Pre-SAP"
|
||||
value={
|
||||
deal.preSapScore ? (
|
||||
<span
|
||||
className={`font-semibold px-1.5 py-0.5 rounded text-xs ${
|
||||
Number(deal.preSapScore) < 30
|
||||
? "text-red-600 bg-red-50"
|
||||
: Number(deal.preSapScore) < 50
|
||||
? "text-amber-700 bg-amber-50"
|
||||
: "text-emerald-700 bg-emerald-50"
|
||||
}`}
|
||||
>
|
||||
{deal.preSapScore}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Survey info */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-2">
|
||||
Survey
|
||||
</p>
|
||||
<div className="divide-y divide-gray-50">
|
||||
<InfoRow label="Survey Type" value={deal.surveyType} />
|
||||
<InfoRow
|
||||
label="Surveyed"
|
||||
value={formatDate(deal.surveyedDate)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Confirmed Date"
|
||||
value={formatDate(deal.confirmedSurveyDate)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Confirmed Time"
|
||||
value={deal.confirmedSurveyTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deal.uprn && (
|
||||
<p className="text-xs text-gray-400 font-mono">UPRN: {deal.uprn}</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Center: Tabs ─────────────────────────────────────────── */}
|
||||
<section className="col-span-12 lg:col-span-6 space-y-4">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl">
|
||||
{VALID_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
data-testid={`deal-page-tab-${tab}`}
|
||||
aria-selected={activeTab === tab}
|
||||
onClick={() => switchTab(tab)}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
|
||||
activeTab === tab
|
||||
? "bg-white text-brandblue shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{TAB_LABELS[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
|
||||
{/* ── Works ── */}
|
||||
<div
|
||||
className={`p-5 space-y-6 ${activeTab === "works" ? "block" : "hidden"}`}
|
||||
>
|
||||
{/* Measures */}
|
||||
<div>
|
||||
<SectionHeader id="measures" label={SECTION_TITLES.measures} />
|
||||
<div className="space-y-3">
|
||||
<MeasureApprovalEditor
|
||||
dealId={deal.dealId}
|
||||
dealName={deal.dealname}
|
||||
portfolioId={portfolioId}
|
||||
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
|
||||
isApprover={isApprover}
|
||||
/>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50 mt-3">
|
||||
<InfoRow label="Installed" value={deal.actualMeasuresInstalled} />
|
||||
<InfoRow label="Lodgement Status" value={deal.lodgementStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical approved */}
|
||||
<div>
|
||||
<SectionHeader id="technical" label={SECTION_TITLES.technical} />
|
||||
<div className="divide-y divide-gray-50">
|
||||
<InfoRow
|
||||
label="Technical Approved Measures"
|
||||
value={
|
||||
technicalApprovedMeasures.length > 0 ? (
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{technicalApprovedMeasures.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="px-2 py-0.5 rounded-full text-[11px] bg-emerald-50 border border-emerald-200 text-emerald-700"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval log */}
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<button
|
||||
onClick={() => setIsLogOpen((v) => !v)}
|
||||
className="flex items-center gap-2 w-full text-left group"
|
||||
>
|
||||
{isLogOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-gray-400 group-hover:text-brandblue transition-colors shrink-0" />
|
||||
)}
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 group-hover:text-brandblue transition-colors">
|
||||
Approval Log
|
||||
</h3>
|
||||
</button>
|
||||
{isLogOpen && (
|
||||
<div className="mt-3">
|
||||
<ApprovalLogSection dealId={deal.dealId} portfolioId={portfolioId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── PIBI ── */}
|
||||
<div
|
||||
className={`p-5 space-y-6 ${activeTab === "pibi" ? "block" : "hidden"}`}
|
||||
>
|
||||
<SectionHeader id="pibi" label={SECTION_TITLES.pibi} />
|
||||
<div className="space-y-3">
|
||||
<PibiDatesEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialOrderDate={deal.pibiOrderDate}
|
||||
initialCompletedDate={deal.pibiCompletedDate}
|
||||
canEdit={canWrite}
|
||||
/>
|
||||
{isApprover ? (
|
||||
<PibiMeasureSelector
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)}
|
||||
canEdit
|
||||
/>
|
||||
) : (
|
||||
pibiMeasures.length > 0 && (
|
||||
<div className="divide-y divide-gray-50">
|
||||
<InfoRow
|
||||
label="Measures for PIBI"
|
||||
value={
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{pibiMeasures.map((m) => (
|
||||
<span
|
||||
key={m}
|
||||
className="px-2 py-0.5 rounded-full text-[11px] bg-gray-50 border border-gray-200 text-gray-600"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Survey & Admin ── */}
|
||||
<div
|
||||
className={`p-5 space-y-6 ${activeTab === "survey-admin" ? "block" : "hidden"}`}
|
||||
>
|
||||
<div>
|
||||
<SectionHeader id="domna" label={SECTION_TITLES.domna} />
|
||||
<DomnaEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialSurveyType={deal.domnaSurveyType ?? null}
|
||||
initialSurveyDate={deal.domnaSurveyDate}
|
||||
canEdit={isApprover}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SectionHeader id="halted" label={SECTION_TITLES.halted} />
|
||||
<HaltedEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
initialHaltedDate={deal.propertyHaltedDate}
|
||||
initialHaltedReason={deal.propertyHaltedReason ?? null}
|
||||
canEdit={isApprover}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Survey Request
|
||||
</h3>
|
||||
<SurveyRequestSection
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Project Removal
|
||||
</h3>
|
||||
<RemovalRequestSection
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Documents ── */}
|
||||
<div
|
||||
className={`p-5 space-y-4 ${activeTab === "documents" ? "block" : "hidden"}`}
|
||||
>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
Documents
|
||||
</h3>
|
||||
{docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${docStatus.isSurveyComplete ? "bg-emerald-500" : docStatus.hasSurveyDocs ? "bg-amber-400" : "bg-gray-300"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Survey docs:{" "}
|
||||
<span className="font-medium">
|
||||
{docStatus.isSurveyComplete
|
||||
? "Complete"
|
||||
: docStatus.hasSurveyDocs
|
||||
? `${docStatus.presentSurveyTypes.length} uploaded`
|
||||
: "None"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${docStatus.installStatus === "all" ? "bg-emerald-500" : docStatus.installStatus === "partial" || docStatus.installStatus === "hasDocs" ? "bg-amber-400" : "bg-gray-300"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Install docs:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{docStatus.installStatus === "none"
|
||||
? "None"
|
||||
: docStatus.installStatus === "all"
|
||||
? "Complete"
|
||||
: "Partial"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{docStatus.measureProgress.length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{docStatus.measureProgress.map((mp) => (
|
||||
<div
|
||||
key={mp.measureName}
|
||||
className="flex items-center justify-between text-xs py-1.5 border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<span className="text-gray-700">{mp.measureName}</span>
|
||||
<span
|
||||
className={`font-medium ${mp.isComplete ? "text-emerald-600" : "text-amber-600"}`}
|
||||
>
|
||||
{mp.uploadedCount}/{mp.requiredCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No documents uploaded yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Right Sidebar: Actions ───────────────────────────────── */}
|
||||
<aside className="col-span-12 lg:col-span-3 space-y-4">
|
||||
|
||||
{/* Removal state badge */}
|
||||
{removalState !== "none" && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-xs font-semibold ${
|
||||
removalState === "removed"
|
||||
? "bg-red-50 border-red-200 text-red-700"
|
||||
: "bg-amber-50 border-amber-200 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{removalState === "pending_removal"
|
||||
? "Pending removal"
|
||||
: removalState === "pending_re_addition"
|
||||
? "Pending re-addition"
|
||||
: "Removed from project"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4 space-y-2.5">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Actions
|
||||
</p>
|
||||
|
||||
{/* Instruct Measure */}
|
||||
{isApprover ? (
|
||||
<button
|
||||
onClick={() => setInstructModalOpen(true)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-brandblue text-white text-sm font-semibold hover:bg-brandmidblue transition-colors"
|
||||
>
|
||||
<span>Instruct Measure</span>
|
||||
<span className="text-xs opacity-70">→</span>
|
||||
</button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
disabled
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-100 text-gray-400 text-sm font-semibold cursor-not-allowed"
|
||||
>
|
||||
<span>Instruct Measure</span>
|
||||
<span className="text-xs">→</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approver permission required</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Request Survey */}
|
||||
<button
|
||||
onClick={() => switchTab("survey-admin")}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
|
||||
>
|
||||
<span>Request Survey</span>
|
||||
<span className="text-xs opacity-50">→</span>
|
||||
</button>
|
||||
|
||||
{/* Request Removal */}
|
||||
<button
|
||||
onClick={() => switchTab("survey-admin")}
|
||||
className="w-full flex items-center justify-between px-4 py-3 rounded-lg bg-gray-50 border border-gray-200 text-gray-700 text-sm font-semibold hover:border-brandblue/30 hover:text-brandblue transition-colors"
|
||||
>
|
||||
<span>Request Removal</span>
|
||||
<span className="text-xs opacity-50">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
|
||||
Project Timeline
|
||||
</p>
|
||||
<MilestoneTimeline deal={deal} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Instruct Measure Modal ─────────────────────────────────── */}
|
||||
<Dialog open={instructModalOpen} onOpenChange={setInstructModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold text-brandblue">
|
||||
Instruct Measure
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<InstructMeasureEditor
|
||||
dealId={deal.dealId}
|
||||
portfolioId={portfolioId}
|
||||
canEdit={isApprover}
|
||||
outOfOrderWarning={outOfOrderInstructionWarning(deal)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { eq, inArray, and, desc } from "drizzle-orm";
|
||||
import { db } from "@/app/db/db";
|
||||
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table";
|
||||
import { uploadedFiles } from "@/app/db/schema/uploaded_files";
|
||||
import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation";
|
||||
import { organisation } from "@/app/db/schema/organisation";
|
||||
import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
|
||||
import { user as userTable } from "@/app/db/schema/users";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type {
|
||||
HubspotDeal,
|
||||
DocStatus,
|
||||
MeasureDocProgress,
|
||||
PortfolioCapabilityType,
|
||||
EffectiveRemovalState,
|
||||
} from "../types";
|
||||
import {
|
||||
EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
|
||||
SURVEY_ALL_DOC_TYPES,
|
||||
} from "../types";
|
||||
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
|
||||
import { classifyDeals } from "../transforms";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import DealPage from "./DealPage";
|
||||
import Link from "next/link";
|
||||
|
||||
const coordinatorUser = alias(hubspotUsers, "coordinator_user");
|
||||
const designerUser = alias(hubspotUsers, "designer_user");
|
||||
|
||||
type DealRow = {
|
||||
deal: InferSelectModel<typeof hubspotDealData>;
|
||||
coordinator: string | null;
|
||||
designer: string | null;
|
||||
};
|
||||
|
||||
function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal {
|
||||
const d = row.deal;
|
||||
return {
|
||||
id: d.id,
|
||||
dealId: d.dealId,
|
||||
dealname: d.dealname,
|
||||
dealstage: d.dealstage,
|
||||
companyId: d.companyId,
|
||||
projectCode: d.projectCode,
|
||||
landlordPropertyId: d.landlordPropertyId,
|
||||
uprn: d.uprn,
|
||||
outcome: d.outcome,
|
||||
outcomeNotes: d.outcomeNotes,
|
||||
majorConditionIssueDescription: d.majorConditionIssueDescription,
|
||||
majorConditionIssuePhotos: d.majorConditionIssuePhotos,
|
||||
majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3,
|
||||
coordinationStatus: d.coordinationStatus,
|
||||
designStatus: d.designStatus,
|
||||
pashubLink: d.pashubLink,
|
||||
sharepointLink: d.sharepointLink,
|
||||
dampMouldFlag: d.dampmouldGrowth,
|
||||
dampMouldAndRepairComments: d.damnpMouldAndRepairComments,
|
||||
preSapScore: d.preSap,
|
||||
coordinator: row.coordinator,
|
||||
ioeV1Date: d.mtpCompletionDate,
|
||||
ioeV2Date: d.mtpReModelCompletionDate,
|
||||
ioeV3Date: d.ioeV3CompletionDate,
|
||||
proposedMeasures: d.proposedMeasures,
|
||||
approvedPackage: d.approvedPackage,
|
||||
designer: row.designer,
|
||||
designDate: d.designCompletionDate,
|
||||
actualMeasuresInstalled: d.actualMeasuresInstalled,
|
||||
installer: d.installer,
|
||||
installerHandover: d.installerHandover,
|
||||
lodgementStatus: d.lodgementStatus,
|
||||
measuresLodgementDate: d.measuresLodgementDate,
|
||||
fullLodgementDate: d.lodgementDate,
|
||||
confirmedSurveyDate: d.confirmedSurveyDate,
|
||||
confirmedSurveyTime: d.confirmedSurveyTime,
|
||||
surveyedDate: d.surveyedDate,
|
||||
designType: d.dealType,
|
||||
eiScore: d.eiScore,
|
||||
eiScorePotential: d.eiScorePotential,
|
||||
epcSapScore: d.epcSapScore,
|
||||
epcSapScorePotential: d.epcSapScorePotential,
|
||||
surveyType: d.surveyType,
|
||||
measuresForPibiOrdered: d.measuresForPibiOrdered,
|
||||
pibiOrderDate: d.pibiOrderDate,
|
||||
pibiCompletedDate: d.pibiCompletedDate,
|
||||
propertyHaltedDate: d.propertyHaltedDate,
|
||||
propertyHaltedReason: d.propertyHaltedReason,
|
||||
technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall,
|
||||
domnaSurveyType: d.domnaSurveyType,
|
||||
domnaSurveyDate: d.domnaSurveyDate,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function DealDetailPage(props: {
|
||||
params: Promise<{ slug: string; dealId: string }>;
|
||||
}) {
|
||||
const { slug: portfolioId, dealId } = await props.params;
|
||||
const session = await getServerSession(AuthOptions);
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const link = await db
|
||||
.select({ hubspotCompanyId: organisation.hubspotCompanyId })
|
||||
.from(portfolioOrganisation)
|
||||
.innerJoin(
|
||||
organisation,
|
||||
eq(portfolioOrganisation.organisationId, organisation.id),
|
||||
)
|
||||
.where(eq(portfolioOrganisation.portfolioId, BigInt(portfolioId)))
|
||||
.limit(1);
|
||||
|
||||
if (!link.length || !link[0].hubspotCompanyId) {
|
||||
redirect(`/portfolio/${portfolioId}/your-projects/live`);
|
||||
}
|
||||
|
||||
const companyId = link[0].hubspotCompanyId;
|
||||
|
||||
const rawDeals = await db
|
||||
.select({
|
||||
deal: hubspotDealData,
|
||||
coordinator: sql<string | null>`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`,
|
||||
designer: sql<string | null>`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`,
|
||||
})
|
||||
.from(hubspotDealData)
|
||||
.leftJoin(
|
||||
coordinatorUser,
|
||||
eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId),
|
||||
)
|
||||
.leftJoin(
|
||||
designerUser,
|
||||
eq(hubspotDealData.designer, designerUser.hubspotOwnerId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(hubspotDealData.companyId, companyId),
|
||||
eq(hubspotDealData.dealId, dealId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!rawDeals.length) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const hubspotDeal = mapDbRowToHubspotDeal(rawDeals[0]);
|
||||
const [deal] = classifyDeals([hubspotDeal]);
|
||||
|
||||
const userEmail = session.user.email;
|
||||
let userCapability: PortfolioCapabilityType = [];
|
||||
let userRole = "read";
|
||||
|
||||
if (userEmail) {
|
||||
const userRow = await db
|
||||
.select({ id: userTable.id })
|
||||
.from(userTable)
|
||||
.where(eq(userTable.email, userEmail))
|
||||
.limit(1);
|
||||
|
||||
if (userRow[0]) {
|
||||
const [capRows, roleRow] = await Promise.all([
|
||||
db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioCapabilities.userId, userRow[0].id),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ role: portfolioUsers.role })
|
||||
.from(portfolioUsers)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioUsers.userId, userRow[0].id),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
userCapability = capRows
|
||||
.map((r) => r.capability)
|
||||
.filter(
|
||||
(c): c is "approver" | "contractor" =>
|
||||
c === "approver" || c === "contractor",
|
||||
);
|
||||
userRole = roleRow[0]?.role ?? "read";
|
||||
}
|
||||
}
|
||||
|
||||
const approvedMeasures: string[] = [];
|
||||
const approvalRows = await db
|
||||
.select({ measureName: dealMeasureApprovals.measureName })
|
||||
.from(dealMeasureApprovals)
|
||||
.where(
|
||||
and(
|
||||
eq(dealMeasureApprovals.hubspotDealId, dealId),
|
||||
eq(dealMeasureApprovals.isApproved, true),
|
||||
),
|
||||
);
|
||||
approvedMeasures.push(...approvalRows.map((r) => r.measureName));
|
||||
|
||||
let removalState: EffectiveRemovalState = "none";
|
||||
const removalRows = await db
|
||||
.select({
|
||||
type: propertyRemovalRequests.type,
|
||||
status: propertyRemovalRequests.status,
|
||||
})
|
||||
.from(propertyRemovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(propertyRemovalRequests.portfolioId, BigInt(portfolioId)),
|
||||
eq(propertyRemovalRequests.hubspotDealId, dealId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(propertyRemovalRequests.requestedAt))
|
||||
.limit(1);
|
||||
|
||||
if (removalRows[0]) {
|
||||
const row = removalRows[0];
|
||||
if (row.status === "pending") {
|
||||
removalState =
|
||||
row.type === "re_addition" ? "pending_re_addition" : "pending_removal";
|
||||
} else if (row.type === "removal" && row.status === "approved") {
|
||||
removalState = "removed";
|
||||
} else if (row.type === "re_addition" && row.status === "declined") {
|
||||
removalState = "removed";
|
||||
}
|
||||
}
|
||||
|
||||
// Doc status — same two-phase strategy as live tracker
|
||||
const docFiles: Array<{ fileType: string; measureName: string | null }> = [];
|
||||
|
||||
const phase1Rows = await db
|
||||
.select({
|
||||
hubsotDealId: uploadedFiles.hubsotDealId,
|
||||
fileType: uploadedFiles.fileType,
|
||||
measureName: uploadedFiles.measureName,
|
||||
})
|
||||
.from(uploadedFiles)
|
||||
.where(eq(uploadedFiles.hubsotDealId, dealId));
|
||||
|
||||
for (const row of phase1Rows) {
|
||||
if (row.fileType !== null) {
|
||||
docFiles.push({ fileType: row.fileType, measureName: row.measureName });
|
||||
}
|
||||
}
|
||||
|
||||
if (docFiles.length === 0 && deal.uprn) {
|
||||
try {
|
||||
const uprnBig = BigInt(deal.uprn);
|
||||
const phase2Rows = await db
|
||||
.select({
|
||||
fileType: uploadedFiles.fileType,
|
||||
measureName: uploadedFiles.measureName,
|
||||
})
|
||||
.from(uploadedFiles)
|
||||
.where(eq(uploadedFiles.uprn, uprnBig));
|
||||
|
||||
for (const row of phase2Rows) {
|
||||
if (row.fileType !== null) {
|
||||
docFiles.push({
|
||||
fileType: row.fileType,
|
||||
measureName: row.measureName,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid UPRN — skip phase 2
|
||||
}
|
||||
}
|
||||
|
||||
const measures =
|
||||
approvedMeasures.length > 0
|
||||
? approvedMeasures
|
||||
: (deal.proposedMeasures ?? "")
|
||||
.split(",")
|
||||
.map((m: string) => m.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const surveyDocs = docFiles.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
|
||||
const installDocs = docFiles.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
|
||||
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
|
||||
|
||||
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
|
||||
const required = getRequiredDocs(measureName);
|
||||
const docsForMeasure = installDocs.filter(
|
||||
(d) => d.measureName === measureName,
|
||||
);
|
||||
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
|
||||
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
|
||||
return {
|
||||
measureName,
|
||||
required,
|
||||
uploaded,
|
||||
isComplete: uploaded.length === required.length,
|
||||
uploadedCount: uploaded.length,
|
||||
requiredCount: required.length,
|
||||
};
|
||||
});
|
||||
|
||||
let installStatus: DocStatus["installStatus"] = "none";
|
||||
if (installDocs.length > 0) {
|
||||
if (measures.length === 0) {
|
||||
installStatus = "hasDocs";
|
||||
} else {
|
||||
installStatus = measureProgress.every((m) => m.isComplete)
|
||||
? "all"
|
||||
: measureProgress.some((m) => m.uploadedCount > 0)
|
||||
? "partial"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
|
||||
const docStatus: DocStatus = {
|
||||
presentSurveyTypes: Array.from(surveyTypeSet),
|
||||
hasSurveyDocs: surveyDocs.length > 0,
|
||||
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) =>
|
||||
surveyTypeSet.has(t),
|
||||
),
|
||||
hasInstallDocs: installDocs.length > 0,
|
||||
installStatus,
|
||||
measureProgress,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">
|
||||
<div className="mb-6">
|
||||
<nav className="flex items-center gap-1.5 text-sm text-gray-500 mb-3">
|
||||
<Link
|
||||
href={`/portfolio/${portfolioId}/your-projects/live`}
|
||||
className="hover:text-brandblue transition-colors"
|
||||
>
|
||||
Live Projects
|
||||
</Link>
|
||||
<span className="text-gray-300">/</span>
|
||||
<span className="text-gray-800 font-medium truncate max-w-xs">
|
||||
{deal.dealname ?? dealId}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="h-px bg-gray-200" />
|
||||
</div>
|
||||
<DealPage
|
||||
deal={deal}
|
||||
portfolioId={portfolioId}
|
||||
userRole={userRole}
|
||||
userCapability={userCapability}
|
||||
approvedMeasures={approvedMeasures}
|
||||
docStatus={docStatus}
|
||||
removalState={removalState}
|
||||
userEmail={userEmail ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue