From 2536f3eb8e9115dd3cb2837b8b44dd4c9a5543aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Apr 2026 16:38:20 +0000 Subject: [PATCH] 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 4b86fa3..95c60e7 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 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); } }