From dda2980cac5e481e63d7ee1e6f6dadd5cd37a634 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 11:56:26 +0000 Subject: [PATCH] redesigning ui for tracking page --- .../live-tracking/property-deal-page.cy.js | 50 ++ .../property-detail-drawer.cy.js | 55 -- .../your-projects/live/LiveTracker.tsx | 30 +- .../your-projects/live/MeasuresTable.tsx | 27 +- .../live/PropertyDetailDrawer.tsx | 32 +- .../your-projects/live/PropertyTable.tsx | 8 +- .../live/PropertyTableColumns.tsx | 17 +- .../your-projects/live/[dealId]/DealPage.tsx | 591 ++++++++++++++++++ .../your-projects/live/[dealId]/page.tsx | 366 +++++++++++ 9 files changed, 1051 insertions(+), 125 deletions(-) create mode 100644 cypress/e2e/live-tracking/property-deal-page.cy.js delete mode 100644 cypress/e2e/live-tracking/property-detail-drawer.cy.js create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx diff --git a/cypress/e2e/live-tracking/property-deal-page.cy.js b/cypress/e2e/live-tracking/property-deal-page.cy.js new file mode 100644 index 0000000..caef0e8 --- /dev/null +++ b/cypress/e2e/live-tracking/property-deal-page.cy.js @@ -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", + ); + }); +}); diff --git a/cypress/e2e/live-tracking/property-detail-drawer.cy.js b/cypress/e2e/live-tracking/property-detail-drawer.cy.js deleted file mode 100644 index 4e37908..0000000 --- a/cypress/e2e/live-tracking/property-detail-drawer.cy.js +++ /dev/null @@ -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"); - }); - }); -}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index a11828b..e73316f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -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(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({ openDetailDrawer(deal)} + portfolioId={portfolioId} docStatusMap={docStatusMap} removalStatusByDeal={removalStatusByDeal} /> @@ -322,7 +305,6 @@ export default function LiveTracker({ data={currentProject?.allDeals ?? []} approvalsByDeal={approvalsByDeal} portfolioId={portfolioId} - onOpenDetail={(deal) => openDetailDrawer(deal, "measures")} /> @@ -437,16 +419,6 @@ export default function LiveTracker({ } /> - {/* ── Property detail drawer ─────────────────────────────────────── */} - ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index eef8469..9259fc4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { Table, @@ -31,11 +32,6 @@ type Props = { data: ClassifiedDeal[]; approvalsByDeal: ApprovalsByDeal; portfolioId: string; - /** - * Called when a row is clicked. Opens PropertyDetailDrawer focused on the - * Works tab so the user can approve measures there. - */ - onOpenDetail?: (deal: ClassifiedDeal) => void; }; function ApprovalStatus({ @@ -143,8 +139,8 @@ export default function MeasuresTable({ data, approvalsByDeal, portfolioId, - onOpenDetail, }: Props) { + const router = useRouter(); const [search, setSearch] = useState(""); const [expandedRows, setExpandedRows] = useState>(new Set()); @@ -202,7 +198,7 @@ export default function MeasuresTable({ {filtered.length} of {dealsWithMeasures.length} properties - · Click a row to approve measures + · Click a row to open property @@ -235,14 +231,15 @@ export default function MeasuresTable({ const stageColor = STAGE_COLORS[deal.displayStage]; 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) => { - if (!onOpenDetail) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - onOpenDetail(deal); + router.push(dealPageUrl); } }; @@ -250,11 +247,11 @@ export default function MeasuresTable({ {/* Expand toggle */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index fcacd3d..1d0973d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -64,9 +64,9 @@ const TAB_LABELS: Record = { // ----------------------------------------------------------------------- // Removal request section // ----------------------------------------------------------------------- -const WRITE_ROLES = ["creator", "admin", "write"]; +export const WRITE_ROLES = ["creator", "admin", "write"]; -function RemovalRequestSection({ +export function RemovalRequestSection({ dealId, portfolioId, userRole, @@ -370,7 +370,7 @@ type SurveyRequestRecord = { fulfilledAt: string | null; }; -function SurveyRequestSection({ +export function SurveyRequestSection({ dealId, portfolioId, userRole, @@ -506,7 +506,7 @@ function SurveyRequestSection({ // ----------------------------------------------------------------------- // Approval log placeholder (expand into a real implementation as needed) // ----------------------------------------------------------------------- -function ApprovalLogSection({ dealId, portfolioId }: { dealId: string; portfolioId: string }) { +export function ApprovalLogSection({ dealId, portfolioId }: { dealId: string; portfolioId: string }) { void dealId; void portfolioId; return

No approvals recorded.

; } @@ -537,7 +537,7 @@ const MILESTONES: { label: string; field: keyof ClassifiedDeal; sublabel?: strin { label: "Stage 1 Lodgement", field: "fullLodgementDate" }, ]; -function formatDate(d: Date | string | null | undefined): string | null { +export function formatDate(d: Date | string | null | undefined): string | null { if (!d) return null; try { return new Date(d).toLocaleDateString("en-GB", { @@ -553,7 +553,7 @@ function formatDate(d: Date | string | null | undefined): string | null { // ----------------------------------------------------------------------- // Mini info row // ----------------------------------------------------------------------- -function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { +export function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { if (!value) return null; return (
@@ -566,7 +566,7 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { // ----------------------------------------------------------------------- // Stage badge // ----------------------------------------------------------------------- -function StageBadge({ stage }: { stage: ClassifiedDeal["displayStage"] }) { +export function StageBadge({ stage }: { stage: ClassifiedDeal["displayStage"] }) { const c = STAGE_COLORS[stage] ?? STAGE_COLORS["Unknown Stage"]; return ( @@ -579,7 +579,7 @@ function StageBadge({ stage }: { stage: ClassifiedDeal["displayStage"] }) { // ----------------------------------------------------------------------- // Vertical milestone timeline // ----------------------------------------------------------------------- -function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) { +export function MilestoneTimeline({ deal }: { deal: ClassifiedDeal }) { const milestones = MILESTONES.map((m) => ({ ...m, date: formatDate(deal[m.field] as Date | string | null), @@ -688,7 +688,7 @@ interface PibiDatesEditorProps { canEdit: boolean; } -function PibiDatesEditor({ +export function PibiDatesEditor({ dealId, portfolioId, initialOrderDate, @@ -874,7 +874,7 @@ interface HaltedEditorProps { canEdit: boolean; } -function HaltedEditor({ +export function HaltedEditor({ dealId, portfolioId, initialHaltedDate, @@ -1112,7 +1112,7 @@ interface DomnaEditorProps { canEdit: boolean; } -function DomnaEditor({ +export function DomnaEditor({ dealId, portfolioId, initialSurveyType, @@ -1294,7 +1294,7 @@ interface PibiMeasureSelectorProps { canEdit: boolean; } -function PibiMeasureSelector({ +export function PibiMeasureSelector({ dealId, portfolioId, proposedMeasures, @@ -1517,7 +1517,7 @@ interface InstructMeasureEditorProps { outOfOrderWarning: string | null; } -function InstructMeasureEditor({ +export function InstructMeasureEditor({ dealId, portfolioId, canEdit, @@ -1667,7 +1667,7 @@ interface MeasureApprovalEditorProps { isApprover: boolean; } -function MeasureApprovalEditor({ +export function MeasureApprovalEditor({ dealId, dealName, portfolioId, @@ -1926,7 +1926,7 @@ interface PropertyDetailDrawerProps { // Section metadata — central to keep the on-screen ordering aligned with the // stage-ordered acceptance criteria of issue #251. -const SECTION_TITLES: Record = { +export const SECTION_TITLES: Record = { survey: "Survey", measures: "Measures", pibi: "PIBI", @@ -1935,7 +1935,7 @@ const SECTION_TITLES: Record = { technical: "Technical Approved", }; -function SectionHeader({ id, label }: { id: DrawerSection; label: string }) { +export function SectionHeader({ id, label }: { id: DrawerSection; label: string }) { return (

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("all"); const [docFilter, setDocFilter] = useState("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({ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx index 63e62dd..a4b1b96 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx @@ -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[] { const columns: ColumnDef[] = [ @@ -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 (
{hasPending && ( )} - {onOpenDetail ? ( - + ) : (

{row.original.dealname ?? "—"} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx new file mode 100644 index 0000000..b865946 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx @@ -0,0 +1,591 @@ +"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 = { + 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( + 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 ( + +

+ + {/* ── Left Sidebar: Property Info ─────────────────────────── */} + + + {/* ── Center: Tabs ─────────────────────────────────────────── */} +
+ {/* Tab bar */} +
+ {VALID_TABS.map((tab) => ( + + ))} +
+ +
+ + {/* ── Works ── */} +
+ {/* Measures */} +
+ +
+ +
+
+ + +
+
+ + {/* Technical approved */} +
+ +
+ 0 ? ( + + {technicalApprovedMeasures.map((m) => ( + + {m} + + ))} + + ) : null + } + /> +
+
+ + {/* Approval log */} +
+ + {isLogOpen && ( +
+ +
+ )} +
+
+ + {/* ── PIBI ── */} +
+ +
+ + {isApprover ? ( + + ) : ( + pibiMeasures.length > 0 && ( +
+ + {pibiMeasures.map((m) => ( + + {m} + + ))} + + } + /> +
+ ) + )} +
+
+ + {/* ── Survey & Admin ── */} +
+
+ + +
+
+ + +
+
+

+ Survey Request +

+ +
+
+

+ Project Removal +

+ +
+
+ + {/* ── Documents ── */} +
+

+ Documents +

+ {docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? ( +
+
+ + + Survey docs:{" "} + + {docStatus.isSurveyComplete + ? "Complete" + : docStatus.hasSurveyDocs + ? `${docStatus.presentSurveyTypes.length} uploaded` + : "None"} + + +
+
+ + + Install docs:{" "} + + {docStatus.installStatus === "none" + ? "None" + : docStatus.installStatus === "all" + ? "Complete" + : "Partial"} + + +
+ {docStatus.measureProgress.length > 0 && ( +
+ {docStatus.measureProgress.map((mp) => ( +
+ {mp.measureName} + + {mp.uploadedCount}/{mp.requiredCount} + +
+ ))} +
+ )} +
+ ) : ( +

No documents uploaded yet.

+ )} +
+ +
+
+ + {/* ── Right Sidebar: Actions ───────────────────────────────── */} + + +
+ + {/* ── Instruct Measure Modal ─────────────────────────────────── */} + + + + + Instruct Measure + + + + + + + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx new file mode 100644 index 0000000..bce8d22 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/page.tsx @@ -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; + 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`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`, + designer: sql`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 ( +
+
+ +
+
+ +
+ ); +}