From 785c40f2d11771660197d002b1878ef11be44fe1 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 20 Apr 2026 15:55:45 +0000
Subject: [PATCH 1/7] adding measure level tracking for uploaded docs
---
.../api/upload/contractor-install/route.ts | 33 ++++
src/app/lib/hubspot/dealSync.ts | 23 ++-
src/app/lib/measureDocumentRequirements.ts | 81 +++++++++
.../live/ContractorUploadModal.tsx | 170 ++++++++++++++++--
.../your-projects/live/DocumentTable.tsx | 1 +
.../live/DocumentTableColumns.tsx | 25 ++-
.../(portfolio)/your-projects/live/page.tsx | 30 +++-
.../(portfolio)/your-projects/live/types.ts | 15 +-
8 files changed, 351 insertions(+), 27 deletions(-)
create mode 100644 src/app/lib/measureDocumentRequirements.ts
diff --git a/src/app/api/upload/contractor-install/route.ts b/src/app/api/upload/contractor-install/route.ts
index 0f9376b1..6dc6b662 100644
--- a/src/app/api/upload/contractor-install/route.ts
+++ b/src/app/api/upload/contractor-install/route.ts
@@ -7,6 +7,26 @@ import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
+import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
+
+function computeMeasureProgress(
+ proposedMeasures: string[],
+ dealDocs: { fileType: string | null; measureName: string | null }[],
+) {
+ const installDocs = dealDocs.filter((d) => d.fileType !== null && d.measureName !== null);
+ return proposedMeasures.map((measureName) => {
+ const required = getRequiredDocs(measureName);
+ const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
+ const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
+ const uploadedCount = required.filter((r) => uploadedTypeSet.has(r)).length;
+ return {
+ measureName,
+ uploadedCount,
+ requiredCount: required.length,
+ isComplete: uploadedCount === required.length,
+ };
+ });
+}
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
export async function POST(req: NextRequest) {
@@ -85,6 +105,8 @@ export async function PATCH(req: NextRequest) {
measureName: z.string().optional(),
}),
),
+ hubspotDealId: z.string().optional(),
+ proposedMeasures: z.array(z.string()).optional(),
});
let body: z.infer;
@@ -110,6 +132,17 @@ export async function PATCH(req: NextRequest) {
.where(eq(uploadedFiles.id, BigInt(update.id)));
}
+ // Sync per-measure progress to HubSpot after classification
+ if (body.hubspotDealId && body.proposedMeasures && body.proposedMeasures.length > 0) {
+ const dealDocs = await db
+ .select({ fileType: uploadedFiles.fileType, measureName: uploadedFiles.measureName })
+ .from(uploadedFiles)
+ .where(eq(uploadedFiles.hubsotDealId, body.hubspotDealId));
+
+ const measureProgress = computeMeasureProgress(body.proposedMeasures, dealDocs);
+ void syncContractorDocUploadToHubSpot({ hubspotDealId: body.hubspotDealId, measureProgress });
+ }
+
return NextResponse.json({ success: true });
} catch (err) {
console.error("PATCH /upload/contractor-install error:", err);
diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts
index f16b1816..67443ed3 100644
--- a/src/app/lib/hubspot/dealSync.ts
+++ b/src/app/lib/hubspot/dealSync.ts
@@ -71,16 +71,37 @@ export async function syncRemovalRequestToHubSpot(params: {
}
}
+type MeasureUploadProgress = {
+ measureName: string;
+ uploadedCount: number;
+ requiredCount: number;
+ isComplete: boolean;
+};
+
export async function syncContractorDocUploadToHubSpot(params: {
hubspotDealId: string;
+ measureProgress?: MeasureUploadProgress[];
}): Promise {
+ let log: string;
+ if (params.measureProgress && params.measureProgress.length > 0) {
+ log = params.measureProgress
+ .map((m) => {
+ if (m.isComplete) return `${m.measureName}: Complete (${m.uploadedCount}/${m.requiredCount} docs)`;
+ if (m.uploadedCount > 0) return `${m.measureName}: In Progress (${m.uploadedCount}/${m.requiredCount} docs)`;
+ return `${m.measureName}: Not Started (0/${m.requiredCount} docs)`;
+ })
+ .join(" | ");
+ } else {
+ log = "Documents available - uploaded by contractor";
+ }
+
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
- contractor_document_upload_log: "Documents available - uploaded by contractor",
+ contractor_document_upload_log: log,
},
});
return;
diff --git a/src/app/lib/measureDocumentRequirements.ts b/src/app/lib/measureDocumentRequirements.ts
new file mode 100644
index 00000000..e196faf9
--- /dev/null
+++ b/src/app/lib/measureDocumentRequirements.ts
@@ -0,0 +1,81 @@
+/**
+ * Measure Document Requirements
+ * Maps HubSpot measure names to the required installer document types (fileType enum values).
+ * Used to compute per-measure upload completion and guide contractors in the upload modal.
+ */
+
+// Required for every measure
+const BASE_DOCS = [
+ "pre_photo",
+ "mid_photo",
+ "post_photo",
+ "pre_installation_building_inspection",
+ "claim_of_compliance",
+ "insurance_guarantee",
+ "workmanship_warranty",
+] as const;
+
+// MCS-accredited measures require MCS certification in addition to base docs
+const MCS_EXTRA = ["mcs_compliance_certificate"] as const;
+
+export const MEASURE_DOC_REQUIREMENTS: Record = {
+ ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"],
+ "Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"],
+ DMevs: [
+ ...BASE_DOCS,
+ "dmev_photos",
+ "anemometer_readings",
+ "commissioning_records",
+ "part_f_ventilation_document",
+ "door_undercut_photos",
+ "trickle_vent_photos",
+ "ventilation_assessment_checklist",
+ "minor_works_electrical_certificate",
+ ],
+ "Loft insulation": [...BASE_DOCS, "loft_hatch_photo"],
+ // All remaining measures require BASE_DOCS only:
+ // CWI, EWI, IWI, "Flat roof", RIR, UFI, HW, Windows, "Ext. doors",
+ // TRVs, "Heating controls", "New boiler", HHRSH, Battery, LEL,
+ // "Listed building", "Removal 2nd heating", Others
+};
+
+/**
+ * Returns the required document types for a given measure name.
+ * Falls back to BASE_DOCS for any measure not explicitly listed.
+ */
+export function getRequiredDocs(measureName: string): string[] {
+ return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS];
+}
+
+/**
+ * Human-readable label for a fileType enum value.
+ * Matches the labels used in ContractorUploadModal FILE_TYPE_OPTIONS.
+ */
+export const FILE_TYPE_LABELS: Record = {
+ pre_photo: "Pre-Install Photos",
+ mid_photo: "Mid-Install Photos",
+ post_photo: "Post-Install Photos",
+ loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
+ dmev_photos: "DMEV Photos (Wetrooms)",
+ door_undercut_photos: "Door Undercut Photos",
+ trickle_vent_photos: "Trickle Vent Photos",
+ pre_installation_building_inspection: "PIBI / Tech Survey",
+ point_of_work_risk_assessment: "Point of Work Risk Assessment",
+ claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
+ mcs_compliance_certificate: "MCS Compliance Certificate",
+ certificate_of_conformity: "Certificate of Conformity",
+ minor_works_electrical_certificate: "Minor Works Electrical Certificate",
+ trustmark_licence_numbers: "TrustMark Licence Numbers",
+ operative_competency: "Operative Competency",
+ ventilation_assessment_checklist: "Ventilation Assessment Checklist",
+ anemometer_readings: "Anemometer Readings",
+ commissioning_records: "Commissioning Records",
+ part_f_ventilation_document: "Approved Document Part F",
+ handover_pack: "Handover Pack",
+ workmanship_warranty: "Workmanship Warranty",
+ insurance_guarantee: "Insurance Backed Guarantee (IBG)",
+ g98_notification: "G98 / G99 Notification",
+ installer_qualifications: "Installer Qualifications",
+ installer_feedback: "Installer Feedback",
+ contractor_other: "Other",
+};
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
index 1fa07b79..4b86fa35 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
@@ -21,7 +21,8 @@ import {
} from "@/app/shadcn_components/ui/select";
import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react";
import { uploadFileToS3 } from "@/app/utils/s3";
-import type { ClassifiedDeal } from "./types";
+import type { ClassifiedDeal, DocStatusMap } from "./types";
+import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
// ── Types ─────────────────────────────────────────────────────────────────
@@ -44,12 +45,13 @@ type FileEntry = {
measureName: string;
};
-type Phase = "loading" | "upload" | "classify";
+type Phase = "loading" | "measure-select" | "upload" | "classify";
type Props = {
deal: ClassifiedDeal;
portfolioId: string;
onClose: () => void;
+ docStatusMap?: DocStatusMap;
};
// ── Constants ─────────────────────────────────────────────────────────────
@@ -200,11 +202,13 @@ async function recordUpload(payload: {
async function saveClassifications(
updates: { id: string; fileType: string; measureName?: string }[],
+ hubspotDealId?: string,
+ proposedMeasures?: string[],
): Promise {
const res = await fetch("/api/upload/contractor-install", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ updates }),
+ body: JSON.stringify({ updates, hubspotDealId, proposedMeasures }),
});
if (!res.ok) throw new Error("Failed to save classifications");
}
@@ -257,8 +261,29 @@ function PasGuidancePanel() {
// ── DocType select ────────────────────────────────────────────────────────
-function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) {
+function DocTypeSelect({
+ value,
+ onChange,
+ showHint = false,
+ requiredDocs,
+ uploadedDocs,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ showHint?: boolean;
+ requiredDocs?: string[]; // file types required for the selected measure
+ uploadedDocs?: string[]; // file types already uploaded for the selected measure
+}) {
const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
+ const requiredSet = new Set(requiredDocs ?? []);
+ const uploadedSet = new Set(uploadedDocs ?? []);
+
+ // If we have required docs, show them as a priority group first
+ const priorityItems = requiredDocs && requiredDocs.length > 0
+ ? requiredDocs
+ .map((t) => FILE_TYPE_OPTIONS.find((o) => o.value === t))
+ .filter((o): o is typeof FILE_TYPE_OPTIONS[number] => !!o)
+ : [];
return (
@@ -268,8 +293,32 @@ function DocTypeSelect({ value, onChange, showHint = false }: { value: string; o
Select type…
+
+ {/* Priority group: required docs for the selected measure */}
+ {priorityItems.length > 0 && (
+
+
+ Required for this measure
+
+ {priorityItems.map((o) => {
+ const alreadyUploaded = uploadedSet.has(o.value);
+ return (
+
+ {o.label}
+ {alreadyUploaded && (
+ ✓ uploaded
+ )}
+
+ );
+ })}
+
+ )}
+
+ {/* Remaining groups */}
{FILE_TYPE_GROUPS.map((group) => {
- const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
+ const items = FILE_TYPE_OPTIONS.filter(
+ (o) => o.group === group && !requiredSet.has(o.value),
+ );
if (!items.length) return null;
return (
@@ -305,7 +354,7 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx
// ── Main component ─────────────────────────────────────────────────────────
-export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
+export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap }: Props) {
const measures = parseMeasures(deal.proposedMeasures);
const fileInputRef = useRef(null);
const [isDragOver, setIsDragOver] = useState(false);
@@ -314,6 +363,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
+ // The measure selected in the measure-select phase (empty = "not measure-specific")
+ const [selectedMeasure, setSelectedMeasure] = useState("");
// ── Fetch existing unclassified files on mount ───────────────────────
@@ -322,7 +373,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
const uprnParam = deal.uprn;
const propIdParam = deal.landlordPropertyId;
if (!uprnParam && !propIdParam) {
- setPhase("upload");
+ setPhase(measures.length > 0 ? "measure-select" : "upload");
return;
}
@@ -350,12 +401,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
}));
setQueue(entries);
setPhase("classify");
+ } else if (measures.length > 0) {
+ setPhase("measure-select");
} else {
setPhase("upload");
}
} catch {
- // If fetch fails, just proceed to upload phase
- setPhase("upload");
+ // If fetch fails, just proceed to measure-select (or upload if no measures)
+ setPhase(measures.length > 0 ? "measure-select" : "upload");
}
}
@@ -373,7 +426,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
displaySize: formatSize(f.size),
status: "queued",
docType: "",
- measureName: measures[0] ?? "",
+ measureName: selectedMeasure,
}));
setQueue((prev) => [...prev, ...newEntries]);
}
@@ -473,6 +526,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
fileType: f.docType,
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
})),
+ deal.dealId,
+ measures.length > 0 ? measures : undefined,
);
onClose();
} catch {
@@ -496,14 +551,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
{phase === "loading" ? "Loading…" :
+ phase === "measure-select" ? "Select Measure" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
{phase === "loading" && "Checking for pending files…"}
+ {phase === "measure-select" && (
+ <>
+ Which measure are you uploading documents for?{" "}
+ {propertyLabel}
+ >
+ )}
{phase === "upload" && (
<>
- Upload install documents for {propertyLabel}.
+ {selectedMeasure
+ ? <>Uploading documents for {selectedMeasure} — {propertyLabel}.>
+ : <>Upload install documents for {propertyLabel}.>
+ }
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
>
)}
@@ -525,6 +590,56 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
)}
+ {/* ── Measure Select ── */}
+ {phase === "measure-select" && (() => {
+ const uprn = deal.uprn ?? null;
+ const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
+ const measureProgressMap = new Map(
+ (docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
+ );
+ return (
+
+
+ Select the measure you are uploading documents for. This helps track completion against required documents.
+
+
+ {measures.map((measure) => {
+ const progress = measureProgressMap.get(measure);
+ const isComplete = progress?.isComplete ?? false;
+ const uploaded = progress?.uploadedCount ?? 0;
+ const required = progress?.requiredCount ?? getRequiredDocs(measure).length;
+ return (
+
+ );
+ })}
+
+
+
+ );
+ })()}
+
{/* ── Phase 1: Upload ── */}
{phase === "upload" && (
<>
@@ -592,7 +707,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
)}
{/* ── Phase 2: Classify ── */}
- {phase === "classify" && (
+ {phase === "classify" && (() => {
+ // Per-entry: look up what's already uploaded for that entry's measure
+ const uprn = deal.uprn ?? null;
+ const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
+ const measureProgressMap = new Map(
+ (docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
+ );
+ return (
{/* PAS guidance */}
@@ -604,7 +726,11 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
Measure
- {classifiableEntries.map((entry) => (
+ {classifiableEntries.map((entry) => {
+ const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
+ const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : undefined;
+ const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : undefined;
+ return (
@@ -614,7 +740,13 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
{entry.existingS3Key &&
Previously uploaded
}
- updateEntryField(entry.id, "docType", v)} showHint />
+ updateEntryField(entry.id, "docType", v)}
+ showHint
+ requiredDocs={requiredDocs}
+ uploadedDocs={uploadedDocs}
+ />
{measures.length > 0 ? (
)}
- )}
+ );
+ })()}
@@ -659,6 +793,10 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
)}
+ {phase === "measure-select" && (
+
+ )}
+
{phase === "upload" && (
<>
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
index 01baed96..82656027 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
@@ -302,6 +302,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
+ docStatusMap={docStatusMap}
/>
)}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
index d630130e..2263d0d8 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
@@ -49,19 +49,38 @@ function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
const installStatus = status?.installStatus ?? "none";
+ const measureProgress = status?.measureProgress ?? [];
+
+ // Build a tooltip showing per-measure doc counts (e.g. "ASHP: 5/9, CWI: 7/7")
+ const tooltip =
+ measureProgress.length > 0
+ ? measureProgress
+ .map((m) => `${m.measureName}: ${m.uploadedCount}/${m.requiredCount}`)
+ .join(" | ")
+ : undefined;
+
if (installStatus === "all") {
return (
-
+
All Measures
);
}
if (installStatus === "partial") {
+ const completedCount = measureProgress.filter((m) => m.isComplete).length;
+ const totalCount = measureProgress.length;
+ const label = totalCount > 0 ? `${completedCount} / ${totalCount} measures` : "Some Measures";
return (
-
+
- Some Measures
+ {label}
);
}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
index fb2cf17c..ff958a0e 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
@@ -13,8 +13,9 @@ 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 type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
+import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
+import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
@@ -271,15 +272,33 @@ export default async function LiveReportingPage(props: {
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByUprn.get(uprn) ?? [];
+
+ // Compute per-measure document progress against the requirements matrix
+ 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 {
- const measuresWithDocs = new Set(
- installDocs.map((d) => d.measureName).filter(Boolean),
- );
- installStatus = measures.every((m) => measuresWithDocs.has(m)) ? "all" : "partial";
+ installStatus = measureProgress.every((m) => m.isComplete)
+ ? "all"
+ : measureProgress.some((m) => m.uploadedCount > 0)
+ ? "partial"
+ : "none";
}
}
@@ -289,6 +308,7 @@ export default async function LiveReportingPage(props: {
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
+ measureProgress,
};
docStatusMap[uprn] = status;
}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
index 987ea3c8..c4a070b2 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts
@@ -250,6 +250,16 @@ export const SURVEY_ALL_DOC_TYPES = new Set([
"ecmk_survey_xml",
]);
+// Per-measure document upload progress
+export type MeasureDocProgress = {
+ measureName: string;
+ required: string[]; // required fileType values for this measure
+ uploaded: string[]; // required fileType values that have been uploaded
+ isComplete: boolean;
+ uploadedCount: number;
+ requiredCount: number;
+};
+
export type DocStatus = {
// Retrofit assessment docs
presentSurveyTypes: string[];
@@ -258,10 +268,11 @@ export type DocStatus = {
// Install docs
hasInstallDocs: boolean;
installStatus: "none" | "partial" | "hasDocs" | "all";
- // "all" = install docs exist for every proposed measure
- // "partial" = some (but not all) proposed measures have docs
+ // "all" = all required docs uploaded for every proposed measure
+ // "partial" = some (but not all) proposed measures have complete docs
// "hasDocs" = has install docs but no measures defined on the deal
// "none" = no install docs at all
+ measureProgress: MeasureDocProgress[]; // one entry per proposed measure
};
export type DocStatusMap = Record; // keyed by UPRN string
From 2536f3eb8e9115dd3cb2837b8b44dd4c9a5543aa Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 20 Apr 2026 16:38:20 +0000
Subject: [PATCH 2/7] updated the UI to manage document uploads down to measure
level
---
.../live/ContractorUploadModal.tsx | 297 ++++++++++++------
.../your-projects/live/DocumentTable.tsx | 6 +-
.../your-projects/live/LiveTracker.tsx | 2 +
.../your-projects/live/PropertyDrawer.tsx | 149 +++++++--
.../(portfolio)/your-projects/live/page.tsx | 8 +-
5 files changed, 346 insertions(+), 116 deletions(-)
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
index 4b86fa35..95c60e75 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
@@ -52,6 +52,7 @@ type Props = {
portfolioId: string;
onClose: () => void;
docStatusMap?: DocStatusMap;
+ approvedMeasures?: string[]; // if non-empty, used instead of proposedMeasures
};
// ── Constants ─────────────────────────────────────────────────────────────
@@ -261,29 +262,122 @@ function PasGuidancePanel() {
// ── DocType select ────────────────────────────────────────────────────────
-function DocTypeSelect({
+// ── DocType button grid — shown when a measure is selected ───────────────
+
+function DocTypeButtonGrid({
value,
onChange,
- showHint = false,
requiredDocs,
uploadedDocs,
}: {
value: string;
onChange: (v: string) => void;
- showHint?: boolean;
- requiredDocs?: string[]; // file types required for the selected measure
- uploadedDocs?: string[]; // file types already uploaded for the selected measure
+ requiredDocs: string[];
+ uploadedDocs: string[];
+}) {
+ const [showOther, setShowOther] = useState(false);
+ const uploadedSet = new Set(uploadedDocs);
+ const requiredSet = new Set(requiredDocs);
+ const isOtherSelected = value !== "" && !requiredSet.has(value);
+
+ return (
+
+ {/* Required doc type buttons */}
+
+ {requiredDocs.map((docType) => {
+ const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
+ const label = option?.label ?? docType;
+ const alreadyUploaded = uploadedSet.has(docType);
+ const isSelected = value === docType;
+
+ return (
+
+ );
+ })}
+
+ {/* Other button */}
+
+
+
+ {/* Other: dropdown for non-required types */}
+ {(showOther || isOtherSelected) && (
+
+ )}
+
+ {/* Show hint for selected type */}
+ {value && (() => {
+ const hint = FILE_TYPE_OPTIONS.find((o) => o.value === value)?.hint;
+ return hint ?
{hint}
: null;
+ })()}
+
+ );
+}
+
+// ── DocType select — fallback when no measure selected ────────────────────
+
+function DocTypeSelect({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (v: string) => void;
}) {
const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
- const requiredSet = new Set(requiredDocs ?? []);
- const uploadedSet = new Set(uploadedDocs ?? []);
-
- // If we have required docs, show them as a priority group first
- const priorityItems = requiredDocs && requiredDocs.length > 0
- ? requiredDocs
- .map((t) => FILE_TYPE_OPTIONS.find((o) => o.value === t))
- .filter((o): o is typeof FILE_TYPE_OPTIONS[number] => !!o)
- : [];
return (
@@ -293,32 +387,8 @@ function DocTypeSelect({
Select type…
-
- {/* Priority group: required docs for the selected measure */}
- {priorityItems.length > 0 && (
-
-
- Required for this measure
-
- {priorityItems.map((o) => {
- const alreadyUploaded = uploadedSet.has(o.value);
- return (
-
- {o.label}
- {alreadyUploaded && (
- ✓ uploaded
- )}
-
- );
- })}
-
- )}
-
- {/* Remaining groups */}
{FILE_TYPE_GROUPS.map((group) => {
- const items = FILE_TYPE_OPTIONS.filter(
- (o) => o.group === group && !requiredSet.has(o.value),
- );
+ const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
if (!items.length) return null;
return (
@@ -326,16 +396,14 @@ function DocTypeSelect({
{group}
{items.map((o) => (
-
- {o.label}
-
+ {o.label}
))}
);
})}
- {showHint && selected?.hint && (
+ {selected?.hint && (
{selected.hint}
)}
@@ -354,8 +422,11 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx
// ── Main component ─────────────────────────────────────────────────────────
-export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap }: Props) {
- const measures = parseMeasures(deal.proposedMeasures);
+export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap, approvedMeasures }: Props) {
+ // Use approved measures when available; fall back to all proposed measures
+ const measures = (approvedMeasures && approvedMeasures.length > 0)
+ ? approvedMeasures
+ : parseMeasures(deal.proposedMeasures);
const fileInputRef = useRef(null);
const [isDragOver, setIsDragOver] = useState(false);
const [queue, setQueue] = useState([]);
@@ -708,63 +779,107 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (() => {
- // Per-entry: look up what's already uploaded for that entry's measure
const uprn = deal.uprn ?? null;
const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
-
+
{/* PAS guidance */}
- {/* Column headers */}
-
- File
- Document Type *
- Measure
-
-
- {classifiableEntries.map((entry) => {
- const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
- const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : undefined;
- const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : undefined;
- return (
-
-
-
-
-
{entry.displayName}
- {entry.displaySize &&
{entry.displaySize}
}
- {entry.existingS3Key &&
Previously uploaded
}
-
+ {/* Measure context banner */}
+ {selectedMeasure && (
+
+
+ {selectedMeasure}
+ {(() => {
+ const mp = measureProgressMap.get(selectedMeasure);
+ if (!mp) return null;
+ return (
+
+ {mp.uploadedCount}/{mp.requiredCount} docs uploaded
+
+ );
+ })()}
-
updateEntryField(entry.id, "docType", v)}
- showHint
- requiredDocs={requiredDocs}
- uploadedDocs={uploadedDocs}
- />
- {measures.length > 0 ? (
-
- ) : (
- —
- )}
+
- );
- })}
+ )}
+
+ {/* File list with classification */}
+
+ {classifiableEntries.map((entry) => {
+ const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
+ const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
+ const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
+
+ return (
+
+ {/* File info row */}
+
+
+
+
{entry.displayName}
+
+ {entry.existingS3Key ? "Previously uploaded · " : ""}
+ {entry.displaySize ?? ""}
+ {entryMeasure && !selectedMeasure && (
+ {entryMeasure}
+ )}
+
+
+ {/* Measure selector — only shown if no pre-selected measure */}
+ {!selectedMeasure && measures.length > 0 && (
+
+ )}
+
+
+ {/* Doc type selector */}
+
+
+ Document type *
+
+ {requiredDocs ? (
+
updateEntryField(entry.id, "docType", v)}
+ requiredDocs={requiredDocs}
+ uploadedDocs={uploadedDocs}
+ />
+ ) : (
+ updateEntryField(entry.id, "docType", v)}
+ />
+ )}
+
+
+ );
+ })}
+
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
index 82656027..26457b9e 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
@@ -29,7 +29,7 @@ import {
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
import ContractorUploadModal from "./ContractorUploadModal";
-import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
+import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
@@ -40,6 +40,7 @@ interface DocumentTableProps {
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
+ approvalsByDeal?: ApprovalsByDeal;
}
function escapeCell(value: unknown): string {
@@ -53,7 +54,7 @@ function escapeCell(value: unknown): string {
: str;
}
-export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
+export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState
("all");
const [installStatusFilter, setInstallStatusFilter] = useState("all");
@@ -303,6 +304,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
docStatusMap={docStatusMap}
+ approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []}
/>
)}
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 8dd230f4..8f73aad7 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
@@ -243,6 +243,7 @@ export default function LiveTracker({
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
+ approvalsByDeal={approvalsByDeal}
/>
@@ -376,6 +377,7 @@ export default function LiveTracker({
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
+ docStatus={drawerState.uprn ? docStatusMap[drawerState.uprn] : undefined}
onClose={() =>
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
index 63ef1313..c40099b8 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx
@@ -21,7 +21,7 @@ import {
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
-import type { PropertyDocument } from "./types";
+import type { PropertyDocument, DocStatus } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
// Human-readable labels for all DB fileType enum values
@@ -86,11 +86,9 @@ function formatDate(iso: string): string {
}
// -----------------------------------------------------------------------
-// Individual document row
+// Reusable download button — encapsulates the presigned URL mutation
// -----------------------------------------------------------------------
-function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
- const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
-
+function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
@@ -107,6 +105,28 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
},
});
+ return (
+
+ );
+}
+
+// -----------------------------------------------------------------------
+// Individual document row — used in retrofit section and install fallback
+// -----------------------------------------------------------------------
+function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
+ const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
+
return (
- {/* Right: download button */}
-
+
);
}
@@ -155,6 +163,7 @@ interface PropertyDrawerProps {
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
+ docStatus?: DocStatus;
onClose: () => void;
}
@@ -163,6 +172,7 @@ export default function PropertyDrawer({
uprn,
landlordPropertyId,
dealname,
+ docStatus,
onClose,
}: PropertyDrawerProps) {
const canQuery = !!(uprn || landlordPropertyId);
@@ -361,13 +371,112 @@ export default function PropertyDrawer({
key="install"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
- className="space-y-2"
+ className="space-y-3"
>
Install Documents
- {installDocs.length > 0 ? (
+
+ {docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
+ // ── Per-measure checklist ──
+
+ {docStatus.measureProgress.map((mp) => {
+ const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
+ const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
+ const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
+
+ return (
+
+ {/* Measure header */}
+
+ {mp.measureName}
+ 0
+ ? "bg-amber-50 text-amber-700 border-amber-200"
+ : "bg-gray-100 text-gray-500 border-gray-200"
+ }`}>
+ {mp.uploadedCount} / {mp.requiredCount} docs
+
+
+
+
+ {/* Uploaded required docs */}
+ {mp.uploaded.map((docType) => {
+ const doc = measureDocs.find((d) => d.docType === docType);
+ if (!doc) return null;
+ return (
+
+
+
+
+
{DOC_TYPE_LABELS[docType] ?? docType}
+
{formatDate(doc.s3UploadTimestamp)}
+
+
+
+
+ );
+ })}
+
+ {/* Missing required docs */}
+ {missingTypes.map((docType) => (
+
+
+
+
+
{DOC_TYPE_LABELS[docType] ?? docType}
+
+ ))}
+
+ {/* Extra docs uploaded for this measure (not in required list) */}
+ {measureDocs
+ .filter((d) => !mp.required.includes(d.docType))
+ .map((doc) => (
+
+
+
+
+
+
+
{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}
+
{formatDate(doc.s3UploadTimestamp)}
+
+
+
+
+ ))
+ }
+
+
+ );
+ })}
+
+ {/* Unassigned / no-measure install docs */}
+ {(() => {
+ const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
+ const unassigned = installDocs.filter(
+ (d) => !d.measureName || !knownMeasures.has(d.measureName),
+ );
+ if (unassigned.length === 0) return null;
+ return (
+
+
Other
+ {unassigned.map((doc) => (
+
+ ))}
+
+ );
+ })()}
+
+ ) : installDocs.length > 0 ? (
+ // ── Fallback: flat list (no measure progress data) ──
{installDocs.map((doc) => (
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
index ff958a0e..1b659b5c 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx
@@ -255,13 +255,15 @@ export default async function LiveReportingPage(props: {
docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
}
- // Build measures lookup from deals (uprn → proposed measure names)
+ // Build measures lookup from deals (uprn → approved measures, falling back to proposed)
const measuresByUprn = new Map
();
for (const deal of deals) {
if (deal.uprn) {
const key = String(deal.uprn);
- const measures = (deal.proposedMeasures ?? "")
- .split(",").map((m: string) => m.trim()).filter(Boolean);
+ const approved = approvalsByDeal[deal.dealId] ?? [];
+ const measures = approved.length > 0
+ ? approved
+ : (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByUprn.set(key, measures);
}
}
From a0b12673f3eaf179a49919d6a0256542e7550eee Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 20 Apr 2026 16:49:06 +0000
Subject: [PATCH 3/7] changed documents management to documents
---
.../your-projects/live/LiveTracker.tsx | 67 ++++++++++++++-----
1 file changed, 52 insertions(+), 15 deletions(-)
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 8f73aad7..824a1cbf 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx
@@ -9,7 +9,13 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
-import { BarChart2, Table2, FolderOpen, Wrench, AlertTriangle } from "lucide-react";
+import {
+ BarChart2,
+ Table2,
+ FolderOpen,
+ Wrench,
+ AlertTriangle,
+} from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
@@ -40,9 +46,9 @@ export default function LiveTracker({
userEmail,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
- const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
- "analytics",
- );
+ const [activeTab, setActiveTab] = useState<
+ "analytics" | "properties" | "documents" | "measures"
+ >("analytics");
// ── Project selector (shared across both tabs) ───────────────────────
const projectCodes = projects.map((p) => p.projectCode);
@@ -81,7 +87,10 @@ export default function LiveTracker({
setOpenTable({
stage,
data: filteredDeals,
- columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[],
+ columns: (columns || [
+ "dealname",
+ "landlordPropertyId",
+ ]) as (keyof ClassifiedDeal)[],
columnLabels: (columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
@@ -90,7 +99,11 @@ export default function LiveTracker({
});
};
- const handleOpenDrawer = (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => {
+ const handleOpenDrawer = (
+ uprn: string | null,
+ landlordPropertyId: string | null,
+ dealname: string | null,
+ ) => {
setDrawerState({ open: true, uprn, landlordPropertyId, dealname });
};
@@ -108,7 +121,11 @@ export default function LiveTracker({
setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
+ onValueChange={(v) =>
+ setActiveTab(
+ v as "analytics" | "properties" | "documents" | "measures",
+ )
+ }
>
{/* Tab bar */}
@@ -127,7 +144,9 @@ export default function LiveTracker({
Properties
0 ? "opacity-100" : "opacity-0 pointer-events-none"
+ pendingRemovalCount > 0
+ ? "opacity-100"
+ : "opacity-0 pointer-events-none"
}`}
aria-hidden={pendingRemovalCount === 0}
>
@@ -139,7 +158,7 @@ export default function LiveTracker({
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
- Document Management
+ Documents
)}
-
+
{pendingRemovalCount}{" "}
- {pendingRemovalCount === 1 ? "property has" : "properties have"} an outstanding removal request
+ {pendingRemovalCount === 1 ? "property has" : "properties have"}{" "}
+ an outstanding removal request
{projectCodes.map((code) =>
code === "__ALL__" ? (
-
) : (
@@ -261,7 +287,11 @@ export default function LiveTracker({
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
-
) : (
@@ -377,9 +407,16 @@ export default function LiveTracker({
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
- docStatus={drawerState.uprn ? docStatusMap[drawerState.uprn] : undefined}
+ docStatus={
+ drawerState.uprn ? docStatusMap[drawerState.uprn] : undefined
+ }
onClose={() =>
- setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
+ setDrawerState({
+ open: false,
+ uprn: null,
+ landlordPropertyId: null,
+ dealname: null,
+ })
}
/>
From c7a57808774b37e18d87d051ddc6f8a9befb8636 Mon Sep 17 00:00:00 2001
From: Khalim Conn-Kowlessar
Date: Mon, 20 Apr 2026 18:58:24 +0000
Subject: [PATCH 4/7] implemeting document search on deal id instead of uprn
---
.../live-tracking/property-documents/route.ts | 17 +-
.../live/ContractorUploadModal.tsx | 6 +-
.../your-projects/live/DocumentTable.tsx | 6 +-
.../live/DocumentTableColumns.tsx | 14 +-
.../your-projects/live/LiveTracker.tsx | 8 +-
.../your-projects/live/PropertyDrawer.tsx | 9 +-
.../your-projects/live/PropertyTable.tsx | 4 +-
.../live/PropertyTableColumns.tsx | 9 +-
.../(portfolio)/your-projects/live/page.tsx | 165 ++++++++++--------
.../(portfolio)/your-projects/live/types.ts | 3 +-
10 files changed, 138 insertions(+), 103 deletions(-)
diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts
index 55774d8a..35f49915 100644
--- a/src/app/api/live-tracking/property-documents/route.ts
+++ b/src/app/api/live-tracking/property-documents/route.ts
@@ -5,22 +5,25 @@ import { uploadedFiles } from "@/app/db/schema/uploaded_files";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
+ const dealIdParam = searchParams.get("dealId");
const uprnParam = searchParams.get("uprn");
const landlordPropertyIdParam = searchParams.get("landlordPropertyId");
- if (!uprnParam && !landlordPropertyIdParam) {
+ if (!dealIdParam && !uprnParam && !landlordPropertyIdParam) {
return NextResponse.json(
- { error: "uprn or landlordPropertyId is required" },
+ { error: "dealId, uprn, or landlordPropertyId is required" },
{ status: 400 },
);
}
try {
- // Prefer UPRN — it's more selective and avoids an OR full-table scan.
- // Only fall back to landlordPropertyId when no UPRN is available.
- const condition = uprnParam
- ? eq(uploadedFiles.uprn, BigInt(uprnParam))
- : eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
+ // Prefer dealId — reliable even when UPRN is missing from the deal.
+ // Fall back to UPRN, then landlordPropertyId.
+ const condition = dealIdParam
+ ? eq(uploadedFiles.hubsotDealId, dealIdParam)
+ : uprnParam
+ ? eq(uploadedFiles.uprn, BigInt(uprnParam))
+ : eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
const rows = await db
.select({
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
index 95c60e75..ccb1db8c 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx
@@ -663,8 +663,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
{/* ── Measure Select ── */}
{phase === "measure-select" && (() => {
- const uprn = deal.uprn ?? null;
- const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
+ const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
@@ -779,8 +778,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (() => {
- const uprn = deal.uprn ?? null;
- const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
+ const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
index 26457b9e..8efbcb81 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx
@@ -36,7 +36,7 @@ type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
- onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
+ onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
@@ -67,7 +67,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const filteredData = useMemo(() => {
return data.filter((d) => {
- const status = d.uprn ? docStatusMap[d.uprn] : undefined;
+ const status = docStatusMap[d.dealId];
if (retroAssessmentFilter !== "all") {
if (retroAssessmentFilter === "none" && !(!status || !status.hasSurveyDocs)) return false;
@@ -115,7 +115,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status";
const body = rows
.map((row) => {
- const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
+ const status = docStatusMap[row.original.dealId];
const retroStatus = status?.isSurveyComplete
? "Complete"
: status?.hasSurveyDocs
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
index 2263d0d8..f1371e79 100644
--- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx
@@ -101,7 +101,7 @@ function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
}
export function createDocumentTableColumns(
- onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
+ onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef[] {
@@ -138,14 +138,14 @@ export function createDocumentTableColumns(
{
id: "retroAssessmentStatus",
accessorFn: (row) => {
- const status = row.uprn ? docStatusMap[row.uprn] : undefined;
+ const status = docStatusMap[row.dealId];
if (status?.isSurveyComplete) return 2;
if (status?.hasSurveyDocs) return 1;
return 0;
},
header: ({ column }) => ,
cell: ({ row }) => {
- const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
+ const status = docStatusMap[row.original.dealId];
return ;
},
enableHiding: false,
@@ -155,7 +155,7 @@ export function createDocumentTableColumns(
{
id: "installDocs",
accessorFn: (row) => {
- const status = row.uprn ? docStatusMap[row.uprn] : undefined;
+ const status = docStatusMap[row.dealId];
const s = status?.installStatus ?? "none";
if (s === "all") return 3;
if (s === "partial") return 2;
@@ -164,7 +164,7 @@ export function createDocumentTableColumns(
},
header: ({ column }) => ,
cell: ({ row }) => {
- const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
+ const status = docStatusMap[row.original.dealId];
return ;
},
enableHiding: false,
@@ -177,8 +177,7 @@ export function createDocumentTableColumns(
Docs
),
cell: ({ row }) => {
- const uprn = row.original.uprn ?? "";
- const status = uprn ? docStatusMap[uprn] : undefined;
+ const status = docStatusMap[row.original.dealId];
let icon: React.ReactNode;
let className: string;
@@ -201,6 +200,7 @@ export function createDocumentTableColumns(