From 785c40f2d11771660197d002b1878ef11be44fe1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Apr 2026 15:55:45 +0000 Subject: [PATCH 01/21] 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 0f9376b..6dc6b66 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 f16b181..67443ed 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 0000000..e196faf --- /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 1fa07b7..4b86fa3 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 ? ( { onChange(v === "__unset__" ? "" : v); }} + > + + + + + Select other type… + {FILE_TYPE_GROUPS.map((group) => { + const items = FILE_TYPE_OPTIONS.filter( + (o) => o.group === group && !requiredSet.has(o.value), + ); + if (!items.length) return null; + return ( + + + {group} + + {items.map((o) => ( + {o.label} + ))} + + ); + })} + + + )} + + {/* 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 8265602..26457b9 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 8dd230f..8f73aad 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 63ef131..c40099b 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 ff958a0..1b659b5 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 03/21] 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 8f73aad..824a1cb 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 04/21] 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 55774d8..35f4991 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 95c60e7..ccb1db8 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 26457b9..8efbcb8 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 2263d0d..f1371e7 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( - - + + Toggle columns - {toggleableColumns.map((col) => ( - col.toggleVisibility(val)} - className="text-sm" - > - {COLUMN_LABELS[col.id] ?? col.id} - - ))} +
+
+ {toggleableColumns.map((col) => ( + col.toggleVisibility(val)} + className="text-sm" + > + {COLUMN_LABELS[col.id] ?? col.id} + + ))} +
+
+
From 4bbd973b6ba494899650e1776b1cc20fa63db469 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 21 Apr 2026 17:47:21 +0000 Subject: [PATCH 09/21] added filtering to filter on lodged rating rather than modelled --- .../scenario/[scenarioId]/metrics/route.ts | 27 +++++++- .../reporting/ReportingClientArea.tsx | 13 +++- .../ReportingFunctionalityButtons.tsx | 69 +++++++++++++++++-- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index ce373b6..5760a33 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -69,6 +69,8 @@ export async function GET( const sid = BigInt(scenarioId); const hideNonCompliant = request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + const useOriginalBaseline = + request.nextUrl.searchParams.get("useOriginalBaseline") === "true"; /* ---------------------------------------------------------- Query 0 — scenario definition @@ -129,7 +131,12 @@ export async function GET( END )::float AS total_sap_uplift FROM latest_plans lp - JOIN property p ON p.id = lp.property_id; + JOIN property p ON p.id = lp.property_id + WHERE ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ); `); const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; @@ -162,8 +169,14 @@ export async function GET( COALESCE(fp.total_uplift, 0) )::float AS total_funding FROM latest_plans lp + JOIN property p ON p.id = lp.property_id LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0; + WHERE lp.cost_of_works > 0 + AND ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ); `); const upgraded = upgradedResult.rows[0] as UpgradedAggregates; @@ -223,6 +236,11 @@ export async function GET( AND plan.post_sap_points >= ${minSap}::float ) ) + AND ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ) ORDER BY created_at DESC LIMIT 1 ) lp ON true @@ -256,6 +274,11 @@ export async function GET( AND plan.post_sap_points >= ${minSap}::float ) ) + AND ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ) ORDER BY created_at DESC LIMIT 1 ) lp ON true diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 47dfeaf..8d89323 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -39,13 +39,16 @@ async function fetchScenarioReport({ portfolioId, scenarioId, hideNonCompliant, + useOriginalBaseline, }: { portfolioId: number; scenarioId: number | "default"; hideNonCompliant: boolean; + useOriginalBaseline: boolean; }) { const params = new URLSearchParams({ hideNonCompliant: String(hideNonCompliant), + useOriginalBaseline: String(useOriginalBaseline), }); const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`; @@ -89,6 +92,8 @@ export function ReportingClientArea({ const [measuresOpen, setMeasuresOpen] = useState(false); const [appliedHideNonCompliant, setAppliedHideNonCompliant] = useState(false); + const [appliedUseOriginalBaseline, setAppliedUseOriginalBaseline] = + useState(false); const [showToast, setShowToast] = useState(false); const drawerOpen = Boolean(selectedScenarioId); @@ -107,12 +112,14 @@ export function ReportingClientArea({ portfolioId, selectedScenarioId, appliedHideNonCompliant, + appliedUseOriginalBaseline, ], queryFn: () => fetchScenarioReport({ portfolioId, scenarioId: selectedScenarioId!, hideNonCompliant: appliedHideNonCompliant, + useOriginalBaseline: appliedUseOriginalBaseline, }), enabled: selectedScenarioId !== null, // only run when scenario selected or default selected keepPreviousData: true, // keep showing old data while loading new scenario or applying filter @@ -238,12 +245,14 @@ export function ReportingClientArea({ { - setAppliedHideNonCompliant(value); + onApply={async ({ hideNonCompliant, useOriginalBaseline }) => { + setAppliedHideNonCompliant(hideNonCompliant); + setAppliedUseOriginalBaseline(useOriginalBaseline); }} /> diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx index 40ad968..9a70414 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx @@ -13,33 +13,45 @@ export interface ReportingFunctionalityButtonsProps { /** Currently applied value */ hideNonCompliant: boolean; + /** Currently applied value */ + useOriginalBaseline: boolean; + /** * Explicit user action. * Parent decides what "apply" means (refetch, mutate, etc). */ - onApply: (value: boolean) => Promise | void; + onApply: (options: { + hideNonCompliant: boolean; + useOriginalBaseline: boolean; + }) => Promise | void; disabled?: boolean; - /* Whether hideNonCompliant filter is available */ + /* Whether filters are available (only for specific non-default scenarios) */ canFilterNonCompliant?: boolean; } export function ReportingFunctionalityButtons({ hideNonCompliant, + useOriginalBaseline, onApply, disabled = false, canFilterNonCompliant = true, }: ReportingFunctionalityButtonsProps) { const [draftHideNonCompliant, setDraftHideNonCompliant] = useState(hideNonCompliant); + const [draftUseOriginalBaseline, setDraftUseOriginalBaseline] = + useState(useOriginalBaseline); const [isApplying, setIsApplying] = useState(false); async function handleApply() { try { setIsApplying(true); - await onApply(draftHideNonCompliant); + await onApply({ + hideNonCompliant: draftHideNonCompliant, + useOriginalBaseline: draftUseOriginalBaseline, + }); } finally { setIsApplying(false); } @@ -50,7 +62,8 @@ export function ReportingFunctionalityButtons({ // reset the filter and trigger the fetch setIsApplying(true); setDraftHideNonCompliant(false); - await onApply(false); + setDraftUseOriginalBaseline(false); + await onApply({ hideNonCompliant: false, useOriginalBaseline: false }); } finally { setIsApplying(false); } @@ -61,6 +74,7 @@ export function ReportingFunctionalityButtons({ onOpenChange={(open) => { if (open) { setDraftHideNonCompliant(hideNonCompliant); + setDraftUseOriginalBaseline(useOriginalBaseline); } }} > @@ -72,7 +86,7 @@ export function ReportingFunctionalityButtons({ className={` relative flex items-center gap-2 ${ - hideNonCompliant + hideNonCompliant || useOriginalBaseline ? "border-brandmidblue/40 bg-brandlightblue/40" : "" } @@ -81,7 +95,7 @@ export function ReportingFunctionalityButtons({ {/* Filter icon */} Filter options - {hideNonCompliant && ( + {(hideNonCompliant || useOriginalBaseline) && ( )} @@ -140,6 +154,47 @@ export function ReportingFunctionalityButtons({
+ {/* Use original SAP points */} +
+ + setDraftUseOriginalBaseline(Boolean(checked)) + } + className="mt-1" + /> + + +
+ {/* Actions */}