redesigning ui for tracking page

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-06 11:56:26 +00:00
parent 4734eeed07
commit dda2980cac
9 changed files with 1051 additions and 125 deletions

View 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",
);
});
});

View file

@ -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");
});
});
});

View file

@ -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}
/>
@ -322,7 +305,6 @@ export default function LiveTracker({
data={currentProject?.allDeals ?? []}
approvalsByDeal={approvalsByDeal}
portfolioId={portfolioId}
onOpenDetail={(deal) => openDetailDrawer(deal, "measures")}
/>
</div>
</TabsContent>
@ -437,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>
);
}

View file

@ -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<Set<string>>(new Set());
@ -202,7 +198,7 @@ export default function MeasuresTable({
{filtered.length} of {dealsWithMeasures.length} properties
</span>
<span className="text-xs text-gray-400 hidden sm:inline">
· Click a row to approve measures
· Click a row to open property
</span>
</div>
</div>
@ -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<HTMLTableRowElement>) => {
if (!onOpenDetail) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(deal);
router.push(dealPageUrl);
}
};
@ -250,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 ${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">

View file

@ -64,9 +64,9 @@ const TAB_LABELS: Record<DrawerTab, string> = {
// -----------------------------------------------------------------------
// 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 <p className="text-xs text-gray-400">No approvals recorded.</p>;
}
@ -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 (
<div className="flex items-start gap-3 py-2 border-b border-gray-50 last:border-0">
@ -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 (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${c.bg} ${c.text} ${c.border}`}>
@ -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<DrawerSection, string> = {
export const SECTION_TITLES: Record<DrawerSection, string> = {
survey: "Survey",
measures: "Measures",
pibi: "PIBI",
@ -1935,7 +1935,7 @@ const SECTION_TITLES: Record<DrawerSection, string> = {
technical: "Technical Approved",
};
function SectionHeader({ id, label }: { id: DrawerSection; label: string }) {
export function SectionHeader({ id, label }: { id: DrawerSection; label: string }) {
return (
<h3
data-testid={`drawer-section-${id}`}

View file

@ -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({

View file

@ -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 ?? "—"}

View file

@ -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<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>
{/* Timeline */}
<div>
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3">
Project Timeline
</p>
<MilestoneTimeline deal={deal} />
</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>
{/* Links */}
{(deal.pashubLink || deal.sharepointLink) && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4 space-y-2">
<p className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-2">
Links
</p>
{deal.pashubLink && (
<a
href={deal.pashubLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-brandblue hover:underline"
>
PAS Hub
</a>
)}
{deal.sharepointLink && (
<a
href={deal.sharepointLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-brandblue hover:underline"
>
SharePoint
</a>
)}
</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>
);
}

View file

@ -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>
);
}